From d5c3126e1000cfbb3eea4c8ea14815d7fbbbed08 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:21:32 +0000 Subject: [PATCH 001/148] refactor(organization): require administrator evidence for create commands createDepartment, createTeam, and createFieldOfStudy take OrganizationAdministratorEvidence instead of the OrganizationActor union. requireOrganizationAdministrator is its only constructor, so a forged or member actor no longer type checks, and the adapter's runtime re-check (decodeOrganizationActor, authorizeOrganizationActor) is deleted. The unused resolveActor option and organizationActorFrom are removed. The stored actor_json keeps its shape, and the authorization-rules proof observes the same tags. Pilot of docs/specs/authz-evidence.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/authority.ts | 6 - apps/backend/src/http-api/transport.test.ts | 1 - apps/backend/src/organization/http.test.ts | 29 +--- apps/backend/src/organization/http.ts | 42 +++--- .../organization/team-interest.http.test.ts | 5 - apps/backend/src/router.ts | 5 - docs/constructs.md | 2 + docs/constructs/authority-evidence.md | 42 ++++++ docs/specs/authz-evidence.md | 97 +++++++++++++ ...authorization-rules-postgres-proof-main.ts | 19 +-- .../organization-postgres-proof-main.ts | 16 ++- packages/database/src/database.test.ts | 78 ++++++----- .../organization/administration-postgres.ts | 21 +-- packages/domain/src/organization/AGENTS.md | 6 + .../src/organization/administration.test.ts | 20 +-- .../domain/src/organization/administration.ts | 25 +--- .../organization/authority-evidence.test.ts | 130 ++++++++++++++++++ packages/domain/src/organization/authority.ts | 59 +++++++- packages/domain/src/organization/service.ts | 13 +- tools/conventions/src/constructs.ts | 2 + 20 files changed, 435 insertions(+), 183 deletions(-) create mode 100644 docs/constructs/authority-evidence.md create mode 100644 docs/specs/authz-evidence.md create mode 100644 packages/domain/src/organization/authority-evidence.test.ts diff --git a/apps/backend/src/authority.ts b/apps/backend/src/authority.ts index cfbe7c4e..f12ff506 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, @@ -435,10 +433,6 @@ export const admissionActorForDepartment = ( 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); - /** Coarse dashboard role from the full projection (spec 0055 §Profile). * Returns the raw Decision so the adapter can translate Deny(reason) into its * typed denial instead of an ambiguous collapse. */ diff --git a/apps/backend/src/http-api/transport.test.ts b/apps/backend/src/http-api/transport.test.ts index 62b8c8a1..132e6952 100644 --- a/apps/backend/src/http-api/transport.test.ts +++ b/apps/backend/src/http-api/transport.test.ts @@ -116,7 +116,6 @@ describe("native request schema error transport", () => { const response = yield* makeOrganizationTestHttp( { config: backendTestConfig.organization, - resolveActor: unreachable, resolveAuthority: unreachable, }, securityServices, diff --git a/apps/backend/src/organization/http.test.ts b/apps/backend/src/organization/http.test.ts index 69624a34..e7078d94 100644 --- a/apps/backend/src/organization/http.test.ts +++ b/apps/backend/src/organization/http.test.ts @@ -1,6 +1,5 @@ import { backendDatabase } from "../../test/database.js"; import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { Identity, IdentityActor, @@ -21,14 +20,12 @@ import { 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 { 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"; @@ -148,19 +145,10 @@ const organization = { }), createDepartment: ( command: Parameters[0], - actor: Parameters[1], + _administrator: 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 })); } @@ -276,19 +264,6 @@ const services = Layer.mergeAll( 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"), diff --git a/apps/backend/src/organization/http.ts b/apps/backend/src/organization/http.ts index ee61f7d4..bef0b46b 100644 --- a/apps/backend/src/organization/http.ts +++ b/apps/backend/src/organization/http.ts @@ -20,10 +20,11 @@ import { Organization, OrganizationCommandId, OrganizationLifecycleCommand, + mapOrganizationAuthorityToOrganizationActor, + requireOrganizationAdministrator, SemesterId, type TeamId, TeamJsonSchema, - type OrganizationActor, type OrganizationCommandFailure, type OrganizationLifecycleFailure, type OrganizationPersonAuthority, @@ -61,10 +62,9 @@ import { Problem, type StrongETag, } from "@vektorprogrammet/http-api/http-semantics"; -import { DateTime, Effect, flow, Match, Option, Predicate, Schema } from "effect"; +import { DateTime, Effect, flow, Match, Option, Predicate, Result, Schema } from "effect"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import { - organizationActorFrom, resolveRequestCredentialInTransaction, resolveRequestPersonAuthorityInTransaction, type OrganizationResolutionError, @@ -102,14 +102,6 @@ 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. @@ -414,36 +406,40 @@ const authorizeCreate = (input: { }) => Effect.gen(function* () { const resolved = yield* resolveRequestPersonAuthorityInTransaction(input.request, {}); - const actor = organizationActorFrom(resolved.authority); + const personId = resolved.authority.personId; + const administrator = requireOrganizationAdministrator(resolved.authority); + // The AccessSpec answers a denial, with its concealment; the evidence is what the command takes. yield* authorizePerson( { spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), credential: resolved.credential, - personId: actor.personId, + personId, resolution: { selection: "ExactlyOne", contexts: [ genericContext({ domainId: "organization", - authorityVersion: `organization:${actor._tag}`, + authorityVersion: `organization:${mapOrganizationAuthorityToOrganizationActor(resolved.authority)._tag}`, }), ], }, - grantScopes: Predicate.isTagged(actor, "OrganizationAdministrator") ? [Scope.Global()] : [], + grantScopes: Result.isSuccess(administrator) ? [Scope.Global()] : [], now: resolved.authorizationInstant, }, input.presentation, ); + const evidence = yield* Effect.fromResult(administrator); + const identity = yield* httpIdentity({ - credentialSubject: `Person:${actor.personId}`, + credentialSubject: `Person:${personId}`, qualifiedOperationId: input.operationId, normalizedTarget: input.target, idempotencyKey: input.idempotencyKey, }); - return { actor, identity }; + return { administrator: evidence, identity }; }); const createDepartment = (request: Request, input: OrganizationApiHttpOptions) => @@ -461,7 +457,7 @@ const createDepartment = (request: Request, input: OrganizationApiHttpOptions) = // 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({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateDepartmentEndpoint, operationId, @@ -482,7 +478,7 @@ const createDepartment = (request: Request, input: OrganizationApiHttpOptions) = commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -532,7 +528,7 @@ const createTeam = (request: Request, input: OrganizationApiHttpOptions) => // 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({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateTeamEndpoint, operationId, @@ -553,7 +549,7 @@ const createTeam = (request: Request, input: OrganizationApiHttpOptions) => commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => @@ -603,7 +599,7 @@ const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) // 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({ + const { administrator, identity } = yield* authorizeCreate({ request, endpoint: CreateFieldOfStudyEndpoint, operationId, @@ -624,7 +620,7 @@ const createFieldOfStudy = (request: Request, input: OrganizationApiHttpOptions) commandId: OrganizationCommandId.make(identity.commandId), ...payload, }), - actor, + administrator, ), ).pipe( Effect.flatMap(({ observation }) => diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.http.test.ts index e4e54c2e..b5376d29 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.http.test.ts @@ -11,7 +11,6 @@ import { TeamId, SemesterId, MembershipId, - OrganizationActorSchema, DepartmentJsonSchema, Organization, PersonId, @@ -280,10 +279,6 @@ const services = Layer.mergeAll( const http = makeOrganizationApiHttp( { config, - resolveActor: () => - Effect.succeed( - OrganizationActorSchema.members[1].make({ personId: PersonId.make("person-member") }), - ), resolveAuthority: (request) => { const cookie = request.headers.get("cookie"); diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index 230766ff..66ad92ce 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -22,7 +22,6 @@ 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, @@ -190,10 +189,6 @@ export const ExternalNativeApiRouterLive = ( }), OrganizationApiHandlers({ config: config.organization, - resolveActor: (request) => - resolveRequestPersonAuthority(request, { now: options.now }).pipe( - Effect.map(organizationActorFrom), - ), resolveAuthority: (request) => resolveRequestPersonAuthority(request, { now: options.now }), }), DirectoryApiHandlers( diff --git a/docs/constructs.md b/docs/constructs.md index b12e2565..abcb8cbb 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -17,6 +17,8 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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. +- [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. + - [`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. - [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. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md new file mode 100644 index 00000000..32e9b50f --- /dev/null +++ b/docs/constructs/authority-evidence.md @@ -0,0 +1,42 @@ +# 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. + +## `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:263](../../packages/domain/src/organization/authority.ts#L263) + +**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. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md new file mode 100644 index 00000000..7e4fb97b --- /dev/null +++ b/docs/specs/authz-evidence.md @@ -0,0 +1,97 @@ +# Authorization evidence + +Status: pilot (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator asked for one +context first. The pilot covers the three Organization administration create commands. The +operator has not approved the rollout to the other contexts. + +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 + +1. A call of a create command with an object literal, an `OrganizationMember`, or an + `OrganizationActor` fails to type check (`@ts-expect-error` negative controls in + `packages/domain/src/organization/authority.test.ts`). +2. For every `OrganizationPersonAuthority` that `Arbitrary.schema` generates, evidence exists + exactly when `globalAdministrator` is `Active`, it names the same person, and it agrees with + `mapOrganizationAuthorityToOrganizationActor` (property test). +3. A member who calls the create endpoints still receives `authority.denied`, and nothing is + written (existing backend and PostgreSQL tests). +4. Stryker, run over the pilot module, leaves no surviving mutant in + `requireOrganizationAdministrator`. + +## Rollout, after operator approval + +One context per branch, in order of call sites: the person and anonymous helpers of +`apps/backend/src/http-api/problem.ts` (44), content, admission, recruitment, team applications, +schools, placements certificates, receipts (seal the existing constructors). Each branch deletes the +context's void helper and its runtime re-check in the adapter. + +Stryker is not in the root catalog. The pilot runs it outside the catalog and reports the score. It +joins the catalog, `just`, and hosted Checks only with the rollout, so that no check exists that +nothing runs. diff --git a/packages/database/runtime/authorization-rules-postgres-proof-main.ts b/packages/database/runtime/authorization-rules-postgres-proof-main.ts index 0bdc6a8c..e867326a 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, diff --git a/packages/database/runtime/organization-postgres-proof-main.ts b/packages/database/runtime/organization-postgres-proof-main.ts index 04ff36b6..599249a1 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/src/database.test.ts b/packages/database/src/database.test.ts index ca5fe2b7..c1c14584 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -40,6 +40,8 @@ import { departmentIdForCommand, Organization, OrganizationCommandId, + OrganizationPersonAuthoritySchema, + requireOrganizationAdministrator, } from "@vektorprogrammet/domain/organization"; import { RecruitmentAssignmentObservationSchema, @@ -4119,40 +4121,18 @@ describe("DatabaseTest", () => { 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 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 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 departmentCommand = { _tag: "CreateDepartment" as const, @@ -4424,10 +4404,26 @@ describe("DatabaseTest", () => { 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} + ) + `; + return { schemaRevision: database.schemaRevision, - denied, - deniedRows: Number(deniedRows[0]?.count ?? "-1"), + actors, departmentCreated, departmentReplayed, departmentConflict, @@ -4467,8 +4463,14 @@ describe("DatabaseTest", () => { }); 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"); diff --git a/packages/database/src/organization/administration-postgres.ts b/packages/database/src/organization/administration-postgres.ts index 00f1f11a..c31b045a 100644 --- a/packages/database/src/organization/administration-postgres.ts +++ b/packages/database/src/organization/administration-postgres.ts @@ -6,11 +6,9 @@ import { DepartmentCreatedObservationSchema, TeamCreatedObservationSchema, FieldOfStudyCreatedObservationSchema, - authorizeOrganizationActor, decodeCreateDepartmentCommand, decodeCreateFieldOfStudyCommand, decodeCreateTeamCommand, - decodeOrganizationActor, departmentIdForCommand, fieldOfStudyIdForCommand, organizationCommandDigest, @@ -26,6 +24,7 @@ import { type DepartmentCreatedObservation, type FieldOfStudyCreatedObservation, type OrganizationActor, + type OrganizationAdministratorEvidence, type OrganizationCommandId, type OrganizationCreateCommand, type OrganizationCreatedObservation, @@ -544,11 +543,10 @@ const insertFieldOfStudy = ( export const createOrganizationDepartment = ( commandInput: CreateDepartmentCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateDepartmentCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -563,7 +561,6 @@ export const createOrganizationDepartment = ( if (receipt !== undefined) return yield* decodeDepartmentReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); const inserted = yield* insertDepartment(sql, command); const observation: DepartmentCreatedObservation = DepartmentCreatedObservationSchema.make( @@ -576,7 +573,7 @@ export const createOrganizationDepartment = ( digest, "Department", inserted.departmentId, - actor, + administrator.actor, observation, ); const selected = yield* readDepartment(sql, inserted.departmentId); @@ -602,11 +599,10 @@ export const createOrganizationDepartment = ( export const createOrganizationTeam = ( commandInput: CreateTeamCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateTeamCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -620,7 +616,6 @@ export const createOrganizationTeam = ( ); if (receipt !== undefined) return yield* decodeTeamReplay(command.commandId, receipt); - yield* authorizeOrganizationActor(actor); yield* requireDepartment(sql, command.departmentId); const inserted = yield* insertTeam(sql, command); @@ -635,7 +630,7 @@ export const createOrganizationTeam = ( digest, "Team", inserted.teamId, - actor, + administrator.actor, observation, ); const selected = yield* readTeam(sql, inserted.teamId); @@ -661,11 +656,10 @@ export const createOrganizationTeam = ( export const createOrganizationFieldOfStudy = ( commandInput: CreateFieldOfStudyCommand, - actorInput: OrganizationActor, + administrator: OrganizationAdministratorEvidence, ): Effect.Effect => Effect.gen(function* () { const command = yield* decodeCreateFieldOfStudyCommand(commandInput); - const actor = yield* decodeOrganizationActor(actorInput); const sql = yield* Database; const digest = organizationCommandDigest(command); @@ -680,7 +674,6 @@ export const createOrganizationFieldOfStudy = ( 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); @@ -697,7 +690,7 @@ export const createOrganizationFieldOfStudy = ( digest, "FieldOfStudy", inserted.fieldOfStudyId, - actor, + administrator.actor, observation, ); const selected = yield* readFieldOfStudy(sql, inserted.fieldOfStudyId); diff --git a/packages/domain/src/organization/AGENTS.md b/packages/domain/src/organization/AGENTS.md index 176064da..e1dd63a0 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -80,6 +80,12 @@ 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. + 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 b74fb668..861a1cc5 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 165e0ca8..7d4229d2 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -1,13 +1,11 @@ -import { flow, Predicate, Effect, Schema } from "effect"; +import { flow, Effect, Schema } from "effect"; 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, @@ -24,13 +22,6 @@ import { 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)), -); - export const decodeCreateDepartmentCommand = flow( Schema.decodeUnknownEffect(CreateDepartmentCommandSchema, { onExcessProperty: "error", @@ -106,15 +97,3 @@ export const teamIdForCommand = (commandId: OrganizationCommandId): TeamIdType = 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", - }), - ); 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 00000000..f1e739ff --- /dev/null +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -0,0 +1,130 @@ +import { describe, 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; + +describe("requireOrganizationAdministrator", () => { + it.effect("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( + "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("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.ts b/packages/domain/src/organization/authority.ts index 55b77d7a..a9c4b1e1 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Result, Schema } from "effect"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -15,8 +15,10 @@ 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( @@ -222,6 +224,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( + new OrganizationRoleDenied({ + actorPersonId: authority.personId, + requiredRole: "OrganizationAdministrator", + }), + ); + export const mapOrganizationAuthorityToProfileRole = ( authority: OrganizationPersonAuthority, ): Decision => { diff --git a/packages/domain/src/organization/service.ts b/packages/domain/src/organization/service.ts index b51ef0e2..4baa09ad 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -23,9 +23,12 @@ import type { CreateFieldOfStudyResult, CreateTeamCommand, CreateTeamResult, - OrganizationActor, } from "./administration-schema.js"; -import type { OrganizationAuthorityInstant, OrganizationPersonAuthority } from "./authority.js"; +import type { + OrganizationAdministratorEvidence, + OrganizationAuthorityInstant, + OrganizationPersonAuthority, +} from "./authority.js"; import type { OrganizationDirectoryFacts } from "./directory.js"; import type { SemesterId, TeamInterestRegistration } from "./schema.js"; import type { @@ -152,15 +155,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, diff --git a/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index ed8b733a..b31f8892 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -41,6 +41,8 @@ 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.", + "authority-evidence": + "Checks a resolved authority and returns the evidence that a command which needs that authority takes; nothing else constructs it.", "http-problem": "Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding.", "sql-lock": "Transaction-scoped PostgreSQL advisory locks under registered keys.", From 55877c8c243f862d33eccf8afeb9f7f3605d8ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:26:44 +0000 Subject: [PATCH 002/148] test(organization): record evidence pilot results and mutation findings Flatten the evidence tests: Stryker 10 with Vitest 5 misses failures of tests nested in describe, and killed 18 of 18 mutants once they were top level. The authorization evidence spec records the falsifier results, and STATE.md records the helpers still trusted by convention. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- STATE.md | 2 + docs/specs/authz-evidence.md | 45 ++++++++---- .../organization/authority-evidence.test.ts | 70 +++++++++---------- 3 files changed, 70 insertions(+), 47 deletions(-) diff --git a/STATE.md b/STATE.md index 4b334309..e4798e65 100644 --- a/STATE.md +++ b/STATE.md @@ -110,6 +110,8 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction - `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`. - Instants: domain fields still use `Rfc3339InstantSchema`, not `Instant` (`packages/domain/src/time.ts`). +- Authorization is trusted by convention: 13 authorize helpers return `void` or a value any module can construct, and a command runs whether or not its caller called one. + The Organization create commands take evidence that only `requireOrganizationAdministrator` constructs; the other contexts await the rollout in [authorization evidence](docs/specs/authz-evidence.md). 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. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index 7e4fb97b..cee4f4f1 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -72,18 +72,39 @@ What stays: - The authorization-rules proof observes `requireOrganizationAdministrator`, with the same observed tags as before. -## Falsifiers - -1. A call of a create command with an object literal, an `OrganizationMember`, or an - `OrganizationActor` fails to type check (`@ts-expect-error` negative controls in - `packages/domain/src/organization/authority.test.ts`). -2. For every `OrganizationPersonAuthority` that `Arbitrary.schema` generates, evidence exists - exactly when `globalAdministrator` is `Active`, it names the same person, and it agrees with - `mapOrganizationAuthorityToOrganizationActor` (property test). -3. A member who calls the create endpoints still receives `authority.denied`, and nothing is - written (existing backend and PostgreSQL tests). -4. Stryker, run over the pilot module, leaves no surviving mutant in - `requireOrganizationAdministrator`. +## 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, after operator approval diff --git a/packages/domain/src/organization/authority-evidence.test.ts b/packages/domain/src/organization/authority-evidence.test.ts index f1e739ff..1d8c024b 100644 --- a/packages/domain/src/organization/authority-evidence.test.ts +++ b/packages/domain/src/organization/authority-evidence.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it } from "@effect/vitest"; +import { expect, expectTypeOf, it } from "@effect/vitest"; import { Effect, Predicate, Result, Schema } from "effect"; import { Arbitrary } from "effect/unstable/arbitrary"; import { @@ -63,8 +63,9 @@ const authority = Arbitrary.map( const propertyOptions = { arbitrary: { seed: 28092026, runs: 200 } } as const; -describe("requireOrganizationAdministrator", () => { - it.effect("returns evidence for an active global administrator and a denial for the others", () => +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), @@ -89,42 +90,41 @@ describe("requireOrganizationAdministrator", () => { expect(failure.requiredRole).toBe("OrganizationAdministrator"); } }), - ); +); - it.prop( - "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); +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")); + 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, - ); + if (Result.isSuccess(result)) { + expect(result.success.actor).toEqual(actor); + } else { + expect(result.failure.actorPersonId).toBe(authority.personId); + } + }, + propertyOptions, +); - it("is the only way to satisfy an administration command", () => { - type Administrator = Parameters[1]; +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(); + 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(); - }); + // 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(); }); From 6ee807dbbf87e0b9259031e81569159b6185dd79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:18:51 +0000 Subject: [PATCH 003/148] refactor(authz): require department reach evidence for unguarded commands Add requireDepartmentReach, the only constructor of DepartmentReach, which proves a capability over one department. Three commands whose only barrier was a check at their HTTP caller now take it: - commandOnboarding takes the coordinator's admissions.outcomes reach and reads its department and audited actor from it. - recordAdmissionOutcome takes the decider's reach and denies evidence for another department on the locked application, a check it lacked. - Placements.execute takes a coordinator's placements.coordinate reach for board and coverage-board changes; own affiliation and coverage still act as the person. Property tests show the evidence agrees with reaches, with department administration, with canManagePlacements, and with the outcome Decide permission, so the replaced handler checks keep their answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/admission/outcome-http.ts | 11 +- .../backend/src/onboarding/claim.http.test.ts | 10 +- apps/backend/src/onboarding/http.ts | 13 +- apps/backend/src/placements/http.ts | 148 ++++++++---- docs/constructs.md | 1 + docs/constructs/authority-evidence.md | 44 ++++ .../database/src/admissions/outcome.test.ts | 38 ++- packages/database/src/admissions/outcome.ts | 9 +- packages/database/src/onboarding.test.ts | 19 +- packages/database/src/onboarding/postgres.ts | 17 +- .../database/src/placements/service.test.ts | 18 +- packages/database/src/placements/service.ts | 13 +- packages/domain/src/admissions/service.ts | 5 +- packages/domain/src/authz/AGENTS.md | 6 + .../domain/src/authz/reach-evidence.test.ts | 228 ++++++++++++++++++ packages/domain/src/authz/reach.ts | 67 ++++- .../authority-fixtures.test-support.ts | 33 +++ packages/domain/src/placements/service.ts | 27 ++- .../verification/onboarding-delivery.test.ts | 9 +- 19 files changed, 627 insertions(+), 89 deletions(-) create mode 100644 packages/domain/src/authz/reach-evidence.test.ts diff --git a/apps/backend/src/admission/outcome-http.ts b/apps/backend/src/admission/outcome-http.ts index f99762cf..301063ab 100644 --- a/apps/backend/src/admission/outcome-http.ts +++ b/apps/backend/src/admission/outcome-http.ts @@ -10,7 +10,7 @@ import { type AdmissionOutcomeEntry, type AdmissionOutcomeOperationFailure, } from "@vektorprogrammet/domain/admissions"; -import { Scope } from "@vektorprogrammet/domain/authz"; +import { requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { OrganizationPersistenceError } from "@vektorprogrammet/domain/organization"; import { @@ -293,11 +293,16 @@ export const AdmissionOutcomesApiHandlers = (input: { now?: () => string }) => { const auth = yield* personAuthority(request); + // 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( request, RecordAdmissionOutcomeEndpoint, selected.departmentId, - true, + false, auth, ); @@ -322,7 +327,7 @@ export const AdmissionOutcomesApiHandlers = (input: { now?: () => string }) => { { applicationId, command, - actor: auth.authority.personId, + decider, now: auth.authorizationInstant, }, (current) => requireCurrentETag(outcomeResource(current).etag, ifMatch), diff --git a/apps/backend/src/onboarding/claim.http.test.ts b/apps/backend/src/onboarding/claim.http.test.ts index 79e08e28..598c7331 100644 --- a/apps/backend/src/onboarding/claim.http.test.ts +++ b/apps/backend/src/onboarding/claim.http.test.ts @@ -1,3 +1,4 @@ +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"; @@ -10,7 +11,7 @@ import { } 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 { 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"; @@ -78,12 +79,15 @@ const seed = Database.use((sql) => `; yield* sql.withTransaction( commandOnboarding({ - departmentId: DepartmentId.make("claim-department"), + coordinator: administratorDepartmentReach( + "claim-issuer", + "admissions.outcomes", + "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], diff --git a/apps/backend/src/onboarding/http.ts b/apps/backend/src/onboarding/http.ts index 96d36303..6668e4e3 100644 --- a/apps/backend/src/onboarding/http.ts +++ b/apps/backend/src/onboarding/http.ts @@ -1,7 +1,6 @@ import { credentialMatchesAccessSpec, - reaches, - ReachTarget, + requireDepartmentReach, Scope, } from "@vektorprogrammet/domain/authz"; import { Database } from "@vektorprogrammet/database"; @@ -184,8 +183,9 @@ const authorize = ( Effect.gen(function* () { const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - if (!reaches(auth.authority, "admissions.outcomes", ReachTarget.Department({ departmentId }))) - return yield* Problem.make("authority.denied"); + const coordinator = yield* Effect.fromResult( + requireDepartmentReach(auth.authority, "admissions.outcomes", departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); yield* authorizePerson( { @@ -208,7 +208,7 @@ const authorize = ( presentation, ); - return auth; + return { ...auth, coordinator }; }); /** @@ -302,9 +302,8 @@ export const OnboardingApiHandlers = (input: { const current = resource(yield* readOnboardingBoard(scope.departmentId)); yield* requireCurrentETag(current.etag, ifMatch); yield* commandOnboarding({ - departmentId: scope.departmentId, + coordinator: auth.coordinator, command: selected, - actor: auth.authority.personId, now: auth.authorizationInstant, invitationId: "onboarding-" + identity.identitySha256, token, diff --git a/apps/backend/src/placements/http.ts b/apps/backend/src/placements/http.ts index c11fcb6a..509b8d10 100644 --- a/apps/backend/src/placements/http.ts +++ b/apps/backend/src/placements/http.ts @@ -1,6 +1,10 @@ import { Database } from "@vektorprogrammet/database"; import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { DomainId, Scope } from "@vektorprogrammet/domain/authz"; +import { DomainId, requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; +import type { + DepartmentId, + OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import { AffiliationScope, @@ -11,11 +15,11 @@ import { Placements, PlacementScope, PlacementScopes, - canManagePlacements, type CoverageCommand as CoverageCommandType, type OwnAffiliationCommand as OwnAffiliationCommandType, type OwnCoverageCommand as OwnCoverageCommandType, type PlacementCommand as PlacementCommandType, + type PlacementExecution, type PlacementOperationFailure, } from "@vektorprogrammet/domain/placements"; import { @@ -44,6 +48,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { type OrganizationResolutionError, resolveRequestPersonAuthorityInTransaction, + type TransactionPersonAuthority, } from "../authority.js"; import { authorizePerson, @@ -155,6 +160,48 @@ type Endpoint = | typeof CommandCoverageBoardEndpoint | typeof ReadPlacementDraftEndpoint; +/** 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 endpoint's AccessSpec for the resolved person in the department's scope. */ +const evaluateAccess = ( + request: Request, + endpoint: Endpoint, + departmentId: PlacementScope["departmentId"] | null, + auth: TransactionPersonAuthority, +) => + 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), + ); + const authorize = ( request: Request, endpoint: Endpoint, @@ -165,37 +212,28 @@ const authorize = ( Effect.gen(function* () { const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - if (manage && (departmentId === null || !canManagePlacements(auth.authority, departmentId))) { - return yield* Problem.make("authority.denied"); - } + if (manage) yield* requireCoordinator(auth.authority, departmentId); - 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), - ); + yield* evaluateAccess(request, endpoint, departmentId, auth); return auth; }); +/** Resolves the person, requires the coordinator's reach, and returns its evidence. */ +const authorizeCoordinator = ( + request: Request, + endpoint: Endpoint, + departmentId: PlacementScope["departmentId"], + now?: () => string, +) => + Effect.gen(function* () { + const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); + const coordinator = yield* requireCoordinator(auth.authority, departmentId); + yield* evaluateAccess(request, endpoint, departmentId, auth); + + return { auth, coordinator }; + }); + type MutationSelection = | { readonly mode: "affiliation"; @@ -413,13 +451,43 @@ export const PlacementsApiHandlers = (input: { now?: () => string }) => { // 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, - ); + // A board or coverage-board change runs on the coordinator's evidence; a person's own + // affiliation or coverage runs as that person. + const { auth, execution } = + selected.mode === "board" || selected.mode === "coverage" + ? yield* authorizeCoordinator( + request, + selected.endpoint, + selected.scope.departmentId, + input.now, + ).pipe( + Effect.map(({ auth, coordinator }) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: selected, + coordinator, + now: auth.authorizationInstant, + commandId, + }), + })), + ) + : yield* authorize( + request, + selected.endpoint, + selected.scope.departmentId, + false, + input.now, + ).pipe( + Effect.map((auth) => ({ + auth, + execution: (commandId: string): PlacementExecution => ({ + mutation: selected, + actor: auth.authority.personId, + now: auth.authorizationInstant, + commandId, + }), + })), + ); const identity = yield* httpIdentity({ credentialSubject: `Person:${auth.authority.personId}`, @@ -438,14 +506,8 @@ export const PlacementsApiHandlers = (input: { now?: () => string }) => { 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), + yield* placements.execute(execution(identity.identitySha256), (current) => + requireCurrentETag(resource(current).etag, ifMatch), ), ); diff --git a/docs/constructs.md b/docs/constructs.md index abcb8cbb..ce7b4080 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -18,6 +18,7 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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. - [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. - [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. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index 32e9b50f..c1c6df63 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -4,6 +4,50 @@ 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( + 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:194](../../packages/domain/src/authz/reach.ts#L194) + +**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. diff --git a/packages/database/src/admissions/outcome.test.ts b/packages/database/src/admissions/outcome.test.ts index db959c5a..cde5edb4 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 acf536ac..14cbbc49 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/onboarding.test.ts b/packages/database/src/onboarding.test.ts index 01ce3742..bfee3ffc 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 87c120f1..8c80c800 100644 --- a/packages/database/src/onboarding/postgres.ts +++ b/packages/database/src/onboarding/postgres.ts @@ -1,6 +1,7 @@ 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, @@ -40,10 +41,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 +58,7 @@ export const commandOnboarding = (input: { Effect.gen(function* () { const { applicantId } = yield* onboardingApplication( input.command.applicationId, - input.departmentId, + input.coordinator.departmentId, ); yield* lockOnboardingApplicant(applicantId); @@ -71,7 +76,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 +85,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})`; }), ); diff --git a/packages/database/src/placements/service.test.ts b/packages/database/src/placements/service.test.ts index 25e18693..e774aadd 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 68ad1f4a..7e1adb4e 100644 --- a/packages/database/src/placements/service.ts +++ b/packages/database/src/placements/service.ts @@ -89,7 +89,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( diff --git a/packages/domain/src/admissions/service.ts b/packages/domain/src/admissions/service.ts index ade0f62d..5cead189 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, diff --git a/packages/domain/src/authz/AGENTS.md b/packages/domain/src/authz/AGENTS.md index 194588c1..03429783 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/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts new file mode 100644 index 00000000..ad355ef6 --- /dev/null +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -0,0 +1,228 @@ +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, + 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 { PlacementExecution } from "../placements/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, + 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, +); + +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 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 a20d450c..8dcf2906 100644 --- a/packages/domain/src/authz/reach.ts +++ b/packages/domain/src/authz/reach.ts @@ -3,7 +3,7 @@ * `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 type { OrganizationPersonAuthority } from "../organization/authority.js"; import type { DepartmentId, MembershipId, PersonId, TeamId } from "../organization/schema.js"; import { @@ -144,6 +144,71 @@ export const reaches = ( 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 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<{ All: {}; diff --git a/packages/domain/src/organization/authority-fixtures.test-support.ts b/packages/domain/src/organization/authority-fixtures.test-support.ts index 8c3c763f..b7c8fde6 100644 --- a/packages/domain/src/organization/authority-fixtures.test-support.ts +++ b/packages/domain/src/organization/authority-fixtures.test-support.ts @@ -1,3 +1,6 @@ +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"; @@ -113,3 +116,33 @@ 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 = ( + personId: string, + capability: C, + departmentId: string, +): DepartmentReach => + Result.getOrThrow( + requireDepartmentReach( + activeAdministratorAuthority(personId), + capability, + DepartmentId.make(departmentId), + ), + ); diff --git a/packages/domain/src/placements/service.ts b/packages/domain/src/placements/service.ts index d0d13eca..8ec068f0 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -1,5 +1,6 @@ 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, @@ -61,12 +62,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 { diff --git a/tools/verification/onboarding-delivery.test.ts b/tools/verification/onboarding-delivery.test.ts index 008f43f5..eed29bd2 100644 --- a/tools/verification/onboarding-delivery.test.ts +++ b/tools/verification/onboarding-delivery.test.ts @@ -1,3 +1,4 @@ +import { administratorDepartmentReach } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { randomBytes } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Database } from "@vektorprogrammet/database"; @@ -22,6 +23,8 @@ 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") }); @@ -51,12 +54,11 @@ const issue = (i: number) => 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), @@ -154,12 +156,11 @@ describe("onboarding delivery lifecycle", () => { Database.use((sql) => sql.withTransaction( commandOnboarding({ - departmentId: dept, + coordinator, command: { applicationId: PublicApplicationIdSchema.make("onboard-app-5"), action: "Issue", }, - actor, now: "2000-01-01T00:00:00.000Z", invitationId: "expired-invitation", token: "onboard_" + "8".repeat(64), From 3a128ccfac187df271495471b36cef8fa3a1d5c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:30:20 +0000 Subject: [PATCH 004/148] refactor(authz): run authorized commands from their evidence Four commands authorized before replay and then authorized again inside the command, each resolving current authority twice in one transaction. Their authorize step now returns branded evidence, and the command runs from it without resolving authority again: - Schools: SchoolCommandAuthorization carries the decoded command and the locked school and departments; executeCommand takes only it. - Recruitment maintenance: RecruitmentMaintenanceAuthorization carries the decoded command; maintainRecruitment takes only it. - Certificates: authorizeCertificateCommand and CertificateCommandTarget give way to DaysServedConfirmationAuthorization and CertificateIssueAuthorization; the scope, the assistant, the department row, and the issuer's seat come from the evidence. - Team applications: authorize returns TeamApplicationAuthorization for the action; delete and revise take their application or team from it. Schools and maintenance resolved authority at DateTime.now twice; they now resolve it once. Each evidence is valid only in the transaction that resolved it, which is the transaction the command runs in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../src/placements/certificates-http.ts | 34 ++++------ .../src/recruitment/maintenance-http.ts | 7 +- .../src/schools/administration-http.ts | 9 +-- apps/backend/src/team-application/http.ts | 17 +++-- .../src/placements/certificates.test.ts | 59 ++++++++++------- .../database/src/placements/certificates.ts | 64 ++++++++++++------- packages/database/src/placements/service.ts | 19 ++++-- .../src/recruitment/maintenance-postgres.ts | 9 ++- .../src/recruitment/postgres-layer.ts | 4 +- .../database/src/schools/administration.ts | 11 ++-- .../database/src/schools/postgres-layer.ts | 4 +- .../src/team-application/delivery.test.ts | 28 +++++--- .../database/src/team-application/postgres.ts | 45 +++++++++---- .../src/team-application/service.test.ts | 47 ++++++++++---- .../database/src/team-application/service.ts | 10 +-- packages/domain/src/placements/certificate.ts | 37 +++++++++-- packages/domain/src/placements/service.ts | 38 +++++++---- .../domain/src/recruitment/maintenance.ts | 15 +++++ packages/domain/src/recruitment/service.ts | 8 ++- packages/domain/src/schools/administration.ts | 26 +++++++- packages/domain/src/schools/service.ts | 8 ++- .../domain/src/team-application/service.ts | 39 +++++++++-- 22 files changed, 362 insertions(+), 176 deletions(-) diff --git a/apps/backend/src/placements/certificates-http.ts b/apps/backend/src/placements/certificates-http.ts index aa36dc4b..cdc74315 100644 --- a/apps/backend/src/placements/certificates-http.ts +++ b/apps/backend/src/placements/certificates-http.ts @@ -10,7 +10,6 @@ import { type CredentialOutcome, DomainId, Scope } from "@vektorprogrammet/domai import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; import type { DepartmentId, PersonId, SemesterId } from "@vektorprogrammet/domain/organization"; import { - CertificateCommandTarget, daysServedEntryVersion, Placements, type CertificateCommandFailure, @@ -364,12 +363,13 @@ const confirmDaysServed = ( 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 }), - ), + const authorization = yield* Placements.use((placements) => + placements.authorizeDaysServedConfirmation(current.principal, { + departmentId, + semesterId, + }), ); + yield* authorizeReader(request, ConfirmDaysServedEndpoint, current, departmentId); const identity = yield* httpIdentity({ @@ -390,14 +390,8 @@ const confirmDaysServed = ( }, execute: Placements.use((placements) => placements.confirmDaysServed( - current.principal, - { - commandId: identity.identitySha256, - departmentId, - semesterId, - personId, - total: input.value.total, - }, + authorization, + { commandId: identity.identitySha256, personId, total: input.value.total }, (entry) => requireCurrentETag(entryETag(entry), ifMatch), ), ).pipe( @@ -439,12 +433,10 @@ const issueCertificate = ( 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 }), - ), + const authorization = yield* Placements.use((placements) => + placements.authorizeCertificateIssue(current.principal, departmentId, personId), ); + yield* authorizeReader(request, IssueCertificateEndpoint, current, departmentId); const identity = yield* httpIdentity({ @@ -466,8 +458,8 @@ const issueCertificate = ( // 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 }, + authorization, + { commandId: identity.identitySha256 }, (preview) => requireCurrentETag(certificateETag(preview), ifMatch), ), ).pipe( diff --git a/apps/backend/src/recruitment/maintenance-http.ts b/apps/backend/src/recruitment/maintenance-http.ts index 484ae638..0f72996a 100644 --- a/apps/backend/src/recruitment/maintenance-http.ts +++ b/apps/backend/src/recruitment/maintenance-http.ts @@ -141,8 +141,11 @@ export const maintainRecruitmentHttp = (request: Request) => 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)); + const authorization = 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({ @@ -160,7 +163,7 @@ export const maintainRecruitmentHttp = (request: Request) => }, execute: Effect.gen(function* () { const result = yield* Recruitment.use((service) => - service.maintainRecruitment(command, personId), + service.maintainRecruitment(authorization), ); // The domain produced the result, so encoding it cannot fail. diff --git a/apps/backend/src/schools/administration-http.ts b/apps/backend/src/schools/administration-http.ts index 025f1c1e..ffd00e4a 100644 --- a/apps/backend/src/schools/administration-http.ts +++ b/apps/backend/src/schools/administration-http.ts @@ -102,7 +102,10 @@ export const executeSchoolCommandHttp = (request: Request) => { const outcome = yield* executeNativeHttpCommandPostgres( Effect.gen(function* () { const personId = yield* authorizeTransport(request, true, presentation); - yield* Schools.use((schools) => schools.authorizeCommand(command, personId)); + + const authorization = yield* Schools.use((schools) => + schools.authorizeCommand(command, personId), + ); const identity = yield* httpIdentity({ credentialSubject: `Person:${personId}`, @@ -118,9 +121,7 @@ export const executeSchoolCommandHttp = (request: Request) => { operationId, }, execute: Effect.gen(function* () { - const result = yield* Schools.use((schools) => - schools.executeCommand(command, personId), - ); + const result = yield* Schools.use((schools) => schools.executeCommand(authorization)); const encoded = yield* Schema.encodeEffect(SchoolCommandResult)(result); diff --git a/apps/backend/src/team-application/http.ts b/apps/backend/src/team-application/http.ts index ce0cbbb4..f237ee19 100644 --- a/apps/backend/src/team-application/http.ts +++ b/apps/backend/src/team-application/http.ts @@ -488,7 +488,7 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = const staff = yield* staffPrincipal(request); // 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 }), @@ -498,7 +498,7 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = yield* authorizeStaff( DeleteTeamApplicationEndpoint, staff, - actor, + authorization.actor, "ExactlyOne", presentation, ); @@ -519,10 +519,9 @@ const deleteApplication = (request: Request, applicationId: TeamApplicationId) = operationId, }, execute: TeamApplications.use((service) => - service.deleteApplication( - { commandId: TeamApplicationCommandId.make(identity.commandId), applicationId }, - staff.principal, - ), + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(identity.commandId), + }), ).pipe( Effect.as({ status: 204, @@ -586,7 +585,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => const staff = yield* staffPrincipal(request); // 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 }), @@ -596,7 +595,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => yield* authorizeStaff( ReviseTeamApplicationIntakeEndpoint, staff, - actor, + authorization.actor, "ExactlyOne", presentation, ); @@ -626,7 +625,7 @@ const reviseIntake = (request: Request, teamId: TeamId) => operationId, }, execute: TeamApplications.use((service) => - service.reviseIntake(command, staff.principal, (current) => + service.reviseIntake(authorization, command, (current) => requireCurrentETag(intakeETag(teamId, current.revision), ifMatch), ), ).pipe( diff --git a/packages/database/src/placements/certificates.test.ts b/packages/database/src/placements/certificates.test.ts index 75b154b5..c4b6222c 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), + ), + ), + ), ), ), ); diff --git a/packages/database/src/placements/certificates.ts b/packages/database/src/placements/certificates.ts index 36b11b0e..dc598047 100644 --- a/packages/database/src/placements/certificates.ts +++ b/packages/database/src/placements/certificates.ts @@ -21,7 +21,8 @@ import { CertificateAccessDenied, CertificateAssistant, CertificateAssistantNotFound, - CertificateCommandTarget, + type CertificateIssueAuthorization, + type DaysServedConfirmationAuthorization, certificateContent, CertificateContent, certificateContentSha256, @@ -705,21 +706,41 @@ const authorizeIssue = ( }); /** - * 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 = ( principal: CertificatePrincipal, - target: CertificateCommandTarget, + scope: PlacementScope, ) => Effect.gen(function* () { const sql = yield* Database; + yield* authorizeConfirmation(sql, principal, scope); - 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; authorizeConfirmation above is what it proves. + return { + principal, + departmentId: scope.departmentId, + semesterId: scope.semesterId, + } as DaysServedConfirmationAuthorization; + }); + +/** + * 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 authorizeCertificateIssue = ( + principal: CertificatePrincipal, + departmentId: DepartmentId, + personId: PersonId, +) => + Effect.gen(function* () { + const sql = yield* Database; + const { department, issuer } = yield* 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; }); /** @@ -727,15 +748,15 @@ export const authorizeCertificateCommand = ( * precondition sees the fresh entry; the earlier confirmations and the service facts stay. */ export const confirmDaysServed = ( - principal: CertificatePrincipal, - command: ConfirmDaysServedCommand, + 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); @@ -846,19 +867,14 @@ export const readCertificate = ( * under which seat, and the hash of the content. The precondition sees the fresh preview. */ export const issueCertificate = ( - principal: CertificatePrincipal, - command: IssueCertificateCommand, + 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); diff --git a/packages/database/src/placements/service.ts b/packages/database/src/placements/service.ts index 7e1adb4e..f8f959b0 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, @@ -169,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) => @@ -185,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/recruitment/maintenance-postgres.ts b/packages/database/src/recruitment/maintenance-postgres.ts index 27d73a11..f2b472cc 100644 --- a/packages/database/src/recruitment/maintenance-postgres.ts +++ b/packages/database/src/recruitment/maintenance-postgres.ts @@ -20,6 +20,7 @@ import { InterviewStaffing, InterviewStaffingHistory, InterviewStaffingManagement, + type RecruitmentMaintenanceAuthorization, RecruitmentMaintenanceCommand, RecruitmentMaintenanceResult, RecruitmentMaintenanceFailure, @@ -239,16 +240,18 @@ export const authorizeMaintenance = (command: RecruitmentMaintenanceCommand, per 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 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), diff --git a/packages/database/src/recruitment/postgres-layer.ts b/packages/database/src/recruitment/postgres-layer.ts index df5c6701..ad1252cf 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/schools/administration.ts b/packages/database/src/schools/administration.ts index ae375e2a..2e2c38b2 100644 --- a/packages/database/src/schools/administration.ts +++ b/packages/database/src/schools/administration.ts @@ -8,6 +8,7 @@ import { SchoolId, SchoolCapacityPlan, SchoolCommand, + type SchoolCommandAuthorization, SchoolCommandResult, ManagedSchool, SchoolDirectoryDepartmentSchema, @@ -154,7 +155,10 @@ export const authorizeSchoolCommand = (command: SchoolCommand, personId: PersonI 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 readSchoolManagement = (personId: PersonId) => @@ -220,14 +224,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( diff --git a/packages/database/src/schools/postgres-layer.ts b/packages/database/src/schools/postgres-layer.ts index d149ac9b..1059e476 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/team-application/delivery.test.ts b/packages/database/src/team-application/delivery.test.ts index 2fcbd756..d18f76ef 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), + }), + ), + ), ), ), ); diff --git a/packages/database/src/team-application/postgres.ts b/packages/database/src/team-application/postgres.ts index c3235d20..876c7634 100644 --- a/packages/database/src/team-application/postgres.ts +++ b/packages/database/src/team-application/postgres.ts @@ -15,6 +15,7 @@ import { TeamApplication, TeamApplicationAccessDenied, TeamApplicationAction, + type TeamApplicationAuthorization, TeamApplicationCommandConflict, TeamApplicationConfirmation, TeamApplicationId, @@ -404,10 +405,7 @@ const applicationTeam = (applicationId: TeamApplicationId, forDeletion: boolean) ), ); -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,6 +419,22 @@ export const authorizeTeamApplicationAction = ( ), }); +/** + * 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 = ( + 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, + ), + ); + export const readPublicTeamApplicationIntake = (teamId: TeamId) => Effect.gen(function* () { const team = yield* findTeamIntake({ teamId, lock: "None" }); @@ -566,7 +580,7 @@ export const readTeamApplication = ( applicationId: TeamApplicationId, ) => Effect.gen(function* () { - const actor = yield* authorizeTeamApplicationAction( + const actor = yield* resolveActionActor( principal, TeamApplicationAction.ReadTeamApplication({ applicationId }), ); @@ -581,14 +595,14 @@ export const readTeamApplication = ( }); export const deleteTeamApplication = ( - command: DeleteTeamApplicationCommand, - principal: TeamApplicationPrincipal, + 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", @@ -644,12 +658,15 @@ export const deleteTeamApplication = ( }); export const reviseTeamApplicationIntake = ( - command: ReviseTeamApplicationIntakeCommand, - principal: TeamApplicationPrincipal, + 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", diff --git a/packages/database/src/team-application/service.test.ts b/packages/database/src/team-application/service.test.ts index c66fab6d..2bdd34e9 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"; @@ -359,10 +360,18 @@ 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), + }), + ), + ), ), ), ); @@ -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, + ), + ), + ), ), ), ); diff --git a/packages/database/src/team-application/service.ts b/packages/database/src/team-application/service.ts index d7c7f88d..ee09920b 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/domain/src/placements/certificate.ts b/packages/domain/src/placements/certificate.ts index 89e5caa9..7a041772 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/service.ts b/packages/domain/src/placements/service.ts index 8ec068f0..a28eae88 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -2,13 +2,14 @@ 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 { @@ -168,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. */ @@ -198,12 +200,22 @@ 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; } diff --git a/packages/domain/src/recruitment/maintenance.ts b/packages/domain/src/recruitment/maintenance.ts index 0478847f..f784dd88 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/service.ts b/packages/domain/src/recruitment/service.ts index 86f28beb..87e756d8 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, diff --git a/packages/domain/src/schools/administration.ts b/packages/domain/src/schools/administration.ts index 7f767421..403c97e6 100644 --- a/packages/domain/src/schools/administration.ts +++ b/packages/domain/src/schools/administration.ts @@ -2,7 +2,13 @@ import { Schema } from "effect"; 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 +70,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), diff --git a/packages/domain/src/schools/service.ts b/packages/domain/src/schools/service.ts index 6d7923fc..f5ca146f 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, diff --git a/packages/domain/src/team-application/service.ts b/packages/domain/src/team-application/service.ts index 644d7247..1793baf5 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; /** From b8c062931a83fdf2b10d7b1ca7edd523f0ac9160 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:35:53 +0000 Subject: [PATCH 005/148] refactor(organization): require a team interest read scope listTeamInterestRegistrations took a filter of authorized departments and teams that its HTTP caller computed, so it read whatever scope a caller named. requireTeamInterestScope now computes that scope from the resolved authority, as the handler did, and is the only constructor of TeamInterestReadScope, which the listing requires. The unused departmentId filter is gone; the semester stays an argument. A property test checks that the scope holds only departments and teams that team-interest.read reaches, inside the requested department, and that it denies exactly where nothing is readable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/organization/http.ts | 50 +++----- .../organization/team-interest.http.test.ts | 33 ++---- docs/constructs.md | 1 + docs/constructs/authority-evidence.md | 43 ++++++- packages/database/src/database.test.ts | 34 ++++-- .../src/organization/postgres-layer.ts | 4 +- .../database/src/organization/postgres.ts | 29 +++-- .../domain/src/authz/reach-evidence.test.ts | 72 ++++++++++++ packages/domain/src/organization/AGENTS.md | 1 + packages/domain/src/organization/authority.ts | 109 +++++++++++++++++- packages/domain/src/organization/service.ts | 12 +- 11 files changed, 290 insertions(+), 98 deletions(-) diff --git a/apps/backend/src/organization/http.ts b/apps/backend/src/organization/http.ts index bef0b46b..2871d8af 100644 --- a/apps/backend/src/organization/http.ts +++ b/apps/backend/src/organization/http.ts @@ -2,7 +2,6 @@ import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-pe import { reachedDepartments, ReachedDepartments, - reachedTeams, ResourceId, ResourceKind, Scope, @@ -21,6 +20,7 @@ import { OrganizationCommandId, OrganizationLifecycleCommand, mapOrganizationAuthorityToOrganizationActor, + requireTeamInterestScope, requireOrganizationAdministrator, SemesterId, type TeamId, @@ -28,7 +28,6 @@ import { 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"; @@ -796,57 +795,34 @@ const listTeamInterest = (request: Request, input: OrganizationApiHttpOptions) = return Effect.gen(function* () { const authority = yield* input.resolveAuthority(request); const requested = yield* optionalQueryIdentity(request, "department", DepartmentId); + const departments = yield* Organization.use(({ listDepartments }) => listDepartments); + // 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"); + const scope = yield* Effect.fromResult( + requireTeamInterestScope(authority, { + requested, + departments: departments.map((department) => department.departmentId), + }), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); yield* authorizeOrganizationCollection({ request, authority, endpoint: ListTeamInterestEndpoint, - departmentIds: authorized, - teams: authorizedTeams, + departmentIds: scope.departmentIds, + teams: scope.teams, 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 semesterId = yield* optionalQueryIdentity(request, "semester", SemesterId); const rows = yield* Organization.use(({ listTeamInterestRegistrations }) => - listTeamInterestRegistrations(filter), + listTeamInterestRegistrations(scope, semesterId), ); return yield* privateReadJson(TeamInterestResponse)({ diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.http.test.ts index b5376d29..09a8b826 100644 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ b/apps/backend/src/organization/team-interest.http.test.ts @@ -15,6 +15,7 @@ import { Organization, PersonId, type OrganizationOperations, + type TeamInterestReadScope, } from "@vektorprogrammet/domain/organization"; import { DateTime, Effect, Layer, Schema } from "effect"; import { describe, expect, it } from "@effect/vitest"; @@ -99,11 +100,8 @@ 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]; @@ -112,20 +110,16 @@ const organization = { listDepartments: Effect.sync(() => storedDepartments), listTeams: () => Effect.succeed([]), listFieldOfStudies: Effect.succeed([]), - listTeamInterestRegistrations: (filter: { - authorizedDepartmentIds: ReadonlyArray; - authorizedTeamIds: ReadonlyArray; - semesterId?: string; - }) => + listTeamInterestRegistrations: (scope: TeamInterestReadScope, semesterId?: SemesterId) => Effect.sync(() => { - lastTeamInterestFilter = filter; + lastTeamInterestScope = scope; const rows = registrationRows .filter( (row) => - (filter.authorizedDepartmentIds.includes(row.departmentId) || - filter.authorizedTeamIds.includes(row.teamId)) && - (filter.semesterId === undefined || row.semesterId === filter.semesterId), + (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); @@ -359,7 +353,7 @@ 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"]); }), ); @@ -371,8 +365,8 @@ describe("spec 0059 team-interest HTTP boundary", () => { "hydra:member": [{ id: 3, userName: "User C", teamName: "Team Two" }], "hydra:totalItems": 1, }); - expect(lastTeamInterestFilter.authorizedDepartmentIds).toEqual([]); - expect(lastTeamInterestFilter.authorizedTeamIds).toEqual(["team-2"]); + expect(lastTeamInterestScope?.departmentIds).toEqual([]); + expect(lastTeamInterestScope?.teams.map(({ teamId }) => teamId)).toEqual(["team-2"]); const otherDepartment = yield* get( "/api/team-interest-registrations?department=department-1", @@ -397,10 +391,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"]); }), ); diff --git a/docs/constructs.md b/docs/constructs.md index ce7b4080..cf75a186 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -20,6 +20,7 @@ Tag a construct only when at least 2 modules outside its own module and the test - [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. - [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. diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index c1c6df63..13f711d2 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -63,7 +63,7 @@ requireOrganizationAdministrator( - Errors: `OrganizationRoleDenied` - Requirements: none - Side effects: none -- Source: [packages/domain/src/organization/authority.ts:263](../../packages/domain/src/organization/authority.ts#L263) +- Source: [packages/domain/src/organization/authority.ts:271](../../packages/domain/src/organization/authority.ts#L271) **How it works** @@ -84,3 +84,44 @@ yield* organization.createDepartment(command, administrator); 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( + 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:366](../../packages/domain/src/organization/authority.ts#L366) + +**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/packages/database/src/database.test.ts b/packages/database/src/database.test.ts index c1c14584..b1e4e88c 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -42,7 +42,9 @@ import { OrganizationCommandId, OrganizationPersonAuthoritySchema, requireOrganizationAdministrator, + requireTeamInterestScope, } from "@vektorprogrammet/domain/organization"; +import { activeAdministratorAuthority } from "@vektorprogrammet/domain/organization/authority-fixtures"; import { RecruitmentAssignmentObservationSchema, RecruitmentScheduleObservationSchema, @@ -93,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, @@ -4084,17 +4086,27 @@ 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], + }), + ), + ); + + const filteredRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: departmentB, + departments: [departmentA, departmentB], + }), + ), + SemesterId.make("semester-scope"), + ); return { authorizedRows, filteredRows }; }); diff --git a/packages/database/src/organization/postgres-layer.ts b/packages/database/src/organization/postgres-layer.ts index af28d44d..8473dd88 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 4f10ef34..91262c2d 100644 --- a/packages/database/src/organization/postgres.ts +++ b/packages/database/src/organization/postgres.ts @@ -25,7 +25,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, @@ -600,40 +600,37 @@ const decodeTeamInterestRegistration = flow( ), ); -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, + scope: TeamInterestReadScope, + semesterId?: SemesterId, ): Effect.Effect< ReadonlyArray, OrganizationDecodeError | OrganizationPersistenceError, Database > => { - if (filter.authorizedDepartmentIds.length === 0 && filter.authorizedTeamIds.length === 0) - return Effect.succeed([]); + if (scope.departmentIds.length === 0 && scope.teams.length === 0) return Effect.succeed([]); return Effect.gen(function* () { const database = yield* Database; @@ -653,7 +650,7 @@ 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) => diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts index ad355ef6..8bd3e57c 100644 --- a/packages/domain/src/authz/reach-evidence.test.ts +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -4,6 +4,8 @@ import { Arbitrary } from "effect/unstable/arbitrary"; import { admissionOutcomePermission } from "../admissions/outcome.js"; import { mapOrganizationAuthorityToDepartmentActor, + requireTeamInterestScope, + type TeamInterestReadScope, OrganizationAuthorityBoardSeatSchema, OrganizationAuthorityMembershipSchema, OrganizationGlobalAdministratorStatusSchema, @@ -12,6 +14,7 @@ import { 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 type { AdmissionsOperations } from "../admissions/service.js"; import { @@ -25,6 +28,9 @@ import { import { type DepartmentReach, DepartmentReachDenied, + ReachedDepartments, + reachedDepartments, + reachedTeams, reaches, ReachTarget, requireDepartmentReach, @@ -200,6 +206,62 @@ it.prop( 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("requireDepartmentReach evidence is the only way to satisfy a department-scoped command", () => { type OutcomeDecider = Parameters[0]["decider"]; @@ -218,6 +280,16 @@ it("requireDepartmentReach evidence is the only way to satisfy a department-scop expectTypeOf>().not.toExtend(); expectTypeOf>().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"]; diff --git a/packages/domain/src/organization/AGENTS.md b/packages/domain/src/organization/AGENTS.md index e1dd63a0..12a2b9bb 100644 --- a/packages/domain/src/organization/AGENTS.md +++ b/packages/domain/src/organization/AGENTS.md @@ -85,6 +85,7 @@ Responsibilities: 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. diff --git a/packages/domain/src/organization/authority.ts b/packages/domain/src/organization/authority.ts index a9c4b1e1..60451fcc 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,4 @@ -import { Result, Schema } from "effect"; +import { Data, Result, Schema } from "effect"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -10,7 +10,15 @@ 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, @@ -309,3 +317,100 @@ 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 = ( + /** 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/service.ts b/packages/domain/src/organization/service.ts index 4baa09ad..b0d3a174 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -26,6 +26,7 @@ import type { } from "./administration-schema.js"; import type { OrganizationAdministratorEvidence, + TeamInterestReadScope, OrganizationAuthorityInstant, OrganizationPersonAuthority, } from "./authority.js"; @@ -64,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 = @@ -129,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 From c2bb9751cf891fa07d4708fdf4144196fb6fb4a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:38:06 +0000 Subject: [PATCH 006/148] refactor(social-events): require creation evidence for a new event SocialEvents.create took the creator's person id and trusted its caller to have evaluated the create AccessSpec. requireSocialEventCreation now builds SocialEventCreation from the same candidate grants, and create takes it: the audited actor comes from the evidence, and a request for another department than the evidence names is a defect. A property test states the rule independently of the grants: an active global administrator creates anywhere, an active appointment creates in its department. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/social-events/http.ts | 10 +++- .../src/social-events/postgres-layer.ts | 4 +- .../database/src/social-events/postgres.ts | 15 +++-- .../domain/src/authz/reach-evidence.test.ts | 34 +++++++++++ .../domain/src/social-events/authority.ts | 58 ++++++++++++++++++- packages/domain/src/social-events/service.ts | 5 +- 6 files changed, 114 insertions(+), 12 deletions(-) diff --git a/apps/backend/src/social-events/http.ts b/apps/backend/src/social-events/http.ts index faea169b..1c7c74cb 100644 --- a/apps/backend/src/social-events/http.ts +++ b/apps/backend/src/social-events/http.ts @@ -4,6 +4,7 @@ import { SocialEventId, SocialEvents, socialEventCandidateGrantScopes, + requireSocialEventCreation, socialEventDepartmentAccessContext, socialEventScopeAccessContext, type SocialEventFailure, @@ -327,6 +328,12 @@ const create = (request: Request, options: SocialEventsApiHttpOptions) => authorization, context: socialEventDepartmentAccessContext(authorization.authority, body.departmentId), }); + + // The AccessSpec admitted the candidate grants; the command takes them as evidence. + const creator = yield* Effect.fromResult( + requireSocialEventCreation(authorization.authority, body.departmentId), + ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); + yield* runTransactionHook(options, request, "create", "after-create-authorization"); yield* SocialEvents.use(({ validateScope }) => validateScope({ departmentId: body.departmentId, semesterId: body.semesterId }), @@ -346,9 +353,8 @@ const create = (request: Request, options: SocialEventsApiHttpOptions) => operationId, }, execute: SocialEvents.use((events) => - events.create({ + events.create(creator, { commandId: SocialEventCommandId.make(identity.commandId), - actorPersonId: authorization.authority.personId, occurredAt: observedAt, eventId: SocialEventId.make(`social_event_${randomUUID()}`), request: body, diff --git a/packages/database/src/social-events/postgres-layer.ts b/packages/database/src/social-events/postgres-layer.ts index 36588329..d501da34 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 90132fdd..dcb49725 100644 --- a/packages/database/src/social-events/postgres.ts +++ b/packages/database/src/social-events/postgres.ts @@ -9,6 +9,7 @@ import { SocialEventScope, SocialEventScopeResource, type CreateSocialEventCommand as CreateSocialEventCommandValue, + type SocialEventCreation, type SocialEventListResource as SocialEventListResourceValue, type SocialEventObservedAt as SocialEventObservedAtValue, type SocialEventResource as SocialEventResourceValue, @@ -278,15 +279,19 @@ 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, + creator: SocialEventCreation, + input: Omit, ): Effect.Effect => Effect.gen(function* () { - const command = yield* decodeCreateCommand(input); + 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, diff --git a/packages/domain/src/authz/reach-evidence.test.ts b/packages/domain/src/authz/reach-evidence.test.ts index 8bd3e57c..53eea3f2 100644 --- a/packages/domain/src/authz/reach-evidence.test.ts +++ b/packages/domain/src/authz/reach-evidence.test.ts @@ -16,6 +16,11 @@ 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, @@ -262,6 +267,26 @@ it.prop( 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"]; @@ -280,6 +305,15 @@ it("requireDepartmentReach evidence is the only way to satisfy a department-scop 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"]; diff --git a/packages/domain/src/social-events/authority.ts b/packages/domain/src/social-events/authority.ts index 7f33dc48..9f64d810 100644 --- a/packages/domain/src/social-events/authority.ts +++ b/packages/domain/src/social-events/authority.ts @@ -1,4 +1,4 @@ -import { Result, Array } from "effect"; +import { Array, Data, Predicate, Result } from "effect"; import { PrincipalSchema, AuthorityRef, @@ -15,7 +15,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}`); @@ -126,3 +126,57 @@ export const socialEventCandidateGrantScopes = ( 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 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 fdad88e6..95f4735c 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,8 +37,10 @@ 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; } From 416850cf0465aecbb9efd2cc5bb3da3df1c836c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:52:29 +0000 Subject: [PATCH 007/148] docs(authz): record the evidence rollout and what stays Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- STATE.md | 5 ++- docs/specs/authz-evidence.md | 77 ++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/STATE.md b/STATE.md index e4798e65..d326778a 100644 --- a/STATE.md +++ b/STATE.md @@ -110,8 +110,9 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction - `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`. - Instants: domain fields still use `Rfc3339InstantSchema`, not `Instant` (`packages/domain/src/time.ts`). -- Authorization is trusted by convention: 13 authorize helpers return `void` or a value any module can construct, and a command runs whether or not its caller called one. - The Organization create commands take evidence that only `requireOrganizationAdministrator` constructs; the other contexts await the rollout in [authorization evidence](docs/specs/authz-evidence.md). +- 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. diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index cee4f4f1..02584242 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -1,8 +1,10 @@ # Authorization evidence -Status: pilot (2026-09-28), on branch `refactor/authz-evidence-organization`. The operator asked for one -context first. The pilot covers the three Organization administration create commands. The -operator has not approved the rollout to the other contexts. +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. @@ -106,13 +108,62 @@ resolved to the repository's copy: a Vitest config that includes `src/organizati and a Stryker config with `testRunner: "vitest"`, `inPlace: true`, `concurrency: 1`, `coverageAnalysis: "off"`, and `mutate: ["src/organization/authority.ts:"]`. -## Rollout, after operator approval - -One context per branch, in order of call sites: the person and anonymous helpers of -`apps/backend/src/http-api/problem.ts` (44), content, admission, recruitment, team applications, -schools, placements certificates, receipts (seal the existing constructors). Each branch deletes the -context's void helper and its runtime re-check in the adapter. - -Stryker is not in the root catalog. The pilot runs it outside the catalog and reports the score. It -joins the catalog, `just`, and hosted Checks only with the rollout, so that no check exists that -nothing runs. +## 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 journeys: see the table in the commit that records them. + +## 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/http-api/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. From 3a55e3aa75f15273b1ab0ba9fe9cd3792e55533c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:32:43 +0000 Subject: [PATCH 008/148] fix(e2e): end a timed-out readiness probe under Bun Bun's node:http destroy() emits close but no error, so a probe attempt that timed out never resumed, and waitForHttp outlived its deadline. The golden team-application journey hung for 25 minutes before its first page when the homepage's first request was slow. A probe against a silent listener with a one-second deadline hung past 8 s before this change and fails at 4.1 s after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/e2e/golden-harness.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/e2e/golden-harness.ts b/tools/e2e/golden-harness.ts index cdd60b1b..ab3279e2 100644 --- a/tools/e2e/golden-harness.ts +++ b/tools/e2e/golden-harness.ts @@ -495,6 +495,9 @@ export const waitForHttp = (probe: HttpProbe) => request.once("timeout", () => request.destroy()); request.once("error", () => resume(Effect.succeed(0))); + // Bun's `destroy()` emits `close` but no `error`, so an attempt that timed out resumes + // here; without it one slow attempt outlives the deadline. A later resume is ignored. + request.once("close", () => resume(Effect.succeed(0))); request.end(); return Effect.sync(() => request.destroy()); From 69796377cfece929ff1a41acdfe32bb4accd9877 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:34:32 +0000 Subject: [PATCH 009/148] docs(authz): record the journey results of the evidence rollout Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/authz-evidence.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/specs/authz-evidence.md b/docs/specs/authz-evidence.md index 02584242..36a81adc 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -147,7 +147,24 @@ Measured on `c2bb975`, with PostgreSQL 18: 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 journeys: see the table in the commit that records them. +- 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 From 0861506302cba18b9f952d629097bc5a0e5778a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:46:59 +0000 Subject: [PATCH 010/148] refactor(rpc): delete the HTTP API and serve native operations as Effect RPC Delete the HttpApi contract, its backend handlers and endpoint tests, the generated SDK, and OpenAPI generation. Better Auth, the OAuth2 routes, and /health stay plain HTTP behind the same ingress. Add packages/rpc: the RPC contract with one group per context, the credential middlewares, the defect boundary, typed problem errors, the AccessSpec annotation, the client, and a script client for journeys. The backend serves NativeRpcs at /api/rpc; each context exports one handler layer that the router lists. Social events is ported end to end as the precedent; every other group is an empty stub that its worker fills from the base commit, following docs/specs/rpc-only.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/package.json | 2 +- .../admission/admission-period.http.test.ts | 473 ---- apps/backend/src/admission/config.ts | 2 +- apps/backend/src/admission/http-access.ts | 138 -- apps/backend/src/admission/http-commands.ts | 504 ---- apps/backend/src/admission/http-context.ts | 92 - apps/backend/src/admission/http-decode.ts | 84 - apps/backend/src/admission/http-problem.ts | 170 -- apps/backend/src/admission/http-reads.ts | 321 --- .../src/admission/http-representation.ts | 54 - apps/backend/src/admission/http.ts | 62 - apps/backend/src/admission/outcome-http.ts | 368 --- apps/backend/src/admission/outcome-rpc.ts | 9 + apps/backend/src/admission/rpc.ts | 8 + .../admission/submit-application.http.test.ts | 348 --- apps/backend/src/config.ts | 2 +- apps/backend/src/contact/http.test.ts | 170 -- apps/backend/src/contact/http.ts | 91 - apps/backend/src/contact/rpc.ts | 8 + apps/backend/src/content/http-access.ts | 90 - apps/backend/src/content/http-commands.ts | 455 ---- apps/backend/src/content/http-context.ts | 107 - apps/backend/src/content/http-decode.ts | 40 - apps/backend/src/content/http-problem.ts | 107 - apps/backend/src/content/http-reads.ts | 274 --- .../src/content/http-representation.ts | 27 - apps/backend/src/content/http.test.ts | 285 --- apps/backend/src/content/http.ts | 55 - apps/backend/src/content/rpc.ts | 8 + apps/backend/src/directory/http.test.ts | 632 ----- apps/backend/src/directory/http.ts | 246 -- apps/backend/src/directory/rpc.ts | 8 + apps/backend/src/http-api/system.ts | 390 --- apps/backend/src/http-api/transport.test.ts | 255 -- apps/backend/src/http-api/transport.ts | 320 --- apps/backend/src/http-semantics.test.ts | 4 +- apps/backend/src/http-semantics.ts | 2 +- apps/backend/src/index.ts | 24 +- apps/backend/src/main.ts | 17 +- apps/backend/src/native-api-preflight.ts | 31 - .../backend/src/onboarding/claim.http.test.ts | 307 --- apps/backend/src/onboarding/http.ts | 400 ---- apps/backend/src/onboarding/rpc.ts | 8 + apps/backend/src/organization/http.test.ts | 576 ----- apps/backend/src/organization/http.ts | 1226 ---------- apps/backend/src/organization/rpc.ts | 8 + .../organization/team-interest.http.test.ts | 448 ---- .../src/placements/certificates-http.test.ts | 603 ----- .../src/placements/certificates-http.ts | 525 ---- .../src/placements/certificates-rpc.ts | 8 + apps/backend/src/placements/http.test.ts | 370 --- apps/backend/src/placements/http.ts | 563 ----- apps/backend/src/placements/rpc.ts | 8 + apps/backend/src/profile/http.test.ts | 209 -- apps/backend/src/profile/http.ts | 354 --- apps/backend/src/profile/rpc.ts | 8 + apps/backend/src/receipt/e2e-support.test.ts | 2 +- apps/backend/src/receipt/e2e-support.ts | 2 +- apps/backend/src/receipt/http-commands.ts | 704 ------ apps/backend/src/receipt/http-context.ts | 61 - apps/backend/src/receipt/http-decode.ts | 345 --- apps/backend/src/receipt/http-problem.ts | 115 - apps/backend/src/receipt/http-reads.ts | 559 ----- .../src/receipt/http-representation.ts | 228 -- apps/backend/src/receipt/http.test.ts | 2128 ----------------- apps/backend/src/receipt/http.ts | 127 - apps/backend/src/receipt/r2.ts | 2 +- apps/backend/src/receipt/rpc.ts | 12 + .../src/recruitment/commands.http.test.ts | 357 --- apps/backend/src/recruitment/http-access.ts | 314 --- apps/backend/src/recruitment/http-commands.ts | 725 ------ apps/backend/src/recruitment/http-context.ts | 35 - apps/backend/src/recruitment/http-decode.ts | 66 - apps/backend/src/recruitment/http-problem.ts | 221 -- apps/backend/src/recruitment/http-reads.ts | 349 --- .../src/recruitment/http-representation.ts | 48 - apps/backend/src/recruitment/http.test.ts | 996 -------- apps/backend/src/recruitment/http.ts | 96 - .../src/recruitment/maintenance-http.ts | 208 -- apps/backend/src/recruitment/rpc.ts | 8 + apps/backend/src/router.oauth.test.ts | 163 -- .../src/router.recruitment-authority.test.ts | 440 ---- apps/backend/src/router.test.ts | 1368 ----------- apps/backend/src/router.ts | 305 +-- apps/backend/src/{http-api => rpc}/AGENTS.md | 0 apps/backend/src/{http-api => rpc}/CLAUDE.md | 0 apps/backend/src/rpc/credential.ts | 195 ++ apps/backend/src/rpc/options.ts | 17 + apps/backend/src/{http-api => rpc}/problem.ts | 549 +---- .../{http-api => rpc}/public-rate-limit.ts | 0 .../src/{http-api => rpc}/read-json.ts | 2 +- .../receipt-transaction.test.ts | 0 .../{http-api => rpc}/receipt-transaction.ts | 29 + apps/backend/src/rpc/router.test.ts | 83 + apps/backend/src/rpc/system.ts | 8 + .../src/schools/administration-http.ts | 149 -- apps/backend/src/schools/http.test.ts | 412 ---- apps/backend/src/schools/http.ts | 155 -- apps/backend/src/social-events/http.ts | 409 ---- apps/backend/src/social-events/rpc.ts | 338 +++ .../backend/src/team-application/http.test.ts | 423 ---- apps/backend/src/team-application/http.ts | 677 ------ apps/backend/src/team-application/rpc.ts | 9 + apps/backend/src/test/native-http.ts | 484 ---- apps/backend/src/test/native-rpc.ts | 172 ++ .../receipts/ApprovalReceiptRow.test.ts | 2 +- .../components/receipts/ReceiptSubmitForm.tsx | 2 +- .../app/foldkit/content/bridge-route.test.ts | 2 +- apps/dashboard/app/foldkit/content/bridge.ts | 4 +- .../app/foldkit/content/browser-client.ts | 2 +- .../app/foldkit/content/command.test.ts | 6 +- apps/dashboard/app/foldkit/content/command.ts | 2 +- apps/dashboard/app/foldkit/content/message.ts | 4 +- apps/dashboard/app/foldkit/content/model.ts | 6 +- .../app/foldkit/content/update.test.ts | 6 +- apps/dashboard/app/foldkit/content/update.ts | 6 +- .../app/foldkit/content/view.test.ts | 6 +- apps/dashboard/app/foldkit/content/view.ts | 2 +- apps/dashboard/app/foldkit/dashboard/model.ts | 2 +- .../app/foldkit/dated-school-service/model.ts | 2 +- .../dashboard/app/foldkit/interview/bridge.ts | 4 +- .../foldkit/interview/browser-client.test.ts | 2 +- .../app/foldkit/interview/browser-client.ts | 2 +- .../app/foldkit/interview/command.ts | 4 +- .../app/foldkit/interview/message.ts | 2 +- apps/dashboard/app/foldkit/interview/model.ts | 2 +- .../foldkit/interview/update.property.test.ts | 2 +- .../dashboard/app/foldkit/interview/update.ts | 2 +- .../foldkit/organization/browser-client.ts | 2 +- .../app/foldkit/organization/delegations.ts | 2 +- .../app/foldkit/organization/management.ts | 2 +- .../app/foldkit/organization/model.ts | 2 +- .../app/foldkit/organization/update.test.ts | 2 +- .../app/foldkit/organization/view.test.ts | 2 +- .../app/foldkit/profile/command.test.ts | 2 +- apps/dashboard/app/foldkit/profile/message.ts | 2 +- apps/dashboard/app/foldkit/profile/model.ts | 2 +- .../recruitment-maintenance/command.ts | 2 +- .../recruitment-maintenance/message.ts | 2 +- .../foldkit/recruitment-maintenance/model.ts | 2 +- .../foldkit/recruitment-maintenance/update.ts | 2 +- .../foldkit/recruitment-maintenance/view.ts | 2 +- .../foldkit/recruitment/bridge-route.test.ts | 2 +- .../app/foldkit/recruitment/bridge.ts | 6 +- .../app/foldkit/recruitment/browser-client.ts | 2 +- .../app/foldkit/recruitment/elements.ts | 2 +- .../dashboard/app/foldkit/recruitment/main.ts | 2 +- .../app/foldkit/recruitment/message.ts | 4 +- .../app/foldkit/recruitment/model.ts | 6 +- .../app/foldkit/recruitment/update.test.ts | 4 +- .../app/foldkit/recruitment/update.ts | 2 +- .../dashboard/app/foldkit/recruitment/view.ts | 2 +- .../app/foldkit/scheduling/command.ts | 2 +- .../app/foldkit/scheduling/elements.ts | 2 +- apps/dashboard/app/foldkit/scheduling/main.ts | 2 +- .../app/foldkit/scheduling/message.ts | 6 +- .../dashboard/app/foldkit/scheduling/model.ts | 6 +- .../app/foldkit/scheduling/update.test.ts | 4 +- .../app/foldkit/scheduling/update.ts | 4 +- .../app/foldkit/scheduling/view.test.ts | 4 +- apps/dashboard/app/foldkit/scheduling/view.ts | 4 +- .../app/foldkit/schools/browser-client.ts | 4 +- apps/dashboard/app/foldkit/schools/command.ts | 4 +- .../app/foldkit/schools/management-view.ts | 2 +- apps/dashboard/app/foldkit/schools/message.ts | 4 +- apps/dashboard/app/foldkit/schools/model.ts | 4 +- .../app/foldkit/schools/update.test.ts | 4 +- apps/dashboard/app/foldkit/schools/update.ts | 4 +- .../app/foldkit/schools/view.test.ts | 4 +- apps/dashboard/app/foldkit/schools/view.ts | 4 +- .../app/foldkit/social-events/bridge.ts | 4 +- .../app/foldkit/social-events/message.ts | 2 +- .../app/foldkit/social-events/update.test.ts | 6 +- .../app/foldkit/social-events/view.ts | 3 +- .../team-applications/browser-client.ts | 2 +- .../app/foldkit/team-applications/command.ts | 2 +- .../app/foldkit/team-applications/elements.ts | 2 +- .../app/foldkit/team-applications/intake.ts | 2 +- .../app/foldkit/team-applications/main.ts | 2 +- .../app/foldkit/team-applications/message.ts | 2 +- .../app/foldkit/team-applications/model.ts | 2 +- .../foldkit/team-applications/paths.test.ts | 2 +- .../foldkit/team-applications/update.test.ts | 2 +- .../app/foldkit/team-applications/update.ts | 2 +- .../app/foldkit/team-applications/view.ts | 2 +- .../app/lib/admission-period-view.test.ts | 2 +- .../app/lib/admission-period-view.ts | 4 +- apps/dashboard/app/lib/api.server.ts | 63 +- apps/dashboard/app/lib/auth.server.test.ts | 2 +- apps/dashboard/app/lib/auth.server.ts | 2 +- .../app/lib/interview-bridge.server.test.ts | 2 +- .../app/lib/interview-bridge.server.ts | 4 +- apps/dashboard/app/lib/native-problem.test.ts | 2 +- apps/dashboard/app/lib/native-problem.ts | 2 +- .../app/lib/preview-role-override.ts | 2 +- apps/dashboard/app/lib/profile-view.test.ts | 2 +- apps/dashboard/app/lib/profile-view.ts | 2 +- apps/dashboard/app/lib/receipt-view.ts | 2 +- .../app/receipt-owner-intake-route.test.ts | 2 +- .../dashboard/app/routes/__foldkit.content.ts | 2 +- .../dashboard/app/routes/__foldkit.profile.ts | 2 +- .../app/routes/__foldkit.social-events.ts | 46 +- .../routes/dashboard.assistenter._index.tsx | 2 +- .../app/routes/dashboard.brukere._index.tsx | 2 +- .../routes/dashboard.epostliste._index.tsx | 2 +- .../routes/dashboard.intervjuer.rapport.tsx | 2 +- .../routes/dashboard.mine-utlegg._index.tsx | 4 +- .../app/routes/dashboard.onboarding.tsx | 4 +- .../app/routes/dashboard.soknad._index.tsx | 2 +- .../routes/dashboard.teaminteresse._index.tsx | 2 +- ...dashboard.tidligere-assistenter._index.tsx | 6 +- .../dashboard.utlegg.$receiptId.file.ts | 2 +- .../app/routes/dashboard.utlegg._index.tsx | 4 +- .../dashboard.utlegg.oppgjor.$receiptId.tsx | 2 +- .../dashboard.utlegg.oppgjor._index.tsx | 2 +- .../app/routes/dashboard.vikarer._index.tsx | 2 +- .../app/routes/dashboard_.foldkit.tsx | 2 +- .../dashboard/app/routes/konto-aktivering.tsx | 2 +- .../e2e/admission-period-management.spec.ts | 2 +- .../e2e/golden-reimbursement-browser.mjs | 2 +- .../e2e/golden-team-application-browser.ts | 2 +- .../e2e/native-content-publication.spec.ts | 2 +- .../e2e/native-identity-browser.spec.ts | 2 +- .../e2e/native-mailing-lists-journey.spec.ts | 2 +- apps/dashboard/e2e/native-operations.ts | 2 +- ...native-organization-administration.spec.ts | 2 +- ...tive-recruitment-interview-conduct.spec.ts | 2 +- .../e2e/native-schools-directory.spec.ts | 2 +- apps/dashboard/e2e/receipt-approval.spec.ts | 2 +- apps/dashboard/e2e/receipts.spec.ts | 2 +- .../run-real-admission-period-management.mjs | 2 +- .../e2e/run-real-interview-response.mjs | 2 +- .../e2e/run-real-native-identity-browser.mjs | 2 +- apps/dashboard/e2e/run-real-receipt-owner.mjs | 2 +- apps/dashboard/package.json | 3 +- apps/dashboard/test/native-http.ts | 2 +- .../e2e/public-applicant-admission.spec.ts | 2 +- .../run-real-public-applicant-admission.mjs | 2 +- apps/homepage/package.json | 3 +- apps/homepage/src/lib/api-types.ts | 2 +- .../src/lib/contact-context.server.ts | 2 +- .../src/lib/contact-message.server.ts | 2 +- apps/homepage/src/lib/news.server.ts | 2 +- .../src/lib/public-application-choices.ts | 2 +- apps/homepage/src/lib/public-application.ts | 2 +- .../src/lib/public-team-application.ts | 2 +- apps/homepage/test/contact-message.test.ts | 6 +- apps/homepage/test/news.test.ts | 4 +- apps/homepage/test/public-application.test.ts | 2 +- .../test/public-team-application.test.ts | 2 +- apps/homepage/test/team-directory.test.ts | 2 +- bun.lock | 219 +- docs/specs/rpc-only.md | 283 +++ oxlint.config.ts | 16 +- packages/http-api/scripts/generate-openapi.ts | 98 - packages/http-api/src/admission-outcomes.ts | 170 -- packages/http-api/src/admissions.ts | 350 --- packages/http-api/src/api.ts | 88 - packages/http-api/src/certificates.ts | 304 --- packages/http-api/src/common.ts | 222 -- packages/http-api/src/contact.ts | 106 - packages/http-api/src/content.ts | 372 --- packages/http-api/src/directory.ts | 260 -- packages/http-api/src/onboarding.ts | 159 -- packages/http-api/src/organization.ts | 596 ----- packages/http-api/src/placements.ts | 312 --- packages/http-api/src/profile.ts | 143 -- packages/http-api/src/receipt-upload.test.ts | 143 -- packages/http-api/src/receipt-upload.ts | 136 -- packages/http-api/src/receipts.ts | 724 ------ packages/http-api/src/recruitment.ts | 797 ------ packages/http-api/src/release.ts | 15 - packages/http-api/src/social-events.ts | 156 -- packages/http-api/src/system.ts | 237 -- packages/http-api/src/team-application.ts | 382 --- packages/http-api/test/native-api.test.ts | 1563 ------------ packages/http-api/test/receipts.test.ts | 33 - packages/{http-api => rpc}/AGENTS.md | 0 packages/{http-api => rpc}/CLAUDE.md | 0 packages/{http-api => rpc}/package.json | 9 +- packages/{http-api => rpc}/src/access.ts | 196 +- packages/rpc/src/admission-outcomes.ts | 11 + packages/rpc/src/admissions.ts | 11 + packages/rpc/src/api.ts | 54 + packages/rpc/src/boundary.ts | 17 + packages/rpc/src/certificates.ts | 11 + packages/rpc/src/client.ts | 47 + packages/rpc/src/contact.ts | 11 + packages/rpc/src/content.ts | 11 + packages/rpc/src/credential.ts | 54 + packages/rpc/src/directory.ts | 11 + .../src/endpoint-problems.ts | 2 +- packages/{http-api => rpc}/src/index.ts | 44 +- packages/rpc/src/onboarding.ts | 11 + packages/rpc/src/organization.ts | 11 + packages/rpc/src/placements.ts | 11 + .../http-semantics.ts => rpc/src/problem.ts} | 316 +-- packages/rpc/src/profile.ts | 11 + packages/rpc/src/receipts.ts | 14 + packages/rpc/src/recruitment.ts | 11 + packages/rpc/src/script-client.ts | 51 + packages/rpc/src/social-events.ts | 122 + packages/rpc/src/system.ts | 11 + packages/rpc/src/team-application.ts | 11 + packages/{http-api => rpc}/src/v2-schemas.ts | 55 +- packages/{http-api => rpc}/tsconfig.json | 0 packages/{http-api => rpc}/vitest.config.ts | 0 packages/sdk/AGENTS.md | 18 - packages/sdk/CHANGELOG.md | 13 - packages/sdk/CLAUDE.md | 1 - packages/sdk/package.json | 37 - packages/sdk/scripts/generate-operations.ts | 62 - .../__tests__/generated-native-client.test.ts | 348 --- packages/sdk/src/config.ts | 46 - packages/sdk/src/effect-client.ts | 108 - packages/sdk/src/env.d.ts | 4 - packages/sdk/src/index.ts | 13 - packages/sdk/src/promise.ts | 46 - packages/sdk/src/reflected-header-contract.ts | 34 - packages/sdk/tsconfig.json | 15 - packages/sdk/tsconfig.test.json | 10 - packages/sdk/vitest.config.ts | 4 - tools/conventions/src/constructs.ts | 6 +- tools/conventions/src/layout.ts | 7 +- tools/e2e/legacy-candidate-native-journey.ts | 2 +- tools/e2e/package.json | 2 +- tools/e2e/placement-check.ts | 2 +- .../organization-import-rehearsal-main.ts | 2 +- tools/verification/package.json | 3 +- .../receipt-reopen-observation.ts | 4 +- 330 files changed, 2444 insertions(+), 33848 deletions(-) delete mode 100644 apps/backend/src/admission/admission-period.http.test.ts delete mode 100644 apps/backend/src/admission/http-access.ts delete mode 100644 apps/backend/src/admission/http-commands.ts delete mode 100644 apps/backend/src/admission/http-context.ts delete mode 100644 apps/backend/src/admission/http-decode.ts delete mode 100644 apps/backend/src/admission/http-problem.ts delete mode 100644 apps/backend/src/admission/http-reads.ts delete mode 100644 apps/backend/src/admission/http-representation.ts delete mode 100644 apps/backend/src/admission/http.ts delete mode 100644 apps/backend/src/admission/outcome-http.ts create mode 100644 apps/backend/src/admission/outcome-rpc.ts create mode 100644 apps/backend/src/admission/rpc.ts delete mode 100644 apps/backend/src/admission/submit-application.http.test.ts delete mode 100644 apps/backend/src/contact/http.test.ts delete mode 100644 apps/backend/src/contact/http.ts create mode 100644 apps/backend/src/contact/rpc.ts delete mode 100644 apps/backend/src/content/http-access.ts delete mode 100644 apps/backend/src/content/http-commands.ts delete mode 100644 apps/backend/src/content/http-context.ts delete mode 100644 apps/backend/src/content/http-decode.ts delete mode 100644 apps/backend/src/content/http-problem.ts delete mode 100644 apps/backend/src/content/http-reads.ts delete mode 100644 apps/backend/src/content/http-representation.ts delete mode 100644 apps/backend/src/content/http.test.ts delete mode 100644 apps/backend/src/content/http.ts create mode 100644 apps/backend/src/content/rpc.ts delete mode 100644 apps/backend/src/directory/http.test.ts delete mode 100644 apps/backend/src/directory/http.ts create mode 100644 apps/backend/src/directory/rpc.ts delete mode 100644 apps/backend/src/http-api/system.ts delete mode 100644 apps/backend/src/http-api/transport.test.ts delete mode 100644 apps/backend/src/http-api/transport.ts delete mode 100644 apps/backend/src/native-api-preflight.ts delete mode 100644 apps/backend/src/onboarding/claim.http.test.ts delete mode 100644 apps/backend/src/onboarding/http.ts create mode 100644 apps/backend/src/onboarding/rpc.ts delete mode 100644 apps/backend/src/organization/http.test.ts delete mode 100644 apps/backend/src/organization/http.ts create mode 100644 apps/backend/src/organization/rpc.ts delete mode 100644 apps/backend/src/organization/team-interest.http.test.ts delete mode 100644 apps/backend/src/placements/certificates-http.test.ts delete mode 100644 apps/backend/src/placements/certificates-http.ts create mode 100644 apps/backend/src/placements/certificates-rpc.ts delete mode 100644 apps/backend/src/placements/http.test.ts delete mode 100644 apps/backend/src/placements/http.ts create mode 100644 apps/backend/src/placements/rpc.ts delete mode 100644 apps/backend/src/profile/http.test.ts delete mode 100644 apps/backend/src/profile/http.ts create mode 100644 apps/backend/src/profile/rpc.ts delete mode 100644 apps/backend/src/receipt/http-commands.ts delete mode 100644 apps/backend/src/receipt/http-context.ts delete mode 100644 apps/backend/src/receipt/http-decode.ts delete mode 100644 apps/backend/src/receipt/http-problem.ts delete mode 100644 apps/backend/src/receipt/http-reads.ts delete mode 100644 apps/backend/src/receipt/http-representation.ts delete mode 100644 apps/backend/src/receipt/http.test.ts delete mode 100644 apps/backend/src/receipt/http.ts create mode 100644 apps/backend/src/receipt/rpc.ts delete mode 100644 apps/backend/src/recruitment/commands.http.test.ts delete mode 100644 apps/backend/src/recruitment/http-access.ts delete mode 100644 apps/backend/src/recruitment/http-commands.ts delete mode 100644 apps/backend/src/recruitment/http-context.ts delete mode 100644 apps/backend/src/recruitment/http-decode.ts delete mode 100644 apps/backend/src/recruitment/http-problem.ts delete mode 100644 apps/backend/src/recruitment/http-reads.ts delete mode 100644 apps/backend/src/recruitment/http-representation.ts delete mode 100644 apps/backend/src/recruitment/http.test.ts delete mode 100644 apps/backend/src/recruitment/http.ts delete mode 100644 apps/backend/src/recruitment/maintenance-http.ts create mode 100644 apps/backend/src/recruitment/rpc.ts delete mode 100644 apps/backend/src/router.oauth.test.ts delete mode 100644 apps/backend/src/router.recruitment-authority.test.ts delete mode 100644 apps/backend/src/router.test.ts rename apps/backend/src/{http-api => rpc}/AGENTS.md (100%) rename apps/backend/src/{http-api => rpc}/CLAUDE.md (100%) create mode 100644 apps/backend/src/rpc/credential.ts create mode 100644 apps/backend/src/rpc/options.ts rename apps/backend/src/{http-api => rpc}/problem.ts (52%) rename apps/backend/src/{http-api => rpc}/public-rate-limit.ts (100%) rename apps/backend/src/{http-api => rpc}/read-json.ts (96%) rename apps/backend/src/{http-api => rpc}/receipt-transaction.test.ts (100%) rename apps/backend/src/{http-api => rpc}/receipt-transaction.ts (90%) create mode 100644 apps/backend/src/rpc/router.test.ts create mode 100644 apps/backend/src/rpc/system.ts delete mode 100644 apps/backend/src/schools/administration-http.ts delete mode 100644 apps/backend/src/schools/http.test.ts delete mode 100644 apps/backend/src/schools/http.ts delete mode 100644 apps/backend/src/social-events/http.ts create mode 100644 apps/backend/src/social-events/rpc.ts delete mode 100644 apps/backend/src/team-application/http.test.ts delete mode 100644 apps/backend/src/team-application/http.ts create mode 100644 apps/backend/src/team-application/rpc.ts delete mode 100644 apps/backend/src/test/native-http.ts create mode 100644 apps/backend/src/test/native-rpc.ts create mode 100644 docs/specs/rpc-only.md delete mode 100644 packages/http-api/scripts/generate-openapi.ts delete mode 100644 packages/http-api/src/admission-outcomes.ts delete mode 100644 packages/http-api/src/admissions.ts delete mode 100644 packages/http-api/src/api.ts delete mode 100644 packages/http-api/src/certificates.ts delete mode 100644 packages/http-api/src/common.ts delete mode 100644 packages/http-api/src/contact.ts delete mode 100644 packages/http-api/src/content.ts delete mode 100644 packages/http-api/src/directory.ts delete mode 100644 packages/http-api/src/onboarding.ts delete mode 100644 packages/http-api/src/organization.ts delete mode 100644 packages/http-api/src/placements.ts delete mode 100644 packages/http-api/src/profile.ts delete mode 100644 packages/http-api/src/receipt-upload.test.ts delete mode 100644 packages/http-api/src/receipt-upload.ts delete mode 100644 packages/http-api/src/receipts.ts delete mode 100644 packages/http-api/src/recruitment.ts delete mode 100644 packages/http-api/src/release.ts delete mode 100644 packages/http-api/src/social-events.ts delete mode 100644 packages/http-api/src/system.ts delete mode 100644 packages/http-api/src/team-application.ts delete mode 100644 packages/http-api/test/native-api.test.ts delete mode 100644 packages/http-api/test/receipts.test.ts rename packages/{http-api => rpc}/AGENTS.md (100%) rename packages/{http-api => rpc}/CLAUDE.md (100%) rename packages/{http-api => rpc}/package.json (77%) rename packages/{http-api => rpc}/src/access.ts (68%) create mode 100644 packages/rpc/src/admission-outcomes.ts create mode 100644 packages/rpc/src/admissions.ts create mode 100644 packages/rpc/src/api.ts create mode 100644 packages/rpc/src/boundary.ts create mode 100644 packages/rpc/src/certificates.ts create mode 100644 packages/rpc/src/client.ts create mode 100644 packages/rpc/src/contact.ts create mode 100644 packages/rpc/src/content.ts create mode 100644 packages/rpc/src/credential.ts create mode 100644 packages/rpc/src/directory.ts rename packages/{http-api => rpc}/src/endpoint-problems.ts (99%) rename packages/{http-api => rpc}/src/index.ts (74%) create mode 100644 packages/rpc/src/onboarding.ts create mode 100644 packages/rpc/src/organization.ts create mode 100644 packages/rpc/src/placements.ts rename packages/{http-api/src/http-semantics.ts => rpc/src/problem.ts} (82%) create mode 100644 packages/rpc/src/profile.ts create mode 100644 packages/rpc/src/receipts.ts create mode 100644 packages/rpc/src/recruitment.ts create mode 100644 packages/rpc/src/script-client.ts create mode 100644 packages/rpc/src/social-events.ts create mode 100644 packages/rpc/src/system.ts create mode 100644 packages/rpc/src/team-application.ts rename packages/{http-api => rpc}/src/v2-schemas.ts (88%) rename packages/{http-api => rpc}/tsconfig.json (100%) rename packages/{http-api => rpc}/vitest.config.ts (100%) delete mode 100644 packages/sdk/AGENTS.md delete mode 100644 packages/sdk/CHANGELOG.md delete mode 100644 packages/sdk/CLAUDE.md delete mode 100644 packages/sdk/package.json delete mode 100644 packages/sdk/scripts/generate-operations.ts delete mode 100644 packages/sdk/src/__tests__/generated-native-client.test.ts delete mode 100644 packages/sdk/src/config.ts delete mode 100644 packages/sdk/src/effect-client.ts delete mode 100644 packages/sdk/src/env.d.ts delete mode 100644 packages/sdk/src/index.ts delete mode 100644 packages/sdk/src/promise.ts delete mode 100644 packages/sdk/src/reflected-header-contract.ts delete mode 100644 packages/sdk/tsconfig.json delete mode 100644 packages/sdk/tsconfig.test.json delete mode 100644 packages/sdk/vitest.config.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 7dcb13d4..e9168e43 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/admission-period.http.test.ts b/apps/backend/src/admission/admission-period.http.test.ts deleted file mode 100644 index 031adb94..00000000 --- a/apps/backend/src/admission/admission-period.http.test.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { Database, IdentitySnapshot, type DatabaseOperations } from "@vektorprogrammet/database"; -import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; -import { OrganizationLive } from "@vektorprogrammet/database/organization"; -import { DatabaseRuntimeLive } from "@vektorprogrammet/database/runtime"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { PersonId } from "@vektorprogrammet/domain/organization"; -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"; -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"; - -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", -} as const; - -const departmentId = "department-periods"; - -const leaderPersonId = "periods-leader"; - -const adminPersonId = "periods-admin"; - -/** One open autumn period and an empty spring semester, a Styret leader, and a global administrator. */ -const seed = Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES (${departmentId}, 'Trondheim')`; - yield* sql` - INSERT INTO admission_period_semesters (semester_id, start_at, end_at) - VALUES - ('semester-autumn', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z'), - ('semester-spring', '2032-01-01T00:00:00.000Z', '2032-07-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-autumn', ${departmentId}, 'semester-autumn', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'period-autumn-created') - `; - yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city, independent) VALUES (${departmentId}, 'Trondheim', 'TRD', 'periods@example.invalid', 'Trondheim', TRUE)`; - // The department's board (Styret) of an independent department: its leader manages admission periods. - yield* sql`INSERT INTO organization_teams (team_id, department_id, name, kind) VALUES ('periods-team', ${departmentId}, 'Styret', 'DepartmentBoard')`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${leaderPersonId}, 'Lise', 'Leader'), (${adminPersonId}, 'Ada', 'Admin')`; - yield* sql`INSERT INTO organization_global_administrator_grants (grant_id, person_id, start_at) VALUES ('periods-admin-grant', ${adminPersonId}, '2020-01-01T00:00:00.000Z')`; - yield* sql` - INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, position_id, is_team_leader) - VALUES ('periods-leader-membership', ${leaderPersonId}, 'periods-team', '2020-01-01T00:00:00.000Z', 'leader', TRUE) - `; - }), -); - -/** 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 - : new IdentityActor({ - 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(new IdentitySessionNotFound()) : 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(new IdentitySessionNotFound()) : 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 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 }>` - SELECT count(*)::integer AS waiting - FROM pg_locks JOIN pg_stat_activity USING (pid) - WHERE NOT pg_locks.granted AND pg_stat_activity.datname = current_database() - `.pipe( - Effect.map((rows) => rows[0]?.waiting ?? 0), - Effect.repeat({ - until: (waiting) => waiting >= expected, - times: 400, - schedule: Schedule.spaced("10 millis"), - }), - ); - -/** - * 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* () { - const database = backendDatabase(seed); - - 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 connection = () => - DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); - - const http = makeBackendTestHttp( - yield* decodeBackendConfig(environment), - 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"); - } - - return http.fetch(new Request(`http://backend.test${path}`, { ...init, headers })); - }; - - /** - * Holds `lock` on a separate connection until `waiters` requests queue behind it, then - * commits without writing, so the queued requests continue in arrival order. - */ - const holdUntilQueued = ( - lock: (sql: DatabaseOperations) => Effect.Effect, - waiters: number, - ) => - Effect.gen(function* () { - const held = yield* Deferred.make(); - - const holder = yield* Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* lock(sql); - yield* Deferred.succeed(held, undefined); - - return yield* waitingRequests(sql, waiters); - }), - ), - ).pipe(Effect.provide(connection(), { local: true }), Effect.forkChild); - - yield* Deferred.await(held); - - return { released: Fiber.join(holder) }; - }); - - const count = (query: string) => - database.run( - Database.use((sql) => sql.unsafe<{ readonly count: number }>(query)).pipe( - Effect.map((rows) => rows[0]?.count ?? 0), - ), - ); - - return { manage, holdUntilQueued, count }; - }); - -const decodeStrict = >( - schema: S, - response: Response, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); - -describe("admission period management over HTTP and PostgreSQL", () => { - it.live("answers the loser of a concurrent revision with 412 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 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" }), - }); - - // Both revisions read revision 0 and pass If-Match before either may lock the row. - const { released } = yield* holdUntilQueued( - (sql) => - sql`SELECT 1 FROM public.admission_periods WHERE admission_period_id = 'period-autumn' FOR UPDATE`, - 2, - ); - - const responses = 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, - ]); - - const winner = responses.find((response) => response.status === 200)!; - const loser = responses.find((response) => response.status === 412)!; - - expect(yield* decodeStrict(AdmissionPeriodManagementItem, winner)).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'", - ), - ).toBe(1); - expect( - yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), - ).toBe(1); - expect( - yield* count("SELECT count(*)::integer AS count FROM native_http_idempotency_receipts"), - ).toBe(1); - }), - ); - - it.live("answers the loser of a concurrent create with 409 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, - }), - }); - - // Both creates read the spring semester without a period before either may write one. - const { released } = yield* holdUntilQueued( - (sql) => sql`LOCK TABLE public.admission_periods IN EXCLUSIVE MODE`, - 2, - ); - - const responses = 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({ - 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'", - ), - ).toBe(1); - }), - ); - - it.live("names the rejected members of a malformed create and patch", () => - Effect.gen(function* () { - const { manage, count } = yield* fixture(); - - const listed = yield* decodeStrict( - AdmissionPeriodManagementListResponse, - yield* manage("/api/admission-periods"), - ); - - 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 empty = yield* patch("emptyPatch", "{}"); - const deleted = yield* patch("deletingPatch", yield* jsonText({ endAt: null })); - const invalid = yield* patch("invalidPatch", yield* jsonText({ endAt: "soon" })); - - 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({ - code: "validation.no-change", - validation: { errors: [makeNativeValidationError("", "no-change")], truncated: false }, - }); - expect(yield* decodeStrict(AdmissionsReviseAdmissionPeriodProblem, 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); - }), - ); - - 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, - startAt: "2032-01-10T08:00:00.000Z", - endAt: "2032-02-01T20:00:00.000Z", - }), - }, - personId, - ); - - const unknownSemester = yield* create("unknownSemester", "semester-unknown"); - - // A global administrator acts in no department of its own, so the create must name one. - const withoutDepartment = yield* create( - "globalWithoutDepartment", - "semester-spring", - adminPersonId, - ); - - expect([unknownSemester.status, withoutDepartment.status]).toEqual([422, 422]); - expect( - yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, unknownSemester), - ).toMatchObject({ - code: "validation.failed", - validation: { - errors: [makeNativeValidationError("/semesterId", "invalid")], - truncated: false, - }, - }); - expect( - yield* decodeStrict(AdmissionsCreateAdmissionPeriodProblem, withoutDepartment), - ).toMatchObject({ - code: "validation.failed", - validation: { - errors: [makeNativeValidationError("/departmentId", "missing")], - truncated: false, - }, - }); - expect( - yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), - ).toBe(0); - }), - ); -}); diff --git a/apps/backend/src/admission/config.ts b/apps/backend/src/admission/config.ts index c08b54be..f4a73944 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/http-access.ts b/apps/backend/src/admission/http-access.ts deleted file mode 100644 index 225c2843..00000000 --- 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 b00a4dbb..00000000 --- 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 0319458c..00000000 --- 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 4084e10c..00000000 --- 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-problem.ts b/apps/backend/src/admission/http-problem.ts deleted file mode 100644 index e00fd6d4..00000000 --- a/apps/backend/src/admission/http-problem.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** Admission problems: the one declared answer for every admission failure. */ -import type { AdmissionPeriodFailure } from "@vektorprogrammet/domain/admission-period"; -import type { - PublicApplicationError, - ReturningAssistantError, -} from "@vektorprogrammet/domain/application"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { makeNativeValidationError, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import type { OrganizationResolutionError } from "../authority.js"; -import { - type CredentialCases, - type OutageCases, - personPresentation, - type ProblemCases, - type ProblemMapper, - problemMapper, - requestInvalid, -} from "../http-api/problem.js"; - -/** Public applications answer a spent rate limit with one fixed delay. */ -const RATE_LIMIT_RETRY_AFTER_SECONDS = 60; - -/** Every failure that an admission handler answers. */ -type AdmissionFailure = - | AdmissionPeriodFailure - | PublicApplicationError - | ReturningAssistantError - | IdentityEngineError - | OrganizationResolutionError; - -/** The unavailable problem of an admission endpoint: a read, a command, or a returning assistant. */ -type AdmissionUnavailable = - | "admissions.unavailable" - | "dependency.unavailable" - | "returning.unavailable"; - -/** 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. */ -type AdmissionOutageTag = - | "AdmissionPeriodPersistenceError" - | "PublicApplicationPersistenceError" - | "PublicApplicationQueryLimitExceeded" - | "IdentityEngineError" - | "OrganizationDecodeError" - | "OrganizationPersistenceError"; - -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. - AdmissionPeriodDecodeError: requestInvalid, - PublicApplicationDecodeError: requestInvalid, - ReturningAssistantDecodeError: requestInvalid, - // The frozen unions have no department or semester code; an unknown one is an invalid value. - DepartmentNotFound: () => - Problem.validation("validation.failed", [ - makeNativeValidationError("/departmentId", "invalid"), - ]), - SemesterNotFound: () => - Problem.validation("validation.failed", [makeNativeValidationError("/semesterId", "invalid")]), - DepartmentRequired: () => - Problem.validation("validation.failed", [ - makeNativeValidationError("/departmentId", "missing"), - ]), - AdmissionPeriodNotFound: () => Problem.make("admission-period.not-found"), - InvalidAdmissionPeriodWindow: () => Problem.make("admission-period.invalid-window"), - AdmissionWindowOutsideSemester: () => Problem.make("admission-period.invalid-window"), - AdmissionPeriodAlreadyExists: () => Problem.make("admission-period.already-exists"), - StaleAdmissionPeriodRevision: () => Problem.make("precondition.failed"), - DuplicateAdmissionPeriodCommandConflict: () => Problem.make("idempotency.digest-conflict"), - NoEligibleAdmissionPeriod: () => Problem.make("application.no-eligible-period"), - AmbiguousAdmissionPeriod: () => Problem.make("application.ambiguous-period"), - FieldOfStudyNotFound: () => Problem.make("application.invalid-field-of-study"), - FieldOfStudyInactive: () => Problem.make("application.invalid-field-of-study"), - FieldOfStudyDepartmentMismatch: () => Problem.make("application.invalid-field-of-study"), - DuplicatePublicApplication: () => Problem.make("application.duplicate"), - DuplicatePublicApplicationCommandConflict: () => Problem.make("idempotency.digest-conflict"), - PublicApplicationNotFound: () => Problem.make("application.not-found"), - RequestBodyTooLarge: () => Problem.make("request.too-large"), - PublicApplicationRateLimitExceeded: () => Problem.rateLimited(RATE_LIMIT_RETRY_AFTER_SECONDS), - ReturningAssistantIdentityMissing: () => Problem.make("returning.identity-missing"), - ReturningAssistantIdentityAmbiguous: () => Problem.make("returning.identity-ambiguous"), - ReturningAssistantHistoryMissing: () => Problem.make("returning.history-missing"), - ReturningAssistantStudyMappingInvalid: () => Problem.make("returning.study-invalid"), - ReturningAssistantPeriodUnavailable: () => Problem.make("returning.period-unavailable"), - ReturningAssistantDuplicate: () => Problem.make("returning.period-unavailable"), - ReturningAssistantTeamScopeDenied: () => Problem.make("returning.team-scope-denied"), - ReturningAssistantRevisionConflict: () => Problem.make("returning.revision-conflict"), - ReturningAssistantCommandConflict: () => Problem.make("idempotency.digest-conflict"), - ReturningAssistantPersistenceError: () => Problem.make("returning.unavailable"), -} satisfies ProblemCases< - Exclude ->; - -/** The cases of `admissionProblems`, which answer an outage with `Unavailable`. */ -type AdmissionCases = typeof admissionFixedCases & - CredentialCases & - OutageCases; - -/** - * 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. - * - * @sideEffects none - * - * @example - * ```ts - * command.pipe(admissionProblems(request, "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. - * - * @construct http-problem - */ -export const admissionProblems = ( - request: Request, - unavailable: Unavailable, -): ProblemMapper> => { - const presentation = personPresentation(request); - - 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. - */ -export const submissionProblems = [ - "validation.failed", - "application.no-eligible-period", - "application.ambiguous-period", - "application.invalid-field-of-study", - "application.duplicate", - "idempotency.digest-conflict", - "request.too-large", - "rate-limit.exceeded", -] 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. - */ -export const periodCommandProblems = [ - "validation.failed", - "admission-period.not-found", - "admission-period.invalid-window", - "admission-period.already-exists", - "idempotency.digest-conflict", -] as const; diff --git a/apps/backend/src/admission/http-reads.ts b/apps/backend/src/admission/http-reads.ts deleted file mode 100644 index 4d2e1d82..00000000 --- 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 96784cd1..00000000 --- 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 78d4c304..00000000 --- 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 301063ab..00000000 --- a/apps/backend/src/admission/outcome-http.ts +++ /dev/null @@ -1,368 +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 { requireDepartmentReach, 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); - - // 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( - request, - RecordAdmissionOutcomeEndpoint, - selected.departmentId, - false, - 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, - decider, - 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 00000000..5df7fb4d --- /dev/null +++ b/apps/backend/src/admission/outcome-rpc.ts @@ -0,0 +1,9 @@ +/** + * The AdmissionOutcomesRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/admission/outcome-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { AdmissionOutcomesRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const AdmissionOutcomesRpcHandlers = (_options: NativeRpcOptions) => + AdmissionOutcomesRpcs.toLayer({}); diff --git a/apps/backend/src/admission/rpc.ts b/apps/backend/src/admission/rpc.ts new file mode 100644 index 00000000..bc3ae6b3 --- /dev/null +++ b/apps/backend/src/admission/rpc.ts @@ -0,0 +1,8 @@ +/** + * The AdmissionsRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/admission/http*.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { AdmissionsRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const AdmissionsRpcHandlers = (_options: NativeRpcOptions) => AdmissionsRpcs.toLayer({}); 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 4a7fe296..00000000 --- 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/config.ts b/apps/backend/src/config.ts index 19952e99..d28cdb72 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, diff --git a/apps/backend/src/contact/http.test.ts b/apps/backend/src/contact/http.test.ts deleted file mode 100644 index 3ce8f7fc..00000000 --- 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 194f016c..00000000 --- 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.ts b/apps/backend/src/contact/rpc.ts new file mode 100644 index 00000000..0461bf35 --- /dev/null +++ b/apps/backend/src/contact/rpc.ts @@ -0,0 +1,8 @@ +/** + * The ContactRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/contact/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { ContactRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const ContactRpcHandlers = (_options: NativeRpcOptions) => ContactRpcs.toLayer({}); diff --git a/apps/backend/src/content/http-access.ts b/apps/backend/src/content/http-access.ts deleted file mode 100644 index 12fb5114..00000000 --- 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 4d4459e5..00000000 --- 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 a566cdcf..00000000 --- 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 a7cb879b..00000000 --- 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-problem.ts b/apps/backend/src/content/http-problem.ts deleted file mode 100644 index 5c95ed1d..00000000 --- a/apps/backend/src/content/http-problem.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** Content failures and the problem each one answers. */ -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import type { - ArticleNotFound, - ContentManagementFailure, - ContentUnauthenticatedActor, -} from "@vektorprogrammet/domain/content"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { - OrganizationDecodeError, - OrganizationPersistenceError, -} from "@vektorprogrammet/domain/organization"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { - type CredentialCases, - type ProblemCases, - type ProblemMapper, - problemMapper, -} from "../http-api/problem.js"; - -/** Every content failure that a content handler answers, but a rejected credential. */ -type ContentFailure = - | Exclude - | ArticleNotFound; - -const contentCases = { - AuthorityInactive: () => Problem.make("authority.denied"), - NotInScope: () => Problem.make("authority.denied"), - NotPublisher: () => Problem.make("authority.denied"), - DraftNotOwned: () => Problem.make("authority.denied"), - ArticleNotFound: () => Problem.make("content.article-not-found"), - SlugConflict: () => Problem.make("content.slug-conflict"), - DepartmentNotFound: () => Problem.make("content.department-not-found"), - CommandConflict: () => Problem.make("content.lifecycle-conflict"), - ContentIntegrityError: () => Problem.make("content.integrity-error"), - ContentPersistenceError: () => Problem.make("content.unavailable"), - ContentDecodeError: () => Problem.make("internal.error"), -} satisfies ProblemCases; - -/** - * The one answer for every content domain failure. - * - * @remarks - * An inactive authority, a department outside the actor's scope, a missing publisher role, and - * another author's draft answer authority.denied. The article, slug, department, and lifecycle - * failures answer their content problems, an unavailable store answers content.unavailable, and a - * stored value that does not decode answers internal.error. - * - * @sideEffects none - * - * @example - * ```ts - * read.pipe(contentProblems, contentActorProblems(presentation)); - * ``` - * - * @avoid Mapping a content failure in a handler: the content endpoints then answer one failure - * differently. Pipe the handler's effect through this. - * - * @construct http-problem - */ -export const contentProblems: ProblemMapper = - problemMapper()(contentCases); - -/** A staff person whom ingress admitted, rejected by the content actor resolution. */ -type ContentActorFailure = - | UnauthenticatedActor - | ContentUnauthenticatedActor - | IdentityEngineError - | OrganizationDecodeError - | OrganizationPersistenceError; - -const contentActorFixedCases = { - IdentityEngineError: () => Problem.make("internal.error"), - OrganizationDecodeError: () => Problem.make("internal.error"), - OrganizationPersistenceError: () => Problem.make("internal.error"), -} satisfies ProblemCases>; - -/** The cases of `contentActorProblems`. */ -type ContentActorCases = typeof contentActorFixedCases & CredentialCases<"UnauthenticatedActor">; - -/** - * A staff person rejected after ingress is answered from the credential the request presented. - * - * @remarks - * A rejected staff person answers `Problem.unauthenticated(presentation)`: credential.missing or - * credential.invalid, as the request's evidence says. An unavailable identity or organization - * projection answers internal.error. - * - * @sideEffects none - * - * @example - * ```ts - * read.pipe(contentProblems, contentActorProblems(presentation)); - * ``` - * - * @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 - */ -export const contentActorProblems = ( - presentation: CredentialPresentation, -): ProblemMapper => - problemMapper()({ - ...contentActorFixedCases, - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - }); diff --git a/apps/backend/src/content/http-reads.ts b/apps/backend/src/content/http-reads.ts deleted file mode 100644 index 75c5ff66..00000000 --- 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 ebb3d48c..00000000 --- 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 5425238d..00000000 --- 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 e6088593..00000000 --- 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/rpc.ts b/apps/backend/src/content/rpc.ts new file mode 100644 index 00000000..cc5066fd --- /dev/null +++ b/apps/backend/src/content/rpc.ts @@ -0,0 +1,8 @@ +/** + * The ContentRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/content/http*.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { ContentRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const ContentRpcHandlers = (_options: NativeRpcOptions) => ContentRpcs.toLayer({}); diff --git a/apps/backend/src/directory/http.test.ts b/apps/backend/src/directory/http.test.ts deleted file mode 100644 index 23da446d..00000000 --- a/apps/backend/src/directory/http.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { PeopleDirectoryResponse } from "@vektorprogrammet/http-api"; -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; - -import { SocialEvents } from "@vektorprogrammet/domain/social-events"; -import { - MembershipId, - TeamId, - DepartmentId, - Organization, - PersonId, - accumulateOrganizationDirectoryFacts, - type OrganizationDirectoryFacts, - type OrganizationOperations, -} from "@vektorprogrammet/domain/organization"; -import { - decodeDirectoryCursor, - encodeDirectoryCursor, - PersonContactProfile, - PersonProfile, - Profile, - ProfileContactNotFound, - type DirectoryEntry, - type ProfileOperations, -} 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"; - -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"); - -const departmentB = DepartmentId.make("department-b"); - -interface SeededMembership { - readonly personId: string; - readonly departmentId: DepartmentId; - /** Membership interval covers the captured instant when true. */ - readonly active: boolean; - /** Leads the board of the (independent) department. */ - readonly boardLeader: boolean; -} - -interface SeededGrant { - readonly personId: string; - readonly status: "Active" | "Inactive"; -} - -/** Canonical directory population shared by every scenario unless replaced. */ -const directoryPeople = [ - { - personId: PersonId.make("person-leader-a"), - firstName: "Active", - lastName: "Leader", - email: "leader-a@example.invalid", - phone: "90000001", - }, - { - personId: PersonId.make("person-multi-department"), - firstName: "Multi", - lastName: "Department", - email: "multi@example.invalid", - phone: "90000002", - }, - { - personId: PersonId.make("person-ended-membership"), - firstName: "Ended", - lastName: "Membership", - email: "ended@example.invalid", - phone: "90000003", - }, -]; - -let people: Array<{ - personId: string; - firstName: string; - lastName: string; - email: string; - phone: string; -}> = []; - -let membershipsByPerson = new Map>([]); - -let grantsByPerson: Record = {}; - -let missingContactFor: string | undefined; - -let callerProjection: { - globalAdministrator: "Active" | "Inactive" | "Absent"; -} | null = null; - -const database = backendDatabase(); - -/** Profile stub: canonical names joined to canonical contacts, paged. */ -const profile: ProfileOperations = { - readProfiles: (personIds) => - Effect.succeed( - personIds.map( - (personId) => - new PersonProfile({ 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, - }), - ), - ), - readOwnProfile: (personId) => - Effect.succeed({ - personId, - firstName: "First", - lastName: "Last", - email: `${personId}@example.invalid`, - phone: "90000000", - nameRevision: 0, - contactRevision: 0, - }), - updateOwnProfile: () => Effect.die("unexpected updateOwnProfile"), - readDirectoryPage: ({ limit, cursor }) => - Effect.gen(function* () { - const sorted = [...people].sort((left, right) => { - const byLastName = left.lastName.localeCompare(right.lastName); - - if (byLastName !== 0) return byLastName; - const byFirstName = left.firstName.localeCompare(right.firstName); - - if (byFirstName !== 0) return byFirstName; - - return left.personId.localeCompare(right.personId); - }); - - let offset = 0; - - if (cursor !== undefined) { - const tuple = yield* decodeDirectoryCursor(cursor); - - const found = sorted.findIndex( - (person) => - person.lastName === tuple.lastName && - person.firstName === tuple.firstName && - person.personId === tuple.personId, - ); - - if (found < 0) - return yield* new ProfileContactNotFound({ personId: PersonId.make(tuple.personId) }); - offset = found + 1; - } - - const page = sorted.slice(offset, offset + limit); - const entries: Array = []; - - for (const person of page) { - if (missingContactFor === person.personId || !person.email) { - return yield* new ProfileContactNotFound({ - personId: PersonId.make(person.personId), - }); - } - - entries.push({ - personId: PersonId.make(person.personId), - firstName: person.firstName, - lastName: person.lastName, - email: person.email, - phone: person.phone, - }); - } - - const last = entries[entries.length - 1]; - - return { - entries, - nextCursor: - last !== undefined && offset + entries.length < sorted.length - ? encodeDirectoryCursor(last) - : undefined, - }; - }).pipe(Effect.mapError((cause) => cause)), -}; - -/** Organization stub: caller projection plus the frozen membership law. */ -const organization = { - listDepartments: Effect.succeed([]), - listTeams: () => Effect.succeed([]), - listFieldOfStudies: Effect.succeed([]), - resolvePersonAuthority: () => { - if (callerProjection === null) throw new Error("no caller projection configured"); - - return Effect.succeed({ - personId: PersonId.make("person-caller"), - evaluatedAt: instant, - globalAdministrator: callerProjection.globalAdministrator, - memberships: (membershipsByPerson.get("person-caller") ?? []).map((seed) => ({ - membershipId: MembershipId.make(`membership-${seed.departmentId}-${seed.personId}`), - teamId: TeamId.make( - seed.boardLeader ? `styret-${seed.departmentId}` : `team-${seed.departmentId}`, - ), - departmentId: DepartmentId.make(seed.departmentId), - active: seed.active, - unitLeader: seed.boardLeader, - unitKind: seed.boardLeader ? "DepartmentBoard" : "Team", - teamScope: "HomeDepartment", - departmentIndependent: true, - })), - nationalBoardSeats: [], - delegations: [], - }); - }, - deriveDirectoryFacts: ( - personIds: ReadonlyArray, - evaluatedAt: string, - ): Effect.Effect => - Effect.sync(() => { - const memberships = personIds.flatMap((personId) => { - const seeds = membershipsByPerson.get(personId) ?? []; - - return seeds.map((seed) => ({ - personId: PersonId.make(personId), - departmentId: DepartmentId.make(seed.departmentId), - // The stub resolves canonical names the way the PostgreSQL - // interpreter's team->department join does. - departmentName: `Name of ${seed.departmentId}`, - active: seed.active, - })); - }); - - const grants = personIds.flatMap((personId) => { - const grant = grantsByPerson[personId]; - - return grant - ? [{ personId: PersonId.make(personId), globalAdministrator: grant.status }] - : []; - }); - - const facts = new Map( - accumulateOrganizationDirectoryFacts({ - personIds: personIds.map((personId) => PersonId.make(personId)), - instant: evaluatedAt, - memberships, - grants, - }), - ); - - for (const personId of personIds) { - if (!facts.has(PersonId.make(personId))) { - facts.set(PersonId.make(personId), { - departments: [], - departmentNames: [], - isActive: false, - globalAdministrator: "Absent", - }); - } - } - - return facts; - }), -} satisfies Partial; - -const resetScenario = () => { - people = [...directoryPeople]; - membershipsByPerson = new Map>([ - [ - "person-leader-a", - [ - { - personId: PersonId.make("person-leader-a"), - departmentId: DepartmentId.make(departmentA), - active: true, - boardLeader: true, - }, - ], - ], - [ - "person-multi-department", - [ - { - personId: PersonId.make("person-multi-department"), - departmentId: DepartmentId.make(departmentA), - active: true, - boardLeader: false, - }, - { - personId: PersonId.make("person-multi-department"), - departmentId: DepartmentId.make(departmentB), - active: true, - boardLeader: false, - }, - ], - ], - [ - "person-ended-membership", - [ - { - personId: PersonId.make("person-ended-membership"), - departmentId: DepartmentId.make(departmentA), - active: false, - boardLeader: false, - }, - ], - ], - ]); - grantsByPerson = {}; - missingContactFor = undefined; - // Caller defaults to an active global administrator viewing everything. - callerProjection = { globalAdministrator: "Active" }; -}; - -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"), -}); - -const identity = Identity.of({ - signIn: () => Effect.die("unexpected sign-in"), - resolveSession: (cookieHeader: string | undefined) => - cookieHeader !== undefined && cookieHeader.includes(`${token}=`) - ? Effect.succeed( - new IdentityActor({ - personId: PersonId.make("person-caller"), - sessionId: "session-1", - expiresAt: DateTime.makeUnsafe("2031-09-16T00: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.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 request = (): Effect.Effect => - backend.fetch( - new Request("http://backend.test/api/people", { - headers: { cookie: `${token}=value` }, - }), - ); - -describe("GET /api/people (spec 0077.2)", () => { - it.live("answers 401 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({ - type: "urn:vektorprogrammet:problem:v0.2:credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - code: "credential.missing", - }); - }), - ); - - it.live("denies a plain member with typed 403 AuthorityInactive", () => - Effect.gen(function* () { - resetScenario(); - callerProjection = { globalAdministrator: "Absent" }; - membershipsByPerson.set("person-caller", [ - { - personId: PersonId.make("person-caller"), - departmentId: DepartmentId.make(departmentA), - active: true, - 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", - }); - }), - ); - - it.live("denies an inactive leader with typed 403 AuthorityInactive", () => - Effect.gen(function* () { - resetScenario(); - callerProjection = { globalAdministrator: "Absent" }; - membershipsByPerson.set("person-caller", [ - { - personId: PersonId.make("person-caller"), - departmentId: DepartmentId.make(departmentA), - active: false, - 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", - }); - }), - ); - - it.live("denies an inactive administrator with typed 403 AuthorityInactive", () => - 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", - }); - }), - ); - - 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()), - ); - - expect(body.activePeople.map((row) => row.personId)).toEqual([ - "person-multi-department", - "person-leader-a", - ]); - 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. - expect(multi?.departments).toEqual(["Name of department-a", "Name of department-b"]); - - for (const row of [...body.activePeople, ...body.inactivePeople]) { - expect(Object.keys(row).sort()).toEqual([ - "departments", - "email", - "firstName", - "isActive", - "lastName", - "personId", - "phone", - "studyProgramme", - ]); - expect(row.studyProgramme).toBeNull(); - } - }), - ); - - it.live("denies a caller with no Organization record with typed 403 NotInScope", () => - Effect.gen(function* () { - resetScenario(); - // Absent grant plus no memberships at all: NotInScope, never a 401. - 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", - }); - }), - ); - - it.live("scopes a department leader to the intersection of their leader departments", () => - Effect.gen(function* () { - resetScenario(); - callerProjection = { globalAdministrator: "Absent" }; - membershipsByPerson.set("person-caller", [ - { - personId: PersonId.make("person-caller"), - departmentId: DepartmentId.make(departmentB), - active: true, - boardLeader: true, - }, - ]); - const response = yield* request(); - expect(response.status).toBe(200); - - const body = yield* Schema.decodeUnknownEffect(PeopleDirectoryResponse)( - yield* Effect.promise(() => response.json()), - ); - - // Only the multi-department person touches department B. - expect(body.activePeople.map((row) => row.personId)).toEqual(["person-multi-department"]); - expect(body.inactivePeople).toEqual([]); - }), - ); - - it.live("returns a legitimate 200 with empty arrays when nothing intersects", () => - Effect.gen(function* () { - resetScenario(); - callerProjection = { globalAdministrator: "Absent" }; - membershipsByPerson.set("person-caller", [ - { - personId: PersonId.make("person-caller"), - departmentId: DepartmentId.make("department-empty"), - active: true, - boardLeader: true, - }, - ]); - people = []; - const response = yield* request(); - expect(response.status).toBe(200); - expect(yield* Effect.promise(() => response.json())).toEqual({ - activePeople: [], - inactivePeople: [], - nextCursor: null, - }); - }), - ); - - it.live("walks every page until exhaustion without duplicating or dropping a person", () => - Effect.gen(function* () { - resetScenario(); - people = Array.from({ length: 205 }, (_, index) => ({ - personId: PersonId.make(`person-bulk-${String(index + 1).padStart(4, "0")}`), - firstName: "Bulk", - lastName: `Family${String(index % 7)}`, - email: `bulk-${index + 1}@example.invalid`, - phone: "90000000", - })); - 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 ids = [...body.activePeople, ...body.inactivePeople].map((row) => row.personId); - expect(new Set(ids).size).toBe(205); - expect(ids.length).toBe(205); - }), - ); - - it.live("fails 503 when a scanned person has no contact row instead of dropping them", () => - Effect.gen(function* () { - resetScenario(); - missingContactFor = "person-multi-department"; - const response = yield* request(); - expect(response.status).toBe(503); - expect(yield* Effect.promise(() => response.json())).toEqual({ - type: "urn:vektorprogrammet:problem:v0.2:directory.unavailable", - title: "Directory unavailable", - status: 503, - detail: "The directory service is temporarily unavailable.", - code: "directory.unavailable", - }); - }), - ); - - 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/http.ts b/apps/backend/src/directory/http.ts deleted file mode 100644 index 9eb2abff..00000000 --- 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/rpc.ts b/apps/backend/src/directory/rpc.ts new file mode 100644 index 00000000..5aacd9df --- /dev/null +++ b/apps/backend/src/directory/rpc.ts @@ -0,0 +1,8 @@ +/** + * The DirectoryRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/directory/http.ts, schools/http.ts, and schools/administration-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { DirectoryRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const DirectoryRpcHandlers = (_options: NativeRpcOptions) => DirectoryRpcs.toLayer({}); diff --git a/apps/backend/src/http-api/system.ts b/apps/backend/src/http-api/system.ts deleted file mode 100644 index f8124b0b..00000000 --- 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 132e6952..00000000 --- a/apps/backend/src/http-api/transport.test.ts +++ /dev/null @@ -1,255 +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, - 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 83f69fc0..00000000 --- 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 28e83252..9fda8f81 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 { diff --git a/apps/backend/src/http-semantics.ts b/apps/backend/src/http-semantics.ts index e9ea5a4d..d10760ce 100644 --- a/apps/backend/src/http-semantics.ts +++ b/apps/backend/src/http-semantics.ts @@ -12,7 +12,7 @@ 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"; const encoder = new TextEncoder(); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 57aecaea..9bfcda28 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"; +export * from "./recruitment/config.js"; \ No newline at end of file diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 1e6ec285..2fe0e234 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,17 @@ 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 f5110de2..00000000 --- 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/onboarding/claim.http.test.ts b/apps/backend/src/onboarding/claim.http.test.ts deleted file mode 100644 index 598c7331..00000000 --- a/apps/backend/src/onboarding/claim.http.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -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 { IdentityActor, IdentitySessionNotFound } from "@vektorprogrammet/domain/identity"; -import { OnboardingClaim, OnboardingClaimResult } from "@vektorprogrammet/domain/onboarding"; -import { 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({ - 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"), - }), - ); - } - }), -); - -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/http.ts b/apps/backend/src/onboarding/http.ts deleted file mode 100644 index 6668e4e3..00000000 --- a/apps/backend/src/onboarding/http.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { - credentialMatchesAccessSpec, - requireDepartmentReach, - 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 }); - - const coordinator = yield* Effect.fromResult( - requireDepartmentReach(auth.authority, "admissions.outcomes", departmentId), - ).pipe(Effect.mapError(() => 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, 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 = (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({ - coordinator: auth.coordinator, - command: selected, - 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 00000000..0b61f979 --- /dev/null +++ b/apps/backend/src/onboarding/rpc.ts @@ -0,0 +1,8 @@ +/** + * The OnboardingRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/onboarding/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { OnboardingRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const OnboardingRpcHandlers = (_options: NativeRpcOptions) => OnboardingRpcs.toLayer({}); diff --git a/apps/backend/src/organization/http.test.ts b/apps/backend/src/organization/http.test.ts deleted file mode 100644 index e7078d94..00000000 --- a/apps/backend/src/organization/http.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -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, - OrganizationCommandConflict, - OrganizationPersistenceError, - OrganizationInvalidReference, -} from "@vektorprogrammet/domain/organization"; -import { makeNativeValidationError } from "@vektorprogrammet/http-api/http-semantics"; -import { 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], - _administrator: Parameters[1], - ) => { - createCalls += 1; - - 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, - 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 2871d8af..00000000 --- a/apps/backend/src/organization/http.ts +++ /dev/null @@ -1,1226 +0,0 @@ -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - reachedDepartments, - ReachedDepartments, - 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, - mapOrganizationAuthorityToOrganizationActor, - requireTeamInterestScope, - requireOrganizationAdministrator, - SemesterId, - type TeamId, - TeamJsonSchema, - type OrganizationCommandFailure, - type OrganizationLifecycleFailure, - type OrganizationPersonAuthority, -} 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, Result, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { - 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 -> 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 personId = resolved.authority.personId; - const administrator = requireOrganizationAdministrator(resolved.authority); - - // The AccessSpec answers a denial, with its concealment; the evidence is what the command takes. - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(input.endpoint)), - 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, - }, - input.presentation, - ); - - const evidence = yield* Effect.fromResult(administrator); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: input.operationId, - normalizedTarget: input.target, - idempotencyKey: input.idempotencyKey, - }); - - return { administrator: evidence, 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 { administrator, 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, - }), - administrator, - ), - ).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 { administrator, 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, - }), - administrator, - ), - ).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 { administrator, 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, - }), - administrator, - ), - ).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); - const departments = yield* Organization.use(({ listDepartments }) => listDepartments); - - // 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* Effect.fromResult( - requireTeamInterestScope(authority, { - requested, - departments: departments.map((department) => department.departmentId), - }), - ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); - - yield* authorizeOrganizationCollection({ - request, - authority, - endpoint: ListTeamInterestEndpoint, - departmentIds: scope.departmentIds, - teams: scope.teams, - presentation, - }); - - // Unknown department reference denies with 422 before any data leaves the store. - if (requested !== undefined) yield* assertDepartmentsExist([requested]); - - const semesterId = yield* optionalQueryIdentity(request, "semester", SemesterId); - - const rows = yield* Organization.use(({ listTeamInterestRegistrations }) => - listTeamInterestRegistrations(scope, semesterId), - ); - - 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.ts b/apps/backend/src/organization/rpc.ts new file mode 100644 index 00000000..e582c3cc --- /dev/null +++ b/apps/backend/src/organization/rpc.ts @@ -0,0 +1,8 @@ +/** + * The OrganizationRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/organization/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { OrganizationRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const OrganizationRpcHandlers = (_options: NativeRpcOptions) => OrganizationRpcs.toLayer({}); diff --git a/apps/backend/src/organization/team-interest.http.test.ts b/apps/backend/src/organization/team-interest.http.test.ts deleted file mode 100644 index 09a8b826..00000000 --- a/apps/backend/src/organization/team-interest.http.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { - DepartmentId, - TeamId, - SemesterId, - MembershipId, - DepartmentJsonSchema, - Organization, - PersonId, - type OrganizationOperations, - type TeamInterestReadScope, -} from "@vektorprogrammet/domain/organization"; -import { DateTime, Effect, Layer, Schema } from "effect"; -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"; - -/** - * Specs 0059/0060 gate matrix and wire shapes, driven through the backend - * authority flow: cookie -> resolveAuthority (one instant) -> leader scope. - */ - -const department = Schema.decodeSync(DepartmentJsonSchema)( - { - departmentId: DepartmentId.make("department-1"), - name: "Department One", - shortName: "ONE", - email: "one@example.invalid", - address: null, - city: "Trondheim", - latitude: null, - longitude: null, - slackChannel: null, - logoPath: null, - active: true, - revision: 0, - }, - { onExcessProperty: "error" }, -); - -const secondDepartment = Schema.decodeSync(DepartmentJsonSchema)( - { - departmentId: DepartmentId.make("department-2"), - name: "Department Two", - shortName: "TWO", - email: "two@example.invalid", - address: null, - city: "Bergen", - latitude: null, - longitude: null, - slackChannel: null, - logoPath: null, - active: true, - revision: 0, - }, - { onExcessProperty: "error" }, -); - -const registrationRows = [ - { - registrationId: 2, - submitterName: "User B", - submitterEmail: "b@example.invalid", - teamId: TeamId.make("team-1"), - teamName: "Team One", - departmentId: DepartmentId.make("department-1"), - semesterId: null, - submittedAt: "2031-09-15T10:00:00.000Z", - revision: 0, - }, - { - registrationId: 1, - submitterName: "User A", - submitterEmail: "a@example.invalid", - teamId: TeamId.make("team-1"), - teamName: "Team One", - departmentId: DepartmentId.make("department-1"), - semesterId: SemesterId.make("semester-host"), - submittedAt: "2031-09-14T10:00:00.000Z", - revision: 3, - }, - { - registrationId: 3, - submitterName: "User C", - submitterEmail: "c@example.invalid", - teamId: TeamId.make("team-2"), - teamName: "Team Two", - departmentId: DepartmentId.make("department-2"), - semesterId: null, - submittedAt: "2031-09-16T10:00:00.000Z", - revision: 0, - }, -] as const; - -// 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: (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; - -type TokenMembership = { - teamId: TeamId; - departmentId: DepartmentId; - active: boolean; - /** Leads the unit; a board of an independent department reaches the department. */ - leader: boolean; - unitKind: "Team" | "DepartmentBoard"; -}; - -type AuthorityByToken = { - globalAdministrator: "Active" | "Inactive" | "Absent"; - memberships: ReadonlyArray; -}; - -const boardOne = TeamId.make("styret-1"); - -const teamOne = TeamId.make("team-1"); - -const teamTwo = TeamId.make("team-2"); - -const departmentOne = DepartmentId.make("department-1"); - -const departmentTwo = DepartmentId.make("department-2"); - -const authorityForToken = (cookie: string | null): AuthorityByToken => { - if (cookie?.includes("admin-session")) { - return { - globalAdministrator: "Active", - memberships: [ - { - teamId: teamOne, - departmentId: departmentOne, - active: true, - leader: false, - unitKind: "Team", - }, - ], - }; - } - - if (cookie?.includes("leader-session")) { - return { - globalAdministrator: "Absent", - memberships: [ - { - teamId: boardOne, - departmentId: departmentOne, - active: true, - leader: true, - unitKind: "DepartmentBoard", - }, - { - teamId: teamTwo, - departmentId: departmentTwo, - active: true, - leader: false, - unitKind: "Team", - }, - ], - }; - } - - if (cookie?.includes("team-captain")) { - return { - globalAdministrator: "Absent", - memberships: [ - { - teamId: teamTwo, - departmentId: departmentTwo, - active: true, - leader: true, - unitKind: "Team", - }, - ], - }; - } - - if (cookie?.includes("inactive-leader")) { - return { - globalAdministrator: "Absent", - memberships: [ - { - teamId: boardOne, - departmentId: departmentOne, - active: false, - leader: true, - unitKind: "DepartmentBoard", - }, - ], - }; - } - - return { - globalAdministrator: "Absent", - memberships: [ - { - teamId: teamOne, - departmentId: departmentOne, - active: true, - leader: false, - unitKind: "Team", - }, - ], - }; -}; - -const config = decodeOrganizationApiConfig({ - ORGANIZATION_MAX_BODY_BYTES: "1024", -}); - -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 === undefined || cookieHeader.length === 0 - ? Effect.fail(new IdentitySessionNotFound()) - : Effect.succeed( - new IdentityActor({ - personId: PersonId.make("team-interest-person"), - 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"), - 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( - Layer.mock(Organization, organization), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), -); - -const http = makeOrganizationApiHttp( - { - config, - 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 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=/, "")}` }, - }), - ); - -describe("spec 0059 team-interest HTTP boundary", () => { - it.live("answers 401 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); - }), - ); - - it.live("denies a plain member and an inactive leader with typed 403", () => - 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 inactive = yield* get("/api/team-interest-registrations", "session=inactive-leader"); - expect(inactive.status).toBe(403); - }), - ); - - it.live("scopes a leader to their authorized union and emits the exact fixture 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({ - "hydra:member": [ - { id: 1, userName: "User A", teamName: "Team One" }, - { id: 2, userName: "User B", teamName: "Team One" }, - ], - "hydra:totalItems": 2, - }); - // Rows ordered registration_id ASC regardless of insert order. - expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1"]); - }), - ); - - it.live("lets an ordinary team's leader read the own team's interest only (O8-11)", () => - 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(lastTeamInterestScope?.departmentIds).toEqual([]); - expect(lastTeamInterestScope?.teams.map(({ teamId }) => teamId)).toEqual(["team-2"]); - - const otherDepartment = yield* get( - "/api/team-interest-registrations?department=department-1", - "session=team-captain", - ); - - expect(otherDepartment.status).toBe(403); - }), - ); - - 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({ - "hydra:member": [ - { id: 1, userName: "User A", teamName: "Team One" }, - { id: 2, userName: "User B", teamName: "Team One" }, - { id: 3, userName: "User C", teamName: "Team Two" }, - ], - "hydra:totalItems": 3, - }); - expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1", "department-2"]); - }), - ); - - it.live("gives a global administrator an empty success while no department exists", () => - Effect.gen(function* () { - storedDepartments = []; - - const teamInterest = yield* get("/api/team-interest-registrations", "session=admin-session"); - - expect(teamInterest.status).toBe(200); - expect(yield* Effect.promise(() => teamInterest.json())).toEqual({ - "hydra:member": [], - "hydra:totalItems": 0, - }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - storedDepartments = [department, secondDepartment]; - }), - ), - ), - ); - - it.live("rejects an unknown mailing-list type at the decode boundary", () => - 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", - ); - - expect(inScope.status).toBe(200); - - const outOfScope = yield* get( - "/api/team-interest-registrations?department=department-2", - "session=leader-session", - ); - - expect(outOfScope.status).toBe(403); - }), - ); -}); diff --git a/apps/backend/src/placements/certificates-http.test.ts b/apps/backend/src/placements/certificates-http.test.ts deleted file mode 100644 index cc9c6430..00000000 --- a/apps/backend/src/placements/certificates-http.test.ts +++ /dev/null @@ -1,603 +0,0 @@ -/** - * 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. - */ -import { Database, IdentitySnapshot, OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { PlacementsLive } from "@vektorprogrammet/database/placements"; -import { - Identity, - IdentityActor, - 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 { backendDatabase } from "../../test/database.js"; -import { makeCertificatesTestHttp } 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"), -}); - -const department = "cert-http-trondheim"; - -const spring = "cert-http-spring"; - -const person = { - leader: "cert-http-leader", - coordinator: "cert-http-coordinator", - outsider: "cert-http-outsider", - ada: "cert-http-ada", - unprintable: "cert-http-li", -} as const; - -/** - * One independent department with a Styret leader, a Skolekoordinering member who confirms days - * served by delegation, and a team member without either capability. Ada and Wei have accepted - * legacy totals from the spring at Lade; Wei's family name is written in a script that the - * certificate font does not cover. - */ -const seed = Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO person_profiles (person_id,first_name,last_name) VALUES - (${person.leader},'Siri','Styreleder'), (${person.coordinator},'Kari','Koordinator'), - (${person.outsider},'Ola','Utenfor'), (${person.ada},'Ada','Assistent'), - (${person.unprintable},'Wei','李')`; - yield* sql`INSERT INTO auth."user" (id,name,email,"emailVerified") - SELECT person_id, first_name, person_id || '@example.invalid', true - FROM person_profiles WHERE person_id LIKE 'cert-http-%'`; - yield* sql`INSERT INTO organization_departments - (department_id,name,short_name,email,city,independent) VALUES - (${department},'Trondheim','TRD','trd@example.invalid','Trondheim',true)`; - yield* sql`INSERT INTO organization_teams (team_id,department_id,name,kind,team_scope) VALUES - ('cert-http-styret',${department},'Styret','DepartmentBoard','HomeDepartment'), - ('cert-http-skolekoordinering',${department},'Skolekoordinering','Team','HomeDepartment'), - ('cert-http-it',${department},'IT','Team','HomeDepartment')`; - yield* sql`INSERT INTO organization_memberships - (membership_id,person_id,team_id,position_name,start_at,is_team_leader) VALUES - ('cert-http-m-leader',${person.leader},'cert-http-styret','Styreleder','2020-01-01',true), - ('cert-http-m-coordinator',${person.coordinator},'cert-http-skolekoordinering',NULL,'2020-01-01',false), - ('cert-http-m-outsider',${person.outsider},'cert-http-it',NULL,'2020-01-01',false)`; - yield* sql`INSERT INTO organization_delegations - (delegation_id,name,team_id,capability,area,area_department_id,holders,start_at) VALUES - (${`delegation-${"e".repeat(64)}`},'Dager tjenestegjort','cert-http-skolekoordinering', - 'placements.days-served','Department',${department},'AllMembers','2020-01-01T00:00:00Z')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id,start_at,end_at) VALUES - (${spring},'2026-01-01T00:00:00Z','2026-08-01T00:00:00Z')`; - yield* sql`INSERT INTO schools_directory_schools - (school_id,name,contact_person,email,phone,language,active) OVERRIDING SYSTEM VALUE VALUES - (9201,'Lade skole','Kontakt','lade@example.invalid','12345678','Norwegian',true)`; - yield* sql`INSERT INTO schools_directory_departments (school_id,department_id) VALUES - (9201,${department})`; - - const people = "b1".padStart(64, "0"); - const service = "b2".padStart(64, "0"); - - yield* sql`INSERT INTO person_cohort_snapshots - (snapshot_key,source_repository,snapshot_id,source_revision,transformation_revision,snapshot_digest,occurrence_count) - VALUES (${people},'cert-http-synthetic','cert-http-people','test','test',${people},2)`; - yield* sql`INSERT INTO person_cohort_occurrences (snapshot_key,occurrence_id,disposition,reason) VALUES - (${people},'cert-http-person-ada','Accepted','LinkedExistingPerson'), - (${people},'cert-http-person-li','Accepted','LinkedExistingPerson')`; - yield* sql`INSERT INTO person_cohort_imports - (source_repository,source_user_id,person_id,mapping_action,source_digest,evidence_ref,snapshot_key,occurrence_id) VALUES - ('cert-http-synthetic','cert-http-source-ada',${person.ada},'LinkExistingPerson',${people},'synthetic',${people},'cert-http-person-ada'), - ('cert-http-synthetic','cert-http-source-li',${person.unprintable},'LinkExistingPerson',${people},'synthetic',${people},'cert-http-person-li')`; - yield* sql`INSERT INTO historical_service_snapshots - (snapshot_key,source_repository,snapshot_id,source_revision,transformation_revision,snapshot_digest,occurrence_count) - VALUES (${service},'cert-http-synthetic','cert-http-service','test','test',${service},2)`; - yield* sql`INSERT INTO historical_service_occurrences (snapshot_key,occurrence_id,disposition,reason) VALUES - (${service},'cert-http-ada-spring','Accepted','Imported'), - (${service},'cert-http-li-spring','Accepted','Imported')`; - yield* sql`INSERT INTO assistant_service_history - (source_repository,source_history_id,source_user_id,person_id,department_id,semester_id,school_id,day,workdays,block,source_digest,evidence_ref,snapshot_key,occurrence_id) VALUES - ('cert-http-synthetic','cert-http-ada-spring','cert-http-source-ada',${person.ada},${department},${spring},9201,'Monday',6,'1',${service},'synthetic',${service},'cert-http-ada-spring'), - ('cert-http-synthetic','cert-http-li-spring','cert-http-source-li',${person.unprintable},${department},${spring},9201,'Friday',3,'1',${service},'synthetic',${service},'cert-http-li-spring')`; - }), -); - -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( - 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( - Database.use((sql) => - sql.unsafe<{ readonly count: number }>( - `SELECT count(*)::integer AS count FROM ${table} WHERE person_id = $1`, - [personId], - ), - ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), - ); - - return { database, request, count }; -}; - -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 bytes = (response: Response) => - Effect.promise(() => response.arrayBuffer()).pipe(Effect.map((buffer) => new Uint8Array(buffer))); - -const daysServedPath = `/api/departments/${department}/semesters/${spring}/days-served`; - -const certificatePath = (personId: string) => - `/api/departments/${department}/certificates/${personId}`; - -const confirm = ( - request: TestRequest, - input: { readonly personId: string; readonly ifMatch: string; 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 }), - }); - -const issue = ( - request: TestRequest, - input: { - readonly issuer: string; - readonly personId: string; - readonly ifMatch: string; - readonly key: string; - }, -) => - request(`${certificatePath(input.personId)}/issues`, { - method: "POST", - person: input.issuer, - headers: { "idempotency-key": key(input.key), "if-match": input.ifMatch }, - }); - -/** The leader reads the assistant's entry and confirms the calculated count. */ -const confirmCalculated = (request: TestRequest, personId: string) => - Effect.gen(function* () { - const list = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, - ); - - 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, - ); - - expect(confirmed.status).toBe(200); - }); - -/** 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); - }); - -describe("certificate scopes over HTTP", () => { - 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 coordinator = yield* request("/api/certificate-scopes", { - person: person.coordinator, - }); - - const outsider = yield* request("/api/certificate-scopes", { person: person.outsider }); - - expect(leader.status).toBe(200); - expect((yield* decoded(leader, CertificateScopes)).departments).toEqual([ - { - departmentId: department, - name: "Trondheim", - confirmDaysServed: true, - issueCertificates: true, - }, - ]); - expect(coordinator.status).toBe(200); - expect((yield* decoded(coordinator, CertificateScopes)).departments).toEqual([ - { - departmentId: department, - name: "Trondheim", - confirmDaysServed: true, - issueCertificates: false, - }, - ]); - expect(outsider.status).toBe(403); - expect((yield* decoded(outsider, CertificatesReadProblem)).code).toBe("authority.denied"); - - // The delegated capability confirms days served and issues nothing. - expect((yield* request(daysServedPath, { person: person.coordinator })).status).toBe(200); - - yield* confirmCalculated(request, person.ada); - - const { etag } = yield* preview(request, person.ada); - - const denied = yield* issue(request, { - issuer: person.coordinator, - personId: person.ada, - ifMatch: etag, - key: "coordinator", - }); - - expect(denied.status).toBe(403); - expect(yield* count("certificate_issues", person.ada)).toBe(0); - }), - ); -}); - -describe("days served over HTTP", () => { - it.live("confirms at the observed tag, refuses a stale one, and reads back the result", () => - Effect.gen(function* () { - const { request, count } = fixture(); - - const before = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, - ); - - const ada = before.items.find((item) => item.personId === person.ada); - - expect(ada).toMatchObject({ calculated: 6, revision: 0, confirmation: null }); - - const observed = ada?.etag ?? ""; - - const confirmed = yield* confirm( - request, - { personId: person.ada, ifMatch: observed, 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); - - // 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, - ); - - expect(stale.status).toBe(412); - expect((yield* decoded(stale, CertificatesConfirmProblem)).code).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); - - const after = yield* decoded( - yield* request(daysServedPath, { person: person.leader }), - DaysServedListResponse, - ); - - // The correction reads back as the next revision; the other assistant is unchanged. - expect(after.items.map((item) => [item.personId, item.confirmation?.total ?? null])).toEqual([ - [person.ada, 4], - [person.unprintable, null], - ]); - expect(yield* count("days_served_confirmations", person.ada)).toBe(2); - }), - ); -}); - -describe("certificates over HTTP", () => { - it.live("denies the outsider and the assistant, and records no issue", () => - Effect.gen(function* () { - const { request, count } = fixture(); - - yield* confirmCalculated(request, person.ada); - - const { etag } = yield* preview(request, person.ada); - - const outsiderList = yield* request(`/api/departments/${department}/certificates`, { - person: person.outsider, - }); - - expect(outsiderList.status).toBe(403); - expect((yield* decoded(outsiderList, CertificatesReadProblem)).code).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, - }); - - expect(denied.status).toBe(403); - expect((yield* decoded(denied, CertificatesIssueProblem)).code).toBe("authority.denied"); - } - - // The assistant cannot read their own certificate either. - const own = yield* request(certificatePath(person.ada), { person: person.ada }); - - expect(own.status).toBe(403); - expect((yield* decoded(own, CertificatesReadProblem)).code).toBe("authority.denied"); - expect(yield* count("certificate_issues", person.ada)).toBe(0); - }), - ); - - it.live( - "replays one issue key with the same bytes, records a new key, and checks authority first", - () => - Effect.gen(function* () { - const { database, request, count } = fixture(); - - yield* confirmCalculated(request, person.ada); - - const certificate = yield* preview(request, person.ada); - - expect(certificate.content?.semesters.map((semester) => semester.days)).toEqual([6]); - - const first = yield* issue(request, { - 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, { - issuer: person.leader, - personId: person.ada, - ifMatch: certificate.etag, - key: "issue", - }); - - expect(replay.status).toBe(200); - expect(yield* bytes(replay)).toEqual(document); - expect(yield* count("certificate_issues", person.ada)).toBe(1); - - const second = yield* issue(request, { - 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 }>` - SELECT content_sha256 AS "contentSha256" FROM certificate_issues - WHERE person_id = ${person.ada} ORDER BY issued_at, issue_id`, - ), - ); - - // Two downloads of unchanged confirmed data: two records with one content hash. - expect(hashes.map((row) => row.contentSha256)).toEqual([ - certificate.contentSha256, - certificate.contentSha256, - ]); - - // The seat ends: the stored response of the first key does not replay. - yield* database.run( - Database.use( - (sql) => sql`UPDATE organization_memberships - SET end_at = date_trunc('milliseconds', now(), 'UTC') - WHERE membership_id = 'cert-http-m-leader'`, - ), - ); - - const revoked = yield* issue(request, { - 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(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(); - - yield* confirmCalculated(request, person.ada); - - const certificate = yield* preview(request, 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. - yield* database.run( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`CREATE SEQUENCE certificate_issue_commit_attempts`; - yield* sql`CREATE FUNCTION fail_first_certificate_issue_commit() RETURNS trigger - LANGUAGE plpgsql AS $$ - BEGIN - IF nextval('certificate_issue_commit_attempts') = 1 THEN - RAISE EXCEPTION 'first certificate issue commit fails' USING ERRCODE = '40001'; - END IF; - RETURN NULL; - END $$`; - yield* sql`CREATE CONSTRAINT TRIGGER fail_first_certificate_issue_commit - AFTER INSERT ON public.certificate_issues DEFERRABLE INITIALLY DEFERRED - FOR EACH ROW EXECUTE FUNCTION fail_first_certificate_issue_commit()`; - }), - ), - ); - - const issued = yield* issue(request, { - 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-"); - - const attempts = yield* database.run( - Database.use( - (sql) => sql<{ readonly attempts: number }>` - SELECT last_value::integer AS attempts FROM certificate_issue_commit_attempts`, - ), - ); - - expect(attempts).toEqual([{ attempts: 2 }]); - expect(yield* count("certificate_issues", person.ada)).toBe(1); - }), - ); - - it.live("refuses a name that no certificate glyph covers and records nothing", () => - Effect.gen(function* () { - const { request, count } = fixture(); - - yield* confirmCalculated(request, person.unprintable); - - const certificate = yield* preview(request, 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", - ); - expect(yield* count("certificate_issues", person.unprintable)).toBe(0); - }), - ); -}); diff --git a/apps/backend/src/placements/certificates-http.ts b/apps/backend/src/placements/certificates-http.ts deleted file mode 100644 index cdc74315..00000000 --- a/apps/backend/src/placements/certificates-http.ts +++ /dev/null @@ -1,525 +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 { - 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. - const authorization = yield* Placements.use((placements) => - placements.authorizeDaysServedConfirmation(current.principal, { - 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( - authorization, - { commandId: identity.identitySha256, 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. - const authorization = yield* Placements.use((placements) => - placements.authorizeCertificateIssue(current.principal, 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( - 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(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-rpc.ts b/apps/backend/src/placements/certificates-rpc.ts new file mode 100644 index 00000000..44a002bc --- /dev/null +++ b/apps/backend/src/placements/certificates-rpc.ts @@ -0,0 +1,8 @@ +/** + * The CertificatesRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/placements/certificates-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { CertificatesRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const CertificatesRpcHandlers = (_options: NativeRpcOptions) => CertificatesRpcs.toLayer({}); diff --git a/apps/backend/src/placements/http.test.ts b/apps/backend/src/placements/http.test.ts deleted file mode 100644 index 50ffffec..00000000 --- a/apps/backend/src/placements/http.test.ts +++ /dev/null @@ -1,370 +0,0 @@ -import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; -import { OrganizationLive } from "@vektorprogrammet/database/organization"; -import { PlacementsLive } from "@vektorprogrammet/database/placements"; -import { - Identity, - IdentityActor, - 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 { 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; - -const departmentId = "draft-department"; - -const semesterId = "draft-semester"; - -const scope = new URLSearchParams({ departmentId, semesterId }).toString(); - -const digest = (value: string) => createHash("sha256").update(value).digest("hex"); - -interface Registration { - readonly person: string; - readonly revision: number; - readonly unavailable: ReadonlyArray<"monday" | "tuesday" | "wednesday" | "thursday" | "friday">; - readonly positionWeeks: 4 | 8; - readonly preferredGroup: "all" | "block-1" | "block-2"; - readonly preferredSchool?: string; -} - -/** - * Alpha has a capacity plan of one place per block on Monday, which bounds its demand of two; - * its active placement fills Monday block 1. Beta has no plan, so demand alone bounds it. - * Availability comes from returning registrations, whose latest revision counts, and from the - * public applications of new applicants whose interview is conducted. - */ -const seed = Database.use((sql) => - Effect.gen(function* () { - yield* sql`INSERT INTO organization_departments(department_id,name,short_name,email,city) VALUES(${departmentId},'Trondheim','TRD','draft@example.invalid','Trondheim')`; - yield* sql`INSERT INTO organization_teams(team_id,department_id,name) VALUES('draft-team',${departmentId},'Skolekoordinering')`; - yield* sql`INSERT INTO admission_period_departments(department_id,name) VALUES(${departmentId},'Trondheim')`; - yield* sql`INSERT INTO admission_period_semesters VALUES('draft-previous','2031-08-01T00:00:00Z','2031-12-31T00:00:00Z'),(${semesterId},'2032-01-01T00:00:00Z','2032-06-30T00:00:00Z')`; - yield* sql`INSERT INTO admission_periods VALUES('draft-period',${departmentId},${semesterId},'2031-11-01T00:00:00Z','2031-12-01T00:00:00Z',0,'draft-seed')`; - yield* sql`INSERT INTO admission_period_fields_of_study VALUES('draft-field',${departmentId},'Matematikk',true)`; - yield* sql`INSERT INTO person_profiles(person_id,first_name,last_name) VALUES('coordinator','Kari','Koordinator'),('berit','Berit','Assistent'),('carl','Carl','Assistent'),('dina','Dina','Assistent'),('erik','Erik','Assistent'),('pia','Pia','Assistent'),('petter','Petter','Assistent'),('nora','Nora','Søker'),('olav','Olav','Søker')`; - yield* sql`INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,position_id,is_team_leader) VALUES('draft-leader','coordinator','draft-team','2020-01-01T00:00:00Z','leader',true)`; - // Skolekoordinering coordinates placements in its department through a delegation (O8-12). - yield* sql`INSERT INTO organization_delegations(delegation_id,name,team_id,capability,area,area_department_id,holders,start_at) VALUES(${`delegation-${"d".repeat(64)}`},'Skolekoordinering fordeler assistenter','draft-team','placements.coordinate','Department',${departmentId},'AllMembers','2020-01-01T00:00:00Z')`; - yield* sql`INSERT INTO organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES('berit',${departmentId},'Active',1),('carl',${departmentId},'Active',1),('dina',${departmentId},'Active',1),('erik',${departmentId},'Active',1),('pia',${departmentId},'Active',1),('petter',${departmentId},'Pending',1),('nora',${departmentId},'Active',1),('olav',${departmentId},'Active',1)`; - - const schools = yield* sql<{ - readonly schoolId: number; - }>`INSERT INTO schools_directory_schools(name,contact_person,email,phone,language,active) VALUES('Alpha skole','Kontakt','alpha@example.invalid','12345678','Norwegian',true),('Beta skole','Kontakt','beta@example.invalid','12345678','Norwegian',true) RETURNING school_id::double precision AS "schoolId"`; - - const [alpha, beta] = schools - .map((school) => school.schoolId) - .sort((left, right) => left - right); - - yield* sql`INSERT INTO schools_directory_departments(school_id,department_id) VALUES(${alpha!},${departmentId}),(${beta!},${departmentId})`; - yield* sql`INSERT INTO schools_capacity_plans(school_id,department_id,semester_id,monday,tuesday,wednesday,thursday,friday) VALUES(${alpha!},${departmentId},${semesterId},1,0,0,0,0)`; - // Nobody can serve Wednesday, so Beta's Wednesday place stays open. - yield* sql`INSERT INTO school_service_demand(department_id,semester_id,school_id,day,block,required_volunteers,revision) VALUES(${departmentId},${semesterId},${alpha!},'Monday','1',2,1),(${departmentId},${semesterId},${alpha!},'Monday','2',2,1),(${departmentId},${semesterId},${alpha!},'Wednesday','1',1,1),(${departmentId},${semesterId},${beta!},'Tuesday','1',1,1),(${departmentId},${semesterId},${beta!},'Tuesday','2',1,1),(${departmentId},${semesterId},${beta!},'Wednesday','1',1,1)`; - yield* sql`INSERT INTO assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES(${`placement-${digest("pia-current")}`},'pia',${departmentId},${semesterId},${alpha!},'Monday',4,'1',true,1)`; - - const registrations: ReadonlyArray = [ - // Carl's first registration had Monday off; the later revision counts. - { - person: "carl", - revision: 1, - unavailable: ["monday", "tuesday"], - positionWeeks: 4, - preferredGroup: "all", - }, - { - person: "carl", - revision: 2, - unavailable: ["tuesday", "wednesday", "thursday"], - positionWeeks: 4, - preferredGroup: "all", - preferredSchool: "Alpha skole", - }, - { - person: "berit", - revision: 1, - unavailable: ["monday", "wednesday", "thursday", "friday"], - positionWeeks: 4, - preferredGroup: "block-2", - }, - // Both blocks on Monday need one school with room in both; Alpha has one place left in block 2 only. - { - person: "dina", - revision: 1, - unavailable: ["tuesday", "wednesday", "thursday", "friday"], - positionWeeks: 8, - preferredGroup: "block-1", - }, - { person: "petter", revision: 1, unavailable: [], positionWeeks: 4, preferredGroup: "all" }, - ]; - - for (const person of ["carl", "berit", "dina", "petter"]) { - 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-${person}`},${`${person}@example.invalid`},${`${person}@example.invalid`},${person},'Assistent','12345678',0,'draft-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(${`application-${person}`},${`applicant-${person}`},'draft-period',${departmentId},'draft-field',2,'2031-11-02T00:00:00Z')`; - yield* sql`INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${`invitation-${person}`},${`application-${person}`},${`applicant-${person}`},${digest(`token-${person}`)},'2032-01-01T00:00:00Z','Claimed','coordinator','2031-11-03T00:00:00Z')`; - yield* sql`INSERT INTO applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES(${`applicant-${person}`},${person},'2031-11-04T00:00:00Z',${`invitation-${person}`})`; - yield* sql`INSERT INTO assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES(${`placement-${digest(`${person}-previous`)}`},${person},${departmentId},'draft-previous',${beta!},'Friday',4,'1',false,2)`; - } - - for (const registration of registrations) { - const unavailable = (day: Registration["unavailable"][number]) => - registration.unavailable.includes(day); - - yield* sql`INSERT INTO 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(${`returning-registration-${digest(`${registration.person}-${registration.revision}`)}`},${`application-${registration.person}`},${`applicant-${registration.person}`},${registration.person},${`placement-${digest(`${registration.person}-previous`)}`},${departmentId},${semesterId},'draft-period',${registration.revision},${`register-${registration.person}-${registration.revision}`},2,${unavailable("monday")},${unavailable("tuesday")},${unavailable("wednesday")},${unavailable("thursday")},${unavailable("friday")},${registration.positionWeeks},${registration.preferredGroup},'Norsk',${registration.preferredSchool ?? null},false,'[]'::jsonb,${`2031-11-1${registration.revision}T00:00:00Z`})`; - } - - // Nora and Olav applied on the public form: Tuesday only, block 1, an international school. - // Both have an account and an active affiliation; only Nora's interview is conducted. - yield* sql`INSERT INTO recruitment_interview_schemas(interview_schema_id,name,question_count,active,revision) VALUES('draft-schema','Intervju',0,true,0)`; - - for (const person of ["nora", "olav"]) { - 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-${person}`},${`${person}@example.invalid`},${`${person}@example.invalid`},${person},'Søker','12345678',1,'draft-field',1)`; - yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language) VALUES(${`application-${person}`},${`applicant-${person}`},'draft-period',${departmentId},'draft-field',1,'2031-11-02T00:00:00Z',true,false,true,true,true,4,'block-1','Engelsk')`; - yield* sql`INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${`invitation-${person}`},${`application-${person}`},${`applicant-${person}`},${digest(`token-${person}`)},'2032-01-01T00:00:00Z','Claimed','coordinator','2031-11-03T00:00:00Z')`; - yield* sql`INSERT INTO applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES(${`applicant-${person}`},${person},'2031-11-04T00:00:00Z',${`invitation-${person}`})`; - yield* sql`INSERT INTO recruitment_interviews(interview_id,application_id,department_id,interviewer_person_id,interview_schema_id,assigned_by_person_id,assigned_at,revision) VALUES(${`interview-${person}`},${`application-${person}`},${departmentId},'coordinator','draft-schema','coordinator','2031-11-05T00:00:00Z',1)`; - } - - yield* sql`INSERT INTO recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES('interview-nora','[]'::jsonb,7,8,9,'coordinator','2031-11-06T00:00:00Z',2,'Ja')`; - }), -); - -/** 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 - : new IdentityActor({ - 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(new IdentitySessionNotFound()) : 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(new IdentitySessionNotFound()) : 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 = () => { - const database = backendDatabase(seed); - - const http = makeBackendTestHttp( - Effect.runSync(decodeBackendConfig(environment)), - 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"); - } - - return http.fetch(new Request(`http://backend.test${path}`, { ...init, headers })); - }; - - return { request }; -}; - -const decodeStrict = >( - schema: S, - response: Response, -) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap((body) => - Schema.decodeUnknownEffect(schema)(body, { onExcessProperty: "error" }), - ), - ); - -describe("placement drafts over HTTP 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}`), - ); - - expect(draft.boardEtag).toBe(board.etag); - expect(draft.openPlaces).toBe(4); - expect(draft.filledPlaces).toBe(3); - // Nora is a new applicant: her application supplies her weekday, block, and school wish. - expect( - draft.placements.map(({ personId, schoolName, day, block, workdays, wishes }) => [ - personId, - schoolName, - day, - block, - workdays, - wishes, - ]), - ).toEqual([ - [ - "carl", - "Alpha skole", - "Monday", - "2", - 4, - { language: "Norsk", preferredSchool: "Alpha skole" }, - ], - ["nora", "Beta skole", "Tuesday", "1", 4, { language: "Engelsk", preferredSchool: null }], - ["berit", "Beta skole", "Tuesday", "2", 4, { language: "Norsk", preferredSchool: null }], - ]); - // Olav's interview is not conducted, so his application supplies nothing yet. - expect( - draft.unplaced.map(({ personId, reason, wishes }) => [personId, reason, wishes]), - ).toEqual([ - ["dina", "NoOpenPlace", { language: "Norsk", preferredSchool: null }], - ["erik", "NoAvailability", null], - ["olav", "NoAvailability", null], - ]); - expect( - draft.openSlots.map(({ schoolName, day, block, places }) => [ - schoolName, - day, - block, - places, - ]), - ).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); - }), - ); - - it.live( - "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}`), - ); - - 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({ - action: "Create", - personId: placement.personId, - schoolId: placement.schoolId, - day: placement.day, - workdays: placement.workdays, - block: placement.block, - }), - }); - - expect(response.status).toBe(200); - etag = (yield* decodeStrict(PlacementBoardResource, response)).etag; - } - - const after = yield* decodeStrict( - PlacementDraftResource, - yield* request(`/api/placements/draft?${scope}`), - ); - - 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"]); - }), - ); - - it.live("serves drafts to scoped coordinators for a known semester only", () => - Effect.gen(function* () { - const { request } = fixture(); - const assistant = yield* request(`/api/placements/draft?${scope}`, {}, "berit"); - - expect(assistant.status).toBe(403); - expect(yield* decodeStrict(PlacementProblem, assistant)).toMatchObject({ - code: "authority.denied", - }); - - const unknown = yield* request( - `/api/placements/draft?${new URLSearchParams({ departmentId, semesterId: "draft-unknown" }).toString()}`, - ); - - expect(unknown.status).toBe(422); - expect(yield* decodeStrict(PlacementProblem, unknown)).toMatchObject({ - code: "scope.invalid", - }); - }), - ); -}); diff --git a/apps/backend/src/placements/http.ts b/apps/backend/src/placements/http.ts deleted file mode 100644 index 509b8d10..00000000 --- a/apps/backend/src/placements/http.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { Database } from "@vektorprogrammet/database"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { DomainId, requireDepartmentReach, Scope } from "@vektorprogrammet/domain/authz"; -import type { - DepartmentId, - OrganizationPersonAuthority, -} from "@vektorprogrammet/domain/organization"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import { - AffiliationScope, - CoverageCommand, - OwnAffiliationCommand, - OwnCoverageCommand, - PlacementCommand, - Placements, - PlacementScope, - PlacementScopes, - type CoverageCommand as CoverageCommandType, - type OwnAffiliationCommand as OwnAffiliationCommandType, - type OwnCoverageCommand as OwnCoverageCommandType, - type PlacementCommand as PlacementCommandType, - type PlacementExecution, - 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, - type TransactionPersonAuthority, -} 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; - -/** 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 endpoint's AccessSpec for the resolved person in the department's scope. */ -const evaluateAccess = ( - request: Request, - endpoint: Endpoint, - departmentId: PlacementScope["departmentId"] | null, - auth: TransactionPersonAuthority, -) => - 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), - ); - -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) yield* requireCoordinator(auth.authority, departmentId); - - yield* evaluateAccess(request, endpoint, departmentId, auth); - - return auth; - }); - -/** Resolves the person, requires the coordinator's reach, and returns its evidence. */ -const authorizeCoordinator = ( - request: Request, - endpoint: Endpoint, - departmentId: PlacementScope["departmentId"], - now?: () => string, -) => - Effect.gen(function* () { - const auth = yield* resolveRequestPersonAuthorityInTransaction(request, { now }); - const coordinator = yield* requireCoordinator(auth.authority, departmentId); - yield* evaluateAccess(request, endpoint, departmentId, auth); - - return { auth, coordinator }; - }); - -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* () { - // A board or coverage-board change runs on the coordinator's evidence; a person's own - // affiliation or coverage runs as that person. - const { auth, execution } = - selected.mode === "board" || selected.mode === "coverage" - ? yield* authorizeCoordinator( - request, - selected.endpoint, - selected.scope.departmentId, - input.now, - ).pipe( - Effect.map(({ auth, coordinator }) => ({ - auth, - execution: (commandId: string): PlacementExecution => ({ - mutation: selected, - coordinator, - now: auth.authorizationInstant, - commandId, - }), - })), - ) - : yield* authorize( - request, - selected.endpoint, - selected.scope.departmentId, - false, - input.now, - ).pipe( - Effect.map((auth) => ({ - auth, - execution: (commandId: string): PlacementExecution => ({ - mutation: selected, - actor: auth.authority.personId, - now: auth.authorizationInstant, - commandId, - }), - })), - ); - - 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(execution(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/rpc.ts b/apps/backend/src/placements/rpc.ts new file mode 100644 index 00000000..d6252a8b --- /dev/null +++ b/apps/backend/src/placements/rpc.ts @@ -0,0 +1,8 @@ +/** + * The PlacementsRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/placements/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { PlacementsRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const PlacementsRpcHandlers = (_options: NativeRpcOptions) => PlacementsRpcs.toLayer({}); diff --git a/apps/backend/src/profile/http.test.ts b/apps/backend/src/profile/http.test.ts deleted file mode 100644 index 5c0d6597..00000000 --- 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 0c0fa369..00000000 --- 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.ts b/apps/backend/src/profile/rpc.ts new file mode 100644 index 00000000..b74b1ca5 --- /dev/null +++ b/apps/backend/src/profile/rpc.ts @@ -0,0 +1,8 @@ +/** + * The ProfileRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/profile/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { ProfileRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const ProfileRpcHandlers = (_options: NativeRpcOptions) => ProfileRpcs.toLayer({}); diff --git a/apps/backend/src/receipt/e2e-support.test.ts b/apps/backend/src/receipt/e2e-support.test.ts index acd81287..09f1e73d 100644 --- a/apps/backend/src/receipt/e2e-support.test.ts +++ b/apps/backend/src/receipt/e2e-support.test.ts @@ -1,4 +1,4 @@ -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 { describe, expect, it } from "@effect/vitest"; diff --git a/apps/backend/src/receipt/e2e-support.ts b/apps/backend/src/receipt/e2e-support.ts index ef1ca9d3..274fb5b5 100644 --- a/apps/backend/src/receipt/e2e-support.ts +++ b/apps/backend/src/receipt/e2e-support.ts @@ -2,7 +2,7 @@ * 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 { ReceiptApiConfig } from "./config.js"; diff --git a/apps/backend/src/receipt/http-commands.ts b/apps/backend/src/receipt/http-commands.ts deleted file mode 100644 index 9dc8594d..00000000 --- a/apps/backend/src/receipt/http-commands.ts +++ /dev/null @@ -1,704 +0,0 @@ -/** Receipt command handlers: submit, revise, withdraw, approve/reject/reopen, and settle. */ -import { - Economy, - ReceiptCommandRequestSchema, - ReceiptDecodeError, - ReceiptId, - ReceiptMutationAuthorizationTarget, - ReceiptPersistenceError, - ReceiptSettlementCommandRequestSchema, - ReceiptVisualId, - type ReceiptCommandPrincipal, - type ReceiptCommandRequest, - type ReceiptFile, - 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, - commandReceiptProblems, - decodeRequest, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemWebResponse, - 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"; -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 { - receiptEtag, - receiptMutationCapsule, - settlementMutationCapsule, -} from "./http-representation.js"; -import { drainReceiptOutbox } from "./outbox-drain.js"; - -export type ReceiptApprovalRoute = { - readonly action: "approve" | "reject" | "reopen"; - readonly receiptId: string; -}; - -const mutationIdentity = ( - request: Request, - principal: ReceiptCommandPrincipal, - qualifiedOperationId: string, - normalizedTarget: string, -) => - idempotencyKeyOf(request).pipe( - Effect.flatMap((idempotencyKey) => - httpIdentity({ - credentialSubject: `Person:${principal.personId}`, - qualifiedOperationId, - normalizedTarget, - idempotencyKey, - }), - ), - ); - -interface PreparedReceiptMutation { - readonly identity: { - readonly identitySha256: string; - readonly commandId: string; - }; - readonly operationId: string; - readonly requestSha256: string; - readonly command: ReceiptCommandRequest; - readonly authorization: ReceiptMutationAuthorization; - readonly response: - | { readonly status: 201; readonly location: string } - | { readonly status: 200; readonly ifMatch: StrongETag; readonly currentEtag: StrongETag }; - readonly allocation?: ReceiptSubmissionAllocation; -} - -type ReceiptMutationAuthorizationFor = - Target["_tag"] extends "SubmitReceipt" - ? Extract - : Exclude; - -const authorizeReceiptMutationInTransaction = ( - target: Target, - principal: ReceiptCommandPrincipal, -) => - Economy.use(({ authorizeReceiptMutation }) => authorizeReceiptMutation(target, principal)).pipe( - Effect.flatMap((authorization) => - authorization._tag === target._tag - ? Effect.succeed( - // SAFETY: The service returns the authorization union, and the exact target discriminant is checked above. - authorization as ReceiptMutationAuthorizationFor, - ) - : Effect.fail( - new ReceiptPersistenceError({ - operation: "authorize Receipt mutation", - message: "authorization target mismatch", - }), - ), - ), - ); - -const executeReceiptMutation = ( - prepare: () => Effect.Effect, - execution: { readonly retry?: "serialization-once" } = {}, -) => - executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const prepared = yield* prepare(); - - return { - identity: { - identitySha256: prepared.identity.identitySha256, - requestSha256: prepared.requestSha256, - operationId: prepared.operationId, - }, - execute: Economy.use(({ executeAuthorizedReceipt }) => - Effect.gen(function* () { - if (prepared.response.status === 200) { - yield* requireCurrentETag(prepared.response.currentEtag, prepared.response.ifMatch); - } - - const result = yield* executeAuthorizedReceipt( - prepared.command, - prepared.authorization, - prepared.allocation, - ); - - return receiptMutationCapsule(result.receipt, prepared.response); - }), - ), - }; - }), - execution, - ); - -/** Stages uploaded bytes; bytes past the limit fail validation and any other failure is the store's. */ -const stageReceiptFile = ( - fileStore: ReceiptFileStore, - file: File, - commandId: string, - contentType: ReceiptFile["contentType"], - maxFileBytes: number, -) => - fileStore.stageBytes(file, commandId, contentType, maxFileBytes).pipe( - Effect.catchTag("ReceiptFileStoreError", (cause) => - Effect.fail( - new ReceiptPersistenceError({ - operation: "stage receipt file", - message: "receipt file staging failed", - cause, - }), - ), - ), - ); - -const cleanupStagedFile = (fileStore: ReceiptFileStore, staged: StagedReceiptFile) => - fileStore.cleanupStage(staged.file).pipe(Effect.ignore); - -export const submitReceipt = ( - request: Request, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => { - 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); - - // 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 authorization = yield* authorizeReceiptMutationInTransaction( - ReceiptMutationAuthorizationTarget.SubmitReceipt( - departmentId === undefined ? {} : { departmentId }, - ), - principal, - ); - - const identity = yield* mutationIdentity( - request, - principal, - "receipts.submitReceipt", - "/api/receipts", - ); - - const nextStaged = yield* stageReceiptFile( - fileStore, - fields.file, - identity.commandId, - fields.contentType, - options.config.maxFileBytes, - ); - - staged = nextStaged; - yield* fileStore.service.stage(nextStaged.file); - - const semanticBody: Schema.JsonObject = { - description: fields.description, - amountOre: fields.amountOre, - receiptDate: fields.receiptDate, - file: { - contentType: nextStaged.file.contentType, - byteLength: nextStaged.file.byteLength, - sha256: nextStaged.file.sha256, - }, - }; - - if (departmentId !== undefined) Object.assign(semanticBody, { departmentId }); - - const commandFields = { - commandId: identity.commandId, - description: fields.description, - amountOre: fields.amountOre, - receiptDate: fields.receiptDate, - file: nextStaged.file, - }; - - if (departmentId !== undefined) Object.assign(commandFields, { departmentId }); - - const command = ReceiptCommandRequestSchema.cases.SubmitReceipt.make(commandFields); - - const nextAllocation = { - receiptId: ReceiptId.make(options.config.nextReceiptId()), - visualId: ReceiptVisualId.make(options.config.nextVisualId()), - }; - - allocation = nextAllocation; - - return { - identity, - operationId: "receipts.submitReceipt", - requestSha256: semanticRequestDigest({ body: semanticBody }), - command, - authorization, - response: { - status: 201, - location: `/api/receipts/${encodeURIComponent(nextAllocation.receiptId)}`, - }, - allocation: nextAllocation, - }; - }), - ); - - if (Predicate.isTagged(outcome, "Committed")) { - if (allocation === undefined) { - return yield* new ReceiptPersistenceError({ - operation: "submit receipt allocation", - message: "transaction preparation produced no allocation", - }); - } - - committed = true; - yield* drainReceiptOutbox(options, fileStore, allocation.receiptId); - } else if (staged?.created === true) { - yield* cleanupStagedFile(fileStore, staged); - } - - return yield* commandOutcomeResponse(outcome); - }).pipe( - receiptProblems, - commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), - // 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( - Effect.suspend(() => - !committed && staged?.created === true ? cleanupStagedFile(fileStore, staged) : Effect.void, - ), - ), - ); -}; - -export const reviseReceipt = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => { - 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 outcome = yield* executeReceiptMutation(() => - Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); - - const authorization = yield* authorizeReceiptMutationInTransaction( - ReceiptMutationAuthorizationTarget.RevisePendingReceipt({ receiptId }), - principal, - ); - - const current = authorization.current; - - const identity = yield* mutationIdentity( - request, - principal, - "receipts.reviseReceipt", - `/api/receipts/${encodeURIComponent(receiptId)}`, - ); - - 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, - identity.commandId, - contentType, - options.config.maxFileBytes, - ); - - staged = nextStaged; - yield* fileStore.service.stage(nextStaged.file); - } - - const amountOre = fields.amountOre ?? Number(current.amountOre); - - if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { - return yield* new ReceiptPersistenceError({ - operation: "decode current receipt amount", - message: "invalid amount", - }); - } - - const semanticBody: Schema.JsonObject = {}; - - if (fields.description !== undefined) - Object.assign(semanticBody, { description: fields.description }); - - if (fields.amountOre !== undefined) - Object.assign(semanticBody, { amountOre: fields.amountOre }); - - if (fields.receiptDate !== undefined) - Object.assign(semanticBody, { receiptDate: fields.receiptDate }); - - if (staged !== undefined) - Object.assign(semanticBody, { - file: { - contentType: staged.file.contentType, - byteLength: staged.file.byteLength, - sha256: staged.file.sha256, - }, - }); - - const command = ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ - commandId: identity.commandId, - receiptId: current.receiptId, - expectedRevision: current.revision, - description: fields.description ?? current.description, - amountOre, - receiptDate: fields.receiptDate ?? current.receiptDate, - file: - staged?.file ?? - ReceiptCommandRequestSchema.cases.RevisePendingReceipt.fields.file.members[1].cases.KeepCurrentFile.make( - {}, - ), - }); - - return { - identity, - operationId: "receipts.reviseReceipt", - requestSha256: semanticRequestDigest(semanticMutationRequest(semanticBody, ifMatch)), - command, - authorization, - response: { - status: 200, - ifMatch, - currentEtag: receiptEtag(receiptId, current.revision), - }, - }; - }), - ); - - if (Predicate.isTagged(outcome, "Committed")) { - committed = true; - yield* drainReceiptOutbox(options, fileStore, receiptId); - } else if (staged?.created === true) { - yield* cleanupStagedFile(fileStore, staged); - } - - 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"), - Effect.ensuring( - Effect.suspend(() => - !committed && staged?.created === true ? cleanupStagedFile(fileStore, staged) : Effect.void, - ), - ), - ); -}; - -export const withdrawReceipt = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, -) => - Effect.gen(function* () { - const ifMatch = yield* requiredIfMatchOf(request); - const body = yield* decodeExactEmptyJson(request); - - const outcome = yield* executeReceiptMutation(() => - Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); - - const authorization = yield* authorizeReceiptMutationInTransaction( - ReceiptMutationAuthorizationTarget.WithdrawPendingReceipt({ receiptId }), - principal, - ); - - const current = authorization.current; - - const identity = yield* mutationIdentity( - request, - principal, - "receipts.withdrawReceipt", - `/api/receipts/${encodeURIComponent(receiptId)}/withdraw`, - ); - - return { - identity, - operationId: "receipts.withdrawReceipt", - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - command: ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ - commandId: identity.commandId, - receiptId: current.receiptId, - expectedRevision: current.revision, - }), - authorization, - response: { - status: 200, - ifMatch, - currentEtag: receiptEtag(receiptId, current.revision), - }, - }; - }), - ); - - if (Predicate.isTagged(outcome, "Committed")) { - yield* drainReceiptOutbox(options, fileStore, receiptId); - } - - 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"), - ); - -/** - * A lane the E2E barrier synchronized says so on its answer. - */ -const markSynchronized = (response: Response) => { - response.headers.set(RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER, "1"); - - return response; -}; - -/** - * Approve, reject, or reopen one receipt. Approve and reject join the E2E concurrency barrier - * when one is composed, and mark synchronized responses, including failures. - */ -export const approvalCommand = ( - request: Request, - route: ReceiptApprovalRoute, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, - barrier: ReceiptE2ETransactionBarrier | undefined, -) => { - let synchronized = false; - - 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 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; - - const identity = yield* mutationIdentity( - request, - principal, - operationId, - normalizedTarget, - ); - - 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" }, - ); - - if (Predicate.isTagged(outcome, "Committed") && route.action !== "reopen") { - yield* drainReceiptOutbox(options, fileStore, route.receiptId); - } - - 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"), - ); - - // 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), - ), - ); - }); -}; - -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); - - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const principal = yield* authorizationPrincipalInTransaction(request, options); - - const revision = yield* Economy.use(({ readReceiptSettlementRevision }) => - readReceiptSettlementRevision(receiptId, principal), - ); - - const identity = yield* mutationIdentity( - request, - principal, - "receipts.settleReceipt", - `/api/receipts/${encodeURIComponent(receiptId)}/settle`, - ); - - const command = yield* decodeRequest(ReceiptSettlementCommandRequestSchema)( - ReceiptSettlementCommandRequestSchema.cases.RecordReceiptSettlement.make({ - commandId: identity.commandId, - receiptId, - expectedRevision: body.expectedRevision, - externalAuthority: body.externalAuthority, - externalReference: body.externalReference, - settledAt: body.settledAt, - }), - ); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId: "receipts.settleReceipt", - }, - execute: Economy.use(({ recordReceiptSettlement }) => - Effect.gen(function* () { - yield* requireCurrentETag(receiptEtag(receiptId, revision), ifMatch); - - if (body.expectedRevision !== revision) { - return yield* Problem.make("precondition.failed"); - } - - const result = yield* recordReceiptSettlement(command, principal); - - return settlementMutationCapsule(result.settlement, result.receipt); - }), - ), - }; - }), - { retry: "serialization-once" }, - ); - - if (Predicate.isTagged(outcome, "Committed")) { - yield* drainReceiptOutbox(options, 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; - }).pipe( - receiptProblems, - commandReceiptProblems, - receiptCredentialProblems(personPresentation(request)), - ); diff --git a/apps/backend/src/receipt/http-context.ts b/apps/backend/src/receipt/http-context.ts deleted file mode 100644 index b4848a83..00000000 --- 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 4eb52ed6..00000000 --- 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-problem.ts b/apps/backend/src/receipt/http-problem.ts deleted file mode 100644 index d9214112..00000000 --- a/apps/backend/src/receipt/http-problem.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** The problems receipt handlers answer for domain, store, and credential failures. */ -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { - ReceiptDecodeError, - ReceiptFailure, - ReceiptFileFailure, - ReceiptSettlementFailure, - UnauthenticatedActor, -} from "@vektorprogrammet/domain/receipt"; -import { type CredentialPresentation, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import type { Cause } from "effect"; -import type { SqlError } from "effect/unstable/sql/SqlError"; -import { - type CredentialCases, - type ProblemCases, - type ProblemMapper, - problemMapper, - requestInvalid, -} from "../http-api/problem.js"; - -/** Every failure that a receipt handler answers, but a rejected credential. */ -type ReceiptHttpFailure = - | Exclude - | ReceiptFileFailure - | IdentityEngineError - | SqlError - | Cause.TimeoutError; - -const receiptCases = { - InactiveActor: () => Problem.make("authority.denied"), - ReceiptOwnerDenied: () => Problem.make("authority.denied"), - ReceiptScopeDenied: () => Problem.make("authority.denied"), - ReceiptAuthorityDenied: () => Problem.make("authority.denied"), - AmbiguousPaymentSelection: () => Problem.make("authority.denied"), - AmbiguousParameterFill: () => Problem.make("authority.denied"), - FailedComposedRequirement: () => Problem.make("authority.denied"), - ReceiptNotFound: () => Problem.make("receipt.not-found"), - StaleReceiptRevision: () => Problem.make("precondition.failed"), - ReceiptDecodeError: () => requestInvalid(), - SettlementAfterRecordedAt: () => Problem.make("settlement.after-recorded-at"), - ReceiptFileNotStaged: () => Problem.make("receipt.file-not-staged"), - ReceiptAlreadyExists: () => Problem.make("receipt.already-exists"), - ReceiptAlreadySettled: () => Problem.make("receipt.already-settled"), - DuplicateExternalSettlementReference: () => - Problem.make("settlement.external-reference-conflict"), - DuplicateReceiptCommandConflict: () => Problem.make("idempotency.digest-conflict"), - InvalidReceiptTransition: () => Problem.make("receipt.invalid-transition"), - ReceiptPersistenceError: () => Problem.make("receipts.unavailable"), - ReceiptFileIdentityConflict: () => Problem.make("receipts.unavailable"), - ReceiptFileEffectConflict: () => Problem.make("receipts.unavailable"), - ReceiptFileInjectedFailure: () => Problem.make("receipts.unavailable"), - IdentityEngineError: () => Problem.make("receipts.unavailable"), - SqlError: () => Problem.make("receipts.unavailable"), - TimeoutError: () => Problem.make("receipts.unavailable"), -} satisfies ProblemCases; - -/** - * The one answer for every receipt failure other than a rejected credential, including an - * unavailable store, Identity, or E2E barrier. - * - * @remarks - * 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. - * - * @sideEffects none - * - * @example - * ```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. - * - * @construct http-problem - */ -export const receiptProblems: ProblemMapper = - problemMapper()(receiptCases); - -/** - * A stored receipt value a read cannot decode is the receipt store failing, not the request. - */ -export const storedReceiptProblems = problemMapper()({ - ReceiptDecodeError: () => Problem.make("receipts.unavailable"), -}); - -/** - * A credential rejected inside a receipt handler is answered from the request's own evidence. - * - * @remarks - * The rejection answers `Problem.unauthenticated(presentation)`: credential.missing when the - * request presented no credential, and credential.invalid when it presented one that was - * rejected. - * - * @sideEffects none - * - * @example - * ```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. - * - * @construct http-problem - */ -export const receiptCredentialProblems = ( - presentation: CredentialPresentation, -): ProblemMapper> => - problemMapper()>({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - }); diff --git a/apps/backend/src/receipt/http-reads.ts b/apps/backend/src/receipt/http-reads.ts deleted file mode 100644 index d639f16c..00000000 --- a/apps/backend/src/receipt/http-reads.ts +++ /dev/null @@ -1,559 +0,0 @@ -/** Receipt read handlers: owner, approver, and finance lists; settlement evidence; files; evidence. */ -import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; -import { readOwnedReceiptFile } from "@vektorprogrammet/database/receipt/postgres"; -import { - AcceptedOAuthServiceCredential, - AuthorityRef, - AuthorityVersion, - AuthorizationInstant, - CapabilityTypeId, - CredentialEvidenceRef, - CredentialMechanismSchema, - CredentialOutcomeSchema, - GrantId, - INTERNAL_RECEIPT_EVIDENCE_ACCESS, - READ_INTERNAL_RECEIPT_EVIDENCE_CAPABILITY, - RECEIPT_DOMAIN_ID, - RECEIPT_RESOURCE_KIND, - ResourceId, - Scope, - ServicePrincipalGrantAuthority, - decodeGrant, - evaluateAccess, - evaluateServicePrincipalReceiptApprovalAccess, - type ReceiptAccessFacts, -} from "@vektorprogrammet/domain/authz"; -import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; -import { - Economy, - ReceiptNotFound, - ReceiptPersistenceError, -} from "@vektorprogrammet/domain/receipt"; -import { - ReadReceiptFileEndpoint, - ReceiptLifecycleEvidenceResponse, - reflectAccessSpec, - type ReceiptSettlementQueueItem, -} from "@vektorprogrammet/http-api"; -import { nativeCookieChallenge, Problem } from "@vektorprogrammet/http-api/http-semantics"; -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 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 { - jsonResponse, - ownedReceiptResource, - privateJsonResponse, - projected, - readPrivateReceiptFile, - receiptEtag, - receiptSettlementEvidenceResource, -} from "./http-representation.js"; - -interface ReceiptAccessRow { - readonly ownerPersonId: string; - readonly departmentId: string; - readonly status: string; - readonly revision: number; -} - -export const listOwnedReceipts = ( - request: Request, - options: ReceiptApiHttpOptions, -) => - Effect.gen(function* () { - const { status: selectedStatus, cursor } = yield* decodeReceiptListQuery(request); - - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); - - const rows = yield* Economy.use(({ listOwnedReceipts }) => - listOwnedReceipts(principal.personId, selectedStatus, 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))); - -export const listReceiptsForApproval = ( - request: Request, - options: ReceiptApiHttpOptions, -) => - Effect.gen(function* () { - const { status, cursor } = yield* decodeReceiptListQuery(request); - - const resolved = - options.identity.resolveApprovalCredential === undefined - ? undefined - : yield* options.identity.resolveApprovalCredential(request); - - if ( - resolved !== undefined && - Predicate.isTagged(resolved.credential.mechanism, "OAuthServiceBearer") && - Predicate.isTagged(resolved.credential.principal, "ServicePrincipal") - ) { - const credential = AcceptedOAuthServiceCredential.make({ - mechanism: resolved.credential.mechanism, - principal: resolved.credential.principal, - evidenceRef: resolved.credential.evidenceRef, - }); - - const authority = yield* ServicePrincipalGrantAuthority.use( - ({ 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", - }), - ), - ); - - const evaluation = evaluateServicePrincipalReceiptApprovalAccess( - credential, - authority, - resolved.authorizationInstant, - ); - - if (!Predicate.isTagged(evaluation, "Allow")) { - return yield* Problem.make("authority.denied"); - } - - const items = yield* projected(() => { - const allowed = new Set( - evaluation.resolution.contexts.flatMap((context) => - context.resource === null ? [] : [context.resource.id], - ), - ); - - const seen = new Set(); - - return authority.candidates.flatMap(({ receipt }) => { - if (seen.has(receipt.receiptId) || !allowed.has(receipt.receiptId)) return []; - 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 jsonResponse( - authority.nextCursor === undefined - ? { items } - : { items, nextCursor: authority.nextCursor }, - 200, - "private, no-store", - ); - } - - const principal = - resolved !== undefined && Predicate.isTagged(resolved.credential.principal, "Person") - ? { - personId: resolved.credential.principal.personId, - authorizationInstant: resolved.authorizationInstant, - } - : yield* options.identity.resolveAuthorizationPrincipal(request); - - 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), - }; - }), - ); - - return jsonResponse( - rows.nextCursor === undefined ? { items } : { items, nextCursor: rows.nextCursor }, - 200, - "private, no-store", - ); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); - -export const readSettlementForFinance = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); - - const settlement = yield* Economy.use(({ readReceiptSettlementForFinance }) => - readReceiptSettlementForFinance( - receiptId, - principal.personId, - principal.authorizationInstant, - ), - ).pipe(storedReceiptProblems); - - return privateJsonResponse(receiptSettlementEvidenceResource(settlement)); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); - -export const listReceiptsForSettlement = ( - request: Request, - options: ReceiptApiHttpOptions, -) => - Effect.gen(function* () { - const { cursor } = yield* decodeReceiptListQuery(request, false); - const principal = yield* options.identity.resolveAuthorizationPrincipal(request); - - 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 => { - const amountOre = Number(row.amountOre); - - if ( - !Number.isSafeInteger(amountOre) || - amountOre <= 0 || - row.status !== "Approved" || - row.approvedAt.length === 0 - ) { - throw new ReceiptPersistenceError({ - operation: "decode settlement queue projection", - message: "invalid settlement queue item", - }); - } - - return { - receiptId: row.receiptId, - visualId: row.visualId, - ownerPersonId: row.ownerPersonId, - departmentId: row.departmentId, - description: row.description, - amountOre, - currency: row.currency, - receiptDate: row.receiptDate, - status: row.status, - approvedAt: row.approvedAt, - revision: row.revision, - etag: receiptEtag(row.receiptId, row.revision), - }; - }), - ); - - return privateJsonResponse( - rows.nextCursor === undefined ? { items } : { items, nextCursor: rows.nextCursor }, - ); - }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(request))); - -/** 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); - - return Effect.gen(function* () { - const sql = yield* Database; - - const file = yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; - - const authenticated = yield* resolveRequestCredentialInTransaction( - request, - "OAuthUserBearer", - { now: options.now }, - ); - - const principal = authenticated.credential.principal; - - if (!Predicate.isTagged(principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - const owned = yield* readOwnedReceiptFile(receiptId, principal.personId); - - if (owned === undefined) { - return yield* Problem.make("resource.not-found"); - } - - const resource = { - kind: RECEIPT_RESOURCE_KIND, - id: ResourceId.make(receiptId), - }; - - const evaluation = evaluateAccess({ - spec: Option.getOrThrow(reflectAccessSpec(ReadReceiptFileEndpoint)), - credential: authenticated.credential, - resolution: { - selection: "ExactlyOne", - contexts: [ - { - domainId: RECEIPT_DOMAIN_ID, - departmentId: DepartmentId.make(owned.departmentId), - resource, - facts: { - ownerPersonId: principal.personId, - state: owned.status, - approverPersonIds: [], - approverServicePrincipalIds: [], - internalEvidenceEnabled: false, - } satisfies ReceiptAccessFacts, - authorityVersion: AuthorityVersion.make(`receipt:${owned.revision}`), - }, - ], - }, - grants: [ - decodeGrant({ - grantId: GrantId.make(`receipt-owner:${receiptId}`), - subject: principal, - capability: { type: CapabilityTypeId.make("receipts.read-owned") }, - scope: Scope.Resource({ resource }), - startAt: AuthorizationInstant.make("1970-01-01T00:00:00.000Z"), - endAt: null, - requirements: [], - source: AuthorityRef.make("economy_receipts.owner_person_id"), - revision: owned.revision, - }), - ], - authorizationInstant: authenticated.authorizationInstant, - }); - - if (!Predicate.isTagged(evaluation, "Allow")) { - return yield* Problem.make("authority.denied"); - } - - return owned.file; - }), - ); - - return yield* readPrivateReceiptFile(file, fileStore, options.config.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. - */ -export const readApprovalReceiptFile = ( - request: Request, - receiptId: string, - options: ReceiptApiHttpOptions, - fileStore: ReceiptFileStore, - barrier: ReceiptE2ETransactionBarrier | undefined, -) => { - const presentation = personPresentation(request); - let synchronized = false; - - return Effect.gen(function* () { - const sql = yield* Database; - - const file = yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; - - const authenticated = yield* resolveRequestCredentialInTransaction( - request, - "OAuthUserBearer", - { now: options.now }, - ); - - const principal = authenticated.credential.principal; - - if (!Predicate.isTagged(principal, "Person")) { - return yield* Problem.unauthenticated(presentation); - } - - if (barrier !== undefined) { - synchronized = yield* barrier(request, receiptId, "file-read"); - } - - return yield* Economy.use(({ readReceiptFileForApproval }) => - readReceiptFileForApproval( - receiptId, - principal.personId, - authenticated.authorizationInstant, - ), - ).pipe( - Effect.catchTag("ReceiptNotFound", () => Effect.fail(Problem.make("resource.not-found"))), - storedReceiptProblems, - ); - }), - ); - - return yield* readPrivateReceiptFile( - file, - fileStore, - options.config.maxFileBytes, - synchronized ? { [RECEIPT_E2E_CONCURRENCY_RESPONSE_HEADER]: "1" } : {}, - ); - }).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, -) => - Effect.gen(function* () { - const authorizationInstant = AuthorizationInstant.make(yield* currentInstant(options.now)); - - const sql = yield* Database; - - return yield* sql.withTransaction( - Effect.gen(function* () { - yield* sql` - SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY - `.pipe(Effect.asVoid); - - // 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), - ).pipe( - Effect.catchTag("IdentitySessionNotFound", () => - Effect.fail( - Problem.unauthenticated(personPresentation(request, nativeCookieChallenge)), - ), - ), - ); - - const personId = actor.personId; - - const rows = yield* sql` - SELECT owner_person_id AS "ownerPersonId", department_id AS "departmentId", - status, revision - FROM public.economy_receipts - WHERE receipt_id = ${receiptId} - `; - - const row = rows[0]; - - if (row === undefined) return yield* new ReceiptNotFound({ receiptId }); - const principal = { _tag: "Person" as const, personId }; - - const context = { - domainId: RECEIPT_DOMAIN_ID, - departmentId: DepartmentId.make(row.departmentId), - resource: { - kind: RECEIPT_RESOURCE_KIND, - id: ResourceId.make(receiptId), - }, - facts: { - ownerPersonId: PersonId.make(row.ownerPersonId), - state: row.status, - approverPersonIds: [], - approverServicePrincipalIds: [], - internalEvidenceEnabled: options.config.e2e !== undefined, - } satisfies ReceiptAccessFacts, - authorityVersion: AuthorityVersion.make(`receipt:${row.revision}`), - }; - - const grant = decodeGrant({ - grantId: GrantId.make(`internal-evidence:${receiptId}:${personId}`), - subject: principal, - capability: { type: READ_INTERNAL_RECEIPT_EVIDENCE_CAPABILITY }, - scope: Scope.And({ - left: Scope.Domain({ domainId: RECEIPT_DOMAIN_ID }), - right: Scope.And({ - left: Scope.Department({ departmentId: context.departmentId }), - right: Scope.Resource({ resource: context.resource }), - }), - }), - startAt: AuthorizationInstant.make("1970-01-01T00:00:00.000Z"), - endAt: null, - requirements: [], - source: AuthorityRef.make("backend.receipt.internal-evidence"), - revision: row.revision, - }); - - const evaluation = evaluateAccess({ - spec: INTERNAL_RECEIPT_EVIDENCE_ACCESS, - credential: CredentialOutcomeSchema.cases.Accepted.make({ - mechanism: CredentialMechanismSchema.cases.BetterAuthCookie.make({}), - principal, - evidenceRef: CredentialEvidenceRef.make("better-auth:resolved-session"), - }), - resolution: { selection: "ExactlyOne", contexts: [context] }, - grants: [grant], - authorizationInstant, - }); - - // The evidence spec reveals every denial of its accepted credential. - if (!Predicate.isTagged(evaluation, "Allow")) { - return yield* Problem.make("authority.denied"); - } - - const evidence = yield* Economy.use(({ readReceiptLifecycleEvidence }) => - readReceiptLifecycleEvidence(receiptId, personId), - ); - - const encoded = yield* Schema.encodeEffect(ReceiptLifecycleEvidenceResponse)(evidence).pipe( - Effect.mapError(() => Problem.make("receipts.unavailable")), - ); - - return jsonResponse(encoded); - }), - ); - }).pipe(receiptProblems); diff --git a/apps/backend/src/receipt/http-representation.ts b/apps/backend/src/receipt/http-representation.ts deleted file mode 100644 index 13962c24..00000000 --- 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.test.ts b/apps/backend/src/receipt/http.test.ts deleted file mode 100644 index 80b1427a..00000000 --- a/apps/backend/src/receipt/http.test.ts +++ /dev/null @@ -1,2128 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { - AcceptedOAuthServiceCredential, - CredentialMechanismSchema, - PrincipalSchema, - CredentialOutcomeSchema, - CredentialEvidenceRef, - ServicePrincipalId, - ServicePrincipalGrantAuthority, - NATIVE_API_PROTECTED_RESOURCE, - RECEIPT_APPROVAL_QUEUE_OPERATION, - 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, - IdentityEngineError, - IdentitySessionNotFound, - type IdentityOperations, -} 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, - ReceiptDecodeError, - ReceiptFileService, - ReceiptNotFound, - ReceiptPersistenceError, - ReceiptOwnerDenied, - ReceiptScopeDenied, - UnauthenticatedActor, - type EconomyOperations, - ReceiptCommandRequestSchema, - type ReceiptCommandRequest, - type ReceiptApprovalFileReadFailure, - type ReceiptCommandPrincipal, - type ReceiptFailure, - type ReceiptLifecycleEvidenceProjection, - type ReceiptFile, - 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 { deriveHttpIdentity, deriveStrongETag } from "../http-semantics.js"; -import { - makeInternalReceiptTestHttp, - makeReceiptTestHttp as makeReceiptApiHttp, -} from "../test/native-http.js"; - -import type { ReceiptApiConfig } from "./config.js"; -import { resolveRequestCredentialAtInstant } from "../authority.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; - -type ReceiptAccessRow = { - readonly ownerPersonId: string; - readonly departmentId: string; - readonly status: string; - readonly revision: number; -}; - -const personId = PersonId.make("person-receipt-http"); - -const departmentOne = DepartmentId.make("department-one"); - -const evaluatedAt = "2026-08-24T12:00:00.000Z"; - -const receiptId = ReceiptId.make("receipt-one"); - -const visualId = ReceiptVisualId.make("visual-one"); - -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", - maxFileBytes: 1024, - now: () => evaluatedAt, - nextReceiptId: () => receiptId, - nextVisualId: () => visualId, -}; - -const pendingReceipt = (overrides: Partial = {}): ProjectionRow => ({ - receiptId, - visualId, - ownerPersonId: personId, - departmentId: departmentOne, - amountOre: "1200", - currency: "NOK", - description: "bus ticket", - receiptDate: "2026-08-01", - submittedAt: evaluatedAt, - approvedAt: null, - status: "Pending", - revision: 0, - ...overrides, -}); - -const settlementEvidence = ( - overrides: Partial = {}, -): ReceiptSettlementEvidence => - Schema.decodeSync(ReceiptSettlementEvidenceSchema)({ - settlementId: "settlement-one", - receiptId, - amountOre: 1200, - currency: "NOK", - paymentDestinationFingerprint: "a".repeat(64), - externalAuthority: "Bank AS", - externalReference: "external-reference-1", - settledAt: "2026-08-24T11:00:00.000Z", - recordedByPersonId: personId, - recordedAt: evaluatedAt, - receiptRevision: 1, - ...overrides, - }); - -const receiptEtag = (id: string, revision: number): string => - deriveStrongETag({ - representationKind: "ReceiptResource", - resourceIdentity: id, - version: revision, - }); - -const fileService = { - stage: () => Effect.void, - apply: () => Effect.void, -}; - -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, -}; - -interface HarnessOptions { - readonly serviceApproval?: ServicePrincipalReceiptGrantAuthority; - readonly unauthenticated?: boolean; - readonly privateFileOwner?: string; - readonly privateFileUnavailable?: boolean; - readonly approvalFileRow?: ProjectionRow; - readonly approvalFileFailure?: "Decode" | "Inactive" | "Scope"; - readonly approvalFileContentType?: "image/jpeg" | "image/png" | "application/pdf"; - readonly ownedRows?: ReadonlyArray; - readonly approvalRows?: ReadonlyArray; - readonly commandFailure?: ReceiptScopeDenied | ReceiptNotFound | ReceiptPersistenceError; - readonly settlementRows?: ReadonlyArray; - readonly settlementEvidence?: ReceiptSettlementEvidence; - readonly evidenceAccessRows?: ReadonlyArray; - readonly evidenceResult?: ReceiptLifecycleEvidenceProjection; - readonly revokeSessionAfterSnapshotRead?: boolean; - readonly initialCommittedVersion?: number; - readonly identitySnapshotFailure?: IdentityEngineError; -} - -type RevocableReceiptAuthority = "Owner" | "Approval"; - -const transactionId = Database.use( - (sql) => sql<{ id: string }>`SELECT pg_current_xact_id()::text AS id`, -).pipe( - Effect.orDie, - Effect.map((rows) => rows[0]!.id), -); - -const transactionIsolation = Database.use( - (sql) => sql<{ isolation: string }>`SELECT current_setting('transaction_isolation') AS isolation`, -).pipe( - Effect.orDie, - Effect.map((rows) => rows[0]!.isolation), -); - -const harness = (options: HarnessOptions = {}) => { - const serviceCredential: AcceptedOAuthServiceCredential | undefined = - options.serviceApproval === undefined - ? undefined - : AcceptedOAuthServiceCredential.make({ - mechanism: CredentialMechanismSchema.cases.OAuthServiceBearer.make({}), - principal: PrincipalSchema.cases.ServicePrincipal.make({ - servicePrincipalId: options.serviceApproval.servicePrincipalId, - }), - evidenceRef: CredentialEvidenceRef.make( - "oauth:ServicePrincipal:receipt-unit:client:1970000000", - ), - }); - - let privateFileReads = 0; - - const approvalFileQueries: Array<{ - readonly receiptId: string; - readonly personId: string; - readonly authorizationInstant: string; - readonly snapshotIsolation: string; - }> = []; - - 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 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; - - const sourceReceipt = (command: ReceiptCommandRequest): ProjectionRow => { - if (Predicate.isTagged(command, "SubmitReceipt")) { - return pendingReceipt({ - receiptId: allocations.at(-1)?.receiptId ?? receiptId, - visualId: allocations.at(-1)?.visualId ?? visualId, - departmentId: DepartmentId.make(String(command.departmentId ?? departmentOne)), - amountOre: String(command.amountOre), - description: String(command.description), - receiptDate: String(command.receiptDate), - }); - } - - return ( - [...(options.ownedRows ?? []), ...(options.approvalRows ?? [])].find( - (row) => row.receiptId === command.receiptId, - ) ?? pendingReceipt() - ); - }; - - const receiptFromProjection = (row: ProjectionRow): Receipt => - new Receipt({ - ...row, - amountOre: Number(row.amountOre), - approvedAt: row.status === "Approved" ? "2026-08-24T12:00:00.000Z" : null, - paymentAccountCiphertext: "encrypted", - file: ReceiptFileSchema.make({ - fileRef: "staging/file-one", - objectKey: "committed/file-one", - contentType: "image/png", - byteLength: 4, - sha256: "aa".repeat(32), - }), - }); - - const makeEconomy = (sql: DatabaseOperations) => { - 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 })), - ); - - commands.push(command); - principals.push(principal); - allocations.push(allocation); - commandTransactionIds.push(yield* transactionId); - const source = sourceReceipt(command); - - const nextRevision = "expectedRevision" in command ? command.expectedRevision + 1 : 0; - - const status = Predicate.isTagged(command, "WithdrawPendingReceipt") - ? "Withdrawn" - : Predicate.isTagged(command, "ApproveReceipt") - ? "Approved" - : Predicate.isTagged(command, "RejectReceipt") - ? "Rejected" - : "Pending"; - - const receipt = receiptFromProjection({ - ...source, - description: "description" in command ? command.description : source.description, - amountOre: String("amountOre" in command ? command.amountOre : source.amountOre), - receiptDate: "receiptDate" in command ? command.receiptDate : source.receiptDate, - status, - revision: nextRevision, - }); - - return { - observation: { - commandId: String(command.commandId), - receiptId: receipt.receiptId, - visualId: receipt.visualId, - status: receipt.status, - revision: receipt.revision, - replayed: false, - }, - receipt, - replayed: false, - outboxCount: 0, - }; - }).pipe(Effect.provideService(Database, sql)); - - const authorizeReceiptMutation: EconomyOperations["authorizeReceiptMutation"] = ( - target, - principal, - ) => - Effect.suspend(() => { - authorizationChecks.push(target._tag); - - if (Predicate.isTagged(target, "SubmitReceipt")) { - return Effect.succeed( - ReceiptMutationAuthorization[target._tag]({ - principal, - actor: { - personId: principal.personId, - departmentId: target.departmentId ?? departmentOne, - active: true, - approvalScope: ApprovalScopeSchema.cases.None.make({}), - }, - departmentId: target.departmentId ?? departmentOne, - paymentAccountCiphertext: "encrypted", - }), - ); - } - - const approval = - Predicate.isTagged(target, "ApproveReceipt") || - Predicate.isTagged(target, "RejectReceipt") || - Predicate.isTagged(target, "ReopenRejectedReceipt"); - - const source = (approval ? options.approvalRows : options.ownedRows)?.find( - (row) => row.receiptId === target.receiptId, - ); - - if (source === undefined) { - return Effect.fail( - approval - ? new ReceiptScopeDenied({ - receiptId: target.receiptId, - departmentId: departmentOne, - }) - : new ReceiptNotFound({ receiptId: target.receiptId }), - ); - } - - if (revokedAuthority === (approval ? "Approval" : "Owner")) { - return Effect.fail( - approval - ? new ReceiptScopeDenied({ - receiptId: target.receiptId, - departmentId: source.departmentId, - }) - : new ReceiptOwnerDenied({ - receiptId: target.receiptId, - personId: principal.personId, - }), - ); - } - - const authorization: ReceiptMutationAuthorization = ReceiptMutationAuthorization[ - target._tag - ]({ - principal, - actor: { - personId: principal.personId, - departmentId: source.departmentId, - active: true, - approvalScope: approval - ? ApprovalScopeSchema.cases.Department.make({ departmentId: source.departmentId }) - : ApprovalScopeSchema.cases.None.make({}), - }, - current: receiptFromProjection(source), - }); - - return Effect.succeed(authorization); - }); - - const executeAuthorizedReceipt: EconomyOperations["executeAuthorizedReceipt"] = ( - input, - authorization, - allocation, - ) => executeReceipt(input, authorization.principal, allocation); - - const readReceiptSettlementRevision: EconomyOperations["readReceiptSettlementRevision"] = ( - requestedReceiptId, - ) => - Effect.suspend(() => { - const source = (options.settlementRows ?? []).find( - (row) => row.receiptId === requestedReceiptId, - ); - - return source === undefined - ? Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })) - : Effect.succeed(source.revision); - }); - - const recordReceiptSettlement: EconomyOperations["recordReceiptSettlement"] = ( - command, - principal, - ) => - Effect.suspend(() => { - const source = (options.settlementRows ?? []).find( - (row) => row.receiptId === command.receiptId, - ); - - if (source === undefined) { - return Effect.fail(new ReceiptNotFound({ receiptId: command.receiptId })); - } - - settlementCommands.push(command); - - const receipt = Struct.assign(receiptFromProjection(source), { - revision: source.revision + 1, - }); - - const evidence = settlementEvidence({ - ...options.settlementEvidence, - receiptId: receipt.receiptId, - amountOre: receipt.amountOre, - currency: "NOK", - recordedByPersonId: principal.personId, - receiptRevision: receipt.revision, - }); - - return Effect.succeed({ - observation: { - commandId: String(command.commandId), - receiptId: receipt.receiptId, - settlementId: evidence.settlementId, - revision: receipt.revision, - replayed: false, - }, - receipt, - settlement: evidence, - replayed: false, - outboxCount: 1, - }); - }); - - const economy: EconomyOperations = { - executeReceipt, - authorizeReceiptMutation, - executeAuthorizedReceipt, - readReceiptSettlementRevision, - recordReceiptSettlement, - listReceiptsForSettlement: () => - Effect.succeed({ - items: (options.settlementRows ?? []).map((row) => { - if (row.status !== "Approved" || row.approvedAt === null) - throw new Error("Settlement queue fixtures must be approved"); - - return { ...row, status: row.status, approvedAt: row.approvedAt }; - }), - }), - readReceiptSettlementForFinance: (requestedReceiptId, queryPersonId) => - Effect.suspend(() => { - settlementReads.push({ receiptId: requestedReceiptId, personId: queryPersonId }); - const evidence = options.settlementEvidence; - - return evidence === undefined || evidence.receiptId !== requestedReceiptId - ? Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })) - : Effect.succeed(evidence); - }), - listOwnedReceipts: () => - Effect.succeed({ - items: (options.ownedRows ?? []).map((row) => ({ - ...row, - settlement: - options.settlementEvidence?.receiptId === row.receiptId - ? options.settlementEvidence - : null, - })), - }), - listReceiptsForApproval: (queryPersonId, authorizationInstant, status) => { - approvalQueries.push({ personId: queryPersonId, authorizationInstant, status }); - - return Effect.succeed({ items: options.approvalRows ?? [] }); - }, - readReceiptFileForApproval: (requestedReceiptId, queryPersonId, authorizationInstant) => - transactionIsolation.pipe( - Effect.flatMap((snapshotIsolation) => - Effect.suspend(() => { - approvalFileQueries.push({ - receiptId: requestedReceiptId, - personId: queryPersonId, - authorizationInstant, - snapshotIsolation, - }); - const source = options.approvalFileRow; - - if (options.approvalFileFailure === "Decode") { - return Effect.fail( - new ReceiptDecodeError({ message: "malformed stored file metadata" }), - ); - } - - if (options.approvalFileFailure === "Inactive") { - return Effect.fail(new InactiveActor({ personId: queryPersonId })); - } - - if (options.approvalFileFailure === "Scope") { - return Effect.fail( - new ReceiptScopeDenied({ - receiptId: requestedReceiptId, - departmentId: source?.departmentId ?? departmentOne, - }), - ); - } - - if (source === undefined || source.receiptId !== requestedReceiptId) { - return Effect.fail(new ReceiptNotFound({ receiptId: requestedReceiptId })); - } - - return Effect.succeed({ - fileRef: "staging/approval-file", - objectKey: "committed/approval-file", - contentType: options.approvalFileContentType ?? "application/pdf", - byteLength: 4, - sha256: "b".repeat(64), - }); - }), - ), - Effect.provideService(Database, sql), - ), - readReceiptLifecycleEvidence: (id, ownerPersonId) => - Effect.sync(() => { - evidenceReads.push({ receiptId: id, personId: ownerPersonId }); - - if (options.evidenceResult === undefined) - throw new Error("unexpected receipt evidence read"); - - return options.evidenceResult; - }), - receiptStatusTotals: Effect.succeed([]), - listStaleOutboxClaims: () => Effect.succeed([]), - recoverStaleOutboxClaim: () => Effect.succeed(0), - deliverNextOutboxEffect: () => Effect.succeed(ReceiptOutboxDeliveryResult.Idle()), - }; - - return economy; - }; - - const database = backendDatabase( - Database.use((sql) => - Effect.gen(function* () { - yield* sql`CREATE TABLE test_credential (version integer NOT NULL)`; - yield* sql`INSERT INTO test_credential VALUES (1)`; - - const evidenceRows = (options.evidenceAccessRows ?? []).map((row) => ({ - ...row, - receiptId, - ownerPersonId: row.ownerPersonId, - })); - - const privateRows = - options.privateFileOwner === undefined - ? [] - : [ - { - receiptId: ReceiptId.make("private"), - ownerPersonId: PersonId.make(options.privateFileOwner), - departmentId: departmentOne, - status: "Approved", - revision: 1, - }, - ]; - - for (const row of [...evidenceRows, ...privateRows]) - yield* sql`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 (${row.receiptId},${"visual-" + row.receiptId},${row.ownerPersonId},${row.departmentId},1200,'NOK','Evidence','2026-08-24',${evaluatedAt}::timestamptz,${row.status},${row.status === "Approved" ? evaluatedAt : null}::timestamptz,'encrypted','staging/private','committed/private','application/pdf',4,${"a".repeat(64)},${row.revision})`; - }), - ), - ); - - const economyLayer = Layer.effect(Economy, Effect.map(Database, makeEconomy)).pipe( - Layer.provide(database.layer), - ); - - const identitySnapshot = IdentitySnapshot.of({ - resolveSession: (cookieHeader) => - Effect.gen(function* () { - identitySnapshotIsolations.push(yield* transactionIsolation); - - const rows = yield* Database.use( - (sql) => sql<{ version: number }>`SELECT version FROM test_credential`, - ).pipe(Effect.orDie); - - const observedVersion = rows[0]!.version; - identitySnapshotVersions.push(observedVersion); - - if (options.identitySnapshotFailure !== undefined) - return yield* options.identitySnapshotFailure; - - if (cookieHeader === undefined || options.unauthenticated === true || observedVersion === 2) - return yield* new IdentitySessionNotFound(); - - return new IdentityActor({ - personId, - sessionId: "receipt-http-session", - expiresAt: DateTime.makeUnsafe("2031-09-16T12: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"), - }); - - const servicePrincipalGrantAuthority = ServicePrincipalGrantAuthority.of({ - readReceiptApprovalCandidates: () => - options.serviceApproval - ? Effect.succeed(options.serviceApproval) - : Effect.die("unexpected service receipt read"), - createGrant: () => Effect.die("unexpected grant write"), - endGrant: () => Effect.die("unexpected grant write"), - revokeGrant: () => Effect.die("unexpected grant write"), - }); - - 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"), - }), - ), - 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 oauthCredentialAuthority = OAuthCredentialAuthority.of({ - resolve: (request, expected) => - Effect.sync(() => { - if ( - request.headers.get("authorization") === `Bearer ${personBearer}` && - expected !== "OAuthServiceBearer" - ) { - return CredentialOutcomeSchema.cases.Accepted.make({ - mechanism: CredentialMechanismSchema.cases.OAuthUserBearer.make({}), - principal: PrincipalSchema.cases.Person.make({ personId }), - evidenceRef: CredentialEvidenceRef.make("oauth:Person:receipt-user:client:1970000000"), - }); - } - - return options.serviceApproval !== undefined && - request.headers.get("authorization") === `Bearer ${serviceBearer}` && - expected === "Either" && - !revokedServiceBearer - ? 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, - Layer.succeed(IdentitySnapshot, identitySnapshot), - Layer.succeed(ServicePrincipalGrantAuthority, servicePrincipalGrantAuthority), - Layer.succeed(Identity, identity), - Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), - ); - - 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; - - 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 - ? Effect.fail( - new ReceiptFileStoreError({ - operation: "readCommitted", - message: "private bytes unavailable", - }), - ) - : Effect.succeed(new Uint8Array([1, 2, 3, 4])); - }), - }, - } satisfies ReceiptTestHttpOptions; - - return { - http: makeReceiptApiHttp(httpOptions, services), - internalHttp: makeInternalReceiptTestHttp(httpOptions, services), - commands, - settlementCommands, - settlementReads: () => settlementReads, - privateFileReads: () => privateFileReads, - principals, - allocations, - approvalQueries, - approvalFileQueries: () => approvalFileQueries, - evidenceReads, - nativeReceiptCount: () => - database.run( - Database.use( - (sql) => - sql<{ - count: number; - }>`SELECT count(*)::integer AS count FROM public.native_http_idempotency_receipts`, - ).pipe(Effect.map((rows) => rows[0]!.count)), - ), - run, - currentTransactionId: transactionId, - mutationTransactions: () => - Effect.gen(function* () { - return { - commandTransactionIds, - receiptWriteTransactionIds: yield* database.run( - Database.use( - (sql) => - sql<{ - id: string; - }>`SELECT xmin::text AS id FROM public.native_http_idempotency_receipts ORDER BY committed_at`, - ).pipe(Effect.map((rows) => rows.map((row) => row.id))), - ), - }; - }), - evidenceCounts: () => ({ evidenceReads }), - snapshotObservations: () => - Effect.gen(function* () { - return { - identitySnapshotIsolations, - identitySnapshotVersions, - committedVersion: yield* database.run( - Database.use( - (sql) => sql<{ version: number }>`SELECT version FROM test_credential`, - ).pipe(Effect.map((rows) => rows[0]!.version)), - ), - }; - }), - revokeCredential: () => - database.run( - Database.use((sql) => sql`UPDATE test_credential SET version=2`).pipe(Effect.asVoid), - ), - revokeAuthority: (authority: RevocableReceiptAuthority) => { - revokedAuthority = authority; - }, - revokeServiceBearer: () => { - 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, - }); -}; - -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), - }); - -const readJson = (response: Response) => - Effect.promise(() => response.json()).pipe( - Effect.flatMap(Schema.decodeUnknownEffect(Schema.Record(Schema.String, Schema.Json))), - ); - -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, - }); - }); - -describe("receipt v0.2 HTTP contract", () => { - it.live("lists only the authenticated person's receipts with private projection ETags", () => - Effect.gen(function* () { - const state = harness({ ownedRows: [pendingReceipt()] }); - const response = yield* request(state.http, "/api/receipts?status=Pending"); - const body = yield* readJson(response); - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); - expect(body).toEqual({ - items: [ - yield* Schema.decodeEffect(ReceiptListItem)({ - ...pendingReceipt(), - amountOre: 1200, - settlement: null, - etag: receiptEtag(receiptId, 0), - }), - ], - }); - expect(state.authorizationPrincipalCalls()).toBe(1); - }), - ); - - it.live("lists only approved unsettled receipts in the private settlement queue", () => - Effect.gen(function* () { - const queueRow = pendingReceipt({ - status: "Approved", - approvedAt: evaluatedAt, - revision: 2, - }); - - const state = harness({ settlementRows: [queueRow] }); - const response = yield* request(state.http, "/api/receipt-settlement-queue"); - - 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({ - items: [ - { - receiptId, - visualId, - ownerPersonId: personId, - departmentId: departmentOne, - description: "bus ticket", - amountOre: 1200, - currency: "NOK", - receiptDate: "2026-08-01", - status: "Approved", - approvedAt: evaluatedAt, - revision: 2, - etag: receiptEtag(receiptId, 2), - }, - ], - }); - }), - ); - - 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", () => - Effect.gen(function* () { - const queueRow = pendingReceipt({ - status: "Approved", - approvedAt: evaluatedAt, - revision: 2, - }); - - const evidence = settlementEvidence({ receiptRevision: 3 }); - const state = harness({ settlementRows: [queueRow], settlementEvidence: evidence }); - - const payload = { - 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 changedReplay = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:settle`, - key, - 2, - { - ...payload, - externalReference: "changed-external-reference", - }, - ); - - 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", - }); - - 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(state.settlementCommands).toHaveLength(1); - }), - ); - - it.live("reads finance settlement evidence privately and conceals a missing settlement", () => - Effect.gen(function* () { - const evidence = settlementEvidence(); - const state = harness({ settlementEvidence: evidence }); - - const visible = yield* request( - state.http, - `/api/receipt-settlement-queue/${encodeURIComponent(receiptId)}`, - ); - - const concealed = yield* request(state.http, "/api/receipt-settlement-queue/not-visible"); - - expect(visible.status).toBe(200); - expect(visible.headers.get("cache-control")).toBe("private, no-store"); - expect(yield* readJson(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", - }); - }), - ); - - it.live("returns the frozen RFC 9457 credential problem", () => - Effect.gen(function* () { - const response = yield* request( - harness({ unauthenticated: true }).http, - "/api/receipts", - undefined, - false, - ); - - yield* expectProblem(response, { - code: "credential.missing", - title: "Credential required", - status: 401, - detail: "A credential is required for this operation.", - }); - }), - ); - - it("derives opaque command identity only from credential, operation, target, and idempotency key", () => { - const first = deriveHttpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: "receipts.submitReceipt", - normalizedTarget: "/api/receipts", - idempotencyKey: IdempotencyKey.make("submit-receipt-idempotency-key"), - }); - - const second = deriveHttpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: "receipts.submitReceipt", - normalizedTarget: "/api/receipts", - idempotencyKey: IdempotencyKey.make("another-receipt-idempotency-key"), - }); - - expect(first.commandId).toMatch(/^httpv2_[A-Za-z0-9_-]{43}$/u); - expect(first.identitySha256).toMatch(/^[a-f0-9]{64}$/u); - expect(second.commandId).not.toBe(first.commandId); - expect(first.commandId).not.toContain(personId); - expect(first.commandId).not.toContain("submit-receipt-idempotency-key"); - }); - - it.live( - "persists and replays exact 201 and 200 response capsules in transaction-scoped native receipts", - () => - Effect.gen(function* () { - const state = harness(); - const executedIn: Array = []; - - const cases = [ - { - key: "created-response-idempotency-key", - requestSha256: "a".repeat(64), - operationId: "receipts.submitReceipt", - capsule: { - status: 201, - mediaType: "application/json", - bodyBytes: new TextEncoder().encode('{"receiptId":"receipt-one"}'), - headers: { - "content-type": "application/json", - location: `/api/receipts/${receiptId}`, - etag: receiptEtag(receiptId, 0), - }, - }, - }, - { - key: "entity-response-idempotency-key", - requestSha256: "b".repeat(64), - operationId: "receipts.withdrawReceipt", - capsule: { - status: 200, - mediaType: "application/json", - bodyBytes: new TextEncoder().encode('{"status":"Withdrawn"}'), - headers: { - "content-type": "application/json", - etag: receiptEtag(receiptId, 1), - }, - }, - }, - ] as const; - - for (const item of cases) { - const derived = deriveHttpIdentity({ - credentialSubject: `Person:${personId}`, - qualifiedOperationId: item.operationId, - normalizedTarget: "/api/receipts", - idempotencyKey: IdempotencyKey.make(item.key), - }); - - const identity = { - identitySha256: derived.identitySha256, - requestSha256: item.requestSha256, - operationId: item.operationId, - }; - - const execute = Effect.gen(function* () { - executedIn.push(yield* state.currentTransactionId); - - return item.capsule; - }); - - const plan = Effect.succeed({ identity, execute }); - const committed = yield* state.run(executeNativeHttpCommandPostgres(plan)); - const replayed = yield* state.run(executeNativeHttpCommandPostgres(plan)); - - expect(committed).toEqual(NativeHttpCommandOutcome.Committed({ response: item.capsule })); - expect(replayed._tag).toBe("Replay"); - - if (!Predicate.isTagged(replayed, "Replay")) - throw new Error("expected native receipt replay"); - - expect({ - ...replayed.response, - bodyBytes: - replayed.response.bodyBytes === null ? null : Array.from(replayed.response.bodyBytes), - }).toEqual({ ...item.capsule, bodyBytes: Array.from(item.capsule.bodyBytes) }); - } - - expect(executedIn).toHaveLength(2); - expect(yield* state.nativeReceiptCount()).toBe(2); - expect((yield* state.mutationTransactions()).receiptWriteTransactionIds).toEqual( - executedIn, - ); - }), - ); - - it.live("preserves exact 201 and 200 statuses through the public HTTP replay path", () => - 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"); - expect(submitState.commands).toHaveLength(1); - - const actionState = harness({ ownedRows: [pendingReceipt()] }); - - const withdrawn = yield* actionRequest( - actionState.http, - `/api/receipts/${receiptId}:withdraw`, - "withdraw-http-replay-key-0001", - ); - - const withdrawnBody = yield* readJson(withdrawn); - - const withdrawReplay = yield* actionRequest( - actionState.http, - `/api/receipts/${receiptId}:withdraw`, - "withdraw-http-replay-key-0001", - ); - - 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); - }), - ); - - it.live( - "authorizes and writes in one snapshot, then rejects replay after credential revocation", - () => - Effect.gen(function* () { - const state = harness({ ownedRows: [pendingReceipt()] }); - const pathname = `/api/receipts/${receiptId}:withdraw`; - const idempotencyKey = "withdraw-current-auth-key-0001"; - - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); - expect(yield* state.snapshotObservations()).toEqual({ - identitySnapshotIsolations: ["serializable"], - identitySnapshotVersions: [1], - - committedVersion: 1, - }); - const committedTransactions = yield* state.mutationTransactions(); - expect(committedTransactions.commandTransactionIds).toHaveLength(1); - 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); - 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"; - - const accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); - expect(state.commands).toHaveLength(1); - expect(state.authorizationChecks()).toEqual(["WithdrawPendingReceipt"]); - - 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); - }), - ); - - 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 accepted = yield* actionRequest(state.http, pathname, idempotencyKey); - expect(accepted.status).toBe(200); - expect(state.commands).toHaveLength(1); - expect(state.authorizationChecks()).toEqual(["ApproveReceipt"]); - - 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); - }), - ); - - 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(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", () => - Effect.gen(function* () { - const actions = [ - ["withdraw", "WithdrawPendingReceipt"], - ["approve", "ApproveReceipt"], - ["reject", "RejectReceipt"], - ] as const; - - 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`, - ); - - 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(state.commands).toHaveLength(1); - { - const observed = state.commands[0]; - expect(observed).toHaveProperty("_tag", commandTag); - expect(observed).toMatchObject({ 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); - }), - ); - - it.live("returns RFC 9457 malformed-request problems for manual and schema query failures", () => - 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.", - }); - } - }), - ); - - it.live("rejects query parameters on semantic receipt commands before execution", () => - Effect.gen(function* () { - const state = harness(); - - const response = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:approve?unexpected=1`, - "approve-query-rejected-0109", - ); - - yield* expectProblem(response, { - code: "request.malformed", - title: "Malformed request", - status: 400, - detail: "The request is malformed.", - }); - 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", - }), - }); - - const response = yield* actionRequest( - state.http, - `/api/receipts/${receiptId}:reopen`, - "reopen-failed-command-0102", - ); - - expect(response.status, yield* Effect.promise(() => response.clone().text())).toBe(503); - - const body = yield* Schema.decodeUnknownEffect(ReceiptsReopenReceiptProblem)( - yield* Effect.promise(() => response.json()), - ); - - expect(body).toMatchObject({ code: "receipts.unavailable" }); - expect(yield* jsonText(body)).not.toContain("private SQL details"); - expect(yield* state.nativeReceiptCount()).toBe(0); - }), - ); - - it.live( - "service-principal queue items expose the same entity condition consumed by person decisions", - () => - Effect.gen(function* () { - for (const action of ["approve", "reopen"] as const) { - const receipt = pendingReceipt({ - status: action === "reopen" ? "Rejected" : "Pending", - revision: 2, - }); - - const grant = makeServicePrincipalReceiptGrant({ - grantId: "service-queue0102", - 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 service = harness({ - serviceApproval: { - servicePrincipalId: ServicePrincipalId.make("service0102"), - clientId: grant.clientId, - protectedResource: NATIVE_API_PROTECTED_RESOURCE, - candidates: [ - { - grant, - receipt: { - ...receipt, - receiptId: grant.receiptId, - visualId: yield* Schema.decodeEffect(ReceiptResource.fields.visualId)(visualId), - ownerPersonId: personId, - }, - }, - ], - rules: [], - }, - }); - - const listed = yield* request( - service.http, - `/api/receipt-approval-queue?status=${receipt.status}`, - { headers: { authorization: `Bearer ${serviceBearer}` } }, - false, - ); - - expect(listed.status).toBe(200); - - const body = yield* Schema.decodeUnknownEffect(ReceiptApprovalQueueResponse)( - yield* Effect.promise(() => listed.json()), - ); - - const item = body.items[0]!; - expect(item.etag).toBe(receiptEtag(receiptId, 2)); - const person = harness({ approvalRows: [receipt] }); - - 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: "{}", - }); - - expect(accepted.status).toBe(200); - } - }), - ); - - it.live( - "authenticates scoped service bearers at HTTP ingress without widening person access", - () => - Effect.gen(function* () { - const scoped = pendingReceipt(); - - 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 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 unscoped = harness({ - serviceApproval: { ...serviceApproval, candidates: [serviceApproval.candidates[1]!] }, - }); - - 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(userBearer.status).toBe(200); - expect(yield* readJson(userBearer)).toMatchObject({ items: [{ receiptId }] }); - }), - ); - - 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, - }); - - const state = harness({ approvalRows: [row] }); - - const listed = yield* request( - state.http, - `/api/receipt-approval-queue?status=${row.status}`, - ); - - const body = yield* Schema.decodeUnknownEffect(ReceiptApprovalQueueResponse)( - yield* Effect.promise(() => listed.json()), - ); - - const item = body.items[0]!; - expect(item.etag).toBe(receiptEtag(receiptId, 2)); - - 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: "{}", - }); - - expect(accepted.status).toBe(200); - } - }), - ); - - 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); - }), - ); -}); - -describe("internal receipt evidence separation", () => { - const internalPath = `/api/receipt-lifecycle-evidence-records/${receiptId}`; - - const evidence: ReceiptLifecycleEvidenceProjection = { - receiptId, - file: { - fileRef: "file-one", - objectKey: "committed/file-one", - contentType: "image/png", - byteLength: 4, - sha256: "a".repeat(64), - }, - settlement: settlementEvidence(), - outbox: [], - audit: [], - }; - - it.live("registers internal.readReceiptEvidence only on the internal Cookie surface", () => - Effect.gen(function* () { - const state = harness({ - evidenceAccessRows: [ - { - ownerPersonId: personId, - departmentId: departmentOne, - status: "Pending", - revision: 2, - }, - ], - evidenceResult: evidence, - }); - - expect((yield* request(state.http, internalPath)).status).toBe(404); - expect((yield* request(state.internalHttp, "/api/receipts")).status).toBe(404); - - const bearerOnly = yield* request( - state.internalHttp, - internalPath, - { headers: { authorization: "Bearer service-token" } }, - false, - ); - - 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.", - }); - - const response = yield* request(state.internalHttp, internalPath); - expect({ - status: response.status, - body: yield* Effect.promise(() => response.json()), - }).toEqual({ - status: 200, - body: 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.", - }); - expect(missing.evidenceReads).toEqual([]); - - const unavailable = harness({ - identitySnapshotFailure: new IdentityEngineError({ - 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.", - }); - expect(unavailable.evidenceReads).toEqual([]); - }), - ); -}); - -describe("private receipt owner reads", () => { - it.live("uses canonical owner authorization before binary storage IO", () => - Effect.gen(function* () { - const owner = harness({ privateFileOwner: personId }); - - const response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); - - 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(owner.privateFileReads()).toBe(1); - const foreign = harness({ privateFileOwner: "different-person" }); - - const denied = yield* foreign.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); - - expect(denied.status).toBe(404); - expect(foreign.privateFileReads()).toBe(0); - }), - ); - it.live("does not expose a successful attachment for unavailable private bytes", () => - Effect.gen(function* () { - const owner = harness({ privateFileOwner: personId, privateFileUnavailable: true }); - - const response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts/private/file", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); - - expect(response.status).toBe(503); - expect(response.headers.get("content-disposition")).toBeNull(); - }), - ); -}); - -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", - }); - - const response = yield* request( - state.http, - "/api/receipt-approval-queue/terminal-approval-file/file", - ); - - 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); - }), - ); - - it.live("does not widen scoped approver access into the owner route", () => - Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("approver-only-file") }), - }); - - const approval = yield* request( - state.http, - "/api/receipt-approval-queue/approver-only-file/file", - ); - - expect(approval.status).toBe(200); - - 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(state.privateFileReads()).toBe(1); - }), - ); - - it.live("keeps credential, absence, foreign scope, and inactive authority failures typed", () => - Effect.gen(function* () { - 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(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(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(missing.privateFileReads()).toBe(0); - - for (const approvalFileFailure of ["Scope", "Inactive"] as const) { - const denied = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("foreign-file") }), - 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(denied.privateFileReads()).toBe(0); - } - }), - ); - - it.live("does not expose file headers when the approved object is unavailable", () => - Effect.gen(function* () { - const state = harness({ - approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("missing-approval-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([]); - }), - ); - - 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 response = yield* request( - state.http, - "/api/receipt-approval-queue/malformed-approval-file/file", - ); - - yield* expectProblem(response, { - code: "receipts.unavailable", - title: "Receipts unavailable", - status: 503, - detail: "The receipt service is temporarily unavailable.", - }); - 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 response = yield* owner.http.fetch( - new Request("http://localhost/api/receipts", { - headers: { cookie: "better-auth.session_token=fixture" }, - }), - ); - - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("private, no-store"); - }), -); diff --git a/apps/backend/src/receipt/http.ts b/apps/backend/src/receipt/http.ts deleted file mode 100644 index 4995aabe..00000000 --- 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/r2.ts b/apps/backend/src/receipt/r2.ts index 289b1804..a9b20b1e 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, diff --git a/apps/backend/src/receipt/rpc.ts b/apps/backend/src/receipt/rpc.ts new file mode 100644 index 00000000..b242a605 --- /dev/null +++ b/apps/backend/src/receipt/rpc.ts @@ -0,0 +1,12 @@ +/** + * The ReceiptsRpcs and InternalReceiptsRpcs handlers. The operations to port, and the HTTP + * handlers they replace (`apps/backend/src/receipt/http*.ts` at the base commit), are listed in + * docs/specs/rpc-only.md. + */ +import { InternalReceiptsRpcs, ReceiptsRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const ReceiptsRpcHandlers = (_options: NativeRpcOptions) => ReceiptsRpcs.toLayer({}); + +export const InternalReceiptsRpcHandlers = (_options: NativeRpcOptions) => + InternalReceiptsRpcs.toLayer({}); diff --git a/apps/backend/src/recruitment/commands.http.test.ts b/apps/backend/src/recruitment/commands.http.test.ts deleted file mode 100644 index ed1a7206..00000000 --- a/apps/backend/src/recruitment/commands.http.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; -import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; -import { OrganizationLive } from "@vektorprogrammet/database/organization"; -import { ProfileLive } from "@vektorprogrammet/database/profile"; -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 { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; -import { - InterviewSchemaId, - Recruitment, - RecruitmentAssignmentCommandId, - RecruitmentConductCommandId, - RecruitmentInterviewId, - RecruitmentInvitationCapabilitySchema, - RecruitmentInvitationId, - RecruitmentInvitationTransition, - RecruitmentScheduleCommandId, -} from "@vektorprogrammet/domain/recruitment"; -import { CorrectInterviewAssessmentResponse } from "@vektorprogrammet/http-api"; -import { DateTime, Deferred, Effect, Fiber, Layer, Schedule, Schema } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { backendDatabase } from "../../test/database.js"; -import { makeRecruitmentTestHttp } from "../test/native-http.js"; - -const departmentId = DepartmentId.make("commands-department"); - -const leaderPersonId = PersonId.make("commands-leader"); - -const interviewerPersonId = PersonId.make("commands-interviewer"); - -const applicationId = PublicApplicationIdSchema.make("commands-application"); - -const interviewId = RecruitmentInterviewId.make("commands-interview"); - -const capability = RecruitmentInvitationCapabilitySchema.make("commands".padEnd(43, "_")); - -const questionIds = Array.from({ length: 8 }, (_, ordinal) => `commands-q${ordinal}`); - -const domain = RecruitmentLive.pipe( - Layer.provideMerge( - Layer.mergeAll(AdmissionsLive, ProfileLive).pipe(Layer.provideMerge(OrganizationLive)), - ), -); - -/** A finalized interview at revision 2, conducted by the assigned interviewer. */ -const finalizedInterview = Effect.gen(function* () { - const sql = yield* Database; - const recruitment = yield* Recruitment; - - const leader = AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ - personId: leaderPersonId, - departmentId, - active: true, - }); - - yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES (${departmentId}, 'Commands')`; - yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('commands-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 ('commands-period', ${departmentId}, 'commands-semester', '2031-09-01T00:00:00.000Z', '2031-10-01T00:00:00.000Z', 'commands-period-created') - `; - yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('commands-field', ${departmentId}, 'Computer Science')`; - 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 ('commands-applicant', 'commands@example.invalid', 'commands@example.invalid', 'Ada', 'Applicant', '90000000', 1, 'commands-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 (${applicationId}, 'commands-applicant', 'commands-period', ${departmentId}, 'commands-field', 2, '2031-09-10T12:00:00.000Z') - `; - yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES (${departmentId}, 'Commands', 'CMD', 'commands-department@example.invalid', 'Bergen')`; - yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('commands-team', ${departmentId}, 'Commands team')`; - yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${leaderPersonId}, 'Lise', 'Leader'), (${interviewerPersonId}, 'Ivar', 'Interviewer')`; - yield* sql` - INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, position_id, is_team_leader) - VALUES - ('commands-leader-membership', ${leaderPersonId}, 'commands-team', '2031-01-01T00:00:00.000Z', 'leader', TRUE), - ('commands-interviewer-membership', ${interviewerPersonId}, 'commands-team', '2031-01-01T00:00:00.000Z', 'assistant', FALSE) - `; - yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES (${interviewerPersonId}, 'commands-interviewer@example.invalid', '91111111')`; - yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count) VALUES ('commands-schema', 'Standard interview', 8)`; - - for (const [ordinal, questionId] of questionIds.entries()) { - yield* sql` - INSERT INTO public.recruitment_interview_schema_questions (interview_schema_id, question_id, ordinal, prompt, help_text, kind, alternatives) - VALUES ('commands-schema', ${questionId}, ${ordinal}, ${`Question ${ordinal}`}, NULL, 'text', '[]'::jsonb) - `; - } - - yield* recruitment.assignApplicant( - { - commandId: RecruitmentAssignmentCommandId.make("commands-assignment"), - applicationId, - interviewerPersonId, - interviewSchemaId: InterviewSchemaId.make("commands-schema"), - }, - { actor: leader, now: "2031-09-15T12:00:00.000Z", interviewId }, - ); - yield* recruitment.scheduleInterview( - { - commandId: RecruitmentScheduleCommandId.make("commands-schedule"), - interviewId, - expectedRevision: 0, - scheduledAt: "2031-09-20T10:00:00.000Z", - room: "A-101", - campus: "Main Campus", - mapLink: "https://maps.example.invalid/interview-room", - message: "Welcome to your interview.", - }, - { - actor: leader, - now: "2031-09-15T12:00:00.000Z", - invitationId: RecruitmentInvitationId.make("commands-invitation"), - responseCapability: capability, - }, - ); - yield* recruitment.transitionInvitation({ - capability, - transition: RecruitmentInvitationTransition.Confirm(), - now: "2031-09-15T12:01:00.000Z", - }); - yield* recruitment.finalizeInterview( - { - commandId: RecruitmentConductCommandId.make("commands-finalize"), - interviewId, - expectedRevision: 1, - answers: questionIds.map((questionId) => ({ questionId, answer: `Original ${questionId}` })), - score: { explanatoryPower: 4, roleModel: 5, suitability: 6 }, - recommendation: "Ja", - }, - { - actor: AdmissionPeriodActorSchema.cases.Member.make({ - personId: interviewerPersonId, - departmentId, - active: true, - }), - now: "2031-09-15T12:02:00.000Z", - }, - ); -}).pipe(Effect.provide(domain)); - -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"), -}); - -const persistedCorrection = Database.use( - (sql) => sql<{ - readonly corrections: number; - readonly correctionReceipts: number; - readonly audits: number; - readonly httpReceipts: number; - readonly revision: number; - }>` - SELECT - (SELECT count(*)::integer FROM public.recruitment_interview_correction_assessments) AS corrections, - (SELECT count(*)::integer FROM public.recruitment_interview_correction_command_receipts) AS "correctionReceipts", - (SELECT count(*)::integer FROM public.recruitment_interview_correction_audit) AS audits, - (SELECT count(*)::integer FROM public.native_http_idempotency_receipts) AS "httpReceipts", - (SELECT revision FROM public.recruitment_interviews WHERE interview_id = ${interviewId}) AS revision - `, -).pipe(Effect.map((rows) => rows[0])); - -const correctionBody = JSON.stringify({ - expectedRevision: 2, - answers: questionIds.map((questionId) => ({ questionId, answer: `Corrected ${questionId}` })), - score: { explanatoryPower: 7, roleModel: 8, suitability: 9 }, - recommendation: "Kanskje", -}); - -const fixture = () => { - const database = backendDatabase(finalizedInterview); - - const http = makeRecruitmentTestHttp( - { - 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, "_"), - }, - resolveActor: () => Effect.die("unexpected recruitment board actor"), - }, - Layer.mergeAll( - database.layer, - domain.pipe(Layer.provide(database.layer)), - Layer.succeed(IdentitySnapshot, identitySnapshot), - ), - ); - - 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 { database, request, competitor }; -}; - -describe("recruitment commands over HTTP 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"); - - expect(conduct.status).toBe(200); - - if (etag === null) throw new Error("The conduct read returned no entity tag"); - expect(yield* database.run(persistedCorrection)).toEqual({ - corrections: 0, - correctionReceipts: 0, - audits: 0, - httpReceipts: 0, - revision: 2, - }); - - 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. - const concurrentUpdate = yield* Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - yield* sql`UPDATE public.recruitment_interviews SET revision = revision WHERE interview_id = ${interviewId}`; - yield* Deferred.succeed(rowHeld, undefined); - - return yield* sql<{ readonly waiting: number }>` - SELECT count(*)::integer AS waiting - FROM pg_locks - WHERE locktype = 'transactionid' 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(competing, { local: true }), Effect.forkChild); - - 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, - }); - - 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({ - interviewId, - predecessorRevision: 2, - resultingRevision: 3, - replayed: false, - }); - - expect(yield* database.run(persistedCorrection)).toEqual({ - corrections: 1, - correctionReceipts: 1, - audits: 1, - 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, - }); - - expect(correction.status).toBe(400); - expect(yield* Effect.promise(() => correction.json())).toMatchObject({ - code: "request.malformed", - }); - expect(yield* database.run(persistedCorrection)).toEqual({ - corrections: 0, - correctionReceipts: 0, - audits: 0, - httpReceipts: 0, - revision: 2, - }); - }), - ); -}); diff --git a/apps/backend/src/recruitment/http-access.ts b/apps/backend/src/recruitment/http-access.ts deleted file mode 100644 index dd085fd2..00000000 --- a/apps/backend/src/recruitment/http-access.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** Recruitment HTTP access: invitation-capability authorization and person access contexts. */ -import type { - Database, - IdentitySnapshot, - OAuthCredentialAuthority, -} from "@vektorprogrammet/database"; -import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import type { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { - AuthorityRef, - AuthorityVersion, - AuthorizationInstant, - CapabilityId, - CredentialEvidenceRef, - DomainId, - GrantId, - INVITATION_RESPONSE_CAPABILITY, - RECRUITMENT_INVITATION_RESOURCE_KIND, - ResourceId, - ResourceKind, - Scope, - accessHttpStatus, - decodeGrant, - evaluateAccessJourney, - type AccessSpec, -} from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { DepartmentId, Organization } from "@vektorprogrammet/domain/organization"; -import { - Recruitment, - type RecruitmentActor, - type RecruitmentFailure, - type RecruitmentInterviewHttpSource, - 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 { - type OrganizationResolutionError, - resolveRequestPersonAuthorityInTransaction, -} from "../authority.js"; -import { authorizePerson, personPresentation } from "../http-api/problem.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; - -type GenericFacts = Schema.JsonObject; - -/** - * Authorizes the holder of an invitation's response capability. - * - * @remarks - * 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. - * - * @sideEffects none - * - * @example - * ```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. - * - * @construct http-problem - */ -export const authorizeInvitationOperation = (input: { - readonly spec: AccessSpec; - readonly source: RecruitmentInvitationHttpSource; - readonly authorizationInstant: string; -}): Effect.Effect> => - Effect.gen(function* () { - const capabilityId = CapabilityId.make(input.source.capabilitySha256); - const principal = { _tag: "CapabilityHolder" as const, capabilityId }; - const instant = AuthorizationInstant.make(input.authorizationInstant); - - const resource = { - kind: RECRUITMENT_INVITATION_RESOURCE_KIND, - id: ResourceId.make(input.source.invitationId), - }; - - if (!Predicate.isTagged(input.spec.capabilities, "One")) { - return yield* Effect.die(new Error("An invitation AccessSpec names exactly one capability")); - } - - const capability = input.spec.capabilities.capability; - - const resolution = { - selection: "ExactlyOne" as const, - contexts: [ - { - domainId: DomainId.make("recruitment"), - departmentId: input.source.departmentId, - resource, - facts: { - capabilityId, - invitationId: input.source.invitationId, - interviewId: input.source.interviewId, - departmentId: input.source.departmentId, - responseState: input.source.responseState, - responseRevision: input.source.responseRevision, - supersededAt: input.source.supersededAt, - }, - authorityVersion: AuthorityVersion.make( - `${input.source.scheduleRevision}:${input.source.responseRevision}`, - ), - }, - ], - }; - - const grant = decodeGrant({ - grantId: GrantId.make(`native-invitation:${input.source.capabilitySha256}`), - subject: principal, - capability, - scope: Scope.Resource({ resource }), - startAt: instant, - endAt: null, - requirements: [], - source: AuthorityRef.make("native-invitation-capability"), - revision: input.source.responseRevision, - }); - - const evaluation = yield* evaluateAccessJourney(input.spec, undefined, { - now: Effect.succeed(instant), - resolveCredential: () => - Effect.succeed({ - _tag: "Accepted" as const, - mechanism: { - _tag: "ObjectCapability" as const, - capabilityType: INVITATION_RESPONSE_CAPABILITY, - }, - principal, - evidenceRef: CredentialEvidenceRef.make( - `native-invitation:${input.source.capabilitySha256}`, - ), - }), - resolveScope: () => Effect.succeed(resolution), - resolveGrants: () => Effect.succeed([grant]), - }); - - const status = accessHttpStatus(evaluation, input.spec.concealment); - - if (status === 404) return yield* Problem.make("resource.not-found"); - - if (status !== 200) { - return yield* Effect.die(new Error(`An invitation AccessSpec revealed a ${status} denial`)); - } - }); - -export const actorDepartment = (actor: RecruitmentActor): DepartmentId | null => - Predicate.isTagged(actor, "GlobalAdmin") ? null : actor.departmentId; - -export const boardContext = (actor: RecruitmentActor, facts: GenericFacts, version: string) => ({ - domainId: DomainId.make("recruitment"), - departmentId: actorDepartment(actor), - resource: null, - facts, - authorityVersion: AuthorityVersion.make(version), -}); - -export const applicationContext = (input: { - readonly applicationId: typeof PublicApplicationIdSchema.Type; - readonly departmentId: DepartmentId; - readonly facts: GenericFacts; - readonly version: string; -}) => ({ - domainId: DomainId.make("recruitment"), - departmentId: input.departmentId, - resource: { - kind: ResourceKind.make("application"), - id: ResourceId.make(input.applicationId), - }, - facts: input.facts, - authorityVersion: AuthorityVersion.make(input.version), -}); - -export const recruitmentInterviewAccessContext = ( - source: RecruitmentInterviewHttpSource, - actor: RecruitmentActor, - allowLeader: boolean, - activeMember: boolean, -) => ({ - domainId: DomainId.make("recruitment"), - departmentId: source.departmentId, - resource: { - kind: ResourceKind.make("recruitment-interview"), - id: ResourceId.make(source.interviewId), - }, - facts: { - assignedInterviewerPersonIds: activeMember ? [source.interviewerPersonId] : [], - interviewParticipantPersonIds: activeMember - ? source.coInterviewerPersonId === null - ? [source.interviewerPersonId] - : [source.interviewerPersonId, source.coInterviewerPersonId] - : [], - linkedApplicantPersonId: source.linkedApplicantPersonId, - departmentAdministratorPersonIds: - allowLeader && - Predicate.isTagged(actor, "DepartmentAdministrator") && - actor.departmentId === source.departmentId - ? [actor.personId] - : [], - }, - authorityVersion: AuthorityVersion.make( - `${source.interviewRevision}:${JSON.stringify(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 { - readonly actor: RecruitmentActor; - readonly authorizationInstant: AuthorizationInstant; - readonly source: RecruitmentInterviewHttpSource; -} - -/** - * Resolves the current person and authorizes one interview inside the caller's transaction; a - * rejected credential is answered from the request's evidence. - * - * @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 - * 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. - * - * @sideEffects Reads the person's credential and authority, and takes recruitment's locks in the - * caller's transaction. - * - * @example - * ```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. - * - * @construct http-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< - InterviewAuthorization, - | RecruitmentFailure - | IdentityEngineError - | UnauthenticatedActor - | OrganizationResolutionError - | Problem<"authority.denied"> - | Problem<"credential.invalid"> - | Problem<"credential.missing"> - | Problem<"resource.not-found">, - Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment -> => - Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - const { source, actor, activeMember } = yield* Recruitment.use((service) => - service.prepareInterview({ - interviewId, - personId: authorization.authority.personId, - authorizationInstant: authorization.authorizationInstant, - }), - ); - - const resource = { - kind: ResourceKind.make("recruitment-interview"), - id: ResourceId.make(interviewId), - }; - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: authorization.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [recruitmentInterviewAccessContext(source, actor, allowLeader, activeMember)], - }, - grantScopes: [Scope.Resource({ resource })], - now: authorization.authorizationInstant, - }, - personPresentation(request), - ); - - return { - actor, - authorizationInstant: authorization.authorizationInstant, - source, - }; - }); diff --git a/apps/backend/src/recruitment/http-commands.ts b/apps/backend/src/recruitment/http-commands.ts deleted file mode 100644 index 058907fc..00000000 --- a/apps/backend/src/recruitment/http-commands.ts +++ /dev/null @@ -1,725 +0,0 @@ -/** Recruitment command handlers: invitation responses, assignment, scheduling, correction, finalize, and cancel. */ -import type { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; -import { ResourceId, ResourceKind, Scope } from "@vektorprogrammet/domain/authz"; -import type { PersonId } from "@vektorprogrammet/domain/organization"; -import { - Recruitment, - RecruitmentAssignmentCommandId, - RecruitmentCancellationCommandId, - RecruitmentConductCommandId, - RecruitmentInterviewCorrectionCommandId, - RecruitmentInvitationTransition, - RecruitmentScheduleCommandId, - type RecruitmentInterviewId, -} from "@vektorprogrammet/domain/recruitment"; -import { - AssignApplicantEndpoint, - CancelInterviewEndpoint, - CancelInterviewRequest, - CancelInterviewResponse, - ConfirmInvitationEndpoint, - ConfirmInvitationPayload, - CorrectInterviewAssessmentEndpoint, - CorrectInterviewAssessmentRequest, - CorrectInterviewAssessmentResponse, - CreateApplicationInterviewRequest, - FinalizeInterviewEndpoint, - FinalizeInterviewRequest, - FinalizeInterviewResponse, - InvitationRejectInput, - InvitationRequestNewTimeInput, - RecruitmentInterviewResource, - RejectInvitationEndpoint, - RequestNewInvitationTimeEndpoint, - ScheduleInterviewEndpoint, - ScheduleInterviewRequest, - ScheduleInterviewResponse, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { isProblem, Problem } from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { currentInstant, resolveRequestPersonAuthorityInTransaction } from "../authority.js"; -import { - authorizePerson, - commandOutcomeResponse, - 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"; -import { - NO_STORE, - normalizeTarget, - responseCapsule, - semanticMutationRequest, - semanticRequestDigest, - type CanonicalSemanticRequest, - type CredentialSubject, -} from "../http-semantics.js"; -import { - applicationContext, - authorizeInvitationOperation, - interviewAuthorizationInTransaction, -} from "./http-access.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { invitationCapability, readRecruitmentBody } from "./http-decode.js"; -import { - admissionPeriodProblems, - assignmentProblems, - conductProblems, - raceProblems, - recruitmentProblems, - schedulingProblems, -} from "./http-problem.js"; -import { interviewETag, invitationETag } from "./http-representation.js"; - -/** - * 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 semanticRequest: CanonicalSemanticRequest; - readonly commandIdSchema: Schema.ConstraintDecoder; - readonly prepare: Effect.Effect< - { - readonly credentialSubject: CredentialSubject; - readonly execute: ( - commandId: NoInfer, - ) => Effect.Effect; - }, - EPrepare, - RPrepare - >; -}) => - 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({ - credentialSubject: prepared.credentialSubject, - qualifiedOperationId: input.operationId, - normalizedTarget, - idempotencyKey, - }); - - const commandId = yield* decodeRequest(input.commandIdSchema)(derived.commandId); - - return { - identity: { - identitySha256: derived.identitySha256, - requestSha256: semanticRequestDigest(input.semanticRequest), - operationId: input.operationId, - }, - execute: prepared - .execute(commandId) - .pipe(Effect.flatMap((response) => Effect.promise(() => responseCapsule(response)))), - }; - }), - { retry: "serialization-once" }, - ).pipe(Effect.flatMap(commandOutcomeResponse)); - -/** An interview command's committed representation, with the interview's new validator. */ -const interviewCommandResponse = ( - interviewId: RecruitmentInterviewId, - personId: PersonId, - output: - | typeof ScheduleInterviewResponse.Type - | typeof CorrectInterviewAssessmentResponse.Type - | typeof FinalizeInterviewResponse.Type - | typeof CancelInterviewResponse.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), - }, - }), - ), - ); - -/** 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, -) => - 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 source = yield* Recruitment.use((service) => - service.readInvitationSnapshot(capability), - ).pipe(Effect.map((snapshot) => snapshot.source)); - - yield* authorizeInvitationOperation({ - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - source, - authorizationInstant: now, - }); - - // An answered invitation fails as already responded, whatever its validator. - if (source.responseState === "Pending") { - yield* requireCurrentETag(invitationETag(source), ifMatch); - } - - const updated = yield* Recruitment.use((service) => - service.transitionInvitation({ capability, transition, now }), - ); - - return new Response(null, { - status: 204, - headers: { - "cache-control": NO_STORE, - etag: invitationETag(updated.source), - vary: "Origin", - }, - }); - }).pipe( - recruitmentProblems(personPresentation(request), "dependency.unavailable"), - // A capability holder is no person, and an invitation answers only its own problems. - unreachable( - "credential.missing", - "credential.invalid", - ...admissionPeriodProblems, - ...assignmentProblems, - "recruitment.interview-not-found", - ...schedulingProblems, - ...conductProblems, - "idempotency.digest-conflict", - ), - ); - -/** 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()), - ), - ); - -/** 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 }), - ), - ), - ); - -/** 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); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readRecruitmentBody(request, input.config.maxBodyBytes).pipe( - Effect.flatMap(decodeRequest(CreateApplicationInterviewRequest)), - ); - - return yield* executeCommand({ - request, - operationId: "recruitment.createApplicationInterview", - routeTemplate: "/api/recruitment/applications/{applicationId}/interviews", - identities: { applicationId }, - semanticRequest: { body }, - commandIdSchema: RecruitmentAssignmentCommandId, - prepare: Effect.gen(function* () { - const authorization = yield* resolveRequestPersonAuthorityInTransaction(request, { - now: input.config.now, - }); - - const { access, actor } = yield* Recruitment.use((service) => - service.prepareAssignment({ - applicationId, - interviewerPersonId: body.interviewerPersonId, - personId: authorization.authority.personId, - authorizationInstant: authorization.authorizationInstant, - }), - ); - - const resource = { - kind: ResourceKind.make("application"), - id: ResourceId.make(applicationId), - }; - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(AssignApplicantEndpoint)), - credential: authorization.credential, - personId: actor.personId, - resolution: { - selection: "ExactlyOne", - contexts: [ - applicationContext({ - applicationId, - departmentId: access.departmentId, - facts: { - departmentAdministratorPersonIds: - Predicate.isTagged(actor, "DepartmentAdministrator") && actor.active - ? [actor.personId] - : [], - eligibleInterviewerPersonIds: access.interviewerEligible - ? [actor.personId] - : [], - }, - version: authorization.authorizationInstant, - }), - ], - }, - grantScopes: [Scope.Resource({ resource })], - now: authorization.authorizationInstant, - }, - presentation, - ); - - return { - credentialSubject: `Person:${actor.personId}` as const, - execute: (commandId: RecruitmentAssignmentCommandId) => - Effect.gen(function* () { - const result = yield* Recruitment.use((service) => - service.assignApplicant( - { commandId, applicationId, ...body }, - { - actor, - now: authorization.authorizationInstant, - interviewId: input.config.nextInterviewId(), - }, - ), - ); - - const interview = result.observation.interview; - const output = yield* strictOutput(RecruitmentInterviewResource)(interview); - - const source = yield* Recruitment.use((service) => - service.readInterviewSource(interview.interviewId, actor.personId), - ); - - const location = normalizeTarget("/api/recruitment/interviews/{interviewId}", { - interviewId: interview.interviewId, - }); - - return new Response(yield* jsonText(output), { - status: 201, - headers: { - "cache-control": NO_STORE, - "content-type": "application/json", - etag: interviewETag(source), - location, - }, - }); - }), - }; - }), - }); - }).pipe( - raceProblems, - recruitmentProblems(presentation, "dependency.unavailable"), - commandReceiptProblems, - // 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", - ...schedulingProblems, - ...conductProblems, - "resource.not-found", - "invitation.already-responded", - ), - ); -}; - -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); - - return yield* executeCommand({ - request, - operationId: "recruitment.scheduleInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:schedule", - identities: { interviewId }, - semanticRequest: semanticMutationRequest(body, ifMatch), - commandIdSchema: RecruitmentScheduleCommandId, - prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, - interviewId, - ScheduleInterviewEndpoint, - true, - input, - ); - - return { - credentialSubject: `Person:${authorization.actor.personId}` as const, - execute: (commandId: RecruitmentScheduleCommandId) => - Effect.gen(function* () { - yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); - - const { observation } = yield* Recruitment.use((service) => - service.scheduleInterview( - { - commandId, - interviewId, - expectedRevision: authorization.source.interviewRevision, - ...body, - }, - { - actor: authorization.actor, - now: authorization.authorizationInstant, - invitationId: input.config.nextInvitationId(), - responseCapability: input.config.nextResponseCapability(), - }, - ), - ); - - const output = yield* strictOutput(ScheduleInterviewResponse)({ - interviewId: observation.interviewId, - schedule: observation.schedule, - responseState: observation.responseState, - notificationState: observation.notificationState, - }); - - return yield* interviewCommandResponse( - interviewId, - authorization.actor.personId, - output, - ); - }), - }; - }), - }); - }).pipe( - raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), - commandReceiptProblems, - // Scheduling reads its interview's own application, which cannot vanish, and - // answers no assignment, conduct, or invitation problem. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...conductProblems, - "resource.not-found", - "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); - - return yield* executeCommand({ - request, - operationId: "recruitment.correctInterviewAssessment", - routeTemplate: "/api/recruitment/interviews/{interviewId}:correct", - identities: { interviewId }, - semanticRequest: semanticMutationRequest(body, ifMatch), - commandIdSchema: RecruitmentInterviewCorrectionCommandId, - prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, - interviewId, - CorrectInterviewAssessmentEndpoint, - false, - input, - ); - - return { - credentialSubject: `Person:${authorization.actor.personId}` as const, - execute: (commandId: RecruitmentInterviewCorrectionCommandId) => - Effect.gen(function* () { - yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); - - if (body.expectedRevision !== authorization.source.interviewRevision) { - return yield* Problem.make("precondition.failed"); - } - - const { observation, replayed } = yield* Recruitment.use((service) => - service.correctInterviewAssessment( - { commandId, interviewId, ...body }, - { - actor: authorization.actor, - now: authorization.authorizationInstant, - authorizationInstant: authorization.authorizationInstant, - }, - ), - ); - - const output = yield* strictOutput(CorrectInterviewAssessmentResponse)({ - _tag: observation._tag, - commandId: observation.commandId, - interviewId: observation.interviewId, - predecessorRevision: observation.predecessorRevision, - resultingRevision: observation.resultingRevision, - replayed, - }); - - return yield* interviewCommandResponse( - interviewId, - authorization.actor.personId, - output, - ); - }), - }; - }), - }); - }).pipe( - raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), - commandReceiptProblems, - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - "resource.not-found", - "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); - - return yield* executeCommand({ - request, - operationId: "recruitment.finalizeInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:finalize", - identities: { interviewId }, - semanticRequest: semanticMutationRequest(body, ifMatch), - commandIdSchema: RecruitmentConductCommandId, - prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, - interviewId, - FinalizeInterviewEndpoint, - false, - input, - ); - - return { - credentialSubject: `Person:${authorization.actor.personId}` as const, - execute: (commandId: RecruitmentConductCommandId) => - Effect.gen(function* () { - yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); - - const { observation } = yield* Recruitment.use((service) => - service.finalizeInterview( - { - commandId, - interviewId, - expectedRevision: authorization.source.interviewRevision, - ...body, - }, - { - actor: authorization.actor, - now: authorization.authorizationInstant, - authorizationInstant: authorization.authorizationInstant, - }, - ), - ); - - const output = yield* strictOutput(FinalizeInterviewResponse)({ - interviewId: observation.interviewId, - finalizedAt: observation.finalizedAt, - completionState: observation.completionState, - cancellationState: observation.cancellationState, - }); - - return yield* interviewCommandResponse( - interviewId, - authorization.actor.personId, - output, - ); - }), - }; - }), - }); - }).pipe( - raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), - commandReceiptProblems, - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - "resource.not-found", - "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); - - return yield* executeCommand({ - request, - operationId: "recruitment.cancelInterview", - routeTemplate: "/api/recruitment/interviews/{interviewId}:cancel", - identities: { interviewId }, - semanticRequest: semanticMutationRequest(body, ifMatch), - commandIdSchema: RecruitmentCancellationCommandId, - prepare: Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, - interviewId, - CancelInterviewEndpoint, - false, - input, - ); - - return { - credentialSubject: `Person:${authorization.actor.personId}` as const, - execute: (commandId: RecruitmentCancellationCommandId) => - Effect.gen(function* () { - yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); - - const { observation } = yield* Recruitment.use((service) => - service.cancelInterview( - { - commandId, - interviewId, - expectedRevision: authorization.source.interviewRevision, - }, - { - actor: authorization.actor, - now: authorization.authorizationInstant, - authorizationInstant: authorization.authorizationInstant, - }, - ), - ); - - const output = yield* strictOutput(CancelInterviewResponse)({ - interviewId: observation.interviewId, - cancelledAt: observation.cancelledAt, - completionState: observation.completionState, - cancellationState: observation.cancellationState, - }); - - return yield* interviewCommandResponse( - interviewId, - authorization.actor.personId, - output, - ); - }), - }; - }), - }); - }).pipe( - raceProblems, - recruitmentProblems(personPresentation(request), "dependency.unavailable"), - commandReceiptProblems, - // Cancellation checks no invitation, and its instant always fits the clock. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - "recruitment.invitation-not-accepted", - "recruitment.conduct-invalid", - "resource.not-found", - "invitation.already-responded", - ), - ); diff --git a/apps/backend/src/recruitment/http-context.ts b/apps/backend/src/recruitment/http-context.ts deleted file mode 100644 index 7cade79a..00000000 --- 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 269d9de1..00000000 --- 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-problem.ts b/apps/backend/src/recruitment/http-problem.ts deleted file mode 100644 index 78730817..00000000 --- a/apps/backend/src/recruitment/http-problem.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Recruitment failures answered as the problems each endpoint declares. - */ -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 Cause, Effect, Match } from "effect"; -import { - type CredentialCases, - isSerializationConflict, - type OutageCases, - type ProblemCases, - type ProblemMapper, - problemMapper, -} from "../http-api/problem.js"; - -/** Every failure a recruitment handler answers: the domain's, and resolving the request's person. */ -type RecruitmentHttpFailure = RecruitmentFailure | IdentityEngineError | Cause.UnknownError; - -/** The unavailable problem of a recruitment endpoint: 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. */ -type RecruitmentOutageTag = - | "RecruitmentPersistenceError" - | "InterviewQuestionsUnavailable" - | "ProfileContactNotFound"; - -const denied = () => Problem.make("authority.denied"); - -const replayConflict = () => Problem.make("idempotency.digest-conflict"); - -const fault = () => Problem.make("internal.error"); - -const recruitmentFixedCases = { - InactiveActor: denied, - RecruitmentInactiveActor: denied, - RecruitmentRoleDenied: denied, - RecruitmentScopeDenied: denied, - RecruitmentInterviewerNotEligible: denied, - RecruitmentAdmissionPeriodNotFound: () => Problem.make("recruitment.admission-period-not-found"), - RecruitmentAmbiguousAdmissionPeriod: () => Problem.make("application.ambiguous-period"), - RecruitmentApplicationNotFound: () => Problem.make("recruitment.application-not-found"), - RecruitmentInterviewSchemaNotFound: () => Problem.make("recruitment.interview-schema-not-found"), - RecruitmentApplicationAlreadyAssigned: () => - Problem.make("recruitment.application-already-assigned"), - RecruitmentInterviewSchemaInactive: () => Problem.make("recruitment.interview-schema-inactive"), - RecruitmentInterviewNotFound: () => Problem.make("recruitment.interview-not-found"), - RecruitmentInterviewAlreadyScheduled: () => Problem.make("recruitment.already-scheduled"), - RecruitmentInterviewStaleRevision: () => Problem.make("precondition.failed"), - RecruitmentScheduleInPast: () => Problem.make("recruitment.schedule-in-past"), - RecruitmentInvitationNotFound: () => Problem.make("resource.not-found"), - RecruitmentInvitationAlreadyResponded: () => Problem.make("invitation.already-responded"), - RecruitmentInterviewAlreadyFinalized: () => Problem.make("recruitment.already-finalized"), - RecruitmentInterviewAlreadyCancelled: () => Problem.make("recruitment.already-cancelled"), - RecruitmentInterviewNotScheduled: () => Problem.make("recruitment.interview-not-scheduled"), - RecruitmentInvitationNotAccepted: () => Problem.make("recruitment.invitation-not-accepted"), - RecruitmentConductValidationError: () => Problem.make("recruitment.conduct-invalid"), - RecruitmentAssignmentCommandConflict: replayConflict, - RecruitmentScheduleCommandConflict: replayConflict, - RecruitmentLifecycleCommandConflict: replayConflict, - // What remains is a fault of recruitment itself or of a dependency's contract. - RecruitmentDecodeError: fault, - RecruitmentInvalidContext: fault, - RecruitmentMaintenanceFailure: fault, - AdmissionPeriodDecodeError: fault, - AdmissionRoleDenied: fault, - AdmissionScopeDenied: fault, - DepartmentRequired: fault, - DepartmentNotFound: fault, - SemesterNotFound: fault, - AdmissionPeriodNotFound: fault, - InvalidAdmissionPeriodWindow: fault, - AdmissionWindowOutsideSemester: fault, - AdmissionPeriodAlreadyExists: fault, - StaleAdmissionPeriodRevision: fault, - DuplicateAdmissionPeriodCommandConflict: fault, - AdmissionPeriodPersistenceError: fault, - OrganizationDecodeError: fault, - OrganizationPersistenceError: fault, - ProfileDecodeError: fault, - ProfileQueryLimitExceeded: fault, - ProfileNotFound: fault, - ProfileStaleRevision: fault, - ProfileCommandConflict: fault, - ProfilePersistenceError: fault, - IdentityEngineError: fault, - UnknownError: fault, -} satisfies ProblemCases< - Exclude ->; - -/** The cases of `recruitmentProblems`, which answer an outage with `Unavailable`. */ -type RecruitmentCases = typeof recruitmentFixedCases & - CredentialCases<"UnauthenticatedActor"> & - OutageCases; - -/** - * The one answer for every recruitment failure. - * - * @remarks - * 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. - * - * @sideEffects none - * - * @example - * ```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. - * - * @construct http-problem - */ -export const recruitmentProblems = ( - presentation: CredentialPresentation, - unavailable: Unavailable, -): ProblemMapper> => { - const outage = () => Problem.make(unavailable); - - return problemMapper()>({ - ...recruitmentFixedCases, - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - RecruitmentPersistenceError: outage, - InterviewQuestionsUnavailable: outage, - ProfileContactNotFound: outage, - }); -}; - -/** - * A failure that lost a serialization or deadlock race answers transaction.conflict, whatever - * failure carried it. - * - * @remarks - * 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. - * - * @sideEffects none - * - * @example - * ```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. - * - * @construct http-problem - */ -export const raceProblems = ( - effect: Effect.Effect, -): Effect.Effect, R> => - Effect.mapError(effect, (failure) => - isSerializationConflict(failure) ? Problem.make("transaction.conflict") : failure, - ); - -/** - * The maintenance API answers its own failures, an unknown interview, and an - * identity outage in its own vocabulary; everything else as recruitment does. - */ -export const maintenanceProblems = problemMapper< - RecruitmentMaintenanceFailure | RecruitmentInterviewNotFound | IdentityEngineError ->()({ - RecruitmentMaintenanceFailure: ({ code }) => - Match.value(code).pipe( - Match.when("Denied", denied), - Match.when("NotFound", () => Problem.make("resource.not-found")), - Match.when("Stale", () => Problem.make("precondition.failed")), - Match.when("Conflict", replayConflict), - Match.when("Invalid", () => Problem.make("recruitment.invalid-command")), - Match.when("Ineligible", () => Problem.make("recruitment.ineligible")), - Match.when("Terminal", () => Problem.make("recruitment.terminal")), - Match.when("EmptyActiveQuestionnaire", () => - Problem.make("recruitment.empty-active-questionnaire"), - ), - Match.exhaustive, - ), - RecruitmentInterviewNotFound: () => Problem.make("resource.not-found"), - IdentityEngineError: () => Problem.make("recruitment.unavailable"), -}); - -/** Problems only choosing a department's one open admission period answers. */ -export const admissionPeriodProblems = [ - "recruitment.admission-period-not-found", - "application.ambiguous-period", -] as const; - -/** Problems only assigning an applicant answers. */ -export const assignmentProblems = [ - "recruitment.application-not-found", - "recruitment.interview-schema-not-found", - "recruitment.application-already-assigned", - "recruitment.interview-schema-inactive", -] as const; - -/** Problems only scheduling an interview answers. */ -export const schedulingProblems = [ - "recruitment.already-scheduled", - "recruitment.schedule-in-past", -] as const; - -/** Problems only reading or changing an interview's conduct answers. */ -export const conductProblems = [ - "recruitment.interview-not-scheduled", - "recruitment.invitation-not-accepted", - "recruitment.already-finalized", - "recruitment.already-cancelled", - "recruitment.conduct-invalid", -] as const; diff --git a/apps/backend/src/recruitment/http-reads.ts b/apps/backend/src/recruitment/http-reads.ts deleted file mode 100644 index 0d3e996e..00000000 --- a/apps/backend/src/recruitment/http-reads.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** Recruitment read handlers: invitation response, boards, interview report, and interview conduct. */ -import { Database } from "@vektorprogrammet/database"; -import { DomainId, Scope } from "@vektorprogrammet/domain/authz"; -import { - InterviewReport, - Recruitment, - type RecruitmentInterviewId, -} from "@vektorprogrammet/domain/recruitment"; -import { - AssignmentBoard, - ConductObservation, - InvitationResponseObservation, - ReadAssignmentBoardEndpoint, - ReadInterviewConductEndpoint, - ReadInterviewReportEndpoint, - ReadInvitationResponseEndpoint, - ReadSchedulingBoardEndpoint, - SchedulingBoard, - reflectAccessSpec, -} from "@vektorprogrammet/http-api"; -import { Problem } from "@vektorprogrammet/http-api/http-semantics"; -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 { - actorDepartment, - authorizeInvitationOperation, - boardContext, - interviewAuthorizationInTransaction, -} from "./http-access.js"; -import type { RecruitmentApiHttpOptions } from "./http-context.js"; -import { - decodeBoardQuery, - decodeInterviewReportQuery, - invitationCapability, -} from "./http-decode.js"; -import { - admissionPeriodProblems, - assignmentProblems, - conductProblems, - raceProblems, - recruitmentProblems, - schedulingProblems, -} from "./http-problem.js"; -import { interviewETag, invitationETag, schedulingBoardWithETags } from "./http-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" }, - }); - -export const readInvitationResponse = (request: Request, input: RecruitmentApiHttpOptions) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const capability = yield* invitationCapability(request); - const now = yield* currentInstant(input.config.now); - - const snapshot = yield* Recruitment.use((service) => - service.readInvitationSnapshot(capability), - ); - - yield* authorizeInvitationOperation({ - spec: Option.getOrThrow(reflectAccessSpec(ReadInvitationResponseEndpoint)), - 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", - }); - }).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. - unreachable( - "credential.missing", - "credential.invalid", - "authority.denied", - ...admissionPeriodProblems, - ...assignmentProblems, - "recruitment.interview-not-found", - ...schedulingProblems, - ...conductProblems, - "invitation.already-responded", - "idempotency.digest-conflict", - ), - ); - -export const readAssignmentBoard = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const query = yield* decodeBoardQuery(request); - const actor = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); - const departmentId = actorDepartment(actor); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadAssignmentBoardEndpoint)), - request, - personId: actor.personId, - resolution: { - selection: "AllMatching", - contexts: [ - boardContext( - actor, - { - departmentAdministratorPersonIds: - Predicate.isTagged(actor, "DepartmentAdministrator") && actor.active - ? [actor.personId] - : [], - }, - now, - ), - ], - }, - grantScopes: - departmentId === null - ? [Scope.Domain({ domainId: DomainId.make("recruitment") })] - : [Scope.Department({ departmentId })], - now, - }, - presentation, - ); - - const observation = yield* Recruitment.use(({ readAssignmentBoard: read }) => - read(query, { actor, now }), - ); - - return privateJson(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. - unreachable( - "validation.failed", - ...assignmentProblems, - "recruitment.interview-not-found", - "precondition.failed", - ...schedulingProblems, - ...conductProblems, - "resource.not-found", - "invitation.already-responded", - "idempotency.digest-conflict", - ), - ); -}; - -export const readInterviewReport = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - const query = yield* decodeInterviewReportQuery(request); - const caller = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); - - const actor = yield* Recruitment.use((service) => - service.resolveInterviewReportLeader(caller.personId, now), - ); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadInterviewReportEndpoint)), - request, - personId: actor.personId, - resolution: { - selection: "AllMatching", - contexts: [ - boardContext(actor, { departmentAdministratorPersonIds: [actor.personId] }, now), - ], - }, - grantScopes: [Scope.Department({ departmentId: actor.departmentId })], - now, - }, - presentation, - ); - - const observation = yield* Recruitment.use((service) => - service.readCompletedInterviewReport(actor.personId, now, query), - ); - - return privateJson(yield* strictOutput(InterviewReport)(observation)); - }).pipe( - // The report locks applicant custody, so it answers a lost race as a conflict. - raceProblems, - recruitmentProblems(presentation, "recruitment.unavailable"), - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - "recruitment.interview-not-found", - "precondition.failed", - ...schedulingProblems, - ...conductProblems, - "resource.not-found", - "invitation.already-responded", - "idempotency.digest-conflict", - ), - ); -}; - -export const readSchedulingBoard = (request: Request, input: RecruitmentApiHttpOptions) => { - const presentation = personPresentation(request); - - return Effect.gen(function* () { - yield* requireNoQuery(request); - const actor = yield* input.resolveActor(request); - const now = yield* currentInstant(input.config.now); - const departmentId = actorDepartment(actor); - - yield* authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(ReadSchedulingBoardEndpoint)), - request, - personId: actor.personId, - resolution: { - selection: "AllMatching", - contexts: [ - boardContext( - actor, - { - departmentMemberPersonIds: - !Predicate.isTagged(actor, "GlobalAdmin") && actor.active ? [actor.personId] : [], - }, - now, - ), - ], - }, - grantScopes: - departmentId === null - ? [Scope.Domain({ domainId: DomainId.make("recruitment") })] - : [Scope.Department({ departmentId })], - now, - }, - presentation, - ); - - const observation = yield* Recruitment.use(({ readSchedulingBoard: read }) => - read({ actor, now }), - ); - - const authority = yield* Recruitment.use((service) => - service.readPersonAuthoritySources(actor.personId), - ); - - return privateJson( - yield* strictOutput(SchedulingBoard)(schedulingBoardWithETags(observation, authority)), - ); - }).pipe( - recruitmentProblems(presentation, "recruitment.unavailable"), - // Every interview on the board belongs to an application that cannot vanish. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - "recruitment.interview-not-found", - "precondition.failed", - ...schedulingProblems, - ...conductProblems, - "resource.not-found", - "invitation.already-responded", - "idempotency.digest-conflict", - ), - ); -}; - -export const readInterviewConduct = ( - request: Request, - interviewId: RecruitmentInterviewId, - input: RecruitmentApiHttpOptions, -) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const snapshot = yield* Database.use((sql) => - sql.withTransaction( - Effect.gen(function* () { - const authorization = yield* interviewAuthorizationInTransaction( - request, - interviewId, - ReadInterviewConductEndpoint, - false, - input, - ); - - const now = yield* currentInstant(input.config.now); - - const observation = yield* Recruitment.use((service) => - service.readInterviewConduct(interviewId, { - actor: authorization.actor, - now, - authorizationInstant: authorization.authorizationInstant, - }), - ); - - const source = yield* Recruitment.use((service) => - service.readInterviewSource(interviewId, authorization.actor.personId), - ); - - return { observation, source }; - }), - ), - ).pipe(Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error")))); - - const output = 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", - }); - }).pipe( - recruitmentProblems(personPresentation(request), "recruitment.unavailable"), - // A conduct read answers no assignment, scheduling, lifecycle, or invitation problem. - unreachable( - ...admissionPeriodProblems, - ...assignmentProblems, - ...schedulingProblems, - "recruitment.already-finalized", - "recruitment.already-cancelled", - "recruitment.conduct-invalid", - "resource.not-found", - "invitation.already-responded", - "idempotency.digest-conflict", - ), - ); diff --git a/apps/backend/src/recruitment/http-representation.ts b/apps/backend/src/recruitment/http-representation.ts deleted file mode 100644 index b125a95e..00000000 --- a/apps/backend/src/recruitment/http-representation.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** Recruitment HTTP representations: ETags and scheduling items. */ -import type { - RecruitmentAuthorityHttpSource, - RecruitmentInterviewHttpSource, - RecruitmentInvitationHttpSource, - RecruitmentSchedulingBoard, -} from "@vektorprogrammet/domain/recruitment"; -import type { StrongETag } from "@vektorprogrammet/http-api"; -import { deriveStrongETag } from "../http-semantics.js"; - -export const invitationETag = (source: RecruitmentInvitationHttpSource): StrongETag => - deriveStrongETag({ - representationKind: "InvitationResponseObservation", - resourceIdentity: `recruitment-invitation:${source.invitationId}`, - version: [source.scheduleRevision, source.responseRevision], - }); - -export const interviewETag = ( - source: Omit, -): StrongETag => - deriveStrongETag({ - representationKind: "RecruitmentInterviewResource", - resourceIdentity: `recruitment-interview:${source.interviewId}`, - version: [ - source.interviewRevision, - source.coInterviewerPersonId ?? "Absent", - source.authority.map((item) => [item.kind, item.identity, item.revisions]), - ], - }); - -export const schedulingBoardWithETags = ( - board: RecruitmentSchedulingBoard, - authority: ReadonlyArray, -) => ({ - ...board, - interviews: board.interviews.map((interview) => ({ - ...interview, - etag: interviewETag({ - interviewId: interview.interviewId, - departmentId: interview.departmentId, - interviewerPersonId: interview.interviewer.personId, - coInterviewerPersonId: - interview.coInterviewer === null ? null : interview.coInterviewer.personId, - interviewRevision: interview.revision, - authority, - }), - })), -}); diff --git a/apps/backend/src/recruitment/http.test.ts b/apps/backend/src/recruitment/http.test.ts deleted file mode 100644 index 4551b500..00000000 --- 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 564728df..00000000 --- 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 0f72996a..00000000 --- a/apps/backend/src/recruitment/maintenance-http.ts +++ /dev/null @@ -1,208 +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. - const authorization = 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(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)), - }; - }), - }; - }), - { 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/rpc.ts b/apps/backend/src/recruitment/rpc.ts new file mode 100644 index 00000000..e2f75fbe --- /dev/null +++ b/apps/backend/src/recruitment/rpc.ts @@ -0,0 +1,8 @@ +/** + * The RecruitmentRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/recruitment/http*.ts and recruitment/maintenance-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { RecruitmentRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const RecruitmentRpcHandlers = (_options: NativeRpcOptions) => RecruitmentRpcs.toLayer({}); diff --git a/apps/backend/src/router.oauth.test.ts b/apps/backend/src/router.oauth.test.ts deleted file mode 100644 index 554fecc1..00000000 --- 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 db774875..00000000 --- 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 074a599a..00000000 --- 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 66ad92ce..e1b9fd1e 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -1,56 +1,35 @@ -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 type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; 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, + NativeRpcs, + nativeRpcPath, +} from "@vektorprogrammet/rpc"; +import { Problem, problemBody, problemHeaders } from "@vektorprogrammet/rpc/problem"; +import { Effect, Layer, Predicate, type Schema } from "effect"; 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 { - recruitmentBoardActorFrom, - resolveAuthenticatedPerson, - resolveAuthenticatedPersonAtInstant, - resolveRequestPerson, - resolveRequestPersonAtInstant, - resolveRequestCredentialAtInstant, - resolveRequestPersonAuthority, -} from "./authority.js"; +import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { AdmissionsRpcHandlers } from "./admission/rpc.js"; +import { AdmissionOutcomesRpcHandlers } from "./admission/outcome-rpc.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 { 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 { SystemRpcHandlers } from "./rpc/system.js"; import { allowsNativePreflightHeaders, decideTrustedOrigin, @@ -59,6 +38,18 @@ 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, and an unavailable health check. + */ +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 => { @@ -68,7 +59,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)); @@ -76,11 +67,9 @@ 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 => + pathname === nativeRpcPath ? ["POST"] : pathname === "/health" ? ["GET"] : []; /** * Answers one web request. A failure is either rendered as a response or dies, and the server @@ -100,7 +89,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, @@ -110,165 +99,91 @@ 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( + Effect.as(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 = ( +export const ExternalNativeRpcRouterLive = ( 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); + const rpcOptions: NativeRpcOptions = { ...options, 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, - 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 = ( +/** The isolated internal RPC routes for an explicitly selected ingress. */ +export const InternalNativeRpcRouterLive = ( 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), - ); - - const notFound = HttpRouter.use((router) => - router.add( - "*", - "*", - Effect.sync(() => - HttpServerResponse.fromWeb(problemWebResponse(Problem.make("resource.not-found"))), - ), - ), + const rpcRoute = RpcServer.layerHttp({ + group: InternalNativeRpcs, + path: internalNativeRpcPath, + protocol: "http", + }).pipe( + Layer.provide(InternalReceiptsRpcHandlers({ ...options, config })), + Layer.provide(nativeRpcCredentialLayer(config.contact)), + Layer.provide(ProblemBoundaryLive), + Layer.provide(RpcSerialization.layerJson), ); - return Layer.mergeAll(internalRoutes, notFound, ProblemBoundaryLive); + return Layer.mergeAll(rpcRoute, notFound); }; const oauthAuthorizationServerMetadataPath = "/.well-known/oauth-authorization-server/api/auth"; @@ -378,8 +293,8 @@ 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 = ( @@ -453,7 +368,7 @@ export const backendHttpHandler = pathname, requestedMethod, headersAllowed: allowsNativePreflightHeaders(prepared.request), - methodsForPath: externalNativePreflightMethodsForPath, + methodsForPath: nativePreflightMethodsForPath, }); if (Predicate.isTagged(preflight, "HeaderMalformed")) { diff --git a/apps/backend/src/http-api/AGENTS.md b/apps/backend/src/rpc/AGENTS.md similarity index 100% rename from apps/backend/src/http-api/AGENTS.md rename to apps/backend/src/rpc/AGENTS.md 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 00000000..317314d7 --- /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 00000000..42ed5487 --- /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/http-api/problem.ts b/apps/backend/src/rpc/problem.ts similarity index 52% rename from apps/backend/src/http-api/problem.ts rename to apps/backend/src/rpc/problem.ts index 6aaf48b3..ef1a7ce0 100644 --- a/apps/backend/src/http-api/problem.ts +++ b/apps/backend/src/rpc/problem.ts @@ -1,43 +1,29 @@ /** - * 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. + * 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, - 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"; +} 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, - evaluateReadPreconditions, type NativeIdempotencyIdentity, - notModifiedResponse, - parseIdempotencyKey, - parseIfNoneMatch, - parseReadIfMatch, - parseRequiredIfMatch, - responseFromCapsule, } from "../http-semantics.js"; import { authorizeAnonymousNativeOperation, @@ -45,41 +31,13 @@ import { type NativePersonAuthorization, } from "../native-operation.js"; import { hasBetterAuthSessionCredential } from "../session-security.js"; -import { readBoundedJson } from "./read-json.js"; -import type { - NativeHttpCommandOutcome, - NativeHttpReceiptInvalid, - NativeHttpReceiptPersistenceError, +import { + decodeStoredSuccess, + type NativeHttpCommandOutcome, + type NativeHttpReceiptInvalid, + type 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); /** @@ -100,7 +58,7 @@ const JsonText = Schema.fromJsonString(Schema.Unknown); * @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 + * @construct rpc-problem */ export const jsonText = (value: A): Effect.Effect => Schema.encodeEffect(JsonText)(value).pipe(Effect.orDie); @@ -119,37 +77,6 @@ export const classifyCredential = ( 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. * @@ -169,7 +96,7 @@ export const webHandler = ( * @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 + * @construct rpc-problem */ export const semanticProblem = ( parse: () => A, @@ -246,7 +173,7 @@ export type OutageCases = { * the copies drift apart, so one failure gets different problems on different endpoints. Map each * domain once and pipe its mapper. * - * @construct http-problem + * @construct rpc-problem */ export const problemMapper = (): (>( @@ -274,161 +201,33 @@ export const problemMapper = }; /** - * 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. + * Derives a command's idempotency identity: the receipt digest and the domain command ID. * * @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. + * 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* 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. + * @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 http-problem + * @construct rpc-problem */ -export const httpIdentity = ( +export const commandIdentity = ( identity: NativeIdempotencyIdentity, -): Effect.Effect> => - semanticProblem( - () => deriveHttpIdentity(identity), - ["request.malformed", "idempotency-key.invalid"], - ); +): 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. @@ -449,7 +248,7 @@ export const httpIdentity = ( * @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 + * @construct rpc-problem */ export const requireCurrentETag = ( current: StrongETag, @@ -459,103 +258,6 @@ export const requireCurrentETag = ( ? 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. @@ -575,7 +277,7 @@ export const personPresentation = ( * @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 + * @construct rpc-problem */ export const isSerializationConflict = (cause: unknown, depth: number = 0): boolean => depth < 8 && @@ -604,42 +306,11 @@ export const isSerializationConflict = (cause: unknown, depth: number = 0): bool * 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 + * @construct rpc-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. * @@ -658,7 +329,7 @@ export const decodeRequest = * @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 + * @construct rpc-problem */ export const strictOutput = >( @@ -693,7 +364,7 @@ const commandReceiptCases = { * @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 + * @construct rpc-problem */ export const commandReceiptProblems: ProblemMapper< NativeHttpReceiptInvalid | NativeHttpReceiptPersistenceError, @@ -703,44 +374,98 @@ export const commandReceiptProblems: ProblemMapper< ); /** - * Answers a command receipt outcome: committed and replayed results, or an idempotency problem. + * 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 = 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 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. * * @sideEffects none * * @example * ```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 + * @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 http-problem + * @construct rpc-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)), +export const commandOutcome = + >(success: S) => + ( + 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. + * + * @construct rpc-problem + */ +export const ProblemBoundaryLive = 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"))), + ), ), - 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 @@ -761,7 +486,7 @@ export const commandOutcomeResponse = ( * @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 + * @construct rpc-problem */ export const authorizeAnonymous = ( spec: AccessSpec, @@ -794,7 +519,7 @@ export const authorizeAnonymous = ( * and reveal that it exists, or a credential answer would ignore the ingress evidence. Map the * evaluation with this. * - * @construct http-problem + * @construct rpc-problem */ export const authorizePerson = ( input: NativePersonAuthorization, @@ -836,7 +561,7 @@ export const authorizePerson = ( * @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 + * @construct rpc-problem */ export const unreachable = ( @@ -858,41 +583,3 @@ export const unreachable = 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/public-rate-limit.ts b/apps/backend/src/rpc/public-rate-limit.ts similarity index 100% rename from apps/backend/src/http-api/public-rate-limit.ts rename to apps/backend/src/rpc/public-rate-limit.ts diff --git a/apps/backend/src/http-api/read-json.ts b/apps/backend/src/rpc/read-json.ts similarity index 96% rename from apps/backend/src/http-api/read-json.ts rename to apps/backend/src/rpc/read-json.ts index bb8007a4..8a6ae26c 100644 --- a/apps/backend/src/http-api/read-json.ts +++ b/apps/backend/src/rpc/read-json.ts @@ -1,4 +1,4 @@ -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"; 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 90% rename from apps/backend/src/http-api/receipt-transaction.ts rename to apps/backend/src/rpc/receipt-transaction.ts index 2e5be1e9..b1b95a87 100644 --- a/apps/backend/src/http-api/receipt-transaction.ts +++ b/apps/backend/src/rpc/receipt-transaction.ts @@ -1,6 +1,7 @@ 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"; const sha256Pattern = /^[a-f0-9]{64}$/u; @@ -355,3 +356,31 @@ 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 = >( + 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 00000000..1655b7f4 --- /dev/null +++ b/apps/backend/src/rpc/router.test.ts @@ -0,0 +1,83 @@ +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 } from "effect"; +import { RpcClient } from "effect/unstable/rpc"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } 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(new IdentitySessionNotFound({})), +}); + +const backend = makeBackendTestRpc(backendTestConfig, 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("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" }); + }), +); diff --git a/apps/backend/src/rpc/system.ts b/apps/backend/src/rpc/system.ts new file mode 100644 index 00000000..6a73f4e7 --- /dev/null +++ b/apps/backend/src/rpc/system.ts @@ -0,0 +1,8 @@ +/** + * The SystemRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/http-api/system.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { SystemRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "./options.js"; + +export const SystemRpcHandlers = (_options: NativeRpcOptions) => SystemRpcs.toLayer({}); diff --git a/apps/backend/src/schools/administration-http.ts b/apps/backend/src/schools/administration-http.ts deleted file mode 100644 index ffd00e4a..00000000 --- a/apps/backend/src/schools/administration-http.ts +++ /dev/null @@ -1,149 +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); - - const authorization = 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(authorization)); - - 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.test.ts b/apps/backend/src/schools/http.test.ts deleted file mode 100644 index 7a00bdb8..00000000 --- a/apps/backend/src/schools/http.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { backendDatabase } from "../../test/database.js"; -import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - Identity, - IdentityActor, - IdentitySessionNotFound, - type IdentityOperations, -} from "@vektorprogrammet/domain/identity"; -import { - Department, - DepartmentId, - DepartmentNotFound, - MembershipId, - Organization, - OrganizationAuthorityInstantSchema, - PersonId, - TeamId, - type OrganizationPersonAuthority, -} from "@vektorprogrammet/domain/organization"; -import { - SchoolDirectoryScopeSchema, - SchoolId, - Schools, - SchoolsDecodeError, - SchoolsPersistenceError, - type SchoolDirectory, - type SchoolDirectoryListInput, -} from "@vektorprogrammet/domain/schools"; -import { Schema, DateTime, Effect, Layer } from "effect"; -import { describe, expect, it } from "@effect/vitest"; -import { makeSchoolsTestHttp as makeSchoolsApiHttp } from "../test/native-http.js"; - -const personId = PersonId.make("schools-http-person"); - -const sessionRequest = (url: string): Request => - new Request(url, { - headers: { cookie: "better-auth.session_token=schools-test-session" }, - }); - -const departmentA = DepartmentId.make("schools-http-a"); - -const departmentB = DepartmentId.make("schools-http-b"); - -const instant = OrganizationAuthorityInstantSchema.make("2032-04-01T12:00:00.000Z"); - -const emptyDirectory: SchoolDirectory = { activeSchools: [], inactiveSchools: [] }; - -const projection = ( - overrides: Partial = {}, -): OrganizationPersonAuthority => ({ - personId, - evaluatedAt: instant, - globalAdministrator: "Absent", - memberships: [ - { - membershipId: MembershipId.make("schools-http-membership"), - teamId: TeamId.make("schools-http-team"), - departmentId: departmentA, - active: true, - unitLeader: false, - unitKind: "Team", - teamScope: "HomeDepartment", - departmentIndependent: false, - }, - ], - nationalBoardSeats: [], - delegations: [], - ...overrides, -}); - -const database = backendDatabase(); - -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("schools-test-session") - ? Effect.succeed( - new IdentityActor({ - personId, - sessionId: "schools-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 makeServices = ( - authority: OrganizationPersonAuthority, - listDirectory: ( - input: SchoolDirectoryListInput, - ) => Effect.Effect, - readDepartment: (departmentId: DepartmentId) => Effect.Effect = ( - departmentId, - ) => - Effect.succeed( - new Department({ - departmentId, - name: "Fixture department", - shortName: "Fixture", - email: "fixture@example.invalid", - address: null, - city: "Oslo", - latitude: null, - longitude: null, - slackChannel: null, - logoPath: null, - active: true, - 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"), - }); - - 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 expectedProblem = (code: string, title: string, status: number, detail: string) => ({ - type: `urn:vektorprogrammet:problem:v0.2:${code}`, - title, - status, - detail, - code, -}); - -const expectedProblemForTag = (tag: string, status: number) => { - if (tag === "UnauthenticatedActor") { - return expectedProblem( - "credential.invalid", - "Invalid credential", - status, - "The supplied credential is invalid.", - ); - } - - if (tag === "SchoolsDepartmentNotFound" && status === 422) { - return expectedProblem( - "schools.invalid-department", - "Invalid school department", - status, - "The selected department is not valid for the school directory.", - ); - } - - if (status === 403) { - return expectedProblem( - "authority.denied", - "Authority denied", - status, - "The authenticated principal is not permitted to perform this operation.", - ); - } - - return expectedProblem( - "schools.unavailable", - "Schools unavailable", - status, - "The school directory is temporarily unavailable.", - ); -}; - -describe("Schools native HTTP adapter", () => { - it.live("runs the named journey once and narrows the visible union by department", () => - Effect.gen(function* () { - const listInputs: Array = []; - - const school: SchoolDirectory = { - activeSchools: [ - { - schoolId: SchoolId.make(1), - name: "Journey School", - contactPerson: "Journey Contact", - email: "journey@example.invalid", - phone: "+47 900 00 000", - language: "Norwegian", - departments: [{ departmentId: departmentA, name: "Department A" }], - isActive: true, - }, - ], - inactiveSchools: [], - }; - - const services = makeServices(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(listInputs).toEqual([ - { - scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ - departmentIds: [departmentA], - }), - departmentId: departmentA, - }, - ]); - }), - ); - - 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", - () => - Effect.gen(function* () { - const cases: ReadonlyArray<{ - readonly name: string; - readonly expectedStatus: number; - readonly expectedTag: string; - readonly authority?: OrganizationPersonAuthority; - readonly query?: string; - readonly resolveActor?: () => Effect.Effect< - { personId: PersonId; authorizationInstant: typeof instant }, - UnauthenticatedActor - >; - readonly readDepartment?: ( - departmentId: DepartmentId, - ) => Effect.Effect; - readonly listDirectory?: ( - input: SchoolDirectoryListInput, - ) => Effect.Effect; - }> = [ - { - name: "missing session", - expectedStatus: 401, - expectedTag: "UnauthenticatedActor", - resolveActor: () => - Effect.fail(new UnauthenticatedActor({ message: "authentication required" })), - }, - { - name: "inactive authority", - expectedStatus: 403, - expectedTag: "AuthorityInactive", - authority: projection({ memberships: [], globalAdministrator: "Inactive" }), - }, - { - name: "absent authority", - expectedStatus: 403, - expectedTag: "NotInScope", - authority: projection({ - memberships: [], - nationalBoardSeats: [], - delegations: [], - globalAdministrator: "Absent", - }), - }, - { - name: "unknown department", - expectedStatus: 422, - expectedTag: "SchoolsDepartmentNotFound", - query: `department=${departmentB}`, - readDepartment: (departmentId) => Effect.fail(new DepartmentNotFound({ departmentId })), - }, - { - name: "outside scope", - expectedStatus: 403, - expectedTag: "SchoolsDepartmentOutOfScope", - query: `department=${departmentB}`, - }, - { - name: "persistence", - expectedStatus: 503, - expectedTag: "SchoolsPersistenceError", - listDirectory: () => - Effect.fail( - new SchoolsPersistenceError({ - operation: "read Schools directory", - message: "unavailable", - }), - ), - }, - { - name: "row decode", - expectedStatus: 503, - expectedTag: "SchoolsDecodeError", - listDirectory: () => - Effect.fail( - new SchoolsDecodeError({ - operation: "decode Schools directory rows", - message: "malformed row", - }), - ), - }, - ]; - - 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 query = testCase.query === undefined ? "" : `?${testCase.query}`; - - const response = yield* api.fetch( - sessionRequest(`http://backend.test/api/schools${query}`), - ); - - expect( - { status: response.status, body: yield* responseBody(response) }, - testCase.name, - ).toEqual({ - status: testCase.expectedStatus, - body: expectedProblemForTag(testCase.expectedTag, testCase.expectedStatus), - }); - } - }), - ); -}); diff --git a/apps/backend/src/schools/http.ts b/apps/backend/src/schools/http.ts deleted file mode 100644 index 5205f844..00000000 --- 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/social-events/http.ts b/apps/backend/src/social-events/http.ts deleted file mode 100644 index 1c7c74cb..00000000 --- a/apps/backend/src/social-events/http.ts +++ /dev/null @@ -1,409 +0,0 @@ -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 { - 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), - }); - - // The AccessSpec admitted the candidate grants; the command takes them as evidence. - const creator = yield* Effect.fromResult( - requireSocialEventCreation(authorization.authority, body.departmentId), - ).pipe(Effect.mapError(() => Problem.make("authority.denied"))); - - 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(creator, { - commandId: SocialEventCommandId.make(identity.commandId), - 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 00000000..86f0c85a --- /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* 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; + }); + +/** + * 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/http.test.ts b/apps/backend/src/team-application/http.test.ts deleted file mode 100644 index 44281740..00000000 --- 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/http.ts b/apps/backend/src/team-application/http.ts deleted file mode 100644 index f237ee19..00000000 --- a/apps/backend/src/team-application/http.ts +++ /dev/null @@ -1,677 +0,0 @@ -import { Database } from "@vektorprogrammet/database"; -import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; -import { - ResourceId, - ResourceKind, - Scope, - type CredentialOutcome, -} from "@vektorprogrammet/domain/authz"; -import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; -import type { TeamId } from "@vektorprogrammet/domain/organization"; -import { - TeamApplicationAction, - TeamApplicationCommandId, - TeamApplications, - ReviseTeamApplicationIntakeCommand, - type TeamApplicationActor, - type TeamApplicationDeleteFailure, - type TeamApplicationId, - type TeamApplicationIntake, - type TeamApplicationPrincipal, - type TeamApplicationPublicReadFailure, - type TeamApplicationReadFailure, - type TeamApplicationReviseFailure, - type TeamApplicationSubmitFailure, -} from "@vektorprogrammet/domain/team-application"; -import { - DeleteTeamApplicationEndpoint, - ExternalNativeApi, - ListTeamApplicationIntakesEndpoint, - ListTeamApplicationsEndpoint, - ReadTeamApplicationEndpoint, - ReadTeamApplicationIntakeEndpoint, - reflectAccessSpec, - ReviseTeamApplicationIntakeEndpoint, - SubmitTeamApplicationEndpoint, - TeamApplicationInput, - TeamApplicationIntakeMergePatch, -} from "@vektorprogrammet/http-api"; -import { - type CredentialPresentation, - makeNativeValidationError, - type NativeValidationError, - Problem, -} from "@vektorprogrammet/http-api/http-semantics"; -import { Effect, Option, Predicate, Schema } from "effect"; -import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { currentInstant, resolveRequestCredentialInTransaction } from "../authority.js"; -import type { TeamApplicationApiConfig } from "../config.js"; -import { - authorizeAnonymous, - authorizePerson, - commandOutcomeResponse, - commandReceiptProblems, - httpIdentity, - idempotencyKeyOf, - personPresentation, - problemMapper, - readJsonBody, - requireCurrentETag, - requiredIfMatchOf, - requireNoQuery, - unreachable, - webHandler, -} from "../http-api/problem.js"; -import { publicRateLimitKey } from "../http-api/public-rate-limit.js"; -import { - executeNativeHttpCommandPostgres, - 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; - -type AcceptedCredential = Extract; - -interface StaffPrincipal { - readonly credential: AcceptedCredential; - 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 - | TeamApplicationSubmitFailure - | TeamApplicationReadFailure - | TeamApplicationDeleteFailure - | TeamApplicationReviseFailure ->()({ - TeamApplicationTeamNotFound: () => Problem.make("resource.not-found"), - TeamApplicationNotFound: () => Problem.make("resource.not-found"), - TeamApplicationIntakeClosed: () => Problem.make("team-application.intake-closed"), - TeamApplicationAccessDenied: () => Problem.make("authority.denied"), - TeamApplicationCommandConflict: () => Problem.make("idempotency.digest-conflict"), - TeamApplicationInvalidCursor: () => Problem.make("request.malformed"), - TeamApplicationPersistenceError: (failure) => - failure.conflict ? Problem.make("transaction.conflict") : Problem.make("internal.error"), -}); - -/** A staff credential rejected inside the transaction is answered from the request's evidence. */ -const staffCredentialProblems = (presentation: CredentialPresentation) => - problemMapper()({ - UnauthenticatedActor: () => Problem.unauthenticated(presentation), - 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", - resourceIdentity: teamId, - version: revision, - }); - -const intakeResource = (teamId: TeamId, intake: TeamApplicationIntake) => ({ - ...intake, - etag: intakeETag(teamId, intake.revision), -}); - -const jsonCapsule = ( - status: 200 | 201, - body: Schema.Json, - headers: { readonly etag: string; readonly location?: string }, -): NativeHttpResponseCapsule => ({ - status, - mediaType: "application/json", - headers: { ...headers, "content-type": "application/json" }, - bodyBytes: jsonBodyBytes(body), -}); - -/** Resolves the current Person credential through the caller's transaction at one instant. */ -const staffPrincipal = (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 StaffPrincipal; - }); - -/** 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, -) => - authorizePerson( - { - spec: Option.getOrThrow(reflectAccessSpec(endpoint)), - credential: staff.credential, - personId: staff.principal.personId, - resolution: { - selection, - contexts: [ - genericContext({ - domainId: "team-applications", - resourceKind: "organization-team", - resourceId: actor.teamId, - authorityVersion: `team-applications:${actor._tag}:${staff.principal.authorizationInstant}`, - }), - ], - }, - grantScopes: [ - Scope.Resource({ - resource: { - kind: ResourceKind.make("organization-team"), - id: ResourceId.make(actor.teamId), - }, - }), - ], - now: staff.principal.authorizationInstant, - }, - presentation, - ); - -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(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) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const instant = yield* currentInstant(undefined); - - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntakeEndpoint)), - { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "team-applications", - resourceKind: "organization-team", - resourceId: teamId, - authorityVersion: `team-application-intake:${instant}`, - }), - ], - }, - instant, - ); - - // A public read runs no serializable transaction, so it cannot lose a conflict. - const intake = yield* TeamApplications.use((service) => service.readPublicIntake(teamId)).pipe( - teamApplicationProblems, - unreachable("transaction.conflict"), - ); - - return json(intake, NO_STORE); - }); - -const listIntakes = (request: Request) => - Effect.gen(function* () { - yield* requireNoQuery(request); - const instant = yield* currentInstant(undefined); - - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(ListTeamApplicationIntakesEndpoint)), - { - 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"), - ); - - return json(intakes, NO_STORE); - }); - -const submit = (request: Request, teamId: TeamId, config: TeamApplicationApiConfig) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - 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)) { - 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. - const outcome = yield* executeNativeHttpCommandPostgres( - Effect.gen(function* () { - const instant = yield* currentInstant(undefined); - - yield* authorizeAnonymous( - Option.getOrThrow(reflectAccessSpec(SubmitTeamApplicationEndpoint)), - { - selection: "ExactlyOne", - contexts: [ - genericContext({ - domainId: "team-applications", - resourceKind: "organization-team", - resourceId: teamId, - authorityVersion: `team-application-create:${instant}`, - }), - ], - }, - instant, - ); - - const identity = yield* httpIdentity({ - credentialSubject: "Anonymous", - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget("/api/teams/{teamId}/applications", { teamId }), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({ body: decoded.value }), - operationId, - }, - execute: TeamApplications.use((service) => - service.submit({ - commandId: TeamApplicationCommandId.make(identity.commandId), - teamId, - application: decoded.value, - }), - ).pipe( - Effect.map(({ confirmation }) => - jsonCapsule(201, confirmation, { - etag: deriveStrongETag({ - representationKind: "TeamApplicationConfirmation", - resourceIdentity: confirmation.applicationId, - version: 0, - }), - location: `/api/team-applications/${encodePathIdentity(confirmation.applicationId)}`, - }), - ), - ), - }; - }), - { 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); - }); - -const listApplications = (request: Request, teamId: TeamId) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - const cursor = yield* cursorQuery(request); - const staff = yield* staffPrincipal(request); - - // A read-only snapshot cannot lose a serialization conflict. - const page = yield* TeamApplications.use((service) => - service.listApplications(staff.principal, teamId, cursor), - ).pipe(teamApplicationProblems, unreachable("transaction.conflict")); - - yield* authorizeStaff( - ListTeamApplicationsEndpoint, - staff, - page.actor, - "AllMatching", - presentation, - ); - - const body = { - teamId: page.teamId, - teamName: page.teamName, - items: page.items, - intake: intakeResource(page.teamId, page.intake), - canManage: Predicate.isTagged(page.actor, "TeamLeader"), - }; - - return json( - page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, - PRIVATE_NO_STORE, - ); - }), - ).pipe(staffCredentialProblems(presentation)); -}; - -const readApplication = (request: Request, applicationId: TeamApplicationId) => { - const presentation = personPresentation(request); - - return snapshotRead( - Effect.gen(function* () { - yield* requireNoQuery(request); - const staff = yield* staffPrincipal(request); - - // A read-only snapshot cannot lose a serialization conflict. - const view = yield* TeamApplications.use((service) => - service.readApplication(staff.principal, applicationId), - ).pipe(teamApplicationProblems, unreachable("transaction.conflict")); - - yield* authorizeStaff( - ReadTeamApplicationEndpoint, - staff, - view.actor, - "ExactlyOne", - presentation, - ); - - return json( - { - ...view.application, - teamName: view.teamName, - canManage: Predicate.isTagged(view.actor, "TeamLeader"), - }, - PRIVATE_NO_STORE, - ); - }), - ).pipe(staffCredentialProblems(presentation)); -}; - -const deleteApplication = (request: Request, applicationId: TeamApplicationId) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - - 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); - - // Authority is current before any stored response can be replayed. - const authorization = yield* TeamApplications.use((service) => - service.authorize( - staff.principal, - TeamApplicationAction.DeleteTeamApplication({ applicationId }), - ), - ); - - yield* authorizeStaff( - DeleteTeamApplicationEndpoint, - staff, - authorization.actor, - "ExactlyOne", - presentation, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${staff.principal.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget("/api/team-applications/{applicationId}", { - applicationId, - }), - idempotencyKey, - }); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest({}), - operationId, - }, - execute: TeamApplications.use((service) => - service.deleteApplication(authorization, { - commandId: TeamApplicationCommandId.make(identity.commandId), - }), - ).pipe( - Effect.as({ - status: 204, - mediaType: null, - headers: {}, - bodyBytes: null, - }), - ), - }; - }), - { retry: "serialization-once" }, - ).pipe(teamApplicationProblems, commandReceiptProblems, staffCredentialProblems(presentation)); - - return yield* commandOutcomeResponse(outcome); - }); - -const reviseIntake = (request: Request, teamId: TeamId) => - Effect.gen(function* () { - yield* requireNoQuery(request); - - const body = yield* readJsonBody( - request, - /^application\/merge-patch\+json(?:\s*;|$)/iu, - MAX_INTAKE_PATCH_BYTES, - ); - - 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 === undefined && patch.deadline === undefined) { - return yield* Problem.validation("validation.no-change", [ - makeNativeValidationError("", "no-change"), - ]); - } - - if (patch.acceptApplication === null) { - return yield* Problem.validation("validation.field-not-deletable", [ - makeNativeValidationError("/acceptApplication", "field-not-deletable"), - ]); - } - - const ifMatch = yield* requiredIfMatchOf(request); - - const idempotencyKey = yield* idempotencyKeyOf(request); - const presentation = personPresentation(request); - - 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); - - // Authority is current before any stored response can be replayed. - const authorization = yield* TeamApplications.use((service) => - service.authorize( - staff.principal, - TeamApplicationAction.ReviseTeamApplicationIntake({ teamId }), - ), - ); - - yield* authorizeStaff( - ReviseTeamApplicationIntakeEndpoint, - staff, - authorization.actor, - "ExactlyOne", - presentation, - ); - - const identity = yield* httpIdentity({ - credentialSubject: `Person:${staff.principal.personId}`, - qualifiedOperationId: operationId, - normalizedTarget: normalizeTarget("/api/teams/{teamId}/application-intake", { - teamId, - }), - idempotencyKey, - }); - - const command = yield* Schema.decodeUnknownEffect(ReviseTeamApplicationIntakeCommand)( - { ...patch, commandId: identity.commandId, teamId }, - { onExcessProperty: "error" }, - ).pipe( - Effect.mapError(() => - Problem.validation("validation.failed", [makeNativeValidationError("", "invalid")]), - ), - ); - - return { - identity: { - identitySha256: identity.identitySha256, - requestSha256: semanticRequestDigest(semanticMutationRequest(body, ifMatch)), - operationId, - }, - execute: TeamApplications.use((service) => - service.reviseIntake(authorization, command, (current) => - requireCurrentETag(intakeETag(teamId, current.revision), ifMatch), - ), - ).pipe( - Effect.map(({ intake }) => { - const resource = intakeResource(teamId, intake); - - return jsonCapsule(200, resource, { etag: resource.etag }); - }), - ), - }; - }), - { retry: "serialization-once" }, - ).pipe( - teamApplicationProblems, - commandReceiptProblems, - staffCredentialProblems(presentation), - // authorize answers an unknown team on revise with an authority denial. - unreachable("resource.not-found"), - ); - - return yield* commandOutcomeResponse(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)), - ), - ), - ); diff --git a/apps/backend/src/team-application/rpc.ts b/apps/backend/src/team-application/rpc.ts new file mode 100644 index 00000000..eaf8c365 --- /dev/null +++ b/apps/backend/src/team-application/rpc.ts @@ -0,0 +1,9 @@ +/** + * The TeamApplicationsRpcs handlers. The operations to port, and the HTTP handlers they replace + * (`apps/backend/src/team-application/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + */ +import { TeamApplicationsRpcs } from "@vektorprogrammet/rpc"; +import type { NativeRpcOptions } from "../rpc/options.js"; + +export const TeamApplicationsRpcHandlers = (_options: NativeRpcOptions) => + TeamApplicationsRpcs.toLayer({}); diff --git a/apps/backend/src/test/native-http.ts b/apps/backend/src/test/native-http.ts deleted file mode 100644 index 51564e43..00000000 --- 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 00000000..23ab40e9 --- /dev/null +++ b/apps/backend/src/test/native-rpc.ts @@ -0,0 +1,172 @@ +/** + * 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( + 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); + }), + ); + +/** 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()), + ); + +/** + * 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. + */ +export const makeBackendTestRpc = ( + config: BackendConfig, + services: TestServiceLayer, + authHandler: BackendAuthHandler = testAuthHandler, + options: BackendHttpOptions = {}, +) => { + const routerLayer = ExternalNativeRpcRouterLive(config, options).pipe( + Layer.provideMerge(completeServices(services)), + Layer.provideMerge(platform), + ); + + 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, 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/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts b/apps/dashboard/app/components/receipts/ApprovalReceiptRow.test.ts index 7984cff8..3ba51434 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 2ab0e4f3..18eb20c3 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/bridge-route.test.ts b/apps/dashboard/app/foldkit/content/bridge-route.test.ts index 5164d273..c898e46c 100644 --- a/apps/dashboard/app/foldkit/content/bridge-route.test.ts +++ b/apps/dashboard/app/foldkit/content/bridge-route.test.ts @@ -1,4 +1,4 @@ -import { ArticleDetailExample, DepartmentExample } from "@vektorprogrammet/http-api"; +import { ArticleDetailExample, DepartmentExample } from "@vektorprogrammet/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { conditionalReadHeaders, nativeProblemResponse, nativeSessionResponse, privateReadHeaders, routeArgs, sessionCookie } from "../../../test/native-http"; diff --git a/apps/dashboard/app/foldkit/content/bridge.ts b/apps/dashboard/app/foldkit/content/bridge.ts index 2f4e7105..48809677 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 06242467..22ecc036 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 d8697401..60ba41ba 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/rpc"; +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 2aba2f97..374bd582 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 9d6b839d..45f349d4 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/rpc" 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 27cdba95..e3c37519 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/rpc" +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 d53c7c2c..c8e7da93 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/rpc" +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 6509de16..58711091 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/rpc" +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 2365cfe6..ed3bf350 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/rpc" +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 5a6068a2..7a3b5b24 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/rpc" 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/model.ts b/apps/dashboard/app/foldkit/dashboard/model.ts index 61034cfb..5fd21204 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/dated-school-service/model.ts b/apps/dashboard/app/foldkit/dated-school-service/model.ts index 98dacf74..7a91f6b2 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/bridge.ts b/apps/dashboard/app/foldkit/interview/bridge.ts index d66637bc..7609d696 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 68618d30..16add18e 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 2223d503..6b665e5e 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 d727aee8..9f294fb9 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 e771b64b..a891cf08 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 bfdd5fc1..4c6d433c 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 d4ec86bf..bb0dd769 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 ad80e9d8..38b7668b 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" +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/browser-client.ts b/apps/dashboard/app/foldkit/organization/browser-client.ts index a5cb1916..829f7f22 100644 --- a/apps/dashboard/app/foldkit/organization/browser-client.ts +++ b/apps/dashboard/app/foldkit/organization/browser-client.ts @@ -1,6 +1,6 @@ import type { DepartmentJson, FieldOfStudyJson, -TeamJson, } from "@vektorprogrammet/http-api" +TeamJson, } from "@vektorprogrammet/rpc" import { createEffectClient, type EffectSdkFailure } from "@vektorprogrammet/sdk/effect"; import { Data, Effect } from "effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; diff --git a/apps/dashboard/app/foldkit/organization/delegations.ts b/apps/dashboard/app/foldkit/organization/delegations.ts index d6cf7e9a..979d8a46 100644 --- a/apps/dashboard/app/foldkit/organization/delegations.ts +++ b/apps/dashboard/app/foldkit/organization/delegations.ts @@ -9,7 +9,7 @@ import { DelegationManagement, IdempotencyKey, type OrganizationCapability, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { createEffectClient } from "@vektorprogrammet/sdk/effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; import { nativeProblemFrom } from "../../lib/native-problem"; diff --git a/apps/dashboard/app/foldkit/organization/management.ts b/apps/dashboard/app/foldkit/organization/management.ts index e605f9ed..b108192c 100644 --- a/apps/dashboard/app/foldkit/organization/management.ts +++ b/apps/dashboard/app/foldkit/organization/management.ts @@ -3,7 +3,7 @@ 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 { AppointmentManagement, OrganizationLifecycleCommand, IdempotencyKey } from "@vektorprogrammet/rpc"; import { createEffectClient } from "@vektorprogrammet/sdk/effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; import { nativeProblemFrom } from "../../lib/native-problem"; diff --git a/apps/dashboard/app/foldkit/organization/model.ts b/apps/dashboard/app/foldkit/organization/model.ts index 35afd7f3..4f5b4ea2 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 870f6c52..fbd3e270 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 44a8e6c5..7d20b0d2 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/command.test.ts b/apps/dashboard/app/foldkit/profile/command.test.ts index 1d264614..943bcea5 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 648171d0..9856c209 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 f9ab7822..b06ea3be 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/command.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts index 9c2544c5..b5bfe396 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts @@ -1,6 +1,6 @@ import { Effect, Match, Schema as S } from "effect"; import { Command } from "foldkit"; -import { IdempotencyKey, RecruitmentMaintenanceCommand } from "@vektorprogrammet/http-api"; +import { IdempotencyKey, RecruitmentMaintenanceCommand } from "@vektorprogrammet/rpc"; import { createEffectClient } from "@vektorprogrammet/sdk/effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; import { nativeProblemFrom } from "../../lib/native-problem"; diff --git a/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/message.ts index ce1c5d61..7b57ee06 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 df3bb913..40359052 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 6294a592..598c9af8 100644 --- a/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts +++ b/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts @@ -1,5 +1,5 @@ import { Match, Option, Predicate, Schema } from "effect"; -import { RecruitmentMaintenanceCommand, PersonId } from "@vektorprogrammet/http-api"; +import { RecruitmentMaintenanceCommand, PersonId } 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 72627526..8d21cda1 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/bridge-route.test.ts b/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts index 9b7d73e0..d0966178 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts @@ -1,4 +1,4 @@ -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 { RecruitmentBridgeFailure } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/recruitment/bridge.ts b/apps/dashboard/app/foldkit/recruitment/bridge.ts index 2d77a00f..edcb5249 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge.ts @@ -1,14 +1,14 @@ 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, ConditionalReadHeaders, CreateApplicationInterviewRequest, FinalizeInterviewRequest, CorrectInterviewAssessmentRequest, CorrectInterviewAssessmentResponse, FinalizeInterviewResponse, IdempotencyHeaders, IdempotencyIfMatchHeaders, RecruitmentInterviewResource, SchedulingBoard, ScheduleInterviewRequest, ScheduleInterviewResponse, StrongETag } from "@vektorprogrammet/rpc"; export const RecruitmentBoardStatus = RecruitmentAssignmentBoardQuerySchema.fields.status; diff --git a/apps/dashboard/app/foldkit/recruitment/browser-client.ts b/apps/dashboard/app/foldkit/recruitment/browser-client.ts index 03cc3ada..d97b5a1b 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, diff --git a/apps/dashboard/app/foldkit/recruitment/elements.ts b/apps/dashboard/app/foldkit/recruitment/elements.ts index 56de3782..154c4e04 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 44955feb..fc118f96 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 ac63cc6d..8b47f6b6 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 260358d6..f88be6a3 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 98bfca06..545d73db 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 344a14d2..f49e66fe 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 963f6b9d..dc4bb349 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/command.ts b/apps/dashboard/app/foldkit/scheduling/command.ts index 8f93625e..ad930bab 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 ba38cad0..1adce518 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 f7feb45b..49ce18c4 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 97def67d..23db1111 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 afe1f006..c8bdce77 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 b27e8d7d..4a53477a 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 993f4dd3..d006e878 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"; diff --git a/apps/dashboard/app/foldkit/scheduling/view.test.ts b/apps/dashboard/app/foldkit/scheduling/view.test.ts index 81a4dd57..c2509de1 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 cd99f753..c16e2e35 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/rpc" 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"; diff --git a/apps/dashboard/app/foldkit/schools/browser-client.ts b/apps/dashboard/app/foldkit/schools/browser-client.ts index 2d0afa51..d24aa25a 100644 --- a/apps/dashboard/app/foldkit/schools/browser-client.ts +++ b/apps/dashboard/app/foldkit/schools/browser-client.ts @@ -4,8 +4,8 @@ import type { SchoolCommand, SchoolCommandResult, SchoolManagement, -} from "@vektorprogrammet/http-api"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { createEffectClient, type EffectSdkFailure } from "@vektorprogrammet/sdk/effect"; import { Effect } from "effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; diff --git a/apps/dashboard/app/foldkit/schools/command.ts b/apps/dashboard/app/foldkit/schools/command.ts index aa9445a7..67af7f20 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/rpc"; 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 dc7bfd1a..7e7c6a99 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/rpc"; 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 62a33a99..44cb61e2 100644 --- a/apps/dashboard/app/foldkit/schools/message.ts +++ b/apps/dashboard/app/foldkit/schools/message.ts @@ -1,11 +1,11 @@ -import { DepartmentId } from "@vektorprogrammet/http-api"; +import { DepartmentId } from "@vektorprogrammet/rpc"; 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 d3ad84d6..bd76b602 100644 --- a/apps/dashboard/app/foldkit/schools/model.ts +++ b/apps/dashboard/app/foldkit/schools/model.ts @@ -3,8 +3,8 @@ import { SchoolId, SchoolManagement, SchoolCommand, -} from "@vektorprogrammet/http-api"; -import { SchoolDirectoryDepartmentSchema, SchoolDirectorySchema } from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; +import { SchoolDirectoryDepartmentSchema, SchoolDirectorySchema } 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 ab68ad34..70208c24 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, SchoolManagement, SchoolCommandResult } from "@vektorprogrammet/rpc"; +import { SchoolId, 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 f7e70b74..41013b6e 100644 --- a/apps/dashboard/app/foldkit/schools/update.ts +++ b/apps/dashboard/app/foldkit/schools/update.ts @@ -1,4 +1,4 @@ -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 { @@ -6,7 +6,7 @@ import { DepartmentId, SemesterId, type SchoolManagement, -} from "@vektorprogrammet/http-api"; +} 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 c0b2b825..e64288c4 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 } from "@vektorprogrammet/rpc"; +import { SchoolId, 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 6643c3c0..ed8e4d01 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/rpc"; +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/bridge.ts b/apps/dashboard/app/foldkit/social-events/bridge.ts index 34995d5f..8a1020a4 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 464eda04..55cc196b 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 55f50625..a3fd77dd 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 266fe9b8..23f18efa 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/browser-client.ts b/apps/dashboard/app/foldkit/team-applications/browser-client.ts index 42962e64..0ca37ff7 100644 --- a/apps/dashboard/app/foldkit/team-applications/browser-client.ts +++ b/apps/dashboard/app/foldkit/team-applications/browser-client.ts @@ -6,7 +6,7 @@ import type { TeamApplicationIntakeResource, TeamApplicationListResponse, TeamApplicationResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { createEffectClient, type EffectSdk } from "@vektorprogrammet/sdk/effect"; import { Effect } from "effect"; import { resolveBrowserApiUrl } from "../../lib/browser-api"; diff --git a/apps/dashboard/app/foldkit/team-applications/command.ts b/apps/dashboard/app/foldkit/team-applications/command.ts index bde629b4..4fa6acab 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 dd0b4607..4d8a5b0f 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 8c49502d..cce9777d 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 3e2762e5..406df7f3 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 fd4939e2..1aeac9c6 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 2ee9e961..fc1c4c26 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 553b850a..ff169b89 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 87fc7825..a3c5e068 100644 --- a/apps/dashboard/app/foldkit/team-applications/update.test.ts +++ b/apps/dashboard/app/foldkit/team-applications/update.test.ts @@ -6,7 +6,7 @@ import { TeamApplicationListResponse, TeamApplicationResource, type NativeProblemCode, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { createEffectClient, type FetchCapability } from "@vektorprogrammet/sdk/effect"; import { Effect, Option, Predicate, Schema as S } from "effect"; import { AsyncData, type Update } from "foldkit"; diff --git a/apps/dashboard/app/foldkit/team-applications/update.ts b/apps/dashboard/app/foldkit/team-applications/update.ts index 97aa0c5e..f3b2ed6b 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 88dec508..ef230ec2 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 1812fdd4..e6c00788 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 3b12ad76..fe126a11 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"; diff --git a/apps/dashboard/app/lib/api.server.ts b/apps/dashboard/app/lib/api.server.ts index 4ebf9cda..1f9abb4b 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 abc24002..a0a9fbbd 100644 --- a/apps/dashboard/app/lib/auth.server.test.ts +++ b/apps/dashboard/app/lib/auth.server.test.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect"; -import { makeNativeProblem } from "@vektorprogrammet/http-api"; +import { makeNativeProblem } from "@vektorprogrammet/rpc"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.hoisted(() => vi.stubEnv("API_URL", "http://api.test")); diff --git a/apps/dashboard/app/lib/auth.server.ts b/apps/dashboard/app/lib/auth.server.ts index 7f84ccbb..891a9b85 100644 --- a/apps/dashboard/app/lib/auth.server.ts +++ b/apps/dashboard/app/lib/auth.server.ts @@ -1,6 +1,6 @@ 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"; diff --git a/apps/dashboard/app/lib/interview-bridge.server.test.ts b/apps/dashboard/app/lib/interview-bridge.server.test.ts index 88029584..06d76a75 100644 --- a/apps/dashboard/app/lib/interview-bridge.server.test.ts +++ b/apps/dashboard/app/lib/interview-bridge.server.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 { InvitationBridgeFailureSchema, INVITATION_INTERACTION_HEADER } from "../foldkit/interview/bridge"; diff --git a/apps/dashboard/app/lib/interview-bridge.server.ts b/apps/dashboard/app/lib/interview-bridge.server.ts index c0251eff..5732c5df 100644 --- a/apps/dashboard/app/lib/interview-bridge.server.ts +++ b/apps/dashboard/app/lib/interview-bridge.server.ts @@ -1,6 +1,6 @@ import { RecruitmentInvitationCapabilitySchema, -RecruitmentInvitationResponseMessageSchema, } from "@vektorprogrammet/http-api" -import { parseJsonWithUniqueMembers } from "@vektorprogrammet/http-api" +RecruitmentInvitationResponseMessageSchema, } from "@vektorprogrammet/rpc" +import { parseJsonWithUniqueMembers } from "@vektorprogrammet/rpc" import { createConfiguredPromiseClient } from "@vektorprogrammet/sdk"; import { Schema as S, Match, flow, Option, Result } from "effect"; import { nativeProblemFrom } from "./native-problem"; diff --git a/apps/dashboard/app/lib/native-problem.test.ts b/apps/dashboard/app/lib/native-problem.test.ts index 5d70536e..d3eef8e4 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 { diff --git a/apps/dashboard/app/lib/native-problem.ts b/apps/dashboard/app/lib/native-problem.ts index 352c3355..851ccaeb 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]); diff --git a/apps/dashboard/app/lib/preview-role-override.ts b/apps/dashboard/app/lib/preview-role-override.ts index 24c766d2..e9bf4bd1 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 0bffd722..71658ee0 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 91be755c..09f15212 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/apps/dashboard/app/lib/receipt-view.ts b/apps/dashboard/app/lib/receipt-view.ts index 4cde54af..bd5db304 100644 --- a/apps/dashboard/app/lib/receipt-view.ts +++ b/apps/dashboard/app/lib/receipt-view.ts @@ -5,7 +5,7 @@ import { ReceiptListItem, ReceiptSettlementQueueItem, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; type OwnedReceiptProjection = typeof ReceiptListItem.Type; diff --git a/apps/dashboard/app/receipt-owner-intake-route.test.ts b/apps/dashboard/app/receipt-owner-intake-route.test.ts index 7932201c..decf87f6 100644 --- a/apps/dashboard/app/receipt-owner-intake-route.test.ts +++ b/apps/dashboard/app/receipt-owner-intake-route.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/http-api"; +import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/rpc"; import { createStaticHandler, RouterContextProvider } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { nativeSessionResponse, sessionCookie } from "../test/native-http"; diff --git a/apps/dashboard/app/routes/__foldkit.content.ts b/apps/dashboard/app/routes/__foldkit.content.ts index 9c101ee0..759933d9 100644 --- a/apps/dashboard/app/routes/__foldkit.content.ts +++ b/apps/dashboard/app/routes/__foldkit.content.ts @@ -1,5 +1,5 @@ -import { StrongETag } from "@vektorprogrammet/http-api"; +import { StrongETag } from "@vektorprogrammet/rpc"; import { Schema as S, flow } from "effect"; import { data } from "react-router"; import { contentBridgeFailure, type ContentBridgeErrorTag, ContentBridgeActionSchema } from "../foldkit/content/bridge"; diff --git a/apps/dashboard/app/routes/__foldkit.profile.ts b/apps/dashboard/app/routes/__foldkit.profile.ts index 153159c7..c81ef648 100644 --- a/apps/dashboard/app/routes/__foldkit.profile.ts +++ b/apps/dashboard/app/routes/__foldkit.profile.ts @@ -1,4 +1,4 @@ -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"; diff --git a/apps/dashboard/app/routes/__foldkit.social-events.ts b/apps/dashboard/app/routes/__foldkit.social-events.ts index 812211c4..97e8705f 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.assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.assistenter._index.tsx index 124cb201..98e78150 100644 --- a/apps/dashboard/app/routes/dashboard.assistenter._index.tsx +++ b/apps/dashboard/app/routes/dashboard.assistenter._index.tsx @@ -11,7 +11,7 @@ import { type OwnCoverageResource, type PlacementBoardResource, type PlacementDraftResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Record, Option, Schema, Match } from "effect"; import { createElement, type ReactNode, useState } from "react"; import { Form, data, useActionData, useFetcher, useLoaderData, useLocation } from "react-router"; diff --git a/apps/dashboard/app/routes/dashboard.brukere._index.tsx b/apps/dashboard/app/routes/dashboard.brukere._index.tsx index dde0323d..44559a8a 100644 --- a/apps/dashboard/app/routes/dashboard.brukere._index.tsx +++ b/apps/dashboard/app/routes/dashboard.brukere._index.tsx @@ -5,7 +5,7 @@ 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 { PeopleDirectoryEntry } from "@vektorprogrammet/rpc"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import { createAuthenticatedClient } from "../lib/api.server"; import type { Route } from "./+types/dashboard.brukere._index"; diff --git a/apps/dashboard/app/routes/dashboard.epostliste._index.tsx b/apps/dashboard/app/routes/dashboard.epostliste._index.tsx index 4b4ab62a..c28e1e0d 100644 --- a/apps/dashboard/app/routes/dashboard.epostliste._index.tsx +++ b/apps/dashboard/app/routes/dashboard.epostliste._index.tsx @@ -1,4 +1,4 @@ -import { MailingListQuery, type PlacementScopes } from "@vektorprogrammet/http-api"; +import { MailingListQuery, type PlacementScopes } from "@vektorprogrammet/rpc"; import { Match, Schema } from "effect"; import { Form, data, useLoaderData, useNavigation } from "react-router"; import { Button } from "../components/ui/button"; diff --git a/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx b/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx index a1c1e5de..e6ab77f3 100644 --- a/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx +++ b/apps/dashboard/app/routes/dashboard.intervjuer.rapport.tsx @@ -3,7 +3,7 @@ import { Schema } from "effect"; import { Form, Link, data, useLoaderData, useNavigation } from "react-router"; import { InterviewReport, InterviewReportQuery, -interviewScoreTotal, } from "@vektorprogrammet/http-api" +interviewScoreTotal, } from "@vektorprogrammet/rpc" import { createAuthenticatedClient } from "../lib/api.server"; import { requireAuth, expiredSessionRedirect } from "../lib/auth.server"; import { recruitmentFailureFromSdk } from "../foldkit/recruitment/bridge"; diff --git a/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx index 01911012..ecfa468c 100644 --- a/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx +++ b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx @@ -20,13 +20,13 @@ import { ReceiptFileTooLarge, ReceiptId, readBoundedReceiptForm, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { data, useActionData, useLoaderData, useNavigation } from "react-router"; import { createAuthenticatedClient } from "../lib/api.server"; diff --git a/apps/dashboard/app/routes/dashboard.onboarding.tsx b/apps/dashboard/app/routes/dashboard.onboarding.tsx index b822cc15..4abc7825 100644 --- a/apps/dashboard/app/routes/dashboard.onboarding.tsx +++ b/apps/dashboard/app/routes/dashboard.onboarding.tsx @@ -1,5 +1,5 @@ -import { OnboardingCommand, OnboardingScope } from "@vektorprogrammet/http-api" -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/http-api"; +import { OnboardingCommand, OnboardingScope } from "@vektorprogrammet/rpc" +import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { useState } from "react"; import { Form, data, useFetcher, useLoaderData } from "react-router"; diff --git a/apps/dashboard/app/routes/dashboard.soknad._index.tsx b/apps/dashboard/app/routes/dashboard.soknad._index.tsx index 47285877..38b313cc 100644 --- a/apps/dashboard/app/routes/dashboard.soknad._index.tsx +++ b/apps/dashboard/app/routes/dashboard.soknad._index.tsx @@ -1,5 +1,5 @@ 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 { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; diff --git a/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx b/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx index fbc5a446..ff5e4db0 100644 --- a/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx +++ b/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx @@ -1,6 +1,6 @@ import { DataTable } from "@/components/data-table"; import type { ColumnDef } from "@tanstack/react-table"; -import { TeamInterestResponse } from "@vektorprogrammet/http-api"; +import { TeamInterestResponse } from "@vektorprogrammet/rpc"; import { useLoaderData } from "react-router"; import { requireAuth } from "../lib/auth.server"; import { createAuthenticatedClient } from "../lib/api.server"; diff --git a/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx index 8c77ee6d..52c22657 100644 --- a/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx +++ b/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx @@ -1,6 +1,6 @@ -import { ReturningAssistantRegistrationInputSchema } from "@vektorprogrammet/http-api" -import { PersonId } from "@vektorprogrammet/http-api" -import { IdempotencyHeaders } from "@vektorprogrammet/http-api"; +import { ReturningAssistantRegistrationInputSchema } from "@vektorprogrammet/rpc" +import { PersonId } from "@vektorprogrammet/rpc" +import { IdempotencyHeaders } from "@vektorprogrammet/rpc"; import { Record, Option, Predicate, Schema } from "effect"; import { data, useFetcher, useLoaderData, useNavigation, useRouteError, useSearchParams } from "react-router"; import { useState, useSyncExternalStore, type FormEvent } from "react"; diff --git a/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts b/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts index c89346c2..b75aac99 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts +++ b/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts @@ -1,5 +1,5 @@ import { Predicate, Schema, flow } from "effect"; -import { ReceiptId } from "@vektorprogrammet/http-api" +import { ReceiptId } from "@vektorprogrammet/rpc" import { createAuthenticatedClient } from "../lib/api.server"; import { requireAuth } from "../lib/auth.server"; diff --git a/apps/dashboard/app/routes/dashboard.utlegg._index.tsx b/apps/dashboard/app/routes/dashboard.utlegg._index.tsx index 49870085..85b6b432 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg._index.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg._index.tsx @@ -11,13 +11,13 @@ import { type ReceiptStatus, ReceiptUiError, } from "@/lib/receipt-view"; -import { ReceiptId } from "@vektorprogrammet/http-api"; +import { 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"; diff --git a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx index f8eaadd8..a96d995e 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx @@ -1,7 +1,7 @@ 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"; diff --git a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx index a5d37caa..c3787039 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor._index.tsx @@ -18,7 +18,7 @@ import { StrongETag, type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { Link, useActionData, useLoaderData, useNavigation } from "react-router"; import { createAuthenticatedClient } from "../lib/api.server"; diff --git a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx index df94029a..873f3a7d 100644 --- a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx +++ b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx @@ -4,7 +4,7 @@ import { IdempotencyIfMatchHeaders, PublicApplicationIdSchema, type AdmissionOutcomeResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Option, Predicate, Schema } from "effect"; import { useState } from "react"; import { diff --git a/apps/dashboard/app/routes/dashboard_.foldkit.tsx b/apps/dashboard/app/routes/dashboard_.foldkit.tsx index 2120e94b..cb3c6cc5 100644 --- a/apps/dashboard/app/routes/dashboard_.foldkit.tsx +++ b/apps/dashboard/app/routes/dashboard_.foldkit.tsx @@ -1,4 +1,4 @@ -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"; diff --git a/apps/dashboard/app/routes/konto-aktivering.tsx b/apps/dashboard/app/routes/konto-aktivering.tsx index 85cc8087..3610a5eb 100644 --- a/apps/dashboard/app/routes/konto-aktivering.tsx +++ b/apps/dashboard/app/routes/konto-aktivering.tsx @@ -1,4 +1,4 @@ -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"; diff --git a/apps/dashboard/e2e/admission-period-management.spec.ts b/apps/dashboard/e2e/admission-period-management.spec.ts index bc400efe..3aab8b21 100644 --- a/apps/dashboard/e2e/admission-period-management.spec.ts +++ b/apps/dashboard/e2e/admission-period-management.spec.ts @@ -15,7 +15,7 @@ import { PublicApplicationConfirmationSchema, SessionUnauthorizedProblem, type SubmitApplicationRequest, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { DateTime, Schema } from "effect"; import { dashboardMount, dashboardPagePath } from "../dashboard-base"; diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index b4ca0619..5993725d 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -5,7 +5,7 @@ import { writeFile } from "node:fs/promises"; import { chromium, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { Predicate } from "effect"; -import { RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/http-api"; +import { RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/rpc"; import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.mjs"; import { sha256 } from "../../../tools/e2e/golden-school-service-evidence.mjs"; diff --git a/apps/dashboard/e2e/golden-team-application-browser.ts b/apps/dashboard/e2e/golden-team-application-browser.ts index e7cc1049..78f9c867 100644 --- a/apps/dashboard/e2e/golden-team-application-browser.ts +++ b/apps/dashboard/e2e/golden-team-application-browser.ts @@ -14,7 +14,7 @@ import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import AxeBuilder from "@axe-core/playwright"; import { chromium, expect as baseExpect, type BrowserContext, type Page } from "@playwright/test"; -import { OrganizationLifecycleCommand } from "@vektorprogrammet/http-api"; +import { OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; // Production bundles load their workflow before rendering server facts; five seconds is too tight. diff --git a/apps/dashboard/e2e/native-content-publication.spec.ts b/apps/dashboard/e2e/native-content-publication.spec.ts index 5f49a0cc..2a677722 100644 --- a/apps/dashboard/e2e/native-content-publication.spec.ts +++ b/apps/dashboard/e2e/native-content-publication.spec.ts @@ -2,7 +2,7 @@ import AxeBuilder from "@axe-core/playwright"; 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 { NativeProblem } from "@vektorprogrammet/rpc"; import { ContentArticleObservationSchema, ContentBridgeActionSchema } from "../app/foldkit/content/bridge"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; diff --git a/apps/dashboard/e2e/native-identity-browser.spec.ts b/apps/dashboard/e2e/native-identity-browser.spec.ts index 010c1921..19aee99d 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -1,4 +1,4 @@ -import { SessionListResponse } from "@vektorprogrammet/http-api"; +import { SessionListResponse } from "@vektorprogrammet/rpc"; import { Schema, Predicate } from "effect"; import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; diff --git a/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts b/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts index 89461f2e..b57662a8 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 { MailingListResponse } from "@vektorprogrammet/rpc"; import { expect, test, type Page } from "@playwright/test"; const nativeIdentityMode = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index fe05b168..64c50690 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -5,7 +5,7 @@ * 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. */ -import { ExternalNativeApi } from "@vektorprogrammet/http-api"; +import { ExternalNativeApi } from "@vektorprogrammet/rpc"; import { OpenApi } from "effect/unstable/httpapi"; const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; diff --git a/apps/dashboard/e2e/native-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 13194e3e..669f233a 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -2,7 +2,7 @@ import { Schema } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { IdempotencyKey } from "@vektorprogrammet/http-api"; +import { IdempotencyKey } from "@vektorprogrammet/rpc"; import { createPromiseClient } from "@vektorprogrammet/sdk"; import { expect, diff --git a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts index b52c4ebd..7ab9f178 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -1,4 +1,4 @@ -import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/http-api"; +import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/rpc"; import { RecruitmentBridgeFailure } from "../app/foldkit/recruitment/bridge"; import { Schema, Predicate } from "effect"; import AxeBuilder from "@axe-core/playwright"; diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index d5dced3c..8fc367c5 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -2,7 +2,7 @@ import AxeBuilder from "@axe-core/playwright"; 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 { NativeProblem } from "@vektorprogrammet/rpc"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; diff --git a/apps/dashboard/e2e/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index 5a540714..1cbddd7f 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -20,7 +20,7 @@ import { ReceiptApprovalQueueResponse, ReceiptListResponse, ReceiptResource, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { z } from "zod"; import { addressesAnyRoute, addressesRoute, legacyRoutes } from "./request-routes.js"; diff --git a/apps/dashboard/e2e/receipts.spec.ts b/apps/dashboard/e2e/receipts.spec.ts index ffd132d3..3b8a86e0 100644 --- a/apps/dashboard/e2e/receipts.spec.ts +++ b/apps/dashboard/e2e/receipts.spec.ts @@ -19,7 +19,7 @@ import { ReceiptListResponse, ReceiptResource, UserProfileResponse, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { dashboardBaseUrl, dashboardMount } from "../dashboard-base"; type JourneyOutcome = Data.TaggedEnum<{ diff --git a/apps/dashboard/e2e/run-real-admission-period-management.mjs b/apps/dashboard/e2e/run-real-admission-period-management.mjs index 35887f4f..cb8c09dc 100644 --- a/apps/dashboard/e2e/run-real-admission-period-management.mjs +++ b/apps/dashboard/e2e/run-real-admission-period-management.mjs @@ -5,7 +5,7 @@ import { AdmissionsApi, CreateAdmissionPeriodEndpoint, ReviseAdmissionPeriodEndpoint, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; diff --git a/apps/dashboard/e2e/run-real-interview-response.mjs b/apps/dashboard/e2e/run-real-interview-response.mjs index 4f922fe0..1b3362c8 100644 --- a/apps/dashboard/e2e/run-real-interview-response.mjs +++ b/apps/dashboard/e2e/run-real-interview-response.mjs @@ -1,7 +1,7 @@ import { RecruitmentReadInvitationResponseProblem, RecruitmentRequestNewInvitationTimeProblem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Predicate, Result, Schema } from "effect"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { createHash, randomBytes } from "node:crypto"; diff --git a/apps/dashboard/e2e/run-real-native-identity-browser.mjs b/apps/dashboard/e2e/run-real-native-identity-browser.mjs index 9705ce3a..fc40ed73 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 { NativeProblem, SessionResponse } from "@vektorprogrammet/rpc"; import { Order, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { mkdtemp, readFile, rm } from "node:fs/promises"; diff --git a/apps/dashboard/e2e/run-real-receipt-owner.mjs b/apps/dashboard/e2e/run-real-receipt-owner.mjs index a7c302e6..850535f4 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 { ReadReceiptEvidenceEndpoint } from "@vektorprogrammet/rpc"; import { Predicate } from "effect"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1f01c5aa..722b914b 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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", diff --git a/apps/dashboard/test/native-http.ts b/apps/dashboard/test/native-http.ts index 94b47ce4..e74e31aa 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/homepage/e2e/public-applicant-admission.spec.ts b/apps/homepage/e2e/public-applicant-admission.spec.ts index 84633874..63bc0c0f 100644 --- a/apps/homepage/e2e/public-applicant-admission.spec.ts +++ b/apps/homepage/e2e/public-applicant-admission.spec.ts @@ -9,7 +9,7 @@ import { PublicApplicationConfirmationSchema, ReadApplicationCatalogEndpoint, SubmitApplicationRequest, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Schema, Struct } from "effect"; import { type HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; import { diff --git a/apps/homepage/e2e/run-real-public-applicant-admission.mjs b/apps/homepage/e2e/run-real-public-applicant-admission.mjs index 00470e42..46c3fb3c 100644 --- a/apps/homepage/e2e/run-real-public-applicant-admission.mjs +++ b/apps/homepage/e2e/run-real-public-applicant-admission.mjs @@ -2,7 +2,7 @@ import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from " import { AdmissionPeriodManagementItem, AdmissionsSubmitApplicationProblem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Predicate, Schema } from "effect"; import { randomBytes, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; diff --git a/apps/homepage/package.json b/apps/homepage/package.json index 6afd0e3e..3fccb69e 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", diff --git a/apps/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index 8850b0a6..c668415f 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -8,7 +8,7 @@ import { ReadTeamApplicationIntakeEndpoint, SubmitContactMessageEndpoint, SubmitTeamApplicationEndpoint, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; type EndpointResponseBody = diff --git a/apps/homepage/src/lib/contact-context.server.ts b/apps/homepage/src/lib/contact-context.server.ts index a0041ab5..5da6f64d 100644 --- a/apps/homepage/src/lib/contact-context.server.ts +++ b/apps/homepage/src/lib/contact-context.server.ts @@ -1,4 +1,4 @@ -import { ContactVisitorIp as ContactVisitorIpSchema } from "@vektorprogrammet/http-api"; +import { ContactVisitorIp as ContactVisitorIpSchema } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import { createContext } from "react-router"; import type { ContactMessageHeaders } from "./api-types"; diff --git a/apps/homepage/src/lib/contact-message.server.ts b/apps/homepage/src/lib/contact-message.server.ts index 9020b5fb..958529df 100644 --- a/apps/homepage/src/lib/contact-message.server.ts +++ b/apps/homepage/src/lib/contact-message.server.ts @@ -1,4 +1,4 @@ -import { ContactMessage, isProblem } from "@vektorprogrammet/http-api"; +import { ContactMessage, isProblem } from "@vektorprogrammet/rpc"; import { createEffectClient } from "@vektorprogrammet/sdk/effect"; import { Match, Predicate, Effect, Schema } from "effect"; import type { ContactMessagePayload, HomepageDepartment } from "./api-types"; diff --git a/apps/homepage/src/lib/news.server.ts b/apps/homepage/src/lib/news.server.ts index 80a6e0cb..3ee1440b 100644 --- a/apps/homepage/src/lib/news.server.ts +++ b/apps/homepage/src/lib/news.server.ts @@ -1,4 +1,4 @@ -import { ArticleSlug, isProblem } from "@vektorprogrammet/http-api"; +import { ArticleSlug, isProblem } from "@vektorprogrammet/rpc"; import type { PublishedNewsArticle, PublishedNewsListing } from "./api-types"; import { createHomepageApiClient } from "./api.server"; import { diff --git a/apps/homepage/src/lib/public-application-choices.ts b/apps/homepage/src/lib/public-application-choices.ts index 79b1aece..6b96a533 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 df048628..03bde1c5 100644 --- a/apps/homepage/src/lib/public-application.ts +++ b/apps/homepage/src/lib/public-application.ts @@ -1,6 +1,6 @@ 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, diff --git a/apps/homepage/src/lib/public-team-application.ts b/apps/homepage/src/lib/public-team-application.ts index 187f2495..77c94300 100644 --- a/apps/homepage/src/lib/public-team-application.ts +++ b/apps/homepage/src/lib/public-team-application.ts @@ -7,7 +7,7 @@ import { TeamApplicationInput, TeamApplicationsReadIntakeProblem, TeamApplicationsSubmitProblem, -} from "@vektorprogrammet/http-api"; +} from "@vektorprogrammet/rpc"; import { Data, Match, Option, Predicate, Schema, Struct } from "effect"; import type { HomepageTeamApplicationIntake, diff --git a/apps/homepage/test/contact-message.test.ts b/apps/homepage/test/contact-message.test.ts index eddd33c4..7a6cb875 100644 --- a/apps/homepage/test/contact-message.test.ts +++ b/apps/homepage/test/contact-message.test.ts @@ -1,6 +1,6 @@ import { RouterContextProvider } from "react-router"; import { contactIngressContext } from "../src/lib/contact-context.server"; -import { DepartmentJsonSchema, type DepartmentJson } from "@vektorprogrammet/http-api" +import { DepartmentJsonSchema, type DepartmentJson } from "@vektorprogrammet/rpc" import { Schema } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -36,8 +36,8 @@ 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"; +import { ContactVisitorIp } from "@vektorprogrammet/rpc" +import { makeNativeProblem } from "@vektorprogrammet/rpc"; const makeDepartment = (overrides: Partial = {}): DepartmentJson => Schema.decodeSync(DepartmentJsonSchema)({ diff --git a/apps/homepage/test/news.test.ts b/apps/homepage/test/news.test.ts index e2bca6cb..fdfd5641 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,7 +9,7 @@ type NewsApiFixture = { }; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ArticleSlug, DepartmentJsonSchema, makeNativeProblem } from "@vektorprogrammet/http-api"; +import { ArticleSlug, DepartmentJsonSchema, makeNativeProblem } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; import type { PublishedNewsSummary, HomepageDepartment, PublishedNewsArticle, PublishedNewsListing } from "../src/lib/api-types"; diff --git a/apps/homepage/test/public-application.test.ts b/apps/homepage/test/public-application.test.ts index 3de0e1a8..1a5e6e41 100644 --- a/apps/homepage/test/public-application.test.ts +++ b/apps/homepage/test/public-application.test.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { createPromiseClient } from "@vektorprogrammet/sdk"; -import { makeNativeProblem } from "@vektorprogrammet/http-api"; +import { makeNativeProblem } from "@vektorprogrammet/rpc"; import { describe, expect, it } from "vitest"; import { mapPublicApplicationError, diff --git a/apps/homepage/test/public-team-application.test.ts b/apps/homepage/test/public-team-application.test.ts index c8962ea8..124eaebc 100644 --- a/apps/homepage/test/public-team-application.test.ts +++ b/apps/homepage/test/public-team-application.test.ts @@ -4,7 +4,7 @@ import { 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"; diff --git a/apps/homepage/test/team-directory.test.ts b/apps/homepage/test/team-directory.test.ts index 47b0b70d..bf4d5d22 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/bun.lock b/bun.lock index 4ff55f2d..5a26a2a9 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", @@ -145,8 +144,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", @@ -223,9 +221,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,20 +236,6 @@ "vitest": "catalog:effect-v4", }, }, - "packages/sdk": { - "name": "@vektorprogrammet/sdk", - "version": "0.2.0", - "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", - }, - }, "tools/conventions": { "name": "@monoweb/conventions", "version": "0.1.0", @@ -273,7 +257,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", @@ -338,8 +322,7 @@ "@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", @@ -1385,9 +1368,9 @@ "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -1451,9 +1434,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 +2442,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 +2458,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=="], @@ -2509,11 +2490,11 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], "react-devtools-inline": ["react-devtools-inline@4.4.0", "", { "dependencies": { "es6-symbol": "^3" } }, "sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ=="], - "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-error-boundary": ["react-error-boundary@6.1.6", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A=="], @@ -2617,7 +2598,7 @@ "sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -2857,7 +2838,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.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-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + "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=="], "vite-plugin-arraybuffer": ["vite-plugin-arraybuffer@0.1.4", "", {}, "sha512-Ubfsw/g4PhdjlgsKYDm2Hlc0qmNJheX8VCeOMayq1k9fMVB1nNuqtWjDH1fj9hSlhlSnlndz/ehMKIuXpB0SeA=="], @@ -2973,28 +2954,10 @@ "@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=="], - - "@monoweb/docs/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "@monoweb/docs/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - - "@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 +2986,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 +3066,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 +3078,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=="], @@ -3209,10 +3170,6 @@ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "react-server-dom-webpack/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "react-server-dom-webpack/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - "read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -3235,19 +3192,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=="], @@ -3255,20 +3212,10 @@ "vocs/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "vocs/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "vocs/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - "vocs/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "waku/@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=="], - "waku/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - - "waku/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], - - "waku/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=="], - "wrangler/miniflare": ["miniflare@5.20260921.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260921.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ=="], "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -3305,14 +3252,6 @@ "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@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=="], "@scalar/api-client/@scalar/workspace-store/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.4.0", "", { "dependencies": { "@scalar/helpers": "0.15.0", "@scalar/openapi-types": "0.9.7" } }, "sha512-Qq+cOhac8xSyV2uiwiqFdwc7ctmZ1uz2n8h6oWSahUDcz9cX8PSuM5vvH+f8w5PbSyaJHVBSN4b85/jf9EfjFw=="], @@ -3425,6 +3364,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=="], @@ -3467,8 +3408,6 @@ "radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="], - "react-server-dom-webpack/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -3525,7 +3464,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=="], @@ -3535,22 +3504,12 @@ "vocs/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "vocs/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], - "waku/@vitejs/plugin-rsc/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "waku/@vitejs/plugin-rsc/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], "waku/@vitejs/plugin-rsc/strip-literal": ["strip-literal@4.0.0", "", { "dependencies": { "js-tokens": "^10.0.0" } }, "sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw=="], - "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=="], "wrangler/miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3605,38 +3564,6 @@ "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], - "@monoweb/docs/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], - - "@monoweb/docs/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=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], - - "@monoweb/docs/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=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], - - "@monoweb/docs/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], - "@scalar/api-client/@scalar/workspace-store/@scalar/json-magic/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "@scalar/blocks/@scalar/workspace-store/@scalar/json-magic/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3663,6 +3590,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=="], @@ -3671,38 +3600,6 @@ "waku/@vitejs/plugin-rsc/strip-literal/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "waku/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], - - "waku/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], - - "waku/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], - - "waku/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], - - "waku/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], - - "waku/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], - - "waku/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=="], - - "waku/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], - - "waku/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], - - "waku/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], - - "waku/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=="], - - "waku/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], - - "waku/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], - - "waku/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], - - "waku/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], - - "waku/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], - "wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], "wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md new file mode 100644 index 00000000..bcb81fb8 --- /dev/null +++ b/docs/specs/rpc-only.md @@ -0,0 +1,283 @@ +# 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 `makeScriptClient` 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. + +## 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 | todo | +| `admissionOutcomes.readOutcome` | GET `/api/admission-outcomes/{applicationId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | +| `admissionOutcomes.readOutcomes` | GET `/api/admission-outcomes` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `admissionOutcomes.recordOutcome` | POST `/api/admission-outcomes/{applicationId}:record` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | + +### admissions (`packages/rpc/src/admissions.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `admissions.createAdmissionPeriod` | POST `/api/admission-periods` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `admissions.listAdmissionPeriods` | GET `/api/admission-periods` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | +| `admissions.listApplicationOptions` | GET `/api/application-options` | ifMatch; if-none-match (dropped) | none | todo | +| `admissions.listOpenAdmissionPeriods` | GET `/api/open-admission-periods` | ifMatch; if-none-match (dropped) | none | todo | +| `admissions.readApplicantProgress` | GET `/api/applicant-progress` | - | cookieHeader, oauthUserBearer | todo | +| `admissions.readApplicationConfirmation` | GET `/api/applications/{applicationId}` | - | none | todo | +| `admissions.readReturningAssistantOptions` | GET `/api/returning-assistant/options` | - | cookieHeader, oauthUserBearer | todo | +| `admissions.registerReturningAssistant` | POST `/api/returning-assistant/registrations` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `admissions.reviseAdmissionPeriod` | PATCH `/api/admission-periods/{admissionPeriodId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | +| `admissions.submitApplication` | POST `/api/applications` | idempotencyKey | none | todo | + +### 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 | todo | +| `certificates.issueCertificate` | POST `/api/departments/{departmentId}/certificates/{personId}/issues` | idempotencyKey; ifMatch; binary application/pdf | cookieHeader, oauthUserBearer | todo | +| `certificates.listCertificates` | GET `/api/departments/{departmentId}/certificates` | query: cursor | cookieHeader, oauthUserBearer | todo | +| `certificates.listDaysServed` | GET `/api/departments/{departmentId}/semesters/{semesterId}/days-served` | query: cursor | cookieHeader, oauthUserBearer | todo | +| `certificates.readCertificate` | GET `/api/departments/{departmentId}/certificates/{personId}` | - | cookieHeader, oauthUserBearer | todo | +| `certificates.readCertificateScopes` | GET `/api/certificate-scopes` | - | cookieHeader, oauthUserBearer | todo | + +### contact (`packages/rpc/src/contact.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `contact.submitContactMessage` | POST `/api/contact-messages` | contact ip header | contactBackend | todo | + +### content (`packages/rpc/src/content.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `content.createArticle` | POST `/api/content/articles` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `content.listNews` | GET `/api/news` | ifMatch; if-none-match (dropped); query: department | none | todo | +| `content.publishArticle` | POST `/api/content/articles/{articleId}:publish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `content.readArticle` | GET `/api/content/articles/{articleId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | +| `content.readContentWorkspace` | GET `/api/content/articles` | query: department | cookieHeader, oauthUserBearer | todo | +| `content.readNewsArticle` | GET `/api/news/{slug}` | ifMatch; if-none-match (dropped); query: version | none | todo | +| `content.reviseArticle` | PATCH `/api/content/articles/{articleId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | +| `content.unpublishArticle` | POST `/api/content/articles/{articleId}:unpublish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | + +### directory (`packages/rpc/src/directory.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `directory.executeSchoolCommand` | POST `/api/schools/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `directory.listPeople` | GET `/api/people` | - | cookieHeader, oauthUserBearer | todo | +| `directory.listSchools` | GET `/api/schools` | query: department | cookieHeader, oauthUserBearer | todo | +| `directory.readSchoolManagement` | GET `/api/schools/management` | - | cookieHeader, oauthUserBearer | todo | + +### internal (`packages/rpc/src/receipts.ts (InternalReceiptsRpcs)`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `receipts.readReceiptEvidence` | GET `/api/receipt-lifecycle-evidence-records/{receiptId}` | - | cookieHeader | todo | + +### onboarding (`packages/rpc/src/onboarding.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `onboarding.claim` | POST `/api/onboarding/claim` | - | cookieHeader | todo | +| `onboarding.command` | POST `/api/onboarding` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | todo | +| `onboarding.readBoard` | GET `/api/onboarding` | query: departmentId | cookieHeader, oauthUserBearer | todo | + +### organization (`packages/rpc/src/organization.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `organization.createDepartment` | POST `/api/departments` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `organization.createFieldOfStudy` | POST `/api/field-of-studies` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `organization.createTeam` | POST `/api/teams` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `organization.executeDelegation` | POST `/api/organization/delegations/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `organization.executeLifecycle` | POST `/api/organization/appointments/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `organization.listDepartments` | GET `/api/departments` | ifMatch; if-none-match (dropped) | none | todo | +| `organization.listFieldOfStudies` | GET `/api/field-of-studies` | ifMatch; if-none-match (dropped) | none | todo | +| `organization.listMailingLists` | GET `/api/mailing-lists` | query: department, semester, type | cookieHeader, oauthUserBearer | todo | +| `organization.listTeamInterest` | GET `/api/team-interest-registrations` | query: department, semester | cookieHeader, oauthUserBearer | todo | +| `organization.listTeams` | GET `/api/teams` | ifMatch; if-none-match (dropped) | none | todo | +| `organization.readAppointmentManagement` | GET `/api/organization/appointments` | - | cookieHeader, oauthUserBearer | todo | +| `organization.readBoardRosters` | GET `/api/organization/board-rosters` | - | cookieHeader, oauthUserBearer | todo | +| `organization.readDelegationManagement` | GET `/api/organization/delegations` | - | cookieHeader, oauthUserBearer | todo | + +### 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 | todo | +| `placements.commandCoverageBoard` | POST `/api/placements/coverage` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `placements.commandOwnAffiliation` | POST `/api/placements/affiliation` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | todo | +| `placements.commandOwnCoverage` | POST `/api/placements/coverage/own` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `placements.listScopes` | GET `/api/placements/scopes` | - | cookieHeader, oauthUserBearer | todo | +| `placements.readBoard` | GET `/api/placements` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `placements.readCoverageBoard` | GET `/api/placements/coverage` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `placements.readDraft` | GET `/api/placements/draft` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `placements.readOwnAffiliation` | GET `/api/placements/affiliation` | query: departmentId | cookieHeader, oauthUserBearer | todo | +| `placements.readOwnCoverage` | GET `/api/placements/coverage/own` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | + +### 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 | todo | +| `profile.updateOwnProfile` | PATCH `/api/profile` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | + +### receipts (`packages/rpc/src/receipts.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `receipts.approveReceipt` | POST `/api/receipts/{receiptId}:approve` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `receipts.listReceipts` | GET `/api/receipts` | query: cursor, status | cookieHeader, oauthUserBearer | todo | +| `receipts.listReceiptsForApproval` | GET `/api/receipt-approval-queue` | query: cursor, status | cookieHeader, oauthUserBearer, oauthServiceBearer | todo | +| `receipts.listReceiptsForSettlement` | GET `/api/receipt-settlement-queue` | query: cursor | cookieHeader, oauthUserBearer | todo | +| `receipts.readReceiptFile` | GET `/api/receipts/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | todo | +| `receipts.readReceiptFileForApproval` | GET `/api/receipt-approval-queue/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | todo | +| `receipts.readReceiptSettlementForFinance` | GET `/api/receipt-settlement-queue/{receiptId}` | - | cookieHeader, oauthUserBearer | todo | +| `receipts.rejectReceipt` | POST `/api/receipts/{receiptId}:reject` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `receipts.reopenReceipt` | POST `/api/receipts/{receiptId}:reopen` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `receipts.reviseReceipt` | PATCH `/api/receipts/{receiptId}` | idempotencyKey; ifMatch; multipart upload | cookieHeader, oauthUserBearer | todo | +| `receipts.settleReceipt` | POST `/api/receipts/{receiptId}:settle` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `receipts.submitReceipt` | POST `/api/receipts` | idempotencyKey; multipart upload; query: departmentId | cookieHeader, oauthUserBearer | todo | +| `receipts.withdrawReceipt` | POST `/api/receipts/{receiptId}:withdraw` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | + +### 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 | todo | +| `recruitment.confirmInvitation` | POST `/api/recruitment/invitation-response:confirm` | ifMatch | invitationCapability | todo | +| `recruitment.correctInterviewAssessment` | POST `/api/recruitment/interviews/{interviewId}:correct` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `recruitment.createApplicationInterview` | POST `/api/recruitment/applications/{applicationId}/interviews` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `recruitment.finalizeInterview` | POST `/api/recruitment/interviews/{interviewId}:finalize` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `recruitment.maintainRecruitment` | POST `/api/recruitment/maintenance/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | +| `recruitment.readAssignmentBoard` | GET `/api/recruitment/application-assignments` | query: status | cookieHeader, oauthUserBearer | todo | +| `recruitment.readInterviewConduct` | GET `/api/recruitment/interviews/{interviewId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | +| `recruitment.readInterviewReport` | GET `/api/recruitment/interview-report` | query: admissionPeriodId, recommendation, participation, sort, direction | cookieHeader, oauthUserBearer | todo | +| `recruitment.readInterviewStaffing` | GET `/api/recruitment/interview-staffing` | - | cookieHeader, oauthUserBearer | todo | +| `recruitment.readInvitationResponse` | GET `/api/recruitment/invitation-response` | ifMatch; if-none-match (dropped) | invitationCapability | todo | +| `recruitment.readQuestionnaires` | GET `/api/recruitment/questionnaires` | - | cookieHeader, oauthUserBearer | todo | +| `recruitment.readSchedulingBoard` | GET `/api/recruitment/interviews` | - | cookieHeader, oauthUserBearer | todo | +| `recruitment.rejectInvitation` | POST `/api/recruitment/invitation-response:reject` | ifMatch | invitationCapability | todo | +| `recruitment.requestNewInvitationTime` | POST `/api/recruitment/invitation-response:request-new-time` | ifMatch | invitationCapability | todo | +| `recruitment.scheduleInterview` | POST `/api/recruitment/interviews/{interviewId}:schedule` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | + +### 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 | + +### system (`packages/rpc/src/system.ts`) + +| RPC tag | Replaces | Transport facts | Credentials | Status | +| --- | --- | --- | --- | --- | +| `system.deleteOwnedSession` | DELETE `/api/sessions/{sessionId}` | idempotencyKey | cookieHeader | todo | +| `system.deleteSession` | DELETE `/api/session` | idempotencyKey | cookieHeader | todo | +| `system.health` | GET `/health` | - | none | todo | +| `system.listSessions` | GET `/api/sessions` | - | cookieHeader | todo | +| `system.readSession` | GET `/api/session` | - | cookieHeader | todo | +| `system.revokeAllSessions` | POST `/api/sessions:revoke-all` | idempotencyKey | cookieHeader | todo | +| `system.revokeOtherSessions` | POST `/api/sessions:revoke-others` | idempotencyKey | cookieHeader | todo | + +### 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 | todo | +| `team-applications.listTeamApplicationIntakes` | GET `/api/team-application-intakes` | - | none | todo | +| `team-applications.listTeamApplications` | GET `/api/teams/{teamId}/applications` | query: cursor | cookieHeader, oauthUserBearer | todo | +| `team-applications.readTeamApplication` | GET `/api/team-applications/{applicationId}` | - | cookieHeader, oauthUserBearer | todo | +| `team-applications.readTeamApplicationIntake` | GET `/api/teams/{teamId}/application-intake` | - | none | todo | +| `team-applications.reviseTeamApplicationIntake` | PATCH `/api/teams/{teamId}/application-intake` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | +| `team-applications.submitTeamApplication` | POST `/api/teams/{teamId}/applications` | idempotencyKey | none | todo | diff --git a/oxlint.config.ts b/oxlint.config.ts index ad4b7f2e..cafb544b 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -17,7 +17,7 @@ const coreEffectFiles = [ "apps/backend/**", "packages/database/**", "packages/domain/**", - "packages/http-api/**", + "packages/rpc/**", ]; const presetRules = (presets: ReadonlyArray, severity: "error" | "off") => @@ -70,7 +70,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 +101,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", @@ -215,7 +215,7 @@ const crossPackageSourceImportPatterns = [ const crossPackageSourceImportPatternsExceptSdk = [ { regex: - "^(\\./)?(\\.\\./)+(apps/[^/]+|tools/[^/]+|packages/(database|domain|http-api)|[^./][^/]*)/src(/|$)", + "^(\\./)?(\\.\\./)+(apps/[^/]+|tools/[^/]+|packages/(database|domain|rpc)|[^./][^/]*)/src(/|$)", message: crossPackageSourceImportMessage, }, ]; @@ -289,11 +289,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", diff --git a/packages/http-api/scripts/generate-openapi.ts b/packages/http-api/scripts/generate-openapi.ts deleted file mode 100644 index 1e662332..00000000 --- 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 eced61a9..00000000 --- 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 4565ff7e..00000000 --- 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 166dab42..00000000 --- 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 2e7f0d82..00000000 --- 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 a726ab98..00000000 --- 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 0192c576..00000000 --- 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 038eee00..00000000 --- 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 2f162718..00000000 --- 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 7d6e1c23..00000000 --- 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 4c4226ea..00000000 --- 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 692ffc45..00000000 --- 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 b30b47e1..00000000 --- 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/receipt-upload.test.ts b/packages/http-api/src/receipt-upload.test.ts deleted file mode 100644 index 280bcdf0..00000000 --- a/packages/http-api/src/receipt-upload.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -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"; - -describe("receipt transfer bounds", () => { - it.effect.each([undefined, "1"])( - "cancels an oversized stream despite declared length %s", - (length) => - Effect.gen(function* () { - let pulls = 0; - const cancelled = Promise.withResolvers(); - const bytes = new TextEncoder(); - - const body = new ReadableStream({ - pull(controller) { - if (pulls === 64) { - controller.close(); - - return; - } - - controller.enqueue( - pulls++ === 0 - ? bytes.encode( - '--receipt\r\nContent-Disposition: form-data; name="description"\r\n\r\n', - ) - : new Uint8Array(4096).fill(120), - ); - }, - cancel() { - cancelled.resolve(); - }, - }); - - const headers = new Headers({ "content-type": "multipart/form-data; boundary=receipt" }); - - if (length !== undefined) headers.set("content-length", length); - - const options: RequestInit & { duplex: "half" } = { - method: "POST", - headers, - body, - duplex: "half", - }; - - const request = new Request("http://receipt.test", options); - - const failure = yield* Effect.promise(() => - readBoundedReceiptForm(request, 1).catch((error: Error) => error), - ); - - assert.ok(failure instanceof MultipartError); - expect(failure.reason._tag).toBe("FieldTooLarge"); - yield* Effect.promise(() => cancelled.promise); - expect(pulls).toBeLessThanOrEqual(36); - }), - ); - - it.effect.each([";", " ;"])("preserves opaque binary bytes with separator %s", (separator) => - Effect.gen(function* () { - const form = new FormData(); - const bytes = new Uint8Array([0, 255, 17, 42]); - form.set("file", new File([bytes], "receipt.png", { type: "image/png" })); - - const request = new Request("http://receipt.test", { method: "POST", body: form }); - request.headers.set( - "content-type", - request.headers.get("content-type")!.replace(";", separator), - ); - const parsed = yield* Effect.promise(() => readBoundedReceiptForm(request, bytes.length)); - - const file = parsed.get("file"); - assert.ok(file instanceof File); - expect(new Uint8Array(yield* Effect.promise(() => file.arrayBuffer()))).toEqual(bytes); - }), - ); - // One transport chunk carries the whole body; 200_000 bytes also exceed the transfer bound. - it.effect.each([5, 200_000])( - "reports a %s-byte file beyond its limit with the fields sent before it", - (fileBytes) => - Effect.gen(function* () { - const form = new FormData(); - form.set("commandId", "receipt-command"); - form.set( - "file", - new File([new Uint8Array(fileBytes)], "receipt.png", { type: "image/png" }), - ); - const encoded = new Request("http://receipt.test", { method: "POST", body: form }); - const bytes = yield* Effect.promise(() => encoded.arrayBuffer()); - const headers = new Headers(encoded.headers); - headers.set("content-length", String(bytes.byteLength)); - - const request = new Request(encoded.url, { method: encoded.method, headers, body: bytes }); - - const failure = yield* Effect.promise(() => - readBoundedReceiptForm(request, 4).catch((error: Error) => error), - ); - - assert.ok(failure instanceof ReceiptFileTooLarge); - expect([...failure.fields.entries()]).toEqual([["commandId", "receipt-command"]]); - }), - ); - it.effect("cancels an incomplete multipart source when the request is aborted", () => - Effect.gen(function* () { - const requestLifetime = yield* Scope.make(); - const signal = yield* Effect.abortSignal.pipe(Scope.provide(requestLifetime)); - const started = Promise.withResolvers(); - const cancelled = Promise.withResolvers(); - - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - '--receipt\r\nContent-Disposition: form-data; name="file"; filename="receipt.png"\r\nContent-Type: image/png\r\n\r\n', - ), - ); - }, - pull() { - started.resolve(); - }, - cancel() { - cancelled.resolve(); - }, - }); - - const options: RequestInit & { duplex: "half" } = { - method: "POST", - headers: { "content-type": "multipart/form-data; boundary=receipt" }, - body, - signal, - duplex: "half", - }; - - const pending = readBoundedReceiptForm(new Request("http://receipt.test", options), 1024); - yield* Effect.promise(() => started.promise); - yield* Scope.close(requestLifetime, Exit.void); - yield* Effect.promise(() => expect(pending).rejects.toThrow()); - yield* Effect.promise(() => cancelled.promise); - }), - ); -}); diff --git a/packages/http-api/src/receipt-upload.ts b/packages/http-api/src/receipt-upload.ts deleted file mode 100644 index 6669f6dc..00000000 --- a/packages/http-api/src/receipt-upload.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { Data, Effect, Stream } from "effect"; -import * as Multipart from "effect/unstable/http/Multipart"; - -export { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; - -/** - * A receipt file part exceeded its byte limit. This is an input error in a readable form: - * `fields` holds the parts read before the file, so a form can answer in place with the - * draft and command identity it submitted. - */ -export class ReceiptFileTooLarge extends RangeError { - readonly fields: FormData; - - constructor(fields: FormData) { - super("Receipt file is too large"); - this.name = "ReceiptFileTooLarge"; - this.fields = fields; - } -} - -/** The request body cannot be read as a receipt form. */ -class ReceiptBodyInvalid extends Data.TaggedError("ReceiptBodyInvalid")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -/** Bytes beyond a file's limit that the transfer bound admits for the other form parts. */ -const transferMargin = 131_072; - -/** The most bytes that a receipt form with a file of at most `maxFileBytes` may transfer. */ -export const receiptTransferMaxBytes = (maxFileBytes: number): number => - maxFileBytes + transferMargin; - -/** - * Transport chunks are split to at most half the margin and parsed one per pull, so the - * parser sees a file cross its limit before the transfer count can cross the body bound. - */ -const transferSlice = transferMargin / 2; - -const slices = function* (chunk: Uint8Array) { - for (let offset = 0; offset < chunk.byteLength; offset += transferSlice) { - yield chunk.subarray(offset, offset + transferSlice); - } -}; - -const boundedReceiptForm = Effect.fnUntraced(function* (request: Request, maxFileBytes: number) { - const maxBytes = receiptTransferMaxBytes(maxFileBytes); - const length = request.headers.get("content-length"); - - if (length !== null && (!/^\d+$/u.test(length) || !Number.isSafeInteger(Number(length)))) { - return yield* new ReceiptBodyInvalid({ message: "Invalid receipt body length" }); - } - - const body = request.body; - - if (!body) return yield* new ReceiptBodyInvalid({ message: "Receipt body is required" }); - const contentType = request.headers.get("content-type") ?? ""; - const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase(); - let size = 0; - - const source = Stream.fromReadableStream({ - evaluate: () => body, - onError: (cause) => new ReceiptBodyInvalid({ message: "Receipt transfer failed", cause }), - }).pipe( - Stream.map(slices), - Stream.flattenIterable, - Stream.rechunk(1), - Stream.map((chunk) => { - size += chunk.byteLength; - - if (size > maxBytes) throw new RangeError("Receipt body is too large"); - - return chunk; - }), - ); - - const form = new FormData(); - - if (mediaType === "application/x-www-form-urlencoded") { - const encoded = yield* source.pipe(Stream.decodeText(), Stream.mkString); - - for (const [key, value] of new URLSearchParams(encoded)) form.append(key, value); - - return form; - } - - if (mediaType !== "multipart/form-data") { - return yield* new ReceiptBodyInvalid({ message: "Unsupported receipt content type" }); - } - - yield* source.pipe( - Stream.pipeThroughChannel(Multipart.makeChannel({ "content-type": contentType })), - Stream.runForEach( - Effect.fnUntraced(function* (part) { - if (Multipart.isField(part)) { - form.append(part.key, part.value); - } else { - const chunks: Array> = []; - let fileBytes = 0; - yield* Stream.runForEach(part.content, (chunk) => - Effect.sync(() => { - fileBytes += chunk.byteLength; - - if (fileBytes > maxFileBytes) throw new ReceiptFileTooLarge(form); - - if (!(chunk.buffer instanceof ArrayBuffer)) - throw new TypeError("Invalid receipt byte buffer"); - chunks.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - }), - ); - form.append(part.key, new File(chunks, part.name, { type: part.contentType })); - } - }), - ), - Effect.provideContext( - Multipart.limitsServices({ - maxParts: 8, - maxFieldSize: 65_536, - maxFileSize: maxBytes, - maxTotalSize: maxBytes, - fieldMimeTypes: [], - }), - ), - ); - - return form; -}); - -/** - * Bound transfer bytes while parsing and cancel the source with its request. - * - * The byte counters, not the declared length, enforce the bounds: a body whose file is - * too large stops after that file's limit, so the fields before it stay readable. - */ -export const readBoundedReceiptForm = (request: Request, maxFileBytes: number): Promise => - Effect.runPromise(boundedReceiptForm(request, maxFileBytes), { signal: request.signal }); diff --git a/packages/http-api/src/receipts.ts b/packages/http-api/src/receipts.ts deleted file mode 100644 index 2dd5efba..00000000 --- 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 7acd6809..00000000 --- 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 81ddad96..00000000 --- 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 9f07baf5..00000000 --- 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 0985b873..00000000 --- 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 f6d56f65..00000000 --- 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 2c0052b4..00000000 --- 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 aab31cdb..00000000 --- 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/http-api/AGENTS.md b/packages/rpc/AGENTS.md similarity index 100% rename from packages/http-api/AGENTS.md rename to packages/rpc/AGENTS.md diff --git a/packages/http-api/CLAUDE.md b/packages/rpc/CLAUDE.md similarity index 100% rename from packages/http-api/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 42d81d90..4a4d8abe 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 68% rename from packages/http-api/src/access.ts rename to packages/rpc/src/access.ts index 5dfd0574..9a074b0d 100644 --- a/packages/http-api/src/access.ts +++ b/packages/rpc/src/access.ts @@ -12,7 +12,7 @@ import { type AuthorizationMode, type CapabilityExpression, CapabilityTypeId, - type CredentialMechanism, + RequirementId, ScopeResolverId, type TypedRequirement, @@ -142,11 +142,11 @@ export const internalReceiptEvidenceAccess = (): AccessSpec => decisionTime: "SnapshotRead", }); -import { Match, Predicate, Context, Option, Schema } from "effect"; -import { HttpApiEndpoint, OpenApi } from "effect/unstable/httpapi"; +import { Match, Predicate, Context, Option, type Schema } from "effect"; +import type { Rpc, RpcMiddleware } from "effect/unstable/rpc"; export const AccessSpecAnnotation = Context.Service( - "@vektorprogrammet/http-api/AccessSpec", + "@vektorprogrammet/rpc/AccessSpec", ); const rank = (order: ReadonlyArray, value: string): number => { @@ -266,152 +266,60 @@ export const assertAccessProjectionRegistryParity = (): void => { 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`, - ); - } - - return Predicate.isString(scheme) ? scheme : null; - }), - Match.exhaustive, - ); -}; - -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"); - } - - // A body capability is the alternative that needs no HTTP security scheme. - return scheme === null ? [{}] : [{ [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); - 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, - }, - ], - ]; - }), - ), - }, - }), + const acceptsPerson = spec.acceptedCredentials.some( + (mechanism) => + Predicate.isTagged(mechanism, "BetterAuthCookie") || + Predicate.isTagged(mechanism, "OAuthUserBearer"), ); -}; -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`); + 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`, + ); + } } - return endpoint.annotateMerge(accessSpecAnnotations(input)); + return Context.make(AccessSpecAnnotation, spec); }; + +/** The AccessSpec that `withAccessSpec` attached to an RPC. */ +export const reflectAccessSpec = (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 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 00000000..09d79dc4 --- /dev/null +++ b/packages/rpc/src/admission-outcomes.ts @@ -0,0 +1,11 @@ +/** + * Admission outcomes: decisions on applications. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/admission-outcomes.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class AdmissionOutcomesRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/admissions.ts b/packages/rpc/src/admissions.ts new file mode 100644 index 00000000..83b23868 --- /dev/null +++ b/packages/rpc/src/admissions.ts @@ -0,0 +1,11 @@ +/** + * Admissions: admission periods and applications. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/admissions.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class AdmissionsRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/api.ts b/packages/rpc/src/api.ts new file mode 100644 index 00000000..2312000a --- /dev/null +++ b/packages/rpc/src/api.ts @@ -0,0 +1,54 @@ +/** + * 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"; + +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 00000000..f0cec168 --- /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 00000000..67ec49f1 --- /dev/null +++ b/packages/rpc/src/certificates.ts @@ -0,0 +1,11 @@ +/** + * Certificates: days served and certificate issue. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/certificates.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class CertificatesRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts new file mode 100644 index 00000000..8808f4d3 --- /dev/null +++ b/packages/rpc/src/client.ts @@ -0,0 +1,47 @@ +/** + * 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/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. + */ +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" })), + ), + ), + ), + ), + ); + +/** 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 00000000..27c4806e --- /dev/null +++ b/packages/rpc/src/contact.ts @@ -0,0 +1,11 @@ +/** + * Contact: anonymous contact messages from the homepage server. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/contact.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class ContactRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/content.ts b/packages/rpc/src/content.ts new file mode 100644 index 00000000..25a5b1af --- /dev/null +++ b/packages/rpc/src/content.ts @@ -0,0 +1,11 @@ +/** + * Content: articles and news. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/content.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class ContentRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/credential.ts b/packages/rpc/src/credential.ts new file mode 100644 index 00000000..60fe9c39 --- /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 00000000..455db26f --- /dev/null +++ b/packages/rpc/src/directory.ts @@ -0,0 +1,11 @@ +/** + * Directory: people and schools. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/directory.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class DirectoryRpcs extends RpcGroup.make() {} 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 73cec7db..f5462c10 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 f815f5cb..a5627e96 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 00000000..33b32d38 --- /dev/null +++ b/packages/rpc/src/onboarding.ts @@ -0,0 +1,11 @@ +/** + * Onboarding: coordinator invitations and claims. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/onboarding.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class OnboardingRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/organization.ts b/packages/rpc/src/organization.ts new file mode 100644 index 00000000..6cdb8df2 --- /dev/null +++ b/packages/rpc/src/organization.ts @@ -0,0 +1,11 @@ +/** + * Organization: departments, teams, fields of study, team interest, mailing lists, delegations, lifecycle. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/organization.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class OrganizationRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/placements.ts b/packages/rpc/src/placements.ts new file mode 100644 index 00000000..1e22657f --- /dev/null +++ b/packages/rpc/src/placements.ts @@ -0,0 +1,11 @@ +/** + * Placements: affiliation, placement boards, drafts, and coverage. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/placements.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class PlacementsRpcs extends RpcGroup.make() {} diff --git a/packages/http-api/src/http-semantics.ts b/packages/rpc/src/problem.ts similarity index 82% rename from packages/http-api/src/http-semantics.ts rename to packages/rpc/src/problem.ts index 41c24444..7f5561cc 100644 --- a/packages/http-api/src/http-semantics.ts +++ b/packages/rpc/src/problem.ts @@ -1,10 +1,9 @@ /** - * 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"; export { parseJsonWithUniqueMembers } from "@vektorprogrammet/domain/http-semantics"; @@ -50,164 +49,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( @@ -1040,7 +881,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 +918,7 @@ export const validationProblemSchema = (code instance: Schema.optional(InstanceUrn), validation: validationBody, }), - ).pipe(HttpApiSchema.status(definition.status)); + ); }; /** The only public extension for semantic validation failures. */ @@ -1139,7 +980,7 @@ 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. * - * @construct http-problem + * @construct rpc-problem */ export const problemUnion = < const Codes extends readonly [NativeProblemCode, ...ReadonlyArray], @@ -1172,7 +1013,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 +1051,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 +1099,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 @@ -1361,7 +1202,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 +1231,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 +1273,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,101 +1287,38 @@ 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); - } - - return [...grouped].map(([status, variants]) => - problemResponse( - status, - Schema.Union(variants), - new Set(variants.map((variant) => variant.fields.code.literal)), - options?.cors !== false, - ), +export const rpcProblems = (problem: Union) => { + const codes = new Set( + problem.members.map((member) => member.fields.code.literal), ); -}; - -/** 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, + 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)), + }), ); }; @@ -1579,7 +1343,7 @@ 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, diff --git a/packages/rpc/src/profile.ts b/packages/rpc/src/profile.ts new file mode 100644 index 00000000..f316a0c2 --- /dev/null +++ b/packages/rpc/src/profile.ts @@ -0,0 +1,11 @@ +/** + * Profile: the caller's own profile. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/profile.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class ProfileRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/receipts.ts b/packages/rpc/src/receipts.ts new file mode 100644 index 00000000..3c2ae5b1 --- /dev/null +++ b/packages/rpc/src/receipts.ts @@ -0,0 +1,14 @@ +/** + * Receipts: submission, revision, approval, settlement, and receipt files. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/receipts.ts` and `receipt-upload.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class ReceiptsRpcs extends RpcGroup.make() {} + +/** Internal receipt operations, served only on the internal ingress. */ +export class InternalReceiptsRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/recruitment.ts b/packages/rpc/src/recruitment.ts new file mode 100644 index 00000000..e121c6b4 --- /dev/null +++ b/packages/rpc/src/recruitment.ts @@ -0,0 +1,11 @@ +/** + * Recruitment: assignments, interviews, invitations, maintenance. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/recruitment.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class RecruitmentRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/script-client.ts b/packages/rpc/src/script-client.ts new file mode 100644 index 00000000..977cafc8 --- /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 makeScriptClient = (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 00000000..1a50732b --- /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 00000000..0e6cebf4 --- /dev/null +++ b/packages/rpc/src/system.ts @@ -0,0 +1,11 @@ +/** + * System: the caller's session and session management. + * + * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is + * `packages/http-api/src/system.ts` at the base commit named there. + * + * @since 0.3.0 + */ +import { RpcGroup } from "effect/unstable/rpc"; + +export class SystemRpcs extends RpcGroup.make() {} diff --git a/packages/rpc/src/team-application.ts b/packages/rpc/src/team-application.ts new file mode 100644 index 00000000..f1de39ce --- /dev/null +++ b/packages/rpc/src/team-application.ts @@ -0,0 +1,11 @@ +/** + * Team applications: applications to one team and its intake. + * + * 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 { RpcGroup } from "effect/unstable/rpc"; + +export class TeamApplicationsRpcs extends RpcGroup.make() {} 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 bff349a0..3faf2af2 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/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 c619355d..00000000 --- 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 139a8da6..00000000 --- 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 43c994c2..00000000 --- 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 22e8e575..00000000 --- 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 d246403c..00000000 --- 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 7862857b..00000000 --- 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 c0831042..00000000 --- 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 a2bc9e2e..00000000 --- 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 ba39401a..00000000 --- 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 a16e0cbf..00000000 --- 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 e169f25f..00000000 --- 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 b54578af..00000000 --- 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 273a52f1..00000000 --- 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 b4302340..00000000 --- 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 eba9f0b2..00000000 --- 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/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index b31f8892..be2d6939 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -40,11 +40,11 @@ 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.", + "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.", - "http-problem": - "Answers a native HTTP request with a declared problem: failure mapping, credential classification, authorization, and decoding.", + "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.", diff --git a/tools/conventions/src/layout.ts b/tools/conventions/src/layout.ts index 545d1039..442b9c49 100644 --- a/tools/conventions/src/layout.ts +++ b/tools/conventions/src/layout.ts @@ -64,8 +64,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", @@ -153,9 +152,9 @@ export const contextLayers = { }, 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, diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index ba4ab172..dd51dbe7 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -22,7 +22,7 @@ import { PlacementScopes, 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"; diff --git a/tools/e2e/package.json b/tools/e2e/package.json index 8ee4c5a4..e1245f15 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 d282fd91..1bcf3b40 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -7,7 +7,7 @@ import { PlacementScope, SchoolServiceNotificationRequest, } from "@vektorprogrammet/domain/placements"; -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/http-api/http-semantics"; +import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/rpc/problem"; /** 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"; diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index b8626c52..98b946d3 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 { SessionResponse } from "@vektorprogrammet/rpc"; import * as BunHttpPlatform from "@effect/platform-bun/BunHttpPlatform"; import * as BunServices from "@effect/platform-bun/BunServices"; import { randomBytes } from "node:crypto"; diff --git a/tools/verification/package.json b/tools/verification/package.json index 7f1a1c7e..844db293 100644 --- a/tools/verification/package.json +++ b/tools/verification/package.json @@ -19,8 +19,7 @@ "@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" diff --git a/tools/verification/receipt-reopen-observation.ts b/tools/verification/receipt-reopen-observation.ts index 5e4f86b2..b8480ffe 100644 --- a/tools/verification/receipt-reopen-observation.ts +++ b/tools/verification/receipt-reopen-observation.ts @@ -7,8 +7,8 @@ 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 { ReceiptResource } from "@vektorprogrammet/rpc"; +import { StrongETag, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; import { createPromiseClient } from "../../packages/sdk/src/promise.js"; export async function observeReceiptReopening(options: { From 33fff2084121b22ecfc1e7768db900df085ea406 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:49:19 +0000 Subject: [PATCH 011/148] feat(homepage): call the native backend over RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/homepage/src/lib/api.server.ts | 54 ++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/homepage/src/lib/api.server.ts b/apps/homepage/src/lib/api.server.ts index 16dba5e9..7a786737 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`. + */ +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 ?? {})), + ); } From 79a666d8ed79fddc7c8ec9090aad13e0ec11f0f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:01:43 +0000 Subject: [PATCH 012/148] feat(rpc): port the session, profile, and contact contracts, the dashboard session check, and the request recorder Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/app/lib/auth.server.test.ts | 118 +++++++++-- apps/dashboard/app/lib/auth.server.ts | 14 +- apps/dashboard/e2e/native-operations.ts | 97 ++++----- .../e2e/run-real-native-identity-browser.mjs | 2 +- apps/dashboard/test/request-routes.test.ts | 52 ++++- docs/constructs/request-ledger.md | 25 +-- packages/rpc/src/contact.ts | 70 ++++++- packages/rpc/src/profile.ts | 128 +++++++++++- packages/rpc/src/system.ts | 195 +++++++++++++++++- 9 files changed, 582 insertions(+), 119 deletions(-) diff --git a/apps/dashboard/app/lib/auth.server.test.ts b/apps/dashboard/app/lib/auth.server.test.ts index a0a9fbbd..e0272916 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/rpc"; +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 891a9b85..4278482a 100644 --- a/apps/dashboard/app/lib/auth.server.ts +++ b/apps/dashboard/app/lib/auth.server.ts @@ -3,7 +3,7 @@ import { nativeDashboardRecoveryMode } from "../server/native-account-mode.serve 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/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 64c50690..4f55442e 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -1,59 +1,48 @@ /** - * 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/rpc"; -import { OpenApi } from "effect/unstable/httpapi"; +import { NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { Option, 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 pathParameter = /\{[^{}]+\}/u; +const decodeRpcRequest = Schema.decodeUnknownOption(RpcRequestMessage); /** - * 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 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. */ -const fillsSegment = (template: string, segment: string): boolean => { - const parameter = pathParameter.exec(template); +export const nativeRpcTag = (body: string): string | undefined => + Option.getOrUndefined(Option.map(decodeRpcRequest(body), (message) => message.tag)); - if (parameter === null) return template === segment; +/** The RPC client posts to the endpoint URL, which its HTTP client may end with one slash. */ +const isRpcPath = (pathname: string): boolean => + pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; - const prefix = template.slice(0, parameter.index); - const suffix = template.slice(parameter.index + parameter[0].length); +/** + * 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; - return ( - segment.length > prefix.length + suffix.length && - segment.startsWith(prefix) && - segment.endsWith(suffix) - ); -}; + if (method !== "POST" || !isRpcPath(pathname)) return false; -/** 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("/") }], - ), -); + if (body === undefined) return true; -/** - * 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. - */ -export const isNativeOperation = (method: string, pathname: string): boolean => { - const segments = pathname.split("/"); + const tag = nativeRpcTag(body); - return nativeOperations.some( - (operation) => - operation.method === method && - operation.segments.length === segments.length && - operation.segments.every((template, index) => fillsSegment(template, segments[index] ?? "")), - ); + return tag !== undefined && NativeRpcs.requests.has(tag); }; /** The identity engine's email-password routes, which native journeys call beside the contract. */ @@ -65,23 +54,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 +79,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/run-real-native-identity-browser.mjs b/apps/dashboard/e2e/run-real-native-identity-browser.mjs index fc40ed73..044f5b67 100644 --- a/apps/dashboard/e2e/run-real-native-identity-browser.mjs +++ b/apps/dashboard/e2e/run-real-native-identity-browser.mjs @@ -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, diff --git a/apps/dashboard/test/request-routes.test.ts b/apps/dashboard/test/request-routes.test.ts index a0c61d58..089fb0eb 100644 --- a/apps/dashboard/test/request-routes.test.ts +++ b/apps/dashboard/test/request-routes.test.ts @@ -1,21 +1,51 @@ +import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { isNativeOperation, isNativeRequest } from "../e2e/native-operations.ts"; +import { isNativeOperation, isNativeRequest, 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 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 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("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("keeps the email sign-in and leaves the native surface for every other identity route", () => { diff --git a/docs/constructs/request-ledger.md b/docs/constructs/request-ledger.md index ff8cc681..ac022a56 100644 --- a/docs/constructs/request-ledger.md +++ b/docs/constructs/request-ledger.md @@ -6,36 +6,37 @@ Classifies the requests that journey recorders observe by whole path segments: n ## `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. +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. ```ts -isNativeRequest(method: string, pathname: string): boolean +isNativeRequest(method: string, pathname: string, body?: string): boolean ``` - Inputs: - `method: string` - `pathname: string` + - `body?: string` - Output: `boolean` - Errors: none - Requirements: none - Side effects: none -- Source: [apps/dashboard/e2e/native-operations.ts:93](../../apps/dashboard/e2e/native-operations.ts#L93) +- Source: [apps/dashboard/e2e/native-operations.ts:82](../../apps/dashboard/e2e/native-operations.ts#L82) **How it works** -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. **Use** ```ts -proxy.records.filter(({ method, pathname }) => !isNativeRequest(method, pathname)); +proxy.records.filter(({ method, pathname, body }) => !isNativeRequest(method, pathname, body)); ``` **Avoid** diff --git a/packages/rpc/src/contact.ts b/packages/rpc/src/contact.ts index 27c4806e..5c331a4b 100644 --- a/packages/rpc/src/contact.ts +++ b/packages/rpc/src/contact.ts @@ -1,11 +1,71 @@ /** * Contact: anonymous contact messages from the homepage server. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/contact.ts` at the base commit named there. - * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class ContactRpcs extends RpcGroup.make(SubmitContactMessage) {} diff --git a/packages/rpc/src/profile.ts b/packages/rpc/src/profile.ts index f316a0c2..d6958431 100644 --- a/packages/rpc/src/profile.ts +++ b/packages/rpc/src/profile.ts @@ -1,11 +1,129 @@ /** * Profile: the caller's own profile. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/profile.ts` at the base commit named there. - * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class ProfileRpcs extends RpcGroup.make(ReadOwnProfile, UpdateOwnProfile) {} diff --git a/packages/rpc/src/system.ts b/packages/rpc/src/system.ts index 0e6cebf4..a2492e6b 100644 --- a/packages/rpc/src/system.ts +++ b/packages/rpc/src/system.ts @@ -1,11 +1,196 @@ /** - * System: the caller's session and session management. + * System: the caller's first-party session and session management. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/system.ts` at the base commit named there. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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"; -export class SystemRpcs extends RpcGroup.make() {} +/** 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, +) {} From 6d836a66074ea63430eda6a82ab5543435e280a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:04:58 +0000 Subject: [PATCH 013/148] feat(rpc): serve admissions and admission outcomes as RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/admission/access.ts | 139 +++++ .../admission/admission-period.rpc.test.ts | 437 ++++++++++++++++ apps/backend/src/admission/commands.ts | 475 ++++++++++++++++++ apps/backend/src/admission/context.ts | 58 +++ apps/backend/src/admission/outcome-rpc.ts | 300 ++++++++++- apps/backend/src/admission/problem.ts | 170 +++++++ apps/backend/src/admission/reads.ts | 284 +++++++++++ apps/backend/src/admission/rpc.ts | 46 +- .../admission/submit-application.rpc.test.ts | 284 +++++++++++ packages/rpc/src/admission-outcomes.ts | 137 ++++- packages/rpc/src/admissions.ts | 337 ++++++++++++- 11 files changed, 2647 insertions(+), 20 deletions(-) create mode 100644 apps/backend/src/admission/access.ts create mode 100644 apps/backend/src/admission/admission-period.rpc.test.ts create mode 100644 apps/backend/src/admission/commands.ts create mode 100644 apps/backend/src/admission/context.ts create mode 100644 apps/backend/src/admission/problem.ts create mode 100644 apps/backend/src/admission/reads.ts create mode 100644 apps/backend/src/admission/submit-application.rpc.test.ts diff --git a/apps/backend/src/admission/access.ts b/apps/backend/src/admission/access.ts new file mode 100644 index 00000000..86c694f8 --- /dev/null +++ b/apps/backend/src/admission/access.ts @@ -0,0 +1,139 @@ +/** 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"; + +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 = ( + 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 = ( + 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.rpc.test.ts b/apps/backend/src/admission/admission-period.rpc.test.ts new file mode 100644 index 00000000..3bf18eaf --- /dev/null +++ b/apps/backend/src/admission/admission-period.rpc.test.ts @@ -0,0 +1,437 @@ +import { Database, IdentitySnapshot, type DatabaseOperations } from "@vektorprogrammet/database"; +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 { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { SemesterId } from "@vektorprogrammet/domain"; +import { + 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 { 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", +} as const; + +const departmentId = "department-periods"; + +const leaderPersonId = "periods-leader"; + +const adminPersonId = "periods-admin"; + +/** One open autumn period and an empty spring semester, a Styret leader, and a global administrator. */ +const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES (${departmentId}, 'Trondheim')`; + yield* sql` + INSERT INTO admission_period_semesters (semester_id, start_at, end_at) + VALUES + ('semester-autumn', '2031-08-01T00:00:00.000Z', '2032-01-01T00:00:00.000Z'), + ('semester-spring', '2032-01-01T00:00:00.000Z', '2032-07-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-autumn', ${departmentId}, 'semester-autumn', '2031-09-01T08:00:00.000Z', '2031-10-01T20:00:00.000Z', 'period-autumn-created') + `; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city, independent) VALUES (${departmentId}, 'Trondheim', 'TRD', 'periods@example.invalid', 'Trondheim', TRUE)`; + // The department's board (Styret) of an independent department: its leader manages admission periods. + yield* sql`INSERT INTO organization_teams (team_id, department_id, name, kind) VALUES ('periods-team', ${departmentId}, 'Styret', 'DepartmentBoard')`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${leaderPersonId}, 'Lise', 'Leader'), (${adminPersonId}, 'Ada', 'Admin')`; + yield* sql`INSERT INTO organization_global_administrator_grants (grant_id, person_id, start_at) VALUES ('periods-admin-grant', ${adminPersonId}, '2020-01-01T00:00:00.000Z')`; + yield* sql` + INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, position_id, is_team_leader) + VALUES ('periods-leader-membership', ${leaderPersonId}, 'periods-team', '2020-01-01T00:00:00.000Z', 'leader', TRUE) + `; + }), +); + +/** 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 + : new IdentityActor({ + 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(new IdentitySessionNotFound()) : 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(new IdentitySessionNotFound()) : 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); + +/** Requests that queue behind a lock in the fixture database. */ +const waitingRequests = (sql: DatabaseOperations, expected: number) => + sql<{ readonly waiting: number }>` + SELECT count(*)::integer AS waiting + FROM pg_locks JOIN pg_stat_activity USING (pid) + WHERE NOT pg_locks.granted AND pg_stat_activity.datname = current_database() + `.pipe( + Effect.map((rows) => rows[0]?.waiting ?? 0), + Effect.repeat({ + until: (waiting) => waiting >= expected, + times: 400, + schedule: Schedule.spaced("10 millis"), + }), + ); + +/** 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. + */ +const fixture = () => + Effect.gen(function* () { + const database = backendDatabase(seed); + + 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 connection = () => + DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); + + const backend = makeBackendTestRpc( + yield* decodeBackendConfig(environment), + Layer.mergeAll( + AdmissionsLive, + OrganizationLive, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(Identity, identity), + ).pipe(Layer.provideMerge(connection())), + ); + + const client = yield* backend.client; + + /** 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 + * commits without writing, so the queued requests continue in arrival order. + */ + const holdUntilQueued = ( + lock: (sql: DatabaseOperations) => Effect.Effect, + waiters: number, + ) => + Effect.gen(function* () { + const held = yield* Deferred.make(); + + const holder = yield* Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* lock(sql); + yield* Deferred.succeed(held, undefined); + + return yield* waitingRequests(sql, waiters); + }), + ), + ).pipe(Effect.provide(connection(), { local: true }), Effect.forkChild); + + yield* Deferred.await(held); + + return { released: Fiber.join(holder) }; + }); + + const count = (query: string) => + database.run( + Database.use((sql) => sql.unsafe<{ readonly count: number }>(query)).pipe( + Effect.map((rows) => rows[0]?.count ?? 0), + ), + ); + + return { client, as, holdUntilQueued, count }; + }); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +describe("admission period management over RPC and PostgreSQL", () => { + it.live("answers the loser of a concurrent revision with precondition.failed", () => + Effect.gen(function* () { + 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 = (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( + (sql) => + sql`SELECT 1 FROM public.admission_periods WHERE admission_period_id = 'period-autumn' FOR UPDATE`, + 2, + ); + + const exits = yield* Effect.all([revise("reviseRaceA"), revise("reviseRaceB")], { + concurrency: "unbounded", + }); + + expect(yield* released).toBe(2); + expect(exits.map(codeOf).sort()).toEqual(["ok", "precondition.failed"]); + + const winner = exits.find(Exit.isSuccess); + + expect(winner?.value).toMatchObject({ + id: "period-autumn", + endAt: "2031-10-02T20:00:00.000Z", + revision: 1, + }); + expect( + yield* count( + "SELECT revision AS count FROM admission_periods WHERE admission_period_id = 'period-autumn'", + ), + ).toBe(1); + expect( + yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), + ).toBe(1); + expect( + yield* count("SELECT count(*)::integer AS count FROM native_http_idempotency_receipts"), + ).toBe(1); + }), + ); + + it.live("answers the loser of a concurrent create with admission-period.already-exists", () => + Effect.gen(function* () { + 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( + (sql) => sql`LOCK TABLE public.admission_periods IN EXCLUSIVE MODE`, + 2, + ); + + const exits = yield* Effect.all([create("createRaceA"), create("createRaceB")], { + concurrency: "unbounded", + }); + + expect(yield* released).toBe(2); + expect(exits.map(codeOf).sort()).toEqual(["admission-period.already-exists", "ok"]); + expect(exits.find(Exit.isSuccess)?.value).toMatchObject({ + departmentId, + semesterId: "semester-spring", + }); + expect( + yield* count( + "SELECT count(*)::integer AS count FROM admission_periods WHERE semester_id = 'semester-spring'", + ), + ).toBe(1); + }), + ); + + it.live("replays a create by its idempotency key with the first answer", () => + Effect.gen(function* () { + const { client, as, count } = yield* fixture(); + + 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 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", { endAt: null }); + + expect(empty).toMatchObject({ + code: "validation.no-change", + validation: { errors: [makeNativeValidationError("", "no-change")], truncated: false }, + }); + expect(deleted).toMatchObject({ + code: "validation.field-not-deletable", + validation: { + errors: [makeNativeValidationError("/endAt", "field-not-deletable")], + truncated: false, + }, + }); + expect( + yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), + ).toBe(0); + }), + ); + + it.live("names the semester or department that a create cannot resolve", () => + Effect.gen(function* () { + 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"); + + // A global administrator acts in no department of its own, so the create must name one. + const withoutDepartment = yield* create( + "globalWithoutDepartment", + "semester-spring", + adminPersonId, + ); + + expect(unknownSemester).toMatchObject({ + code: "validation.failed", + validation: { + errors: [makeNativeValidationError("/semesterId", "invalid")], + truncated: false, + }, + }); + expect(withoutDepartment).toMatchObject({ + code: "validation.failed", + validation: { + errors: [makeNativeValidationError("/departmentId", "missing")], + truncated: false, + }, + }); + expect( + yield* count("SELECT count(*)::integer AS count FROM admission_period_command_receipts"), + ).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 00000000..ffe463a1 --- /dev/null +++ b/apps/backend/src/admission/commands.ts @@ -0,0 +1,475 @@ +/** 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"; + +/** 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, + }), +}); + +export const registerReturningAssistant = ( + 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 createAdmissionPeriod = ( + 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"), + ); + +/** + * 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 }; + }); + +export const reviseAdmissionPeriod = ( + 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* new AdmissionPeriodNotFound({ 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 submitApplication = ( + 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* new PublicApplicationRateLimitExceeded({}); + } + + 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"), + ); diff --git a/apps/backend/src/admission/context.ts b/apps/backend/src/admission/context.ts new file mode 100644 index 00000000..89738de9 --- /dev/null +++ b/apps/backend/src/admission/context.ts @@ -0,0 +1,58 @@ +/** 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, Schema } from "effect"; +import { admissionActorForDepartment, unscopedAdmissionActorFrom } from "../authority.js"; + +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, 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 +> => + 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/outcome-rpc.ts b/apps/backend/src/admission/outcome-rpc.ts index 5df7fb4d..5f8bdeef 100644 --- a/apps/backend/src/admission/outcome-rpc.ts +++ b/apps/backend/src/admission/outcome-rpc.ts @@ -1,9 +1,299 @@ /** - * The AdmissionOutcomesRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/admission/outcome-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { AdmissionOutcomesRpcs } from "@vektorprogrammet/rpc"; +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"; -export const AdmissionOutcomesRpcHandlers = (_options: NativeRpcOptions) => - AdmissionOutcomesRpcs.toLayer({}); +/** 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/problem.ts b/apps/backend/src/admission/problem.ts new file mode 100644 index 00000000..958823ad --- /dev/null +++ b/apps/backend/src/admission/problem.ts @@ -0,0 +1,170 @@ +/** Admission problems: the one declared answer for every admission failure. */ +import type { AdmissionPeriodFailure } from "@vektorprogrammet/domain/admission-period"; +import type { + PublicApplicationError, + ReturningAssistantError, +} from "@vektorprogrammet/domain/application"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import { makeNativeValidationError, Problem } from "@vektorprogrammet/rpc/problem"; +import type { Headers } from "effect/unstable/http"; +import type { OrganizationResolutionError } from "../authority.js"; +import { + type CredentialCases, + type OutageCases, + personPresentation, + type ProblemCases, + type ProblemMapper, + problemMapper, + requestInvalid, +} from "../rpc/problem.js"; + +/** Public applications answer a spent rate limit with one fixed delay. */ +const RATE_LIMIT_RETRY_AFTER_SECONDS = 60; + +/** Every failure that an admission handler answers. */ +type AdmissionFailure = + | AdmissionPeriodFailure + | PublicApplicationError + | ReturningAssistantError + | IdentityEngineError + | OrganizationResolutionError; + +/** The unavailable problem of an admission RPC: a read, a command, or a returning assistant. */ +type AdmissionUnavailable = + | "admissions.unavailable" + | "dependency.unavailable" + | "returning.unavailable"; + +/** 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 RPC's unavailable problem. */ +type AdmissionOutageTag = + | "AdmissionPeriodPersistenceError" + | "PublicApplicationPersistenceError" + | "PublicApplicationQueryLimitExceeded" + | "IdentityEngineError" + | "OrganizationDecodeError" + | "OrganizationPersistenceError"; + +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 request. + AdmissionPeriodDecodeError: requestInvalid, + PublicApplicationDecodeError: requestInvalid, + ReturningAssistantDecodeError: requestInvalid, + // The frozen unions have no department or semester code; an unknown one is an invalid value. + DepartmentNotFound: () => + Problem.validation("validation.failed", [ + makeNativeValidationError("/departmentId", "invalid"), + ]), + SemesterNotFound: () => + Problem.validation("validation.failed", [makeNativeValidationError("/semesterId", "invalid")]), + DepartmentRequired: () => + Problem.validation("validation.failed", [ + makeNativeValidationError("/departmentId", "missing"), + ]), + AdmissionPeriodNotFound: () => Problem.make("admission-period.not-found"), + InvalidAdmissionPeriodWindow: () => Problem.make("admission-period.invalid-window"), + AdmissionWindowOutsideSemester: () => Problem.make("admission-period.invalid-window"), + AdmissionPeriodAlreadyExists: () => Problem.make("admission-period.already-exists"), + StaleAdmissionPeriodRevision: () => Problem.make("precondition.failed"), + DuplicateAdmissionPeriodCommandConflict: () => Problem.make("idempotency.digest-conflict"), + NoEligibleAdmissionPeriod: () => Problem.make("application.no-eligible-period"), + AmbiguousAdmissionPeriod: () => Problem.make("application.ambiguous-period"), + FieldOfStudyNotFound: () => Problem.make("application.invalid-field-of-study"), + FieldOfStudyInactive: () => Problem.make("application.invalid-field-of-study"), + FieldOfStudyDepartmentMismatch: () => Problem.make("application.invalid-field-of-study"), + DuplicatePublicApplication: () => Problem.make("application.duplicate"), + DuplicatePublicApplicationCommandConflict: () => Problem.make("idempotency.digest-conflict"), + PublicApplicationNotFound: () => Problem.make("application.not-found"), + RequestBodyTooLarge: () => Problem.make("request.too-large"), + PublicApplicationRateLimitExceeded: () => Problem.rateLimited(RATE_LIMIT_RETRY_AFTER_SECONDS), + ReturningAssistantIdentityMissing: () => Problem.make("returning.identity-missing"), + ReturningAssistantIdentityAmbiguous: () => Problem.make("returning.identity-ambiguous"), + ReturningAssistantHistoryMissing: () => Problem.make("returning.history-missing"), + ReturningAssistantStudyMappingInvalid: () => Problem.make("returning.study-invalid"), + ReturningAssistantPeriodUnavailable: () => Problem.make("returning.period-unavailable"), + ReturningAssistantDuplicate: () => Problem.make("returning.period-unavailable"), + ReturningAssistantTeamScopeDenied: () => Problem.make("returning.team-scope-denied"), + ReturningAssistantRevisionConflict: () => Problem.make("returning.revision-conflict"), + ReturningAssistantCommandConflict: () => Problem.make("idempotency.digest-conflict"), + ReturningAssistantPersistenceError: () => Problem.make("returning.unavailable"), +} satisfies ProblemCases< + Exclude +>; + +/** The cases of `admissionProblems`, which answer an outage with `Unavailable`. */ +type AdmissionCases = typeof admissionFixedCases & + CredentialCases & + OutageCases; + +/** + * The one answer for every admission failure. + * + * @remarks + * 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(headers, "dependency.unavailable")); + * ``` + * + * @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 rpc-problem + */ +export const admissionProblems = ( + 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), + }); +}; + +/** + * 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", + "application.no-eligible-period", + "application.ambiguous-period", + "application.invalid-field-of-study", + "application.duplicate", + "idempotency.digest-conflict", + "request.too-large", + "rate-limit.exceeded", +] 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. + */ +export const periodCommandProblems = [ + "validation.failed", + "admission-period.not-found", + "admission-period.invalid-window", + "admission-period.already-exists", + "idempotency.digest-conflict", +] as const; diff --git a/apps/backend/src/admission/reads.ts b/apps/backend/src/admission/reads.ts new file mode 100644 index 00000000..8404ddfb --- /dev/null +++ b/apps/backend/src/admission/reads.ts @@ -0,0 +1,284 @@ +/** 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"; + +export const readReturningAssistantOptions = ( + 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 listAdmissionPeriods = (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 listOpenAdmissionPeriods = (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 listApplicationOptions = (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 readApplicationConfirmation = ( + 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 readApplicantProgress = (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"), + ); diff --git a/apps/backend/src/admission/rpc.ts b/apps/backend/src/admission/rpc.ts index bc3ae6b3..79422ad3 100644 --- a/apps/backend/src/admission/rpc.ts +++ b/apps/backend/src/admission/rpc.ts @@ -1,8 +1,48 @@ /** - * The AdmissionsRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/admission/http*.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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"; -export const AdmissionsRpcHandlers = (_options: NativeRpcOptions) => AdmissionsRpcs.toLayer({}); +/** 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.rpc.test.ts b/apps/backend/src/admission/submit-application.rpc.test.ts new file mode 100644 index 00000000..958f1d49 --- /dev/null +++ b/apps/backend/src/admission/submit-application.rpc.test.ts @@ -0,0 +1,284 @@ +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( + yield* decodeBackendConfig(environment), + 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/packages/rpc/src/admission-outcomes.ts b/packages/rpc/src/admission-outcomes.ts index 09d79dc4..69a5cbb5 100644 --- a/packages/rpc/src/admission-outcomes.ts +++ b/packages/rpc/src/admission-outcomes.ts @@ -1,11 +1,136 @@ /** - * Admission outcomes: decisions on applications. - * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/admission-outcomes.ts` at the base commit named there. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class AdmissionOutcomesRpcs extends RpcGroup.make( + ListAdmissionOutcomeScopes, + ReadAdmissionOutcomes, + ReadAdmissionOutcome, + RecordAdmissionOutcome, +) {} diff --git a/packages/rpc/src/admissions.ts b/packages/rpc/src/admissions.ts index 83b23868..3dba08de 100644 --- a/packages/rpc/src/admissions.ts +++ b/packages/rpc/src/admissions.ts @@ -1,11 +1,336 @@ /** - * Admissions: admission periods and applications. - * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/admissions.ts` at the base commit named there. + * Admissions: admission periods, public applications, applicant progress, and returning + * assistants. * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class AdmissionsRpcs extends RpcGroup.make( + ListOpenAdmissionPeriods, + ListApplicationOptions, + SubmitApplication, + ReadApplicationConfirmation, + ReadApplicantProgress, + ListAdmissionPeriods, + CreateAdmissionPeriod, + ReviseAdmissionPeriod, + ReadReturningAssistantOptions, + RegisterReturningAssistant, +) {} From ee4aa9c3da6b0ed14d04d6d734b88db93e614bc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:06:19 +0000 Subject: [PATCH 014/148] feat(rpc): serve organization and directory operations as RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/directory/rpc.test.ts | 561 ++++++++++++ apps/backend/src/directory/rpc.ts | 365 +++++++- .../backend/src/directory/schools.rpc.test.ts | 344 ++++++++ apps/backend/src/organization/rpc.test.ts | 420 +++++++++ apps/backend/src/organization/rpc.ts | 815 +++++++++++++++++- .../organization/team-interest.rpc.test.ts | 444 ++++++++++ packages/rpc/src/directory.ts | 186 +++- packages/rpc/src/organization.ts | 414 ++++++++- 8 files changed, 3531 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/directory/rpc.test.ts create mode 100644 apps/backend/src/directory/schools.rpc.test.ts create mode 100644 apps/backend/src/organization/rpc.test.ts create mode 100644 apps/backend/src/organization/team-interest.rpc.test.ts diff --git a/apps/backend/src/directory/rpc.test.ts b/apps/backend/src/directory/rpc.test.ts new file mode 100644 index 00000000..23cae366 --- /dev/null +++ b/apps/backend/src/directory/rpc.test.ts @@ -0,0 +1,561 @@ +import { backendDatabase } from "../../test/database.js"; +import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; + +import { + MembershipId, + TeamId, + DepartmentId, + Organization, + PersonId, + accumulateOrganizationDirectoryFacts, + type OrganizationDirectoryFacts, + type OrganizationOperations, +} from "@vektorprogrammet/domain/organization"; +import { + decodeDirectoryCursor, + encodeDirectoryCursor, + PersonContactProfile, + PersonProfile, + Profile, + ProfileContactNotFound, + type DirectoryEntry, + type ProfileOperations, +} from "@vektorprogrammet/domain/profile"; +import { Schema, DateTime, Effect, Layer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +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 instant = "2031-09-15T12:00:00.000Z"; + +const departmentA = DepartmentId.make("department-a"); + +const departmentB = DepartmentId.make("department-b"); + +interface SeededMembership { + readonly personId: string; + readonly departmentId: DepartmentId; + /** Membership interval covers the captured instant when true. */ + readonly active: boolean; + /** Leads the board of the (independent) department. */ + readonly boardLeader: boolean; +} + +interface SeededGrant { + readonly personId: string; + readonly status: "Active" | "Inactive"; +} + +/** Canonical directory population shared by every scenario unless replaced. */ +const directoryPeople = [ + { + personId: PersonId.make("person-leader-a"), + firstName: "Active", + lastName: "Leader", + email: "leader-a@example.invalid", + phone: "90000001", + }, + { + personId: PersonId.make("person-multi-department"), + firstName: "Multi", + lastName: "Department", + email: "multi@example.invalid", + phone: "90000002", + }, + { + personId: PersonId.make("person-ended-membership"), + firstName: "Ended", + lastName: "Membership", + email: "ended@example.invalid", + phone: "90000003", + }, +]; + +let people: Array<{ + personId: string; + firstName: string; + lastName: string; + email: string; + phone: string; +}> = []; + +let membershipsByPerson = new Map>([]); + +let grantsByPerson: Record = {}; + +let missingContactFor: string | undefined; + +let callerProjection: { + globalAdministrator: "Active" | "Inactive" | "Absent"; +} | null = null; + +const database = backendDatabase(); + +/** Profile stub: canonical names joined to canonical contacts, paged. */ +const profile: ProfileOperations = { + readProfiles: (personIds) => + Effect.succeed( + personIds.map( + (personId) => + new PersonProfile({ 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, + }), + ), + ), + readOwnProfile: (personId) => + Effect.succeed({ + personId, + firstName: "First", + lastName: "Last", + email: `${personId}@example.invalid`, + phone: "90000000", + nameRevision: 0, + contactRevision: 0, + }), + updateOwnProfile: () => Effect.die("unexpected updateOwnProfile"), + readDirectoryPage: ({ limit, cursor }) => + Effect.gen(function* () { + const sorted = [...people].sort((left, right) => { + const byLastName = left.lastName.localeCompare(right.lastName); + + if (byLastName !== 0) return byLastName; + const byFirstName = left.firstName.localeCompare(right.firstName); + + if (byFirstName !== 0) return byFirstName; + + return left.personId.localeCompare(right.personId); + }); + + let offset = 0; + + if (cursor !== undefined) { + const tuple = yield* decodeDirectoryCursor(cursor); + + const found = sorted.findIndex( + (person) => + person.lastName === tuple.lastName && + person.firstName === tuple.firstName && + person.personId === tuple.personId, + ); + + if (found < 0) + return yield* new ProfileContactNotFound({ personId: PersonId.make(tuple.personId) }); + offset = found + 1; + } + + const page = sorted.slice(offset, offset + limit); + const entries: Array = []; + + for (const person of page) { + if (missingContactFor === person.personId || !person.email) { + return yield* new ProfileContactNotFound({ + personId: PersonId.make(person.personId), + }); + } + + entries.push({ + personId: PersonId.make(person.personId), + firstName: person.firstName, + lastName: person.lastName, + email: person.email, + phone: person.phone, + }); + } + + const last = entries[entries.length - 1]; + + return { + entries, + nextCursor: + last !== undefined && offset + entries.length < sorted.length + ? encodeDirectoryCursor(last) + : undefined, + }; + }).pipe(Effect.mapError((cause) => cause)), +}; + +/** Organization stub: caller projection plus the frozen membership law. */ +const organization = { + listDepartments: Effect.succeed([]), + listTeams: () => Effect.succeed([]), + listFieldOfStudies: Effect.succeed([]), + resolvePersonAuthority: () => { + if (callerProjection === null) throw new Error("no caller projection configured"); + + return Effect.succeed({ + personId: PersonId.make("person-caller"), + evaluatedAt: instant, + globalAdministrator: callerProjection.globalAdministrator, + memberships: (membershipsByPerson.get("person-caller") ?? []).map((seed) => ({ + membershipId: MembershipId.make(`membership-${seed.departmentId}-${seed.personId}`), + teamId: TeamId.make( + seed.boardLeader ? `styret-${seed.departmentId}` : `team-${seed.departmentId}`, + ), + departmentId: DepartmentId.make(seed.departmentId), + active: seed.active, + unitLeader: seed.boardLeader, + unitKind: seed.boardLeader ? "DepartmentBoard" : "Team", + teamScope: "HomeDepartment", + departmentIndependent: true, + })), + nationalBoardSeats: [], + delegations: [], + }); + }, + deriveDirectoryFacts: ( + personIds: ReadonlyArray, + evaluatedAt: string, + ): Effect.Effect => + Effect.sync(() => { + const memberships = personIds.flatMap((personId) => { + const seeds = membershipsByPerson.get(personId) ?? []; + + return seeds.map((seed) => ({ + personId: PersonId.make(personId), + departmentId: DepartmentId.make(seed.departmentId), + // The stub resolves canonical names the way the PostgreSQL + // interpreter's team->department join does. + departmentName: `Name of ${seed.departmentId}`, + active: seed.active, + })); + }); + + const grants = personIds.flatMap((personId) => { + const grant = grantsByPerson[personId]; + + return grant + ? [{ personId: PersonId.make(personId), globalAdministrator: grant.status }] + : []; + }); + + const facts = new Map( + accumulateOrganizationDirectoryFacts({ + personIds: personIds.map((personId) => PersonId.make(personId)), + instant: evaluatedAt, + memberships, + grants, + }), + ); + + for (const personId of personIds) { + if (!facts.has(PersonId.make(personId))) { + facts.set(PersonId.make(personId), { + departments: [], + departmentNames: [], + isActive: false, + globalAdministrator: "Absent", + }); + } + } + + return facts; + }), +} satisfies Partial; + +const resetScenario = () => { + people = [...directoryPeople]; + membershipsByPerson = new Map>([ + [ + "person-leader-a", + [ + { + personId: PersonId.make("person-leader-a"), + departmentId: DepartmentId.make(departmentA), + active: true, + boardLeader: true, + }, + ], + ], + [ + "person-multi-department", + [ + { + personId: PersonId.make("person-multi-department"), + departmentId: DepartmentId.make(departmentA), + active: true, + boardLeader: false, + }, + { + personId: PersonId.make("person-multi-department"), + departmentId: DepartmentId.make(departmentB), + active: true, + boardLeader: false, + }, + ], + ], + [ + "person-ended-membership", + [ + { + personId: PersonId.make("person-ended-membership"), + departmentId: DepartmentId.make(departmentA), + active: false, + boardLeader: false, + }, + ], + ], + ]); + grantsByPerson = {}; + missingContactFor = undefined; + // Caller defaults to an active global administrator viewing everything. + callerProjection = { globalAdministrator: "Active" }; +}; + +resetScenario(); + +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 !== undefined && cookieHeader.includes(`${token}=`) + ? Effect.succeed( + new IdentityActor({ + personId: PersonId.make("person-caller"), + sessionId: "session-1", + expiresAt: DateTime.makeUnsafe("2031-09-16T00: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.succeed(Profile, profile), + Layer.mock(Organization, organization), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), +); + +const backend = makeBackendTestRpc(backendTestConfig, backendServices); + +const listPeople = Effect.gen(function* () { + const client = yield* backend.client; + + return yield* RpcClient.withHeaders(client["directory.listPeople"](), { + 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("directory.listPeople", () => { + it.live("answers credential.missing without a session", () => + Effect.gen(function* () { + 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, + detail: "A credential is required for this operation.", + code: "credential.missing", + }); + }), + ); + + it.live("denies a plain member with authority.denied", () => + Effect.gen(function* () { + resetScenario(); + callerProjection = { globalAdministrator: "Absent" }; + membershipsByPerson.set("person-caller", [ + { + personId: PersonId.make("person-caller"), + departmentId: DepartmentId.make(departmentA), + active: true, + boardLeader: false, + }, + ]); + expect(yield* failure).toEqual(authorityDenied); + }), + ); + + it.live("denies an inactive leader with authority.denied", () => + Effect.gen(function* () { + resetScenario(); + callerProjection = { globalAdministrator: "Absent" }; + membershipsByPerson.set("person-caller", [ + { + personId: PersonId.make("person-caller"), + departmentId: DepartmentId.make(departmentA), + active: false, + boardLeader: true, + }, + ]); + expect(yield* failure).toEqual(authorityDenied); + }), + ); + + it.live("denies an inactive administrator with authority.denied", () => + Effect.gen(function* () { + resetScenario(); + callerProjection = { globalAdministrator: "Inactive" }; + expect(yield* failure).toEqual(authorityDenied); + }), + ); + + it.live("shows an active global administrator the cross-department directory", () => + Effect.gen(function* () { + resetScenario(); + + const body = yield* listPeople; + + expect(body.activePeople.map((row) => row.personId)).toEqual([ + "person-multi-department", + "person-leader-a", + ]); + 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, sorted. + expect(multi?.departments).toEqual(["Name of department-a", "Name of department-b"]); + + const wire = yield* Schema.encodeEffect(PeopleDirectoryResponse)(body); + + for (const row of [...wire.activePeople, ...wire.inactivePeople]) { + expect(Object.keys(row).sort()).toEqual([ + "departments", + "email", + "firstName", + "isActive", + "lastName", + "personId", + "phone", + "studyProgramme", + ]); + expect(row.studyProgramme).toBeNull(); + } + }), + ); + + it.live("denies a caller with no Organization record with authority.denied", () => + Effect.gen(function* () { + resetScenario(); + // Absent grant plus no memberships at all: a denial, never a credential problem. + callerProjection = { globalAdministrator: "Absent" }; + membershipsByPerson.set("person-caller", []); + expect(yield* failure).toEqual(authorityDenied); + }), + ); + + it.live("scopes a department leader to the intersection of their leader departments", () => + Effect.gen(function* () { + resetScenario(); + callerProjection = { globalAdministrator: "Absent" }; + membershipsByPerson.set("person-caller", [ + { + personId: PersonId.make("person-caller"), + departmentId: DepartmentId.make(departmentB), + active: true, + boardLeader: true, + }, + ]); + + const body = yield* listPeople; + + // Only the multi-department person touches department B. + expect(body.activePeople.map((row) => row.personId)).toEqual(["person-multi-department"]); + expect(body.inactivePeople).toEqual([]); + }), + ); + + it.live("answers empty arrays when nothing intersects", () => + Effect.gen(function* () { + resetScenario(); + callerProjection = { globalAdministrator: "Absent" }; + membershipsByPerson.set("person-caller", [ + { + personId: PersonId.make("person-caller"), + departmentId: DepartmentId.make("department-empty"), + active: true, + boardLeader: true, + }, + ]); + people = []; + expect(yield* listPeople).toEqual({ + activePeople: [], + inactivePeople: [], + nextCursor: null, + }); + }), + ); + + it.live("walks every page until exhaustion without duplicating or dropping a person", () => + Effect.gen(function* () { + resetScenario(); + people = Array.from({ length: 205 }, (_, index) => ({ + personId: PersonId.make(`person-bulk-${String(index + 1).padStart(4, "0")}`), + firstName: "Bulk", + lastName: `Family${String(index % 7)}`, + email: `bulk-${index + 1}@example.invalid`, + phone: "90000000", + })); + membershipsByPerson = new Map>([]); + grantsByPerson = {}; + + const body = yield* listPeople; + + const ids = [...body.activePeople, ...body.inactivePeople].map((row) => row.personId); + expect(new Set(ids).size).toBe(205); + expect(ids.length).toBe(205); + }), + ); + + it.live("fails directory.unavailable when a scanned person has no contact row", () => + Effect.gen(function* () { + resetScenario(); + missingContactFor = "person-multi-department"; + expect(yield* failure).toEqual({ + type: "urn:vektorprogrammet:problem:v0.2:directory.unavailable", + title: "Directory unavailable", + status: 503, + detail: "The directory service is temporarily unavailable.", + code: "directory.unavailable", + }); + }), + ); +}); diff --git a/apps/backend/src/directory/rpc.ts b/apps/backend/src/directory/rpc.ts index 5aacd9df..29b21e1c 100644 --- a/apps/backend/src/directory/rpc.ts +++ b/apps/backend/src/directory/rpc.ts @@ -1,8 +1,365 @@ /** - * The DirectoryRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/directory/http.ts, schools/http.ts, and schools/administration-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { DirectoryRpcs } from "@vektorprogrammet/rpc"; +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"; -export const DirectoryRpcHandlers = (_options: NativeRpcOptions) => DirectoryRpcs.toLayer({}); +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* 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* 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/directory/schools.rpc.test.ts b/apps/backend/src/directory/schools.rpc.test.ts new file mode 100644 index 00000000..20ec6b21 --- /dev/null +++ b/apps/backend/src/directory/schools.rpc.test.ts @@ -0,0 +1,344 @@ +import { backendDatabase } from "../../test/database.js"; +import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + Department, + DepartmentId, + DepartmentNotFound, + MembershipId, + Organization, + OrganizationAuthorityInstantSchema, + PersonId, + TeamId, + type OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; +import { + SchoolDirectoryScopeSchema, + SchoolId, + Schools, + SchoolsDecodeError, + SchoolsPersistenceError, + type SchoolDirectory, + type SchoolDirectoryListInput, +} from "@vektorprogrammet/domain/schools"; +import { isProblem, problemBody } from "@vektorprogrammet/rpc/problem"; +import { DateTime, Effect, Layer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +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-rpc-person"); + +const SESSION = "schools-test-session"; + +const departmentA = DepartmentId.make("schools-rpc-a"); + +const departmentB = DepartmentId.make("schools-rpc-b"); + +const instant = OrganizationAuthorityInstantSchema.make("2032-04-01T12:00:00.000Z"); + +const emptyDirectory: SchoolDirectory = { activeSchools: [], inactiveSchools: [] }; + +const projection = ( + overrides: Partial = {}, +): OrganizationPersonAuthority => ({ + personId, + evaluatedAt: instant, + globalAdministrator: "Absent", + memberships: [ + { + membershipId: MembershipId.make("schools-rpc-membership"), + teamId: TeamId.make("schools-rpc-team"), + departmentId: departmentA, + active: true, + unitLeader: false, + unitKind: "Team", + teamScope: "HomeDepartment", + departmentIndependent: false, + }, + ], + nationalBoardSeats: [], + delegations: [], + ...overrides, +}); + +const database = backendDatabase(); + +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(SESSION) + ? Effect.succeed( + new IdentityActor({ + personId, + sessionId: 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 makeBackend = ( + authority: OrganizationPersonAuthority, + listDirectory: ( + input: SchoolDirectoryListInput, + ) => Effect.Effect, + readDepartment: (departmentId: DepartmentId) => Effect.Effect = ( + departmentId, + ) => + Effect.succeed( + new Department({ + departmentId, + name: "Fixture department", + shortName: "Fixture", + email: "fixture@example.invalid", + address: null, + city: "Oslo", + latitude: null, + longitude: null, + slackChannel: null, + logoPath: null, + active: true, + revision: 0, + }), + ), +) => + makeBackendTestRpc( + backendTestConfig, + 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), + ), + testAuthHandler, + // The composition pins the authorization instant that the projection was evaluated at. + { now: () => instant }, + ); + +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}`, + title, + status, + detail, + code, +}); + +const expectedProblemForTag = (tag: string, status: number) => { + if (tag === "UnauthenticatedActor") { + return expectedProblem( + "credential.invalid", + "Invalid credential", + status, + "The supplied credential is invalid.", + ); + } + + if (tag === "SchoolsDepartmentNotFound" && status === 422) { + return expectedProblem( + "schools.invalid-department", + "Invalid school department", + status, + "The selected department is not valid for the school directory.", + ); + } + + if (status === 403) { + return expectedProblem( + "authority.denied", + "Authority denied", + status, + "The authenticated principal is not permitted to perform this operation.", + ); + } + + return expectedProblem( + "schools.unavailable", + "Schools unavailable", + status, + "The school directory is temporarily unavailable.", + ); +}; + +describe("Schools directory RPC", () => { + it.live("runs the named journey once and narrows the visible union by department", () => + Effect.gen(function* () { + const listInputs: Array = []; + + const school: SchoolDirectory = { + activeSchools: [ + { + schoolId: SchoolId.make(1), + name: "Journey School", + contactPerson: "Journey Contact", + email: "journey@example.invalid", + phone: "+47 900 00 000", + language: "Norwegian", + departments: [{ departmentId: departmentA, name: "Department A" }], + isActive: true, + }, + ], + inactiveSchools: [], + }; + + const backend = makeBackend(projection(), (input) => { + listInputs.push(input); + + return Effect.succeed(school); + }); + + expect(yield* listSchools(backend, departmentA)).toEqual(school); + expect(listInputs).toEqual([ + { + scope: SchoolDirectoryScopeSchema.cases.DepartmentIds.make({ + departmentIds: [departmentA], + }), + departmentId: departmentA, + }, + ]); + }), + ); + + it.live( + "maps authentication, authority, reference, scope, and persistence failures exactly", + () => + Effect.gen(function* () { + const cases: ReadonlyArray<{ + readonly name: string; + readonly expectedStatus: number; + readonly expectedTag: string; + readonly authority?: OrganizationPersonAuthority; + readonly departmentId?: DepartmentId; + readonly session?: string; + readonly readDepartment?: ( + departmentId: DepartmentId, + ) => Effect.Effect; + readonly listDirectory?: ( + input: SchoolDirectoryListInput, + ) => Effect.Effect; + }> = [ + { + name: "unknown session", + expectedStatus: 401, + expectedTag: "UnauthenticatedActor", + session: "unknown-session", + }, + { + name: "inactive authority", + expectedStatus: 403, + expectedTag: "AuthorityInactive", + authority: projection({ memberships: [], globalAdministrator: "Inactive" }), + }, + { + name: "absent authority", + expectedStatus: 403, + expectedTag: "NotInScope", + authority: projection({ + memberships: [], + nationalBoardSeats: [], + delegations: [], + globalAdministrator: "Absent", + }), + }, + { + name: "unknown department", + expectedStatus: 422, + expectedTag: "SchoolsDepartmentNotFound", + departmentId: departmentB, + readDepartment: (departmentId) => Effect.fail(new DepartmentNotFound({ departmentId })), + }, + { + name: "outside scope", + expectedStatus: 403, + expectedTag: "SchoolsDepartmentOutOfScope", + departmentId: departmentB, + }, + { + name: "persistence", + expectedStatus: 503, + expectedTag: "SchoolsPersistenceError", + listDirectory: () => + Effect.fail( + new SchoolsPersistenceError({ + operation: "read Schools directory", + message: "unavailable", + }), + ), + }, + { + name: "row decode", + expectedStatus: 503, + expectedTag: "SchoolsDecodeError", + listDirectory: () => + Effect.fail( + new SchoolsDecodeError({ + operation: "decode Schools directory rows", + message: "malformed row", + }), + ), + }, + ]; + + for (const testCase of cases) { + const backend = makeBackend( + testCase.authority ?? projection(), + testCase.listDirectory ?? (() => Effect.succeed(emptyDirectory)), + testCase.readDepartment, + ); + + const failure = yield* Effect.flip( + listSchools(backend, testCase.departmentId, testCase.session), + ); + + expect(isProblem(failure) ? problemBody(failure) : failure, testCase.name).toEqual( + expectedProblemForTag(testCase.expectedTag, testCase.expectedStatus), + ); + } + }), + ); +}); diff --git a/apps/backend/src/organization/rpc.test.ts b/apps/backend/src/organization/rpc.test.ts new file mode 100644 index 00000000..7919761e --- /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(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 sessionPerson = (cookieHeader: string | undefined) => + cookieHeader?.includes(ADMIN_SESSION) + ? PersonId.make("person-admin") + : cookieHeader?.includes(MEMBER_SESSION) + ? PersonId.make("person-member") + : undefined; + +const sessionActor = (cookieHeader: string | undefined) => { + const personId = sessionPerson(cookieHeader); + + return personId === undefined + ? Effect.fail(new IdentitySessionNotFound()) + : Effect.succeed( + new IdentityActor({ + 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( + backendTestConfig, + 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 index e582c3cc..9e43ad9e 100644 --- a/apps/backend/src/organization/rpc.ts +++ b/apps/backend/src/organization/rpc.ts @@ -1,8 +1,815 @@ /** - * The OrganizationRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/organization/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { OrganizationRpcs } from "@vektorprogrammet/rpc"; +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"; -export const OrganizationRpcHandlers = (_options: NativeRpcOptions) => OrganizationRpcs.toLayer({}); +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.rpc.test.ts b/apps/backend/src/organization/team-interest.rpc.test.ts new file mode 100644 index 00000000..977b74de --- /dev/null +++ b/apps/backend/src/organization/team-interest.rpc.test.ts @@ -0,0 +1,444 @@ +import { OAuthCredentialAuthority } from "@vektorprogrammet/database"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { + DepartmentId, + DepartmentJsonSchema, + MembershipId, + Organization, + type OrganizationOperations, + PersonId, + SemesterId, + TeamId, + type TeamInterestReadScope, +} from "@vektorprogrammet/domain/organization"; +import { isProblem } 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 { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +/** + * 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)( + { + departmentId: DepartmentId.make("department-1"), + name: "Department One", + shortName: "ONE", + email: "one@example.invalid", + address: null, + city: "Trondheim", + latitude: null, + longitude: null, + slackChannel: null, + logoPath: null, + active: true, + revision: 0, + }, + { onExcessProperty: "error" }, +); + +const secondDepartment = Schema.decodeSync(DepartmentJsonSchema)( + { + departmentId: DepartmentId.make("department-2"), + name: "Department Two", + shortName: "TWO", + email: "two@example.invalid", + address: null, + city: "Bergen", + latitude: null, + longitude: null, + slackChannel: null, + logoPath: null, + active: true, + revision: 0, + }, + { onExcessProperty: "error" }, +); + +const registrationRows = [ + { + registrationId: 2, + submitterName: "User B", + submitterEmail: "b@example.invalid", + teamId: TeamId.make("team-1"), + teamName: "Team One", + departmentId: DepartmentId.make("department-1"), + semesterId: null, + submittedAt: "2031-09-15T10:00:00.000Z", + revision: 0, + }, + { + registrationId: 1, + submitterName: "User A", + submitterEmail: "a@example.invalid", + teamId: TeamId.make("team-1"), + teamName: "Team One", + departmentId: DepartmentId.make("department-1"), + semesterId: SemesterId.make("semester-host"), + submittedAt: "2031-09-14T10:00:00.000Z", + revision: 3, + }, + { + registrationId: 3, + submitterName: "User C", + submitterEmail: "c@example.invalid", + teamId: TeamId.make("team-2"), + teamName: "Team Two", + departmentId: DepartmentId.make("department-2"), + semesterId: null, + submittedAt: "2031-09-16T10:00:00.000Z", + revision: 0, + }, +] as const; + +// 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]; + +type TokenMembership = { + teamId: TeamId; + departmentId: DepartmentId; + active: boolean; + /** Leads the unit; a board of an independent department reaches the department. */ + leader: boolean; + unitKind: "Team" | "DepartmentBoard"; +}; + +type AuthorityByToken = { + globalAdministrator: "Active" | "Inactive" | "Absent"; + memberships: ReadonlyArray; +}; + +const boardOne = TeamId.make("styret-1"); + +const teamOne = TeamId.make("team-1"); + +const teamTwo = TeamId.make("team-2"); + +const departmentOne = DepartmentId.make("department-1"); + +const departmentTwo = DepartmentId.make("department-2"); + +/** 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: [ + { + teamId: teamOne, + departmentId: departmentOne, + active: true, + leader: false, + unitKind: "Team", + }, + ], + }; + } + + if (token === "leader-session") { + return { + globalAdministrator: "Absent", + memberships: [ + { + teamId: boardOne, + departmentId: departmentOne, + active: true, + leader: true, + unitKind: "DepartmentBoard", + }, + { + teamId: teamTwo, + departmentId: departmentTwo, + active: true, + leader: false, + unitKind: "Team", + }, + ], + }; + } + + if (token === "team-captain") { + return { + globalAdministrator: "Absent", + memberships: [ + { + teamId: teamTwo, + departmentId: departmentTwo, + active: true, + leader: true, + unitKind: "Team", + }, + ], + }; + } + + if (token === "inactive-leader") { + return { + globalAdministrator: "Absent", + memberships: [ + { + teamId: boardOne, + departmentId: departmentOne, + active: false, + leader: true, + unitKind: "DepartmentBoard", + }, + ], + }; + } + + return { + globalAdministrator: "Absent", + memberships: [ + { + teamId: teamOne, + departmentId: departmentOne, + active: true, + leader: false, + unitKind: "Team", + }, + ], + }; +}; + +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"), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), +}); + +const identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookieHeader: string | undefined) => { + const token = cookieHeader?.startsWith(tokenPrefix) + ? cookieHeader.slice(tokenPrefix.length) + : ""; + + return token.length === 0 + ? Effect.fail(new IdentitySessionNotFound()) + : Effect.succeed( + new IdentityActor({ + 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"), + 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( + backendTestConfig, + Layer.mergeAll( + Layer.mock(Organization, organization), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + ), +); + +const asSession = + (token: string) => + (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: `${tokenPrefix}${token}` }); + +const codeOf = (effect: Effect.Effect) => + Effect.flip(effect).pipe( + Effect.map((failure) => (isProblem(failure) ? failure.code : String(failure))), + ); + +describe("team-interest RPC boundary", () => { + it.live("answers credential.missing without a session before any data leaves the store", () => + Effect.gen(function* () { + const client = yield* backend.client; + + expect(yield* codeOf(client["organization.listTeamInterest"]({}))).toBe("credential.missing"); + }), + ); + + it.live("denies a plain member and an inactive leader", () => + Effect.gen(function* () { + const client = yield* backend.client; + + 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 answers the exact envelope", () => + Effect.gen(function* () { + 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" }, + ], + "hydra:totalItems": 2, + }); + // Rows ordered registration_id ASC regardless of insert order. + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1"]); + }), + ); + + it.live("lets an ordinary team's leader read the own team's interest only", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("team-captain"), + ); + + 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 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" }, + { id: 3, userName: "User C", teamName: "Team Two" }, + ], + "hydra:totalItems": 3, + }); + expect(lastTeamInterestScope?.departmentIds).toEqual(["department-1", "department-2"]); + }), + ); + + it.live("gives a global administrator an empty success while no department exists", () => + Effect.gen(function* () { + storedDepartments = []; + + const client = yield* backend.client; + + const response = yield* client["organization.listTeamInterest"]({}).pipe( + asSession("admin-session"), + ); + + expect(response).toEqual({ "hydra:member": [], "hydra:totalItems": 0 }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + storedDepartments = [department, secondDepartment]; + }), + ), + ), + ); + + it.live("narrows by department inside scope and denies a department outside it", () => + Effect.gen(function* () { + const client = yield* backend.client; + + const inScope = yield* client["organization.listTeamInterest"]({ + departmentId: departmentOne, + }).pipe(asSession("leader-session")); + + expect(inScope["hydra:totalItems"]).toBe(2); + + expect( + yield* codeOf( + client["organization.listTeamInterest"]({ departmentId: departmentTwo }).pipe( + asSession("leader-session"), + ), + ), + ).toBe("authority.denied"); + }), + ); +}); diff --git a/packages/rpc/src/directory.ts b/packages/rpc/src/directory.ts index 455db26f..0deeba1d 100644 --- a/packages/rpc/src/directory.ts +++ b/packages/rpc/src/directory.ts @@ -1,11 +1,187 @@ /** * Directory: people and schools. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/directory.ts` at the base commit named there. - * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class DirectoryRpcs extends RpcGroup.make( + ListPeople, + ListSchools, + ReadSchoolManagement, + ExecuteSchoolCommand, +) {} diff --git a/packages/rpc/src/organization.ts b/packages/rpc/src/organization.ts index 6cdb8df2..7b419a4c 100644 --- a/packages/rpc/src/organization.ts +++ b/packages/rpc/src/organization.ts @@ -1,11 +1,415 @@ /** * Organization: departments, teams, fields of study, team interest, mailing lists, delegations, lifecycle. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/organization.ts` at the base commit named there. - * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class OrganizationRpcs extends RpcGroup.make( + ListDepartments, + ListTeams, + ListFieldOfStudies, + ListTeamInterest, + ListMailingLists, + CreateDepartment, + CreateTeam, + CreateFieldOfStudy, + ReadAppointmentManagement, + ReadBoardRosters, + ExecuteLifecycle, + ReadDelegationManagement, + ExecuteDelegation, +) {} From b558626208f154f9ce7a8bbd35cccf44678550c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:12:22 +0000 Subject: [PATCH 015/148] feat(backend): serve the session, profile, and contact RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/contact/rpc.test.ts | 183 ++++++++++++++ apps/backend/src/contact/rpc.ts | 82 +++++- apps/backend/src/profile/rpc.test.ts | 238 ++++++++++++++++++ apps/backend/src/profile/rpc.ts | 362 ++++++++++++++++++++++++++- apps/backend/src/rpc/system.test.ts | 303 ++++++++++++++++++++++ apps/backend/src/rpc/system.ts | 344 ++++++++++++++++++++++++- 6 files changed, 1501 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/contact/rpc.test.ts create mode 100644 apps/backend/src/profile/rpc.test.ts create mode 100644 apps/backend/src/rpc/system.test.ts diff --git a/apps/backend/src/contact/rpc.test.ts b/apps/backend/src/contact/rpc.test.ts new file mode 100644 index 00000000..d3e443d4 --- /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(new DepartmentNotFound({ 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( + { ...backendTestConfig, contact }, + 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 index 0461bf35..1a88824b 100644 --- a/apps/backend/src/contact/rpc.ts +++ b/apps/backend/src/contact/rpc.ts @@ -1,8 +1,84 @@ /** - * The ContactRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/contact/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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"; -export const ContactRpcHandlers = (_options: NativeRpcOptions) => ContactRpcs.toLayer({}); +/** 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; + + const services = + config === undefined ? undefined : Layer.merge(ContactQuotaLive, deliveryFor(config)); + + return ContactRpcs.toLayer({ + "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.provide(services), contactProblems); + }), + }); +}; diff --git a/apps/backend/src/profile/rpc.test.ts b/apps/backend/src/profile/rpc.test.ts new file mode 100644 index 00000000..ca67a351 --- /dev/null +++ b/apps/backend/src/profile/rpc.test.ts @@ -0,0 +1,238 @@ +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 = new IdentityActor({ + 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(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.mock(IdentitySnapshot, { + resolveSession: (cookieHeader) => + identity.resolveSession(cookieHeader).pipe( + Effect.catchTag("IdentitySessionExpired", () => Effect.fail(new IdentitySessionNotFound())), + ), + }), + 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( + backendTestConfig, + 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( + new OrganizationPersistenceError({ + 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 index b74b1ca5..57bac459 100644 --- a/apps/backend/src/profile/rpc.ts +++ b/apps/backend/src/profile/rpc.ts @@ -1,8 +1,362 @@ /** - * The ProfileRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/profile/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { ProfileRpcs } from "@vektorprogrammet/rpc"; +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"; -export const ProfileRpcHandlers = (_options: NativeRpcOptions) => ProfileRpcs.toLayer({}); +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)), + ); + +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/rpc/system.test.ts b/apps/backend/src/rpc/system.test.ts new file mode 100644 index 00000000..145a9771 --- /dev/null +++ b/apps/backend/src/rpc/system.test.ts @@ -0,0 +1,303 @@ +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 = 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 actor = new IdentityActor({ + 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(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: [] }), + ...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(new IdentitySessionNotFound())), + ), + 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(new IdentityOwnedSessionNotFound({ 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( + backendTestConfig, + 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"), + }), + ), + ), + ); +}; + +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, "user-agent": "session-rpc-test" }); + + 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(new IdentityOwnedSessionNotFound({ 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 index 6a73f4e7..35bfbe05 100644 --- a/apps/backend/src/rpc/system.ts +++ b/apps/backend/src/rpc/system.ts @@ -1,8 +1,344 @@ /** - * The SystemRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/http-api/system.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { SystemRpcs } from "@vektorprogrammet/rpc"; +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"; -export const SystemRpcHandlers = (_options: NativeRpcOptions) => SystemRpcs.toLayer({}); +/** 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")), + }); From fc9b30c5778dd75654c1ceac9223a015deda7a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:12:49 +0000 Subject: [PATCH 016/148] feat(rpc): call admissions over RPC from the dashboard and homepage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../dashboard.opptaksperioder._index.tsx | 48 ++- .../app/routes/dashboard.soknad._index.tsx | 9 +- ...dashboard.tidligere-assistenter._index.tsx | 29 +- .../app/routes/dashboard.vikarer._index.tsx | 42 ++- .../e2e/public-applicant-admission.spec.ts | 321 ++++++++---------- .../run-real-public-applicant-admission.mjs | 127 +++---- apps/homepage/src/lib/api-types.ts | 4 +- apps/homepage/src/lib/public-application.ts | 15 +- .../homepage/src/routes/_home.assistenter.tsx | 33 +- apps/homepage/test/public-application.test.ts | 42 ++- 10 files changed, 339 insertions(+), 331 deletions(-) diff --git a/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx b/apps/dashboard/app/routes/dashboard.opptaksperioder._index.tsx index d586e0a6..f6b37b22 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.soknad._index.tsx b/apps/dashboard/app/routes/dashboard.soknad._index.tsx index 38b313cc..941d1a2d 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/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.tidligere-assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.tidligere-assistenter._index.tsx index 52c22657..fe31be83 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/rpc" -import { PersonId } from "@vektorprogrammet/rpc" -import { IdempotencyHeaders } from "@vektorprogrammet/rpc"; +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.vikarer._index.tsx b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx index 873f3a7d..59b8b8c2 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/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, diff --git a/apps/homepage/e2e/public-applicant-admission.spec.ts b/apps/homepage/e2e/public-applicant-admission.spec.ts index 63bc0c0f..ef4101d7 100644 --- a/apps/homepage/e2e/public-applicant-admission.spec.ts +++ b/apps/homepage/e2e/public-applicant-admission.spec.ts @@ -2,21 +2,18 @@ import AxeBuilder from "@axe-core/playwright"; 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/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { makeScriptClient, 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,50 +142,78 @@ 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 = makeScriptClient(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. */ @@ -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-real-public-applicant-admission.mjs b/apps/homepage/e2e/run-real-public-applicant-admission.mjs index 46c3fb3c..c6f36773 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/rpc"; +import { CreateAdmissionPeriodRequest, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { makeScriptClient } 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 = makeScriptClient(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 = makeScriptClient(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/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index c668415f..024dd277 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -3,12 +3,12 @@ import { ListNewsEndpoint, ListTeamApplicationIntakesEndpoint, ListTeamsEndpoint, - ReadApplicationCatalogEndpoint, ReadNewsArticleEndpoint, ReadTeamApplicationIntakeEndpoint, SubmitContactMessageEndpoint, SubmitTeamApplicationEndpoint, } from "@vektorprogrammet/rpc"; +import type { PublicApplicationCatalogSchema } from "@vektorprogrammet/rpc"; import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; type EndpointResponseBody = @@ -27,7 +27,7 @@ export type PublishedNewsSummary = PublishedNewsListing["articles"][number]; export type PublishedNewsArticle = EndpointBody; -export type PublicApplicationCatalog = EndpointBody; +export type PublicApplicationCatalog = typeof PublicApplicationCatalogSchema.Type; export type ContactMessagePayload = HttpApiEndpoint.Payload< typeof SubmitContactMessageEndpoint diff --git a/apps/homepage/src/lib/public-application.ts b/apps/homepage/src/lib/public-application.ts index 03bde1c5..be6a0c50 100644 --- a/apps/homepage/src/lib/public-application.ts +++ b/apps/homepage/src/lib/public-application.ts @@ -1,4 +1,3 @@ -import { HttpClientError } from "effect/unstable/http"; import type { PublicApplicationCatalog } from "./api-types"; import { isProblem, problemBody, validationProblemSchema, type ValidationProblem, IdempotencyKey, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; import { Data, Match, Option, Predicate, Schema } from "effect"; @@ -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/routes/_home.assistenter.tsx b/apps/homepage/src/routes/_home.assistenter.tsx index ba73c69b..8a6970db 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/test/public-application.test.ts b/apps/homepage/test/public-application.test.ts index 1a5e6e41..93cb0999 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/rpc"; +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 = [ @@ -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())); } }); - From 24804503b5a7e9917ba3e27f929ec0e3fd4aeccd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:19:46 +0000 Subject: [PATCH 017/148] feat(dashboard,homepage): call the session, profile, and contact RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../app/foldkit/dashboard/shell-route.test.ts | 75 +++++--- .../app/foldkit/dashboard/shell.server.ts | 9 +- apps/dashboard/app/lib/oauth.server.test.ts | 10 +- apps/dashboard/app/oauth-routes.test.ts | 5 +- .../dashboard/app/routes/__foldkit.profile.ts | 29 ++-- .../app/routes/dashboard.profile._index.tsx | 12 +- .../dashboard.profile.rediger._index.tsx | 16 +- apps/dashboard/test/native-rpc.ts | 116 +++++++++++++ .../src/lib/contact-context.server.ts | 15 +- .../src/lib/contact-message.server.ts | 80 ++++----- apps/homepage/test/api.server.test.ts | 39 +++-- apps/homepage/test/contact-message.test.ts | 164 +++++++----------- apps/homepage/test/native-rpc.ts | 124 +++++++++++++ 13 files changed, 454 insertions(+), 240 deletions(-) create mode 100644 apps/dashboard/test/native-rpc.ts create mode 100644 apps/homepage/test/native-rpc.ts diff --git a/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts b/apps/dashboard/app/foldkit/dashboard/shell-route.test.ts index cfb21d27..ca0c714d 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 fc8c870a..d2fa88b5 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/lib/oauth.server.test.ts b/apps/dashboard/app/lib/oauth.server.test.ts index 31c9ed84..e008b33e 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/oauth-routes.test.ts b/apps/dashboard/app/oauth-routes.test.ts index 43d1b647..b6342e94 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/routes/__foldkit.profile.ts b/apps/dashboard/app/routes/__foldkit.profile.ts index c81ef648..f51fdb73 100644 --- a/apps/dashboard/app/routes/__foldkit.profile.ts +++ b/apps/dashboard/app/routes/__foldkit.profile.ts @@ -3,7 +3,7 @@ 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/dashboard.profile._index.tsx b/apps/dashboard/app/routes/dashboard.profile._index.tsx index 1a75e77c..266b5746 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 a8bb7cfa..3216f3ee 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/test/native-rpc.ts b/apps/dashboard/test/native-rpc.ts new file mode 100644 index 00000000..5253ef45 --- /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/homepage/src/lib/contact-context.server.ts b/apps/homepage/src/lib/contact-context.server.ts index 5da6f64d..e0f59ee0 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/rpc"; +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 958529df..ffcfa367 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/rpc"; -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 { 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/test/api.server.test.ts b/apps/homepage/test/api.server.test.ts index 539f2c3c..165e0cb3 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 7a6cb875..706022c2 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/rpc" -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/rpc" -import { makeNativeProblem } from "@vektorprogrammet/rpc"; - -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 00000000..42199e64 --- /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; + }, + }; +}; From cb5306596cb87c1b7ef204b7d395d666847efec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:20:13 +0000 Subject: [PATCH 018/148] test(rpc): drive the admission journeys and probes over RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../app/routes/dashboard.vikarer._index.tsx | 2 +- .../e2e/admission-period-management.spec.ts | 357 +++++++---------- apps/dashboard/e2e/native-substitutes.spec.ts | 71 ++-- .../run-real-admission-period-management.mjs | 103 +++-- tools/acceptance/applicant-progress-0107.ts | 58 ++- tools/acceptance/substitute-outcome-check.ts | 370 +++++++++--------- 6 files changed, 473 insertions(+), 488 deletions(-) diff --git a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx index 59b8b8c2..5fe34c31 100644 --- a/apps/dashboard/app/routes/dashboard.vikarer._index.tsx +++ b/apps/dashboard/app/routes/dashboard.vikarer._index.tsx @@ -185,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/e2e/admission-period-management.spec.ts b/apps/dashboard/e2e/admission-period-management.spec.ts index 3aab8b21..73d967f1 100644 --- a/apps/dashboard/e2e/admission-period-management.spec.ts +++ b/apps/dashboard/e2e/admission-period-management.spec.ts @@ -2,20 +2,18 @@ import AxeBuilder from "@axe-core/playwright"; 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, - NativeProblemRegistry, - OpenAdmissionPeriodListResponse, - PublicApplicationConfirmationSchema, - SessionUnauthorizedProblem, - type SubmitApplicationRequest, + SubmitApplicationRequest, } from "@vektorprogrammet/rpc"; +import { + IdempotencyKey, + type NativeProblemCode, + NativeProblemRegistry, + type StrongETag, +} from "@vektorprogrammet/rpc/problem"; +import { makeScriptClient, 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); - } +/** 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.idempotencyKey !== undefined) { - headers.set("idempotency-key", options.idempotencyKey); - } + return answer.value; +} - if (options.ifMatch !== undefined) headers.set("if-match", options.ifMatch); +const nativeApi = (backendOrigin: string, dashboardOrigin: string) => { + const native = makeScriptClient(backendOrigin); - if (options.body !== undefined) { - headers.set("content-type", options.contentType ?? "application/json"); - } + /** 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 }; - const requestBody: Pick = - options.body === undefined ? {} : { body: JSON.stringify(options.body) }; - - 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,8 +296,6 @@ 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(); @@ -342,14 +305,14 @@ test.describe("Native admission-period management", () => { // 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/native-substitutes.spec.ts b/apps/dashboard/e2e/native-substitutes.spec.ts index 9e511b2f..553ca156 100644 --- a/apps/dashboard/e2e/native-substitutes.spec.ts +++ b/apps/dashboard/e2e/native-substitutes.spec.ts @@ -2,6 +2,9 @@ import AxeBuilder from "@axe-core/playwright"; 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 { makeScriptClient } from "@vektorprogrammet/rpc/script"; const manifestPath = process.env.SUBSTITUTE_JOURNEY_MANIFEST; @@ -43,17 +46,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 ??= makeScriptClient(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 +275,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/run-real-admission-period-management.mjs b/apps/dashboard/e2e/run-real-admission-period-management.mjs index cb8c09dc..5cb61605 100644 --- a/apps/dashboard/e2e/run-real-admission-period-management.mjs +++ b/apps/dashboard/e2e/run-real-admission-period-management.mjs @@ -1,11 +1,7 @@ import { Predicate } from "effect"; import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { deriveHttpIdentity, encodePathIdentity } from "@vektorprogrammet/backend/http-semantics"; -import { - AdmissionsApi, - CreateAdmissionPeriodEndpoint, - ReviseAdmissionPeriodEndpoint, -} from "@vektorprogrammet/rpc"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; @@ -365,13 +361,68 @@ 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. + */ +const rpcAnswerOutcome = (bytes) => { + const answers = parseJsonBody(bytes); + const answer = Array.isArray(answers) ? answers[0] : null; + const exit = Predicate.hasProperty(answer, "exit") ? answer.exit : null; + + if (Predicate.isTagged(exit, "Success")) return { status: 200, problemCode: null }; + + const cause = Predicate.hasProperty(exit, "cause") && Array.isArray(exit.cause) ? exit.cause : []; + const failure = cause.find((reason) => Predicate.isTagged(reason, "Fail")); + const problem = Predicate.hasProperty(failure, "error") ? failure.error : null; + + return Predicate.hasProperty(problem, "code") && + Predicate.isString(problem.code) && + Predicate.hasProperty(problem, "status") && + Predicate.isNumber(problem.status) + ? { status: problem.status, problemCode: problem.code } + : { status: 500, problemCode: "defect" }; +}; /** * 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 +439,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" && url.pathname === nativeRpcPath; 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 +478,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 +616,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 +628,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/tools/acceptance/applicant-progress-0107.ts b/tools/acceptance/applicant-progress-0107.ts index c352cec1..46d1c813 100644 --- a/tools/acceptance/applicant-progress-0107.ts +++ b/tools/acceptance/applicant-progress-0107.ts @@ -11,8 +11,8 @@ 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 { makeScriptClient } from "../../packages/rpc/src/script-client.js"; import type { Page } from "playwright"; import { join } from "node:path"; import type { Pool, PoolClient } from "pg"; @@ -349,15 +349,27 @@ export const runApplicantProgress0107 = async (input: { readonly errors: string[]; readonly audit: (page: Page) => Promise>; }) => { - const request = (path: string, cookie = input.cookie) => - fetch(`${input.api}${path}`, { headers: { cookie, origin: input.ui } }); + const native = makeScriptClient(input.api); - 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"); + /** 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 = async (cookie: string = input.cookie) => { + const answer = await readProgress(cookie); + + if (!answer.ok) { + throw new Error(`applicant progress failed: ${inspect(answer, { depth: 10 })}`); + } + + return decodeApplicantProgressResponse(answer.value); + }; + + assert.equal((await readProgress(null)).status, 401); + const body = await progressOf(); const unlinkedSignIn = await fetch(`${input.api}/api/auth/sign-in/email`, { method: "POST", @@ -371,9 +383,7 @@ export const runApplicantProgress0107 = async (input: { assert.equal(unlinkedSignIn.status, 200); const unlinkedCookie = unlinkedSignIn.headers.get("set-cookie")?.split(";")[0]; 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((await 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`, @@ -381,15 +391,12 @@ export const runApplicantProgress0107 = async (input: { ); assert.ok((expiredSessions.rowCount ?? 0) >= 1); - assert.equal((await request("/api/applicant-progress", unlinkedCookie)).status, 401); - const body = decodeApplicantProgressResponse(await response.json()); + assert.equal((await 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 current = await progressOf(); const application = current.applications.find( (candidate) => candidate.applicationId === "application-progress-completed-0107", @@ -401,20 +408,6 @@ export const runApplicantProgress0107 = async (input: { 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", @@ -617,7 +610,8 @@ export const runApplicantProgress0107 = async (input: { }); assert.equal(signOut.status, 200); - assert.equal((await request("/api/applicant-progress")).status, 401); + assert.equal((await readProgress()).status, 401); + await native.dispose(); assert.deepEqual(input.errors, []); await writeFile( join(input.artifacts, "applicant-progress-targeted-evidence.json"), diff --git a/tools/acceptance/substitute-outcome-check.ts b/tools/acceptance/substitute-outcome-check.ts index b691f00e..c2289718 100644 --- a/tools/acceptance/substitute-outcome-check.ts +++ b/tools/acceptance/substitute-outcome-check.ts @@ -12,16 +12,15 @@ 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 { - IdempotencyIfMatchHeaders, - isProblem, - NativeProblem, - problemBody, -} from "../../packages/http-api/src/http-semantics.js"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; + 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 { makeScriptClient, 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 { type DisposablePostgres, @@ -32,6 +31,23 @@ import { stopOwnedProcess } from "./owned-process.js"; const root = new URL("../../", import.meta.url).pathname; +/** 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 requireDatabase = createRequire( new URL("../../packages/database/package.json", import.meta.url), ); @@ -159,50 +175,26 @@ try { 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); - } - } - - const requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; - - return fetch(`${backendOrigin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, - }); - }; + const native = makeScriptClient(backendOrigin); - const read = async (path: string, cookie = leader) => { - const response = await request(path, cookie); - assert.equal(response.status, 200, `${path}: ${await response.clone().text()}`); + /** 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 }; - return response.json(); + 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 = 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 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 = async (applicationId: string) => @@ -222,34 +214,33 @@ try { ).rows; const gates: string[] = []; - const scopesPath = "/api/admission-outcomes/scopes"; - 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) => + native.call(as(cookie), (client) => client["admissionOutcomes.listScopes"]()); - return scopes.departments.map((item: { departmentId: string }) => item.departmentId); - }; + const scopeDepartments = async (cookie: string) => + expectValue(await listScopes(cookie), "scopes").departments.map((item) => item.departmentId); + + const scopeOf = (semesterId: string) => + Schema.decodeSync(AdmissionOutcomeScope)({ departmentId, semesterId }); + + const readBoard = (semesterId: string, cookie?: string) => + native.call(as(cookie), (client) => + client["admissionOutcomes.readOutcomes"](scopeOf(semesterId)), + ); - const boardPath = (semesterId: string) => - `/api/admission-outcomes?${new URLSearchParams({ departmentId, semesterId }).toString()}`; + const board = async (semesterId: string, cookie = leader) => + expectValue(await readBoard(semesterId, cookie), `board ${semesterId}`); - const sdk = createPromiseClient(backendOrigin, { cookie: leader, origin: dashboardOrigin }); - const leaderScopes = await read(scopesPath); + const leaderScopes = expectValue(await listScopes(leader), "leader scopes"); assert.deepEqual(await scopeDepartments(leader), [departmentId]); assert.ok( [historicalSemesterId, openSemesterId, noPeriodSemesterId].every((semesterId) => - leaderScopes.semesters.some((item: { semesterId: string }) => item.semesterId === semesterId), + leaderScopes.semesters.some((item) => 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)`, ); @@ -260,7 +251,7 @@ try { ); gates.push("scopes list only the departments each person may read, from every membership"); - const submission = { + const submission = Schema.decodeSync(SubmitApplicationRequest)({ departmentId, firstName: "Anne", lastName: "API", @@ -279,26 +270,26 @@ try { preferredGroup: "all", language: "Norsk", }, - }; + }); + + const { applicationId } = expectValue( + await native.call(as(), (client) => + client["admissions.submitApplication"]({ idempotencyKey: newKey(), request: submission }), + ), + "submission", + ); - 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 scopeQuery = Schema.decodeSync(AdmissionOutcomeScope)({ - departmentId, - semesterId: openSemesterId, - }); + const openBoard = await board(openSemesterId); - const board = await read(boardPath(openSemesterId)); - assert.equal(board._tag, "Decide"); - assert.equal(board.admissionPeriodId, openPeriodId); + if (!Predicate.isTagged(openBoard, "Decide")) + assert.fail("admission management decides the open board"); + assert.equal(openBoard.admissionPeriodId, openPeriodId); const periodApplications = ( await database.query( @@ -312,15 +303,14 @@ try { [applicationId, "application-native-journey-0049"].toSorted(), ); assert.deepEqual( - board.entries.map((entry: { applicationId: string }) => entry.applicationId).toSorted(), + openBoard.entries.map((entry) => entry.applicationId).toSorted(), periodApplications, "admission management sees every application of the period", ); - const initial = board.entries.find( - (entry: { applicationId: string }) => entry.applicationId === applicationId, - ); + const initial = openBoard.entries.find((entry) => entry.applicationId === applicationId); + assert.ok(initial); assert.deepEqual( { firstName: initial.firstName, @@ -341,111 +331,109 @@ try { 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); + + const readItem = (cookie?: string) => + native.call(as(cookie), (client) => client["admissionOutcomes.readOutcome"]({ applicationId })); + + const record = ( + cookie: string | undefined, + request: AdmissionOutcomeCommand, + ifMatch: StrongETag, + idempotencyKey = newKey(), + ) => + native.call(as(cookie), (client) => + client["admissionOutcomes.recordOutcome"]({ applicationId, idempotencyKey, ifMatch, request }), + ); + + const item = expectValue(await readItem(leader), "leader single read"); 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", - ); + // 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 }]; - const invalidCommands: ReadonlyArray = [ - {}, - { outcome: "Maybe" }, - { outcome: null }, - { outcome: "Substitute", departmentId }, - ]; + for (const invalid of invalidCommands) { + const response = await fetch(`${backendOrigin}${nativeRpcPath}`, { + method: "POST", + headers: { "content-type": "application/json", cookie: leader, origin: dashboardOrigin }, + body: JSON.stringify( + WireRequest.make({ + id: "1", + tag: "admissionOutcomes.recordOutcome", + payload: { applicationId, idempotencyKey: newKey(), ifMatch: item.etag, request: invalid }, + headers: [], + }), + ), + }); + + assert.equal(response.status, 200, `invalid ${JSON.stringify(invalid)}`); + Schema.decodeUnknownSync(RejectedBeforeHandler)(await response.json()); + } - 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"); + gates.push("invalid outcomes are rejected before the handler and 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); + const key = newKey(); + const substitute = expectValue(await record(leader, { outcome: "Substitute" }, item.etag, key), "record"); 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( + expectValue(await record(leader, { outcome: "Substitute" }, item.etag, key), "replay"), + 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), + expectProblem( + await record(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( + expectValue(await record(leader, { outcome: "Substitute" }, substitute.etag), "again"), + 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", + "Substitute is recorded once with ifMatch and an idempotency key; replay and a repeated outcome add no revision", ); - await expectProblem( - await request(recordPath, leader, { outcome: "Rejected" }, item.etag), + expectProblem( + await record(leader, { outcome: "Rejected" }, item.etag), 412, "precondition.failed", "stale version", ); - 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 }, - }; - - 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 staleKey = newKey(); - return true; - }); - } + for (const attempt of ["initial stale record", "unchanged rejected retry"]) + expectProblem( + await record(leader, { outcome: "Rejected" }, item.etag, staleKey), + 412, + "precondition.failed", + attempt, + ); assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push("a stale version is refused (412), also on an unchanged SDK retry, and records nothing"); + gates.push("a stale version is refused (412), also on an unchanged retry, and records nothing"); - const memberBoard = await read(boardPath(openSemesterId), member); assert.deepEqual( - memberBoard, + await board(openSemesterId, member), AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - departmentId, - semesterId: openSemesterId, - admissionPeriodId: openPeriodId, + ...scopeOf(openSemesterId), + admissionPeriodId: openBoard.admissionPeriodId, substitutes: [ { applicationId, - firstName: "Anne", - lastName: "API", + firstName: submission.firstName, + lastName: submission.lastName, email: submission.email, phone: submission.phone, }, @@ -453,35 +441,31 @@ try { }), "a member reads only who is on call, with name, e-mail and phone", ); - - 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), + expectProblem(await readItem(member), 403, "authority.denied", "member single read"); + expectProblem( + await record(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), + expectProblem(await readBoard(openSemesterId, other), 403, "authority.denied", "other department board"); + expectProblem(await readItem(other), 403, "authority.denied", "other department item"); + expectProblem( + await record(other, { outcome: "Rejected" }, substitute.etag), 403, "authority.denied", "other department record", ); - 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, + expectProblem(await listScopes(), 401, "credential.missing", "anonymous scopes"); + expectProblem(await readBoard(openSemesterId), 401, "credential.missing", "anonymous board"); + expectProblem(await readItem(), 401, "credential.missing", "anonymous item"); + expectProblem( + await record(undefined, { outcome: "Rejected" }, substitute.etag), 401, + "credential.missing", "anonymous record", ); assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); @@ -491,22 +475,22 @@ try { `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, + (await board(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); + expectProblem(await readBoard(openSemesterId, other), 403, "authority.denied", "ended grant"); 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), + record(leader, { outcome: "Rejected" }, substitute.etag), + record(leader, { outcome: "Rejected" }, substitute.etag), ]); - const statuses = concurrent.map((response) => response.status); + 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), @@ -519,15 +503,20 @@ try { ]; assert.deepEqual(await outcomeHistory(applicationId), rejectedHistory); + const memberAfterReject = await board(openSemesterId, member); + assert.deepEqual( - (await read(boardPath(openSemesterId), member)).substitutes, + Predicate.isTagged(memberAfterReject, "ReadOnly") ? memberAfterReject.substitutes : null, [], "a rejected applicant is no longer on call", ); + + const leaderAfterReject = await board(openSemesterId); + assert.equal( - (await read(boardPath(openSemesterId))).entries.find( - (entry: { applicationId: string }) => entry.applicationId === applicationId, - ).outcome, + 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"); @@ -535,14 +524,14 @@ try { 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), + expectProblem( + await record(leader, { outcome: "Substitute" }, item.etag, key), 403, "authority.denied", "revoked authority cannot replay a stored result", ); - await expectProblem( - await request(boardPath(openSemesterId), leader), + expectProblem( + await readBoard(openSemesterId, leader), 403, "authority.denied", "revoked authority cannot read", @@ -553,25 +542,23 @@ try { gates.push("revoked authority cannot replay a stored result or read"); assert.deepEqual( - await read(boardPath(noPeriodSemesterId)), + await board(noPeriodSemesterId), AdmissionOutcomeBoardResource.cases.Decide.make({ - departmentId, - semesterId: noPeriodSemesterId, + ...scopeOf(noPeriodSemesterId), admissionPeriodId: null, entries: [], }), ); assert.deepEqual( - await read(boardPath(noPeriodSemesterId), member), + await board(noPeriodSemesterId, member), AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - departmentId, - semesterId: noPeriodSemesterId, + ...scopeOf(noPeriodSemesterId), admissionPeriodId: null, substitutes: [], }), ); - await expectProblem( - await request(boardPath("semester-missing-substitutes"), leader), + expectProblem( + await readBoard("semester-missing-substitutes", leader), 422, "scope.invalid", "unknown semester", @@ -599,6 +586,7 @@ try { // 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"); + await native.dispose(); const manifest = { revision, From 10a6f056b47cb46bf87cd8f9ef6b12fe1833d1bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:22:28 +0000 Subject: [PATCH 019/148] test(rpc): drive the returning-assistant journey over RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../acceptance/returning-assistant-journey.ts | 201 +++++++++--------- 1 file changed, 100 insertions(+), 101 deletions(-) diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 49f283f0..00a02c87 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -1,11 +1,12 @@ 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 { nativeRpcPath } from "../../packages/rpc/src/api.js"; +import { IdempotencyKey, NativeProblem } from "../../packages/rpc/src/problem.js"; +import { makeScriptClient } 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, @@ -18,7 +19,7 @@ import type { Pool } from "pg"; import type { Browser, Locator, Page } 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, @@ -32,6 +33,19 @@ import { DepartmentId } from "../../packages/domain/src/organization/schema.js"; import { Match, Predicate, Schema } from "effect"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; +/** 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", firstName: "Rita", @@ -715,10 +729,34 @@ export const runReturningAssistantBrowserJourney = async ({ .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 = makeScriptClient(api); + + /** The browser context's current session, sent from the dashboard origin. */ + const sessionHeaders = async () => ({ + cookie: (await context.cookies()).map((cookie) => `${cookie.name}=${cookie.value}`).join("; "), + origin: ui, }); + 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 = async ( + idempotencyKey: string, + data: typeof ReturningAssistantRegistrationInputSchema.Encoded, + ) => { + const request = Schema.decodeSync(ReturningAssistantRegistrationInputSchema)(data); + + return native.call(await sessionHeaders(), (client) => + client["admissions.registerReturningAssistant"]({ + idempotencyKey: IdempotencyKey.make(idempotencyKey), + request, + }), + ); + }; + + const optionsAnswer = await readOptions({ origin: ui, cookie: cookieHeader }); + const waitForDashboardAction = async (periodId: string, trigger: () => Promise) => { const responsePromise = returning.waitForResponse( (response) => { @@ -766,7 +804,7 @@ export const runReturningAssistantBrowserJourney = async ({ 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) => ( @@ -806,19 +844,16 @@ export const runReturningAssistantBrowserJourney = async ({ assert.ok(cookie, `${gate} session cookie`); const before = await negativeMutationSnapshot(probePerson.personId); - const response = await fetch(`${api}/api/returning-assistant/options`, { - headers: { origin: ui, accept: "application/json", cookie }, - }); - - const body = await response.text(); - assert.equal(response.status, expectedStatus, `${gate} status body=${body}`); + const answer = await readOptions({ origin: ui, cookie }); + const body = JSON.stringify(answer); + assert.equal(answer.status, expectedStatus, `${gate} status body=${body}`); const after = await negativeMutationSnapshot(probePerson.personId); if (JSON.stringify(after) !== JSON.stringify(before)) throw new Error( `${gate} must not mutate before=${JSON.stringify(before)} after=${JSON.stringify(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); @@ -859,16 +894,32 @@ export const runReturningAssistantBrowserJourney = async ({ status: "proven", }); - const browserOptions = await returning.evaluate(async (endpoint) => { - const response = await fetch(endpoint, { - credentials: "include", - headers: { accept: "application/json" }, - }); + // The browser calls the RPC with its own session cookie, across origins, as CORS admits it. + const browserOptions = await returning.evaluate( + async ({ endpoint, message }) => { + const response = await fetch(endpoint, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + }); - return { status: response.status }; - }, `${api}/api/returning-assistant/options`); + return { status: response.status, answer: await response.json() }; + }, + { + 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. + Schema.decodeUnknownSync(WireSuccess)(browserOptions.answer); trace.push({ phase: "options-probe", status: browserOptions.status }); const originalCustody = await pool.query( @@ -958,9 +1009,7 @@ export const runReturningAssistantBrowserJourney = async ({ const anonymousBefore = await negativeMutationSnapshot(person.personId); - const anonymous = await fetch(`${api}/api/returning-assistant/options`, { - headers: { origin: ui, accept: "application/json" }, - }); + const anonymous = await readOptions({ origin: ui }); assert.equal(anonymous.status, 401); assert.deepEqual(await negativeMutationSnapshot(person.personId), anonymousBefore); @@ -974,29 +1023,22 @@ export const runReturningAssistantBrowserJourney = async ({ assert.equal(foreignTeam.rows.length, 1); const wrongTeamBefore = await 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 = await 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.ok(!wrongTeam.ok); + assert.equal(wrongTeam.status, 403); + assert.equal(wrongTeam.code, "returning.team-scope-denied"); assert.deepEqual(await negativeMutationSnapshot(person.personId), wrongTeamBefore); trace.push({ phase: "negative-gate", gate: "cross-department-team", - status: wrongTeam.status(), - code: wrongTeamBody.code, + status: wrongTeam.status, + code: wrongTeam.code, }); let resolveFirstAction!: () => void; let rejectFirstAction!: (cause: unknown) => void; @@ -1651,45 +1693,28 @@ export const runReturningAssistantBrowserJourney = async ({ 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, - }, - ); + const answer = await register(idempotencyKey, payload); - return { idempotencyKey, payload, status: response.status(), body: await response.text() }; + return { idempotencyKey, payload, status: answer.status, answer }; }), ); assert.deepEqual( concurrentDetails.map(({ status }) => status).sort((left, right) => left - right), - [201, 412], + [200, 412], JSON.stringify(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, @@ -2432,16 +2457,9 @@ export const runReturningAssistantBrowserJourney = async ({ [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 exactReplay = await register(firstCommandKey, 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", @@ -2456,21 +2474,11 @@ export const runReturningAssistantBrowserJourney = async ({ ]); try { - const closedReplay = await context.request.post( - `${api}/api/returning-assistant/registrations`, - { - headers: { - "content-type": "application/json", - "idempotency-key": firstCommandKey, - origin: ui, - }, - data: firstReplayPayload, - }, - ); + const closedReplay = await register(firstCommandKey, firstReplayPayload); - assert.equal(closedReplay.status(), 409); + assert.equal(closedReplay.status, 409); assert.deepEqual(await negativeMutationSnapshot(person.personId), closedBefore); - trace.push({ phase: "negative-gate", gate: "closed-period", status: closedReplay.status() }); + 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", @@ -2493,24 +2501,15 @@ export const runReturningAssistantBrowserJourney = async ({ assert.equal(session.rows[0].count, 1); await 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 = await register(firstCommandKey, firstReplayPayload); - assert.equal(revokedReplay.status(), 401); + assert.equal(revokedReplay.status, 401); + await native.dispose(); assert.deepEqual(await 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(); From fd89885c7cb16f5c2b625400d86bdf4b014c0502 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:27:02 +0000 Subject: [PATCH 020/148] test(e2e): drive the identity and profile journeys over RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/native-identity-browser.spec.ts | 215 +++++++++--------- apps/dashboard/e2e/native-operations.ts | 71 +++++- .../e2e/native-profile-self-edit.spec.ts | 178 ++++++++------- .../e2e/run-real-native-identity-browser.mjs | 54 +++-- .../e2e/run-real-native-profile-self-edit.mjs | 75 +++--- apps/dashboard/test/request-routes.test.ts | 48 +++- 6 files changed, 387 insertions(+), 254 deletions(-) diff --git a/apps/dashboard/e2e/native-identity-browser.spec.ts b/apps/dashboard/e2e/native-identity-browser.spec.ts index 19aee99d..aef6426b 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -1,5 +1,6 @@ -import { SessionListResponse } from "@vektorprogrammet/rpc"; -import { Schema, Predicate } from "effect"; +import { IdempotencyKey, nativeRpcPath, type NativeRpcClient, type SessionListResponse } from "@vektorprogrammet/rpc"; +import { makeScriptClient } 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"; @@ -277,52 +278,81 @@ 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 = makeScriptClient(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("; "); + +/** 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); + +/** 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"]()); - expect(body.length).toBeGreaterThan(0); - expect(body.filter((session) => session.current)).toHaveLength(1); + if (!response.ok) throw new Error(`system.listSessions answered ${response.code}`); - return body; + expect(response.value.length).toBeGreaterThan(0); + expect(response.value.filter((session) => session.current)).toHaveLength(1); + + return response.value; }; -/** 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") }; +/** 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 nativeMutation = ( - context: BrowserContext, - method: "DELETE" | "POST", - path: string, - origin = dashboardOrigin, -) => - context.request.fetch(`${apiOrigin}${path}`, { - method, - headers: { ...originHeaders(origin), ...commandHeaders(method) }, - }); +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + tag: Schema.String, + payload: Schema.Json, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), +}); -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: Schema.encodeSync(Schema.fromJsonString(RpcRequest))( + RpcRequest.make({ + id: "0", + tag: "system.revokeOtherSessions", + payload: { idempotencyKey: commandKey() }, + headers: [], + }), + ), + }) + ).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 +378,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 +401,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 +429,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 +460,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 +475,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 +503,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 +705,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-operations.ts b/apps/dashboard/e2e/native-operations.ts index 4f55442e..c96a14ef 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -7,8 +7,8 @@ * prove that a journey sends no legacy, provider, recovery, or token request, rather than listing * such routes by name. */ -import { NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; -import { Option, Schema } from "effect"; +import { NativeProblem, NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { Array as Arr, Match, Option, Schema } from "effect"; /** One RPC request message, as the JSON serialization of the RPC client sends it over HTTP. */ const RpcRequestMessage = Schema.fromJsonString( @@ -24,6 +24,73 @@ const decodeRpcRequest = Schema.decodeUnknownOption(RpcRequestMessage); export const nativeRpcTag = (body: string): string | undefined => Option.getOrUndefined(Option.map(decodeRpcRequest(body), (message) => message.tag)); +const RequestId = Schema.Union([Schema.String, Schema.Finite]); + +/** 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) }), + ]), +}); + +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; + +/** + * 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 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 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, + ); +}; + /** The RPC client posts to the endpoint URL, which its HTTP client may end with one slash. */ const isRpcPath = (pathname: string): boolean => pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; diff --git a/apps/dashboard/e2e/native-profile-self-edit.spec.ts b/apps/dashboard/e2e/native-profile-self-edit.spec.ts index 93d641fb..5af8821c 100644 --- a/apps/dashboard/e2e/native-profile-self-edit.spec.ts +++ b/apps/dashboard/e2e/native-profile-self-edit.spec.ts @@ -1,4 +1,6 @@ -import { Schema, Predicate } from "effect"; +import { IdempotencyKey, type NativeRpcClient, StrongETag } from "@vektorprogrammet/rpc"; +import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { type Effect, Predicate, Schema } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { writeFile } from "node:fs/promises"; import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test"; @@ -129,12 +131,45 @@ const assertAxe = async (page: Page, results: Record, state: str results[state] = blockingViolations.length; }; +const nativeScript = makeScriptClient(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/run-real-native-identity-browser.mjs b/apps/dashboard/e2e/run-real-native-identity-browser.mjs index 044f5b67..05662549 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/rpc"; +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(); @@ -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, @@ -554,7 +560,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 +609,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 +636,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 +651,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 +781,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-profile-self-edit.mjs b/apps/dashboard/e2e/run-real-native-profile-self-edit.mjs index ed3dc24c..f61b3d3c 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/test/request-routes.test.ts b/apps/dashboard/test/request-routes.test.ts index 089fb0eb..91bd0a9f 100644 --- a/apps/dashboard/test/request-routes.test.ts +++ b/apps/dashboard/test/request-routes.test.ts @@ -1,6 +1,16 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { isNativeOperation, isNativeRequest, nativeRpcTag } 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"; /** The RPC request message that the client posts for one RPC. */ @@ -13,6 +23,27 @@ const RpcRequest = Schema.TaggedStruct("Request", { 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: [] }), @@ -48,6 +79,21 @@ describe("journey request route classification", () => { expect(nativeRpcTag(ack)).toBeUndefined(); }); + 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", () => { expect(isNativeRequest("POST", "/api/auth/sign-in/email")).toBe(true); From 47d6cb541cca916ac5bdee39d27f158ba218dcde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:27:10 +0000 Subject: [PATCH 021/148] test(rpc): call admission operations over RPC from other journeys Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/native-placement.spec.ts | 67 ++++++++++++++++--- ...tive-recruitment-interview-conduct.spec.ts | 24 +++++-- tools/acceptance/onboarding-check.ts | 61 +++++++++++------ tools/acceptance/recommendation-check.ts | 14 +++- .../acceptance/returning-assistant-journey.ts | 4 +- tools/e2e/placement-check.ts | 43 ++++++------ 6 files changed, 152 insertions(+), 61 deletions(-) diff --git a/apps/dashboard/e2e/native-placement.spec.ts b/apps/dashboard/e2e/native-placement.spec.ts index 58a0688f..32a85193 100644 --- a/apps/dashboard/e2e/native-placement.spec.ts +++ b/apps/dashboard/e2e/native-placement.spec.ts @@ -8,6 +8,9 @@ import type { 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 { PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { makeScriptClient } from "@vektorprogrammet/rpc/script"; import { Order } from "effect"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -117,18 +120,40 @@ const readOwnCoverage = async (page: Page) => { return response.json(); }; +let outcomeClient: ReturnType | undefined; + +/** The native RPC client of the admission-outcome calls, made once the manifest names the backend. */ +const outcomes = () => { + outcomeClient ??= makeScriptClient(manifest.backendOrigin); + + return outcomeClient; +}; + +/** 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, +}); + /** 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, }) => { @@ -1005,6 +1030,32 @@ test("golden school-service continuous functional journey", async ({ browser }) http.push({ check, status: response.status(), boundary: "authenticated-http" }); }; + /** 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; try { @@ -1348,9 +1399,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 +1428,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-recruitment-interview-conduct.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts index 7ab9f178..6f220ade 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -1,5 +1,7 @@ import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/rpc"; import { RecruitmentBridgeFailure } from "../app/foldkit/recruitment/bridge"; +import { ApplicantProgressResponseSchema } from "@vektorprogrammet/rpc"; +import { makeScriptClient } from "@vektorprogrammet/rpc/script"; import { Schema, Predicate } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; @@ -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 = makeScriptClient(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); diff --git a/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index cb783aa7..7e353b5f 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -15,6 +15,9 @@ import { startDisposablePostgres, } from "../postgres/index.ts"; import { Predicate, Schema, Struct } from "effect"; +import { SubmitApplicationRequest } from "../../packages/rpc/src/v2-schemas.js"; +import { IdempotencyKey } from "../../packages/rpc/src/problem.js"; +import { makeScriptClient } from "../../packages/rpc/src/script-client.js"; const root = new URL("../../", import.meta.url).pathname; @@ -290,31 +293,43 @@ try { 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", - }, + // Public applications are served as an RPC; an applicant submits anonymously. + const applications = makeScriptClient(backendOrigin); + + const submit = async (email: string, firstName: string) => { + const request = Schema.decodeSync(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 = await applications.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), + request, }), - 201, ); + assert.ok(answer.ok, JSON.stringify(answer)); + + return answer.value; + }; + const browserApplication = await submit(persons.applicant.email, "Onboarding"); let revokedReplayObserved = false; @@ -697,6 +712,8 @@ try { ).rows[0].count, 0, ); + await applications.dispose(); + evidence = { revision, passed: true, diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index cae3159d..4a3a9821 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -64,6 +64,8 @@ import { 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 { PublicApplicationIdSchema } from "../../packages/domain/src/application/schema.js"; +import { makeScriptClient } from "../../packages/rpc/src/script-client.js"; import { FetchHttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -2361,12 +2363,18 @@ try { assertNoRecommendation(JSON.parse(applicantObservation)); assert.ok(!applicantObservation.includes("Kanskje")); - const applicationProjection = await fetch( - `${api}/api/applications/application-recommendation-maybe`, + // The public application confirmation is an anonymous RPC. + const confirmations = makeScriptClient(api); + + const applicationProjection = await confirmations.call({}, (client) => + client["admissions.readApplicationConfirmation"]({ + applicationId: PublicApplicationIdSchema.make("application-recommendation-maybe"), + }), ); + await confirmations.dispose(); assert.equal(applicationProjection.status, 200); - const applicationBody = await applicationProjection.text(); + const applicationBody = JSON.stringify(applicationProjection); assertNoRecommendation(JSON.parse(applicationBody)); assert.ok(!applicationBody.includes("Kanskje")); recordGate( diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 00a02c87..e0f2d9f5 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -624,7 +624,7 @@ export const runReturningAssistantBrowserJourney = async ({ if ( url.pathname.includes("/dashboard/tidligere-assistenter") || - url.pathname.includes("/api/returning-assistant/") || + url.pathname === nativeRpcPath || url.pathname.includes("/api/auth/") ) { responses.push(`request ${request.method()} ${url.pathname}`); @@ -639,7 +639,7 @@ export const runReturningAssistantBrowserJourney = async ({ if ( !url.pathname.includes("/dashboard/tidligere-assistenter") && - !url.pathname.includes("/api/returning-assistant/") && + url.pathname !== nativeRpcPath && !url.pathname.includes("/api/auth/") ) return; diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index 1bcf3b40..a1be5882 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -7,7 +7,8 @@ import { PlacementScope, SchoolServiceNotificationRequest, } from "@vektorprogrammet/domain/placements"; -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/rpc/problem"; +import { IdempotencyIfMatchHeaders, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { makeScriptClient } from "@vektorprogrammet/rpc/script"; /** 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"; @@ -1604,28 +1605,32 @@ 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 = makeScriptClient(backendOrigin); + const leaderSession = { cookie: leader, origin: dashboardOrigin }; + const outcomeApplicationId = PublicApplicationIdSchema.make(coverageApplicationId); + + const outcomeAnswer = await outcomeClient.call(leaderSession, (client) => + client["admissionOutcomes.readOutcome"]({ applicationId: outcomeApplicationId }), ); + assert.ok(outcomeAnswer.ok, JSON.stringify(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 = await outcomeClient.call(leaderSession, (client) => + client["admissionOutcomes.recordOutcome"]({ + applicationId: outcomeApplicationId, + idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), + ifMatch: outcomeEntry.etag, + request: { outcome: "Substitute" }, + }), + ); + + await outcomeClient.dispose(); + assert.ok(onCallAnswer.ok, JSON.stringify(onCallAnswer)); + const onCall = onCallAnswer.value; assert.deepEqual([onCall.outcome, onCall.revision], ["Substitute", 1]); From 4bb6dc4aa72e66ef31450ada429fa5c12677df3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:30:07 +0000 Subject: [PATCH 022/148] docs(rpc): record the admissions port and its lost HTTP capabilities Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../backend/src/admission/outcome.rpc.test.ts | 249 ++++++++++++++++++ docs/specs/rpc-only.md | 72 ++++- 2 files changed, 307 insertions(+), 14 deletions(-) create mode 100644 apps/backend/src/admission/outcome.rpc.test.ts 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 00000000..7f784912 --- /dev/null +++ b/apps/backend/src/admission/outcome.rpc.test.ts @@ -0,0 +1,249 @@ +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 + : new IdentityActor({ + 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(new IdentitySessionNotFound()) : 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(new IdentitySessionNotFound()) : 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( + backendTestConfig, + 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/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index bcb81fb8..7ee4bd09 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -98,25 +98,69 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `admissionOutcomes.listScopes` | GET `/api/admission-outcomes/scopes` | - | cookieHeader, oauthUserBearer | todo | -| `admissionOutcomes.readOutcome` | GET `/api/admission-outcomes/{applicationId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | -| `admissionOutcomes.readOutcomes` | GET `/api/admission-outcomes` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `admissionOutcomes.recordOutcome` | POST `/api/admission-outcomes/{applicationId}:record` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `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 | todo | -| `admissions.listAdmissionPeriods` | GET `/api/admission-periods` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | -| `admissions.listApplicationOptions` | GET `/api/application-options` | ifMatch; if-none-match (dropped) | none | todo | -| `admissions.listOpenAdmissionPeriods` | GET `/api/open-admission-periods` | ifMatch; if-none-match (dropped) | none | todo | -| `admissions.readApplicantProgress` | GET `/api/applicant-progress` | - | cookieHeader, oauthUserBearer | todo | -| `admissions.readApplicationConfirmation` | GET `/api/applications/{applicationId}` | - | none | todo | -| `admissions.readReturningAssistantOptions` | GET `/api/returning-assistant/options` | - | cookieHeader, oauthUserBearer | todo | -| `admissions.registerReturningAssistant` | POST `/api/returning-assistant/registrations` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `admissions.reviseAdmissionPeriod` | PATCH `/api/admission-periods/{admissionPeriodId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | -| `admissions.submitApplication` | POST `/api/applications` | idempotencyKey | none | todo | +| `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`) From 21716ac45a41c09a3afdade8c909c83b60876071 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:30:45 +0000 Subject: [PATCH 023/148] test(e2e): drive the contact journey and the recovery probe over RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/native-identity-browser.spec.ts | 17 +------ apps/dashboard/e2e/native-operations.ts | 17 +++++++ apps/homepage/e2e/run-native-contact.mjs | 50 +++++++++++++++---- tools/acceptance/password-recovery-check.ts | 25 ++++++---- 4 files changed, 75 insertions(+), 34 deletions(-) diff --git a/apps/dashboard/e2e/native-identity-browser.spec.ts b/apps/dashboard/e2e/native-identity-browser.spec.ts index aef6426b..78c8c788 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -7,6 +7,7 @@ import AxeBuilder from "@axe-core/playwright"; 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"; @@ -320,13 +321,6 @@ const sessionOperations = { client["system.deleteOwnedSession"]({ idempotencyKey: commandKey(), sessionId }), }; -const RpcRequest = Schema.TaggedStruct("Request", { - id: Schema.String, - tag: Schema.String, - payload: Schema.Json, - headers: Schema.Array(Schema.Tuple([Schema.String, Schema.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. @@ -335,14 +329,7 @@ const browserRpcStatus = async (context: BrowserContext, origin: string) => ( await context.request.post(`${apiOrigin}${nativeRpcPath}`, { headers: { ...originHeaders(origin), "content-type": "application/json" }, - data: Schema.encodeSync(Schema.fromJsonString(RpcRequest))( - RpcRequest.make({ - id: "0", - tag: "system.revokeOtherSessions", - payload: { idempotencyKey: commandKey() }, - headers: [], - }), - ), + data: nativeRpcRequestBody("system.revokeOtherSessions", { idempotencyKey: commandKey() }), }) ).status(); diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index c96a14ef..0f519405 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -17,6 +17,23 @@ const RpcRequestMessage = Schema.fromJsonString( const decodeRpcRequest = Schema.decodeUnknownOption(RpcRequestMessage); +/** 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)); + +/** + * 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. + */ +export const nativeRpcRequestBody = (tag: string, payload: Schema.Json = null): string => + encodeRpcRequest(RpcRequest.make({ id: "0", tag, payload, headers: [] })); + /** * 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. diff --git a/apps/homepage/e2e/run-native-contact.mjs b/apps/homepage/e2e/run-native-contact.mjs index 311bd2f4..45569adc 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"; @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { chromium, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; +import { Predicate, Schema } from "effect"; const root = fileURLToPath(new URL("../../../", import.meta.url)); @@ -377,20 +378,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 +442,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 +478,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"; diff --git a/tools/acceptance/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index c37ea8dd..04a80ac2 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -20,6 +20,7 @@ import { } from "../postgres/index.ts"; import { drainPasswordResetMail } from "../../packages/database/src/password-recovery.js"; import { HttpMailLive } from "../../apps/backend/src/mail/http.js"; +import { nativeRpcRequestBody, nativeRpcStatus } from "../../apps/dashboard/e2e/native-operations.js"; const root = new URL("../../", import.meta.url).pathname; @@ -104,6 +105,18 @@ try { 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 = async (cookie: string) => + nativeRpcStatus( + await ( + await fetch(`${canonicalOrigin}/api/rpc`, { + method: "POST", + headers: { cookie, origin: dashboardOrigin, "content-type": "application/json" }, + body: nativeRpcRequestBody("system.readSession"), + }) + ).text(), + ); + const env = { ...process.env, BACKEND_HOST: "127.0.0.1", @@ -313,11 +326,7 @@ try { 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 }, - }); - - assert.equal(r.status, 401); + assert.equal(await readSessionStatus(cookie), 401); } gates.push("both old sessions denied"); @@ -461,11 +470,7 @@ try { `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(stillLive.status, 200); + assert.equal(await readSessionStatus(partialCookie), 200); await login(oldPassword); gates.push("session deletion failure returns5xx; password changed with old session still live"); const expiredToken = await nextToken(); From 679df161be21138457194eb4c1be76de52686cc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:31:27 +0000 Subject: [PATCH 024/148] feat(rpc): migrate organization and directory clients to RPC Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../foldkit/organization/browser-client.ts | 89 ++---- .../app/foldkit/organization/delegations.ts | 40 +-- .../app/foldkit/organization/management.ts | 26 +- .../foldkit/schools/browser-client.test.ts | 45 ++- .../app/foldkit/schools/browser-client.ts | 85 +++--- apps/dashboard/app/foldkit/schools/command.ts | 2 +- .../app/foldkit/schools/management-view.ts | 2 +- apps/dashboard/app/foldkit/schools/message.ts | 3 +- apps/dashboard/app/foldkit/schools/model.ts | 9 +- .../app/foldkit/schools/update.test.ts | 4 +- apps/dashboard/app/foldkit/schools/update.ts | 8 +- .../app/foldkit/schools/view.test.ts | 4 +- apps/dashboard/app/foldkit/schools/view.ts | 2 +- apps/dashboard/app/lib/browser-native.ts | 41 +++ .../app/routes/dashboard.brukere._index.tsx | 13 +- .../routes/dashboard.epostliste._index.tsx | 24 +- .../routes/dashboard.teaminteresse._index.tsx | 14 +- .../routes/dashboard.teamsoknader._index.tsx | 13 +- .../e2e/native-mailing-lists-journey.spec.ts | 81 ++++-- ...native-organization-administration.spec.ts | 273 +++++++++--------- .../e2e/native-schools-directory.spec.ts | 97 +++++-- .../e2e/native-team-interest-journey.spec.ts | 29 +- ...eal-native-organization-administration.mjs | 193 +++++++++---- .../e2e/run-real-native-schools-directory.mjs | 124 +++++++- apps/homepage/src/lib/api-types.ts | 7 +- .../src/lib/contact-message.server.ts | 12 +- apps/homepage/src/lib/news.server.ts | 17 +- .../homepage/src/lib/team-directory.server.ts | 21 +- docs/specs/rpc-only.md | 84 ++++-- .../organization-import-rehearsal-main.ts | 267 +++++++++++------ .../organization-import-rehearsal.test.ts | 20 +- .../organization-import-rehearsal.ts | 41 ++- 32 files changed, 1043 insertions(+), 647 deletions(-) create mode 100644 apps/dashboard/app/lib/browser-native.ts diff --git a/apps/dashboard/app/foldkit/organization/browser-client.ts b/apps/dashboard/app/foldkit/organization/browser-client.ts index 829f7f22..77994f5b 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/rpc" -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 979d8a46..e39956d6 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/rpc"; -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 b108192c..e32d43e4 100644 --- a/apps/dashboard/app/foldkit/organization/management.ts +++ b/apps/dashboard/app/foldkit/organization/management.ts @@ -4,8 +4,7 @@ import { Command, Runtime, Update } from "foldkit"; import { taggedStruct } from "foldkit/schema"; import type { Html, HtmlBuilder } from "foldkit/html"; import { AppointmentManagement, OrganizationLifecycleCommand, IdempotencyKey } from "@vektorprogrammet/rpc"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +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/schools/browser-client.test.ts b/apps/dashboard/app/foldkit/schools/browser-client.test.ts index 67d6407d..68a017ae 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 d24aa25a..90972d38 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/rpc"; import { IdempotencyKey } from "@vektorprogrammet/rpc"; -import { createEffectClient, type EffectSdkFailure } from "@vektorprogrammet/sdk/effect"; 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 67af7f20..c7a376df 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/rpc"; +import { DepartmentId } from "@vektorprogrammet/domain"; import { Effect, Match, Schema as S } from "effect"; import { Command } from "foldkit"; import type { SchoolsBridgeFailure } from "./bridge"; diff --git a/apps/dashboard/app/foldkit/schools/management-view.ts b/apps/dashboard/app/foldkit/schools/management-view.ts index 7e7c6a99..b8b39f51 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/rpc"; +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 44cb61e2..569973b9 100644 --- a/apps/dashboard/app/foldkit/schools/message.ts +++ b/apps/dashboard/app/foldkit/schools/message.ts @@ -1,10 +1,9 @@ -import { DepartmentId } from "@vektorprogrammet/rpc"; +import { DepartmentId, SchoolId } from "@vektorprogrammet/domain"; import { SchoolDirectorySchema, SchoolManagement, SchoolCommand, SchoolCommandResult, - SchoolId, } from "@vektorprogrammet/rpc"; import { Tabs } from "@foldkit/ui"; import { Schema as S } from "effect"; diff --git a/apps/dashboard/app/foldkit/schools/model.ts b/apps/dashboard/app/foldkit/schools/model.ts index bd76b602..d3f8fa88 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/rpc"; -import { SchoolDirectoryDepartmentSchema, SchoolDirectorySchema } from "@vektorprogrammet/rpc"; +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 70208c24..08c88aaf 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/rpc"; -import { SchoolId, type SchoolDirectory } from "@vektorprogrammet/rpc"; +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 41013b6e..e980739c 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/rpc"; import { Tabs } from "@foldkit/ui"; import { Match as M, Option, Schema, Predicate } from "effect"; -import { - SchoolCommand, - DepartmentId, - SemesterId, - type SchoolManagement, -} from "@vektorprogrammet/rpc"; +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 e64288c4..4ec53fd7 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/rpc"; -import { SchoolId, type SchoolDirectory } from "@vektorprogrammet/rpc"; +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 ed8e4d01..1fdd4015 100644 --- a/apps/dashboard/app/foldkit/schools/view.ts +++ b/apps/dashboard/app/foldkit/schools/view.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; import { managementView } from "./management-view"; -import { DepartmentId } from "@vektorprogrammet/rpc"; +import { DepartmentId } from "@vektorprogrammet/domain"; import type { SchoolDirectory, SchoolDirectoryEntry } from "@vektorprogrammet/rpc"; import { Input, Select } from "@foldkit/ui"; import { AsyncData } from "foldkit"; diff --git a/apps/dashboard/app/lib/browser-native.ts b/apps/dashboard/app/lib/browser-native.ts new file mode 100644 index 00000000..ca6573b6 --- /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/routes/dashboard.brukere._index.tsx b/apps/dashboard/app/routes/dashboard.brukere._index.tsx index 44559a8a..1f01549a 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/rpc"; +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 c28e1e0d..8389a5b7 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/rpc"; -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.teaminteresse._index.tsx b/apps/dashboard/app/routes/dashboard.teaminteresse._index.tsx index ff5e4db0..db442689 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/rpc"; +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 4f4fa7b2..46f7b422 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/e2e/native-mailing-lists-journey.spec.ts b/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts index b57662a8..8b4a9b3b 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/rpc"; +import { DepartmentId, SemesterId } from "@vektorprogrammet/domain"; +import { makeScriptClient } 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 = makeScriptClient(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-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 669f233a..c00be589 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 { Option, Schema } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { IdempotencyKey } from "@vektorprogrammet/rpc"; -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, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { makeScriptClient, 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 = makeScriptClient(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" && + url.pathname === nativeRpcPath && + 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 }), + ); + + required(fieldResult, "createFieldOfStudy"); - expect(unknownReferenceResponse.status()).toBe(422); - const unknownReferenceBody = await responseBody(unknownReferenceResponse); + 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, @@ -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-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index 8fc367c5..751a61da 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -1,8 +1,10 @@ import AxeBuilder from "@axe-core/playwright"; 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/rpc"; +import { Option, Schema } from "effect"; +import { DepartmentId } from "@vektorprogrammet/domain"; +import { NativeProblem, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { makeScriptClient } from "@vektorprogrammet/rpc/script"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -10,11 +12,45 @@ 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; +const apiOrigin = process.env.API_URL ?? "http://127.0.0.1:8790"; + +/** 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 +78,7 @@ type BrowserRequest = { readonly method: string; readonly pathname: string; readonly search: string; + readonly rpcTag?: string | undefined; }; type BrowserResponse = BrowserRequest & { readonly status: number }; @@ -55,7 +92,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 +105,7 @@ const openContext = async ( method: response.request().method(), pathname: url.pathname, search: url.search, + rpcTag: rpcTagOf(response.request().postData()), status: response.status(), }); }); @@ -112,6 +155,7 @@ test.describe("Native Schools directory (spec 0061)", () => { test("proves the authority matrix, Foldkit interactions, retry, and request confinement", async ({ browser, + baseURL, }) => { const browserRequests: BrowserRequest[] = []; const browserResponses: BrowserResponse[] = []; @@ -188,17 +232,20 @@ 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)}`, + const native = makeScriptClient(apiOrigin); + + const emptyDepartment = await native + .call( { - credentials: "same-origin", - headers: { accept: "application/json" }, + cookie: (await administrator.context.cookies()) + .map(({ name, value }) => `${name}=${value}`) + .join("; "), + origin: new URL(baseURL ?? administrator.page.url()).origin, }, - ); - - return { status: response.status, body: await response.json() }; - }, departments.empty); + (client) => client[listSchoolsTag]({ departmentId: departments.empty }), + ) + .finally(() => native.dispose()) + .then((result) => ({ status: result.status, body: result.ok ? result.value : result.code })); expect(emptyDepartment).toEqual({ status: 200, @@ -261,13 +308,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) => + new URL(response.url()).pathname === nativeRpcPath && + 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 +326,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" && + request.pathname === nativeRpcPath && + request.rpcTag === listSchoolsTag, ); expect( @@ -300,10 +352,11 @@ 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) => response.pathname === nativeRpcPath && response.rpcTag === listSchoolsTag, ), observations, pageErrors, diff --git a/apps/dashboard/e2e/native-team-interest-journey.spec.ts b/apps/dashboard/e2e/native-team-interest-journey.spec.ts index 930ed395..e64ea635 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 { makeScriptClient } 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 = makeScriptClient(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/run-real-native-organization-administration.mjs b/apps/dashboard/e2e/run-real-native-organization-administration.mjs index 95118947..f3e4df49 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"; @@ -368,6 +368,75 @@ 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 and payload. */ +const RpcRequestMessage = Schema.Struct({ tag: Schema.String, payload: Schema.Unknown }); + +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", @@ -423,9 +492,28 @@ async function startRecordingProxy(targetOrigin) { responseContentType: null, responseEtag: null, responseLocation: null, + transportPath: url.pathname, + rpcTag: null, status: 0, }; + const rpc = url.pathname === "/api/rpc" ? parseRpcRequest(record.request) : undefined; + + // 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 +549,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 +754,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 +775,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`, @@ -1027,15 +1115,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 +1231,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 +1273,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 +1291,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 +1329,7 @@ async function main() { const expectedStatusFacts = [ [ "/api/departments", - 201, + 200, true, false, adminPersonId, @@ -1268,7 +1338,7 @@ async function main() { ], [ "/api/teams", - 201, + 200, true, false, adminPersonId, @@ -1277,7 +1347,7 @@ async function main() { ], [ "/api/field-of-studies", - 201, + 200, true, false, adminPersonId, @@ -1304,7 +1374,7 @@ async function main() { ], [ "/api/departments", - 201, + 200, true, false, adminPersonId, @@ -1381,11 +1451,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 +1463,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 +1477,7 @@ async function main() { ({ method, path, + rpcTag, status, sessionCookieAuth, authorizationHeaderPresent, @@ -1424,6 +1490,7 @@ async function main() { }) => ({ method, path, + rpcTag, status, sessionCookieAuth, authorizationHeaderPresent, diff --git a/apps/dashboard/e2e/run-real-native-schools-directory.mjs b/apps/dashboard/e2e/run-real-native-schools-directory.mjs index 7c874e47..9de06627 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"; @@ -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 = pathname === "/api/rpc" ? 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); @@ -480,7 +571,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 +585,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 +598,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 +687,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/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index c668415f..fa5bf64c 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -1,14 +1,13 @@ import { - ListDepartmentsEndpoint, ListNewsEndpoint, ListTeamApplicationIntakesEndpoint, - ListTeamsEndpoint, ReadApplicationCatalogEndpoint, ReadNewsArticleEndpoint, ReadTeamApplicationIntakeEndpoint, SubmitContactMessageEndpoint, SubmitTeamApplicationEndpoint, } from "@vektorprogrammet/rpc"; +import type { DepartmentJson, TeamJson } from "@vektorprogrammet/rpc"; import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; type EndpointResponseBody = @@ -19,7 +18,7 @@ type EndpointBody = Exclude< void >; -export type HomepageDepartment = EndpointBody[number]; +export type HomepageDepartment = DepartmentJson; export type PublishedNewsListing = EndpointBody; @@ -39,7 +38,7 @@ export type ContactMessageHeaders = HttpApiEndpoint.Headers< export type NewsArticleSlug = HttpApiEndpoint.Params["Type"]["slug"]; -export type HomepageTeam = EndpointBody[number]; +export type HomepageTeam = TeamJson; export type HomepageTeamIntake = EndpointBody[number]; diff --git a/apps/homepage/src/lib/contact-message.server.ts b/apps/homepage/src/lib/contact-message.server.ts index 958529df..92801b38 100644 --- a/apps/homepage/src/lib/contact-message.server.ts +++ b/apps/homepage/src/lib/contact-message.server.ts @@ -2,7 +2,7 @@ import { ContactMessage, isProblem } from "@vektorprogrammet/rpc"; 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 { callHomepageNative } from "./api.server"; import { CONTACT_BACKEND_HEADER, CONTACT_IP_HEADER, @@ -18,15 +18,11 @@ import { async function activeDepartments(backendOrigin?: string): Promise { try { - const result = await createHomepageApiClient(backendOrigin).organization.listDepartments({ - headers: {}, + const listed = await callHomepageNative((client) => client["organization.listDepartments"](), { + explicitOrigin: backendOrigin, }); - if (result.body === undefined) { - throw new Error("The conditional department response has no body."); - } - - const departments = result.body.filter((department) => department.active); + const departments = listed.filter((department) => department.active); const slugs = new Set(); for (const department of departments) { diff --git a/apps/homepage/src/lib/news.server.ts b/apps/homepage/src/lib/news.server.ts index 3ee1440b..f1471de7 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/rpc"; import type { PublishedNewsArticle, PublishedNewsListing } from "./api-types"; -import { createHomepageApiClient } from "./api.server"; +import { callHomepageNative, createHomepageApiClient } from "./api.server"; import { applyDepartmentFilter, NEWS_TEASER_COUNT, @@ -41,18 +41,9 @@ const readListing = async (): Promise => { }; 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 diff --git a/apps/homepage/src/lib/team-directory.server.ts b/apps/homepage/src/lib/team-directory.server.ts index fb9f2b47..8892d168 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/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index bcb81fb8..12cd10ad 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -152,10 +152,27 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `directory.executeSchoolCommand` | POST `/api/schools/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `directory.listPeople` | GET `/api/people` | - | cookieHeader, oauthUserBearer | todo | -| `directory.listSchools` | GET `/api/schools` | query: department | cookieHeader, oauthUserBearer | todo | -| `directory.readSchoolManagement` | GET `/api/schools/management` | - | cookieHeader, oauthUserBearer | todo | +| `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)`) @@ -175,19 +192,52 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `organization.createDepartment` | POST `/api/departments` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `organization.createFieldOfStudy` | POST `/api/field-of-studies` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `organization.createTeam` | POST `/api/teams` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `organization.executeDelegation` | POST `/api/organization/delegations/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `organization.executeLifecycle` | POST `/api/organization/appointments/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `organization.listDepartments` | GET `/api/departments` | ifMatch; if-none-match (dropped) | none | todo | -| `organization.listFieldOfStudies` | GET `/api/field-of-studies` | ifMatch; if-none-match (dropped) | none | todo | -| `organization.listMailingLists` | GET `/api/mailing-lists` | query: department, semester, type | cookieHeader, oauthUserBearer | todo | -| `organization.listTeamInterest` | GET `/api/team-interest-registrations` | query: department, semester | cookieHeader, oauthUserBearer | todo | -| `organization.listTeams` | GET `/api/teams` | ifMatch; if-none-match (dropped) | none | todo | -| `organization.readAppointmentManagement` | GET `/api/organization/appointments` | - | cookieHeader, oauthUserBearer | todo | -| `organization.readBoardRosters` | GET `/api/organization/board-rosters` | - | cookieHeader, oauthUserBearer | todo | -| `organization.readDelegationManagement` | GET `/api/organization/delegations` | - | cookieHeader, oauthUserBearer | todo | +| `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`) diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index 98b946d3..778a751d 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/rpc"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { makeScriptClient, 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; @@ -992,6 +990,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 +1035,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" && transportPath === nativeRpcPath + ? 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 +1086,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 +1105,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 +1167,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 +1250,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 +1524,7 @@ 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 +1704,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 +2125,102 @@ const runRehearsal = async ( guard.addHttp(dashboardOrigin, "dashboard-loopback"); const cookieHeader = `${SPEC_0067.sessionCookieName}=${sessionCookie}`; - const fetchObservation = async ( + const native = makeScriptClient(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 +2235,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 +2263,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 +2408,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.test.ts b/tools/verification/organization-import-rehearsal.test.ts index cd649797..1d8ca54d 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 5c0e469e..342bf97c 100644 --- a/tools/verification/organization-import-rehearsal.ts +++ b/tools/verification/organization-import-rehearsal.ts @@ -38,14 +38,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: [ { From b8e3f0368eb1e6415bd9979323020c26cd530d1b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:37:29 +0000 Subject: [PATCH 025/148] test(e2e): read sessions and profiles over RPC in the other journeys Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/dashboard-list-type-boundary.spec.ts | 134 ++++++++---------- .../e2e/golden-reimbursement-browser.mjs | 15 +- .../e2e/golden-team-application-browser.ts | 15 +- apps/dashboard/e2e/native-operations.ts | 9 +- ...native-organization-administration.spec.ts | 28 ++-- ...native-recruitment-session-journey.spec.ts | 24 +++- apps/dashboard/e2e/receipt-approval.spec.ts | 44 ++++-- apps/dashboard/e2e/receipts.spec.ts | 21 +-- .../run-real-native-receipt-settlement.mjs | 21 ++- tools/e2e/legacy-candidate-native-journey.ts | 74 ++++++---- 10 files changed, 238 insertions(+), 147 deletions(-) diff --git a/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts b/apps/dashboard/e2e/dashboard-list-type-boundary.spec.ts index b6ad62b6..40a47e5c 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/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index 5993725d..e712be97 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -5,7 +5,8 @@ import { writeFile } from "node:fs/promises"; import { chromium, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { Predicate } from "effect"; -import { RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/rpc"; +import { nativeRpcPath, RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/rpc"; +import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.ts"; import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.mjs"; import { sha256 } from "../../../tools/e2e/golden-school-service-evidence.mjs"; @@ -73,7 +74,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 }); diff --git a/apps/dashboard/e2e/golden-team-application-browser.ts b/apps/dashboard/e2e/golden-team-application-browser.ts index 78f9c867..4cd450ef 100644 --- a/apps/dashboard/e2e/golden-team-application-browser.ts +++ b/apps/dashboard/e2e/golden-team-application-browser.ts @@ -14,8 +14,9 @@ import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import AxeBuilder from "@axe-core/playwright"; import { chromium, expect as baseExpect, type BrowserContext, type Page } from "@playwright/test"; -import { OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; +import { nativeRpcPath, OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; import { Schema } from "effect"; +import { nativeRpcRequestBody, nativeRpcStatus, 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 }); @@ -370,7 +371,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 }); diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 0f519405..30c04de2 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -8,7 +8,7 @@ * such routes by name. */ import { NativeProblem, NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; -import { Array as Arr, Match, Option, Schema } from "effect"; +import { Array as Arr, Match, Option, Predicate, Schema } from "effect"; /** One RPC request message, as the JSON serialization of the RPC client sends it over HTTP. */ const RpcRequestMessage = Schema.fromJsonString( @@ -94,6 +94,13 @@ export const nativeRpcOutcome = (body: string): NativeRpcOutcome | undefined => ); }; +/** 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); diff --git a/apps/dashboard/e2e/native-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 669f233a..8e8e9afa 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -2,7 +2,8 @@ import { Schema } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { IdempotencyKey } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.js"; import { createPromiseClient } from "@vektorprogrammet/sdk"; import { expect, @@ -82,20 +83,25 @@ const authenticate = async ( const cookie = sessionCookies.map(({ name, value }) => `${name}=${value}`).join("; "); - const sessionResponse = await request.get(`${API_ORIGIN}/api/session`, { - headers: { Cookie: cookie }, - }); + // One RPC as a browser sends it, with the session cookie and the dashboard origin. + const readNative = async (tag: string) => + ( + await request.post(`${API_ORIGIN}${nativeRpcPath}`, { + headers: { Cookie: cookie, Origin: DASHBOARD_ORIGIN, "content-type": "application/json" }, + data: nativeRpcRequestBody(tag), + }) + ).text(); - expect(sessionResponse.status()).toBe(200); - expect(await responseBody(sessionResponse)).toMatchObject({ current: true }); + const session = await readNative("system.readSession"); - const profileResponse = await request.get(`${API_ORIGIN}/api/profile`, { - headers: { Cookie: cookie }, - }); + expect(nativeRpcStatus(session)).toBe(200); + expect(nativeRpcValue(session)).toMatchObject({ current: true }); + + const profile = await readNative("profile.readOwnProfile"); - expect(profileResponse.status()).toBe(200); + expect(nativeRpcStatus(profile)).toBe(200); const expectedPersonId = requiredEnvironment(personIdEnvironment); - expect(await responseBody(profileResponse)).toMatchObject({ personId: expectedPersonId }); + expect(nativeRpcValue(profile)).toMatchObject({ profile: { personId: expectedPersonId } }); return { cookie, diff --git a/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts b/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts index d2af0df1..8333e548 100644 --- a/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts @@ -3,6 +3,8 @@ import AxeBuilder from "@axe-core/playwright"; 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$/); diff --git a/apps/dashboard/e2e/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index 1cbddd7f..619be1c5 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -21,8 +21,15 @@ import { ReceiptListResponse, ReceiptResource, } from "@vektorprogrammet/rpc"; -import { Schema } from "effect"; +import { Predicate, Schema } from "effect"; import { z } from "zod"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.js"; import { addressesAnyRoute, addressesRoute, legacyRoutes } from "./request-routes.js"; const execFileAsync = promisify(execFile); @@ -538,34 +545,43 @@ 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(); - expect(sessionResponse.status()).toBe(200); + const sessionAnswer = await readNative("system.readSession"); + + 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 { diff --git a/apps/dashboard/e2e/receipts.spec.ts b/apps/dashboard/e2e/receipts.spec.ts index 3b8a86e0..8c51316f 100644 --- a/apps/dashboard/e2e/receipts.spec.ts +++ b/apps/dashboard/e2e/receipts.spec.ts @@ -18,8 +18,10 @@ import { ReceiptLifecycleEvidenceResponse, ReceiptListResponse, ReceiptResource, - UserProfileResponse, + nativeRpcPath, + OwnProfileResource, } from "@vektorprogrammet/rpc"; +import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.js"; import { dashboardBaseUrl, dashboardMount } from "../dashboard-base"; type JourneyOutcome = Data.TaggedEnum<{ @@ -83,7 +85,7 @@ 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 +200,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; diff --git a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index 8407b735..5899a1a3 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -16,7 +16,12 @@ import { createPromiseClient } from "@vektorprogrammet/sdk"; import { dashboardMount } from "../dashboard-base.ts"; import { journeyClock } from "../../../tools/e2e/journey-clock.ts"; -import { isNativeRequest } from "./native-operations.ts"; +import { + isNativeRequest, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -636,12 +641,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, diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index dd51dbe7..9e9e228c 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -20,8 +20,9 @@ import { OwnAffiliationResource, PlacementBoardResource, PlacementScopes, + nativeRpcPath, + OwnProfileResource, ReceiptListResponse, - UserProfileResponse, } from "@vektorprogrammet/rpc"; import { Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; import { Etag, FetchHttpClient, HttpRouter } from "effect/unstable/http"; @@ -29,10 +30,14 @@ 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 { @@ -128,12 +133,10 @@ export const observeLegacyCandidateNativeJourney = async ( platform, BunHttpPlatform.layer, Etag.layer, - HttpRouter.layer.pipe( - Layer.provide(Layer.succeed(HttpRouter.RouterConfig)(nativeHttpRouterConfig)), - ), + 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), @@ -196,6 +199,40 @@ export const observeLegacyCandidateNativeJourney = async ( return result; }; + /** 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) }; + }; + + 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); + }; + const signIn = async (label: string, identity: CandidateNativeIdentity) => { phase = `${label}-native-sign-in`; @@ -232,12 +269,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,13 +280,8 @@ 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( @@ -435,12 +462,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); From f91482393f7911c50f8f01008306b6cb54b9ac13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:38:26 +0000 Subject: [PATCH 026/148] feat(dashboard): read the own profile over RPC in the dashboard loaders Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/app/routes/dashboard._index.tsx | 13 +++++-------- .../app/routes/dashboard.intervjuer._index.tsx | 9 ++++----- .../app/routes/dashboard.sokere._index.tsx | 9 ++++----- apps/dashboard/app/routes/dashboard_.foldkit.tsx | 10 ++++------ 4 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/app/routes/dashboard._index.tsx b/apps/dashboard/app/routes/dashboard._index.tsx index d880a7b0..57969fff 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.intervjuer._index.tsx b/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx index 5770a850..8a4e1506 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, createAuthenticatedClient } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.intervjuer._index"; @@ -26,10 +26,9 @@ export async function loader({ request }: Route.LoaderArgs) { 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); } diff --git a/apps/dashboard/app/routes/dashboard.sokere._index.tsx b/apps/dashboard/app/routes/dashboard.sokere._index.tsx index e4a6da99..753f2db6 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, createAuthenticatedClient } from "../lib/api.server"; import { expiredSessionRedirect, requireAuth } from "../lib/auth.server"; import type { Route } from "./+types/dashboard.sokere._index"; @@ -26,10 +26,9 @@ export async function loader({ request }: Route.LoaderArgs) { 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); } diff --git a/apps/dashboard/app/routes/dashboard_.foldkit.tsx b/apps/dashboard/app/routes/dashboard_.foldkit.tsx index cb3c6cc5..05bd7058 100644 --- a/apps/dashboard/app/routes/dashboard_.foldkit.tsx +++ b/apps/dashboard/app/routes/dashboard_.foldkit.tsx @@ -4,7 +4,7 @@ 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; From 6dffa5e4de8caf855d82ab5465e60071a24b3707 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:39:05 +0000 Subject: [PATCH 027/148] docs(rpc): record the session, profile, and contact port Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/rpc-only.md | 59 +++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index bcb81fb8..b9a6d5ed 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -133,7 +133,19 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `contact.submitContactMessage` | POST `/api/contact-messages` | contact ip header | contactBackend | todo | +| `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`) @@ -208,8 +220,23 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `profile.readOwnProfile` | GET `/api/profile` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | -| `profile.updateOwnProfile` | PATCH `/api/profile` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | +| `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`) @@ -262,13 +289,25 @@ with the reason in Missing capabilities. | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `system.deleteOwnedSession` | DELETE `/api/sessions/{sessionId}` | idempotencyKey | cookieHeader | todo | -| `system.deleteSession` | DELETE `/api/session` | idempotencyKey | cookieHeader | todo | -| `system.health` | GET `/health` | - | none | todo | -| `system.listSessions` | GET `/api/sessions` | - | cookieHeader | todo | -| `system.readSession` | GET `/api/session` | - | cookieHeader | todo | -| `system.revokeAllSessions` | POST `/api/sessions:revoke-all` | idempotencyKey | cookieHeader | todo | -| `system.revokeOtherSessions` | POST `/api/sessions:revoke-others` | idempotencyKey | cookieHeader | todo | +| `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`) From c9264b79c913585362cec91c142ad0f56eb8ed9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:07:21 +0000 Subject: [PATCH 028/148] chore(lint): make every Effect language-service rule an error Enable all five @effect/tsgo presets (correctness, antipattern, style, effectNative, recommended) as errors everywhere. apps/homepage and apps/dashboard keep their previous set: recommended and correctness without effectNative. The guard test now enumerates every rule the package ships, so a rule or category added by an upgrade is an error at once, and it accepts the React applications' override as the only relaxation. Measured on 6979637, the newly enabled rules report 3,475 findings outside the React applications, 323 of them in files the RPC migration deletes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 45 ++++++++--- tools/conventions/tests/oxlint-groups.test.ts | 79 +++++++++++++------ 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index cafb544b..d2820164 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/rpc/**", -]; +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 = { @@ -250,7 +269,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", @@ -281,7 +299,6 @@ export default defineConfig({ }, overrides: [ ...expandedEffectConfig.overrides, - { files: coreEffectFiles, rules: presetRules([effectNative], "error") }, { files: ["apps/*/src/**", "apps/dashboard/app/**", "packages/*/src/**"], rules: { @@ -421,6 +438,8 @@ export default defineConfig({ globals: { Bun: "readonly", HTMLRewriter: "readonly" }, rules: { "no-undef": "error" }, }, + // 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/tools/conventions/tests/oxlint-groups.test.ts b/tools/conventions/tests/oxlint-groups.test.ts index 792962bb..901fb8f1 100644 --- a/tools/conventions/tests/oxlint-groups.test.ts +++ b/tools/conventions/tests/oxlint-groups.test.ts @@ -1,6 +1,6 @@ // 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 config from "../../../oxlint.config.ts"; @@ -57,14 +57,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,38 +124,67 @@ 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", () => { + for (const path of [ + "packages/domain/src/probe.ts", + "packages/database/src/probe.test.ts", + "packages/rpc/src/probe.ts", + "apps/backend/src/probe.ts", + "apps/docs/src/probe.tsx", + "tools/e2e/probe.ts", + "tools/acceptance/probe.ts", + "tools/conventions/src/probe.ts", + ]) { + expect({ path, notErrors: notErrorsAt(path) }).toEqual({ path, notErrors: [] }); + } }); - test("are relaxed by no override", () => { + 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 by no override but the React applications'", () => { const relaxed = overrides.flatMap((override) => Object.entries(override.rules ?? {}).some( ([rule, setting]) => @@ -166,6 +195,6 @@ describe("Effect language-service rules", () => { : [], ); - expect(relaxed).toEqual([]); + expect(relaxed).toEqual(["apps/homepage/**", "apps/dashboard/**"]); }); }); From 2252fbdab220a7d9c8fd23822812f4d59d8cdedd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:12:18 +0000 Subject: [PATCH 029/148] fix(rpc): keep ingress facts out of reach of RPC message headers The RPC server lays each message's headers over the HTTP request headers, so a caller could forge the source address, user agent, or request correlation that session revocation audit and the contact quota read. The ingress now keeps only the headers a caller may supply inside a message (cookie, authorization, and the two contact headers the contact RPC reads behind its secret) on both the external and the internal RPC paths. A test at the ingress failed before the change (cf-connecting-ip reached the handler) and passes after it. The RPC path also answers preflight with a trailing slash, and both router builders take NativeRpcOptions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/main.ts | 4 +- apps/backend/src/router.ts | 362 ++++++++++++++++++---------- apps/backend/src/rpc/router.test.ts | 50 +++- apps/backend/src/test/native-rpc.ts | 2 +- 4 files changed, 281 insertions(+), 137 deletions(-) diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 2fe0e234..2596f9e1 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -152,7 +152,9 @@ const httpPlatformLayer = Layer.mergeAll(platformLayer, BunHttpPlatform.layer, E const httpLayer = Layer.merge(httpPlatformLayer, HttpRouter.layer); const nativeRpcLayer = ( - ingress === "external" ? ExternalNativeRpcRouterLive(config) : InternalNativeRpcRouterLive(config) + ingress === "external" + ? ExternalNativeRpcRouterLive({ config }) + : InternalNativeRpcRouterLive({ config }) ).pipe( HttpRouter.provideRequest(Layer.merge(backendServicesLayer, platformLayer)), Layer.provide(backendServicesLayer), diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index e1b9fd1e..469bb868 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -1,5 +1,6 @@ import { BlockList, isIP } from "node:net"; 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 { InternalNativeRpcs, @@ -8,12 +9,12 @@ import { nativeRpcPath, } from "@vektorprogrammet/rpc"; import { Problem, problemBody, problemHeaders } from "@vektorprogrammet/rpc/problem"; -import { Effect, Layer, Predicate, type Schema } from "effect"; +import { Effect, Layer, Option, Predicate, Schema } from "effect"; +import { dual } from "effect/Function"; import { HttpEffect, HttpRouter, HttpServerResponse } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { AdmissionsRpcHandlers } from "./admission/rpc.js"; import { AdmissionOutcomesRpcHandlers } from "./admission/outcome-rpc.js"; -import type { BackendConfig } from "./config.js"; import { ContactRpcHandlers } from "./contact/rpc.js"; import { ContentRpcHandlers } from "./content/rpc.js"; import { DirectoryRpcHandlers } from "./directory/rpc.js"; @@ -67,9 +68,84 @@ const methodNotAllowed = (methods: ReadonlyArray): Response => { return response; }; +/** Whether `pathname` addresses the RPC endpoint; the client may add a trailing slash. */ +const isNativeRpcPath = (pathname: string): boolean => + pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; + /** The HTTP methods of each native path: the RPC endpoint and the health probe. */ export const nativePreflightMethodsForPath = (pathname: string): ReadonlyArray => - pathname === nativeRpcPath ? ["POST"] : pathname === "/health" ? ["GET"] : []; + 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); + +const RpcJsonBody = Schema.fromJsonString(Schema.Json); + +/** + * Keeps only the caller message headers of every RPC request message in a JSON body, so the + * handlers see every other header from the HTTP request alone. A body that is not JSON passes + * unchanged: the RPC server rejects it. + */ +export const restrictRpcMessageHeaders = (body: string): string => { + const decoded = Schema.decodeOption(RpcJsonBody)(body); + + if (Option.isNone(decoded)) return body; + + 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())), + }), + }); + + const value = decoded.value; + + return JSON.stringify(Array.isArray(value) ? value.map(restrict) : restrict(value)); +}; + +const isInternalRpcPath = (pathname: string): boolean => + pathname === internalNativeRpcPath || pathname === `${internalNativeRpcPath}/`; + +/** The RPC request with its message headers restricted; any other request unchanged. */ +const restrictedNativeRequest = (request: Request, pathname: string) => + request.method !== "POST" || !(isNativeRpcPath(pathname) || isInternalRpcPath(pathname)) + ? Effect.succeed(request) + : Effect.promise(() => request.text()).pipe( + Effect.map((body) => { + const headers = new Headers(request.headers); + + headers.delete("content-length"); + + return new Request(request.url, { + method: request.method, + headers, + body: restrictRpcMessageHeaders(body), + }); + }), + ); /** * Answers one web request. A failure is either rendered as a response or dies, and the server @@ -129,11 +205,8 @@ const notFound = HttpRouter.use((router) => * 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 ExternalNativeRpcRouterLive = ( - config: BackendConfig, - options: BackendHttpOptions = {}, -) => { - const rpcOptions: NativeRpcOptions = { ...options, config }; +export const ExternalNativeRpcRouterLive = (rpcOptions: NativeRpcOptions) => { + const config = rpcOptions.config; const handlers = Layer.mergeAll( AdmissionOutcomesRpcHandlers(rpcOptions), @@ -168,16 +241,15 @@ export const ExternalNativeRpcRouterLive = ( }; /** The isolated internal RPC routes for an explicitly selected ingress. */ -export const InternalNativeRpcRouterLive = ( - config: BackendConfig, - options: BackendHttpOptions = {}, -) => { +export const InternalNativeRpcRouterLive = (rpcOptions: NativeRpcOptions) => { + const config = rpcOptions.config; + const rpcRoute = RpcServer.layerHttp({ group: InternalNativeRpcs, path: internalNativeRpcPath, protocol: "http", }).pipe( - Layer.provide(InternalReceiptsRpcHandlers({ ...options, config })), + Layer.provide(InternalReceiptsRpcHandlers(rpcOptions)), Layer.provide(nativeRpcCredentialLayer(config.contact)), Layer.provide(ProblemBoundaryLive), Layer.provide(RpcSerialization.layerJson), @@ -296,65 +368,68 @@ export const nativeRouterWebHandler = (router: HttpRouter.HttpRouter): BackendHt * 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), ); @@ -362,98 +437,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: nativePreflightMethodsForPath, - }); + 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* nativeHandler(yield* restrictedNativeRequest(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* nativeHandler(yield* restrictedNativeRequest(prepared.request, pathname)); + }).pipe(Effect.orDie); + }, +); diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts index 1655b7f4..ace3cc0d 100644 --- a/apps/backend/src/rpc/router.test.ts +++ b/apps/backend/src/rpc/router.test.ts @@ -3,14 +3,15 @@ import { Identity, IdentitySessionNotFound } from "@vektorprogrammet/domain/iden import { isProblem } from "@vektorprogrammet/rpc/problem"; import { DepartmentId } from "@vektorprogrammet/domain/organization"; import { SemesterId } from "@vektorprogrammet/domain"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Schema } from "effect"; import { RpcClient } from "effect/unstable/rpc"; import { backendTestConfig } from "../../test/config.js"; -import { makeBackendTestRpc } from "../test/native-rpc.js"; +import { backendHttpHandler } from "../router.js"; +import { 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(new IdentitySessionNotFound({})), + resolveSession: () => Effect.fail(IdentitySessionNotFound.make({})), }); const backend = makeBackendTestRpc(backendTestConfig, rejectingIdentity); @@ -81,3 +82,46 @@ it.effect("an RPC from an untrusted origin answers origin.denied before any hand expect(yield* Effect.promise(() => response.json())).toMatchObject({ code: "origin.denied" }); }), ); + +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/test/native-rpc.ts b/apps/backend/src/test/native-rpc.ts index 23ab40e9..43f13ae2 100644 --- a/apps/backend/src/test/native-rpc.ts +++ b/apps/backend/src/test/native-rpc.ts @@ -134,7 +134,7 @@ export const makeBackendTestRpc = ( authHandler: BackendAuthHandler = testAuthHandler, options: BackendHttpOptions = {}, ) => { - const routerLayer = ExternalNativeRpcRouterLive(config, options).pipe( + const routerLayer = ExternalNativeRpcRouterLive({ ...options, config }).pipe( Layer.provideMerge(completeServices(services)), Layer.provideMerge(platform), ); From 6bc27880faacf45b5416ee7dd788183fec8a29be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:13:14 +0000 Subject: [PATCH 030/148] chore(lint): leave Effect.provide to the declared entry points strict-effect-provide cannot recognise an entry point; its documentation asks to disable it there. The exemption derives from effectConfig: files declared as composition roots or tests, and the backend test harness. The guard pins it as the only relaxation outside the React applications. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 13 +++++ tools/conventions/tests/oxlint-groups.test.ts | 53 +++++++++++++------ 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index d2820164..1076948d 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -256,6 +256,18 @@ 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", +]; + export default defineConfig({ ...expandedEffectConfig, extends: effectTsgoPresets, @@ -438,6 +450,7 @@ 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() }, ], diff --git a/tools/conventions/tests/oxlint-groups.test.ts b/tools/conventions/tests/oxlint-groups.test.ts index 901fb8f1..d5b08a2a 100644 --- a/tools/conventions/tests/oxlint-groups.test.ts +++ b/tools/conventions/tests/oxlint-groups.test.ts @@ -157,21 +157,37 @@ describe("Effect language-service rules", () => { ]); }); - test("are all errors outside the React applications", () => { + 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.test.ts", + "packages/database/src/probe.ts", "packages/rpc/src/probe.ts", "apps/backend/src/probe.ts", "apps/docs/src/probe.tsx", - "tools/e2e/probe.ts", - "tools/acceptance/probe.ts", + "tools/scripts/probe.ts", "tools/conventions/src/probe.ts", ]) { expect({ path, notErrors: notErrorsAt(path) }).toEqual({ path, notErrors: [] }); } }); + 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"], + }); + } + }); + test("keep the recommended and correctness rules in the React applications", () => { const off = shippedRules.filter((rule) => !reactApplicationRules.has(rule)); @@ -184,17 +200,24 @@ describe("Effect language-service rules", () => { } }); - test("are relaxed by no override but the React applications'", () => { - 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("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(relaxed).toEqual(["apps/homepage/**", "apps/dashboard/**"]); + 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/**"]); }); }); From 1038f7de25b83b8b5e224b7013ffb0750d77a7a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:20:37 +0000 Subject: [PATCH 031/148] chore(rpc): one options object for the test harness, and the lint policy makeBackendTestRpc takes a named options object with transportHeaders, the HTTP headers a browser sends, since the ingress keeps only credential and contact headers of an RPC message; the session audit test sends its user agent that way. The script client is nativeScriptClient, which is not a service constructor. The specification states how workers satisfy the full Effect rule set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../admission/admission-period.rpc.test.ts | 15 ++++----- apps/backend/src/admission/commands.ts | 6 +++- apps/backend/src/admission/context.ts | 5 ++- apps/backend/src/admission/outcome-rpc.ts | 8 ++++- .../backend/src/admission/outcome.rpc.test.ts | 15 +++++---- .../admission/submit-application.rpc.test.ts | 16 ++++++---- apps/backend/src/contact/rpc.test.ts | 16 +++++----- apps/backend/src/directory/rpc.test.ts | 2 +- .../backend/src/directory/schools.rpc.test.ts | 14 ++++----- apps/backend/src/index.ts | 2 +- apps/backend/src/organization/rpc.test.ts | 8 ++--- .../organization/team-interest.rpc.test.ts | 8 ++--- apps/backend/src/profile/rpc.test.ts | 23 ++++++++------ apps/backend/src/rpc/router.test.ts | 2 +- apps/backend/src/rpc/system.test.ts | 26 ++++++++++------ apps/backend/src/rpc/system.ts | 11 +++++-- apps/backend/src/test/native-rpc.ts | 31 ++++++++++++++----- .../e2e/admission-period-management.spec.ts | 4 +-- .../e2e/native-identity-browser.spec.ts | 4 +-- .../e2e/native-mailing-lists-journey.spec.ts | 4 +-- ...native-organization-administration.spec.ts | 4 +-- apps/dashboard/e2e/native-placement.spec.ts | 6 ++-- .../e2e/native-profile-self-edit.spec.ts | 4 +-- ...tive-recruitment-interview-conduct.spec.ts | 4 +-- .../e2e/native-schools-directory.spec.ts | 4 +-- apps/dashboard/e2e/native-substitutes.spec.ts | 6 ++-- .../e2e/native-team-interest-journey.spec.ts | 4 +-- .../e2e/public-applicant-admission.spec.ts | 4 +-- .../run-real-public-applicant-admission.mjs | 6 ++-- docs/specs/rpc-only.md | 22 ++++++++++++- packages/rpc/src/script-client.ts | 2 +- tools/acceptance/applicant-progress-0107.ts | 4 +-- tools/acceptance/onboarding-check.ts | 4 +-- tools/acceptance/recommendation-check.ts | 4 +-- .../acceptance/returning-assistant-journey.ts | 4 +-- tools/acceptance/substitute-outcome-check.ts | 4 +-- tools/e2e/placement-check.ts | 4 +-- .../organization-import-rehearsal-main.ts | 4 +-- 38 files changed, 195 insertions(+), 119 deletions(-) diff --git a/apps/backend/src/admission/admission-period.rpc.test.ts b/apps/backend/src/admission/admission-period.rpc.test.ts index 3bf18eaf..19e52045 100644 --- a/apps/backend/src/admission/admission-period.rpc.test.ts +++ b/apps/backend/src/admission/admission-period.rpc.test.ts @@ -161,15 +161,15 @@ const fixture = () => const connection = () => DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); - const backend = makeBackendTestRpc( - 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())), - ); + }); const client = yield* backend.client; @@ -427,9 +427,10 @@ describe("admission period management over RPC and PostgreSQL", () => { Effect.gen(function* () { const { client, as } = yield* fixture(); - const denied = yield* as(client["admissions.listAdmissionPeriods"](), "periods-stranger").pipe( - Effect.flip, - ); + 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 index ffe463a1..c1eeb7ab 100644 --- a/apps/backend/src/admission/commands.ts +++ b/apps/backend/src/admission/commands.ts @@ -57,7 +57,11 @@ import { } 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 { + admissionGrantScopes, + authorizeAdmissionPerson, + returningAuthorization, +} from "./access.js"; import { admissionActorForAuthority } from "./context.js"; import { admissionProblems } from "./problem.js"; diff --git a/apps/backend/src/admission/context.ts b/apps/backend/src/admission/context.ts index 89738de9..a87739ec 100644 --- a/apps/backend/src/admission/context.ts +++ b/apps/backend/src/admission/context.ts @@ -5,7 +5,10 @@ import { InactiveActor, type AdmissionPeriodActor, } from "@vektorprogrammet/domain/admission-period"; -import { DepartmentId, type OrganizationPersonAuthority } from "@vektorprogrammet/domain/organization"; +import { + DepartmentId, + type OrganizationPersonAuthority, +} from "@vektorprogrammet/domain/organization"; import { Effect, Schema } from "effect"; import { admissionActorForDepartment, unscopedAdmissionActorFrom } from "../authority.js"; diff --git a/apps/backend/src/admission/outcome-rpc.ts b/apps/backend/src/admission/outcome-rpc.ts index 5f8bdeef..cbda3001 100644 --- a/apps/backend/src/admission/outcome-rpc.ts +++ b/apps/backend/src/admission/outcome-rpc.ts @@ -178,7 +178,13 @@ export const AdmissionOutcomesRpcHandlers = (options: NativeRpcOptions) => { ); for (const department of scopes.departments) - yield* authorize(headers, ListAdmissionOutcomeScopes, department.departmentId, false, auth); + yield* authorize( + headers, + ListAdmissionOutcomeScopes, + department.departmentId, + false, + auth, + ); return yield* strictOutput(AdmissionOutcomeScopes)(scopes); }), diff --git a/apps/backend/src/admission/outcome.rpc.test.ts b/apps/backend/src/admission/outcome.rpc.test.ts index 7f784912..6b54d25d 100644 --- a/apps/backend/src/admission/outcome.rpc.test.ts +++ b/apps/backend/src/admission/outcome.rpc.test.ts @@ -110,15 +110,15 @@ const fixture = () => Effect.gen(function* () { const database = backendDatabase(seed); - const backend = makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( + 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; @@ -143,7 +143,8 @@ const fixture = () => 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"); +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", () => @@ -151,7 +152,9 @@ describe("admission outcomes over RPC and PostgreSQL", () => { const { client, as, history } = yield* fixture(); const scopes = yield* as(client["admissionOutcomes.listScopes"](), leaderA); - expect(scopes.departments.map((department) => department.departmentId)).toEqual([departmentA]); + expect(scopes.departments.map((department) => department.departmentId)).toEqual([ + departmentA, + ]); const board = yield* as( client["admissionOutcomes.readOutcomes"]({ departmentId: departmentA, semesterId }), diff --git a/apps/backend/src/admission/submit-application.rpc.test.ts b/apps/backend/src/admission/submit-application.rpc.test.ts index 958f1d49..92de43f7 100644 --- a/apps/backend/src/admission/submit-application.rpc.test.ts +++ b/apps/backend/src/admission/submit-application.rpc.test.ts @@ -7,7 +7,10 @@ import { PublicApplicationIdSchema, PublicApplicationSubmitInputSchema, } from "@vektorprogrammet/domain/application"; -import { PublicApplicationConfirmationSchema, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; +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"; @@ -72,10 +75,10 @@ const fixture = () => Effect.gen(function* () { const database = backendDatabase(openPeriod); - const backend = makeBackendTestRpc( - yield* decodeBackendConfig(environment), - Layer.mergeAll(database.layer, AdmissionsLive.pipe(Layer.provide(database.layer))), - ); + const backend = makeBackendTestRpc({ + config: yield* decodeBackendConfig(environment), + services: Layer.mergeAll(database.layer, AdmissionsLive.pipe(Layer.provide(database.layer))), + }); const client = yield* backend.client; @@ -116,7 +119,8 @@ const fixture = () => }); /** The problem code of a failed call; a transport failure has none. */ -const codeOf = (failure: Problem | RpcClientError) => (isProblem(failure) ? failure.code : "defect"); +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", () => diff --git a/apps/backend/src/contact/rpc.test.ts b/apps/backend/src/contact/rpc.test.ts index d3e443d4..89228df8 100644 --- a/apps/backend/src/contact/rpc.test.ts +++ b/apps/backend/src/contact/rpc.test.ts @@ -40,10 +40,10 @@ const organization = Layer.mock(Organization, { const contactIngress = (contact: typeof config | undefined) => { const database = backendDatabase(); - const backend = makeBackendTestRpc( - { ...backendTestConfig, contact }, - Layer.mergeAll(database.layer, organization), - ); + const backend = makeBackendTestRpc({ + config: { ...backendTestConfig, contact }, + services: Layer.mergeAll(database.layer, organization), + }); return { backend, @@ -67,7 +67,9 @@ const contactIngress = (contact: typeof config | undefined) => { database.run( Database.use( (sql) => - sql<{ count: number }>`SELECT count(*)::integer AS count FROM public.contact_rate_windows`, + sql<{ + count: number; + }>`SELECT count(*)::integer AS count FROM public.contact_rate_windows`, ), ), }; @@ -117,9 +119,7 @@ describe("contact.submitContactMessage", () => { 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.submit({ ip, token: config.backendToken })).toBe("header.malformed"); } expect(yield* ingress.consumedWindows()).toEqual([{ count: 0 }]); diff --git a/apps/backend/src/directory/rpc.test.ts b/apps/backend/src/directory/rpc.test.ts index 23cae366..bb7c9454 100644 --- a/apps/backend/src/directory/rpc.test.ts +++ b/apps/backend/src/directory/rpc.test.ts @@ -359,7 +359,7 @@ const backendServices = Layer.mergeAll( Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), ); -const backend = makeBackendTestRpc(backendTestConfig, backendServices); +const backend = makeBackendTestRpc({ config: backendTestConfig, services: backendServices }); const listPeople = Effect.gen(function* () { const client = yield* backend.client; diff --git a/apps/backend/src/directory/schools.rpc.test.ts b/apps/backend/src/directory/schools.rpc.test.ts index 20ec6b21..48cac65f 100644 --- a/apps/backend/src/directory/schools.rpc.test.ts +++ b/apps/backend/src/directory/schools.rpc.test.ts @@ -122,9 +122,9 @@ const makeBackend = ( }), ), ) => - makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( + makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( database.layer, Layer.mock(Organization, { resolvePersonAuthorityForRead: () => Effect.succeed(authority), @@ -142,10 +142,10 @@ const makeBackend = ( Layer.succeed(Identity, identity), Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), ), - testAuthHandler, - // The composition pins the authorization instant that the projection was evaluated at. - { now: () => instant }, - ); + authHandler: testAuthHandler, + options: // The composition pins the authorization instant that the projection was evaluated at. + { now: () => instant }, + }); const listSchools = ( backend: ReturnType, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 9bfcda28..0cca15e9 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -12,4 +12,4 @@ export * from "./receipt/r2.js"; export * from "./organization/config.js"; -export * from "./recruitment/config.js"; \ No newline at end of file +export * from "./recruitment/config.js"; diff --git a/apps/backend/src/organization/rpc.test.ts b/apps/backend/src/organization/rpc.test.ts index 7919761e..e8fcc9f5 100644 --- a/apps/backend/src/organization/rpc.test.ts +++ b/apps/backend/src/organization/rpc.test.ts @@ -233,16 +233,16 @@ const identity = Identity.of({ const database = backendDatabase(); -const backend = makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( +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) => diff --git a/apps/backend/src/organization/team-interest.rpc.test.ts b/apps/backend/src/organization/team-interest.rpc.test.ts index 977b74de..5049542b 100644 --- a/apps/backend/src/organization/team-interest.rpc.test.ts +++ b/apps/backend/src/organization/team-interest.rpc.test.ts @@ -291,14 +291,14 @@ const identity = Identity.of({ signOut: () => Effect.succeed({ setCookies: [] }), } satisfies IdentityOperations); -const backend = makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( +const backend = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( Layer.mock(Organization, organization), Layer.succeed(Identity, identity), Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), ), -); +}); const asSession = (token: string) => diff --git a/apps/backend/src/profile/rpc.test.ts b/apps/backend/src/profile/rpc.test.ts index ca67a351..04780eaf 100644 --- a/apps/backend/src/profile/rpc.test.ts +++ b/apps/backend/src/profile/rpc.test.ts @@ -74,9 +74,13 @@ const securityServices = Layer.mergeAll( Layer.succeed(Identity, identity), Layer.mock(IdentitySnapshot, { resolveSession: (cookieHeader) => - identity.resolveSession(cookieHeader).pipe( - Effect.catchTag("IdentitySessionExpired", () => Effect.fail(new IdentitySessionNotFound())), - ), + identity + .resolveSession(cookieHeader) + .pipe( + Effect.catchTag("IdentitySessionExpired", () => + Effect.fail(new IdentitySessionNotFound()), + ), + ), }), Layer.succeed( OAuthCredentialAuthority, @@ -104,15 +108,15 @@ const backendFor = ( const database = backendDatabase(seedProfile(representationRevision)); const organization = Layer.mock(Organization, { resolvePersonAuthority: () => resolved }); - return makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( + 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); @@ -159,8 +163,9 @@ describe("profile.readOwnProfile", () => { 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)), + Effect.flatMap( + backendFor(Effect.succeed(resolved), representationRevision).client, + (client) => client["profile.readOwnProfile"]().pipe(RpcClient.withHeaders(cookie)), ); const leader = authority("Absent", [{ active: true, unitLeader: true }]); diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts index ace3cc0d..965e177c 100644 --- a/apps/backend/src/rpc/router.test.ts +++ b/apps/backend/src/rpc/router.test.ts @@ -14,7 +14,7 @@ const rejectingIdentity = Layer.mock(Identity, { resolveSession: () => Effect.fail(IdentitySessionNotFound.make({})), }); -const backend = makeBackendTestRpc(backendTestConfig, rejectingIdentity); +const backend = makeBackendTestRpc({ config: backendTestConfig, services: rejectingIdentity }); it.effect("an RPC without a credential answers credential.missing from the middleware", () => Effect.gen(function* () { diff --git a/apps/backend/src/rpc/system.test.ts b/apps/backend/src/rpc/system.test.ts index 145a9771..b0b56e10 100644 --- a/apps/backend/src/rpc/system.test.ts +++ b/apps/backend/src/rpc/system.test.ts @@ -63,9 +63,13 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked const snapshot = IdentitySnapshot.of({ resolveSession: (cookieHeader) => - identity.resolveSession(cookieHeader).pipe( - Effect.catchTag("IdentitySessionExpired", () => Effect.fail(new IdentitySessionNotFound())), - ), + identity + .resolveSession(cookieHeader) + .pipe( + Effect.catchTag("IdentitySessionExpired", () => + Effect.fail(new IdentitySessionNotFound()), + ), + ), revokeCurrentSession: (_actor, request) => Effect.sync(() => { recorded.revoked.push({ operation: "current", request }); @@ -74,7 +78,9 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked }), revokeSession: (_actor, sessionId, request) => identity.revokeSession(undefined, sessionId, request).pipe( - Effect.tap(() => Effect.sync(() => recorded.revoked.push({ operation: sessionId, request }))), + Effect.tap(() => + Effect.sync(() => recorded.revoked.push({ operation: sessionId, request })), + ), Effect.catchTag(["IdentitySessionNotFound", "IdentitySessionExpired"], () => Effect.fail(new IdentityOwnedSessionNotFound({ sessionId })), ), @@ -93,9 +99,9 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked }), }); - return makeBackendTestRpc( - backendTestConfig, - Layer.mergeAll( + return makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( database.layer, Layer.succeed(Identity, identity), Layer.succeed(IdentitySnapshot, snapshot), @@ -107,7 +113,9 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked }), ), ), - ); + // 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` }; @@ -121,7 +129,7 @@ describe("session RPCs", () => { Effect.gen(function* () { const recorded: Recorded = { revoked: [] }; const client = yield* backendFor(identityOperations(), recorded).client; - const as = RpcClient.withHeaders({ ...cookie, "user-agent": "session-rpc-test" }); + const as = RpcClient.withHeaders(cookie); expect(yield* client["system.readSession"]().pipe(as)).toEqual({ sessionId: "session-1", diff --git a/apps/backend/src/rpc/system.ts b/apps/backend/src/rpc/system.ts index 35bfbe05..b4927853 100644 --- a/apps/backend/src/rpc/system.ts +++ b/apps/backend/src/rpc/system.ts @@ -6,7 +6,11 @@ * 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 { + IdentitySnapshot, + type Database, + type IdentitySnapshotService, +} from "@vektorprogrammet/database"; import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; import { Scope } from "@vektorprogrammet/domain/authz"; import { @@ -293,7 +297,10 @@ export const SystemRpcHandlers = (options: NativeRpcOptions) => normalizedTarget: "/api/session", idempotencyKey, mutate: (identity, actor) => - identity.revokeCurrentSession(actor, identityRequestContext(credentialRequestOf(headers))), + 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"), diff --git a/apps/backend/src/test/native-rpc.ts b/apps/backend/src/test/native-rpc.ts index 43f13ae2..70685cc3 100644 --- a/apps/backend/src/test/native-rpc.ts +++ b/apps/backend/src/test/native-rpc.ts @@ -54,7 +54,7 @@ export type TestServiceLayer = Layer.Layer; const unavailableIdentity = () => Effect.fail( - new IdentityEngineError({ operation: "test", message: "Unexpected identity operation" }), + IdentityEngineError.make({ operation: "test", message: "Unexpected identity operation" }), ); const unimplementedServices = Layer.mergeAll( @@ -122,18 +122,33 @@ const serveEachRequest = ({ 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. */ -export const makeBackendTestRpc = ( - config: BackendConfig, - services: TestServiceLayer, - authHandler: BackendAuthHandler = testAuthHandler, - options: BackendHttpOptions = {}, -) => { +export const makeBackendTestRpc = ({ + config, + services, + authHandler = testAuthHandler, + options = {}, + transportHeaders = {}, +}: BackendTestRpcOptions) => { const routerLayer = ExternalNativeRpcRouterLive({ ...options, config }).pipe( Layer.provideMerge(completeServices(services)), Layer.provideMerge(platform), @@ -151,7 +166,7 @@ export const makeBackendTestRpc = ( Effect.gen(function* () { const web = new Request(new URL(nativeRpcPath, "http://native-rpc.test"), { method: request.method, - headers: { ...request.headers, origin }, + headers: { ...request.headers, ...transportHeaders, origin }, body: Predicate.isTagged(request.body, "Uint8Array") ? request.body.body : undefined, }); diff --git a/apps/dashboard/e2e/admission-period-management.spec.ts b/apps/dashboard/e2e/admission-period-management.spec.ts index 73d967f1..b84bd6c5 100644 --- a/apps/dashboard/e2e/admission-period-management.spec.ts +++ b/apps/dashboard/e2e/admission-period-management.spec.ts @@ -13,7 +13,7 @@ import { NativeProblemRegistry, type StrongETag, } from "@vektorprogrammet/rpc/problem"; -import { makeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { DateTime, Schema } from "effect"; import { dashboardMount, dashboardPagePath } from "../dashboard-base"; @@ -155,7 +155,7 @@ function expectValue(answer: ScriptCallResult): A { } const nativeApi = (backendOrigin: string, dashboardOrigin: string) => { - const native = makeScriptClient(backendOrigin); + const native = nativeScriptClient(backendOrigin); /** The headers of one call: the person's session, sent from the dashboard origin. */ const as = (session?: string): Readonly> => diff --git a/apps/dashboard/e2e/native-identity-browser.spec.ts b/apps/dashboard/e2e/native-identity-browser.spec.ts index 78c8c788..d9ebc494 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -1,5 +1,5 @@ import { IdempotencyKey, nativeRpcPath, type NativeRpcClient, type SessionListResponse } from "@vektorprogrammet/rpc"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Effect, Predicate, Schema } from "effect"; import assert from "node:assert/strict"; import { randomBytes } from "node:crypto"; @@ -279,7 +279,7 @@ const signInContext = async ( expect(response.status()).toBe(200); }; -const nativeScript = makeScriptClient(apiOrigin === "" ? "http://native.invalid" : apiOrigin); +const nativeScript = nativeScriptClient(apiOrigin === "" ? "http://native.invalid" : apiOrigin); type NativeClient = NativeRpcClient["Service"]; diff --git a/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts b/apps/dashboard/e2e/native-mailing-lists-journey.spec.ts index 8b4a9b3b..afb02324 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 { DepartmentId, SemesterId } from "@vektorprogrammet/domain"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { expect, test, type Page } from "@playwright/test"; const nativeIdentityMode = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -23,7 +23,7 @@ const trondheim = DepartmentId.make("department-0059-trondheim"); const bergen = DepartmentId.make("department-0059-bergen"); -const native = makeScriptClient(apiOrigin); +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) => ({ diff --git a/apps/dashboard/e2e/native-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index c00be589..80dd0d36 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { DepartmentId } from "@vektorprogrammet/domain"; import { IdempotencyKey, nativeRpcPath } from "@vektorprogrammet/rpc"; -import { makeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +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"; @@ -23,7 +23,7 @@ const requiredEnvironment = (name: string): string => { return value; }; -const native = makeScriptClient(API_ORIGIN); +const native = nativeScriptClient(API_ORIGIN); /** The JSON evidence of one RPC answer: its value, or its problem body. */ const answerBody = (result: ScriptCallResult): Schema.Json => { diff --git a/apps/dashboard/e2e/native-placement.spec.ts b/apps/dashboard/e2e/native-placement.spec.ts index 32a85193..98a3ee5b 100644 --- a/apps/dashboard/e2e/native-placement.spec.ts +++ b/apps/dashboard/e2e/native-placement.spec.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { expect, test, type BrowserContext, type Page, type Locator } from "@playwright/test"; import { PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Order } from "effect"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -120,11 +120,11 @@ const readOwnCoverage = async (page: Page) => { return response.json(); }; -let outcomeClient: ReturnType | undefined; +let outcomeClient: ReturnType | undefined; /** The native RPC client of the admission-outcome calls, made once the manifest names the backend. */ const outcomes = () => { - outcomeClient ??= makeScriptClient(manifest.backendOrigin); + outcomeClient ??= nativeScriptClient(manifest.backendOrigin); return outcomeClient; }; diff --git a/apps/dashboard/e2e/native-profile-self-edit.spec.ts b/apps/dashboard/e2e/native-profile-self-edit.spec.ts index 5af8821c..0f3e2e23 100644 --- a/apps/dashboard/e2e/native-profile-self-edit.spec.ts +++ b/apps/dashboard/e2e/native-profile-self-edit.spec.ts @@ -1,5 +1,5 @@ import { IdempotencyKey, type NativeRpcClient, StrongETag } from "@vektorprogrammet/rpc"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { type Effect, Predicate, Schema } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { writeFile } from "node:fs/promises"; @@ -131,7 +131,7 @@ const assertAxe = async (page: Page, results: Record, state: str results[state] = blockingViolations.length; }; -const nativeScript = makeScriptClient(apiOrigin); +const nativeScript = nativeScriptClient(apiOrigin); type NativeClient = NativeRpcClient["Service"]; diff --git a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts index 6f220ade..1213abd3 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -1,7 +1,7 @@ import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/rpc"; import { RecruitmentBridgeFailure } from "../app/foldkit/recruitment/bridge"; import { ApplicantProgressResponseSchema } from "@vektorprogrammet/rpc"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Schema, Predicate } from "effect"; import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; @@ -309,7 +309,7 @@ test.describe("Native recruitment interview conduct (spec 0063)", () => { .map(({ name, value }) => `${name}=${value}`) .join("; "); - const progressClient = makeScriptClient(apiOrigin); + const progressClient = nativeScriptClient(apiOrigin); const applicantProgress = await progressClient.call( { cookie: applicantCookie, origin: dashboardOrigin }, diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index 751a61da..36149e56 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -4,7 +4,7 @@ import { expect, test, type Browser, type BrowserContext, type Page } from "@pla import { Option, Schema } from "effect"; import { DepartmentId } from "@vektorprogrammet/domain"; import { NativeProblem, nativeRpcPath } from "@vektorprogrammet/rpc"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -232,7 +232,7 @@ test.describe("Native Schools directory (spec 0061)", () => { administrator.page.getByRole("rowheader", { name: "Historisk Internasjonal" }), ).toBeVisible(); - const native = makeScriptClient(apiOrigin); + const native = nativeScriptClient(apiOrigin); const emptyDepartment = await native .call( diff --git a/apps/dashboard/e2e/native-substitutes.spec.ts b/apps/dashboard/e2e/native-substitutes.spec.ts index 553ca156..76587bfd 100644 --- a/apps/dashboard/e2e/native-substitutes.spec.ts +++ b/apps/dashboard/e2e/native-substitutes.spec.ts @@ -4,7 +4,7 @@ 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 { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; const manifestPath = process.env.SUBSTITUTE_JOURNEY_MANIFEST; @@ -46,11 +46,11 @@ const outcomeForm = (page: Page, name: string) => const onCallItems = (page: Page) => page.getByRole("list", { name: "Vikarer på vakt", exact: true }).getByRole("listitem"); -let native: ReturnType | undefined; +let native: ReturnType | undefined; /** The backend's native RPC client, made on first use once the manifest names the backend. */ const nativeClient = () => { - native ??= makeScriptClient(manifest.backendOrigin); + native ??= nativeScriptClient(manifest.backendOrigin); return native; }; diff --git a/apps/dashboard/e2e/native-team-interest-journey.spec.ts b/apps/dashboard/e2e/native-team-interest-journey.spec.ts index e64ea635..8c12b521 100644 --- a/apps/dashboard/e2e/native-team-interest-journey.spec.ts +++ b/apps/dashboard/e2e/native-team-interest-journey.spec.ts @@ -1,4 +1,4 @@ -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { expect, test, type Page } from "@playwright/test"; const nativeIdentityMode = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -73,7 +73,7 @@ test.describe("Native team-interest journey (spec 0059)", () => { test("native RPC gates anonymous callers", async ({ baseURL }) => { test.skip(!nativeIdentityMode, "requires the real native identity topology"); - const native = makeScriptClient(apiOrigin); + const native = nativeScriptClient(apiOrigin); try { const anonymous = await native.call( diff --git a/apps/homepage/e2e/public-applicant-admission.spec.ts b/apps/homepage/e2e/public-applicant-admission.spec.ts index ef4101d7..48dfacfe 100644 --- a/apps/homepage/e2e/public-applicant-admission.spec.ts +++ b/apps/homepage/e2e/public-applicant-admission.spec.ts @@ -8,7 +8,7 @@ import { SubmitApplicationRequest, } from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; -import { makeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { Schema, Struct } from "effect"; import { expect, @@ -143,7 +143,7 @@ function applicationInput(overrides: Partial = {}): Applicatio type SubmittedApplication = ApplicationInput & { readonly applicantId?: string }; /** The backend's native RPC client, one public call at a time. */ -const native = makeScriptClient(BACKEND_ORIGIN); +const native = nativeScriptClient(BACKEND_ORIGIN); /** * Submits one application under its own idempotency key unless a replay names one. The input diff --git a/apps/homepage/e2e/run-real-public-applicant-admission.mjs b/apps/homepage/e2e/run-real-public-applicant-admission.mjs index c6f36773..10d9ce2d 100644 --- a/apps/homepage/e2e/run-real-public-applicant-admission.mjs +++ b/apps/homepage/e2e/run-real-public-applicant-admission.mjs @@ -1,7 +1,7 @@ import { postgresProgram, reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { CreateAdmissionPeriodRequest, SubmitApplicationRequest } from "@vektorprogrammet/rpc"; import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { Predicate, Schema } from "effect"; import { randomBytes, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; @@ -394,7 +394,7 @@ async function signInLeader() { } async function createOpenPeriod(leaderCookie) { - const native = makeScriptClient(backendOrigin); + const native = nativeScriptClient(backendOrigin); try { const answer = await native.call({ cookie: leaderCookie, origin: staffOrigin }, (client) => @@ -647,7 +647,7 @@ function assertDurableEvidence(postgres, lifecycle, delivery, persistenceFailure * browser application's availability, so only the outage can reject it. */ async function exercisePostgresFailure(postgres, availability) { - const native = makeScriptClient(backendOrigin); + const native = nativeScriptClient(backendOrigin); try { const request = decodeStrict(SubmitApplicationRequest)({ diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index d70dd21f..32754cf9 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -62,13 +62,33 @@ dashboard bridge `apps/dashboard/app/routes/__foldkit.social-events.ts`. Copy 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 `makeScriptClient` 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: diff --git a/packages/rpc/src/script-client.ts b/packages/rpc/src/script-client.ts index 977cafc8..73e02b4f 100644 --- a/packages/rpc/src/script-client.ts +++ b/packages/rpc/src/script-client.ts @@ -28,7 +28,7 @@ export type ScriptCallResult = * registry status of that code, and a defect or transport failure answers `defect`. `dispose` * releases the client. */ -export const makeScriptClient = (origin: string) => { +export const nativeScriptClient = (origin: string) => { const runtime = ManagedRuntime.make(nativeRpcClientLayer(origin)); const call = ( diff --git a/tools/acceptance/applicant-progress-0107.ts b/tools/acceptance/applicant-progress-0107.ts index 46d1c813..787e8ded 100644 --- a/tools/acceptance/applicant-progress-0107.ts +++ b/tools/acceptance/applicant-progress-0107.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { inspect } from "node:util"; import { decodeApplicantProgressResponse } from "../../packages/domain/src/application/schema.js"; -import { makeScriptClient } from "../../packages/rpc/src/script-client.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"; @@ -349,7 +349,7 @@ export const runApplicantProgress0107 = async (input: { readonly errors: string[]; readonly audit: (page: Page) => Promise>; }) => { - const native = makeScriptClient(input.api); + const native = nativeScriptClient(input.api); /** Reads the progress as the person whose session `cookie` names, or anonymously. */ const readProgress = (cookie: string | null = input.cookie) => diff --git a/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index 7e353b5f..286a02e8 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -17,7 +17,7 @@ import { import { Predicate, Schema, Struct } from "effect"; import { SubmitApplicationRequest } from "../../packages/rpc/src/v2-schemas.js"; import { IdempotencyKey } from "../../packages/rpc/src/problem.js"; -import { makeScriptClient } from "../../packages/rpc/src/script-client.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; const root = new URL("../../", import.meta.url).pathname; @@ -294,7 +294,7 @@ try { const board = async () => expectStatus(await request(boardPath, leader), 200); // Public applications are served as an RPC; an applicant submits anonymously. - const applications = makeScriptClient(backendOrigin); + const applications = nativeScriptClient(backendOrigin); const submit = async (email: string, firstName: string) => { const request = Schema.decodeSync(SubmitApplicationRequest)({ diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index 4a3a9821..2d26dc00 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -65,7 +65,7 @@ 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 { PublicApplicationIdSchema } from "../../packages/domain/src/application/schema.js"; -import { makeScriptClient } from "../../packages/rpc/src/script-client.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; import { FetchHttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -2364,7 +2364,7 @@ try { assert.ok(!applicantObservation.includes("Kanskje")); // The public application confirmation is an anonymous RPC. - const confirmations = makeScriptClient(api); + const confirmations = nativeScriptClient(api); const applicationProjection = await confirmations.call({}, (client) => client["admissions.readApplicationConfirmation"]({ diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index e0f2d9f5..3c7943d0 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -1,7 +1,7 @@ import { type RecruitmentInvitationDeliveryResult } from "../../packages/database/src/recruitment/index.js"; import { nativeRpcPath } from "../../packages/rpc/src/api.js"; import { IdempotencyKey, NativeProblem } from "../../packages/rpc/src/problem.js"; -import { makeScriptClient } from "../../packages/rpc/src/script-client.js"; +import { nativeScriptClient } from "../../packages/rpc/src/script-client.js"; import { RecruitmentInterviewResource, ScheduleInterviewResponse, @@ -729,7 +729,7 @@ export const runReturningAssistantBrowserJourney = async ({ .map((cookie) => `${cookie.name}=${cookie.value}`) .join("; "); - const native = makeScriptClient(api); + const native = nativeScriptClient(api); /** The browser context's current session, sent from the dashboard origin. */ const sessionHeaders = async () => ({ diff --git a/tools/acceptance/substitute-outcome-check.ts b/tools/acceptance/substitute-outcome-check.ts index c2289718..439fa0e3 100644 --- a/tools/acceptance/substitute-outcome-check.ts +++ b/tools/acceptance/substitute-outcome-check.ts @@ -19,7 +19,7 @@ import { } 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 { makeScriptClient, type ScriptCallResult } from "../../packages/rpc/src/script-client.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 { @@ -175,7 +175,7 @@ try { member = await login(persons.member), other = await login(persons.otherDepartment); - const native = makeScriptClient(backendOrigin); + const native = nativeScriptClient(backendOrigin); /** Headers of one call: the dashboard origin, and the person's session when there is one. */ const as = (cookie?: string): Readonly> => diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index a1be5882..41efd483 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -8,7 +8,7 @@ import { SchoolServiceNotificationRequest, } from "@vektorprogrammet/domain/placements"; import { IdempotencyIfMatchHeaders, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; -import { makeScriptClient } from "@vektorprogrammet/rpc/script"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; /** 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"; @@ -1606,7 +1606,7 @@ try { const coverageApplicationId = "application-coverage-0111"; // Admission outcomes are served as RPCs; the leader calls them with the leader's session. - const outcomeClient = makeScriptClient(backendOrigin); + const outcomeClient = nativeScriptClient(backendOrigin); const leaderSession = { cookie: leader, origin: dashboardOrigin }; const outcomeApplicationId = PublicApplicationIdSchema.make(coverageApplicationId); diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index 778a751d..c51133f9 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 { addressesAnyRoute, legacyRoutes } from "../../apps/dashboard/e2e/request-routes.js"; import { nativeRpcPath } from "@vektorprogrammet/rpc"; -import { makeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; +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"; @@ -2125,7 +2125,7 @@ const runRehearsal = async ( guard.addHttp(dashboardOrigin, "dashboard-loopback"); const cookieHeader = `${SPEC_0067.sessionCookieName}=${sessionCookie}`; - const native = makeScriptClient(backendOrigin); + const native = nativeScriptClient(backendOrigin); const anonymousHeaders = { origin: dashboardOrigin }; const administratorHeaders = { cookie: cookieHeader, origin: dashboardOrigin }; From 286e330845ec57cc1a654ad575c6a293b1fab9d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:34:21 +0000 Subject: [PATCH 032/148] feat(rpc): serve placements and certificates as RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../src/placements/certificates-rpc.test.ts | 546 ++++++++++++++++++ .../src/placements/certificates-rpc.ts | 464 ++++++++++++++- apps/backend/src/placements/rpc.test.ts | 385 ++++++++++++ apps/backend/src/placements/rpc.ts | 515 ++++++++++++++++- packages/rpc/src/certificates.ts | 255 +++++++- packages/rpc/src/placements.ts | 264 ++++++++- 6 files changed, 2412 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/placements/certificates-rpc.test.ts create mode 100644 apps/backend/src/placements/rpc.test.ts diff --git a/apps/backend/src/placements/certificates-rpc.test.ts b/apps/backend/src/placements/certificates-rpc.test.ts new file mode 100644 index 00000000..51a507df --- /dev/null +++ b/apps/backend/src/placements/certificates-rpc.test.ts @@ -0,0 +1,546 @@ +/** + * 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 { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +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 { backendTestConfig } from "../../test/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 identity = Identity.of({ + signIn: () => Effect.die("unexpected sign-in"), + resolveSession: (cookie) => { + const person = sessionPerson(cookie); + + return person === undefined + ? Effect.fail(IdentitySessionNotFound.make({})) + : 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(IdentitySessionNotFound.make({})) + : 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"), +}); + +const department = DepartmentId.make("cert-http-trondheim"); + +const spring = SemesterId.make("cert-http-spring"); + +const person = { + leader: "cert-http-leader", + coordinator: "cert-http-coordinator", + outsider: "cert-http-outsider", + ada: "cert-http-ada", + unprintable: "cert-http-li", +} as const; + +/** + * One independent department with a Styret leader, a Skolekoordinering member who confirms days + * served by delegation, and a team member without either capability. Ada and Wei have accepted + * legacy totals from the spring at Lade; Wei's family name is written in a script that the + * certificate font does not cover. + */ +const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO person_profiles (person_id,first_name,last_name) VALUES + (${person.leader},'Siri','Styreleder'), (${person.coordinator},'Kari','Koordinator'), + (${person.outsider},'Ola','Utenfor'), (${person.ada},'Ada','Assistent'), + (${person.unprintable},'Wei','李')`; + yield* sql`INSERT INTO auth."user" (id,name,email,"emailVerified") + SELECT person_id, first_name, person_id || '@example.invalid', true + FROM person_profiles WHERE person_id LIKE 'cert-http-%'`; + yield* sql`INSERT INTO organization_departments + (department_id,name,short_name,email,city,independent) VALUES + (${department},'Trondheim','TRD','trd@example.invalid','Trondheim',true)`; + yield* sql`INSERT INTO organization_teams (team_id,department_id,name,kind,team_scope) VALUES + ('cert-http-styret',${department},'Styret','DepartmentBoard','HomeDepartment'), + ('cert-http-skolekoordinering',${department},'Skolekoordinering','Team','HomeDepartment'), + ('cert-http-it',${department},'IT','Team','HomeDepartment')`; + yield* sql`INSERT INTO organization_memberships + (membership_id,person_id,team_id,position_name,start_at,is_team_leader) VALUES + ('cert-http-m-leader',${person.leader},'cert-http-styret','Styreleder','2020-01-01',true), + ('cert-http-m-coordinator',${person.coordinator},'cert-http-skolekoordinering',NULL,'2020-01-01',false), + ('cert-http-m-outsider',${person.outsider},'cert-http-it',NULL,'2020-01-01',false)`; + yield* sql`INSERT INTO organization_delegations + (delegation_id,name,team_id,capability,area,area_department_id,holders,start_at) VALUES + (${`delegation-${"e".repeat(64)}`},'Dager tjenestegjort','cert-http-skolekoordinering', + 'placements.days-served','Department',${department},'AllMembers','2020-01-01T00:00:00Z')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id,start_at,end_at) VALUES + (${spring},'2026-01-01T00:00:00Z','2026-08-01T00:00:00Z')`; + yield* sql`INSERT INTO schools_directory_schools + (school_id,name,contact_person,email,phone,language,active) OVERRIDING SYSTEM VALUE VALUES + (9201,'Lade skole','Kontakt','lade@example.invalid','12345678','Norwegian',true)`; + yield* sql`INSERT INTO schools_directory_departments (school_id,department_id) VALUES + (9201,${department})`; + + const people = "b1".padStart(64, "0"); + const service = "b2".padStart(64, "0"); + + yield* sql`INSERT INTO person_cohort_snapshots + (snapshot_key,source_repository,snapshot_id,source_revision,transformation_revision,snapshot_digest,occurrence_count) + VALUES (${people},'cert-http-synthetic','cert-http-people','test','test',${people},2)`; + yield* sql`INSERT INTO person_cohort_occurrences (snapshot_key,occurrence_id,disposition,reason) VALUES + (${people},'cert-http-person-ada','Accepted','LinkedExistingPerson'), + (${people},'cert-http-person-li','Accepted','LinkedExistingPerson')`; + yield* sql`INSERT INTO person_cohort_imports + (source_repository,source_user_id,person_id,mapping_action,source_digest,evidence_ref,snapshot_key,occurrence_id) VALUES + ('cert-http-synthetic','cert-http-source-ada',${person.ada},'LinkExistingPerson',${people},'synthetic',${people},'cert-http-person-ada'), + ('cert-http-synthetic','cert-http-source-li',${person.unprintable},'LinkExistingPerson',${people},'synthetic',${people},'cert-http-person-li')`; + yield* sql`INSERT INTO historical_service_snapshots + (snapshot_key,source_repository,snapshot_id,source_revision,transformation_revision,snapshot_digest,occurrence_count) + VALUES (${service},'cert-http-synthetic','cert-http-service','test','test',${service},2)`; + yield* sql`INSERT INTO historical_service_occurrences (snapshot_key,occurrence_id,disposition,reason) VALUES + (${service},'cert-http-ada-spring','Accepted','Imported'), + (${service},'cert-http-li-spring','Accepted','Imported')`; + yield* sql`INSERT INTO assistant_service_history + (source_repository,source_history_id,source_user_id,person_id,department_id,semester_id,school_id,day,workdays,block,source_digest,evidence_ref,snapshot_key,occurrence_id) VALUES + ('cert-http-synthetic','cert-http-ada-spring','cert-http-source-ada',${person.ada},${department},${spring},9201,'Monday',6,'1',${service},'synthetic',${service},'cert-http-ada-spring'), + ('cert-http-synthetic','cert-http-li-spring','cert-http-source-li',${person.unprintable},${department},${spring},9201,'Friday',3,'1',${service},'synthetic',${service},'cert-http-li-spring')`; + }), +); + +/** Each fixture is one backend process with its own database. */ +const fixture = () => { + const database = backendDatabase(seed); + + 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 count = (table: string, personId: string) => + database.run( + Database.use((sql) => + sql.unsafe<{ readonly count: number }>( + `SELECT count(*)::integer AS count FROM ${table} WHERE person_id = $1`, + [personId], + ), + ).pipe(Effect.map((rows) => rows[0]?.count ?? 0)), + ); + + return { database, client, count }; +}; + +type Client = Effect.Success["client"]>; + +const as = (personId: string) => + RpcClient.withHeaders({ cookie: `better-auth.session_token=${personId}` }); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); + +const daysServedScope = { departmentId: department, semesterId: spring }; + +const confirm = ( + client: Client, + input: { readonly personId: string; readonly ifMatch: StrongETag; readonly key: string }, + total: number, +) => + 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 = ( + client: Client, + input: { + readonly issuer: string; + readonly personId: string; + readonly ifMatch: StrongETag; + readonly key: string; + }, +) => + 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 = (client: Client, personId: string) => + Effect.gen(function* () { + const list = yield* client["certificates.listDaysServed"](daysServedScope).pipe( + as(person.leader), + ); + + const entry = list.items.find((item) => item.personId === personId); + + expect(entry).toBeDefined(); + + if (entry === undefined) return; + + 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 = (client: Client, personId: string) => + client["certificates.readCertificate"]({ + departmentId: department, + personId: PersonId.make(personId), + }).pipe(as(person.leader)); + +describe("certificate scopes over RPC", () => { + it.live("grants the scope read to either capability and denies everyone else", () => + Effect.gen(function* () { + const { client: connect, count } = fixture(); + const client = yield* connect; + const leader = yield* client["certificates.readCertificateScopes"]().pipe(as(person.leader)); + + const coordinator = yield* client["certificates.readCertificateScopes"]().pipe( + as(person.coordinator), + ); + + const outsider = yield* Effect.flip( + client["certificates.readCertificateScopes"]().pipe(as(person.outsider)), + ); + + expect(leader.departments).toEqual([ + { + departmentId: department, + name: "Trondheim", + confirmDaysServed: true, + issueCertificates: true, + }, + ]); + expect(coordinator.departments).toEqual([ + { + departmentId: department, + name: "Trondheim", + confirmDaysServed: true, + issueCertificates: false, + }, + ]); + expect(problemCode(outsider)).toBe("authority.denied"); + + // The delegated capability confirms days served and issues nothing. + yield* client["certificates.listDaysServed"](daysServedScope).pipe(as(person.coordinator)); + + yield* confirmCalculated(client, person.ada); + + const { etag } = yield* preview(client, person.ada); + + const denied = yield* Effect.flip( + issue(client, { + issuer: person.coordinator, + personId: person.ada, + ifMatch: etag, + key: "coordinator", + }), + ); + + expect(problemCode(denied)).toBe("authority.denied"); + expect(yield* count("certificate_issues", person.ada)).toBe(0); + }), + ); +}); + +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 { client: connect, count } = fixture(); + const client = yield* connect; + + 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 }); + + if (ada === undefined) return; + + const entry = yield* confirm( + client, + { personId: person.ada, ifMatch: ada.etag, key: "first" }, + 6, + ); + + expect(entry).toMatchObject({ + revision: 1, + confirmation: { revision: 1, total: 6, calculated: 6, confirmedBy: person.leader }, + }); + 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* Effect.flip( + confirm(client, { personId: person.ada, ifMatch: ada.etag, key: "stale" }, 4), + ); + + expect(problemCode(stale)).toBe("precondition.failed"); + expect(yield* count("days_served_confirmations", person.ada)).toBe(1); + + yield* confirm(client, { personId: person.ada, ifMatch: entry.etag, key: "adjust" }, 4); + + const after = yield* client["certificates.listDaysServed"](daysServedScope).pipe( + as(person.leader), + ); + + // The correction reads back as the next revision; the other assistant is unchanged. + expect(after.items.map((item) => [item.personId, item.confirmation?.total ?? null])).toEqual([ + [person.ada, 4], + [person.unprintable, null], + ]); + expect(yield* count("days_served_confirmations", person.ada)).toBe(2); + }), + ); +}); + +describe("certificates over RPC", () => { + it.live("denies the outsider and the assistant, and records no issue", () => + Effect.gen(function* () { + const { client: connect, count } = fixture(); + const client = yield* connect; + + yield* confirmCalculated(client, person.ada); + + const { etag } = yield* preview(client, person.ada); + + const outsiderList = yield* Effect.flip( + client["certificates.listCertificates"]({ departmentId: department }).pipe( + as(person.outsider), + ), + ); + + expect(problemCode(outsiderList)).toBe("authority.denied"); + + for (const [issuer, attempt] of [ + [person.outsider, "outsider"], + [person.ada, "own"], + ] as const) { + const denied = yield* Effect.flip( + issue(client, { issuer, personId: person.ada, ifMatch: etag, key: attempt }), + ); + + expect(problemCode(denied)).toBe("authority.denied"); + } + + // The assistant cannot read their own certificate either. + const own = yield* Effect.flip( + client["certificates.readCertificate"]({ + departmentId: department, + personId: PersonId.make(person.ada), + }).pipe(as(person.ada)), + ); + + expect(problemCode(own)).toBe("authority.denied"); + expect(yield* count("certificate_issues", person.ada)).toBe(0); + }), + ); + + it.live( + "replays one issue key with the same bytes, records a new key, and checks authority first", + () => + Effect.gen(function* () { + const { database, client: connect, count } = fixture(); + const client = yield* connect; + + yield* confirmCalculated(client, person.ada); + + const certificate = yield* preview(client, person.ada); + + expect(certificate.content?.semesters.map((semester) => semester.days)).toEqual([6]); + + const document = yield* issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "issue", + }); + + expect(new TextDecoder().decode(document.subarray(0, 5))).toBe("%PDF-"); + + const replay = yield* issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "issue", + }); + + expect(replay).toEqual(document); + expect(yield* count("certificate_issues", person.ada)).toBe(1); + + yield* issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "second", + }); + + const hashes = yield* database.run( + Database.use( + (sql) => sql<{ readonly contentSha256: string }>` + SELECT content_sha256 AS "contentSha256" FROM certificate_issues + WHERE person_id = ${person.ada} ORDER BY issued_at, issue_id`, + ), + ); + + // Two downloads of unchanged confirmed data: two records with one content hash. + expect(hashes.map((row) => row.contentSha256)).toEqual([ + certificate.contentSha256, + certificate.contentSha256, + ]); + + // The seat ends: the stored response of the first key does not replay. + yield* database.run( + Database.use( + (sql) => sql`UPDATE organization_memberships + SET end_at = date_trunc('milliseconds', now(), 'UTC') + WHERE membership_id = 'cert-http-m-leader'`, + ), + ); + + const revoked = yield* Effect.flip( + issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "issue", + }), + ); + + 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, client: connect, count } = fixture(); + const client = yield* connect; + + yield* confirmCalculated(client, 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. + yield* database.run( + Database.use((sql) => + Effect.gen(function* () { + yield* sql`CREATE SEQUENCE certificate_issue_commit_attempts`; + yield* sql`CREATE FUNCTION fail_first_certificate_issue_commit() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + IF nextval('certificate_issue_commit_attempts') = 1 THEN + RAISE EXCEPTION 'first certificate issue commit fails' USING ERRCODE = '40001'; + END IF; + RETURN NULL; + END $$`; + yield* sql`CREATE CONSTRAINT TRIGGER fail_first_certificate_issue_commit + AFTER INSERT ON public.certificate_issues DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION fail_first_certificate_issue_commit()`; + }), + ), + ); + + const issued = yield* issue(client, { + issuer: person.leader, + personId: person.ada, + ifMatch: certificate.etag, + key: "commit-fault", + }); + + expect(new TextDecoder().decode(issued.subarray(0, 5))).toBe("%PDF-"); + + const attempts = yield* database.run( + Database.use( + (sql) => sql<{ readonly attempts: number }>` + SELECT last_value::integer AS attempts FROM certificate_issue_commit_attempts`, + ), + ); + + expect(attempts).toEqual([{ attempts: 2 }]); + expect(yield* count("certificate_issues", person.ada)).toBe(1); + }), + ); + + it.live("refuses a name that no certificate glyph covers and records nothing", () => + Effect.gen(function* () { + const { client: connect, count } = fixture(); + const client = yield* connect; + + yield* confirmCalculated(client, person.unprintable); + + const certificate = yield* preview(client, person.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 index 44a002bc..966600ff 100644 --- a/apps/backend/src/placements/certificates-rpc.ts +++ b/apps/backend/src/placements/certificates-rpc.ts @@ -1,8 +1,464 @@ /** - * The CertificatesRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/placements/certificates-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { CertificatesRpcs } from "@vektorprogrammet/rpc"; +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"; -export const CertificatesRpcHandlers = (_options: NativeRpcOptions) => CertificatesRpcs.toLayer({}); +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/rpc.test.ts b/apps/backend/src/placements/rpc.test.ts new file mode 100644 index 00000000..cbfbdd4e --- /dev/null +++ b/apps/backend/src/placements/rpc.test.ts @@ -0,0 +1,385 @@ +/** + * 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"; +import { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +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 { backendDatabase } from "../../test/database.js"; +import { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const departmentId = DepartmentId.make("draft-department"); + +const semesterId = SemesterId.make("draft-semester"); + +const scope = { departmentId, semesterId }; + +const digest = (value: string) => createHash("sha256").update(value).digest("hex"); + +interface Registration { + readonly person: string; + readonly revision: number; + readonly unavailable: ReadonlyArray<"monday" | "tuesday" | "wednesday" | "thursday" | "friday">; + readonly positionWeeks: 4 | 8; + readonly preferredGroup: "all" | "block-1" | "block-2"; + readonly preferredSchool?: string; +} + +/** + * Alpha has a capacity plan of one place per block on Monday, which bounds its demand of two; + * its active placement fills Monday block 1. Beta has no plan, so demand alone bounds it. + * Availability comes from returning registrations, whose latest revision counts, and from the + * public applications of new applicants whose interview is conducted. + */ +const seed = Database.use((sql) => + Effect.gen(function* () { + yield* sql`INSERT INTO organization_departments(department_id,name,short_name,email,city) VALUES(${departmentId},'Trondheim','TRD','draft@example.invalid','Trondheim')`; + yield* sql`INSERT INTO organization_teams(team_id,department_id,name) VALUES('draft-team',${departmentId},'Skolekoordinering')`; + yield* sql`INSERT INTO admission_period_departments(department_id,name) VALUES(${departmentId},'Trondheim')`; + yield* sql`INSERT INTO admission_period_semesters VALUES('draft-previous','2031-08-01T00:00:00Z','2031-12-31T00:00:00Z'),(${semesterId},'2032-01-01T00:00:00Z','2032-06-30T00:00:00Z')`; + yield* sql`INSERT INTO admission_periods VALUES('draft-period',${departmentId},${semesterId},'2031-11-01T00:00:00Z','2031-12-01T00:00:00Z',0,'draft-seed')`; + yield* sql`INSERT INTO admission_period_fields_of_study VALUES('draft-field',${departmentId},'Matematikk',true)`; + yield* sql`INSERT INTO person_profiles(person_id,first_name,last_name) VALUES('coordinator','Kari','Koordinator'),('berit','Berit','Assistent'),('carl','Carl','Assistent'),('dina','Dina','Assistent'),('erik','Erik','Assistent'),('pia','Pia','Assistent'),('petter','Petter','Assistent'),('nora','Nora','Søker'),('olav','Olav','Søker')`; + yield* sql`INSERT INTO organization_memberships(membership_id,person_id,team_id,start_at,position_id,is_team_leader) VALUES('draft-leader','coordinator','draft-team','2020-01-01T00:00:00Z','leader',true)`; + // Skolekoordinering coordinates placements in its department through a delegation (O8-12). + yield* sql`INSERT INTO organization_delegations(delegation_id,name,team_id,capability,area,area_department_id,holders,start_at) VALUES(${`delegation-${"d".repeat(64)}`},'Skolekoordinering fordeler assistenter','draft-team','placements.coordinate','Department',${departmentId},'AllMembers','2020-01-01T00:00:00Z')`; + yield* sql`INSERT INTO organization_volunteer_affiliations(person_id,department_id,status,revision) VALUES('berit',${departmentId},'Active',1),('carl',${departmentId},'Active',1),('dina',${departmentId},'Active',1),('erik',${departmentId},'Active',1),('pia',${departmentId},'Active',1),('petter',${departmentId},'Pending',1),('nora',${departmentId},'Active',1),('olav',${departmentId},'Active',1)`; + + const schools = yield* sql<{ + readonly schoolId: number; + }>`INSERT INTO schools_directory_schools(name,contact_person,email,phone,language,active) VALUES('Alpha skole','Kontakt','alpha@example.invalid','12345678','Norwegian',true),('Beta skole','Kontakt','beta@example.invalid','12345678','Norwegian',true) RETURNING school_id::double precision AS "schoolId"`; + + const [alpha, beta] = schools + .map((school) => school.schoolId) + .sort((left, right) => left - right); + + yield* sql`INSERT INTO schools_directory_departments(school_id,department_id) VALUES(${alpha!},${departmentId}),(${beta!},${departmentId})`; + yield* sql`INSERT INTO schools_capacity_plans(school_id,department_id,semester_id,monday,tuesday,wednesday,thursday,friday) VALUES(${alpha!},${departmentId},${semesterId},1,0,0,0,0)`; + // Nobody can serve Wednesday, so Beta's Wednesday place stays open. + yield* sql`INSERT INTO school_service_demand(department_id,semester_id,school_id,day,block,required_volunteers,revision) VALUES(${departmentId},${semesterId},${alpha!},'Monday','1',2,1),(${departmentId},${semesterId},${alpha!},'Monday','2',2,1),(${departmentId},${semesterId},${alpha!},'Wednesday','1',1,1),(${departmentId},${semesterId},${beta!},'Tuesday','1',1,1),(${departmentId},${semesterId},${beta!},'Tuesday','2',1,1),(${departmentId},${semesterId},${beta!},'Wednesday','1',1,1)`; + yield* sql`INSERT INTO assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES(${`placement-${digest("pia-current")}`},'pia',${departmentId},${semesterId},${alpha!},'Monday',4,'1',true,1)`; + + const registrations: ReadonlyArray = [ + // Carl's first registration had Monday off; the later revision counts. + { + person: "carl", + revision: 1, + unavailable: ["monday", "tuesday"], + positionWeeks: 4, + preferredGroup: "all", + }, + { + person: "carl", + revision: 2, + unavailable: ["tuesday", "wednesday", "thursday"], + positionWeeks: 4, + preferredGroup: "all", + preferredSchool: "Alpha skole", + }, + { + person: "berit", + revision: 1, + unavailable: ["monday", "wednesday", "thursday", "friday"], + positionWeeks: 4, + preferredGroup: "block-2", + }, + // Both blocks on Monday need one school with room in both; Alpha has one place left in block 2 only. + { + person: "dina", + revision: 1, + unavailable: ["tuesday", "wednesday", "thursday", "friday"], + positionWeeks: 8, + preferredGroup: "block-1", + }, + { person: "petter", revision: 1, unavailable: [], positionWeeks: 4, preferredGroup: "all" }, + ]; + + for (const person of ["carl", "berit", "dina", "petter"]) { + 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-${person}`},${`${person}@example.invalid`},${`${person}@example.invalid`},${person},'Assistent','12345678',0,'draft-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(${`application-${person}`},${`applicant-${person}`},'draft-period',${departmentId},'draft-field',2,'2031-11-02T00:00:00Z')`; + yield* sql`INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${`invitation-${person}`},${`application-${person}`},${`applicant-${person}`},${digest(`token-${person}`)},'2032-01-01T00:00:00Z','Claimed','coordinator','2031-11-03T00:00:00Z')`; + yield* sql`INSERT INTO applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES(${`applicant-${person}`},${person},'2031-11-04T00:00:00Z',${`invitation-${person}`})`; + yield* sql`INSERT INTO assistant_placements(placement_id,person_id,department_id,semester_id,school_id,day,workdays,block,active,revision) VALUES(${`placement-${digest(`${person}-previous`)}`},${person},${departmentId},'draft-previous',${beta!},'Friday',4,'1',false,2)`; + } + + for (const registration of registrations) { + const unavailable = (day: Registration["unavailable"][number]) => + registration.unavailable.includes(day); + + yield* sql`INSERT INTO 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(${`returning-registration-${digest(`${registration.person}-${registration.revision}`)}`},${`application-${registration.person}`},${`applicant-${registration.person}`},${registration.person},${`placement-${digest(`${registration.person}-previous`)}`},${departmentId},${semesterId},'draft-period',${registration.revision},${`register-${registration.person}-${registration.revision}`},2,${unavailable("monday")},${unavailable("tuesday")},${unavailable("wednesday")},${unavailable("thursday")},${unavailable("friday")},${registration.positionWeeks},${registration.preferredGroup},'Norsk',${registration.preferredSchool ?? null},false,'[]'::jsonb,${`2031-11-1${registration.revision}T00:00:00Z`})`; + } + + // Nora and Olav applied on the public form: Tuesday only, block 1, an international school. + // Both have an account and an active affiliation; only Nora's interview is conducted. + yield* sql`INSERT INTO recruitment_interview_schemas(interview_schema_id,name,question_count,active,revision) VALUES('draft-schema','Intervju',0,true,0)`; + + for (const person of ["nora", "olav"]) { + 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-${person}`},${`${person}@example.invalid`},${`${person}@example.invalid`},${person},'Søker','12345678',1,'draft-field',1)`; + yield* sql`INSERT INTO admission_applications(application_id,applicant_id,admission_period_id,department_id,field_of_study_id,year_of_study,submitted_at,monday_unavailable,tuesday_unavailable,wednesday_unavailable,thursday_unavailable,friday_unavailable,position_weeks,preferred_group,language) VALUES(${`application-${person}`},${`applicant-${person}`},'draft-period',${departmentId},'draft-field',1,'2031-11-02T00:00:00Z',true,false,true,true,true,4,'block-1','Engelsk')`; + yield* sql`INSERT INTO applicant_account_invitations(invitation_id,application_id,applicant_id,token_digest,expires_at,state,issued_by,issued_at) VALUES(${`invitation-${person}`},${`application-${person}`},${`applicant-${person}`},${digest(`token-${person}`)},'2032-01-01T00:00:00Z','Claimed','coordinator','2031-11-03T00:00:00Z')`; + yield* sql`INSERT INTO applicant_account_links(applicant_id,person_id,linked_at,invitation_id) VALUES(${`applicant-${person}`},${person},'2031-11-04T00:00:00Z',${`invitation-${person}`})`; + yield* sql`INSERT INTO recruitment_interviews(interview_id,application_id,department_id,interviewer_person_id,interview_schema_id,assigned_by_person_id,assigned_at,revision) VALUES(${`interview-${person}`},${`application-${person}`},${departmentId},'coordinator','draft-schema','coordinator','2031-11-05T00:00:00Z',1)`; + } + + yield* sql`INSERT INTO recruitment_interview_conducts(interview_id,answers,explanatory_power,role_model,suitability,finalized_by_person_id,finalized_at,interview_revision,recommendation) VALUES('interview-nora','[]'::jsonb,7,8,9,'coordinator','2031-11-06T00:00:00Z',2,'Ja')`; + }), +); + +/** 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); + +/** One backend over its own seeded database; `as` sends a person's session cookie. */ +const fixture = () => { + const database = backendDatabase(seed); + + const { client } = makeBackendTestRpc({ + config: backendTestConfig, + services: Layer.mergeAll( + PlacementsLive, + OrganizationLive, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(Identity, identity), + ).pipe(Layer.provideMerge(database.layer)), + }); + + const as = (personId = "coordinator") => + RpcClient.withHeaders({ cookie: `better-auth.session_token=${personId}` }); + + return { client, as }; +}; + +const problemCode = (failure: E) => (isProblem(failure) ? failure.code : failure); + +describe("placement drafts over RPC and PostgreSQL", () => { + it.live("drafts open demand within capacity for unplaced active assistants", () => + Effect.gen(function* () { + 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); + expect(draft.filledPlaces).toBe(3); + // Nora is a new applicant: her application supplies her weekday, block, and school wish. + expect( + draft.placements.map(({ personId, schoolName, day, block, workdays, wishes }) => [ + personId, + schoolName, + day, + block, + workdays, + wishes, + ]), + ).toEqual([ + [ + "carl", + "Alpha skole", + "Monday", + "2", + 4, + { language: "Norsk", preferredSchool: "Alpha skole" }, + ], + ["nora", "Beta skole", "Tuesday", "1", 4, { language: "Engelsk", preferredSchool: null }], + ["berit", "Beta skole", "Tuesday", "2", 4, { language: "Norsk", preferredSchool: null }], + ]); + // Olav's interview is not conducted, so his application supplies nothing yet. + expect( + draft.unplaced.map(({ personId, reason, wishes }) => [personId, reason, wishes]), + ).toEqual([ + ["dina", "NoOpenPlace", { language: "Norsk", preferredSchool: null }], + ["erik", "NoAvailability", null], + ["olav", "NoAvailability", null], + ]); + expect( + draft.openSlots.map(({ schoolName, day, block, places }) => [ + schoolName, + day, + block, + places, + ]), + ).toEqual([["Beta skole", "Wednesday", "1", 1]]); + + // Nothing is stored, and the same board gives the same draft. + expect(yield* client["placements.readDraft"](scope).pipe(as())).toEqual(draft); + }), + ); + + it.live( + "applies through the placement commands, after which only the unfilled rest remains", + () => + Effect.gen(function* () { + 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 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()); + + // 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* 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 and board commands to scoped coordinators for a known semester only", () => + Effect.gen(function* () { + 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(problemCode(denied)).toBe("authority.denied"); + expect((yield* client["placements.readBoard"](scope).pipe(as())).etag).toBe(board.etag); + + const unknown = yield* Effect.flip( + client["placements.readDraft"]({ + departmentId, + semesterId: SemesterId.make("draft-unknown"), + }).pipe(as()), + ); + + 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 index d6252a8b..f04d42ac 100644 --- a/apps/backend/src/placements/rpc.ts +++ b/apps/backend/src/placements/rpc.ts @@ -1,8 +1,515 @@ /** - * The PlacementsRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/placements/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { PlacementsRpcs } from "@vektorprogrammet/rpc"; +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"; -export const PlacementsRpcHandlers = (_options: NativeRpcOptions) => PlacementsRpcs.toLayer({}); +/** + * 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/packages/rpc/src/certificates.ts b/packages/rpc/src/certificates.ts index 67ec49f1..08ff5650 100644 --- a/packages/rpc/src/certificates.ts +++ b/packages/rpc/src/certificates.ts @@ -1,11 +1,256 @@ /** - * Certificates: days served and certificate issue. + * 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. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/certificates.ts` at the base commit named there. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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 class CertificatesRpcs extends RpcGroup.make() {} +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/placements.ts b/packages/rpc/src/placements.ts index 1e22657f..8db878e0 100644 --- a/packages/rpc/src/placements.ts +++ b/packages/rpc/src/placements.ts @@ -1,11 +1,267 @@ /** * Placements: affiliation, placement boards, drafts, and coverage. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/placements.ts` at the base commit named there. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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 class PlacementsRpcs extends RpcGroup.make() {} +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, +) {} From d0d65a2bb3842dca787fa45bce31b0fb3ed5def6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:39:32 +0000 Subject: [PATCH 033/148] feat(rpc): recruitment and onboarding contracts and handlers Port the 16 recruitment and 3 onboarding operations from the deleted HttpApi handlers to RPC, keeping AccessSpecs, in-transaction authority, command receipts (the HTTP capsules with their entity tags), and the old route paths as normalized targets. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/onboarding/rpc.ts | 401 ++++++++- apps/backend/src/recruitment/access.ts | 329 +++++++ apps/backend/src/recruitment/commands.ts | 833 ++++++++++++++++++ apps/backend/src/recruitment/context.ts | 46 + apps/backend/src/recruitment/maintenance.ts | 215 +++++ apps/backend/src/recruitment/problem.ts | 221 +++++ apps/backend/src/recruitment/reads.ts | 345 ++++++++ .../backend/src/recruitment/representation.ts | 55 ++ apps/backend/src/recruitment/rpc.ts | 62 +- packages/rpc/src/onboarding.ts | 165 +++- packages/rpc/src/recruitment.ts | 684 +++++++++++++- 11 files changed, 3337 insertions(+), 19 deletions(-) create mode 100644 apps/backend/src/recruitment/access.ts create mode 100644 apps/backend/src/recruitment/commands.ts create mode 100644 apps/backend/src/recruitment/context.ts create mode 100644 apps/backend/src/recruitment/maintenance.ts create mode 100644 apps/backend/src/recruitment/problem.ts create mode 100644 apps/backend/src/recruitment/reads.ts create mode 100644 apps/backend/src/recruitment/representation.ts diff --git a/apps/backend/src/onboarding/rpc.ts b/apps/backend/src/onboarding/rpc.ts index 0b61f979..9d2c3902 100644 --- a/apps/backend/src/onboarding/rpc.ts +++ b/apps/backend/src/onboarding/rpc.ts @@ -1,8 +1,401 @@ /** - * The OnboardingRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/onboarding/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { OnboardingRpcs } from "@vektorprogrammet/rpc"; +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"; -export const OnboardingRpcHandlers = (_options: NativeRpcOptions) => OnboardingRpcs.toLayer({}); +/** 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/recruitment/access.ts b/apps/backend/src/recruitment/access.ts new file mode 100644 index 00000000..081fc6fa --- /dev/null +++ b/apps/backend/src/recruitment/access.ts @@ -0,0 +1,329 @@ +/** Recruitment access: invitation-capability authorization and person access contexts. */ +import type { + Database, + IdentitySnapshot, + OAuthCredentialAuthority, +} from "@vektorprogrammet/database"; +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import type { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { + AuthorityRef, + AuthorityVersion, + AuthorizationInstant, + CapabilityId, + CredentialEvidenceRef, + DomainId, + GrantId, + INVITATION_RESPONSE_CAPABILITY, + RECRUITMENT_INVITATION_RESOURCE_KIND, + ResourceId, + ResourceKind, + Scope, + accessHttpStatus, + decodeGrant, + evaluateAccessJourney, + type AccessSpec, +} from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { DepartmentId, Organization } from "@vektorprogrammet/domain/organization"; +import { + Recruitment, + type RecruitmentActor, + type RecruitmentFailure, + type RecruitmentInterviewHttpSource, + type RecruitmentInterviewId, + type RecruitmentInvitationHttpSource, +} from "@vektorprogrammet/domain/recruitment"; +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 { credentialRequestOf } from "../rpc/credential.js"; +import { authorizePerson, personPresentation } from "../rpc/problem.js"; + +type GenericFacts = Schema.JsonObject; + +/** + * Authorizes the holder of an invitation's response capability. + * + * @remarks + * 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. + * + * @sideEffects none + * + * @example + * ```ts + * 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 rpc-problem + */ +export const authorizeInvitationOperation = (input: { + readonly spec: AccessSpec; + readonly source: RecruitmentInvitationHttpSource; + readonly authorizationInstant: string; +}): Effect.Effect> => + Effect.gen(function* () { + const capabilityId = CapabilityId.make(input.source.capabilitySha256); + const principal = { _tag: "CapabilityHolder" as const, capabilityId }; + const instant = AuthorizationInstant.make(input.authorizationInstant); + + const resource = { + kind: RECRUITMENT_INVITATION_RESOURCE_KIND, + id: ResourceId.make(input.source.invitationId), + }; + + if (!Predicate.isTagged(input.spec.capabilities, "One")) { + return yield* Effect.die(new Error("An invitation AccessSpec names exactly one capability")); + } + + const capability = input.spec.capabilities.capability; + + const resolution = { + selection: "ExactlyOne" as const, + contexts: [ + { + domainId: DomainId.make("recruitment"), + departmentId: input.source.departmentId, + resource, + facts: { + capabilityId, + invitationId: input.source.invitationId, + interviewId: input.source.interviewId, + departmentId: input.source.departmentId, + responseState: input.source.responseState, + responseRevision: input.source.responseRevision, + supersededAt: input.source.supersededAt, + }, + authorityVersion: AuthorityVersion.make( + `${input.source.scheduleRevision}:${input.source.responseRevision}`, + ), + }, + ], + }; + + const grant = decodeGrant({ + grantId: GrantId.make(`native-invitation:${input.source.capabilitySha256}`), + subject: principal, + capability, + scope: Scope.Resource({ resource }), + startAt: instant, + endAt: null, + requirements: [], + source: AuthorityRef.make("native-invitation-capability"), + revision: input.source.responseRevision, + }); + + const evaluation = yield* evaluateAccessJourney(input.spec, undefined, { + now: Effect.succeed(instant), + resolveCredential: () => + Effect.succeed({ + _tag: "Accepted" as const, + mechanism: { + _tag: "ObjectCapability" as const, + capabilityType: INVITATION_RESPONSE_CAPABILITY, + }, + principal, + evidenceRef: CredentialEvidenceRef.make( + `native-invitation:${input.source.capabilitySha256}`, + ), + }), + resolveScope: () => Effect.succeed(resolution), + resolveGrants: () => Effect.succeed([grant]), + }); + + const status = accessHttpStatus(evaluation, input.spec.concealment); + + if (status === 404) return yield* Problem.make("resource.not-found"); + + if (status !== 200) { + return yield* Effect.die(new Error(`An invitation AccessSpec revealed a ${status} denial`)); + } + }); + +export const actorDepartment = (actor: RecruitmentActor): DepartmentId | null => + Predicate.isTagged(actor, "GlobalAdmin") ? null : actor.departmentId; + +export const boardContext = ({ + actor, + facts, + version, +}: { + readonly actor: RecruitmentActor; + readonly facts: GenericFacts; + readonly version: string; +}) => ({ + domainId: DomainId.make("recruitment"), + departmentId: actorDepartment(actor), + resource: null, + facts, + authorityVersion: AuthorityVersion.make(version), +}); + +export const applicationContext = (input: { + readonly applicationId: typeof PublicApplicationIdSchema.Type; + readonly departmentId: DepartmentId; + readonly facts: GenericFacts; + readonly version: string; +}) => ({ + domainId: DomainId.make("recruitment"), + departmentId: input.departmentId, + resource: { + kind: ResourceKind.make("application"), + id: ResourceId.make(input.applicationId), + }, + facts: input.facts, + authorityVersion: AuthorityVersion.make(input.version), +}); + +/** 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: { + kind: ResourceKind.make("recruitment-interview"), + id: ResourceId.make(source.interviewId), + }, + facts: { + assignedInterviewerPersonIds: activeMember ? [source.interviewerPersonId] : [], + interviewParticipantPersonIds: activeMember + ? source.coInterviewerPersonId === null + ? [source.interviewerPersonId] + : [source.interviewerPersonId, source.coInterviewerPersonId] + : [], + linkedApplicantPersonId: source.linkedApplicantPersonId, + departmentAdministratorPersonIds: + allowLeader && + Predicate.isTagged(actor, "DepartmentAdministrator") && + actor.departmentId === source.departmentId + ? [actor.personId] + : [], + }, + authorityVersion: AuthorityVersion.make( + `${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. */ +export interface InterviewAuthorization { + readonly actor: RecruitmentActor; + readonly authorizationInstant: AuthorizationInstant; + readonly source: RecruitmentInterviewHttpSource; +} + +/** + * Resolves the current person and authorizes one interview inside the caller's transaction; a + * rejected credential is answered from the request's evidence. + * + * @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 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. + * + * @sideEffects Reads the person's credential and authority, and takes recruitment's locks in the + * caller's transaction. + * + * @example + * ```ts + * 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 rpc-problem + */ +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 + | UnauthenticatedActor + | OrganizationResolutionError + | Problem<"authority.denied"> + | Problem<"credential.invalid"> + | Problem<"credential.missing"> + | Problem<"resource.not-found">, + Database | IdentitySnapshot | OAuthCredentialAuthority | Organization | Recruitment +> => + Effect.gen(function* () { + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(input.headers), + { now: input.now }, + ); + + const { source, actor, activeMember } = yield* Recruitment.use((service) => + service.prepareInterview({ + interviewId: input.interviewId, + personId: authorization.authority.personId, + authorizationInstant: authorization.authorizationInstant, + }), + ); + + const resource = { + kind: ResourceKind.make("recruitment-interview"), + id: ResourceId.make(input.interviewId), + }; + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: authorization.credential, + personId: actor.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + recruitmentInterviewAccessContext({ + source, + actor, + allowLeader: input.allowLeader, + activeMember, + }), + ], + }, + grantScopes: [Scope.Resource({ resource })], + now: authorization.authorizationInstant, + }, + personPresentation(input.headers), + ); + + return { + actor, + authorizationInstant: authorization.authorizationInstant, + source, + }; + }); diff --git a/apps/backend/src/recruitment/commands.ts b/apps/backend/src/recruitment/commands.ts new file mode 100644 index 00000000..2b952c7b --- /dev/null +++ b/apps/backend/src/recruitment/commands.ts @@ -0,0 +1,833 @@ +/** + * 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"; +import { + Recruitment, + RecruitmentAssignmentCommandId, + RecruitmentCancellationCommandId, + RecruitmentConductCommandId, + RecruitmentInterviewCorrectionCommandId, + RecruitmentInvitationTransition, + RecruitmentScheduleCommandId, + type RecruitmentInterviewId, +} from "@vektorprogrammet/domain/recruitment"; +import { + CancelInterview, + CancelInterviewResponse, + ConfirmInvitation, + CorrectInterviewAssessment, + CorrectInterviewAssessmentResponse, + CreateApplicationInterview, + FinalizeInterview, + FinalizeInterviewResponse, + RecruitmentInterviewResource, + RejectInvitation, + RequestNewInvitationTime, + ScheduleInterview, + ScheduleInterviewResponse, + reflectAccessSpec, + 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, + commandIdentity, + commandReceiptProblems, + personPresentation, + requireCurrentETag, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { + executeNativeHttpCommandPostgres, + NativeHttpReceiptInvalid, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; +import { + applicationContext, + authorizeInvitationOperation, + interviewAuthorizationInTransaction, +} from "./access.js"; +import { + admissionPeriodProblems, + assignmentProblems, + conductProblems, + raceProblems, + recruitmentProblems, + schedulingProblems, +} 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. + */ +const executeCommand = (input: { + readonly operationId: string; + readonly normalizedTarget: string; + readonly idempotencyKey: IdempotencyKey; + readonly semanticRequest: CanonicalSemanticRequest; + readonly commandIdSchema: Schema.Codec; + readonly prepare: Effect.Effect< + { + readonly credentialSubject: CredentialSubject; + readonly execute: ( + commandId: NoInfer, + ) => 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 derived = yield* commandIdentity({ + credentialSubject: prepared.credentialSubject, + qualifiedOperationId: input.operationId, + normalizedTarget: input.normalizedTarget, + idempotencyKey: input.idempotencyKey, + }); + + // 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: { + identitySha256: derived.identitySha256, + requestSha256: semanticRequestDigest(input.semanticRequest), + operationId: input.operationId, + }, + execute: prepared.execute(commandId), + }; + }), + { retry: "serialization-once" }, + ); + +/** 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 answer, with the interview's new entity tag. */ +const interviewCommandCapsule = ( + schema: S, + interviewId: RecruitmentInterviewId, + personId: PersonId, + output: S["Type"], +) => + Recruitment.use((service) => service.readInterviewSource(interviewId, personId)).pipe( + Effect.flatMap((updated) => interviewCapsule(schema)(output, interviewETag(updated))), + ); + +/** 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* () { + 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(input.rpc)), + source, + authorizationInstant: now, + }); + + // An answered invitation fails as already responded, whatever its validator. + if (source.responseState === "Pending") { + yield* requireCurrentETag(invitationETag(source), input.ifMatch); + } + + const updated = yield* Recruitment.use((service) => + service.transitionInvitation({ capability, transition: input.transition, now }), + ); + + return { etag: invitationETag(updated.source) }; + }).pipe( + 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", + "credential.invalid", + ...admissionPeriodProblems, + ...assignmentProblems, + "recruitment.interview-not-found", + ...schedulingProblems, + ...conductProblems, + "idempotency.digest-conflict", + ), + ); + +/** Accepts one invitation. */ +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 = ({ + 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 = ({ + 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* () { + const normalizedTarget = yield* Effect.sync(() => + normalizeTarget("/api/recruitment/applications/{applicationId}/interviews", { + applicationId, + }), + ); + + const outcome = yield* executeCommand({ + operationId: "recruitment.createApplicationInterview", + normalizedTarget, + idempotencyKey: payload.idempotencyKey, + semanticRequest: { body }, + commandIdSchema: RecruitmentAssignmentCommandId, + prepare: Effect.gen(function* () { + const authorization = yield* resolveRequestPersonAuthorityInTransaction( + credentialRequestOf(headers), + { now: config.now }, + ); + + const { access, actor } = yield* Recruitment.use((service) => + service.prepareAssignment({ + applicationId, + interviewerPersonId: body.interviewerPersonId, + personId: authorization.authority.personId, + authorizationInstant: authorization.authorizationInstant, + }), + ); + + const resource = { + kind: ResourceKind.make("application"), + id: ResourceId.make(applicationId), + }; + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(CreateApplicationInterview)), + credential: authorization.credential, + personId: actor.personId, + resolution: { + selection: "ExactlyOne", + contexts: [ + applicationContext({ + applicationId, + departmentId: access.departmentId, + facts: { + departmentAdministratorPersonIds: + Predicate.isTagged(actor, "DepartmentAdministrator") && actor.active + ? [actor.personId] + : [], + eligibleInterviewerPersonIds: access.interviewerEligible + ? [actor.personId] + : [], + }, + version: authorization.authorizationInstant, + }), + ], + }, + grantScopes: [Scope.Resource({ resource })], + now: authorization.authorizationInstant, + }, + presentation, + ); + + return { + credentialSubject: `Person:${actor.personId}` as const, + execute: (commandId: RecruitmentAssignmentCommandId) => + Effect.gen(function* () { + const result = yield* Recruitment.use((service) => + service.assignApplicant( + { commandId, applicationId, ...body }, + { + actor, + now: authorization.authorizationInstant, + interviewId: config.nextInterviewId(), + }, + ), + ); + + const interview = result.observation.interview; + const output = yield* strictOutput(RecruitmentInterviewResource)(interview); + + const source = yield* Recruitment.use((service) => + service.readInterviewSource(interview.interviewId, actor.personId), + ); + + const location = yield* interviewTarget( + "/api/recruitment/interviews/{interviewId}", + interview.interviewId, + ); + + 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: presentation, unavailable: "dependency.unavailable" }), + commandReceiptProblems, + // 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", + ...schedulingProblems, + ...conductProblems, + "resource.not-found", + "invitation.already-responded", + ), + ); +}; + +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 Effect.gen(function* () { + const outcome = yield* executeCommand({ + operationId: "recruitment.scheduleInterview", + 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({ + headers, + interviewId, + rpc: ScheduleInterview, + allowLeader: true, + now: config.now, + }); + + return { + credentialSubject: `Person:${authorization.actor.personId}` as const, + execute: (commandId: RecruitmentScheduleCommandId) => + Effect.gen(function* () { + yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); + + const { observation } = yield* Recruitment.use((service) => + service.scheduleInterview( + { + commandId, + interviewId, + expectedRevision: authorization.source.interviewRevision, + ...body, + }, + { + actor: authorization.actor, + now: authorization.authorizationInstant, + invitationId: config.nextInvitationId(), + responseCapability: config.nextResponseCapability(), + }, + ), + ); + + const output = yield* strictOutput(ScheduleInterviewResponse)({ + interviewId: observation.interviewId, + schedule: observation.schedule, + responseState: observation.responseState, + notificationState: observation.notificationState, + }); + + return yield* interviewCommandCapsule( + ScheduleInterviewResponse, + interviewId, + authorization.actor.personId, + output, + ); + }), + }; + }), + }); + + const stored = yield* interviewCommandOutcome(ScheduleInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; + }).pipe( + raceProblems, + 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. + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + ...conductProblems, + "resource.not-found", + "invitation.already-responded", + ), + ); +}; + +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 Effect.gen(function* () { + const outcome = yield* executeCommand({ + operationId: "recruitment.correctInterviewAssessment", + 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({ + headers, + interviewId, + rpc: CorrectInterviewAssessment, + allowLeader: false, + now: config.now, + }); + + return { + credentialSubject: `Person:${authorization.actor.personId}` as const, + execute: (commandId: RecruitmentInterviewCorrectionCommandId) => + Effect.gen(function* () { + yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); + + if (body.expectedRevision !== authorization.source.interviewRevision) { + return yield* Problem.make("precondition.failed"); + } + + const { observation, replayed } = yield* Recruitment.use((service) => + service.correctInterviewAssessment( + { commandId, interviewId, ...body }, + { + actor: authorization.actor, + now: authorization.authorizationInstant, + authorizationInstant: authorization.authorizationInstant, + }, + ), + ); + + const output = yield* strictOutput(CorrectInterviewAssessmentResponse)({ + _tag: observation._tag, + commandId: observation.commandId, + interviewId: observation.interviewId, + predecessorRevision: observation.predecessorRevision, + resultingRevision: observation.resultingRevision, + replayed, + }); + + return yield* interviewCommandCapsule( + CorrectInterviewAssessmentResponse, + interviewId, + authorization.actor.personId, + output, + ); + }), + }; + }), + }); + + const stored = yield* interviewCommandOutcome(CorrectInterviewAssessmentResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; + }).pipe( + raceProblems, + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), + commandReceiptProblems, + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + ...schedulingProblems, + "resource.not-found", + "invitation.already-responded", + ), + ); +}; + +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 Effect.gen(function* () { + const outcome = yield* executeCommand({ + operationId: "recruitment.finalizeInterview", + 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({ + headers, + interviewId, + rpc: FinalizeInterview, + allowLeader: false, + now: config.now, + }); + + return { + credentialSubject: `Person:${authorization.actor.personId}` as const, + execute: (commandId: RecruitmentConductCommandId) => + Effect.gen(function* () { + yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); + + const { observation } = yield* Recruitment.use((service) => + service.finalizeInterview( + { + commandId, + interviewId, + expectedRevision: authorization.source.interviewRevision, + ...body, + }, + { + actor: authorization.actor, + now: authorization.authorizationInstant, + authorizationInstant: authorization.authorizationInstant, + }, + ), + ); + + const output = yield* strictOutput(FinalizeInterviewResponse)({ + interviewId: observation.interviewId, + finalizedAt: observation.finalizedAt, + completionState: observation.completionState, + cancellationState: observation.cancellationState, + }); + + return yield* interviewCommandCapsule( + FinalizeInterviewResponse, + interviewId, + authorization.actor.personId, + output, + ); + }), + }; + }), + }); + + const stored = yield* interviewCommandOutcome(FinalizeInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; + }).pipe( + raceProblems, + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), + commandReceiptProblems, + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + ...schedulingProblems, + "resource.not-found", + "invitation.already-responded", + ), + ); +}; + +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 Effect.gen(function* () { + const outcome = yield* executeCommand({ + operationId: "recruitment.cancelInterview", + 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({ + headers, + interviewId, + rpc: CancelInterview, + allowLeader: false, + now: config.now, + }); + + return { + credentialSubject: `Person:${authorization.actor.personId}` as const, + execute: (commandId: RecruitmentCancellationCommandId) => + Effect.gen(function* () { + yield* requireCurrentETag(interviewETag(authorization.source), ifMatch); + + const { observation } = yield* Recruitment.use((service) => + service.cancelInterview( + { + commandId, + interviewId, + expectedRevision: authorization.source.interviewRevision, + }, + { + actor: authorization.actor, + now: authorization.authorizationInstant, + authorizationInstant: authorization.authorizationInstant, + }, + ), + ); + + const output = yield* strictOutput(CancelInterviewResponse)({ + interviewId: observation.interviewId, + cancelledAt: observation.cancelledAt, + completionState: observation.completionState, + cancellationState: observation.cancellationState, + }); + + return yield* interviewCommandCapsule( + CancelInterviewResponse, + interviewId, + authorization.actor.personId, + output, + ); + }), + }; + }), + }); + + const stored = yield* interviewCommandOutcome(CancelInterviewResponse)(outcome); + + return { result: stored.body, etag: stored.etag }; + }).pipe( + raceProblems, + recruitmentProblems({ + presentation: personPresentation(headers), + unavailable: "dependency.unavailable", + }), + commandReceiptProblems, + // Cancellation checks no invitation, and its instant always fits the clock. + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + ...schedulingProblems, + "recruitment.invitation-not-accepted", + "recruitment.conduct-invalid", + "resource.not-found", + "invitation.already-responded", + ), + ); +}; diff --git a/apps/backend/src/recruitment/context.ts b/apps/backend/src/recruitment/context.ts new file mode 100644 index 00000000..ca36b290 --- /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/maintenance.ts b/apps/backend/src/recruitment/maintenance.ts new file mode 100644 index 00000000..0626714c --- /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/problem.ts b/apps/backend/src/recruitment/problem.ts new file mode 100644 index 00000000..a4cb565e --- /dev/null +++ b/apps/backend/src/recruitment/problem.ts @@ -0,0 +1,221 @@ +/** 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/rpc/problem"; +import { type Cause, Effect, Match } from "effect"; +import { + type CredentialCases, + isSerializationConflict, + type OutageCases, + type ProblemCases, + type ProblemMapper, + problemMapper, +} from "../rpc/problem.js"; + +/** Every failure a recruitment handler answers: the domain's, and resolving the request's person. */ +type RecruitmentRpcFailure = RecruitmentFailure | IdentityEngineError | Cause.UnknownError; + +/** 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 RPC's unavailable problem. */ +type RecruitmentOutageTag = + | "RecruitmentPersistenceError" + | "InterviewQuestionsUnavailable" + | "ProfileContactNotFound"; + +const denied = () => Problem.make("authority.denied"); + +const replayConflict = () => Problem.make("idempotency.digest-conflict"); + +const fault = () => Problem.make("internal.error"); + +const recruitmentFixedCases = { + InactiveActor: denied, + RecruitmentInactiveActor: denied, + RecruitmentRoleDenied: denied, + RecruitmentScopeDenied: denied, + RecruitmentInterviewerNotEligible: denied, + RecruitmentAdmissionPeriodNotFound: () => Problem.make("recruitment.admission-period-not-found"), + RecruitmentAmbiguousAdmissionPeriod: () => Problem.make("application.ambiguous-period"), + RecruitmentApplicationNotFound: () => Problem.make("recruitment.application-not-found"), + RecruitmentInterviewSchemaNotFound: () => Problem.make("recruitment.interview-schema-not-found"), + RecruitmentApplicationAlreadyAssigned: () => + Problem.make("recruitment.application-already-assigned"), + RecruitmentInterviewSchemaInactive: () => Problem.make("recruitment.interview-schema-inactive"), + RecruitmentInterviewNotFound: () => Problem.make("recruitment.interview-not-found"), + RecruitmentInterviewAlreadyScheduled: () => Problem.make("recruitment.already-scheduled"), + RecruitmentInterviewStaleRevision: () => Problem.make("precondition.failed"), + RecruitmentScheduleInPast: () => Problem.make("recruitment.schedule-in-past"), + RecruitmentInvitationNotFound: () => Problem.make("resource.not-found"), + RecruitmentInvitationAlreadyResponded: () => Problem.make("invitation.already-responded"), + RecruitmentInterviewAlreadyFinalized: () => Problem.make("recruitment.already-finalized"), + RecruitmentInterviewAlreadyCancelled: () => Problem.make("recruitment.already-cancelled"), + RecruitmentInterviewNotScheduled: () => Problem.make("recruitment.interview-not-scheduled"), + RecruitmentInvitationNotAccepted: () => Problem.make("recruitment.invitation-not-accepted"), + RecruitmentConductValidationError: () => Problem.make("recruitment.conduct-invalid"), + RecruitmentAssignmentCommandConflict: replayConflict, + RecruitmentScheduleCommandConflict: replayConflict, + RecruitmentLifecycleCommandConflict: replayConflict, + // What remains is a fault of recruitment itself or of a dependency's contract. + RecruitmentDecodeError: fault, + RecruitmentInvalidContext: fault, + RecruitmentMaintenanceFailure: fault, + AdmissionPeriodDecodeError: fault, + AdmissionRoleDenied: fault, + AdmissionScopeDenied: fault, + DepartmentRequired: fault, + DepartmentNotFound: fault, + SemesterNotFound: fault, + AdmissionPeriodNotFound: fault, + InvalidAdmissionPeriodWindow: fault, + AdmissionWindowOutsideSemester: fault, + AdmissionPeriodAlreadyExists: fault, + StaleAdmissionPeriodRevision: fault, + DuplicateAdmissionPeriodCommandConflict: fault, + AdmissionPeriodPersistenceError: fault, + OrganizationDecodeError: fault, + OrganizationPersistenceError: fault, + ProfileDecodeError: fault, + ProfileQueryLimitExceeded: fault, + ProfileNotFound: fault, + ProfileStaleRevision: fault, + ProfileCommandConflict: fault, + ProfilePersistenceError: fault, + IdentityEngineError: fault, + UnknownError: fault, +} satisfies ProblemCases< + Exclude +>; + +/** The cases of `recruitmentProblems`, which answer an outage with `Unavailable`. */ +type RecruitmentCases = typeof recruitmentFixedCases & + CredentialCases<"UnauthenticatedActor"> & + OutageCases; + +/** + * The one answer for every recruitment failure. + * + * @remarks + * 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. + * + * @sideEffects none + * + * @example + * ```ts + * read.pipe(recruitmentProblems({ presentation: personPresentation(headers), unavailable: "recruitment.unavailable" })); + * ``` + * + * @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 rpc-problem + */ +export const recruitmentProblems = ({ + presentation, + unavailable, +}: { + readonly presentation: CredentialPresentation; + readonly unavailable: Unavailable; +}): ProblemMapper> => { + const outage = () => Problem.make(unavailable); + + return problemMapper()>({ + ...recruitmentFixedCases, + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + RecruitmentPersistenceError: outage, + InterviewQuestionsUnavailable: outage, + ProfileContactNotFound: outage, + }); +}; + +/** + * A failure that lost a serialization or deadlock race answers transaction.conflict, whatever + * failure carried it. + * + * @remarks + * 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. + * + * @sideEffects none + * + * @example + * ```ts + * command.pipe(raceProblems, recruitmentProblems({ presentation, unavailable: "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. + * + * @construct rpc-problem + */ +export const raceProblems = ( + effect: Effect.Effect, +): Effect.Effect, R> => + Effect.mapError(effect, (failure) => + isSerializationConflict(failure) ? Problem.make("transaction.conflict") : failure, + ); + +/** + * 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 +>()({ + RecruitmentMaintenanceFailure: ({ code }) => + Match.value(code).pipe( + Match.when("Denied", denied), + Match.when("NotFound", () => Problem.make("resource.not-found")), + Match.when("Stale", () => Problem.make("precondition.failed")), + Match.when("Conflict", replayConflict), + Match.when("Invalid", () => Problem.make("recruitment.invalid-command")), + Match.when("Ineligible", () => Problem.make("recruitment.ineligible")), + Match.when("Terminal", () => Problem.make("recruitment.terminal")), + Match.when("EmptyActiveQuestionnaire", () => + Problem.make("recruitment.empty-active-questionnaire"), + ), + Match.exhaustive, + ), + RecruitmentInterviewNotFound: () => Problem.make("resource.not-found"), + IdentityEngineError: () => Problem.make("recruitment.unavailable"), +}); + +/** Problems only choosing a department's one open admission period answers. */ +export const admissionPeriodProblems = [ + "recruitment.admission-period-not-found", + "application.ambiguous-period", +] as const; + +/** Problems only assigning an applicant answers. */ +export const assignmentProblems = [ + "recruitment.application-not-found", + "recruitment.interview-schema-not-found", + "recruitment.application-already-assigned", + "recruitment.interview-schema-inactive", +] as const; + +/** Problems only scheduling an interview answers. */ +export const schedulingProblems = [ + "recruitment.already-scheduled", + "recruitment.schedule-in-past", +] as const; + +/** Problems only reading or changing an interview's conduct answers. */ +export const conductProblems = [ + "recruitment.interview-not-scheduled", + "recruitment.invitation-not-accepted", + "recruitment.already-finalized", + "recruitment.already-cancelled", + "recruitment.conduct-invalid", +] as const; diff --git a/apps/backend/src/recruitment/reads.ts b/apps/backend/src/recruitment/reads.ts new file mode 100644 index 00000000..02645fc7 --- /dev/null +++ b/apps/backend/src/recruitment/reads.ts @@ -0,0 +1,345 @@ +/** 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, + ReadAssignmentBoard, + ReadInterviewConduct, + ReadInterviewReport, + ReadInvitationResponse, + ReadSchedulingBoard, + SchedulingBoard, + reflectAccessSpec, + type InvitationResponseResource, +} from "@vektorprogrammet/rpc"; +import { Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate, Schema } from "effect"; +import { currentInstant } from "../authority.js"; +import { credentialRequestOf } from "../rpc/credential.js"; +import { authorizePerson, personPresentation, strictOutput, unreachable } from "../rpc/problem.js"; +import { + actorDepartment, + authorizeInvitationOperation, + boardContext, + interviewAuthorizationInTransaction, +} from "./access.js"; +import { type RecruitmentCall, recruitmentBoardActor } from "./context.js"; +import { + admissionPeriodProblems, + assignmentProblems, + conductProblems, + raceProblems, + recruitmentProblems, + schedulingProblems, +} from "./problem.js"; +import { interviewETag, invitationETag, schedulingBoardWithETags } from "./representation.js"; + +/** + * 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 = ({ + headers, + payload, + options, +}: RecruitmentCall<{ readonly capability: string }>): Effect.Effect< + InvitationResponseResource, + Problem<"resource.not-found"> | Problem<"internal.error"> | Problem<"recruitment.unavailable">, + Recruitment +> => + Effect.gen(function* () { + 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(ReadInvitationResponse)), + source: snapshot.source, + authorizationInstant: now, + }); + + const observation = yield* strictOutput(InvitationResponseObservation)(snapshot.observation); + + return { observation, etag: invitationETag(snapshot.source) }; + }).pipe( + 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", + ...schedulingProblems, + ...conductProblems, + "invitation.already-responded", + "idempotency.digest-conflict", + ), + ); + +export const readAssignmentBoard = ({ + headers, + payload: query, + options, +}: RecruitmentCall) => { + const presentation = personPresentation(headers); + + return Effect.gen(function* () { + 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(ReadAssignmentBoard)), + request: credentialRequestOf(headers), + personId: actor.personId, + resolution: { + selection: "AllMatching", + contexts: [ + boardContext({ + actor, + facts: { + departmentAdministratorPersonIds: + Predicate.isTagged(actor, "DepartmentAdministrator") && actor.active + ? [actor.personId] + : [], + }, + version: now, + }), + ], + }, + grantScopes: + departmentId === null + ? [Scope.Domain({ domainId: DomainId.make("recruitment") })] + : [Scope.Department({ departmentId })], + now, + }, + presentation, + ); + + const observation = yield* Recruitment.use(({ readAssignmentBoard: read }) => + read(query, { actor, now }), + ); + + return yield* strictOutput(AssignmentBoard)(observation); + }).pipe( + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), + // A board answers no assignment, interview, or invitation problem. + unreachable( + ...assignmentProblems, + "recruitment.interview-not-found", + "precondition.failed", + ...schedulingProblems, + ...conductProblems, + "resource.not-found", + "invitation.already-responded", + "idempotency.digest-conflict", + ), + ); +}; + +export const readInterviewReport = ({ + headers, + payload: query, + options, +}: RecruitmentCall) => { + const presentation = personPresentation(headers); + + return Effect.gen(function* () { + 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), + ); + + yield* authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(ReadInterviewReport)), + request: credentialRequestOf(headers), + personId: actor.personId, + resolution: { + selection: "AllMatching", + contexts: [ + boardContext({ + actor, + facts: { departmentAdministratorPersonIds: [actor.personId] }, + version: now, + }), + ], + }, + grantScopes: [Scope.Department({ departmentId: actor.departmentId })], + now, + }, + presentation, + ); + + const observation = yield* Recruitment.use((service) => + service.readCompletedInterviewReport(actor.personId, now, query), + ); + + return yield* strictOutput(InterviewReport)(observation); + }).pipe( + // The report locks applicant custody, so it answers a lost race as a conflict. + raceProblems, + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + "recruitment.interview-not-found", + "precondition.failed", + ...schedulingProblems, + ...conductProblems, + "resource.not-found", + "invitation.already-responded", + "idempotency.digest-conflict", + ), + ); +}; + +export const readSchedulingBoard = ({ headers, options }: RecruitmentCall) => { + const presentation = personPresentation(headers); + + return Effect.gen(function* () { + 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(ReadSchedulingBoard)), + request: credentialRequestOf(headers), + personId: actor.personId, + resolution: { + selection: "AllMatching", + contexts: [ + boardContext({ + actor, + facts: { + departmentMemberPersonIds: + !Predicate.isTagged(actor, "GlobalAdmin") && actor.active ? [actor.personId] : [], + }, + version: now, + }), + ], + }, + grantScopes: + departmentId === null + ? [Scope.Domain({ domainId: DomainId.make("recruitment") })] + : [Scope.Department({ departmentId })], + now, + }, + presentation, + ); + + const observation = yield* Recruitment.use(({ readSchedulingBoard: read }) => + read({ actor, now }), + ); + + const authority = yield* Recruitment.use((service) => + service.readPersonAuthoritySources(actor.personId), + ); + + return yield* strictOutput(SchedulingBoard)( + schedulingBoardWithETags({ board: observation, authority }), + ); + }).pipe( + recruitmentProblems({ presentation: presentation, unavailable: "recruitment.unavailable" }), + // Every interview on the board belongs to an application that cannot vanish. + unreachable( + ...admissionPeriodProblems, + ...assignmentProblems, + "recruitment.interview-not-found", + "precondition.failed", + ...schedulingProblems, + ...conductProblems, + "resource.not-found", + "invitation.already-responded", + "idempotency.digest-conflict", + ), + ); +}; + +export const readInterviewConduct = ({ + headers, + payload: { interviewId }, + options, +}: RecruitmentCall<{ readonly interviewId: RecruitmentInterviewId }>) => + Effect.gen(function* () { + const snapshot = yield* Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + const authorization = yield* interviewAuthorizationInTransaction({ + headers, + interviewId, + rpc: ReadInterviewConduct, + allowLeader: false, + now: options.config.recruitment.now, + }); + + const now = yield* currentInstant(options.config.recruitment.now); + + const observation = yield* Recruitment.use((service) => + service.readInterviewConduct(interviewId, { + actor: authorization.actor, + now, + authorizationInstant: authorization.authorizationInstant, + }), + ); + + const source = yield* Recruitment.use((service) => + service.readInterviewSource(interviewId, authorization.actor.personId), + ); + + return { observation, source }; + }), + ), + ).pipe(Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error")))); + + const detail = yield* strictOutput(ConductObservation)(snapshot.observation); + + return { detail, etag: interviewETag(snapshot.source) }; + }).pipe( + 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", + "resource.not-found", + "invitation.already-responded", + "idempotency.digest-conflict", + ), + ); diff --git a/apps/backend/src/recruitment/representation.ts b/apps/backend/src/recruitment/representation.ts new file mode 100644 index 00000000..d4442bb2 --- /dev/null +++ b/apps/backend/src/recruitment/representation.ts @@ -0,0 +1,55 @@ +/** Recruitment representations: the strong entity tags of invitations and interviews. */ +import type { + RecruitmentAuthorityHttpSource, + RecruitmentInterviewHttpSource, + RecruitmentInvitationHttpSource, + RecruitmentSchedulingBoard, +} from "@vektorprogrammet/domain/recruitment"; +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", + resourceIdentity: `recruitment-invitation:${source.invitationId}`, + 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 => + deriveStrongETag({ + representationKind: "RecruitmentInterviewResource", + resourceIdentity: `recruitment-interview:${source.interviewId}`, + version: [ + source.interviewRevision, + source.coInterviewerPersonId ?? "Absent", + source.authority.map((item) => [item.kind, item.identity, item.revisions]), + ], + }); + +/** 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, + etag: interviewETag({ + interviewId: interview.interviewId, + departmentId: interview.departmentId, + interviewerPersonId: interview.interviewer.personId, + coInterviewerPersonId: + interview.coInterviewer === null ? null : interview.coInterviewer.personId, + interviewRevision: interview.revision, + authority, + }), + })), +}); diff --git a/apps/backend/src/recruitment/rpc.ts b/apps/backend/src/recruitment/rpc.ts index e2f75fbe..fe057e07 100644 --- a/apps/backend/src/recruitment/rpc.ts +++ b/apps/backend/src/recruitment/rpc.ts @@ -1,8 +1,64 @@ /** - * The RecruitmentRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/recruitment/http*.ts and recruitment/maintenance-http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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"; -export const RecruitmentRpcHandlers = (_options: NativeRpcOptions) => RecruitmentRpcs.toLayer({}); +/** 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/packages/rpc/src/onboarding.ts b/packages/rpc/src/onboarding.ts index 33b32d38..d66ffeaa 100644 --- a/packages/rpc/src/onboarding.ts +++ b/packages/rpc/src/onboarding.ts @@ -1,11 +1,164 @@ /** - * Onboarding: coordinator invitations and claims. - * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/onboarding.ts` at the base commit named there. + * Onboarding: coordinators invite admitted applicants to an account, and an applicant claims the + * invitation. * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class OnboardingRpcs extends RpcGroup.make( + ReadOnboardingBoard, + CommandOnboarding, + ClaimOnboarding, +) {} diff --git a/packages/rpc/src/recruitment.ts b/packages/rpc/src/recruitment.ts index e121c6b4..462600a4 100644 --- a/packages/rpc/src/recruitment.ts +++ b/packages/rpc/src/recruitment.ts @@ -1,11 +1,683 @@ /** - * Recruitment: assignments, interviews, invitations, maintenance. - * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/recruitment.ts` at the base commit named there. + * Recruitment: applicant assignment, interview scheduling and conduct, invitation responses, and + * maintenance of questionnaires and interview staffing. * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class RecruitmentRpcs extends RpcGroup.make( + ReadInvitationResponse, + ConfirmInvitation, + RejectInvitation, + RequestNewInvitationTime, + ReadAssignmentBoard, + ReadSchedulingBoard, + ReadInterviewReport, + CreateApplicationInterview, + ScheduleInterview, + ReadInterviewConduct, + FinalizeInterview, + CorrectInterviewAssessment, + CancelInterview, + ReadQuestionnaires, + ReadInterviewStaffing, + MaintainRecruitment, +) {} From e017441697ebc99b82156f4fbcf62cea98e0ccd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:44:14 +0000 Subject: [PATCH 034/148] refactor(rpc): clean the foundation and first wave under every Effect rule Every file of packages/rpc/src and apps/backend/src outside the recruitment, onboarding, receipt, and placements slices now lints clean under the full @effect/tsgo rule set. - missing-pipeable-signature: each exported function of two or more parameters is a `dual` with an overload pair. Fixed arities use `dual(n)`; optional trailing parameters use the predicate form, which recognizes the data argument (a Request, an Effect, a registry code, a scope resolver, a delivery config). Data-first calls keep their meaning. - isSerializationConflict takes one argument; the depth moved into a private recursive helper. publicRateLimit takes both of its arguments, which both callers already passed. - new-schema-class: `X.make(...)` for Schema classes and tagged errors. - strict-boolean-expressions: explicit `=== undefined` and `.length` checks. - strict-effect-provide: ContactRpcHandlers builds its stateless quota and delivery once with the handlers and provides the built context per call. - service-not-as-class and deterministic-keys: AccessSpecAnnotation is a Context.Service class; both service keys follow the module path. - docs/constructs: delivery and worker pages rendered for the new signatures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/admission/access.ts | 147 +++-- .../admission/admission-period.rpc.test.ts | 10 +- apps/backend/src/admission/commands.ts | 89 ++- apps/backend/src/admission/context.ts | 58 +- .../backend/src/admission/outcome.rpc.test.ts | 10 +- apps/backend/src/admission/problem.ts | 46 +- apps/backend/src/admission/reads.ts | 78 ++- apps/backend/src/application/effects.ts | 4 +- apps/backend/src/application/worker.ts | 72 ++- apps/backend/src/authority.schools.test.ts | 8 +- apps/backend/src/authority.ts | 515 ++++++++++++------ apps/backend/src/config.ts | 48 +- apps/backend/src/contact/config.ts | 18 +- apps/backend/src/contact/rpc.test.ts | 2 +- apps/backend/src/contact/rpc.ts | 36 +- apps/backend/src/delivery/http.ts | 70 ++- apps/backend/src/directory/rpc.test.ts | 32 +- apps/backend/src/directory/rpc.ts | 2 +- .../backend/src/directory/schools.rpc.test.ts | 15 +- apps/backend/src/http-semantics.ts | 173 +++--- apps/backend/src/mail/cloudflare.ts | 8 +- apps/backend/src/mail/http.ts | 16 +- apps/backend/src/native-operation.ts | 75 +-- apps/backend/src/organization/rpc.test.ts | 14 +- .../organization/team-interest.rpc.test.ts | 6 +- .../src/password-recovery/drain-main.ts | 2 +- apps/backend/src/profile/rpc.test.ts | 8 +- apps/backend/src/profile/rpc.ts | 2 +- apps/backend/src/rpc/problem.ts | 217 +++++--- apps/backend/src/rpc/public-rate-limit.ts | 9 +- apps/backend/src/rpc/read-json.ts | 13 +- apps/backend/src/rpc/receipt-transaction.ts | 203 ++++--- apps/backend/src/rpc/system.test.ts | 12 +- apps/backend/src/session-security.ts | 55 +- apps/backend/src/social-events/rpc.ts | 2 +- apps/backend/src/team-application/worker.ts | 24 +- apps/backend/src/worker-support.ts | 38 +- docs/constructs/delivery.md | 19 +- docs/constructs/worker.md | 17 +- packages/rpc/src/access.ts | 141 ++--- packages/rpc/src/client.ts | 2 +- packages/rpc/src/problem.ts | 149 +++-- 42 files changed, 1598 insertions(+), 867 deletions(-) diff --git a/apps/backend/src/admission/access.ts b/apps/backend/src/admission/access.ts index 86c694f8..ab2d8ffc 100644 --- a/apps/backend/src/admission/access.ts +++ b/apps/backend/src/admission/access.ts @@ -28,6 +28,7 @@ import { genericContext, type NativePersonAuthorization } from "../native-operat 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") @@ -64,13 +65,32 @@ export const returningPersonResource = (personId: string) => * * @construct rpc-problem */ -export const authorizeAdmissionPerson = ( - 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")); +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 @@ -95,45 +115,78 @@ export const authorizeAdmissionPerson = ( * * @construct rpc-problem */ -export const returningAuthorization = ( - 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 }, - ); +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, - }); + 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; - }); + return authorization; + }), +); diff --git a/apps/backend/src/admission/admission-period.rpc.test.ts b/apps/backend/src/admission/admission-period.rpc.test.ts index 19e52045..53ef7784 100644 --- a/apps/backend/src/admission/admission-period.rpc.test.ts +++ b/apps/backend/src/admission/admission-period.rpc.test.ts @@ -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"), diff --git a/apps/backend/src/admission/commands.ts b/apps/backend/src/admission/commands.ts index c1eeb7ab..077df9b2 100644 --- a/apps/backend/src/admission/commands.ts +++ b/apps/backend/src/admission/commands.ts @@ -64,6 +64,7 @@ import { } 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; @@ -82,7 +83,7 @@ const managementItem = (period: Omit): ManagementItem => }), }); -export const registerReturningAssistant = ( +const registerReturningAssistantEffect = ( headers: Headers.Headers, payload: { readonly idempotencyKey: IdempotencyKey; @@ -154,7 +155,25 @@ export const registerReturningAssistant = ( return yield* commandOutcome(ReturningAssistantRegistrationResponseSchema)(outcome); }).pipe(admissionProblems(headers, "returning.unavailable"), commandReceiptProblems); -export const createAdmissionPeriod = ( +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; @@ -246,6 +265,24 @@ export const createAdmissionPeriod = ( 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. @@ -270,7 +307,7 @@ const interpretPatch = (patch: AdmissionPeriodMergePatch) => return { source, startAt: patch.startAt ?? undefined, endAt: patch.endAt ?? undefined }; }); -export const reviseAdmissionPeriod = ( +const reviseAdmissionPeriodEffect = ( headers: Headers.Headers, payload: { readonly admissionPeriodId: AdmissionPeriodId; @@ -316,7 +353,7 @@ export const reviseAdmissionPeriod = ( const current = periods.find((period) => period.id === admissionPeriodId); if (current === undefined) { - return yield* new AdmissionPeriodNotFound({ admissionPeriodId }); + return yield* AdmissionPeriodNotFound.make({ admissionPeriodId }); } yield* authorizeAdmissionPerson(headers, { @@ -386,7 +423,29 @@ export const reviseAdmissionPeriod = ( unreachable("admission-period.already-exists"), ); -export const submitApplication = ( +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; @@ -400,7 +459,7 @@ export const submitApplication = ( const now = yield* currentInstant(config.now); if (!config.rateLimit.consume(publicRateLimitKey(credentialRequestOf(headers)), now)) { - return yield* new PublicApplicationRateLimitExceeded({}); + return yield* PublicApplicationRateLimitExceeded.make({}); } const operationId = "admissions.submitApplication"; @@ -477,3 +536,21 @@ export const submitApplication = ( // 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/context.ts b/apps/backend/src/admission/context.ts index a87739ec..6f671398 100644 --- a/apps/backend/src/admission/context.ts +++ b/apps/backend/src/admission/context.ts @@ -9,13 +9,14 @@ import { DepartmentId, type OrganizationPersonAuthority, } from "@vektorprogrammet/domain/organization"; -import { Effect, Schema } from "effect"; +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(new InactiveActor({ personId: actor.personId })); + : Effect.fail(InactiveActor.make({ personId: actor.personId })); const isAdmissionActorDenial = Schema.is( Schema.Union([InactiveActor, AdmissionScopeDenied, AdmissionRoleDenied]), @@ -42,20 +43,39 @@ const isAdmissionActorDenial = Schema.is( * * @construct rpc-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)); +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/outcome.rpc.test.ts b/apps/backend/src/admission/outcome.rpc.test.ts index 6b54d25d..02620650 100644 --- a/apps/backend/src/admission/outcome.rpc.test.ts +++ b/apps/backend/src/admission/outcome.rpc.test.ts @@ -70,7 +70,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"), @@ -81,7 +81,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"), @@ -94,7 +96,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"), diff --git a/apps/backend/src/admission/problem.ts b/apps/backend/src/admission/problem.ts index 958823ad..43343717 100644 --- a/apps/backend/src/admission/problem.ts +++ b/apps/backend/src/admission/problem.ts @@ -17,6 +17,7 @@ import { problemMapper, requestInvalid, } 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; @@ -123,24 +124,35 @@ type AdmissionCases = typeof admission * * @construct rpc-problem */ -export const admissionProblems = ( - headers: Headers.Headers, - unavailable: Unavailable, -): ProblemMapper> => { - const presentation = personPresentation(headers); +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 diff --git a/apps/backend/src/admission/reads.ts b/apps/backend/src/admission/reads.ts index 8404ddfb..33fb1d00 100644 --- a/apps/backend/src/admission/reads.ts +++ b/apps/backend/src/admission/reads.ts @@ -41,11 +41,9 @@ import { } from "./access.js"; import { admissionActorForAuthority } from "./context.js"; import { admissionProblems, periodCommandProblems, submissionProblems } from "./problem.js"; +import { dual } from "effect/Function"; -export const readReturningAssistantOptions = ( - headers: Headers.Headers, - options: NativeRpcOptions, -) => +const readReturningAssistantOptionsEffect = (headers: Headers.Headers, options: NativeRpcOptions) => Effect.gen(function* () { const authorization = yield* returningAuthorization( headers, @@ -71,7 +69,17 @@ export const readReturningAssistantOptions = ( ), ); -export const listAdmissionPeriods = (headers: Headers.Headers, options: NativeRpcOptions) => +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, @@ -128,7 +136,17 @@ export const listAdmissionPeriods = (headers: Headers.Headers, options: NativeRp unreachable(...periodCommandProblems, "precondition.failed"), ); -export const listOpenAdmissionPeriods = (headers: Headers.Headers, options: NativeRpcOptions) => +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); @@ -172,7 +190,17 @@ export const listOpenAdmissionPeriods = (headers: Headers.Headers, options: Nati ), ); -export const listApplicationOptions = (headers: Headers.Headers, options: NativeRpcOptions) => +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); @@ -200,7 +228,17 @@ export const listApplicationOptions = (headers: Headers.Headers, options: Native unreachable(...submissionProblems, "application.not-found"), ); -export const readApplicationConfirmation = ( +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, @@ -235,7 +273,19 @@ export const readApplicationConfirmation = ( unreachable(...submissionProblems), ); -export const readApplicantProgress = (headers: Headers.Headers, options: NativeRpcOptions) => +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* () { @@ -282,3 +332,13 @@ export const readApplicantProgress = (headers: Headers.Headers, options: NativeR 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/application/effects.ts b/apps/backend/src/application/effects.ts index 8c758083..9b4a2e1e 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 3bfddfa3..cf7b81fd 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 f68fa6dd..0c51a31e 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 f12ff506..72d1376a 100644 --- a/apps/backend/src/authority.ts +++ b/apps/backend/src/authority.ts @@ -52,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; @@ -71,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" })), ), ); @@ -81,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; @@ -102,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) { @@ -110,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" })), ), ); } @@ -133,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 = ( @@ -149,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) { @@ -159,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 }) => @@ -178,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" })), ), ); }; @@ -214,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 = ( @@ -248,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; @@ -295,117 +357,248 @@ 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. @@ -420,18 +613,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); +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 @@ -472,8 +669,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 }); }; /** @@ -496,6 +693,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 d28cdb72..ea10ea34 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -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,7 @@ 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 +418,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/config.ts b/apps/backend/src/contact/config.ts index fe0c84d1..ec75d400 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/rpc.test.ts b/apps/backend/src/contact/rpc.test.ts index 89228df8..64651594 100644 --- a/apps/backend/src/contact/rpc.test.ts +++ b/apps/backend/src/contact/rpc.test.ts @@ -33,7 +33,7 @@ const message = ContactMessage.make({ /** An Organization in which no department can receive a message, so no delivery is attempted. */ const organization = Layer.mock(Organization, { - readDepartment: (departmentId) => Effect.fail(new DepartmentNotFound({ departmentId })), + readDepartment: (departmentId) => Effect.fail(DepartmentNotFound.make({ departmentId })), }); /** The contact RPC as the homepage server reaches it, with or without delivery configured. */ diff --git a/apps/backend/src/contact/rpc.ts b/apps/backend/src/contact/rpc.ts index 1a88824b..454ce91d 100644 --- a/apps/backend/src/contact/rpc.ts +++ b/apps/backend/src/contact/rpc.ts @@ -65,20 +65,30 @@ const decodeVisitorIp = Schema.decodeUnknownEffect(ContactVisitorIp); export const ContactRpcHandlers = (options: NativeRpcOptions) => { const config = options.config.contact; - const services = - config === undefined ? undefined : Layer.merge(ContactQuotaLive, deliveryFor(config)); + 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.toLayer({ - "contact.submitContactMessage": (message, { headers }) => - Effect.gen(function* () { - const ip = yield* decodeVisitorIp(headers[CONTACT_IP_HEADER]).pipe( - Effect.mapError(() => Problem.make("header.malformed")), - ); + 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"); + // 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.provide(services), contactProblems); - }), - }); + yield* submitContact(message, ip).pipe( + Effect.provideContext(services), + contactProblems, + ); + }), + }); + }), + ); }; diff --git a/apps/backend/src/delivery/http.ts b/apps/backend/src/delivery/http.ts index 9fd84958..10b33ca1 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/rpc.test.ts b/apps/backend/src/directory/rpc.test.ts index bb7c9454..9be8841e 100644 --- a/apps/backend/src/directory/rpc.test.ts +++ b/apps/backend/src/directory/rpc.test.ts @@ -106,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) => @@ -160,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; } @@ -168,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), }); } @@ -244,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 }] : []; }); @@ -334,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"), diff --git a/apps/backend/src/directory/rpc.ts b/apps/backend/src/directory/rpc.ts index 29b21e1c..3a971810 100644 --- a/apps/backend/src/directory/rpc.ts +++ b/apps/backend/src/directory/rpc.ts @@ -322,7 +322,7 @@ export const DirectoryRpcHandlers = (options: NativeRpcOptions) => const operationId = "directory.executeSchoolCommand"; if (idempotencyKey !== command.commandId) - return yield* new SchoolCommandFailure({ code: "Conflict" }); + return yield* SchoolCommandFailure.make({ code: "Conflict" }); // Domain and credential failures are mapped after the executor, whose retry reads their // causes. diff --git a/apps/backend/src/directory/schools.rpc.test.ts b/apps/backend/src/directory/schools.rpc.test.ts index 48cac65f..c7d4e80e 100644 --- a/apps/backend/src/directory/schools.rpc.test.ts +++ b/apps/backend/src/directory/schools.rpc.test.ts @@ -78,15 +78,15 @@ const oauthCredentialAuthority = OAuthCredentialAuthority.of({ const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), resolveSession: (cookieHeader: string | undefined) => - cookieHeader?.includes(SESSION) + cookieHeader?.includes(SESSION) === true ? Effect.succeed( - new IdentityActor({ + IdentityActor.make({ personId, 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"), @@ -106,7 +106,7 @@ const makeBackend = ( departmentId, ) => Effect.succeed( - new Department({ + Department.make({ departmentId, name: "Fixture department", shortName: "Fixture", @@ -290,7 +290,8 @@ describe("Schools directory RPC", () => { expectedStatus: 422, expectedTag: "SchoolsDepartmentNotFound", departmentId: departmentB, - readDepartment: (departmentId) => Effect.fail(new DepartmentNotFound({ departmentId })), + readDepartment: (departmentId) => + Effect.fail(DepartmentNotFound.make({ departmentId })), }, { name: "outside scope", @@ -304,7 +305,7 @@ describe("Schools directory RPC", () => { expectedTag: "SchoolsPersistenceError", listDirectory: () => Effect.fail( - new SchoolsPersistenceError({ + SchoolsPersistenceError.make({ operation: "read Schools directory", message: "unavailable", }), @@ -316,7 +317,7 @@ describe("Schools directory RPC", () => { expectedTag: "SchoolsDecodeError", listDirectory: () => Effect.fail( - new SchoolsDecodeError({ + SchoolsDecodeError.make({ operation: "decode Schools directory rows", message: "malformed row", }), diff --git a/apps/backend/src/http-semantics.ts b/apps/backend/src/http-semantics.ts index d10760ce..aa174837 100644 --- a/apps/backend/src/http-semantics.ts +++ b/apps/backend/src/http-semantics.ts @@ -14,6 +14,7 @@ import { StrongETag as StrongETagSchema, } 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/mail/cloudflare.ts b/apps/backend/src/mail/cloudflare.ts index 40b9d532..045f8704 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 3ce8a08e..e0872ed7 100644 --- a/apps/backend/src/mail/http.ts +++ b/apps/backend/src/mail/http.ts @@ -12,17 +12,17 @@ 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 +39,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/native-operation.ts b/apps/backend/src/native-operation.ts index 4d94d388..1cce675c 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/organization/rpc.test.ts b/apps/backend/src/organization/rpc.test.ts index e8fcc9f5..c4df7e99 100644 --- a/apps/backend/src/organization/rpc.test.ts +++ b/apps/backend/src/organization/rpc.test.ts @@ -142,12 +142,12 @@ const organization = { createCalls += 1; if (command.name === "Conflict") { - return Effect.fail(new OrganizationCommandConflict({ commandId: command.commandId })); + return Effect.fail(OrganizationCommandConflict.make({ commandId: command.commandId })); } if (command.name === "Unavailable") { return Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "createDepartment", message: "database unavailable", }), @@ -160,7 +160,7 @@ const organization = { createCalls += 1; if (command.departmentId === "department-unknown") { - return Effect.fail(new OrganizationInvalidReference({ referenceKind: "Department" })); + return Effect.fail(OrganizationInvalidReference.make({ referenceKind: "Department" })); } return Effect.succeed({ @@ -185,9 +185,9 @@ const organization = { } satisfies Partial; const sessionPerson = (cookieHeader: string | undefined) => - cookieHeader?.includes(ADMIN_SESSION) + cookieHeader?.includes(ADMIN_SESSION) === true ? PersonId.make("person-admin") - : cookieHeader?.includes(MEMBER_SESSION) + : cookieHeader?.includes(MEMBER_SESSION) === true ? PersonId.make("person-member") : undefined; @@ -195,9 +195,9 @@ const sessionActor = (cookieHeader: string | undefined) => { const personId = sessionPerson(cookieHeader); return personId === undefined - ? Effect.fail(new IdentitySessionNotFound()) + ? Effect.fail(IdentitySessionNotFound.make()) : Effect.succeed( - new IdentityActor({ + IdentityActor.make({ personId, sessionId: "organization-rpc-session", expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), diff --git a/apps/backend/src/organization/team-interest.rpc.test.ts b/apps/backend/src/organization/team-interest.rpc.test.ts index 5049542b..47dacdc2 100644 --- a/apps/backend/src/organization/team-interest.rpc.test.ts +++ b/apps/backend/src/organization/team-interest.rpc.test.ts @@ -267,14 +267,14 @@ const oauthCredentialAuthority = OAuthCredentialAuthority.of({ const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), resolveSession: (cookieHeader: string | undefined) => { - const token = cookieHeader?.startsWith(tokenPrefix) + const token = cookieHeader?.startsWith(tokenPrefix) === true ? cookieHeader.slice(tokenPrefix.length) : ""; return token.length === 0 - ? Effect.fail(new IdentitySessionNotFound()) + ? Effect.fail(IdentitySessionNotFound.make()) : Effect.succeed( - new IdentityActor({ + IdentityActor.make({ personId: PersonId.make(token), sessionId: "team-interest-session", expiresAt: DateTime.makeUnsafe("2031-09-16T12:00:00.000Z"), diff --git a/apps/backend/src/password-recovery/drain-main.ts b/apps/backend/src/password-recovery/drain-main.ts index 06e17804..8cb56205 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/profile/rpc.test.ts b/apps/backend/src/profile/rpc.test.ts index 04780eaf..55e4810c 100644 --- a/apps/backend/src/profile/rpc.test.ts +++ b/apps/backend/src/profile/rpc.test.ts @@ -48,7 +48,7 @@ const authority = ( const member = authority("Absent", [{ active: true, unitLeader: false }]); -const actor = new IdentityActor({ +const actor = IdentityActor.make({ personId, sessionId: "profile-test-session", expiresAt: DateTime.makeUnsafe("2032-04-02T12:00:00.000Z"), @@ -59,7 +59,7 @@ const identity = Identity.of({ resolveSession: (cookieHeader: string | undefined) => cookieHeader?.includes("profile-test-session") === true ? Effect.succeed(actor) - : 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"), @@ -78,7 +78,7 @@ const securityServices = Layer.mergeAll( .resolveSession(cookieHeader) .pipe( Effect.catchTag("IdentitySessionExpired", () => - Effect.fail(new IdentitySessionNotFound()), + Effect.fail(IdentitySessionNotFound.make()), ), ), }), @@ -145,7 +145,7 @@ describe("profile.readOwnProfile", () => { Effect.gen(function* () { const client = yield* backendFor( Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "resolve profile test authority", message: "provider unavailable", }), diff --git a/apps/backend/src/profile/rpc.ts b/apps/backend/src/profile/rpc.ts index 57bac459..e765fe8e 100644 --- a/apps/backend/src/profile/rpc.ts +++ b/apps/backend/src/profile/rpc.ts @@ -104,7 +104,7 @@ const profileActor = (authority: OrganizationPersonAuthority) => { if (Predicate.isTagged(decision, "Deny")) { return Effect.fail( decision.reason === "Unauthenticated" - ? new UnauthenticatedActor({ message: "Authentication required" }) + ? UnauthenticatedActor.make({ message: "Authentication required" }) : Problem.make("authority.denied"), ); } diff --git a/apps/backend/src/rpc/problem.ts b/apps/backend/src/rpc/problem.ts index ef1a7ce0..e3fd6d5e 100644 --- a/apps/backend/src/rpc/problem.ts +++ b/apps/backend/src/rpc/problem.ts @@ -37,6 +37,7 @@ import { type NativeHttpReceiptInvalid, type NativeHttpReceiptPersistenceError, } from "./receipt-transaction.js"; +import { dual } from "effect/Function"; const JsonText = Schema.fromJsonString(Schema.Unknown); @@ -66,16 +67,29 @@ export const jsonText = (value: A): Effect.Effect => /** * 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, - }); +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. @@ -98,19 +112,32 @@ export const classifyCredential = ( * * @construct rpc-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)); - } - }); +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; @@ -250,13 +277,31 @@ export const commandIdentity = ( * * @construct rpc-problem */ -export const requireCurrentETag = ( - current: StrongETag, - ifMatch: StrongETag, -): Effect.Effect> => - Predicate.isTagged(evaluateMutationPrecondition(current, ifMatch), "Failed") - ? Effect.fail(Problem.make("precondition.failed")) - : Effect.void; +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 @@ -279,14 +324,8 @@ export const requireCurrentETag = ( * * @construct rpc-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))); +export const isSerializationConflict = (cause: unknown): boolean => + serializationConflictWithin(cause, 0); /** * The request as a whole fails validation; no single member is singled out. @@ -390,10 +429,14 @@ export const commandReceiptProblems: ProblemMapper< * * @construct rpc-problem */ -export const personPresentation = ( - headers: Headers.Headers, - challenge: string = nativeUserChallenges(), -): CredentialPresentation => classifyCredential(headers.authorization, headers.cookie, challenge); +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 @@ -488,16 +531,29 @@ export const ProblemBoundaryLive = Layer.succeed(ProblemBoundary)( * * @construct rpc-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}`)), +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. @@ -521,25 +577,50 @@ export const authorizeAnonymous = ( * * @construct rpc-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")), +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 diff --git a/apps/backend/src/rpc/public-rate-limit.ts b/apps/backend/src/rpc/public-rate-limit.ts index e1a0dff2..a7900c60 100644 --- a/apps/backend/src/rpc/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/rpc/read-json.ts b/apps/backend/src/rpc/read-json.ts index 8a6ae26c..1dfa97eb 100644 --- a/apps/backend/src/rpc/read-json.ts +++ b/apps/backend/src/rpc/read-json.ts @@ -1,6 +1,7 @@ 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/rpc/receipt-transaction.ts b/apps/backend/src/rpc/receipt-transaction.ts index b1b95a87..46a534b7 100644 --- a/apps/backend/src/rpc/receipt-transaction.ts +++ b/apps/backend/src/rpc/receipt-transaction.ts @@ -3,6 +3,7 @@ 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; @@ -248,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) => @@ -375,12 +397,25 @@ export const successCapsule = ); /** The success that `successCapsule` stored. A stored value that no longer decodes is a defect. */ -export const decodeStoredSuccess = >( - 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); +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/system.test.ts b/apps/backend/src/rpc/system.test.ts index b0b56e10..c8c26538 100644 --- a/apps/backend/src/rpc/system.test.ts +++ b/apps/backend/src/rpc/system.test.ts @@ -19,7 +19,7 @@ import { makeBackendTestRpc } from "../test/native-rpc.js"; const token = "better-auth.session_token"; -const currentSession = new IdentitySession({ +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"), @@ -29,7 +29,7 @@ const currentSession = new IdentitySession({ current: true, }); -const actor = new IdentityActor({ +const actor = IdentityActor.make({ personId: PersonId.make("member-1"), sessionId: "session-1", expiresAt: currentSession.expiresAt, @@ -41,7 +41,7 @@ const identityOperations = (overrides: Partial = {}): Identi resolveSession: (cookieHeader) => cookieHeader?.split(/;\s*/u).includes(`${token}=valid-session`) === true ? Effect.succeed(actor) - : Effect.fail(new IdentitySessionNotFound()), + : Effect.fail(IdentitySessionNotFound.make()), readCurrentSession: () => Effect.succeed(currentSession), listSessions: () => Effect.succeed([currentSession]), revokeCurrentSession: () => Effect.succeed({ setCookies: [] }), @@ -67,7 +67,7 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked .resolveSession(cookieHeader) .pipe( Effect.catchTag("IdentitySessionExpired", () => - Effect.fail(new IdentitySessionNotFound()), + Effect.fail(IdentitySessionNotFound.make()), ), ), revokeCurrentSession: (_actor, request) => @@ -82,7 +82,7 @@ const backendFor = (identity: IdentityOperations, recorded: Recorded = { revoked Effect.sync(() => recorded.revoked.push({ operation: sessionId, request })), ), Effect.catchTag(["IdentitySessionNotFound", "IdentitySessionExpired"], () => - Effect.fail(new IdentityOwnedSessionNotFound({ sessionId })), + Effect.fail(IdentityOwnedSessionNotFound.make({ sessionId })), ), ), revokeOtherSessions: (_actor, request) => @@ -253,7 +253,7 @@ describe("session RPCs", () => { return owned.delete(sessionId) ? Effect.succeed({ setCookies: [] }) - : Effect.fail(new IdentityOwnedSessionNotFound({ sessionId })); + : Effect.fail(IdentityOwnedSessionNotFound.make({ sessionId })); }), }), ).client; diff --git a/apps/backend/src/session-security.ts b/apps/backend/src/session-security.ts index 89fad93d..a762c9af 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,7 +187,7 @@ export const decideTrustedOrigin = ( return sameOriginBrowserRequest ? OriginDecision.Allowed({ origin: null }) : OriginDecision.Rejected(); -}; +}); /** * Browser-controlled headers supported by the current native API contract. @@ -225,22 +226,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 +260,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 +278,4 @@ export const withTrustedOriginCors = (response: Response, origin: string | null) statusText: response.statusText, headers, }); -}; +}); diff --git a/apps/backend/src/social-events/rpc.ts b/apps/backend/src/social-events/rpc.ts index 86f0c85a..a96b909f 100644 --- a/apps/backend/src/social-events/rpc.ts +++ b/apps/backend/src/social-events/rpc.ts @@ -149,7 +149,7 @@ const resolveSocialEventAuthority = ( ); if (!Predicate.isTagged(authenticated.credential.principal, "Person")) { - return yield* new UnauthenticatedActor({ message: "authentication required" }); + return yield* UnauthenticatedActor.make({ message: "authentication required" }); } const personId = authenticated.credential.principal.personId; diff --git a/apps/backend/src/team-application/worker.ts b/apps/backend/src/team-application/worker.ts index 1c10ced3..0a5b5374 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/worker-support.ts b/apps/backend/src/worker-support.ts index c447b069..25ea10db 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/docs/constructs/delivery.md b/docs/constructs/delivery.md index 246d5fe0..53d6efe0 100644 --- a/docs/constructs/delivery.md +++ b/docs/constructs/delivery.md @@ -9,22 +9,15 @@ Delivers committed effects to providers after the transaction. The [index](../co Shared acknowledged JSON transport; deliberately no retry on ambiguous acceptance. ```ts -deliverJson( - body: Schema.Json, - config: HttpDeliveryConfig, - headers: Readonly> = {} -): Effect.Effect +const deliverJson: { (body: Schema.Json, config: HttpDeliveryConfig, headers?: Readonly>): Effect.Effect; (config: HttpDeliveryConfig, headers?: Readonly>): (body: Schema.Json) => Effect.Effect } ``` -- Inputs: - - `body: Schema.Json` - - `config: HttpDeliveryConfig` - - `headers: Readonly> = {}` -- Output: `Effect.Effect` -- Errors: `HttpDeliveryFailure | Cause.TimeoutError` -- Requirements: `HttpClient.HttpClient` +- Inputs: none +- Output: `{ (body: Schema.Json, config: HttpDeliveryConfig, headers?: Readonly>): Effect.Effect; (config: HttpDeliveryConfig, headers?: Readonly>): (body: Schema.Json) => Effect.Effect }` +- Errors: none +- Requirements: none - 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/worker.md b/docs/constructs/worker.md index 09622891..9c097b89 100644 --- a/docs/constructs/worker.md +++ b/docs/constructs/worker.md @@ -9,20 +9,15 @@ Runs background workers on the Effect clock. The [index](../constructs.md) lists Runs `tick` at once, then again after each success. ```ts -pollForever( - tick: Effect.Effect, - options: PollOptions -): Effect.Effect +const pollForever: { (options: PollOptions): (tick: Effect.Effect) => Effect.Effect; (tick: Effect.Effect, options: PollOptions): Effect.Effect } ``` -- Inputs: - - `tick: Effect.Effect` - - `options: PollOptions` -- Output: `Effect.Effect` -- Errors: `E` -- Requirements: `R` +- Inputs: none +- Output: `{ (options: PollOptions): (tick: Effect.Effect) => Effect.Effect; (tick: Effect.Effect, options: PollOptions): Effect.Effect }` +- Errors: none +- Requirements: none - 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/packages/rpc/src/access.ts b/packages/rpc/src/access.ts index 9a074b0d..2937754c 100644 --- a/packages/rpc/src/access.ts +++ b/packages/rpc/src/access.ts @@ -12,7 +12,6 @@ import { type AuthorizationMode, type CapabilityExpression, CapabilityTypeId, - 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 => @@ -142,12 +158,14 @@ export const internalReceiptEvidenceAccess = (): AccessSpec => decisionTime: "SnapshotRead", }); -import { Match, Predicate, Context, Option, type Schema } from "effect"; +import { Match, Predicate, Context, Option, Schema } from "effect"; import type { Rpc, RpcMiddleware } from "effect/unstable/rpc"; +import { dual } from "effect/Function"; -export const AccessSpecAnnotation = Context.Service( - "@vektorprogrammet/rpc/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); @@ -266,12 +275,11 @@ export const assertAccessProjectionRegistryParity = (): void => { assertAccessProjectionRegistryParity(); - /** * 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 => { +export const accessSpecAnnotations = (input: AccessSpec): Context.Context => { const spec = makeAccessSpec(input); const acceptsPerson = spec.acceptedCredentials.some( @@ -298,8 +306,9 @@ export const accessSpecAnnotations = (input: AccessSpec): Context.Context): Option.Option => - Context.getOption(rpc.annotations, AccessSpecAnnotation); +export const reflectAccessSpec = ( + 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 = diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts index 8808f4d3..5be2bc88 100644 --- a/packages/rpc/src/client.ts +++ b/packages/rpc/src/client.ts @@ -15,7 +15,7 @@ const makeClient = RpcClient.make(NativeRpcs); export class NativeRpcClient extends Context.Service< NativeRpcClient, Effect.Success ->()("@vektorprogrammet/rpc/NativeRpcClient") {} +>()("@vektorprogrammet/rpc/client/NativeRpcClient") {} /** * The client of the backend at `origin`. diff --git a/packages/rpc/src/problem.ts b/packages/rpc/src/problem.ts index 7f5561cc..e6bf333b 100644 --- a/packages/rpc/src/problem.ts +++ b/packages/rpc/src/problem.ts @@ -4,6 +4,7 @@ * @since 0.2.0 */ import { Data, ErrorReporter, Predicate, Struct, Schema, SchemaGetter, type Types } from "effect"; +import { dual } from "effect/Function"; export { parseJsonWithUniqueMembers } from "@vektorprogrammet/domain/http-semantics"; @@ -49,7 +50,6 @@ export const Sha256Hex = Schema.String.pipe( export type Sha256Hex = typeof Sha256Hex.Type; - const ValidationPointer = Schema.String.pipe( Schema.check( Schema.makeFilter( @@ -92,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( @@ -931,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" || @@ -982,21 +1005,30 @@ function problemVariant(code: NativeProblemCode) { * * @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; @@ -1316,12 +1348,20 @@ export const rpcProblems = (problem: Uni ).pipe( Schema.encodeTo(problem, { // SAFETY: the union above decoded one declared problem variant. - decode: SchemaGetter.transform((wire) => Problem.fromWire(wire as WireProblemBody, {})), + decode: SchemaGetter.transform((wire) => + Problem.fromWire(wire as WireProblemBody, {}), + ), encode: SchemaGetter.transform((value) => problemBody(value)), }), ); }; +/** The body of one registry problem, with its code and an optional instance. */ +type RegistryProblemBody = (typeof NativeProblemRegistry)[Code] & { + readonly code: Code; + readonly instance?: string; +}; + /** * Builds one safe fixed public problem value. * @@ -1345,19 +1385,32 @@ export const rpcProblems = (problem: Uni * * @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({ From 3607bab5b8625f4e71a592a81b9f99c0426c71ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:45:25 +0000 Subject: [PATCH 035/148] feat(rpc): serve the receipt operations as RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/commands.ts | 705 +++++++ apps/backend/src/receipt/context.ts | 40 + apps/backend/src/receipt/e2e-support.test.ts | 5 +- apps/backend/src/receipt/e2e-support.ts | 16 +- apps/backend/src/receipt/problem.ts | 113 ++ apps/backend/src/receipt/reads.ts | 551 ++++++ apps/backend/src/receipt/representation.ts | 188 ++ apps/backend/src/receipt/rpc.test.ts | 1863 ++++++++++++++++++ apps/backend/src/receipt/rpc.ts | 123 +- apps/backend/src/receipt/validate.ts | 135 ++ packages/rpc/src/receipts.ts | 582 +++++- 11 files changed, 4300 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/receipt/commands.ts create mode 100644 apps/backend/src/receipt/context.ts create mode 100644 apps/backend/src/receipt/problem.ts create mode 100644 apps/backend/src/receipt/reads.ts create mode 100644 apps/backend/src/receipt/representation.ts create mode 100644 apps/backend/src/receipt/rpc.test.ts create mode 100644 apps/backend/src/receipt/validate.ts diff --git a/apps/backend/src/receipt/commands.ts b/apps/backend/src/receipt/commands.ts new file mode 100644 index 00000000..c153f4a1 --- /dev/null +++ b/apps/backend/src/receipt/commands.ts @@ -0,0 +1,705 @@ +/** + * 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, + ReceiptId, + ReceiptMutationAuthorizationTarget, + ReceiptPersistenceError, + ReceiptSettlementCommandRequestSchema, + ReceiptVisualId, + type ReceiptCommandPrincipal, + type ReceiptCommandRequest, + type ReceiptFile, + type ReceiptMutationAuthorization, + type ReceiptSubmissionAllocation, +} from "@vektorprogrammet/domain/receipt"; +import { + 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, + personPresentation, + requestInvalid, + requireCurrentETag, + unreachable, +} 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 { drainReceiptOutbox } from "./outbox-drain.js"; +import { receiptCredentialProblems, receiptProblems } from "./problem.js"; +import { + receiptEtag, + receiptMutationCapsule, + settlementMutationCapsule, +} 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; +} + +/** The empty JSON body of the HTTP transitions, which their request digest still covers. */ +const emptyBody = {}; + +const mutationIdentity = ( + principal: ReceiptCommandPrincipal, + qualifiedOperationId: string, + normalizedTarget: string, + idempotencyKey: 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: { + readonly identitySha256: string; + readonly commandId: string; + }; + readonly operationId: string; + readonly requestSha256: string; + readonly command: ReceiptCommandRequest; + readonly authorization: ReceiptMutationAuthorization; + readonly response: + | { readonly status: 201; readonly location: string } + | { readonly status: 200; readonly ifMatch: StrongETag; readonly currentEtag: StrongETag }; + readonly allocation?: ReceiptSubmissionAllocation; +} + +type ReceiptMutationAuthorizationFor = + Target["_tag"] extends "SubmitReceipt" + ? Extract + : Exclude; + +const authorizeReceiptMutationInTransaction = ( + target: Target, + principal: ReceiptCommandPrincipal, +) => + Economy.use(({ authorizeReceiptMutation }) => authorizeReceiptMutation(target, principal)).pipe( + Effect.flatMap((authorization) => + authorization._tag === target._tag + ? Effect.succeed( + // SAFETY: The service returns the authorization union, and the exact target discriminant is checked above. + authorization as ReceiptMutationAuthorizationFor, + ) + : Effect.fail( + ReceiptPersistenceError.make({ + operation: "authorize Receipt mutation", + message: "authorization target mismatch", + }), + ), + ), + ); + +const executeReceiptMutation = ( + prepare: () => Effect.Effect, + execution: { readonly retry?: "serialization-once" } = {}, +) => + executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const prepared = yield* prepare(); + + return { + identity: { + identitySha256: prepared.identity.identitySha256, + requestSha256: prepared.requestSha256, + operationId: prepared.operationId, + }, + execute: Economy.use(({ executeAuthorizedReceipt }) => + Effect.gen(function* () { + if (prepared.response.status === 200) { + yield* requireCurrentETag(prepared.response.currentEtag, prepared.response.ifMatch); + } + + const result = yield* executeAuthorizedReceipt( + prepared.command, + prepared.authorization, + prepared.allocation, + ); + + return yield* receiptMutationCapsule({ + receipt: result.receipt, + response: prepared.response, + }); + }), + ), + }; + }), + execution, + ); + +/** Stages uploaded bytes; any failure of the store is the store's. */ +const stageReceiptFile = ( + fileStore: ReceiptFileStore, + upload: ValidatedReceiptFile, + commandId: string, + maxFileBytes: number, +) => + fileStore.stageBytes(upload.file, commandId, upload.contentType, maxFileBytes).pipe( + Effect.catchTag("ReceiptFileStoreError", (cause) => + Effect.fail( + ReceiptPersistenceError.make({ + operation: "stage receipt file", + message: "receipt file staging failed", + cause, + }), + ), + ), + ); + +const cleanupStagedFile = (fileStore: ReceiptFileStore, staged: StagedReceiptFile) => + fileStore.cleanupStage(staged.file).pipe(Effect.ignore); + +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* 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(context); + + const authorization = yield* authorizeReceiptMutationInTransaction( + ReceiptMutationAuthorizationTarget.SubmitReceipt( + departmentId === undefined ? {} : { departmentId }, + ), + principal, + ); + + const identity = yield* mutationIdentity( + principal, + "receipts.submitReceipt", + "/api/receipts", + payload.idempotencyKey, + ); + + const nextStaged = yield* stageReceiptFile( + fileStore, + fields.file, + identity.commandId, + config.maxFileBytes, + ); + + staged = nextStaged; + yield* fileStore.service.stage(nextStaged.file); + + const semanticFields = { + description: fields.description, + amountOre: fields.amountOre, + receiptDate: fields.receiptDate, + file: semanticFile(nextStaged.file), + }; + + // 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, + description: fields.description, + amountOre: fields.amountOre, + receiptDate: fields.receiptDate, + file: nextStaged.file, + }; + + const command = ReceiptCommandRequestSchema.cases.SubmitReceipt.make( + departmentId === undefined ? commandFields : { ...commandFields, departmentId }, + ); + + const nextAllocation = { + receiptId: ReceiptId.make(config.nextReceiptId()), + visualId: ReceiptVisualId.make(config.nextVisualId()), + }; + + allocation = nextAllocation; + + return { + identity, + operationId: "receipts.submitReceipt", + requestSha256: semanticRequestDigest({ body: semanticBody }), + command, + authorization, + response: { + status: 201, + location: `/api/receipts/${encodeURIComponent(nextAllocation.receiptId)}`, + }, + allocation: nextAllocation, + } satisfies PreparedReceiptMutation; + }), + ); + + if (Predicate.isTagged(outcome, "Committed")) { + if (allocation === undefined) { + return yield* ReceiptPersistenceError.make({ + operation: "submit receipt allocation", + message: "transaction preparation produced no allocation", + }); + } + + committed = true; + yield* drainReceiptOutbox({ config }, fileStore, allocation.receiptId); + } else if (staged?.created === true) { + yield* cleanupStagedFile(fileStore, staged); + } + + return yield* commandOutcome(ReceiptResource)(outcome); + }).pipe( + receiptProblems, + commandReceiptProblems, + 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( + Effect.suspend(() => + !committed && staged?.created === true ? cleanupStagedFile(fileStore, staged) : Effect.void, + ), + ), + ); +}; + +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 fields = yield* validateRevision({ + request: payload.request, + maxFileBytes: config.maxFileBytes, + }); + + const outcome = yield* executeReceiptMutation(() => + Effect.gen(function* () { + const principal = yield* authorizationPrincipalInTransaction(context); + + const authorization = yield* authorizeReceiptMutationInTransaction( + ReceiptMutationAuthorizationTarget.RevisePendingReceipt({ receiptId }), + principal, + ); + + const current = authorization.current; + + const identity = yield* mutationIdentity( + principal, + "receipts.reviseReceipt", + `/api/receipts/${encodeURIComponent(receiptId)}`, + payload.idempotencyKey, + ); + + if (fields.file !== undefined) { + const nextStaged = yield* stageReceiptFile( + fileStore, + fields.file, + identity.commandId, + config.maxFileBytes, + ); + + staged = nextStaged; + yield* fileStore.service.stage(nextStaged.file); + } + + const amountOre = fields.amountOre ?? Number(current.amountOre); + + if (!Number.isSafeInteger(amountOre) || amountOre <= 0) { + return yield* ReceiptPersistenceError.make({ + operation: "decode current receipt amount", + message: "invalid amount", + }); + } + + const semanticBody: Record = {}; + + if (fields.description !== undefined) semanticBody.description = fields.description; + + if (fields.amountOre !== undefined) semanticBody.amountOre = fields.amountOre; + + if (fields.receiptDate !== undefined) semanticBody.receiptDate = fields.receiptDate; + + if (staged !== undefined) semanticBody.file = semanticFile(staged.file); + + const command = ReceiptCommandRequestSchema.cases.RevisePendingReceipt.make({ + commandId: identity.commandId, + receiptId: current.receiptId, + expectedRevision: current.revision, + description: fields.description ?? current.description, + amountOre, + receiptDate: fields.receiptDate ?? current.receiptDate, + file: + staged?.file ?? + ReceiptCommandRequestSchema.cases.RevisePendingReceipt.fields.file.members[1].cases.KeepCurrentFile.make( + {}, + ), + }); + + return { + identity, + operationId: "receipts.reviseReceipt", + requestSha256: semanticRequestDigest(semanticMutationRequest(semanticBody, ifMatch)), + command, + authorization, + response: { + status: 200, + ifMatch, + currentEtag: receiptEtag({ receiptId, revision: current.revision }), + }, + } satisfies PreparedReceiptMutation; + }), + ); + + if (Predicate.isTagged(outcome, "Committed")) { + committed = true; + yield* drainReceiptOutbox({ config }, fileStore, receiptId); + } else if (staged?.created === true) { + yield* cleanupStagedFile(fileStore, staged); + } + + return yield* commandOutcome(ReceiptResource)(outcome); + }).pipe( + receiptProblems, + commandReceiptProblems, + receiptCredentialProblems(personPresentation(headers)), + // Only a submission allocates a receipt identity that can already exist. + unreachable("receipt.already-exists"), + Effect.ensuring( + Effect.suspend(() => + !committed && staged?.created === true ? cleanupStagedFile(fileStore, staged) : Effect.void, + ), + ), + ); +}; + +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(context); + + const authorization = yield* authorizeReceiptMutationInTransaction( + ReceiptMutationAuthorizationTarget.WithdrawPendingReceipt({ receiptId }), + principal, + ); + + const current = authorization.current; + + const identity = yield* mutationIdentity( + principal, + "receipts.withdrawReceipt", + `/api/receipts/${encodeURIComponent(receiptId)}/withdraw`, + payload.idempotencyKey, + ); + + return { + identity, + operationId: "receipts.withdrawReceipt", + requestSha256: semanticRequestDigest(semanticMutationRequest(emptyBody, ifMatch)), + command: ReceiptCommandRequestSchema.cases.WithdrawPendingReceipt.make({ + commandId: identity.commandId, + receiptId: current.receiptId, + expectedRevision: current.revision, + }), + authorization, + response: { + status: 200, + ifMatch, + currentEtag: receiptEtag({ receiptId, revision: current.revision }), + }, + } satisfies PreparedReceiptMutation; + }), + ); + + if (Predicate.isTagged(outcome, "Committed")) { + 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. + unreachable("receipt.already-exists", "validation.failed", "receipt.file-not-staged"), + ); +}; + +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), + ); + +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. + */ +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* () { + const outcome = yield* executeReceiptMutation( + () => + Effect.gen(function* () { + const principal = yield* authorizationPrincipalInTransaction(context); + + if (action !== "reopen" && barrier !== undefined) { + yield* barrier(headers, receiptId, action); + } + + const authorization = yield* authorizeReceiptMutationInTransaction( + approvalTarget(action, receiptId), + principal, + ); + + const current = authorization.current; + + const identity = yield* mutationIdentity( + principal, + operationId, + normalizedTarget, + payload.idempotencyKey, + ); + + 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" }, + ); + + 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 = ({ + 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(context); + + const revision = yield* Economy.use(({ readReceiptSettlementRevision }) => + readReceiptSettlementRevision(receiptId, principal), + ); + + const identity = yield* mutationIdentity( + principal, + "receipts.settleReceipt", + `/api/receipts/${encodeURIComponent(receiptId)}/settle`, + payload.idempotencyKey, + ); + + const command = yield* Schema.decodeEffect(ReceiptSettlementCommandRequestSchema)( + ReceiptSettlementCommandRequestSchema.cases.RecordReceiptSettlement.make({ + commandId: identity.commandId, + receiptId, + expectedRevision: body.expectedRevision, + externalAuthority: body.externalAuthority, + externalReference: body.externalReference, + settledAt: body.settledAt, + }), + ).pipe(Effect.mapError(() => requestInvalid())); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest(semanticMutationRequest({ ...body }, ifMatch)), + operationId: "receipts.settleReceipt", + }, + execute: Economy.use(({ recordReceiptSettlement }) => + Effect.gen(function* () { + yield* requireCurrentETag(receiptEtag({ receiptId, revision }), ifMatch); + + if (body.expectedRevision !== revision) { + return yield* Problem.make("precondition.failed"); + } + + const result = yield* recordReceiptSettlement(command, principal); + + return yield* settlementMutationCapsule({ + settlement: result.settlement, + receipt: result.receipt, + }); + }), + ), + }; + }), + { retry: "serialization-once" }, + ); + + if (Predicate.isTagged(outcome, "Committed")) { + yield* drainReceiptOutbox({ config: options.config.receipt }, fileStore, receiptId); + } + + return yield* commandOutcome(ReceiptSettlementEvidenceResource)(outcome); + }).pipe( + receiptProblems, + commandReceiptProblems, + receiptCredentialProblems(personPresentation(headers)), + ); +}; diff --git a/apps/backend/src/receipt/context.ts b/apps/backend/src/receipt/context.ts new file mode 100644 index 00000000..93e31b11 --- /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/e2e-support.test.ts b/apps/backend/src/receipt/e2e-support.test.ts index 09f1e73d..6e504d54 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/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 274fb5b5..9edfc416 100644 --- a/apps/backend/src/receipt/e2e-support.ts +++ b/apps/backend/src/receipt/e2e-support.ts @@ -4,6 +4,7 @@ */ 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/problem.ts b/apps/backend/src/receipt/problem.ts new file mode 100644 index 00000000..19a96abb --- /dev/null +++ b/apps/backend/src/receipt/problem.ts @@ -0,0 +1,113 @@ +/** The problems that receipt handlers answer for domain, store, and credential failures. */ +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { + ReceiptDecodeError, + ReceiptFailure, + ReceiptFileFailure, + ReceiptSettlementFailure, + UnauthenticatedActor, +} from "@vektorprogrammet/domain/receipt"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import type { Cause } from "effect"; +import type { SqlError } from "effect/unstable/sql/SqlError"; +import { + type CredentialCases, + type ProblemCases, + type ProblemMapper, + problemMapper, + requestInvalid, +} from "../rpc/problem.js"; + +/** Every failure that a receipt handler answers, but a rejected credential. */ +type ReceiptRpcFailure = + | Exclude + | ReceiptFileFailure + | IdentityEngineError + | SqlError + | Cause.TimeoutError; + +const receiptCases = { + InactiveActor: () => Problem.make("authority.denied"), + ReceiptOwnerDenied: () => Problem.make("authority.denied"), + ReceiptScopeDenied: () => Problem.make("authority.denied"), + ReceiptAuthorityDenied: () => Problem.make("authority.denied"), + AmbiguousPaymentSelection: () => Problem.make("authority.denied"), + AmbiguousParameterFill: () => Problem.make("authority.denied"), + FailedComposedRequirement: () => Problem.make("authority.denied"), + ReceiptNotFound: () => Problem.make("receipt.not-found"), + StaleReceiptRevision: () => Problem.make("precondition.failed"), + ReceiptDecodeError: () => requestInvalid(), + SettlementAfterRecordedAt: () => Problem.make("settlement.after-recorded-at"), + ReceiptFileNotStaged: () => Problem.make("receipt.file-not-staged"), + ReceiptAlreadyExists: () => Problem.make("receipt.already-exists"), + ReceiptAlreadySettled: () => Problem.make("receipt.already-settled"), + DuplicateExternalSettlementReference: () => + Problem.make("settlement.external-reference-conflict"), + DuplicateReceiptCommandConflict: () => Problem.make("idempotency.digest-conflict"), + InvalidReceiptTransition: () => Problem.make("receipt.invalid-transition"), + ReceiptPersistenceError: () => Problem.make("receipts.unavailable"), + ReceiptFileIdentityConflict: () => Problem.make("receipts.unavailable"), + ReceiptFileEffectConflict: () => Problem.make("receipts.unavailable"), + ReceiptFileInjectedFailure: () => Problem.make("receipts.unavailable"), + IdentityEngineError: () => Problem.make("receipts.unavailable"), + SqlError: () => Problem.make("receipts.unavailable"), + TimeoutError: () => Problem.make("receipts.unavailable"), +} satisfies ProblemCases; + +/** + * The one answer for every receipt failure other than a rejected credential, including an + * unavailable store, Identity, or E2E barrier. + * + * @remarks + * 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. + * + * @sideEffects none + * + * @example + * ```ts + * command.pipe(receiptProblems, commandReceiptProblems, receiptCredentialProblems(presentation)); + * ``` + * + * @avoid Mapping a receipt failure in a handler: the receipt RPCs then answer one failure + * differently. Pipe the handler's effect through this. + * + * @construct rpc-problem + */ +export const receiptProblems: ProblemMapper = + problemMapper()(receiptCases); + +/** 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"), +}); + +/** + * A credential rejected inside a receipt handler is answered from the request's own evidence. + * + * @remarks + * The rejection answers `Problem.unauthenticated(presentation)`: credential.missing when the + * request presented no credential, and credential.invalid when it presented one that was + * rejected. + * + * @sideEffects none + * + * @example + * ```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. + * + * @construct rpc-problem + */ +export const receiptCredentialProblems = ( + presentation: CredentialPresentation, +): ProblemMapper> => + problemMapper()>({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + }); diff --git a/apps/backend/src/receipt/reads.ts b/apps/backend/src/receipt/reads.ts new file mode 100644 index 00000000..c0c10b4f --- /dev/null +++ b/apps/backend/src/receipt/reads.ts @@ -0,0 +1,551 @@ +/** 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 { + AcceptedOAuthServiceCredential, + AuthorityRef, + AuthorityVersion, + AuthorizationInstant, + CapabilityTypeId, + CredentialEvidenceRef, + CredentialMechanismSchema, + CredentialOutcomeSchema, + GrantId, + INTERNAL_RECEIPT_EVIDENCE_ACCESS, + READ_INTERNAL_RECEIPT_EVIDENCE_CAPABILITY, + RECEIPT_DOMAIN_ID, + RECEIPT_RESOURCE_KIND, + ResourceId, + Scope, + ServicePrincipalGrantAuthority, + decodeGrant, + evaluateAccess, + evaluateServicePrincipalReceiptApprovalAccess, + type ReceiptAccessFacts, +} from "@vektorprogrammet/domain/authz"; +import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { + Economy, + ReceiptNotFound, + ReceiptPersistenceError, + type ReceiptStatus, +} from "@vektorprogrammet/domain/receipt"; +import { + ReadReceiptFile, + ReceiptLifecycleEvidenceResponse, + reflectAccessSpec, + type ReceiptApprovalQueueItem, + type ReceiptSettlementQueueItem, +} from "@vektorprogrammet/rpc"; +import { nativeCookieChallenge, Problem } from "@vektorprogrammet/rpc/problem"; +import { Effect, Option, Predicate, Schema } from "effect"; +import { currentInstant, resolveRequestCredentialInTransaction } from "../authority.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 { + receiptCredentialProblems, + receiptProblems, + storedReceiptProblems, +} from "./problem.js"; +import { + ownedReceiptResource, + projected, + readPrivateReceiptFile, + receiptEtag, + receiptSettlementEvidenceResource, +} from "./representation.js"; +import { requireReceiptCursor } from "./validate.js"; + +interface ReceiptAccessRow { + readonly ownerPersonId: string; + readonly departmentId: string; + readonly status: string; + readonly revision: number; +} + +/** 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* () { + yield* requireReceiptCursor(query.cursor); + + const principal = yield* authorizationPrincipal(caller); + + const rows = yield* Economy.use(({ listOwnedReceipts }) => + listOwnedReceipts(principal.personId, query.status, query.cursor), + ).pipe(storedReceiptProblems); + + const items = yield* projected(() => rows.items.map(ownedReceiptResource)); + + 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 = ({ + caller, + query, +}: { + readonly caller: ReceiptCaller; + readonly query: ReceiptListQuery; +}) => + Effect.gen(function* () { + const { status, cursor } = query; + + yield* requireReceiptCursor(cursor); + + const resolved = yield* approvalCredential(caller); + + if ( + Predicate.isTagged(resolved.credential.mechanism, "OAuthServiceBearer") && + Predicate.isTagged(resolved.credential.principal, "ServicePrincipal") + ) { + const credential = AcceptedOAuthServiceCredential.make({ + mechanism: resolved.credential.mechanism, + principal: resolved.credential.principal, + evidenceRef: resolved.credential.evidenceRef, + }); + + const authority = yield* ServicePrincipalGrantAuthority.use( + ({ readReceiptApprovalCandidates }) => + readReceiptApprovalCandidates(credential, resolved.authorizationInstant, status, cursor), + ).pipe( + Effect.mapError( + () => + ReceiptPersistenceError.make({ + operation: "read service receipt approval authority", + message: "service receipt approval authority is unavailable", + }), + ), + ); + + const evaluation = evaluateServicePrincipalReceiptApprovalAccess( + credential, + authority, + resolved.authorizationInstant, + ); + + if (!Predicate.isTagged(evaluation, "Allow")) { + return yield* Problem.make("authority.denied"); + } + + const items = yield* projected(() => { + const allowed = new Set( + evaluation.resolution.contexts.flatMap((context) => + context.resource === null ? [] : [context.resource.id], + ), + ); + + const seen = new Set(); + + return authority.candidates.flatMap(({ receipt }) => { + if (seen.has(receipt.receiptId) || !allowed.has(receipt.receiptId)) return []; + seen.add(receipt.receiptId); + + if (status !== undefined && receipt.status !== status) return []; + + return [approvalQueueItem(receipt, "decode service approver projection")]; + }); + }); + + return page(items, authority.nextCursor); + } + + 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) => approvalQueueItem(row, "decode approver projection")), + ); + + return page(items, rows.nextCursor); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); + +export const readSettlementForFinance = ({ + caller, + receiptId, +}: { + readonly caller: ReceiptCaller; + readonly receiptId: string; +}) => + Effect.gen(function* () { + const principal = yield* authorizationPrincipal(caller); + + const settlement = yield* Economy.use(({ readReceiptSettlementForFinance }) => + readReceiptSettlementForFinance( + receiptId, + principal.personId, + principal.authorizationInstant, + ), + ).pipe(storedReceiptProblems); + + return yield* projected(() => receiptSettlementEvidenceResource(settlement)); + }).pipe(receiptProblems, receiptCredentialProblems(personPresentation(caller.headers))); + +export const listReceiptsForSettlement = ({ + caller, + cursor, +}: { + readonly caller: ReceiptCaller; + readonly cursor: string | undefined; +}) => + Effect.gen(function* () { + 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): ReceiptSettlementQueueItem => { + const amountOre = Number(row.amountOre); + + if ( + !Number.isSafeInteger(amountOre) || + amountOre <= 0 || + row.status !== "Approved" || + row.approvedAt.length === 0 + ) { + throw ReceiptPersistenceError.make({ + operation: "decode settlement queue projection", + message: "invalid settlement queue item", + }); + } + + return { + receiptId: row.receiptId, + visualId: row.visualId, + ownerPersonId: row.ownerPersonId, + departmentId: row.departmentId, + description: row.description, + amountOre, + currency: row.currency, + receiptDate: row.receiptDate, + status: row.status, + approvedAt: row.approvedAt, + revision: row.revision, + etag: receiptEtag(row), + }; + }), + ); + + 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 = ({ + 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; + + const file = yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; + + const authenticated = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + { now: options.now }, + ); + + const principal = authenticated.credential.principal; + + if (!Predicate.isTagged(principal, "Person")) { + return yield* Problem.unauthenticated(presentation); + } + + const owned = yield* readOwnedReceiptFile(receiptId, principal.personId); + + if (owned === undefined) { + return yield* Problem.make("resource.not-found"); + } + + const resource = { + kind: RECEIPT_RESOURCE_KIND, + id: ResourceId.make(receiptId), + }; + + const evaluation = evaluateAccess({ + spec: Option.getOrThrow(reflectAccessSpec(ReadReceiptFile)), + credential: authenticated.credential, + resolution: { + selection: "ExactlyOne", + contexts: [ + { + domainId: RECEIPT_DOMAIN_ID, + departmentId: DepartmentId.make(owned.departmentId), + resource, + facts: { + ownerPersonId: principal.personId, + state: owned.status, + approverPersonIds: [], + approverServicePrincipalIds: [], + internalEvidenceEnabled: false, + } satisfies ReceiptAccessFacts, + authorityVersion: AuthorityVersion.make(`receipt:${owned.revision}`), + }, + ], + }, + grants: [ + decodeGrant({ + grantId: GrantId.make(`receipt-owner:${receiptId}`), + subject: principal, + capability: { type: CapabilityTypeId.make("receipts.read-owned") }, + scope: Scope.Resource({ resource }), + startAt: AuthorizationInstant.make("1970-01-01T00:00:00.000Z"), + endAt: null, + requirements: [], + source: AuthorityRef.make("economy_receipts.owner_person_id"), + revision: owned.revision, + }), + ], + authorizationInstant: authenticated.authorizationInstant, + }); + + if (!Predicate.isTagged(evaluation, "Allow")) { + return yield* Problem.make("authority.denied"); + } + + return owned.file; + }), + ); + + 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. + */ +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; + + const file = yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY`; + + const authenticated = yield* resolveRequestCredentialInTransaction( + credentialRequestOf(headers), + "OAuthUserBearer", + { now: options.now }, + ); + + const principal = authenticated.credential.principal; + + if (!Predicate.isTagged(principal, "Person")) { + return yield* Problem.unauthenticated(presentation); + } + + if (barrier !== undefined) { + yield* barrier(headers, receiptId, "file-read"); + } + + return yield* Economy.use(({ readReceiptFileForApproval }) => + readReceiptFileForApproval( + receiptId, + principal.personId, + authenticated.authorizationInstant, + ), + ).pipe( + Effect.catchTag("ReceiptNotFound", () => Effect.fail(Problem.make("resource.not-found"))), + storedReceiptProblems, + ); + }), + ); + + return yield* readPrivateReceiptFile({ + file, + fileStore, + maxFileBytes: options.config.receipt.maxFileBytes, + }); + }).pipe(receiptProblems, receiptCredentialProblems(presentation)); +}; + +/** Internal lifecycle evidence; enabled only by the E2E test-mode access fact. */ +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; + + return yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql` + SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY + `.pipe(Effect.asVoid); + + // Only the Cookie credential reaches this operation, so a rejection challenges for it alone. + const actor = yield* IdentitySnapshot.use(({ resolveSession }) => + resolveSession(headers.cookie, authorizationInstant), + ).pipe( + Effect.catchTag("IdentitySessionNotFound", () => + Effect.fail(Problem.unauthenticated(personPresentation(headers, nativeCookieChallenge))), + ), + ); + + const personId = actor.personId; + + const rows = yield* sql` + SELECT owner_person_id AS "ownerPersonId", department_id AS "departmentId", + status, revision + FROM public.economy_receipts + WHERE receipt_id = ${receiptId} + `; + + const row = rows[0]; + + if (row === undefined) return yield* ReceiptNotFound.make({ receiptId }); + const principal = { _tag: "Person" as const, personId }; + + const context = { + domainId: RECEIPT_DOMAIN_ID, + departmentId: DepartmentId.make(row.departmentId), + resource: { + kind: RECEIPT_RESOURCE_KIND, + id: ResourceId.make(receiptId), + }, + facts: { + ownerPersonId: PersonId.make(row.ownerPersonId), + state: row.status, + approverPersonIds: [], + approverServicePrincipalIds: [], + internalEvidenceEnabled: options.config.receipt.e2e !== undefined, + } satisfies ReceiptAccessFacts, + authorityVersion: AuthorityVersion.make(`receipt:${row.revision}`), + }; + + const grant = decodeGrant({ + grantId: GrantId.make(`internal-evidence:${receiptId}:${personId}`), + subject: principal, + capability: { type: READ_INTERNAL_RECEIPT_EVIDENCE_CAPABILITY }, + scope: Scope.And({ + left: Scope.Domain({ domainId: RECEIPT_DOMAIN_ID }), + right: Scope.And({ + left: Scope.Department({ departmentId: context.departmentId }), + right: Scope.Resource({ resource: context.resource }), + }), + }), + startAt: AuthorizationInstant.make("1970-01-01T00:00:00.000Z"), + endAt: null, + requirements: [], + source: AuthorityRef.make("backend.receipt.internal-evidence"), + revision: row.revision, + }); + + const evaluation = evaluateAccess({ + spec: INTERNAL_RECEIPT_EVIDENCE_ACCESS, + credential: CredentialOutcomeSchema.cases.Accepted.make({ + mechanism: CredentialMechanismSchema.cases.BetterAuthCookie.make({}), + principal, + evidenceRef: CredentialEvidenceRef.make("better-auth:resolved-session"), + }), + resolution: { selection: "ExactlyOne", contexts: [context] }, + grants: [grant], + authorizationInstant, + }); + + // The evidence spec reveals every denial of its accepted credential. + if (!Predicate.isTagged(evaluation, "Allow")) { + return yield* Problem.make("authority.denied"); + } + + const evidence = yield* Economy.use(({ readReceiptLifecycleEvidence }) => + readReceiptLifecycleEvidence(receiptId, personId), + ); + + return yield* Schema.decodeEffect(ReceiptLifecycleEvidenceResponse)(evidence).pipe( + Effect.mapError(() => Problem.make("receipts.unavailable")), + ); + }), + ); + }).pipe(receiptProblems); diff --git a/apps/backend/src/receipt/representation.ts b/apps/backend/src/receipt/representation.ts new file mode 100644 index 00000000..880a7c37 --- /dev/null +++ b/apps/backend/src/receipt/representation.ts @@ -0,0 +1,188 @@ +/** 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/rpc.test.ts b/apps/backend/src/receipt/rpc.test.ts new file mode 100644 index 00000000..d5913fd7 --- /dev/null +++ b/apps/backend/src/receipt/rpc.test.ts @@ -0,0 +1,1863 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + type DatabaseOperations, + Database, + IdentitySnapshot, + OAuthCredentialAuthority, +} from "@vektorprogrammet/database"; +import { + AcceptedOAuthServiceCredential, + CredentialEvidenceRef, + CredentialMechanismSchema, + CredentialOutcomeSchema, + NATIVE_API_PROTECTED_RESOURCE, + PrincipalSchema, + RECEIPT_APPROVAL_QUEUE_OPERATION, + ServicePrincipalGrantAuthority, + ServicePrincipalId, + makeServicePrincipalReceiptGrant, + type ServicePrincipalReceiptGrantAuthority, +} from "@vektorprogrammet/domain/authz"; +import { + Identity, + IdentityActor, + IdentityEngineError, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { + ApprovalScopeSchema, + Economy, + InactiveActor, + Receipt, + ReceiptCommandRequestSchema, + ReceiptDecodeError, + ReceiptFileSchema, + ReceiptFileService, + ReceiptId, + ReceiptMutationAuthorization, + ReceiptNotFound, + ReceiptOutboxDeliveryResult, + ReceiptOwnerDenied, + ReceiptPersistenceError, + ReceiptScopeDenied, + ReceiptSettlementEvidenceSchema, + ReceiptVisualId, + type EconomyOperations, + type OwnedReceiptProjectionItem, + type ReceiptApprovalFileReadFailure, + type ReceiptCommandRequest, + type ReceiptFailure, + type ReceiptFile, + type ReceiptLifecycleEvidenceProjection, + type ReceiptSettlementCommandRequest, + type ReceiptSettlementEvidence, + type ReceiptStatus, + type ReceiptSubmissionAllocation, +} from "@vektorprogrammet/domain/receipt"; +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 { + 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 { RECEIPT_E2E_CONCURRENCY_REQUEST_HEADER } from "./e2e-support.js"; +import { ReceiptFileStoreError, type ReceiptFileStore } from "./filesystem.js"; + +type ProjectionRow = Omit; + +type ReceiptAccessRow = { + readonly ownerPersonId: string; + readonly departmentId: string; + readonly status: string; + readonly revision: number; +}; + +const personId = PersonId.make("person-receipt-rpc"); + +const departmentOne = DepartmentId.make("department-one"); + +const evaluatedAt = "2026-08-24T12:00:00.000Z"; + +const receiptId = ReceiptId.make("receipt-one"); + +const visualId = ReceiptVisualId.make("visual-one"); + +const serviceBearer = "receipt-service-token"; + +const personBearer = "receipt-person-credential"; + +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, + ownerPersonId: personId, + departmentId: departmentOne, + amountOre: "1200", + currency: "NOK", + description: "bus ticket", + receiptDate: "2026-08-01", + submittedAt: evaluatedAt, + approvedAt: null, + status: "Pending", + revision: 0, + ...overrides, +}); + +const settlementEvidence = ( + overrides: Partial = {}, +): ReceiptSettlementEvidence => + Schema.decodeSync(ReceiptSettlementEvidenceSchema)({ + settlementId: "settlement-one", + receiptId, + amountOre: 1200, + currency: "NOK", + paymentDestinationFingerprint: "a".repeat(64), + externalAuthority: "Bank AS", + externalReference: "external-reference-1", + settledAt: "2026-08-24T11:00:00.000Z", + recordedByPersonId: personId, + recordedAt: evaluatedAt, + receiptRevision: 1, + ...overrides, + }); + +const receiptEtag = (id: string, revision: number) => + deriveStrongETag({ + representationKind: "ReceiptResource", + resourceIdentity: id, + version: revision, + }); + +const key = (value: string) => IdempotencyKey.make(value.padEnd(22, "0")); + +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 { + readonly serviceApproval?: ServicePrincipalReceiptGrantAuthority; + readonly unauthenticated?: boolean; + readonly privateFileOwner?: string; + readonly privateFileUnavailable?: boolean; + readonly approvalFileRow?: ProjectionRow; + readonly approvalFileFailure?: "Decode" | "Inactive" | "Scope"; + readonly approvalFileContentType?: "image/jpeg" | "image/png" | "application/pdf"; + readonly ownedRows?: ReadonlyArray; + readonly approvalRows?: ReadonlyArray; + readonly commandFailure?: ReceiptScopeDenied | ReceiptNotFound | ReceiptPersistenceError; + readonly settlementRows?: ReadonlyArray; + readonly settlementEvidence?: ReceiptSettlementEvidence; + readonly evidenceAccessRows?: ReadonlyArray; + readonly evidenceResult?: ReceiptLifecycleEvidenceProjection; + readonly identitySnapshotFailure?: IdentityEngineError; + readonly transportHeaders?: Readonly>; +} + +type RevocableReceiptAuthority = "Owner" | "Approval"; + +const transactionId = Database.use( + (sql) => sql<{ id: string }>`SELECT pg_current_xact_id()::text AS id`, +).pipe( + Effect.orDie, + Effect.map((rows) => rows[0]!.id), +); + +const transactionIsolation = Database.use( + (sql) => sql<{ isolation: string }>`SELECT current_setting('transaction_isolation') AS isolation`, +).pipe( + Effect.orDie, + 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 + ? undefined + : AcceptedOAuthServiceCredential.make({ + mechanism: CredentialMechanismSchema.cases.OAuthServiceBearer.make({}), + principal: PrincipalSchema.cases.ServicePrincipal.make({ + servicePrincipalId: options.serviceApproval.servicePrincipalId, + }), + evidenceRef: CredentialEvidenceRef.make( + "oauth:ServicePrincipal:receipt-unit:client:1970000000", + ), + }); + + let privateFileReads = 0; + + const approvalFileQueries: Array<{ + readonly receiptId: string; + readonly personId: string; + readonly authorizationInstant: string; + readonly snapshotIsolation: string; + }> = []; + + const commands: Array = []; + const allocations: Array = []; + const settlementCommands: Array = []; + const settlementReads: Array<{ readonly receiptId: string; readonly personId: string }> = []; + + const approvalQueries: Array<{ + 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 = []; + const authorizationChecks: Array = []; + let revokedAuthority: RevocableReceiptAuthority | undefined; + let revokedServiceBearer = false; + + const sourceReceipt = (command: ReceiptCommandRequest): ProjectionRow => { + if (Predicate.isTagged(command, "SubmitReceipt")) { + return pendingReceipt({ + receiptId: allocations.at(-1)?.receiptId ?? receiptId, + visualId: allocations.at(-1)?.visualId ?? visualId, + departmentId: DepartmentId.make(String(command.departmentId ?? departmentOne)), + amountOre: String(command.amountOre), + description: String(command.description), + receiptDate: String(command.receiptDate), + }); + } + + return ( + [...(options.ownedRows ?? []), ...(options.approvalRows ?? [])].find( + (row) => row.receiptId === command.receiptId, + ) ?? pendingReceipt() + ); + }; + + const receiptFromProjection = (row: ProjectionRow): Receipt => + Receipt.make({ + ...row, + amountOre: Number(row.amountOre), + approvedAt: row.status === "Approved" ? "2026-08-24T12:00:00.000Z" : null, + paymentAccountCiphertext: "encrypted", + file: ReceiptFileSchema.make({ + fileRef: "staging/file-one", + objectKey: "committed/file-one", + contentType: "image/png", + byteLength: 4, + sha256: "aa".repeat(32), + }), + }); + + const makeEconomy = (sql: DatabaseOperations) => { + 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) => ReceiptDecodeError.make({ message: cause.message })), + ); + + commands.push(command); + allocations.push(allocation); + commandTransactionIds.push(yield* transactionId); + const source = sourceReceipt(command); + + const nextRevision = "expectedRevision" in command ? command.expectedRevision + 1 : 0; + + const status = Predicate.isTagged(command, "WithdrawPendingReceipt") + ? "Withdrawn" + : Predicate.isTagged(command, "ApproveReceipt") + ? "Approved" + : Predicate.isTagged(command, "RejectReceipt") + ? "Rejected" + : "Pending"; + + const receipt = receiptFromProjection({ + ...source, + description: "description" in command ? command.description : source.description, + amountOre: String("amountOre" in command ? command.amountOre : source.amountOre), + receiptDate: "receiptDate" in command ? command.receiptDate : source.receiptDate, + status, + revision: nextRevision, + }); + + return { + observation: { + commandId: String(command.commandId), + receiptId: receipt.receiptId, + visualId: receipt.visualId, + status: receipt.status, + revision: receipt.revision, + replayed: false, + }, + receipt, + replayed: false, + outboxCount: 0, + }; + }).pipe(Effect.provideService(Database, sql)); + + const authorizeReceiptMutation: EconomyOperations["authorizeReceiptMutation"] = ( + target, + principal, + ) => + Effect.suspend(() => { + authorizationChecks.push(target._tag); + + if (Predicate.isTagged(target, "SubmitReceipt")) { + return Effect.succeed( + ReceiptMutationAuthorization[target._tag]({ + principal, + actor: { + personId: principal.personId, + departmentId: target.departmentId ?? departmentOne, + active: true, + approvalScope: ApprovalScopeSchema.cases.None.make({}), + }, + departmentId: target.departmentId ?? departmentOne, + paymentAccountCiphertext: "encrypted", + }), + ); + } + + const approval = + Predicate.isTagged(target, "ApproveReceipt") || + Predicate.isTagged(target, "RejectReceipt") || + Predicate.isTagged(target, "ReopenRejectedReceipt"); + + const source = (approval ? options.approvalRows : options.ownedRows)?.find( + (row) => row.receiptId === target.receiptId, + ); + + if (source === undefined) { + return Effect.fail( + approval + ? ReceiptScopeDenied.make({ + receiptId: target.receiptId, + departmentId: departmentOne, + }) + : ReceiptNotFound.make({ receiptId: target.receiptId }), + ); + } + + if (revokedAuthority === (approval ? "Approval" : "Owner")) { + return Effect.fail( + approval + ? ReceiptScopeDenied.make({ + receiptId: target.receiptId, + departmentId: source.departmentId, + }) + : ReceiptOwnerDenied.make({ + receiptId: target.receiptId, + personId: principal.personId, + }), + ); + } + + const authorization: ReceiptMutationAuthorization = ReceiptMutationAuthorization[ + target._tag + ]({ + principal, + actor: { + personId: principal.personId, + departmentId: source.departmentId, + active: true, + approvalScope: approval + ? ApprovalScopeSchema.cases.Department.make({ departmentId: source.departmentId }) + : ApprovalScopeSchema.cases.None.make({}), + }, + current: receiptFromProjection(source), + }); + + return Effect.succeed(authorization); + }); + + const executeAuthorizedReceipt: EconomyOperations["executeAuthorizedReceipt"] = ( + input, + authorization, + allocation, + ) => executeReceipt(input, authorization.principal, allocation); + + const readReceiptSettlementRevision: EconomyOperations["readReceiptSettlementRevision"] = ( + requestedReceiptId, + ) => + Effect.suspend(() => { + const source = (options.settlementRows ?? []).find( + (row) => row.receiptId === requestedReceiptId, + ); + + return source === undefined + ? Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })) + : Effect.succeed(source.revision); + }); + + const recordReceiptSettlement: EconomyOperations["recordReceiptSettlement"] = ( + command, + principal, + ) => + Effect.suspend(() => { + const source = (options.settlementRows ?? []).find( + (row) => row.receiptId === command.receiptId, + ); + + if (source === undefined) { + return Effect.fail(ReceiptNotFound.make({ receiptId: command.receiptId })); + } + + settlementCommands.push(command); + + const receipt = Struct.assign(receiptFromProjection(source), { + revision: source.revision + 1, + }); + + const evidence = settlementEvidence({ + ...options.settlementEvidence, + receiptId: receipt.receiptId, + amountOre: receipt.amountOre, + currency: "NOK", + recordedByPersonId: principal.personId, + receiptRevision: receipt.revision, + }); + + return Effect.succeed({ + observation: { + commandId: String(command.commandId), + receiptId: receipt.receiptId, + settlementId: evidence.settlementId, + revision: receipt.revision, + replayed: false, + }, + receipt, + settlement: evidence, + replayed: false, + outboxCount: 1, + }); + }); + + const economy: EconomyOperations = { + executeReceipt, + authorizeReceiptMutation, + executeAuthorizedReceipt, + readReceiptSettlementRevision, + recordReceiptSettlement, + listReceiptsForSettlement: () => + Effect.succeed({ + items: (options.settlementRows ?? []).map((row) => { + if (row.status !== "Approved" || row.approvedAt === null) + throw new Error("Settlement queue fixtures must be approved"); + + return { ...row, status: row.status, approvedAt: row.approvedAt }; + }), + }), + readReceiptSettlementForFinance: (requestedReceiptId, queryPersonId) => + Effect.suspend(() => { + settlementReads.push({ receiptId: requestedReceiptId, personId: queryPersonId }); + const evidence = options.settlementEvidence; + + return evidence === undefined || evidence.receiptId !== requestedReceiptId + ? Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })) + : Effect.succeed(evidence); + }), + listOwnedReceipts: () => + Effect.succeed({ + items: (options.ownedRows ?? []).map((row) => ({ + ...row, + settlement: + options.settlementEvidence?.receiptId === row.receiptId + ? options.settlementEvidence + : null, + })), + }), + listReceiptsForApproval: (queryPersonId, authorizationInstant, status) => { + approvalQueries.push({ personId: queryPersonId, authorizationInstant, status }); + + return Effect.succeed({ items: options.approvalRows ?? [] }); + }, + readReceiptFileForApproval: (requestedReceiptId, queryPersonId, authorizationInstant) => + transactionIsolation.pipe( + Effect.flatMap((snapshotIsolation) => + Effect.suspend(() => { + approvalFileQueries.push({ + receiptId: requestedReceiptId, + personId: queryPersonId, + authorizationInstant, + snapshotIsolation, + }); + const source = options.approvalFileRow; + + if (options.approvalFileFailure === "Decode") { + return Effect.fail( + ReceiptDecodeError.make({ message: "malformed stored file metadata" }), + ); + } + + if (options.approvalFileFailure === "Inactive") { + return Effect.fail(InactiveActor.make({ personId: queryPersonId })); + } + + if (options.approvalFileFailure === "Scope") { + return Effect.fail( + ReceiptScopeDenied.make({ + receiptId: requestedReceiptId, + departmentId: source?.departmentId ?? departmentOne, + }), + ); + } + + if (source === undefined || source.receiptId !== requestedReceiptId) { + return Effect.fail(ReceiptNotFound.make({ receiptId: requestedReceiptId })); + } + + return Effect.succeed({ + fileRef: "staging/approval-file", + objectKey: "committed/approval-file", + contentType: options.approvalFileContentType ?? "application/pdf", + byteLength: 4, + sha256: "b".repeat(64), + }); + }), + ), + Effect.provideService(Database, sql), + ), + readReceiptLifecycleEvidence: (id, ownerPersonId) => + Effect.sync(() => { + evidenceReads.push({ receiptId: id, personId: ownerPersonId }); + + if (options.evidenceResult === undefined) + throw new Error("unexpected receipt evidence read"); + + return options.evidenceResult; + }), + receiptStatusTotals: Effect.succeed([]), + listStaleOutboxClaims: () => Effect.succeed([]), + recoverStaleOutboxClaim: () => Effect.succeed(0), + deliverNextOutboxEffect: () => Effect.succeed(ReceiptOutboxDeliveryResult.Idle()), + }; + + return economy; + }; + + const database = backendDatabase( + Database.use((sql) => + Effect.gen(function* () { + yield* sql`CREATE TABLE test_credential (version integer NOT NULL)`; + yield* sql`INSERT INTO test_credential VALUES (1)`; + + const evidenceRows = (options.evidenceAccessRows ?? []).map((row) => ({ + ...row, + receiptId, + })); + + const privateRows = + options.privateFileOwner === undefined + ? [] + : [ + { + receiptId: ReceiptId.make("private"), + ownerPersonId: PersonId.make(options.privateFileOwner), + departmentId: departmentOne, + status: "Approved", + revision: 1, + }, + ]; + + for (const row of [...evidenceRows, ...privateRows]) + yield* sql`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 (${row.receiptId},${"visual-" + row.receiptId},${row.ownerPersonId},${row.departmentId},1200,'NOK','Evidence','2026-08-24',${evaluatedAt}::timestamptz,${row.status},${row.status === "Approved" ? evaluatedAt : null}::timestamptz,'encrypted','staging/private','committed/private','application/pdf',4,${"a".repeat(64)},${row.revision})`; + }), + ), + ); + + const economyLayer = Layer.effect(Economy, Effect.map(Database, makeEconomy)).pipe( + Layer.provide(database.layer), + ); + + const identitySnapshot = IdentitySnapshot.of({ + resolveSession: (cookieHeader) => + Effect.gen(function* () { + identitySnapshotIsolations.push(yield* transactionIsolation); + + const rows = yield* Database.use( + (sql) => sql<{ version: number }>`SELECT version FROM test_credential`, + ).pipe(Effect.orDie); + + const observedVersion = rows[0]!.version; + identitySnapshotVersions.push(observedVersion); + + if (options.identitySnapshotFailure !== undefined) + return yield* options.identitySnapshotFailure; + + if (cookieHeader === undefined || options.unauthenticated === true || observedVersion === 2) + return yield* IdentitySessionNotFound.make({}); + + return actorOf(); + }), + 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 servicePrincipalGrantAuthority = ServicePrincipalGrantAuthority.of({ + readReceiptApprovalCandidates: () => + 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: () => + 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"), + 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 oauthCredentialAuthority = OAuthCredentialAuthority.of({ + resolve: (request, expected) => + Effect.sync(() => { + if ( + request.headers.get("authorization") === `Bearer ${personBearer}` && + expected !== "OAuthServiceBearer" + ) { + return CredentialOutcomeSchema.cases.Accepted.make({ + mechanism: CredentialMechanismSchema.cases.OAuthUserBearer.make({}), + principal: PrincipalSchema.cases.Person.make({ personId }), + evidenceRef: CredentialEvidenceRef.make("oauth:Person:receipt-user:client:1970000000"), + }); + } + + return serviceCredential !== undefined && + request.headers.get("authorization") === `Bearer ${serviceBearer}` && + expected === "Either" && + !revokedServiceBearer + ? serviceCredential + : CredentialOutcomeSchema.cases.Rejected.make({ reason: "Revoked" as const }); + }), + resolveInTransaction: () => Effect.die("unexpected OAuth credential resolution"), + }); + + const services = Layer.mergeAll( + database.layer, + economyLayer, + Layer.succeed(IdentitySnapshot, identitySnapshot), + Layer.succeed(ServicePrincipalGrantAuthority, servicePrincipalGrantAuthority), + Layer.succeed(Identity, identity), + Layer.succeed(OAuthCredentialAuthority, oauthCredentialAuthority), + Layer.succeed(ReceiptFileService, fileService), + ); + + 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.privateFileUnavailable === true + ? Effect.fail( + new ReceiptFileStoreError({ + operation: "readCommitted", + message: "private bytes unavailable", + }), + ) + : Effect.succeed(new Uint8Array([1, 2, 3, 4])); + }), + }; + + const backend = makeBackendTestRpc({ + config, + services, + options: { now: () => evaluatedAt, receiptFileStore }, + transportHeaders: options.transportHeaders, + }); + + const run = (effect: Effect.Effect): Effect.Effect => + database.run(effect); + + return { + client: backend.client, + internalClient: makeInternalTestRpc(services), + commands, + settlementCommands, + settlementReads: () => settlementReads, + privateFileReads: () => privateFileReads, + allocations, + approvalQueries, + approvalFileQueries: () => approvalFileQueries, + evidenceReads, + nativeReceiptCount: () => + run( + Database.use( + (sql) => + sql<{ + count: number; + }>`SELECT count(*)::integer AS count FROM public.native_http_idempotency_receipts`, + ).pipe(Effect.map((rows) => rows[0]!.count)), + ), + run, + currentTransactionId: transactionId, + mutationTransactions: () => + Effect.gen(function* () { + return { + commandTransactionIds: [...commandTransactionIds], + receiptWriteTransactionIds: yield* run( + Database.use( + (sql) => + sql<{ + id: string; + }>`SELECT xmin::text AS id FROM public.native_http_idempotency_receipts ORDER BY committed_at`, + ).pipe(Effect.map((rows) => rows.map((row) => row.id))), + ), + }; + }), + snapshotObservations: () => + Effect.gen(function* () { + return { + 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)), + ), + }; + }), + revokeCredential: () => + run(Database.use((sql) => sql`UPDATE test_credential SET version=2`).pipe(Effect.asVoid)), + revokeAuthority: (authority: RevocableReceiptAuthority) => { + revokedAuthority = authority; + }, + revokeServiceBearer: () => { + revokedServiceBearer = true; + }, + authorizationChecks: () => authorizationChecks, + }; +}; + +/** Runs one call with the person's session cookie. */ +const withCookie = (effect: Effect.Effect) => + RpcClient.withHeaders(effect, { cookie: sessionCookie }); + +/** Runs one call with an OAuth bearer. */ +const withBearer = (effect: Effect.Effect, bearer: string) => + RpcClient.withHeaders(effect, { authorization: `Bearer ${bearer}` }); + +/** 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 transition = (idempotencyKey: string, revision = 0) => ({ + receiptId, + idempotencyKey: key(idempotencyKey), + ifMatch: receiptEtag(receiptId, revision), +}); + +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 client = yield* state.client; + + const body = yield* withCookie(client["receipts.listReceipts"]({ status: "Pending" })); + + expect(body).toEqual({ + items: [ + yield* Schema.decodeEffect(ReceiptListItem)({ + ...pendingReceipt(), + amountOre: 1200, + settlement: null, + etag: receiptEtag(receiptId, 0), + }), + ], + }); + }), + ); + + it.live("lists only approved unsettled receipts in the settlement queue", () => + Effect.gen(function* () { + const queueRow = pendingReceipt({ + status: "Approved", + approvedAt: evaluatedAt, + revision: 2, + }); + + const state = harness({ settlementRows: [queueRow] }); + const client = yield* state.client; + + expect(yield* withCookie(client["receipts.listReceiptsForSettlement"]({}))).toEqual({ + items: [ + { + receiptId, + visualId, + ownerPersonId: personId, + departmentId: departmentOne, + description: "bus ticket", + amountOre: 1200, + currency: "NOK", + receiptDate: "2026-08-01", + status: "Approved", + approvedAt: evaluatedAt, + revision: 2, + etag: receiptEtag(receiptId, 2), + }, + ], + }); + }), + ); + + it.live("records immutable settlement evidence with revision and idempotent replay", () => + Effect.gen(function* () { + const queueRow = pendingReceipt({ + status: "Approved", + approvedAt: evaluatedAt, + revision: 2, + }); + + const evidence = settlementEvidence({ receiptRevision: 3 }); + const state = harness({ settlementRows: [queueRow], settlementEvidence: evidence }); + const client = yield* state.client; + + const request = { + expectedRevision: 2, + externalAuthority: evidence.externalAuthority, + externalReference: evidence.externalReference, + settledAt: evidence.settledAt, + }; + + const settle = (body: typeof request) => + withCookie( + client["receipts.settleReceipt"]({ ...transition("settleReceiptKey", 2), request: body }), + ); + + const recorded = yield* settle(request); + const replay = yield* settle(request); + + const changedReplay = yield* settle({ + ...request, + externalReference: "changed-external-reference", + }).pipe(Effect.flip); + + 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 and conceals a missing settlement", () => + Effect.gen(function* () { + const evidence = settlementEvidence(); + const state = harness({ settlementEvidence: evidence }); + const client = yield* state.client; + + const visible = yield* withCookie( + client["receipts.readReceiptSettlementForFinance"]({ receiptId }), + ); + + const concealed = yield* withCookie( + client["receipts.readReceiptSettlementForFinance"]({ + receiptId: ReceiptId.make("not-visible"), + }), + ).pipe(Effect.flip); + + expect(visible).toMatchObject(evidence); + expect(state.settlementReads()).toEqual([ + { receiptId, personId }, + { receiptId: ReceiptId.make("not-visible"), personId }, + ]); + expect(codeOf(concealed)).toBe("receipt.not-found"); + }), + ); + + it.live("answers credential.missing without a credential", () => + Effect.gen(function* () { + const client = yield* harness({ unauthenticated: true }).client; + const failure = yield* client["receipts.listReceipts"]({}).pipe(Effect.flip); + + expect(isProblem(failure) && [failure.code, failure.status]).toEqual([ + "credential.missing", + 401, + ]); + }), + ); + + it("derives opaque command identity only from credential, operation, target, and idempotency key", () => { + const first = deriveHttpIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: "receipts.submitReceipt", + normalizedTarget: "/api/receipts", + idempotencyKey: IdempotencyKey.make("submit-receipt-idempotency-key"), + }); + + const second = deriveHttpIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: "receipts.submitReceipt", + normalizedTarget: "/api/receipts", + idempotencyKey: IdempotencyKey.make("another-receipt-idempotency-key"), + }); + + expect(first.commandId).toMatch(/^httpv2_[A-Za-z0-9_-]{43}$/u); + expect(first.identitySha256).toMatch(/^[a-f0-9]{64}$/u); + expect(second.commandId).not.toBe(first.commandId); + expect(first.commandId).not.toContain(personId); + expect(first.commandId).not.toContain("submit-receipt-idempotency-key"); + }); + + it.live( + "persists and replays exact 201 and 200 response capsules in transaction-scoped native receipts", + () => + Effect.gen(function* () { + const state = harness(); + const executedIn: Array = []; + + const cases = [ + { + key: "created-response-idempotency-key", + requestSha256: "a".repeat(64), + operationId: "receipts.submitReceipt", + capsule: { + status: 201, + mediaType: "application/json", + bodyBytes: new TextEncoder().encode('{"receiptId":"receipt-one"}'), + headers: { + "content-type": "application/json", + location: `/api/receipts/${receiptId}`, + etag: receiptEtag(receiptId, 0), + }, + }, + }, + { + key: "entity-response-idempotency-key", + requestSha256: "b".repeat(64), + operationId: "receipts.withdrawReceipt", + capsule: { + status: 200, + mediaType: "application/json", + bodyBytes: new TextEncoder().encode('{"status":"Withdrawn"}'), + headers: { + "content-type": "application/json", + etag: receiptEtag(receiptId, 1), + }, + }, + }, + ] as const; + + for (const item of cases) { + const derived = deriveHttpIdentity({ + credentialSubject: `Person:${personId}`, + qualifiedOperationId: item.operationId, + normalizedTarget: "/api/receipts", + idempotencyKey: IdempotencyKey.make(item.key), + }); + + const identity = { + identitySha256: derived.identitySha256, + requestSha256: item.requestSha256, + operationId: item.operationId, + }; + + const execute = Effect.gen(function* () { + executedIn.push(yield* state.currentTransactionId); + + return item.capsule; + }); + + const plan = Effect.succeed({ identity, execute }); + const committed = yield* state.run(executeNativeHttpCommandPostgres(plan)); + const replayed = yield* state.run(executeNativeHttpCommandPostgres(plan)); + + expect(committed).toEqual(NativeHttpCommandOutcome.Committed({ response: item.capsule })); + expect(replayed._tag).toBe("Replay"); + + if (!Predicate.isTagged(replayed, "Replay")) + throw new Error("expected native receipt replay"); + + expect({ + ...replayed.response, + bodyBytes: + replayed.response.bodyBytes === null ? null : Array.from(replayed.response.bodyBytes), + }).toEqual({ ...item.capsule, bodyBytes: Array.from(item.capsule.bodyBytes) }); + } + + expect(executedIn).toHaveLength(2); + expect(yield* state.nativeReceiptCount()).toBe(2); + expect((yield* state.mutationTransactions()).receiptWriteTransactionIds).toEqual( + executedIn, + ); + }), + ); + + it.live("replays a submission and a withdrawal with their first answers", () => + Effect.gen(function* () { + const submitState = harness(); + 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 withdraw = withCookie( + actionClient["receipts.withdrawReceipt"](transition("withdrawReplayKey")), + ); + + 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 revised = yield* withCookie( + client["receipts.reviseReceipt"]({ + ...transition("reviseWithFile"), + request: { amountOre: 1500, file: { contentType: "application/pdf", bytes: pngBytes } }, + }), + ); + + 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" }, + }); + }), + ); + + it.live( + "authorizes and writes in one snapshot, then rejects replay after credential revocation", + () => + Effect.gen(function* () { + const state = harness({ ownedRows: [pendingReceipt()] }); + const client = yield* state.client; + + 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(); + expect(committedTransactions.commandTransactionIds).toHaveLength(1); + expect(committedTransactions.receiptWriteTransactionIds).toEqual( + committedTransactions.commandTransactionIds, + ); + + yield* state.revokeCredential(); + 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 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; + + for (const [action, authority, row, target] of cases) { + const state = harness( + authority === "Owner" ? { ownedRows: [row] } : { approvalRows: [row] }, + ); + + const client = yield* state.client; + const payload = transition(`${action}Revocation`); + + 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, + ), + ); + + yield* command; + expect(state.commands).toHaveLength(1); + expect(state.authorizationChecks()).toEqual([target]); + + const committedTransactions = yield* state.mutationTransactions(); + state.revokeAuthority(authority); + const revokedReplay = yield* command.pipe(Effect.flip); + + 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("executes each transition as its own command", () => + Effect.gen(function* () { + const actions = [ + ["withdraw", "WithdrawPendingReceipt"], + ["approve", "ApproveReceipt"], + ["reject", "RejectReceipt"], + ] as const; + + for (const [action, commandTag] of actions) { + const state = harness({ ownedRows: [pendingReceipt()], approvalRows: [pendingReceipt()] }); + 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(answered.approvedAt).toBe(action === "approve" ? "2026-08-24T12:00:00.000Z" : null); + expect(state.commands).toHaveLength(1); + expect(state.commands[0]).toMatchObject({ + _tag: commandTag, + receiptId, + expectedRevision: 0, + }); + expect(state.commands[0]?.commandId).toMatch(/^httpv2_[A-Za-z0-9_-]{43}$/u); + } + + // 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("keeps the HTTP route as the normalized target of each command identity", () => + Effect.gen(function* () { + 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("answers a stale If-Match with precondition.failed before any transition", () => + Effect.gen(function* () { + const state = harness({ approvalRows: [pendingReceipt({ revision: 1 })] }); + const client = yield* state.client; + + const stale = yield* withCookie( + client["receipts.approveReceipt"](transition("approveStaleKey", 0)), + ).pipe(Effect.flip); + + expect(codeOf(stale)).toBe("precondition.failed"); + expect(state.commands).toEqual([]); + }), + ); + + 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 client = yield* state.client; + + const failure = yield* withCookie( + client["receipts.reopenReceipt"](transition("reopenFailedCommand")), + ).pipe(Effect.flip); + + expect(codeOf(failure)).toBe("receipts.unavailable"); + expect(yield* jsonText(failure)).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( + "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) { + const receipt = pendingReceipt({ + status: action === "reopen" ? "Rejected" : "Pending", + revision: 2, + }); + + const grant = makeServicePrincipalReceiptGrant({ + grantId: "service-queue0102", + 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 service = harness({ + serviceApproval: { + servicePrincipalId: ServicePrincipalId.make("service0102"), + clientId: grant.clientId, + protectedResource: NATIVE_API_PROTECTED_RESOURCE, + candidates: [ + { + grant, + receipt: { + ...receipt, + receiptId: grant.receiptId, + visualId: yield* Schema.decodeEffect(ReceiptResource.fields.visualId)(visualId), + ownerPersonId: personId, + }, + }, + ], + rules: [], + }, + }); + + const serviceClient = yield* service.client; + + const serviceQueue = yield* withBearer( + serviceClient["receipts.listReceiptsForApproval"]({ status: receipt.status }), + serviceBearer, + ); + + expect(serviceQueue.items[0]?.etag).toBe(receiptEtag(receiptId, 2)); + + const person = harness({ approvalRows: [receipt] }); + const personClient = yield* person.client; + + const personQueue = yield* withCookie( + personClient["receipts.listReceiptsForApproval"]({ status: receipt.status }), + ); + + const etag = personQueue.items[0]?.etag ?? StrongETag.make('"missing"'); + expect(etag).toBe(receiptEtag(receiptId, 2)); + + const payload = { + receiptId, + idempotencyKey: key(`${action}FromQueue`), + ifMatch: etag, + }; + + yield* withCookie( + action === "approve" + ? personClient["receipts.approveReceipt"](payload) + : personClient["receipts.reopenReceipt"](payload), + ); + + expect(person.commands).toHaveLength(1); + } + }), + ); + + 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 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 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 client = yield* state.client; + const queue = client["receipts.listReceiptsForApproval"]({}); + + expect(yield* withBearer(queue, serviceBearer)).toMatchObject({ + items: [{ receiptId }], + }); + expect(state.approvalQueries).toEqual([]); + + const serviceOnPersonOnly = yield* withBearer( + client["receipts.listReceipts"]({}), + serviceBearer, + ).pipe(Effect.flip); + + expect(codeOf(serviceOnPersonOnly)).toBe("credential.invalid"); + + const unscoped = harness({ + serviceApproval: { ...serviceApproval, candidates: [serviceApproval.candidates[1]!] }, + }); + + const unscopedClient = yield* unscoped.client; + + const denied = yield* withBearer( + unscopedClient["receipts.listReceiptsForApproval"]({}), + serviceBearer, + ).pipe(Effect.flip); + + expect(codeOf(denied)).toBe("authority.denied"); + + const mixed = yield* RpcClient.withHeaders(queue, { + cookie: sessionCookie, + authorization: `Bearer ${serviceBearer}`, + }).pipe(Effect.flip); + + 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("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", () => { + const evidence: ReceiptLifecycleEvidenceProjection = { + receiptId, + file: { + fileRef: "file-one", + objectKey: "committed/file-one", + contentType: "image/png", + byteLength: 4, + sha256: "a".repeat(64), + }, + settlement: settlementEvidence(), + outbox: [], + audit: [], + }; + + it.live("reads evidence with the Cookie credential on the internal surface only", () => + Effect.gen(function* () { + const state = harness({ + evidenceAccessRows: [ + { + ownerPersonId: personId, + departmentId: departmentOne, + status: "Pending", + revision: 2, + }, + ], + evidenceResult: evidence, + }); + + const client = yield* state.internalClient; + + const bearerOnly = yield* withBearer( + client["receipts.readReceiptEvidence"]({ receiptId }), + "service-token", + ).pipe(Effect.flip); + + expect(codeOf(bearerOnly)).toBe("credential.invalid"); + + 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 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: IdentityEngineError.make({ + operation: "resolveSnapshotSession", + message: "database 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([]); + }), + ); +}); + +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 file = yield* withCookie( + ownerClient["receipts.readReceiptFile"]({ receiptId: privateId }), + ); + + expect({ contentType: file.contentType, bytes: [...file.bytes] }).toEqual({ + contentType: "application/pdf", + bytes: [1, 2, 3, 4], + }); + expect(owner.privateFileReads()).toBe(1); + + const foreign = harness({ privateFileOwner: "different-person" }); + const foreignClient = yield* foreign.client; + + const denied = yield* withCookie( + foreignClient["receipts.readReceiptFile"]({ receiptId: privateId }), + ).pipe(Effect.flip); + + expect(codeOf(denied)).toBe("resource.not-found"); + expect(foreign.privateFileReads()).toBe(0); + }), + ); + + 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 failure = yield* withCookie( + client["receipts.readReceiptFile"]({ receiptId: ReceiptId.make("private") }), + ).pipe(Effect.flip); + + expect(codeOf(failure)).toBe("receipts.unavailable"); + }), + ); +}); + +describe("scoped receipt approval file reads", () => { + it.live("reads an active scoped terminal receipt in the credential snapshot", () => + Effect.gen(function* () { + const fileId = ReceiptId.make("terminal-approval-file"); + + const state = harness({ + approvalFileRow: pendingReceipt({ receiptId: fileId, status: "Approved" }), + approvalFileContentType: "application/pdf", + }); + + 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 read", () => + Effect.gen(function* () { + const fileId = ReceiptId.make("approver-only-file"); + const state = harness({ approvalFileRow: pendingReceipt({ receiptId: fileId }) }); + const client = yield* state.client; + + yield* withCookie(client["receipts.readReceiptFileForApproval"]({ receiptId: fileId })); + + const owner = yield* withCookie( + client["receipts.readReceiptFile"]({ receiptId: fileId }), + ).pipe(Effect.flip); + + 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 }); + expect(yield* read(unauthenticated, "approval-file", false)).toBe("credential.missing"); + expect(unauthenticated.privateFileReads()).toBe(0); + + const invalid = harness({ unauthenticated: true }); + expect(yield* read(invalid, "approval-file", true)).toBe("credential.invalid"); + expect(invalid.privateFileReads()).toBe(0); + + const missing = harness(); + expect(yield* read(missing, "missing-file", true)).toBe("resource.not-found"); + expect(missing.privateFileReads()).toBe(0); + + for (const approvalFileFailure of ["Scope", "Inactive"] as const) { + const denied = harness({ + approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("foreign-file") }), + approvalFileFailure, + }); + + expect(yield* read(denied, "foreign-file", true)).toBe("authority.denied"); + expect(denied.privateFileReads()).toBe(0); + } + }), + ); + + it.live("answers receipts.unavailable when the object or its stored metadata is unreadable", () => + Effect.gen(function* () { + const unavailable = harness({ + approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("missing-object") }), + privateFileUnavailable: true, + }); + + const unavailableClient = yield* unavailable.client; + + const objectFailure = yield* withCookie( + unavailableClient["receipts.readReceiptFileForApproval"]({ + receiptId: ReceiptId.make("missing-object"), + }), + ).pipe(Effect.flip); + + expect(codeOf(objectFailure)).toBe("receipts.unavailable"); + expect(unavailable.privateFileReads()).toBe(1); + + const malformed = harness({ + approvalFileRow: pendingReceipt({ receiptId: ReceiptId.make("malformed-file") }), + approvalFileFailure: "Decode", + }); + + const malformedClient = yield* malformed.client; + + const metadataFailure = yield* withCookie( + malformedClient["receipts.readReceiptFileForApproval"]({ + receiptId: ReceiptId.make("malformed-file"), + }), + ).pipe(Effect.flip); + + 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 index b242a605..7a47b1c6 100644 --- a/apps/backend/src/receipt/rpc.ts +++ b/apps/backend/src/receipt/rpc.ts @@ -1,12 +1,123 @@ /** - * The ReceiptsRpcs and InternalReceiptsRpcs handlers. The operations to port, and the HTTP - * handlers they replace (`apps/backend/src/receipt/http*.ts` at the base commit), are listed in - * docs/specs/rpc-only.md. + * 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"; -export const ReceiptsRpcHandlers = (_options: NativeRpcOptions) => ReceiptsRpcs.toLayer({}); +/** The composition-owned private receipt store, or the filesystem store of the receipt config. */ +const receiptFileStoreLayer = (options: NativeRpcOptions) => { + const config = options.config.receipt; -export const InternalReceiptsRpcHandlers = (_options: NativeRpcOptions) => - InternalReceiptsRpcs.toLayer({}); + 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 00000000..94f9239c --- /dev/null +++ b/apps/backend/src/receipt/validate.ts @@ -0,0 +1,135 @@ +/** + * 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/packages/rpc/src/receipts.ts b/packages/rpc/src/receipts.ts index 3c2ae5b1..4a6c74ca 100644 --- a/packages/rpc/src/receipts.ts +++ b/packages/rpc/src/receipts.ts @@ -1,14 +1,586 @@ /** * Receipts: submission, revision, approval, settlement, and receipt files. * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/receipts.ts` and `receipt-upload.ts` at the base commit named there. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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 class ReceiptsRpcs extends RpcGroup.make() {} +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() {} +export class InternalReceiptsRpcs extends RpcGroup.make(ReadReceiptEvidence) {} From aacb811d4bf28afdc185a72ec9884955f3bf5567 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:48:54 +0000 Subject: [PATCH 036/148] feat(rpc): move placement callers and journeys to the placement RPCs The assistant route calls the placement RPCs through callNative. The placement journeys, the recruitment first-placement spec, and the legacy candidate journey call them through the script client or the raw RPC wire. The spec records what the placement and certificate RPCs no longer carry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../routes/dashboard.assistenter._index.tsx | 116 ++++---- apps/dashboard/e2e/native-placement.spec.ts | 135 +++++---- ...native-recruitment-first-placement.spec.ts | 45 ++- .../e2e/run-real-native-placement.mjs | 1 - docs/specs/rpc-only.md | 93 ++++-- tools/e2e/legacy-candidate-native-journey.ts | 106 +++++-- tools/e2e/placement-check.ts | 268 ++++++++++-------- 7 files changed, 485 insertions(+), 279 deletions(-) diff --git a/apps/dashboard/app/routes/dashboard.assistenter._index.tsx b/apps/dashboard/app/routes/dashboard.assistenter._index.tsx index 98e78150..b4f865a6 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, @@ -12,12 +11,13 @@ import { type PlacementBoardResource, type PlacementDraftResource, } 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/e2e/native-placement.spec.ts b/apps/dashboard/e2e/native-placement.spec.ts index 98a3ee5b..856e1028 100644 --- a/apps/dashboard/e2e/native-placement.spec.ts +++ b/apps/dashboard/e2e/native-placement.spec.ts @@ -1,17 +1,18 @@ import AxeBuilder from "@axe-core/playwright"; 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 { PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; +import { type NativeRpcClient, PublicApplicationIdSchema } from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; -import { Order } from "effect"; +import { type Effect, Match, Order, Schema } from "effect"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -81,45 +82,6 @@ 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 } }, - ); - - expect(response.status()).toBe(200); - - return response.json(); -}; - -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 } }, - ); - - expect(response.status()).toBe(200); - - return response.json(); -}; - -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 } }, - ); - - expect(response.status()).toBe(200); - - return response.json(); -}; - let outcomeClient: ReturnType | undefined; /** The native RPC client of the admission-outcome calls, made once the manifest names the backend. */ @@ -137,6 +99,37 @@ const sessionOf = async (page: Page) => ({ 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, + }); + +/** + * 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); + + if (!answer.ok) throw new Error(`The placement read answered ${answer.code}`); + + return answer.value; +}; + +const readBoard = (page: Page) => + readAs(page, (client) => client["placements.readBoard"](placementScope())); + +const readCoverageBoard = (page: Page) => + readAs(page, (client) => client["placements.readCoverageBoard"](placementScope())); + +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 answer = await outcomes().call(await sessionOf(page), (client) => @@ -1014,20 +1007,54 @@ 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 problem = await response.json(); - expect(response.status(), JSON.stringify(problem)).toBe(expected); + const session = await sessionOf(actor); - if (expectedCode !== undefined) expect(problem.code).toBe(expectedCode); - http.push({ check, status: response.status(), boundary: "authenticated-http" }); + 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), + }), + ), + ), + ); + + expect(answer.status, JSON.stringify(answer)).toBe(expected); + + 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. */ diff --git a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts index 914b72ed..b75b50a6 100644 --- a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts @@ -2,7 +2,11 @@ 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 { PersonId } from "@vektorprogrammet/domain/organization"; +import { PlacementScope } from "@vektorprogrammet/domain/placements"; +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; @@ -308,22 +312,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")); diff --git a/apps/dashboard/e2e/run-real-native-placement.mjs b/apps/dashboard/e2e/run-real-native-placement.mjs index c02ff7a1..806fa548 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/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 32754cf9..b76536f0 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -186,12 +186,38 @@ Notes: | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `certificates.confirmDaysServed` | POST `/api/departments/{departmentId}/semesters/{semesterId}/days-served/{personId}` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `certificates.issueCertificate` | POST `/api/departments/{departmentId}/certificates/{personId}/issues` | idempotencyKey; ifMatch; binary application/pdf | cookieHeader, oauthUserBearer | todo | -| `certificates.listCertificates` | GET `/api/departments/{departmentId}/certificates` | query: cursor | cookieHeader, oauthUserBearer | todo | -| `certificates.listDaysServed` | GET `/api/departments/{departmentId}/semesters/{semesterId}/days-served` | query: cursor | cookieHeader, oauthUserBearer | todo | -| `certificates.readCertificate` | GET `/api/departments/{departmentId}/certificates/{personId}` | - | cookieHeader, oauthUserBearer | todo | -| `certificates.readCertificateScopes` | GET `/api/certificate-scopes` | - | cookieHeader, oauthUserBearer | todo | +| `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`) @@ -319,16 +345,51 @@ Notes (organization): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `placements.commandBoard` | POST `/api/placements` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.commandCoverageBoard` | POST `/api/placements/coverage` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.commandOwnAffiliation` | POST `/api/placements/affiliation` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | todo | -| `placements.commandOwnCoverage` | POST `/api/placements/coverage/own` | idempotencyKey; ifMatch; query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.listScopes` | GET `/api/placements/scopes` | - | cookieHeader, oauthUserBearer | todo | -| `placements.readBoard` | GET `/api/placements` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.readCoverageBoard` | GET `/api/placements/coverage` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.readDraft` | GET `/api/placements/draft` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | -| `placements.readOwnAffiliation` | GET `/api/placements/affiliation` | query: departmentId | cookieHeader, oauthUserBearer | todo | -| `placements.readOwnCoverage` | GET `/api/placements/coverage/own` | query: departmentId, semesterId | cookieHeader, oauthUserBearer | todo | +| `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`) diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index 9e9e228c..40873ba9 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -233,6 +233,52 @@ export const observeLegacyCandidateNativeJourney = async ( 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`; @@ -284,9 +330,10 @@ export const observeLegacyCandidateNativeJourney = async ( 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], ); @@ -329,11 +376,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, ); @@ -342,9 +390,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, ); @@ -352,19 +401,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, ); @@ -377,23 +436,30 @@ 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); diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index 41efd483..47de6188 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -1,14 +1,15 @@ -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; import { + AffiliationScope, CoverageCommand, + OwnAffiliationCommand, OwnCoverageCommand, PlacementCommand, PlacementScope, SchoolServiceNotificationRequest, } from "@vektorprogrammet/domain/placements"; -import { IdempotencyIfMatchHeaders, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; -import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; /** 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"; @@ -25,7 +26,7 @@ import { startDisposablePostgres, type DisposablePostgres, } from "@monoweb/postgres"; -import { Schema, Record as Rec, Struct } from "effect"; +import { Option, Schema, Record as Rec, Struct } from "effect"; import { createGoldenObserver, goldenFaults, goldenSteps } from "./golden-school-service.mjs"; import { goldenArtifactName, @@ -813,131 +814,181 @@ try { wrong = await login(persons.wrongDepartment), candidate = await login(persons.candidate); - const sdk = createPromiseClient(backendOrigin, { cookie: leader, 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 volunteerSdk = createPromiseClient(backendOrigin, { - cookie: volunteer, - origin: dashboardOrigin, - }); - - const wrongSdk = createPromiseClient(backendOrigin, { cookie: wrong, origin: dashboardOrigin }); - - const candidateSdk = createPromiseClient(backendOrigin, { - cookie: candidate, - origin: dashboardOrigin, - }); + const valueOf = (result: ScriptCallResult): A => + result.ok ? result.value : assert.fail(JSON.stringify(result)); const query = Schema.decodeSync(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); + ): 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), + }), + ); } - if (!(body === undefined)) { - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("idempotency-key", key); - - if (etag) { - nativeHeaders.set("if-match", etag); - } + 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 requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; - - return fetch(`${backendOrigin}${path}`, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, - }); }; - 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 = async ( + response: ScriptCallResult, + status: number, + code?: string, + ): Promise => { + 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 readBoard = async () => + valueOf(await native.call(session(leader), (client) => client["placements.readBoard"](query))); const command = async (payload: Schema.Json) => { const board = await readBoard(); - return ( - await sdk.placements.commandBoard({ - query, - headers: idempotencyHeaders(board.etag), - payload: Schema.decodeUnknownSync(PlacementCommand)(payload), - }) - ).body; + return valueOf( + await 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 = async (cookie: string) => + valueOf( + await native.call(session(cookie), (client) => client["placements.readOwnCoverage"](query)), + ); - const readCoverageBoard = async () => (await sdk.placements.readCoverageBoard({ query })).body; + const readCoverageBoard = async () => + valueOf( + await native.call(session(leader), (client) => + client["placements.readCoverageBoard"](query), + ), + ); const commandOwnCoverage = async ( - client: typeof sdk, + cookie: string, payload: Schema.Json, etag?: string, - key?: string, + key = randomBytes(18).toString("base64url"), ) => { - 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 current = etag ?? (await readOwnCoverage(cookie)).etag; + + return valueOf( + await native.call(session(cookie), (client) => + client["placements.commandOwnCoverage"]({ + ...query, + ...commandKeys(current, key), + request: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), + }), + ), + ); }; - const commandCoverage = async (payload: Schema.Json, etag?: string, key?: string) => { - const resource = etag === undefined ? await readCoverageBoard() : undefined; - - return ( - await sdk.placements.commandCoverageBoard({ - query, - headers: idempotencyHeaders(etag ?? resource!.etag, key), - payload: Schema.decodeUnknownSync(CoverageCommand)(payload), - }) - ).body; + const commandCoverage = async ( + payload: Schema.Json, + etag?: string, + key = randomBytes(18).toString("base64url"), + ) => { + const current = etag ?? (await readCoverageBoard()).etag; + + return valueOf( + await 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. + await expectStatus(await request(boardPath, leader), 200); assert.deepEqual( (await readBoard()).schools.map((school) => school.schoolId), [961, 962], ); assert.ok( - (await sdk.placements.listScopes()).body.semesters.some( + valueOf( + await native.call(session(leader), (client) => client["placements.listScopes"]()), + ).semesters.some( (semester) => semester.semesterId === semesterId, ), ); @@ -1013,15 +1064,16 @@ try { }; let board = await readBoard(); - await expectStatus(await request(boardPath, leader, create), 428, "precondition.required"); + // 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( @@ -1459,10 +1511,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); + await expectStatus(await request(ownCoveragePath, volunteer), 200); + const initialOwnCoverage = await readOwnCoverage(volunteer); assert.deepEqual( initialOwnCoverage.commitments .filter((item) => item.proposalId === apiCoverageProposalId) @@ -1471,13 +1521,13 @@ try { [commitments.Monday, commitments.Wednesday].sort(), ); assert.deepEqual( - (await readOwnCoverage(wrongSdk)).commitments.filter( + (await readOwnCoverage(wrong)).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ), [], ); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).commitments.filter( + (await readOwnCoverage(candidate)).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ), [], @@ -1495,7 +1545,7 @@ try { const reportAbsenceKey = randomBytes(18).toString("base64url"); const reportedOwnCoverage = await commandOwnCoverage( - volunteerSdk, + volunteer, coveredAbsenceCommand, initialOwnCoverage.etag, reportAbsenceKey, @@ -1590,7 +1640,7 @@ try { // and not a person with an active affiliation but no placement. let coverageBoard = await readCoverageBoard(); assert.deepEqual(coverageBoard.coverers, []); - assert.deepEqual((await readOwnCoverage(volunteerSdk)).coverers, []); + assert.deepEqual((await readOwnCoverage(volunteer)).coverers, []); for (const personId of [substitute.personId, volunteerId, wrongId]) await rejectedCoverage( @@ -1643,18 +1693,15 @@ try { coverageBoard = await readCoverageBoard(); assert.deepEqual(coverageBoard.coverers, [onCallCoverer]); - assert.deepEqual((await readOwnCoverage(volunteerSdk)).coverers, [onCallCoverer]); + assert.deepEqual((await readOwnCoverage(volunteer)).coverers, [onCallCoverer]); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).coverers, + (await 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), @@ -1663,17 +1710,17 @@ try { ownCoveragePath, cookie, payload, - (await readOwnCoverage(client)).etag, + (await 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 = await readOwnCoverage(volunteer); const recordKey = randomBytes(18).toString("base64url"); const recordedOwn = await commandOwnCoverage( - volunteerSdk, + volunteer, recordCoverage(coveredAbsence.absenceId, substitute.personId), ownBeforeRecord.etag, recordKey, @@ -1842,7 +1889,7 @@ try { [substitute.personId, leaderId], ); assert.deepEqual( - (await readOwnCoverage(candidateSdk)).commitments + (await readOwnCoverage(candidate)).commitments .filter((item) => item.proposalId === apiCoverageProposalId) .map((item) => item.commitmentId), [commitments.Monday], @@ -1940,7 +1987,7 @@ try { ownCoveragePath, volunteer, coveredAbsenceCommand, - (await readOwnCoverage(volunteerSdk)).etag, + (await readOwnCoverage(volunteer)).etag, ], ] as const) await expectStatus(await request(path, cookie, payload, etag), 409, "commitment.closed"); @@ -2017,7 +2064,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 = await commandOwnCoverage(volunteer, { action: "ReportAbsence", commitmentId: commitments.Wednesday, }); @@ -2095,10 +2142,8 @@ try { 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( (item) => item.commitmentId === commitments.Wednesday, @@ -2863,6 +2908,7 @@ try { peopleBefore, "canonical Person unchanged", ); + await native.dispose(); const bunVersion = process.versions.bun; const postgresVersion = Schema.decodeUnknownSync(Schema.String)( From 2e61647f02b3376f83835eb19dfceadcc51b3406 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:49:35 +0000 Subject: [PATCH 037/148] test(rpc): recruitment and onboarding over RPC and PostgreSQL Port the recruitment boundary, actor authority, correction retry, and onboarding claim tests to the RPC harness, and cover the coordinator's onboarding board command with its entity tag and replay. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/onboarding/claim.rpc.test.ts | 390 +++++++ .../src/recruitment/authority.rpc.test.ts | 343 +++++++ .../src/recruitment/commands.rpc.test.ts | 372 +++++++ apps/backend/src/recruitment/rpc.test.ts | 948 ++++++++++++++++++ 4 files changed, 2053 insertions(+) create mode 100644 apps/backend/src/onboarding/claim.rpc.test.ts create mode 100644 apps/backend/src/recruitment/authority.rpc.test.ts create mode 100644 apps/backend/src/recruitment/commands.rpc.test.ts create mode 100644 apps/backend/src/recruitment/rpc.test.ts 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 00000000..f5c1e568 --- /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/recruitment/authority.rpc.test.ts b/apps/backend/src/recruitment/authority.rpc.test.ts new file mode 100644 index 00000000..c5408907 --- /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.rpc.test.ts b/apps/backend/src/recruitment/commands.rpc.test.ts new file mode 100644 index 00000000..863c0a64 --- /dev/null +++ b/apps/backend/src/recruitment/commands.rpc.test.ts @@ -0,0 +1,372 @@ +import { Database, IdentitySnapshot } from "@vektorprogrammet/database"; +import { AdmissionsLive } from "@vektorprogrammet/database/admissions"; +import { OrganizationLive } from "@vektorprogrammet/database/organization"; +import { ProfileLive } from "@vektorprogrammet/database/profile"; +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 { + Identity, + IdentityActor, + IdentitySessionNotFound, + type IdentityOperations, +} from "@vektorprogrammet/domain/identity"; +import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; +import { + InterviewSchemaId, + Recruitment, + RecruitmentAssignmentCommandId, + RecruitmentConductCommandId, + RecruitmentInterviewId, + RecruitmentInvitationCapabilitySchema, + RecruitmentInvitationId, + RecruitmentInvitationTransition, + RecruitmentScheduleCommandId, +} from "@vektorprogrammet/domain/recruitment"; +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 { backendTestConfig } from "../../test/config.js"; +import { makeBackendTestRpc } from "../test/native-rpc.js"; + +const departmentId = DepartmentId.make("commands-department"); + +const leaderPersonId = PersonId.make("commands-leader"); + +const interviewerPersonId = PersonId.make("commands-interviewer"); + +const applicationId = PublicApplicationIdSchema.make("commands-application"); + +const interviewId = RecruitmentInterviewId.make("commands-interview"); + +const capability = RecruitmentInvitationCapabilitySchema.make("commands".padEnd(43, "_")); + +const questionIds = Array.from({ length: 8 }, (_, ordinal) => `commands-q${ordinal}`); + +const domain = RecruitmentLive.pipe( + Layer.provideMerge( + Layer.mergeAll(AdmissionsLive, ProfileLive).pipe(Layer.provideMerge(OrganizationLive)), + ), +); + +/** A finalized interview at revision 2, conducted by the assigned interviewer. */ +const finalizedInterview = Effect.gen(function* () { + const sql = yield* Database; + const recruitment = yield* Recruitment; + + const leader = AdmissionPeriodActorSchema.cases.DepartmentAdministrator.make({ + personId: leaderPersonId, + departmentId, + active: true, + }); + + yield* sql`INSERT INTO admission_period_departments (department_id, name) VALUES (${departmentId}, 'Commands')`; + yield* sql`INSERT INTO admission_period_semesters (semester_id, start_at, end_at) VALUES ('commands-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 ('commands-period', ${departmentId}, 'commands-semester', '2031-09-01T00:00:00.000Z', '2031-10-01T00:00:00.000Z', 'commands-period-created') + `; + yield* sql`INSERT INTO admission_period_fields_of_study (field_of_study_id, department_id, name) VALUES ('commands-field', ${departmentId}, 'Computer Science')`; + 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 ('commands-applicant', 'commands@example.invalid', 'commands@example.invalid', 'Ada', 'Applicant', '90000000', 1, 'commands-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 (${applicationId}, 'commands-applicant', 'commands-period', ${departmentId}, 'commands-field', 2, '2031-09-10T12:00:00.000Z') + `; + yield* sql`INSERT INTO organization_departments (department_id, name, short_name, email, city) VALUES (${departmentId}, 'Commands', 'CMD', 'commands-department@example.invalid', 'Bergen')`; + yield* sql`INSERT INTO organization_teams (team_id, department_id, name) VALUES ('commands-team', ${departmentId}, 'Commands team')`; + yield* sql`INSERT INTO person_profiles (person_id, first_name, last_name) VALUES (${leaderPersonId}, 'Lise', 'Leader'), (${interviewerPersonId}, 'Ivar', 'Interviewer')`; + yield* sql` + INSERT INTO organization_memberships (membership_id, person_id, team_id, start_at, position_id, is_team_leader) + VALUES + ('commands-leader-membership', ${leaderPersonId}, 'commands-team', '2031-01-01T00:00:00.000Z', 'leader', TRUE), + ('commands-interviewer-membership', ${interviewerPersonId}, 'commands-team', '2031-01-01T00:00:00.000Z', 'assistant', FALSE) + `; + yield* sql`INSERT INTO person_contact_profiles (person_id, email, phone) VALUES (${interviewerPersonId}, 'commands-interviewer@example.invalid', '91111111')`; + yield* sql`INSERT INTO recruitment_interview_schemas (interview_schema_id, name, question_count) VALUES ('commands-schema', 'Standard interview', 8)`; + + for (const [ordinal, questionId] of questionIds.entries()) { + yield* sql` + INSERT INTO public.recruitment_interview_schema_questions (interview_schema_id, question_id, ordinal, prompt, help_text, kind, alternatives) + VALUES ('commands-schema', ${questionId}, ${ordinal}, ${`Question ${ordinal}`}, NULL, 'text', '[]'::jsonb) + `; + } + + yield* recruitment.assignApplicant( + { + commandId: RecruitmentAssignmentCommandId.make("commands-assignment"), + applicationId, + interviewerPersonId, + interviewSchemaId: InterviewSchemaId.make("commands-schema"), + }, + { actor: leader, now: "2031-09-15T12:00:00.000Z", interviewId }, + ); + yield* recruitment.scheduleInterview( + { + commandId: RecruitmentScheduleCommandId.make("commands-schedule"), + interviewId, + expectedRevision: 0, + scheduledAt: "2031-09-20T10:00:00.000Z", + room: "A-101", + campus: "Main Campus", + mapLink: "https://maps.example.invalid/interview-room", + message: "Welcome to your interview.", + }, + { + actor: leader, + now: "2031-09-15T12:00:00.000Z", + invitationId: RecruitmentInvitationId.make("commands-invitation"), + responseCapability: capability, + }, + ); + yield* recruitment.transitionInvitation({ + capability, + transition: RecruitmentInvitationTransition.Confirm(), + now: "2031-09-15T12:01:00.000Z", + }); + yield* recruitment.finalizeInterview( + { + commandId: RecruitmentConductCommandId.make("commands-finalize"), + interviewId, + expectedRevision: 1, + answers: questionIds.map((questionId) => ({ questionId, answer: `Original ${questionId}` })), + score: { explanatoryPower: 4, roleModel: 5, suitability: 6 }, + recommendation: "Ja", + }, + { + actor: AdmissionPeriodActorSchema.cases.Member.make({ + personId: interviewerPersonId, + departmentId, + active: true, + }), + now: "2031-09-15T12:02:00.000Z", + }, + ); +}).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, + 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; + readonly correctionReceipts: number; + readonly audits: number; + readonly httpReceipts: number; + readonly revision: number; + }>` + SELECT + (SELECT count(*)::integer FROM public.recruitment_interview_correction_assessments) AS corrections, + (SELECT count(*)::integer FROM public.recruitment_interview_correction_command_receipts) AS "correctionReceipts", + (SELECT count(*)::integer FROM public.recruitment_interview_correction_audit) AS audits, + (SELECT count(*)::integer FROM public.native_http_idempotency_receipts) AS "httpReceipts", + (SELECT revision FROM public.recruitment_interviews WHERE interview_id = ${interviewId}) AS revision + `, +).pipe(Effect.map((rows) => rows[0])); + +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 = () => + Effect.gen(function* () { + const database = backendDatabase(finalizedInterview); + + const backend = makeBackendTestRpc({ + config: { + ...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, "_"), + }, + }, + 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"); + + return DatabaseRuntimeLive({ ...target, maxConnections: 1 }).pipe(Layer.orDie); + }); + + return { database, client, asInterviewer, 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 RPC and PostgreSQL", () => { + it.live("retries an assessment correction once after a PostgreSQL serialization failure", () => + Effect.gen(function* () { + const { database, client, asInterviewer, competitor } = yield* fixture(); + + const conduct = yield* asInterviewer( + client["recruitment.readInterviewConduct"]({ interviewId }), + ); + + expect(yield* database.run(persistedCorrection)).toEqual({ + corrections: 0, + correctionReceipts: 0, + audits: 0, + httpReceipts: 0, + revision: 2, + }); + + 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. + const concurrentUpdate = yield* Database.use((sql) => + sql.withTransaction( + Effect.gen(function* () { + yield* sql`UPDATE public.recruitment_interviews SET revision = revision WHERE interview_id = ${interviewId}`; + yield* Deferred.succeed(rowHeld, undefined); + + return yield* sql<{ readonly waiting: number }>` + SELECT count(*)::integer AS waiting + FROM pg_locks + WHERE locktype = 'transactionid' 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(competing, { local: true }), Effect.forkChild); + + yield* Deferred.await(rowHeld); + + 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(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, + correctionReceipts: 1, + audits: 1, + httpReceipts: 1, + revision: 3, + }); + + // A retry with the same key replays the first answer, entity tag included. + expect(yield* correct).toEqual(corrected); + + // 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: 1, + correctionReceipts: 1, + audits: 1, + httpReceipts: 1, + revision: 3, + }); + }), + ); +}); diff --git a/apps/backend/src/recruitment/rpc.test.ts b/apps/backend/src/recruitment/rpc.test.ts new file mode 100644 index 00000000..1f54c5ae --- /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" }); + } +}); From 512a4edd432d5e182ae60067e3f54a8326baf9ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:51:23 +0000 Subject: [PATCH 038/148] feat(dashboard): call the receipt RPCs from the receipt routes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/AGENTS.md | 4 +- .../app/lib/receipt-upload.server.test.ts | 143 ++++++++++++++++++ .../app/lib/receipt-upload.server.ts | 141 +++++++++++++++++ apps/dashboard/app/receipt-file-route.test.ts | 119 ++++++++++----- .../app/receipt-owner-intake-route.test.ts | 83 ++++++++-- .../routes/dashboard.mine-utlegg._index.tsx | 128 +++++++++------- .../dashboard.utlegg.$receiptId.file.ts | 87 +++-------- .../app/routes/dashboard.utlegg._index.tsx | 68 +++++---- .../dashboard.utlegg.oppgjor.$receiptId.tsx | 12 +- .../dashboard.utlegg.oppgjor._index.tsx | 49 +++--- 10 files changed, 610 insertions(+), 224 deletions(-) create mode 100644 apps/dashboard/app/lib/receipt-upload.server.test.ts create mode 100644 apps/dashboard/app/lib/receipt-upload.server.ts diff --git a/apps/backend/src/receipt/AGENTS.md b/apps/backend/src/receipt/AGENTS.md index a3af77d1..593729c5 100644 --- a/apps/backend/src/receipt/AGENTS.md +++ b/apps/backend/src/receipt/AGENTS.md @@ -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/dashboard/app/lib/receipt-upload.server.test.ts b/apps/dashboard/app/lib/receipt-upload.server.test.ts new file mode 100644 index 00000000..28a97aa3 --- /dev/null +++ b/apps/dashboard/app/lib/receipt-upload.server.test.ts @@ -0,0 +1,143 @@ +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.server"; + +describe("receipt transfer bounds", () => { + it.effect.each([undefined, "1"])( + "cancels an oversized stream despite declared length %s", + (length) => + Effect.gen(function* () { + let pulls = 0; + const cancelled = Promise.withResolvers(); + const bytes = new TextEncoder(); + + const body = new ReadableStream({ + pull(controller) { + if (pulls === 64) { + controller.close(); + + return; + } + + controller.enqueue( + pulls++ === 0 + ? bytes.encode( + '--receipt\r\nContent-Disposition: form-data; name="description"\r\n\r\n', + ) + : new Uint8Array(4096).fill(120), + ); + }, + cancel() { + cancelled.resolve(); + }, + }); + + const headers = new Headers({ "content-type": "multipart/form-data; boundary=receipt" }); + + if (length !== undefined) headers.set("content-length", length); + + const options: RequestInit & { duplex: "half" } = { + method: "POST", + headers, + body, + duplex: "half", + }; + + const request = new Request("http://receipt.test", options); + + const failure = yield* Effect.promise(() => + readBoundedReceiptForm(request, 1).catch((error: Error) => error), + ); + + assert.ok(failure instanceof MultipartError); + expect(failure.reason._tag).toBe("FieldTooLarge"); + yield* Effect.promise(() => cancelled.promise); + expect(pulls).toBeLessThanOrEqual(36); + }), + ); + + it.effect.each([";", " ;"])("preserves opaque binary bytes with separator %s", (separator) => + Effect.gen(function* () { + const form = new FormData(); + const bytes = new Uint8Array([0, 255, 17, 42]); + form.set("file", new File([bytes], "receipt.png", { type: "image/png" })); + + const request = new Request("http://receipt.test", { method: "POST", body: form }); + request.headers.set( + "content-type", + request.headers.get("content-type")!.replace(";", separator), + ); + const parsed = yield* Effect.promise(() => readBoundedReceiptForm(request, bytes.length)); + + const file = parsed.get("file"); + assert.ok(file instanceof File); + expect(new Uint8Array(yield* Effect.promise(() => file.arrayBuffer()))).toEqual(bytes); + }), + ); + // One transport chunk carries the whole body; 200_000 bytes also exceed the transfer bound. + it.effect.each([5, 200_000])( + "reports a %s-byte file beyond its limit with the fields sent before it", + (fileBytes) => + Effect.gen(function* () { + const form = new FormData(); + form.set("commandId", "receipt-command"); + form.set( + "file", + new File([new Uint8Array(fileBytes)], "receipt.png", { type: "image/png" }), + ); + const encoded = new Request("http://receipt.test", { method: "POST", body: form }); + const bytes = yield* Effect.promise(() => encoded.arrayBuffer()); + const headers = new Headers(encoded.headers); + headers.set("content-length", String(bytes.byteLength)); + + const request = new Request(encoded.url, { method: encoded.method, headers, body: bytes }); + + const failure = yield* Effect.promise(() => + readBoundedReceiptForm(request, 4).catch((error: Error) => error), + ); + + assert.ok(failure instanceof ReceiptFileTooLarge); + expect([...failure.fields.entries()]).toEqual([["commandId", "receipt-command"]]); + }), + ); + it.effect("cancels an incomplete multipart source when the request is aborted", () => + Effect.gen(function* () { + const requestLifetime = yield* Scope.make(); + const signal = yield* Effect.abortSignal.pipe(Scope.provide(requestLifetime)); + const started = Promise.withResolvers(); + const cancelled = Promise.withResolvers(); + + const body = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + '--receipt\r\nContent-Disposition: form-data; name="file"; filename="receipt.png"\r\nContent-Type: image/png\r\n\r\n', + ), + ); + }, + pull() { + started.resolve(); + }, + cancel() { + cancelled.resolve(); + }, + }); + + const options: RequestInit & { duplex: "half" } = { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=receipt" }, + body, + signal, + duplex: "half", + }; + + const pending = readBoundedReceiptForm(new Request("http://receipt.test", options), 1024); + yield* Effect.promise(() => started.promise); + yield* Scope.close(requestLifetime, Exit.void); + yield* Effect.promise(() => expect(pending).rejects.toThrow()); + yield* Effect.promise(() => cancelled.promise); + }), + ); +}); diff --git a/apps/dashboard/app/lib/receipt-upload.server.ts b/apps/dashboard/app/lib/receipt-upload.server.ts new file mode 100644 index 00000000..7f7989aa --- /dev/null +++ b/apps/dashboard/app/lib/receipt-upload.server.ts @@ -0,0 +1,141 @@ +/** + * 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"; + +export { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; + +/** + * A receipt file part exceeded its byte limit. This is an input error in a readable form: + * `fields` holds the parts read before the file, so a form can answer in place with the + * draft and command identity it submitted. + */ +export class ReceiptFileTooLarge extends RangeError { + readonly fields: FormData; + + constructor(fields: FormData) { + super("Receipt file is too large"); + this.name = "ReceiptFileTooLarge"; + this.fields = fields; + } +} + +/** The request body cannot be read as a receipt form. */ +class ReceiptBodyInvalid extends Data.TaggedError("ReceiptBodyInvalid")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +/** Bytes beyond a file's limit that the transfer bound admits for the other form parts. */ +const transferMargin = 131_072; + +/** The most bytes that a receipt form with a file of at most `maxFileBytes` may transfer. */ +export const receiptTransferMaxBytes = (maxFileBytes: number): number => + maxFileBytes + transferMargin; + +/** + * Transport chunks are split to at most half the margin and parsed one per pull, so the + * parser sees a file cross its limit before the transfer count can cross the body bound. + */ +const transferSlice = transferMargin / 2; + +const slices = function* (chunk: Uint8Array) { + for (let offset = 0; offset < chunk.byteLength; offset += transferSlice) { + yield chunk.subarray(offset, offset + transferSlice); + } +}; + +const boundedReceiptForm = Effect.fnUntraced(function* (request: Request, maxFileBytes: number) { + const maxBytes = receiptTransferMaxBytes(maxFileBytes); + const length = request.headers.get("content-length"); + + if (length !== null && (!/^\d+$/u.test(length) || !Number.isSafeInteger(Number(length)))) { + return yield* new ReceiptBodyInvalid({ message: "Invalid receipt body length" }); + } + + const body = request.body; + + if (!body) return yield* new ReceiptBodyInvalid({ message: "Receipt body is required" }); + const contentType = request.headers.get("content-type") ?? ""; + const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase(); + let size = 0; + + const source = Stream.fromReadableStream({ + evaluate: () => body, + onError: (cause) => new ReceiptBodyInvalid({ message: "Receipt transfer failed", cause }), + }).pipe( + Stream.map(slices), + Stream.flattenIterable, + Stream.rechunk(1), + Stream.map((chunk) => { + size += chunk.byteLength; + + if (size > maxBytes) throw new RangeError("Receipt body is too large"); + + return chunk; + }), + ); + + const form = new FormData(); + + if (mediaType === "application/x-www-form-urlencoded") { + const encoded = yield* source.pipe(Stream.decodeText(), Stream.mkString); + + for (const [key, value] of new URLSearchParams(encoded)) form.append(key, value); + + return form; + } + + if (mediaType !== "multipart/form-data") { + return yield* new ReceiptBodyInvalid({ message: "Unsupported receipt content type" }); + } + + yield* source.pipe( + Stream.pipeThroughChannel(Multipart.makeChannel({ "content-type": contentType })), + Stream.runForEach( + Effect.fnUntraced(function* (part) { + if (Multipart.isField(part)) { + form.append(part.key, part.value); + } else { + const chunks: Array> = []; + let fileBytes = 0; + yield* Stream.runForEach(part.content, (chunk) => + Effect.sync(() => { + fileBytes += chunk.byteLength; + + if (fileBytes > maxFileBytes) throw new ReceiptFileTooLarge(form); + + if (!(chunk.buffer instanceof ArrayBuffer)) + throw new TypeError("Invalid receipt byte buffer"); + chunks.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + }), + ); + form.append(part.key, new File(chunks, part.name, { type: part.contentType })); + } + }), + ), + Effect.provideContext( + Multipart.limitsServices({ + maxParts: 8, + maxFieldSize: 65_536, + maxFileSize: maxBytes, + maxTotalSize: maxBytes, + fieldMimeTypes: [], + }), + ), + ); + + return form; +}); + +/** + * Bound transfer bytes while parsing and cancel the source with its request. + * + * The byte counters, not the declared length, enforce the bounds: a body whose file is + * too large stops after that file's limit, so the fields before it stay readable. + */ +export const readBoundedReceiptForm = (request: Request, maxFileBytes: number): Promise => + Effect.runPromise(boundedReceiptForm(request, maxFileBytes), { signal: request.signal }); diff --git a/apps/dashboard/app/receipt-file-route.test.ts b/apps/dashboard/app/receipt-file-route.test.ts index 490f20b0..655c930a 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 decf87f6..e28f2dda 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/rpc"; +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/dashboard.mine-utlegg._index.tsx b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx index ecfa468c..2e4fa110 100644 --- a/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx +++ b/apps/dashboard/app/routes/dashboard.mine-utlegg._index.tsx @@ -16,10 +16,10 @@ import { type ReceiptUiErrorField, } from "@/lib/receipt-view"; import { - RECEIPT_FILE_MAX_BYTES, - ReceiptFileTooLarge, + ReceiptCursor, ReceiptId, - readBoundedReceiptForm, + type ReceiptFileUpload, + type SubmitReceiptRequest, } from "@vektorprogrammet/rpc"; import { IdempotencyKey, @@ -27,10 +27,15 @@ import { type IdempotencyKey as IdempotencyKeyValue, type StrongETag as StrongETagValue, } from "@vektorprogrammet/rpc"; -import { Schema } from "effect"; +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.utlegg.$receiptId.file.ts b/apps/dashboard/app/routes/dashboard.utlegg.$receiptId.file.ts index b75aac99..0ceffb26 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/rpc" +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 85b6b432..f7a6499e 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,7 +11,7 @@ import { type ReceiptStatus, ReceiptUiError, } from "@/lib/receipt-view"; -import { ReceiptId } from "@vektorprogrammet/rpc"; +import { ReceiptCursor, ReceiptId } from "@vektorprogrammet/rpc"; import { IdempotencyKey, StrongETag, @@ -20,7 +20,7 @@ import { } 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 a96d995e..426f7163 100644 --- a/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx +++ b/apps/dashboard/app/routes/dashboard.utlegg.oppgjor.$receiptId.tsx @@ -4,7 +4,7 @@ import { isUnauthorizedError, mapReceiptSettlementEvidenceView, mapApprovalRecei 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 c3787039..84f6ca10 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/rpc"; -import { Schema } from "effect"; +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 }; From b4f5c6a140089ec7feb90623532163cbbaa7fe50 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:55:43 +0000 Subject: [PATCH 039/148] feat(dashboard): recruitment and onboarding callers over RPC The recruitment bridge route, the interview invitation bridge, the maintenance Foldkit program, and the interview, applicant, onboarding, and account-claim routes call the recruitment and onboarding RPCs. The invitation capability travels in the payload with no session cookie beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../dashboard/app/foldkit/interview/update.ts | 4 +- .../recruitment-maintenance/command.ts | 33 +++-- .../foldkit/recruitment-maintenance/update.ts | 3 +- .../foldkit/recruitment/bridge-route.test.ts | 114 +++++++++++------ .../app/foldkit/recruitment/bridge.ts | 16 ++- apps/dashboard/app/foldkit/scheduling/view.ts | 4 +- .../app/lib/interview-bridge.server.test.ts | 65 +++++++--- .../app/lib/interview-bridge.server.ts | 75 +++++------ .../interview-response-exchange-route.test.ts | 30 ++++- .../app/routes/__foldkit.recruitment.ts | 116 ++++++++++-------- .../routes/dashboard.intervjuer._index.tsx | 10 +- .../routes/dashboard.intervjuer.rapport.tsx | 15 +-- .../app/routes/dashboard.onboarding.tsx | 44 +++---- .../app/routes/dashboard.sokere._index.tsx | 10 +- .../dashboard/app/routes/konto-aktivering.tsx | 7 +- 15 files changed, 326 insertions(+), 220 deletions(-) diff --git a/apps/dashboard/app/foldkit/interview/update.ts b/apps/dashboard/app/foldkit/interview/update.ts index 38b7668b..369abf86 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/rpc" +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/recruitment-maintenance/command.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/command.ts index b5bfe396..44b975ff 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/rpc"; -import { createEffectClient } from "@vektorprogrammet/sdk/effect"; -import { resolveBrowserApiUrl } from "../../lib/browser-api"; +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/update.ts b/apps/dashboard/app/foldkit/recruitment-maintenance/update.ts index 598c9af8..b57d6ac8 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/rpc"; +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/bridge-route.test.ts b/apps/dashboard/app/foldkit/recruitment/bridge-route.test.ts index d0966178..71a33755 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/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 edcb5249..812be29f 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge.ts @@ -8,7 +8,21 @@ RecruitmentAssignmentBoardQuerySchema, RecruitmentAssignmentBoardSchema, RecruitmentInterviewConductObservationSchema, RecruitmentInterviewId, } from "@vektorprogrammet/rpc" -import { CancelInterviewRequest, CancelInterviewResponse, ConditionalReadHeaders, CreateApplicationInterviewRequest, FinalizeInterviewRequest, CorrectInterviewAssessmentRequest, CorrectInterviewAssessmentResponse, FinalizeInterviewResponse, IdempotencyHeaders, IdempotencyIfMatchHeaders, RecruitmentInterviewResource, SchedulingBoard, ScheduleInterviewRequest, ScheduleInterviewResponse, StrongETag } 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; diff --git a/apps/dashboard/app/foldkit/scheduling/view.ts b/apps/dashboard/app/foldkit/scheduling/view.ts index c16e2e35..0589aa9e 100644 --- a/apps/dashboard/app/foldkit/scheduling/view.ts +++ b/apps/dashboard/app/foldkit/scheduling/view.ts @@ -1,5 +1,5 @@ import { Predicate, Match } from "effect"; -import { interviewRecommendations } from "@vektorprogrammet/rpc" +import { interviewRecommendations } from "@vektorprogrammet/domain/recruitment"; import type { RecruitmentInterviewConductObservation, RecruitmentInterviewQuestionSnapshot, RecruitmentSchedulingInterview, } from "@vektorprogrammet/rpc" @@ -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/lib/interview-bridge.server.test.ts b/apps/dashboard/app/lib/interview-bridge.server.test.ts index 06d76a75..6d704734 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/rpc"; +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 5732c5df..1dc48b86 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/rpc" import { parseJsonWithUniqueMembers } from "@vektorprogrammet/rpc" -import { createConfiguredPromiseClient } from "@vektorprogrammet/sdk"; +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 ba64c125..3e52410b 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/routes/__foldkit.recruitment.ts b/apps/dashboard/app/routes/__foldkit.recruitment.ts index a7cd21d8..bc827baf 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/dashboard.intervjuer._index.tsx b/apps/dashboard/app/routes/dashboard.intervjuer._index.tsx index 8a4e1506..e061f099 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 { callNative, 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,7 +21,6 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); let profile; @@ -40,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 e6ab77f3..d0cebd26 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/rpc" -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.onboarding.tsx b/apps/dashboard/app/routes/dashboard.onboarding.tsx index 4abc7825..0711c3c4 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/rpc" -import { IdempotencyIfMatchHeaders } from "@vektorprogrammet/rpc"; +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.sokere._index.tsx b/apps/dashboard/app/routes/dashboard.sokere._index.tsx index 753f2db6..4a446ca7 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 { callNative, 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,7 +21,6 @@ const responseHeaders = { export async function loader({ request }: Route.LoaderArgs) { const cookie = await requireAuth(request); - const client = createAuthenticatedClient(cookie, request); let profile; @@ -49,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/konto-aktivering.tsx b/apps/dashboard/app/routes/konto-aktivering.tsx index 3610a5eb..6b81142a 100644 --- a/apps/dashboard/app/routes/konto-aktivering.tsx +++ b/apps/dashboard/app/routes/konto-aktivering.tsx @@ -4,7 +4,7 @@ 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, From 0980ab922299d30440c51654e18d31b06bf71c5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:00:35 +0000 Subject: [PATCH 040/148] test(receipts): port the receipt journeys and verification tools to the receipt RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/fixtures/receipt-api.ts | 684 ---------------- apps/dashboard/e2e/receipt-approval.spec.ts | 746 ++++++++---------- apps/dashboard/e2e/receipt-rpc.ts | 56 ++ apps/dashboard/e2e/receipts.spec.ts | 277 +++---- tools/e2e/legacy-candidate-native-journey.ts | 85 +- .../receipt-delivery-observation.ts | 99 ++- .../verification/receipt-import-rehearsal.ts | 52 +- .../receipt-reopen-observation.ts | 150 ++-- .../unattended-delivery-recovery.ts | 43 +- 9 files changed, 769 insertions(+), 1423 deletions(-) delete mode 100644 apps/dashboard/e2e/fixtures/receipt-api.ts create mode 100644 apps/dashboard/e2e/receipt-rpc.ts diff --git a/apps/dashboard/e2e/fixtures/receipt-api.ts b/apps/dashboard/e2e/fixtures/receipt-api.ts deleted file mode 100644 index d191d6e0..00000000 --- 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/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index 619be1c5..ddf5c448 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -18,6 +18,7 @@ import { } from "@playwright/test"; import { ReceiptApprovalQueueResponse, + ReceiptFileContent, ReceiptListResponse, ReceiptResource, } from "@vektorprogrammet/rpc"; @@ -30,6 +31,7 @@ import { 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); @@ -100,15 +102,7 @@ const decodeOwnedReceiptPage = Schema.decodeUnknownSync(ReceiptListResponse); const decodeApprovalQueuePage = Schema.decodeUnknownSync(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(ReceiptFileContent); const fileIdentitySchema = z.array( z @@ -241,44 +235,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( @@ -337,9 +404,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 = ` @@ -421,13 +487,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 { @@ -453,36 +516,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) { @@ -497,15 +561,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( @@ -579,9 +639,7 @@ async function authenticate( } else { const outcome = nativeRpcOutcome(profileAnswer); - expect(Predicate.isTagged(outcome, "Problem") && outcome.problem.code).toBe( - "authority.denied", - ); + expect(Predicate.isTagged(outcome, "Problem") && outcome.problem.code).toBe("authority.denied"); } return { @@ -701,9 +759,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, @@ -713,9 +769,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"); @@ -729,15 +786,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"); @@ -790,8 +843,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( @@ -802,11 +856,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( @@ -817,61 +870,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"); + expectApprovedRpcFile(ownerFileResponse, approvedReceipt.file, fileIdentitiesBefore); - 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); - - 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( @@ -882,11 +916,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( @@ -897,11 +930,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( @@ -912,9 +944,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( @@ -925,9 +958,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( @@ -938,9 +972,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( @@ -951,9 +986,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( @@ -1041,12 +1077,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, @@ -1076,15 +1113,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, }, ); @@ -1094,68 +1130,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; @@ -1326,15 +1302,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, }, ); @@ -1342,20 +1317,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"); @@ -1422,21 +1395,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", @@ -1444,15 +1415,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, }, ); @@ -1462,11 +1432,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, }, ); @@ -1476,11 +1449,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, }, ); @@ -1501,21 +1477,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", @@ -1523,12 +1497,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( @@ -1539,23 +1512,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, @@ -1611,26 +1586,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, @@ -1658,48 +1631,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, @@ -1726,29 +1692,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", @@ -1763,26 +1722,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); @@ -2008,10 +1962,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, @@ -2019,43 +1973,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(), @@ -2109,10 +2059,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 00000000..e51969be --- /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 8c51316f..7d35cbb0 100644 --- a/apps/dashboard/e2e/receipts.spec.ts +++ b/apps/dashboard/e2e/receipts.spec.ts @@ -3,18 +3,9 @@ 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, @@ -22,6 +13,7 @@ import { 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<{ @@ -77,8 +69,6 @@ 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); @@ -275,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; @@ -292,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, @@ -304,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)}]`); } @@ -332,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, @@ -486,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(); @@ -512,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]; @@ -595,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"); @@ -661,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, @@ -723,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, ); @@ -768,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", @@ -819,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(); @@ -858,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"); @@ -881,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", @@ -909,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( @@ -928,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, @@ -956,7 +917,7 @@ test.describe("Native Receipt owner journey", () => { fileStore: "private-filesystem", }, unauthenticated: { - status: unauthenticatedResponse.status(), + status: unauthenticatedResponse.status, code: unauthenticatedTag, }, rejected: [ @@ -971,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/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index 9e9e228c..050eb332 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -22,6 +22,7 @@ import { PlacementScopes, nativeRpcPath, OwnProfileResource, + ReceiptFileContent, ReceiptListResponse, } from "@vektorprogrammet/rpc"; import { Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect"; @@ -221,6 +222,39 @@ export const observeLegacyCandidateNativeJourney = async ( 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 = async ( + name: string, + tag: "receipts.listReceipts" | "receipts.readReceiptFile", + payload: Schema.Json, + cookie?: string, + authorization?: string, + ) => { + phase = name; + const headers = new Headers({ origin: dashboardOrigin, "content-type": "application/json" }); + + if (cookie !== undefined) headers.set("cookie", cookie); + + if (authorization !== undefined) headers.set("authorization", authorization); + + const response = await runtime.runPromise( + api( + new Request(backendOrigin + nativeRpcPath, { + method: "POST", + headers, + body: nativeRpcRequestBody(tag, payload), + }), + ), + ); + + const answer = await response.text(); + + return { status: nativeRpcStatus(answer), value: nativeRpcValue(answer) }; + }; + const ownProfile = async (name: string, cookie: string) => { const answer = await readOwnProfile(name, cookie); assert.equal(answer.status, 200); @@ -398,13 +432,16 @@ export const observeLegacyCandidateNativeJourney = async ( 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), ); @@ -415,34 +452,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) { diff --git a/tools/verification/receipt-delivery-observation.ts b/tools/verification/receipt-delivery-observation.ts index 1c343d01..64bc37b3 100644 --- a/tools/verification/receipt-delivery-observation.ts +++ b/tools/verification/receipt-delivery-observation.ts @@ -1,6 +1,9 @@ -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 { Predicate } from "effect"; +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 { createServer } from "node:http"; import { spawn } from "node:child_process"; @@ -137,46 +140,49 @@ export const observeReceiptDelivery = async (options: { 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 native = nativeScriptClient(origin); + const as = (session: string) => ({ cookie: session, origin: dashboardOrigin }); - const response = await fetch(`${origin}/api/receipts?departmentId=receipt-department-0095`, { - method: "POST", - headers: headers(cookie, key), - body: form, + /** 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 }, + ) => + 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); }); - assert.equal(response.status, 201, await response.clone().text()); - - const body = Schema.decodeUnknownSync(Schema.Record(Schema.String, Schema.Json))( - await response.json(), + const submit = async (key: string) => { + const result = await 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(Predicate.isString(body.receiptId) && body.receiptId.length > 0); + assert.ok(result.ok, `synthetic submission answered ${result.status}`); + assert.ok(result.value.receiptId.length > 0); - return { id: body.receiptId, etag: response.headers.get("etag")! }; + return { id: result.value.receiptId, etag: result.value.etag }; }; try { @@ -240,13 +246,9 @@ export const observeReceiptDelivery = async (options: { // 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: "{}", - }); + const approval = await transition("approve", approverCookie, "receipt0097-approve", failed); - assert.equal(approval.status, 200, await approval.clone().text()); + assert.equal(approval.status, 200, JSON.stringify(approval)); assert.ok((await outbox(failed.id)).some((r) => r.status === "Failed")); const approvalEnvelope = attempts.at(-1)!; assert.equal(approvalEnvelope.to, "owner0095@example.invalid"); @@ -284,13 +286,9 @@ export const observeReceiptDelivery = async (options: { 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: "{}", - }); + const rejected = await transition("reject", approverCookie, "receipt0097-reject", concurrent); - assert.equal(rejected.status, 200, await rejected.clone().text()); + assert.equal(rejected.status, 200, JSON.stringify(rejected)); assert.ok((await outbox(concurrent.id)).some((r) => r.status === "Failed")); assert.equal(redirected, 0); mode = "accept"; @@ -329,11 +327,7 @@ export const observeReceiptDelivery = async (options: { "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: "{}", - }); + const denied = await transition("reject", approverCookie, "receipt0097-denied", missing); assert.equal(denied.status, 403); assert.equal( @@ -369,6 +363,7 @@ export const observeReceiptDelivery = async (options: { "local synthetic; 2xx means transport acceptance, not human receipt; receiver deduplication required; at-least-once retry", }; } finally { + await native.dispose(); sink.closeAllConnections(); await new Promise((resolve) => sink.close(() => resolve())); } diff --git a/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index b8a4e6c4..dbfda237 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -47,7 +47,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, @@ -563,7 +563,8 @@ try { foreign, ...[cookie, foreign].map((value) => value.slice(value.indexOf("=") + 1)), ); - const client = createPromiseClient(backendOrigin, { cookie, origin: dashboardOrigin }); + const native = nativeScriptClient(backendOrigin); + const ownerHeaders = { cookie, origin: dashboardOrigin }; const reconciliationDiagnostics: Array<{ sourcePrimaryKey: string; @@ -579,8 +580,12 @@ try { Effect.tryPromise({ try: async () => { 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 = await native.call(ownerHeaders, (client) => + client["receipts.listReceipts"]({}), + ); + + assert.ok(list.ok, `listReceipts answered ${list.status}`); + 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); @@ -589,11 +594,15 @@ try { assert.ok(!JSON.stringify(item).includes(result.receipt.file.objectKey)); phase = "native private byte download"; - const downloaded = await client.receipts.readReceiptFile({ - params: { receiptId: ReceiptId.make(result.receipt.receiptId) }, - }); + const downloaded = await native.call(ownerHeaders, (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(result.receipt.receiptId), + }), + ); + + assert.ok(downloaded.ok, `readReceiptFile answered ${downloaded.status}`); - return digest(downloaded.body) === result.receipt.file.sha256; + return digest(downloaded.value.bytes) === result.receipt.file.sha256; }, catch: (cause) => { const reason = safe(String(cause)); @@ -636,12 +645,19 @@ try { }); for (const deniedCookie of [foreign, undefined]) { - const r = await fetch(`${backendOrigin}/api/receipts/${result.receipt.receiptId}/file`, { - headers: deniedCookie ? { cookie: deniedCookie } : {}, - }); + const denied = await native.call( + deniedCookie === undefined + ? { origin: dashboardOrigin } + : { cookie: deniedCookie, origin: dashboardOrigin }, + (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(result.receipt.receiptId), + }), + ); - assert.equal(r.status, deniedCookie ? 404 : 401); - assert.ok(!Buffer.from(await r.arrayBuffer()).equals(bytes)); + // 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); } } @@ -671,12 +687,16 @@ try { assert.equal(collisionLedger[0]?.result, "Quarantined"); assert.ok(collisionLedger[0]?.reasons_json.reasons.includes("DestinationIdentityCollision")); - const invalidBearer = await fetch( - `${backendOrigin}/api/receipts/${accepted[0]!.receipt.receiptId}/file`, - { headers: { cookie, authorization: "Bearer invalid-synthetic-0095" } }, + const invalidBearer = await native.call( + { cookie, authorization: "Bearer invalid-synthetic-0095", origin: dashboardOrigin }, + (client) => + client["receipts.readReceiptFile"]({ + receiptId: ReceiptId.make(accepted[0]!.receipt.receiptId), + }), ); assert.equal(invalidBearer.status, 401); + await native.dispose(); const stable = await snapshot(); const fileDigest = async () => { diff --git a/tools/verification/receipt-reopen-observation.ts b/tools/verification/receipt-reopen-observation.ts index b8480ffe..6baae9ce 100644 --- a/tools/verification/receipt-reopen-observation.ts +++ b/tools/verification/receipt-reopen-observation.ts @@ -6,10 +6,19 @@ 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/rpc"; +import { Match } from "effect"; +import { ReceiptId } from "@vektorprogrammet/rpc"; import { StrongETag, IdempotencyKey } from "@vektorprogrammet/rpc/problem"; -import { createPromiseClient } from "../../packages/sdk/src/promise.js"; +import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; + +type ReceiptAction = "approve" | "reject" | "reopen" | "withdraw"; + +/** 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; +}; export async function observeReceiptReopening(options: { pool: Pool; @@ -24,35 +33,31 @@ export async function observeReceiptReopening(options: { 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 native = nativeScriptClient(origin); + const as = (session: string) => ({ cookie: session, origin: dashboardOrigin }); + /** One receipt transition, as the session given, answered with its registry status. */ const request = ( id: string, - action: string, - etag?: string, + action: ReceiptAction, + etag: string, session = approverCookie, key: string = randomUUID(), - body = "{}", ) => - fetch(`${origin}/api/receipts/${id}:${action}`, { - method: "POST", - headers: headers(session, etag, key), - body, + 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 = async (id: string) => @@ -81,37 +86,33 @@ export async function observeReceiptReopening(options: { }); 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 resource = resourceOf( + await 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", ); - 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); + const response = resourceOf(await request(receipt.id, "reject", receipt.etag), "rejection"); - return { - ...receipt, - initialEtag: receipt.etag, - etag: Schema.decodeUnknownSync(StrongETag)(response.headers.get("etag")), - }; + return { ...receipt, initialEtag: receipt.etag, etag: response.etag }; }; options.setDeliveryAvailable(false); @@ -121,23 +122,11 @@ export async function observeReceiptReopening(options: { assert.ok(before.outbox.some((item: any) => item.status === "Failed")); const attemptsBefore = options.attempts(); - // Invalid commands must have no persisted footprint. + // 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", 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); @@ -182,13 +171,12 @@ export async function observeReceiptReopening(options: { 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: {}, - }); + const reopened = resourceOf( + await request(target.id, "reopen", target.etag, approverCookie, retryKey), + "reopening", + ); - assert.equal(reopened.body.status, "Pending"); + assert.equal(reopened.status, "Pending"); const after = await snapshot(target.id); assert.deepEqual(after.receipt, { ...before.receipt, @@ -200,16 +188,15 @@ export async function observeReceiptReopening(options: { 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: {}, - }); + const replay = resourceOf( + await request(target.id, "reopen", target.etag, approverCookie, retryKey), + "reopening replay", + ); - assert.deepEqual(replay.body, reopened.body); + assert.deepEqual(replay, reopened); assert.deepEqual(await snapshot(target.id), after); assert.equal( - (await request(target.id, "reopen", reopened.body.etag, approverCookie, retryKey)).status, + (await request(target.id, "reopen", reopened.etag, approverCookie, retryKey)).status, 409, ); await pool.query( @@ -222,7 +209,7 @@ export async function observeReceiptReopening(options: { 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); + assert.equal((await request(target.id, "reopen", reopened.etag)).status, 409); const race = await rejected(); const results = await Promise.all([ @@ -250,7 +237,7 @@ export async function observeReceiptReopening(options: { ); assert.equal((await request(race.id, "approve", race.etag)).status, 412); - for (const action of ["approve", "withdraw"]) { + for (const action of ["approve", "withdraw"] as const) { const receipt = await submit(); const closed = await request( @@ -260,9 +247,9 @@ export async function observeReceiptReopening(options: { action === "withdraw" ? cookie : approverCookie, ); - assert.equal(closed.status, 200); + const closedEtag = resourceOf(closed, `${action} of a pending receipt`).etag; const closedBefore = await snapshot(receipt.id); - assert.equal((await request(receipt.id, "reopen", closed.headers.get("etag")!)).status, 409); + assert.equal((await request(receipt.id, "reopen", closedEtag)).status, 409); assert.deepEqual(await snapshot(receipt.id), closedBefore); } @@ -326,7 +313,7 @@ export async function observeReceiptReopening(options: { const mutations: Array<{ path: string; status: number }> = []; try { - for (const cwd of [join(options.root, "packages/sdk"), dashboardRoot]) { + for (const cwd of [dashboardRoot]) { const build = spawn("bun", ["run", "build"], { cwd, env: environment, @@ -551,6 +538,7 @@ export async function observeReceiptReopening(options: { }; } finally { if (browser) await browser.close(); + await native.dispose(); for (const child of children) { if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) continue; diff --git a/tools/verification/unattended-delivery-recovery.ts b/tools/verification/unattended-delivery-recovery.ts index 7245f3ea..4fe6751b 100644 --- a/tools/verification/unattended-delivery-recovery.ts +++ b/tools/verification/unattended-delivery-recovery.ts @@ -8,6 +8,8 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Pool } from "pg"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { type DisposablePostgres, loopbackPortFree, @@ -286,26 +288,31 @@ const resetRequest = async () => { }; 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 response = await fetch(`${origin}/api/receipts?departmentId=${department}`, { - method: "POST", - headers: { cookie, origin: dashboardOrigin, "idempotency-key": randomUUID() }, - body: form, - }); + const native = nativeScriptClient(origin); + + try { + const result = await native.call({ cookie, origin: dashboardOrigin }, (client) => + client["receipts.submitReceipt"]({ + idempotencyKey: IdempotencyKey.make(randomUUID()), + departmentId: department, + request: { + description: "Disposable delivery recovery proof", + amountOre: 500, + receiptDate: new Date().toISOString().slice(0, 10), + file: { + contentType: "application/pdf", + bytes: new TextEncoder().encode("%PDF-1.4\nDisposable receipt\n%%EOF"), + }, + }, + }), + ); - assert.equal(response.status, 201, "real receipt submission accepted"); + assert.ok(result.ok, "real receipt submission accepted"); - return Schema.decodeUnknownSync(Schema.Struct({ receiptId: Schema.String }))( - await response.json(), - ).receiptId; + return result.value.receiptId; + } finally { + await native.dispose(); + } }; const business = async () => From ecb1d96f6b6b42c31ccba326db289562bd0e9d38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:11:43 +0000 Subject: [PATCH 041/148] test(receipts): port the receipt journey drivers and record the RPC migration of receipts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/reads.ts | 21 +- apps/backend/src/receipt/representation.ts | 28 +-- apps/backend/src/receipt/validate.ts | 10 +- .../e2e/golden-reimbursement-browser.mjs | 194 +++++++++------- .../run-real-native-receipt-settlement.mjs | 208 ++++++++++++------ .../e2e/run-real-receipt-approval.mjs | 136 ++++++++---- apps/dashboard/e2e/run-real-receipt-owner.mjs | 11 +- docs/specs/rpc-only.md | 96 ++++++-- packages/domain/src/receipt/README.md | 26 +-- tools/e2e/golden-reimbursement.mjs | 4 +- tools/e2e/legacy-candidate-native-journey.ts | 30 +-- 11 files changed, 492 insertions(+), 272 deletions(-) diff --git a/apps/backend/src/receipt/reads.ts b/apps/backend/src/receipt/reads.ts index c0c10b4f..18ae0d36 100644 --- a/apps/backend/src/receipt/reads.ts +++ b/apps/backend/src/receipt/reads.ts @@ -45,11 +45,7 @@ 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 { - receiptCredentialProblems, - receiptProblems, - storedReceiptProblems, -} from "./problem.js"; +import { receiptCredentialProblems, receiptProblems, storedReceiptProblems } from "./problem.js"; import { ownedReceiptResource, projected, @@ -153,12 +149,11 @@ export const listReceiptsForApproval = ({ ({ readReceiptApprovalCandidates }) => readReceiptApprovalCandidates(credential, resolved.authorizationInstant, status, cursor), ).pipe( - Effect.mapError( - () => - ReceiptPersistenceError.make({ - 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", + }), ), ); @@ -469,7 +464,9 @@ export const readReceiptLifecycleEvidence = ({ resolveSession(headers.cookie, authorizationInstant), ).pipe( Effect.catchTag("IdentitySessionNotFound", () => - Effect.fail(Problem.unauthenticated(personPresentation(headers, nativeCookieChallenge))), + Effect.fail( + Problem.unauthenticated(personPresentation(headers, nativeCookieChallenge)), + ), ), ); diff --git a/apps/backend/src/receipt/representation.ts b/apps/backend/src/receipt/representation.ts index 880a7c37..352b858b 100644 --- a/apps/backend/src/receipt/representation.ts +++ b/apps/backend/src/receipt/representation.ts @@ -137,19 +137,21 @@ export const receiptMutationCapsule = ({ 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 }, - })), + 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. */ diff --git a/apps/backend/src/receipt/validate.ts b/apps/backend/src/receipt/validate.ts index 94f9239c..78681b99 100644 --- a/apps/backend/src/receipt/validate.ts +++ b/apps/backend/src/receipt/validate.ts @@ -41,7 +41,11 @@ const validAmountOre = (amountOre: number) => Number.isSafeInteger(amountOre) && /** 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)], + [ + bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + : Uint8Array.from(bytes), + ], "receipt", { type: contentType }, ); @@ -132,4 +136,6 @@ export const validateSubmitDepartment = (departmentId: string | undefined) => * 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); + cursor === undefined + ? Effect.void + : decodeReceiptCursor(cursor).pipe(Effect.orDie, Effect.asVoid); diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index e712be97..bb68a5a9 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -5,8 +5,17 @@ import { writeFile } from "node:fs/promises"; import { chromium, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; import { Predicate } from "effect"; -import { nativeRpcPath, RECEIPT_FILE_MAX_BYTES, receiptTransferMaxBytes } from "@vektorprogrammet/rpc"; -import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.ts"; +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.mjs"; import { sha256 } from "../../../tools/e2e/golden-school-service-evidence.mjs"; @@ -43,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(); - return body; + 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 answer.body; }; const problem = async (response, status, code) => { @@ -119,15 +155,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", @@ -149,7 +200,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, ); @@ -163,22 +214,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()]); @@ -192,18 +242,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", ); @@ -213,19 +259,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"); @@ -263,7 +304,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, ); @@ -327,7 +368,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, ); @@ -392,35 +433,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({ @@ -433,9 +466,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 = []; @@ -444,13 +484,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/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index 5899a1a3..eb78c9af 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"; @@ -12,12 +12,13 @@ import { createRequire } from "node:module"; import AxeBuilder from "@axe-core/playwright"; 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, + nativeRpcOutcome, nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue, @@ -291,6 +292,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 = []; @@ -317,26 +379,41 @@ 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. + const rpc = url.pathname === nativeRpcPath ? 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; @@ -460,12 +537,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; } @@ -481,31 +554,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( @@ -969,33 +1032,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", ); @@ -1027,12 +1078,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"); @@ -1112,7 +1163,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) => ({ @@ -1140,7 +1190,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, @@ -1678,11 +1734,18 @@ async function main() { "stale settlement revision", ); + // A settlement answers its evidence alone; the settled receipt's tag is in the owner's list. + const settledEtag = receiptById( + (await listOwned()).items, + submittedReceipt.receiptId, + "owner after settlement", + ).etag; + const alreadySettledResponse = await requestSettlement( proxy.origin, settler.cookie, submittedReceipt.receiptId, - canonicalRecord.responseHeaders.etag, + settledEtag, randomUUID(), settlementAttemptPayload( "second-settlement-reference-0114", @@ -1782,15 +1845,15 @@ 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( @@ -1912,7 +1975,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), + ), ), }; @@ -1961,7 +2029,7 @@ async function main() { topology: { dashboard: "production-react-router-server", backend: "native-effect-http-api", - sdk: "generated-@vektorprogrammet/sdk", + client: "native-rpc-@vektorprogrammet/rpc", browser: "real-headless-chromium", database: "disposable-postgresql-local", delivery: "acknowledged-loopback-http", diff --git a/apps/dashboard/e2e/run-real-receipt-approval.mjs b/apps/dashboard/e2e/run-real-receipt-approval.mjs index 36847a0f..496f4a4a 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 { 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,48 @@ 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. + const rpc = url.pathname === "/api/rpc" ? 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 +473,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 +1210,6 @@ function assertJourneyEvidence(journeyEvidence, seedEvidence) { }, command: { inactiveActor: 403, - malformedJson: 400, - excessJson: 422, - queryParameters: 400, foreignDepartment: 403, absentDepartmentScope: 404, absentGlobalScope: 404, @@ -1288,17 +1353,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 +1414,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 +1440,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 +1449,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 850535f4..b21225cc 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/rpc"; +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, diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 32754cf9..3b6696d0 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -254,7 +254,16 @@ Notes (directory): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `receipts.readReceiptEvidence` | GET `/api/receipt-lifecycle-evidence-records/{receiptId}` | - | cookieHeader | todo | +| `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`) @@ -356,19 +365,78 @@ Notes (profile): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `receipts.approveReceipt` | POST `/api/receipts/{receiptId}:approve` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `receipts.listReceipts` | GET `/api/receipts` | query: cursor, status | cookieHeader, oauthUserBearer | todo | -| `receipts.listReceiptsForApproval` | GET `/api/receipt-approval-queue` | query: cursor, status | cookieHeader, oauthUserBearer, oauthServiceBearer | todo | -| `receipts.listReceiptsForSettlement` | GET `/api/receipt-settlement-queue` | query: cursor | cookieHeader, oauthUserBearer | todo | -| `receipts.readReceiptFile` | GET `/api/receipts/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | todo | -| `receipts.readReceiptFileForApproval` | GET `/api/receipt-approval-queue/{receiptId}/file` | binary application/octet-stream | cookieHeader, oauthUserBearer | todo | -| `receipts.readReceiptSettlementForFinance` | GET `/api/receipt-settlement-queue/{receiptId}` | - | cookieHeader, oauthUserBearer | todo | -| `receipts.rejectReceipt` | POST `/api/receipts/{receiptId}:reject` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `receipts.reopenReceipt` | POST `/api/receipts/{receiptId}:reopen` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `receipts.reviseReceipt` | PATCH `/api/receipts/{receiptId}` | idempotencyKey; ifMatch; multipart upload | cookieHeader, oauthUserBearer | todo | -| `receipts.settleReceipt` | POST `/api/receipts/{receiptId}:settle` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `receipts.submitReceipt` | POST `/api/receipts` | idempotencyKey; multipart upload; query: departmentId | cookieHeader, oauthUserBearer | todo | -| `receipts.withdrawReceipt` | POST `/api/receipts/{receiptId}:withdraw` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `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`) diff --git a/packages/domain/src/receipt/README.md b/packages/domain/src/receipt/README.md index de1e0b1a..673e273d 100644 --- a/packages/domain/src/receipt/README.md +++ b/packages/domain/src/receipt/README.md @@ -35,7 +35,7 @@ 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. +The [RPC contract](../../../rpc/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. @@ -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. diff --git a/tools/e2e/golden-reimbursement.mjs b/tools/e2e/golden-reimbursement.mjs index f37a884b..e7df8e01 100644 --- a/tools/e2e/golden-reimbursement.mjs +++ b/tools/e2e/golden-reimbursement.mjs @@ -373,8 +373,7 @@ try { "apps/dashboard", "packages/domain", "packages/database", - "packages/http-api", - "packages/sdk", + "packages/rpc", "tools/e2e", ]) .split("\0") @@ -524,7 +523,6 @@ try { 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); diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index 050eb332..38b7ffa9 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -130,12 +130,7 @@ export const observeLegacyCandidateNativeJourney = async ( AuthLive(config.auth).pipe(Layer.provide(database)), ); - const http = Layer.mergeAll( - platform, - BunHttpPlatform.layer, - Etag.layer, - HttpRouter.layer, - ); + const http = Layer.mergeAll(platform, BunHttpPlatform.layer, Etag.layer, HttpRouter.layer); const nativeApi = ExternalNativeRpcRouterLive(config, { now: () => input.asOf }).pipe( HttpRouter.provideRequest(Layer.merge(services, platform)), @@ -226,7 +221,7 @@ export const observeLegacyCandidateNativeJourney = async ( * 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 = async ( + const receiptRpc = ( name: string, tag: "receipts.listReceipts" | "receipts.readReceiptFile", payload: Schema.Json, @@ -240,19 +235,16 @@ export const observeLegacyCandidateNativeJourney = async ( if (authorization !== undefined) headers.set("authorization", authorization); - const response = await runtime.runPromise( - api( - new Request(backendOrigin + nativeRpcPath, { - method: "POST", - headers, - body: nativeRpcRequestBody(tag, payload), - }), - ), - ); - - const answer = await response.text(); + const request = new Request(backendOrigin + nativeRpcPath, { + method: "POST", + headers, + body: nativeRpcRequestBody(tag, payload), + }); - return { status: nativeRpcStatus(answer), value: nativeRpcValue(answer) }; + return runtime + .runPromise(api(request)) + .then((response) => response.text()) + .then((answer) => ({ status: nativeRpcStatus(answer), value: nativeRpcValue(answer) })); }; const ownProfile = async (name: string, cookie: string) => { From a132cce6041e723c703e533f63b80e7ef89d999d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:13:50 +0000 Subject: [PATCH 042/148] chore(journeys): recruitment and onboarding drivers and probes over RPC Journey recorders classify each RPC as the HTTP route it replaced, with its key, tag, cookie, and answer read from the RPC messages (native-rpc-ledger). Acceptance probes call the recruitment and onboarding RPCs and read them as the replaced routes' responses. Drivers no longer build the deleted SDK. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- ...native-recruitment-first-placement.spec.ts | 55 ++-- ...tive-recruitment-interview-conduct.spec.ts | 26 +- apps/dashboard/e2e/native-rpc-ledger.ts | 284 +++++++++++++++++ .../e2e/run-real-interview-response.mjs | 294 ++++++++---------- .../e2e/run-real-native-onboarding.mjs | 1 - ...l-native-recruitment-interview-conduct.mjs | 25 +- ...ative-recruitment-interview-scheduling.mjs | 29 +- .../co-interviewer-correction-0106.ts | 89 +++--- .../interview-correction-boundaries.ts | 57 ++-- .../interview-report-observation.ts | 161 +++++----- tools/acceptance/onboarding-check.ts | 44 ++- tools/acceptance/recommendation-check.ts | 79 +++-- .../acceptance/returning-assistant-journey.ts | 172 +++++----- tools/e2e/legacy-candidate-native-journey.ts | 2 +- ...run-real-native-recruitment-assignment.mjs | 40 ++- 15 files changed, 890 insertions(+), 468 deletions(-) create mode 100644 apps/dashboard/e2e/native-rpc-ledger.ts diff --git a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts index 914b72ed..9eb77769 100644 --- a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts @@ -3,6 +3,11 @@ 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 { DepartmentId } from "@vektorprogrammet/domain/organization"; +import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; +import { RecruitmentInterviewId } from "@vektorprogrammet/domain/recruitment"; +import { IdempotencyKey } from "@vektorprogrammet/rpc/problem"; +import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; const manifestPath = process.env.PLACEMENT_JOURNEY_MANIFEST; @@ -238,33 +243,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); diff --git a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts index 1213abd3..689bf583 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -1,4 +1,4 @@ -import { CancelInterviewResponse, ConductObservation } from "@vektorprogrammet/rpc"; +import { CancelInterviewResponse } from "@vektorprogrammet/rpc"; import { RecruitmentBridgeFailure } from "../app/foldkit/recruitment/bridge"; import { ApplicantProgressResponseSchema } from "@vektorprogrammet/rpc"; import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; @@ -433,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); diff --git a/apps/dashboard/e2e/native-rpc-ledger.ts b/apps/dashboard/e2e/native-rpc-ledger.ts new file mode 100644 index 00000000..3d4693ed --- /dev/null +++ b/apps/dashboard/e2e/native-rpc-ledger.ts @@ -0,0 +1,284 @@ +/** + * 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 { 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.Tuple([Schema.Struct({ error: Schema.Json })]), + }), + ]), + }), +]); + +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, + * and a failure answers its problem at the registry status of the problem's code. `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: [{ 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/run-real-interview-response.mjs b/apps/dashboard/e2e/run-real-interview-response.mjs index 1b3362c8..3d5ad240 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/rpc"; -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,12 @@ 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"; 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 +67,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 +620,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 = path === "/api/rpc" ? 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 +663,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 +708,7 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { const responseHeaders = [...upstream.headers.entries()] .flatMap(([name, value]) => - name !== invitationCapabilityHeader ? [`${name}:${value}`] : [], + [`${name}:${value}`], ) .join("\n"); @@ -711,6 +717,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 +793,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 +883,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 +925,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 +1450,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[0].commandPath, - status: 204, + status: 200, invitationActor: "accepted", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1509,7 +1482,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[1].commandPath, - status: 204, + status: 200, invitationActor: "rejected", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1565,7 +1538,7 @@ function assertNativeTransport(records) { { method: "POST", path: responseCases[2].commandPath, - status: 204, + status: 200, invitationActor: "requested-new-time", sessionCookieAuth: false, authorizationHeaderPresent: false, @@ -1693,13 +1666,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 +1680,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 +1984,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-onboarding.mjs b/apps/dashboard/e2e/run-real-native-onboarding.mjs index 683fef46..577cb8e4 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-recruitment-interview-conduct.mjs b/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs index c18e614d..9aa82ee3 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,5 @@ import { Predicate } from "effect"; +import { replacedAnswer, replacedRequest } from "./native-rpc-ledger.ts"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; @@ -250,6 +251,15 @@ const startProxy = async (targetOrigin) => { responseHasCapability: false, }; + const rpc = path === "/api/rpc" ? 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 +285,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 +399,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 cd9a25ad..5094cf2d 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,12 @@ 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"; 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 +475,20 @@ async function startRecordingProxy(targetOrigin) { status: 0, }; + const rpc = path === "/api/rpc" ? 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 +521,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 +982,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/tools/acceptance/co-interviewer-correction-0106.ts b/tools/acceptance/co-interviewer-correction-0106.ts index f8a1da31..0fbe9bce 100644 --- a/tools/acceptance/co-interviewer-correction-0106.ts +++ b/tools/acceptance/co-interviewer-correction-0106.ts @@ -14,6 +14,7 @@ import { import { assertInterviewCorrectionIntegrity } from "./interview-correction-integrity.ts"; import type { CoInterviewerCorrection0106Fixture } from "./recommendation-preupgrade-fixture.ts"; import { Predicate, Schema } from "effect"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; type CorrectionRequestHeaders = { @@ -152,12 +153,20 @@ 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 = async (cookie: string): Promise => { + const response = await readConduct(cookie); + const responseEtag = response.headers.get("etag"); assert.equal(response.status, 200, await response.clone().text()); assert.ok(responseEtag, "detail response must carry a strong ETag"); @@ -171,7 +180,7 @@ export async function runCoInterviewerCorrectionJourney( }; const getBoard = async (cookie: string): Promise => { - const response = await fetch(`${api}/api/recruitment/interviews`, { headers: headers(cookie) }); + const response = await recruitment("recruitment.readSchedulingBoard", null, cookie); assert.equal(response.status, 200, await response.clone().text()); const body: unknown = await response.json(); @@ -195,11 +204,11 @@ 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( @@ -456,10 +465,7 @@ export async function runCoInterviewerCorrectionJourney( ); const deniedBefore = await snapshot(); - const unassignedRead = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}`, - { headers: headers(unassignedCookie) }, - ); + const unassignedRead = await readConduct(unassignedCookie); status("unassigned:read", unassignedRead.status); assert.equal(unassignedRead.status, 403, await unassignedRead.text()); @@ -507,10 +513,7 @@ export async function runCoInterviewerCorrectionJourney( 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 = await readConduct(co.cookie); status("designation-changed:co-read", changedCo.status); assert.equal(changedCo.status, 403, await changedCo.text()); @@ -607,61 +610,53 @@ export async function runCoInterviewerCorrectionJourney( const authorityBefore = await snapshot(); - const finalization = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:finalize`, + const finalization = await recruitment( + "recruitment.finalizeInterview", { - method: "POST", - headers: { - ...headers(co.cookie, afterBrowserCorrection.etag, freshId("co-interviewer-finalize")), - "content-type": "application/json", - }, - body: JSON.stringify({ + 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, }), }, + co.cookie, ); status("co-interviewer:finalize", finalization.status); assert.equal(finalization.status, 403, await finalization.text()); - const cancellation = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:cancel`, + const cancellation = await recruitment( + "recruitment.cancelInterview", { - method: "POST", - headers: { - ...headers(co.cookie, afterBrowserCorrection.etag, freshId("co-interviewer-cancel")), - "content-type": "application/json", - }, - body: "{}", + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-cancel"), + ifMatch: afterBrowserCorrection.etag, }, + co.cookie, ); status("co-interviewer:cancel", cancellation.status); assert.equal(cancellation.status, 403, await cancellation.text()); - const schedule = await fetch( - `${api}/api/recruitment/interviews/${encodeURIComponent(fixture.targetInterviewId)}:schedule`, + const schedule = await recruitment( + "recruitment.scheduleInterview", { - method: "POST", - headers: { - ...headers( - co.cookie, - boardItem(await getBoard(co.cookie)).etag, - freshId("co-interviewer-schedule"), - ), - "content-type": "application/json", - }, - body: JSON.stringify({ + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-schedule"), + ifMatch: boardItem(await getBoard(co.cookie)).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.", - }), + }, }, + co.cookie, ); status("co-interviewer:schedule", schedule.status); diff --git a/tools/acceptance/interview-correction-boundaries.ts b/tools/acceptance/interview-correction-boundaries.ts index 43a6d279..4238b43b 100644 --- a/tools/acceptance/interview-correction-boundaries.ts +++ b/tools/acceptance/interview-correction-boundaries.ts @@ -1,5 +1,9 @@ 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 assert from "node:assert/strict"; @@ -175,8 +179,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,30 +195,26 @@ 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`, { + fetch(`${api}/api/rpc`, { method: "POST", - headers: { - ...headers(requestCookie, etag, key), - "content-type": "application/json", - }, + headers: { ...headers(requestCookie), "content-type": "application/json" }, body, - }); + }).then(replacedHttpResponse); const interviewIds = [interviewId, selfLinkRaceInterviewId]; const applicantIds = [identity.applicantId, raceIdentity.applicantId]; @@ -817,6 +821,12 @@ export async function assertInterviewCorrectionBoundaries( ["null-recommendation", { ...currentPayload, recommendation: null }], ]; + const semanticInvalidCases = new Set([ + "unknown-question-same-cardinality", + "duplicate-question-same-cardinality", + "missing-question-entry", + ]); + for (const [name, invalid] of invalidCases) { const before = await snapshot(); @@ -836,12 +846,19 @@ export async function assertInterviewCorrectionBoundaries( ); status(`invalid:${name}`, response.status); - assert.equal(response.status, name === "malformed-json" ? 400 : 422, await response.text()); + + // 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), and a body + // that is no RPC message is refused by the server's transport. + const expected = semanticInvalidCases.has(name) ? 422 : name === "malformed-json" ? -1 : 500; + + if (expected === -1) assert.ok(response.status >= 400, await response.text()); + else assert.equal(response.status, expected, await response.text()); await 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(); diff --git a/tools/acceptance/interview-report-observation.ts b/tools/acceptance/interview-report-observation.ts index 4f4146f4..75d7b2dd 100644 --- a/tools/acceptance/interview-report-observation.ts +++ b/tools/acceptance/interview-report-observation.ts @@ -12,6 +12,12 @@ import { InterviewReportRow, } from "../../packages/domain/src/recruitment/report.js"; import { Match, Predicate, Schema } from "effect"; +import { nativeRpcPath } from "../../packages/rpc/src/api.js"; +import { + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcValue, +} from "../../apps/dashboard/e2e/native-operations.js"; const ids = { person: "report-coordinator-0103", @@ -301,23 +307,46 @@ export async function observeInterviewReport(o: Options) { 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( + Schema.decodeUnknownSync(Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }))( + await 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) => { + const headers = new Headers({ origin: ui, "content-type": "application/json" }); + + if (session !== "") headers.set("cookie", session); + + return fetch(`${api}${nativeRpcPath}`, { + method: "POST", + headers, + body: nativeRpcRequestBody(tag, payload), + }).then((response) => + response.text().then((text) => ({ + status: nativeRpcStatus(text) ?? response.status, + value: nativeRpcValue(text), + text, + })), + ); + }; + + const get = (query: Record = {}, session = cookie) => + rpc("recruitment.readInterviewReport", query, session); + + /** The code of the problem that an RPC answered. */ + const problemCode = (value: Schema.Json | undefined) => + Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }))(value).code; const read = async (query: Record = {}): Promise => { const response = await get(query); if (response.status !== 200) { - const problem = await response.json().catch(() => ({})); - const authority = ( await 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`, @@ -330,15 +359,13 @@ export async function observeInterviewReport(o: Options) { 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 Schema.decodeUnknownSync(InterviewReport)(response.value, { onExcessProperty: "error", }); }; @@ -371,18 +398,12 @@ export async function observeInterviewReport(o: Options) { 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 = await read( + Schema.encodeSync(InterviewReportQuery)( + Schema.decodeSync(InterviewReportQuery)({ admissionPeriodId: ids.period }), + ), + ); const sqlRows = ( await pool.query( @@ -508,18 +529,26 @@ export async function observeInterviewReport(o: Options) { assert.equal((await get({}, "")).status, 401); assert.equal((await get({}, o.ordinaryCookie)).status, 403); - const assignedDetailResponse = await fetch( - `${api}/api/recruitment/interviews/interview-native-conduct-a-0063`, - { headers: { cookie: o.ordinaryCookie, origin: ui } }, - ); + const conduct = (session: string) => + rpc( + "recruitment.readInterviewConduct", + { interviewId: "interview-native-conduct-a-0063" }, + session, + ); + + const assignedDetailResponse = await conduct(o.ordinaryCookie); - assert.equal(assignedDetailResponse.status, 200, await assignedDetailResponse.clone().text()); + assert.equal(assignedDetailResponse.status, 200, assignedDetailResponse.text); + + const assignedResource = Schema.decodeUnknownSync( + Schema.Struct({ detail: Schema.Json, etag: Schema.String }), + )(assignedDetailResponse.value); const assignedDetail = Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - await assignedDetailResponse.json(), + assignedResource.detail, ); - const assignedETag = assignedDetailResponse.headers.get("etag"); + const assignedETag = assignedResource.etag; assert.ok(Predicate.isString(assignedETag)); assert.ok(Predicate.isNumber(assignedDetail.revision)); assert.ok(Array.isArray(assignedDetail.answers)); @@ -529,57 +558,42 @@ 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 = await 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 = await 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: Schema.encodeSync(Schema.Json)({ expectedRevision: assignedDetail.revision, answers: assignedDetail.answers, score: assignedDetail.score, recommendation: assignedDetail.recommendation, }), }, + cookie, ); - assert.equal( - coordinatorCorrectionResponse.status, - 403, - await coordinatorCorrectionResponse.text(), - ); + assert.equal(coordinatorCorrectionResponse.status, 403, coordinatorCorrectionResponse.text); assert.deepEqual(await 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((await 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((await 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.equal((await get({ [key]: value })).status, 500); assert.deepEqual(await 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", @@ -599,21 +613,18 @@ export async function observeInterviewReport(o: Options) { 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 denial = await get({ admissionPeriodId: ids.period }, cookie, { - "if-none-match": oldCondition, - }); + const denial = await 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.value); assert.ok(["authority.denied", "credential.invalid"].includes(code)); authorityDenials.push({ condition: setup, status, code }); assert.deepEqual(await snapshot(), baseline); @@ -648,7 +659,7 @@ export async function observeInterviewReport(o: Options) { authorityDenials.push({ condition: "ambiguous-department", status: ambiguousStatus, - code: (await ambiguousResponse.json()).code, + code: problemCode(ambiguousResponse.value), }); await pool.query( `DELETE FROM public.organization_memberships WHERE membership_id='report-ambiguous-membership'`, @@ -703,7 +714,9 @@ export async function observeInterviewReport(o: Options) { if (response.status === 200) assert.ok( - !(await response.json()).rows.some((r: any) => r.interviewId === "report-interview-race"), + !Schema.decodeUnknownSync(InterviewReport)(response.value).rows.some( + (r) => r.interviewId === "report-interview-race", + ), ); for (const filter of ["all", "Ja", "Nei", "Kanskje", "not-recorded"]) @@ -1055,7 +1068,7 @@ 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", }; diff --git a/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index 286a02e8..6fc4ce94 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -18,6 +18,7 @@ import { Predicate, Schema, Struct } from "effect"; 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"; const root = new URL("../../", import.meta.url).pathname; @@ -259,11 +260,44 @@ try { const requestBody: Pick = body === undefined ? {} : { body: JSON.stringify(body) }; - return fetch(backendOrigin + path, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, - }); + 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 fetch(backendOrigin + path, { + method: body === undefined ? "GET" : "POST", + headers: nativeHeaders, + ...requestBody, + }); + } + + const rpcHeaders = { origin: dashboardOrigin, cookie }; + + if (url.pathname === "/api/onboarding/claim") { + return replacedFetch({ + origin: backendOrigin, + tag: "onboarding.claim", + payload: body ?? null, + headers: rpcHeaders, + }); + } + + const scope = { departmentId: url.searchParams.get("departmentId") ?? "" }; + + return body === undefined + ? replacedFetch({ + origin: backendOrigin, + tag: "onboarding.readBoard", + payload: scope, + headers: rpcHeaders, + }) + : replacedFetch({ + origin: backendOrigin, + tag: "onboarding.command", + payload: { ...scope, idempotencyKey: key, ifMatch: etag ?? "", request: body }, + headers: rpcHeaders, + }); }; const expectStatus = async (response: Response, status: number) => { diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index 2d26dc00..a560156a 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -66,6 +66,7 @@ import { NotificationGateway } from "../../packages/domain/src/notification/serv import { Array as Arr, Predicate, Schema } from "effect"; 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 { FetchHttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -645,7 +646,6 @@ try { recordGate( "0039/0040 upgrades preserved original interview/schedule/invitation/conduct/lifecycle rows", ); - 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); @@ -1179,33 +1179,29 @@ try { throw new ReturningTargetedComplete({ returningStages, reportEvidence }); } + // Each call is the RPC that replaced the route, answered as that route's HTTP response. const get = (id: string) => - fetch(`${api}/api/recruitment/interviews/${id}`, { headers: { cookie, origin: ui } }); + replacedFetch({ + origin: api, + tag: "recruitment.readInterviewConduct", + payload: { interviewId: id }, + headers: { cookie, origin: ui }, + }); 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), + replacedFetch({ + origin: api, + tag: "recruitment.finalizeInterview", + payload: { interviewId: id, idempotencyKey: key, ifMatch: etag, request: body }, + headers: { cookie, origin: ui }, }); 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), + replacedFetch({ + origin: api, + tag: "recruitment.correctInterviewAssessment", + payload: { interviewId: id, idempotencyKey: key, ifMatch: etag, request: body }, + headers: { cookie, origin: ui }, }); const open = async (p: any, name: string) => { @@ -2354,8 +2350,12 @@ try { ); } - const applicantResponse = await fetch(`${api}/api/recruitment/invitation-response`, { - headers: { "x-recruitment-invitation-capability": invitationCapability, origin: ui }, + // The capability travels in the payload, with no session beside it. + const applicantResponse = await replacedFetch({ + origin: api, + tag: "recruitment.readInvitationResponse", + payload: { capability: invitationCapability }, + headers: { origin: ui }, }); assert.equal(applicantResponse.status, 200); @@ -2399,20 +2399,16 @@ try { 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: "{}", + const selfCancel = await replacedFetch({ + origin: api, + tag: "recruitment.cancelInterview", + payload: { + interviewId: "interview-recommendation-self", + idempotencyKey: fixtureKeys.selfCancel, + ifMatch: etag, }, - ); + headers: { cookie, origin: ui }, + }); assert.equal(selfCancel.status, 403); assert.deepEqual(await effectSnapshot(), effectsAfterInterviewCompletions); @@ -2428,10 +2424,11 @@ try { [onboardingToken], ); - 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 }), + const onboardingProjection = await replacedFetch({ + origin: api, + tag: "onboarding.claim", + payload: { mode: "ExistingAccount", token: onboardingToken }, + headers: { cookie, origin: ui }, }); assert.equal(onboardingProjection.status, 200); diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 3c7943d0..4894c756 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -32,6 +32,7 @@ import { import { DepartmentId } from "../../packages/domain/src/organization/schema.js"; import { Match, Predicate, Schema } from "effect"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; +import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; /** One RPC request on the JSON wire, as a browser sends it without the typed client. */ const WireRequest = Schema.TaggedStruct("Request", { @@ -181,6 +182,42 @@ 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, @@ -1804,16 +1841,15 @@ 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 = await assign(page, "returning-next-assignment-ambiguous-0104"); const ambiguousBodyText = await ambiguousAssignment.text(); trace.push({ @@ -1962,20 +1998,14 @@ export const runReturningAssistantBrowserJourney = async ({ 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 assignmentResponse = await assign(coordinatorPage, assignmentCommandId); assignmentStatus = assignmentResponse.status(); assignmentBodyText = await assignmentResponse.text(); assignmentETag = assignmentResponse.headers()["etag"] ?? ""; - if (assignmentStatus !== 201) { + // An RPC command answers 200; the 201 was an HTTP transport fact. + if (assignmentStatus !== 200) { const assignmentActorContext = await pool.query( `SELECT membership.person_id, @@ -2033,16 +2063,15 @@ 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: { + const scheduleResponse = await 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", @@ -2050,7 +2079,7 @@ export const runReturningAssistantBrowserJourney = async ({ message: "Vi ser frem til intervjuet.", }, }, - ); + }); const scheduleBodyText = await scheduleResponse.text(); @@ -2122,16 +2151,17 @@ export const runReturningAssistantBrowserJourney = async ({ 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 = await readInvitation(); const invitationPendingText = await invitationPendingResponse.text(); @@ -2155,30 +2185,23 @@ 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 = await replacedFetch({ + origin: api, + tag: "recruitment.confirmInvitation", + payload: { capability: invitationCapability, ifMatch: invitationETag }, + headers: { origin: ui }, + }); const invitationConfirmText = await 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 = await readInvitation(); assert.equal(invitationAcceptedResponse.status, 200); @@ -2200,11 +2223,16 @@ 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 = await readConduct(); assert.equal(conductResponse.status(), 200); const conductETag = conductResponse.headers()["etag"]; @@ -2236,17 +2264,19 @@ 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 = await 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", + }, }, }); @@ -2267,9 +2297,7 @@ 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 = await readConduct(); assert.equal(conductAfterResponse.status(), 200); diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index 9e9e228c..cd0eae70 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -136,7 +136,7 @@ export const observeLegacyCandidateNativeJourney = async ( HttpRouter.layer, ); - const nativeApi = ExternalNativeRpcRouterLive(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), diff --git a/tools/e2e/run-real-native-recruitment-assignment.mjs b/tools/e2e/run-real-native-recruitment-assignment.mjs index e127929c..58bc2671 100644 --- a/tools/e2e/run-real-native-recruitment-assignment.mjs +++ b/tools/e2e/run-real-native-recruitment-assignment.mjs @@ -12,12 +12,12 @@ import { databaseSchemaRevision, } from "@vektorprogrammet/database/migrations"; import { Predicate } from "effect"; +import { replacedAnswer, replacedRequest } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; 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, @@ -408,6 +408,25 @@ const startRecordingProxy = async (targetOrigin) => { responseEtag: null, }; + const rpc = path === "/api/rpc" ? 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.pathAndQuery = + rpc.tag === "recruitment.readAssignmentBoard" && Predicate.isString(rpc.payload.status) + ? `${rpc.path}?status=${rpc.payload.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); try { @@ -441,6 +460,17 @@ const startRecordingProxy = async (targetOrigin) => { if (upstream.status >= 400) record.responseFailure = parseJsonBody(responseBytes); record.responseJson = parseJsonBody(responseBytes) ?? null; record.responseEtag = upstream.headers.get("etag"); + + 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; + } + response.statusCode = upstream.status; for (const [name, value] of upstream.headers.entries()) { @@ -769,11 +799,6 @@ const main = async () => { 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. @@ -844,7 +869,8 @@ const main = async () => { const requiredTransportTail = [ ["GET", `${boardPath}?status=new`, 200], - ["POST", createPath, 201], + // 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], ]; From 285484c6775b0255c53501510d36e4719dcebff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:15:05 +0000 Subject: [PATCH 043/148] docs(rpc): record the recruitment and onboarding port and its lost HTTP capabilities Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/rpc-only.md | 112 ++++++++++++++++++++++++++++++++++------- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 32754cf9..35af7a19 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -260,9 +260,32 @@ Notes (directory): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `onboarding.claim` | POST `/api/onboarding/claim` | - | cookieHeader | todo | -| `onboarding.command` | POST `/api/onboarding` | idempotencyKey; ifMatch; query: departmentId | cookieHeader, oauthUserBearer | todo | -| `onboarding.readBoard` | GET `/api/onboarding` | query: departmentId | cookieHeader, oauthUserBearer | todo | +| `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`) @@ -374,22 +397,73 @@ Notes (profile): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `recruitment.cancelInterview` | POST `/api/recruitment/interviews/{interviewId}:cancel` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `recruitment.confirmInvitation` | POST `/api/recruitment/invitation-response:confirm` | ifMatch | invitationCapability | todo | -| `recruitment.correctInterviewAssessment` | POST `/api/recruitment/interviews/{interviewId}:correct` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `recruitment.createApplicationInterview` | POST `/api/recruitment/applications/{applicationId}/interviews` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `recruitment.finalizeInterview` | POST `/api/recruitment/interviews/{interviewId}:finalize` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `recruitment.maintainRecruitment` | POST `/api/recruitment/maintenance/commands` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `recruitment.readAssignmentBoard` | GET `/api/recruitment/application-assignments` | query: status | cookieHeader, oauthUserBearer | todo | -| `recruitment.readInterviewConduct` | GET `/api/recruitment/interviews/{interviewId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | -| `recruitment.readInterviewReport` | GET `/api/recruitment/interview-report` | query: admissionPeriodId, recommendation, participation, sort, direction | cookieHeader, oauthUserBearer | todo | -| `recruitment.readInterviewStaffing` | GET `/api/recruitment/interview-staffing` | - | cookieHeader, oauthUserBearer | todo | -| `recruitment.readInvitationResponse` | GET `/api/recruitment/invitation-response` | ifMatch; if-none-match (dropped) | invitationCapability | todo | -| `recruitment.readQuestionnaires` | GET `/api/recruitment/questionnaires` | - | cookieHeader, oauthUserBearer | todo | -| `recruitment.readSchedulingBoard` | GET `/api/recruitment/interviews` | - | cookieHeader, oauthUserBearer | todo | -| `recruitment.rejectInvitation` | POST `/api/recruitment/invitation-response:reject` | ifMatch | invitationCapability | todo | -| `recruitment.requestNewInvitationTime` | POST `/api/recruitment/invitation-response:request-new-time` | ifMatch | invitationCapability | todo | -| `recruitment.scheduleInterview` | POST `/api/recruitment/interviews/{interviewId}:schedule` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `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`) From 100a86539ee0f31448789fca07742efc162cd52b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:33:04 +0000 Subject: [PATCH 044/148] feat(rpc): serve team applications over RPC Port the seven team-application operations to TeamApplicationsRpcs with their AccessSpecs, in-transaction evidence, command receipts under the old routes, the public rate limit, and the outbox delivery unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/team-application/rpc.test.ts | 409 +++++++++++ apps/backend/src/team-application/rpc.ts | 651 +++++++++++++++++- packages/rpc/src/team-application.ts | 278 +++++++- 3 files changed, 1330 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/team-application/rpc.test.ts 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 00000000..ac48044a --- /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/rpc.ts b/apps/backend/src/team-application/rpc.ts index eaf8c365..a9b2bbe8 100644 --- a/apps/backend/src/team-application/rpc.ts +++ b/apps/backend/src/team-application/rpc.ts @@ -1,9 +1,650 @@ /** - * The TeamApplicationsRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/team-application/http.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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 { TeamApplicationsRpcs } from "@vektorprogrammet/rpc"; +import { Database } from "@vektorprogrammet/database"; +import { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import { + ResourceId, + ResourceKind, + Scope, + type CredentialOutcome, +} from "@vektorprogrammet/domain/authz"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { TeamId } from "@vektorprogrammet/domain/organization"; +import { + ReviseTeamApplicationIntakeCommand, + TeamApplicationAction, + TeamApplicationCommandId, + TeamApplications, + type TeamApplicationActor, + type TeamApplicationDeleteFailure, + type TeamApplicationId, + type TeamApplicationIntake, + type TeamApplicationPrincipal, + type TeamApplicationPublicReadFailure, + type TeamApplicationReadFailure, + type TeamApplicationReviseFailure, + type TeamApplicationSubmitFailure, +} from "@vektorprogrammet/domain/team-application"; +import { + DeleteTeamApplication, + ListTeamApplicationIntakes, + ListTeamApplications, + PublicTeamApplicationIntake, + ReadTeamApplication, + ReadTeamApplicationIntake, + reflectAccessSpec, + ReviseTeamApplicationIntake, + SubmitTeamApplication, + TeamApplicationConfirmation, + TeamApplicationIntakeListResponse, + TeamApplicationIntakeResource, + TeamApplicationListResponse, + TeamApplicationResource, + TeamApplicationsRpcs, + type TeamApplicationInput, + type TeamApplicationIntakeMergePatch, +} from "@vektorprogrammet/rpc"; +import { + type CredentialPresentation, + type IdempotencyKey, + makeNativeValidationError, + Problem, + 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 { + 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, + commandIdentity, + commandOutcome, + commandReceiptProblems, + personPresentation, + problemMapper, + requestInvalid, + requireCurrentETag, + strictOutput, + unreachable, +} from "../rpc/problem.js"; +import { publicRateLimitKey } from "../rpc/public-rate-limit.js"; +import { + executeNativeHttpCommandPostgres, + type NativeHttpCommandOutcome, + type NativeHttpResponseCapsule, +} from "../rpc/receipt-transaction.js"; -export const TeamApplicationsRpcHandlers = (_options: NativeRpcOptions) => - TeamApplicationsRpcs.toLayer({}); +type AcceptedCredential = Extract; + +interface StaffPrincipal { + readonly credential: AcceptedCredential; + readonly principal: TeamApplicationPrincipal; +} + +/** The one answer for every team-application domain failure. */ +const teamApplicationProblems = problemMapper< + | TeamApplicationPublicReadFailure + | TeamApplicationSubmitFailure + | TeamApplicationReadFailure + | TeamApplicationDeleteFailure + | TeamApplicationReviseFailure +>()({ + TeamApplicationTeamNotFound: () => Problem.make("resource.not-found"), + TeamApplicationNotFound: () => Problem.make("resource.not-found"), + TeamApplicationIntakeClosed: () => Problem.make("team-application.intake-closed"), + TeamApplicationAccessDenied: () => Problem.make("authority.denied"), + TeamApplicationCommandConflict: () => Problem.make("idempotency.digest-conflict"), + TeamApplicationInvalidCursor: () => Problem.make("request.malformed"), + TeamApplicationPersistenceError: (failure) => + failure.conflict ? Problem.make("transaction.conflict") : Problem.make("internal.error"), +}); + +/** A staff credential rejected inside the transaction is answered from the request's evidence. */ +const staffCredentialProblems = (presentation: CredentialPresentation) => + problemMapper()({ + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + IdentityEngineError: () => Problem.make("internal.error"), + }); + +const intakeETag = (teamId: TeamId, revision: number) => + deriveStrongETag({ + representationKind: "TeamApplicationIntake", + resourceIdentity: teamId, + version: revision, + }); + +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, + headers: { readonly etag: string; readonly location?: string }, +): NativeHttpResponseCapsule => ({ + status, + mediaType: "application/json", + headers: { ...headers, "content-type": "application/json" }, + 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 = (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 StaffPrincipal; + }); + +/** Evaluates the declared AccessSpec against the team the domain actor was resolved for. */ +const authorizeStaff = (input: { + readonly rpc: Pick; + readonly staff: StaffPrincipal; + readonly actor: TeamApplicationActor; + readonly selection: "ExactlyOne" | "AllMatching"; + readonly presentation: CredentialPresentation; +}) => + authorizePerson( + { + spec: Option.getOrThrow(reflectAccessSpec(input.rpc)), + credential: input.staff.credential, + personId: input.staff.principal.personId, + resolution: { + selection: input.selection, + contexts: [ + genericContext({ + domainId: "team-applications", + resourceKind: "organization-team", + resourceId: input.actor.teamId, + authorityVersion: `team-applications:${input.actor._tag}:${input.staff.principal.authorizationInstant}`, + }), + ], + }, + grantScopes: [ + Scope.Resource({ + resource: { + kind: ResourceKind.make("organization-team"), + id: ResourceId.make(input.actor.teamId), + }, + }), + ], + now: input.staff.principal.authorizationInstant, + }, + input.presentation, + ); + +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(Effect.catchTag("SqlError", () => Effect.fail(Problem.make("internal.error")))); + +const readIntake = (teamId: TeamId) => + Effect.gen(function* () { + const instant = yield* currentInstant(undefined); + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(ReadTeamApplicationIntake)), + { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "team-applications", + resourceKind: "organization-team", + resourceId: teamId, + authorityVersion: `team-application-intake:${instant}`, + }), + ], + }, + instant, + ); + + // A public read runs no serializable transaction, so it cannot lose a conflict. + const intake = yield* TeamApplications.use((service) => service.readPublicIntake(teamId)).pipe( + teamApplicationProblems, + unreachable("transaction.conflict"), + ); + + return yield* strictOutput(PublicTeamApplicationIntake)(intake); + }); + +const listIntakes = Effect.gen(function* () { + const instant = yield* currentInstant(undefined); + + 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"), + ); + + return yield* strictOutput(TeamApplicationIntakeListResponse)(intakes); +}); + +const submit = ( + headers: Headers.Headers, + payload: { + readonly teamId: TeamId; + readonly idempotencyKey: IdempotencyKey; + readonly request: TeamApplicationInput; + }, + options: NativeRpcOptions, +) => + Effect.gen(function* () { + const { teamId, idempotencyKey, request } = payload; + const config = options.config.teamApplication; + const arrivedAt = yield* currentInstant(undefined); + + // 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 operationId = "team-applications.submitTeamApplication"; + + // Domain failures are mapped after the executor, whose retry reads their causes. + const outcome = yield* executeNativeHttpCommandPostgres( + Effect.gen(function* () { + const instant = yield* currentInstant(undefined); + + yield* authorizeAnonymous( + Option.getOrThrow(reflectAccessSpec(SubmitTeamApplication)), + { + selection: "ExactlyOne", + contexts: [ + genericContext({ + domainId: "team-applications", + resourceKind: "organization-team", + resourceId: teamId, + authorityVersion: `team-application-create:${instant}`, + }), + ], + }, + instant, + ); + + // 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 }), + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({ body: request }), + operationId, + }, + execute: TeamApplications.use((service) => + service.submit({ + commandId: TeamApplicationCommandId.make(identity.commandId), + teamId, + application: request, + }), + ).pipe( + Effect.flatMap(({ confirmation }) => + strictOutput(TeamApplicationConfirmation)(confirmation), + ), + Effect.map((confirmation) => + jsonCapsule(201, confirmation, { + etag: deriveStrongETag({ + representationKind: "TeamApplicationConfirmation", + resourceIdentity: confirmation.applicationId, + version: 0, + }), + location: `/api/team-applications/${encodePathIdentity(confirmation.applicationId)}`, + }), + ), + ), + }; + }), + { retry: "serialization-once" }, + ).pipe(teamApplicationProblems, commandReceiptProblems); + + return yield* commandOutcome(TeamApplicationConfirmation)(outcome); + }).pipe( + // A submission reads no page. + unreachable("request.malformed"), + ); + +const listApplications = ( + headers: Headers.Headers, + payload: { readonly teamId: TeamId; readonly cursor?: string | undefined }, +) => { + const presentation = personPresentation(headers); + + return snapshotRead( + Effect.gen(function* () { + const staff = yield* staffPrincipal(headers); + + // A read-only snapshot cannot lose a serialization conflict. + const page = yield* TeamApplications.use((service) => + service.listApplications(staff.principal, payload.teamId, payload.cursor), + ).pipe(teamApplicationProblems, unreachable("transaction.conflict")); + + yield* authorizeStaff({ + rpc: ListTeamApplications, + staff, + actor: page.actor, + selection: "AllMatching", + presentation, + }); + + const body = { + teamId: page.teamId, + teamName: page.teamName, + items: page.items, + intake: intakeResource(page.teamId, page.intake), + canManage: Predicate.isTagged(page.actor, "TeamLeader"), + }; + + return yield* strictOutput(TeamApplicationListResponse)( + page.nextCursor === undefined ? body : { ...body, nextCursor: page.nextCursor }, + ); + }), + ).pipe(staffCredentialProblems(presentation)); +}; + +const readApplication = (headers: Headers.Headers, applicationId: TeamApplicationId) => { + const presentation = personPresentation(headers); + + return snapshotRead( + Effect.gen(function* () { + const staff = yield* staffPrincipal(headers); + + // 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", "request.malformed")); + + yield* authorizeStaff({ + rpc: ReadTeamApplication, + staff, + actor: view.actor, + selection: "ExactlyOne", + presentation, + }); + + return yield* strictOutput(TeamApplicationResource)({ + ...view.application, + teamName: view.teamName, + canManage: Predicate.isTagged(view.actor, "TeamLeader"), + }); + }), + ).pipe(staffCredentialProblems(presentation)); +}; + +const deleteApplication = ( + headers: Headers.Headers, + payload: { readonly applicationId: TeamApplicationId; readonly idempotencyKey: IdempotencyKey }, +) => + Effect.gen(function* () { + 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(headers); + + // Authority is current before any stored response can be replayed. + const authorization = yield* TeamApplications.use((service) => + service.authorize( + staff.principal, + TeamApplicationAction.DeleteTeamApplication({ applicationId }), + ), + ); + + yield* authorizeStaff({ + rpc: DeleteTeamApplication, + staff, + actor: authorization.actor, + selection: "ExactlyOne", + presentation, + }); + + // 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}", { + applicationId, + }), + idempotencyKey, + }); + + return { + identity: { + identitySha256: identity.identitySha256, + requestSha256: semanticRequestDigest({}), + operationId, + }, + execute: TeamApplications.use((service) => + service.deleteApplication(authorization, { + commandId: TeamApplicationCommandId.make(identity.commandId), + }), + ).pipe(Effect.as(noContent)), + }; + }), + { retry: "serialization-once" }, + ).pipe(teamApplicationProblems, commandReceiptProblems, staffCredentialProblems(presentation)); + + return yield* noContentOutcome(outcome); + }).pipe( + // A deletion reads no page. + unreachable("request.malformed"), + ); + +/** 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")]), + ); + } + + if (patch.acceptApplication === null) { + return Effect.fail( + Problem.validation("validation.field-not-deletable", [ + makeNativeValidationError("/acceptApplication", "field-not-deletable"), + ]), + ); + } + + return Effect.void; +}; + +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; + + yield* requireApplicablePatch(patch); + + 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(headers); + + // Authority is current before any stored response can be replayed. + const authorization = yield* TeamApplications.use((service) => + service.authorize( + staff.principal, + TeamApplicationAction.ReviseTeamApplicationIntake({ teamId }), + ), + ); + + yield* authorizeStaff({ + rpc: ReviseTeamApplicationIntake, + staff, + actor: authorization.actor, + selection: "ExactlyOne", + presentation, + }); + + // 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", { + teamId, + }), + idempotencyKey, + }); + + const command = yield* Schema.decodeUnknownEffect(ReviseTeamApplicationIntakeCommand)( + { ...patch, commandId: identity.commandId, teamId }, + { onExcessProperty: "error" }, + ).pipe(Effect.mapError(requestInvalid)); + + return { + identity: { + identitySha256: identity.identitySha256, + // 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(authorization, command, (current) => + requireCurrentETag(intakeETag(teamId, current.revision), ifMatch), + ), + ).pipe( + Effect.map(({ intake }) => { + const resource = intakeResource(teamId, intake); + + return jsonCapsule(200, resource, { etag: resource.etag }); + }), + ), + }; + }), + { retry: "serialization-once" }, + ).pipe( + teamApplicationProblems, + commandReceiptProblems, + staffCredentialProblems(presentation), + // 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* commandOutcome(TeamApplicationIntakeResource)(outcome); + }); + +/** 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/packages/rpc/src/team-application.ts b/packages/rpc/src/team-application.ts index f1de39ce..562bb0b2 100644 --- a/packages/rpc/src/team-application.ts +++ b/packages/rpc/src/team-application.ts @@ -1,11 +1,283 @@ /** - * Team applications: applications to one team and its intake. + * 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 { RpcGroup } from "effect/unstable/rpc"; +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 class TeamApplicationsRpcs extends RpcGroup.make() {} +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, +) {} From fdba786619693d227486443688a9df15c95db143 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:33:44 +0000 Subject: [PATCH 045/148] feat(content): serve the content operations as RPCs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/content/access.ts | 148 ++++++++ apps/backend/src/content/commands.ts | 527 +++++++++++++++++++++++++++ apps/backend/src/content/context.ts | 84 +++++ apps/backend/src/content/problem.ts | 108 ++++++ apps/backend/src/content/reads.ts | 213 +++++++++++ apps/backend/src/content/rpc.test.ts | 370 +++++++++++++++++++ apps/backend/src/content/rpc.ts | 26 +- packages/rpc/src/content.ts | 325 ++++++++++++++++- 8 files changed, 1792 insertions(+), 9 deletions(-) create mode 100644 apps/backend/src/content/access.ts create mode 100644 apps/backend/src/content/commands.ts create mode 100644 apps/backend/src/content/context.ts create mode 100644 apps/backend/src/content/problem.ts create mode 100644 apps/backend/src/content/reads.ts create mode 100644 apps/backend/src/content/rpc.test.ts diff --git a/apps/backend/src/content/access.ts b/apps/backend/src/content/access.ts new file mode 100644 index 00000000..430317ed --- /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 00000000..ecee977a --- /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 00000000..620156df --- /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/problem.ts b/apps/backend/src/content/problem.ts new file mode 100644 index 00000000..d0ff756d --- /dev/null +++ b/apps/backend/src/content/problem.ts @@ -0,0 +1,108 @@ +/** Content failures and the problem each one answers. */ +import type { UnauthenticatedActor } from "@vektorprogrammet/domain/admission-period"; +import type { + ArticleNotFound, + ContentManagementFailure, + ContentUnauthenticatedActor, +} from "@vektorprogrammet/domain/content"; +import type { IdentityEngineError } from "@vektorprogrammet/domain/identity"; +import type { + OrganizationDecodeError, + OrganizationPersistenceError, +} from "@vektorprogrammet/domain/organization"; +import { type CredentialPresentation, Problem } from "@vektorprogrammet/rpc/problem"; +import { + type CredentialCases, + type ProblemCases, + type ProblemMapper, + problemMapper, +} from "../rpc/problem.js"; + +/** Every content failure that a content handler answers, but a rejected credential. */ +type ContentFailure = + | Exclude + | ArticleNotFound; + +const contentCases = { + AuthorityInactive: () => Problem.make("authority.denied"), + NotInScope: () => Problem.make("authority.denied"), + NotPublisher: () => Problem.make("authority.denied"), + DraftNotOwned: () => Problem.make("authority.denied"), + ArticleNotFound: () => Problem.make("content.article-not-found"), + SlugConflict: () => Problem.make("content.slug-conflict"), + DepartmentNotFound: () => Problem.make("content.department-not-found"), + CommandConflict: () => Problem.make("content.lifecycle-conflict"), + ContentIntegrityError: () => Problem.make("content.integrity-error"), + ContentPersistenceError: () => Problem.make("content.unavailable"), + ContentDecodeError: () => Problem.make("internal.error"), +} satisfies ProblemCases; + +/** + * The one answer for every content domain failure. + * + * @remarks + * An inactive authority, a department outside the actor's scope, a missing publisher role, and + * another author's draft answer authority.denied. The article, slug, department, and lifecycle + * failures answer their content problems, an unavailable store answers content.unavailable, and a + * stored value that does not decode answers internal.error. + * + * @sideEffects none + * + * @example + * ```ts + * read.pipe(contentProblems, contentActorProblems(presentation)); + * ``` + * + * @avoid Mapping a content failure in a handler: the content RPCs then answer one failure + * differently. Pipe the handler's effect through this. + * + * @construct rpc-problem + */ +export const contentProblems: ProblemMapper = + problemMapper()(contentCases); + +/** A staff person whom the credential middleware admitted, rejected by the actor resolution. */ +type ContentActorFailure = + | UnauthenticatedActor + | ContentUnauthenticatedActor + | IdentityEngineError + | OrganizationDecodeError + | OrganizationPersistenceError; + +const contentActorFixedCases = { + IdentityEngineError: () => Problem.make("internal.error"), + OrganizationDecodeError: () => Problem.make("internal.error"), + OrganizationPersistenceError: () => Problem.make("internal.error"), +} satisfies ProblemCases>; + +/** The cases of `contentActorProblems`. */ +type ContentActorCases = typeof contentActorFixedCases & CredentialCases<"UnauthenticatedActor">; + +/** + * 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 + * credential.invalid, as the request's evidence says. An unavailable identity or organization + * projection answers internal.error. + * + * @sideEffects none + * + * @example + * ```ts + * read.pipe(contentProblems, contentActorProblems(presentation)); + * ``` + * + * @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 rpc-problem + */ +export const contentActorProblems = ( + presentation: CredentialPresentation, +): ProblemMapper => + problemMapper()({ + ...contentActorFixedCases, + UnauthenticatedActor: () => Problem.unauthenticated(presentation), + }); diff --git a/apps/backend/src/content/reads.ts b/apps/backend/src/content/reads.ts new file mode 100644 index 00000000..e6bfc8bf --- /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 00000000..e7e4f208 --- /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 index cc5066fd..754a13a8 100644 --- a/apps/backend/src/content/rpc.ts +++ b/apps/backend/src/content/rpc.ts @@ -1,8 +1,28 @@ /** - * The ContentRpcs handlers. The operations to port, and the HTTP handlers they replace - * (`apps/backend/src/content/http*.ts` at the base commit), are listed in docs/specs/rpc-only.md. + * 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"; -export const ContentRpcHandlers = (_options: NativeRpcOptions) => ContentRpcs.toLayer({}); +/** 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/packages/rpc/src/content.ts b/packages/rpc/src/content.ts index 25a5b1af..7fb9526c 100644 --- a/packages/rpc/src/content.ts +++ b/packages/rpc/src/content.ts @@ -1,11 +1,324 @@ /** - * Content: articles and news. - * - * Its operations are listed in docs/specs/rpc-only.md; the HTTP contract they replace is - * `packages/http-api/src/content.ts` at the base commit named there. + * Content: staff article drafts and publication, and the public news they publish. * * @since 0.3.0 */ -import { RpcGroup } from "effect/unstable/rpc"; +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() {} +export class ContentRpcs extends RpcGroup.make( + ReadContentWorkspace, + CreateArticle, + ReadArticle, + ReviseArticle, + PublishArticle, + UnpublishArticle, + ListNews, + ReadNewsArticle, +) {} From f36cd93f59b91b87495e164bacfd63a565ead17c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:37:48 +0000 Subject: [PATCH 046/148] feat(rpc): call team applications over RPC from the dashboard and homepage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../team-applications/browser-client.ts | 67 ++++--- .../foldkit/team-applications/update.test.ts | 107 +++++------ apps/homepage/src/lib/api-types.ts | 19 +- .../src/lib/public-team-application.ts | 58 ++---- .../src/routes/_home.team_.$teamId.soknad.tsx | 44 +++-- .../test/public-team-application.test.ts | 173 +++++++----------- 6 files changed, 196 insertions(+), 272 deletions(-) diff --git a/apps/dashboard/app/foldkit/team-applications/browser-client.ts b/apps/dashboard/app/foldkit/team-applications/browser-client.ts index 0ca37ff7..0bf4445c 100644 --- a/apps/dashboard/app/foldkit/team-applications/browser-client.ts +++ b/apps/dashboard/app/foldkit/team-applications/browser-client.ts @@ -1,5 +1,6 @@ import type { IdempotencyKey, + NativeRpcClient, StrongETag, TeamApplicationId, TeamApplicationIntakeMergePatch, @@ -7,13 +8,12 @@ import type { TeamApplicationListResponse, TeamApplicationResource, } from "@vektorprogrammet/rpc"; -import { createEffectClient, type EffectSdk } from "@vektorprogrammet/sdk/effect"; 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/update.test.ts b/apps/dashboard/app/foldkit/team-applications/update.test.ts index a3c5e068..65bfe9cb 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/rpc"; -import { createEffectClient, type FetchCapability } from "@vektorprogrammet/sdk/effect"; +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/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index e709e02b..6ab9b290 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -1,16 +1,19 @@ import { ListNewsEndpoint, - ListTeamApplicationIntakesEndpoint, ReadNewsArticleEndpoint, - ReadTeamApplicationIntakeEndpoint, SubmitContactMessageEndpoint, - SubmitTeamApplicationEndpoint, } from "@vektorprogrammet/rpc"; import type { DepartmentJson, PublicApplicationCatalogSchema, TeamJson, } from "@vektorprogrammet/rpc"; +import type { + PublicTeamApplicationIntake, + TeamApplicationConfirmation, + TeamApplicationInput, + TeamApplicationIntakeListItem, +} from "@vektorprogrammet/rpc"; import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; type EndpointResponseBody = @@ -43,12 +46,10 @@ export type NewsArticleSlug = HttpApiEndpoint.Params[number]; +export type HomepageTeamIntake = TeamApplicationIntakeListItem; -export type HomepageTeamApplicationIntake = EndpointBody; +export type HomepageTeamApplicationIntake = PublicTeamApplicationIntake; -export type TeamApplicationPayload = HttpApiEndpoint.Payload< - typeof SubmitTeamApplicationEndpoint ->["Type"]; +export type TeamApplicationPayload = TeamApplicationInput; -export type SubmittedTeamApplication = EndpointBody; +export type SubmittedTeamApplication = TeamApplicationConfirmation; diff --git a/apps/homepage/src/lib/public-team-application.ts b/apps/homepage/src/lib/public-team-application.ts index 77c94300..d545ffe0 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, @@ -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/routes/_home.team_.$teamId.soknad.tsx b/apps/homepage/src/routes/_home.team_.$teamId.soknad.tsx index 9a085365..985384ec 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/public-team-application.test.ts b/apps/homepage/test/public-team-application.test.ts index 124eaebc..44777c66 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/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}`); From 964951e78856587ff62afb3be4a4f3978c2bc327 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:39:57 +0000 Subject: [PATCH 047/148] feat(content): call the content RPCs from the dashboard and homepage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../app/foldkit/content/bridge-route.test.ts | 228 ++++++++++++++---- .../app/foldkit/content/command.test.ts | 2 +- apps/dashboard/app/foldkit/content/message.ts | 2 +- apps/dashboard/app/foldkit/content/model.ts | 2 +- .../app/foldkit/content/update.test.ts | 2 +- apps/dashboard/app/foldkit/content/update.ts | 2 +- .../app/foldkit/content/view.test.ts | 2 +- apps/dashboard/app/foldkit/content/view.ts | 2 +- .../dashboard/app/routes/__foldkit.content.ts | 126 +++++----- apps/homepage/src/lib/api-types.ts | 13 +- apps/homepage/src/lib/news.server.ts | 39 +-- apps/homepage/test/news.test.ts | 46 ++-- 12 files changed, 288 insertions(+), 178 deletions(-) diff --git a/apps/dashboard/app/foldkit/content/bridge-route.test.ts b/apps/dashboard/app/foldkit/content/bridge-route.test.ts index c898e46c..20de8954 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/rpc"; +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/command.test.ts b/apps/dashboard/app/foldkit/content/command.test.ts index 60ba41ba..e1e04355 100644 --- a/apps/dashboard/app/foldkit/content/command.test.ts +++ b/apps/dashboard/app/foldkit/content/command.test.ts @@ -1,7 +1,7 @@ import { ContentFailure } from "./model"; import { FailedCommand } from "./message"; import { ArticleId } from "@vektorprogrammet/rpc"; -import { DepartmentId } 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"; diff --git a/apps/dashboard/app/foldkit/content/message.ts b/apps/dashboard/app/foldkit/content/message.ts index 45f349d4..fc0094cc 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/rpc" -import { DepartmentId } 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 e3c37519..3da4dfba 100644 --- a/apps/dashboard/app/foldkit/content/model.ts +++ b/apps/dashboard/app/foldkit/content/model.ts @@ -1,5 +1,5 @@ import { ArticleId, ContentWorkspaceSchema, type ContentWorkspace } from "@vektorprogrammet/rpc" -import { DepartmentId } 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 c8e7da93..c92596e1 100644 --- a/apps/dashboard/app/foldkit/content/update.test.ts +++ b/apps/dashboard/app/foldkit/content/update.test.ts @@ -1,5 +1,5 @@ import { ArticleId, ArticleSlug, type ContentWorkspace } from "@vektorprogrammet/rpc" -import { DepartmentId } 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"; diff --git a/apps/dashboard/app/foldkit/content/update.ts b/apps/dashboard/app/foldkit/content/update.ts index 58711091..3b41c3cd 100644 --- a/apps/dashboard/app/foldkit/content/update.ts +++ b/apps/dashboard/app/foldkit/content/update.ts @@ -1,6 +1,6 @@ import { Predicate } from "effect"; import type { ArticleId, ContentWorkspace } from "@vektorprogrammet/rpc" -import type { DepartmentId } 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"; diff --git a/apps/dashboard/app/foldkit/content/view.test.ts b/apps/dashboard/app/foldkit/content/view.test.ts index ed3bf350..1cc0893d 100644 --- a/apps/dashboard/app/foldkit/content/view.test.ts +++ b/apps/dashboard/app/foldkit/content/view.test.ts @@ -10,7 +10,7 @@ import { updateFor } from "./update"; const config = {update: updateFor(commandsFor(createBrowserContentWorkspaceClient("/content"))), view}; import { ArticleId, type ContentWorkspace } from "@vektorprogrammet/rpc" -import { DepartmentId } 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"; diff --git a/apps/dashboard/app/foldkit/content/view.ts b/apps/dashboard/app/foldkit/content/view.ts index 7a3b5b24..e8141109 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/rpc" +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/routes/__foldkit.content.ts b/apps/dashboard/app/routes/__foldkit.content.ts index 759933d9..bf253e0d 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/rpc"; 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/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index e709e02b..4d049910 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -1,7 +1,5 @@ import { - ListNewsEndpoint, ListTeamApplicationIntakesEndpoint, - ReadNewsArticleEndpoint, ReadTeamApplicationIntakeEndpoint, SubmitContactMessageEndpoint, SubmitTeamApplicationEndpoint, @@ -11,6 +9,11 @@ import type { PublicApplicationCatalogSchema, TeamJson, } from "@vektorprogrammet/rpc"; +import type { + NewsArticleQuery, + PublishedNewsArticle as PublishedNewsArticleType, + PublishedNewsListing as PublishedNewsListingType, +} from "@vektorprogrammet/rpc"; import type { HttpApiEndpoint, HttpApiSchema } from "effect/unstable/httpapi"; type EndpointResponseBody = @@ -23,11 +26,11 @@ type EndpointBody = Exclude< export type HomepageDepartment = DepartmentJson; -export type PublishedNewsListing = EndpointBody; +export type PublishedNewsListing = PublishedNewsListingType; export type PublishedNewsSummary = PublishedNewsListing["articles"][number]; -export type PublishedNewsArticle = EndpointBody; +export type PublishedNewsArticle = PublishedNewsArticleType; export type PublicApplicationCatalog = typeof PublicApplicationCatalogSchema.Type; @@ -39,7 +42,7 @@ export type ContactMessageHeaders = HttpApiEndpoint.Headers< typeof SubmitContactMessageEndpoint >["Type"]; -export type NewsArticleSlug = HttpApiEndpoint.Params["Type"]["slug"]; +export type NewsArticleSlug = NewsArticleQuery["slug"]; export type HomepageTeam = TeamJson; diff --git a/apps/homepage/src/lib/news.server.ts b/apps/homepage/src/lib/news.server.ts index f1471de7..6ffbc469 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/rpc"; -import type { PublishedNewsArticle, PublishedNewsListing } from "./api-types"; -import { callHomepageNative, createHomepageApiClient } from "./api.server"; +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,17 +24,8 @@ 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(); } @@ -70,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)) { @@ -78,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: {} }), - ]); - - if (articleResult.body === undefined || listingResult.body === undefined) { - throw new Error("The conditional news response has no body."); - } + const articleSlug = ArticleSlug.make(slug); + const query = version === undefined ? { slug: articleSlug } : { slug: articleSlug, version }; - 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/test/news.test.ts b/apps/homepage/test/news.test.ts index fdfd5641..e2989417 100644 --- a/apps/homepage/test/news.test.ts +++ b/apps/homepage/test/news.test.ts @@ -9,47 +9,52 @@ type NewsApiFixture = { }; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ArticleSlug, DepartmentJsonSchema, makeNativeProblem } from "@vektorprogrammet/rpc"; +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; }); From 75d046f3b579cf75c30a1d4f9fb82a99949a0217 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:42:27 +0000 Subject: [PATCH 048/148] feat(rpc): move the golden team-application journey to the RPCs The browser journey posts each probe as an RPC and reads it as the status of the route it replaced. The spec records the team-application port and what the RPCs no longer carry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/golden-team-application-browser.ts | 232 ++++++++++-------- docs/specs/rpc-only.md | 57 ++++- tools/e2e/golden-team-application.ts | 59 ++--- 3 files changed, 206 insertions(+), 142 deletions(-) diff --git a/apps/dashboard/e2e/golden-team-application-browser.ts b/apps/dashboard/e2e/golden-team-application-browser.ts index 4cd450ef..088b71f7 100644 --- a/apps/dashboard/e2e/golden-team-application-browser.ts +++ b/apps/dashboard/e2e/golden-team-application-browser.ts @@ -15,8 +15,14 @@ import { setTimeout as sleep } from "node:timers/promises"; import AxeBuilder from "@axe-core/playwright"; import { chromium, expect as baseExpect, type BrowserContext, type Page } from "@playwright/test"; import { nativeRpcPath, OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; -import { Schema } from "effect"; -import { nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue } from "./native-operations.js"; +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 }); @@ -196,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; @@ -306,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(); @@ -389,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 () => { @@ -424,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, @@ -541,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 { @@ -628,7 +672,7 @@ export const runTeamApplicationBrowser = async ( const direct = await body( await submitThroughApi(fixture.alfa.teamId, first.submission.key, fixture.first), - 201, + 200, Confirmation, ); @@ -663,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(); @@ -739,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, ); @@ -769,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(); @@ -803,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" }); @@ -812,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, @@ -835,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" }); @@ -852,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"], ); @@ -887,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. @@ -909,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"); @@ -997,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/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 7f8dab0c..362c8cda 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -630,10 +630,53 @@ Notes (system): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `team-applications.deleteTeamApplication` | DELETE `/api/team-applications/{applicationId}` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `team-applications.listTeamApplicationIntakes` | GET `/api/team-application-intakes` | - | none | todo | -| `team-applications.listTeamApplications` | GET `/api/teams/{teamId}/applications` | query: cursor | cookieHeader, oauthUserBearer | todo | -| `team-applications.readTeamApplication` | GET `/api/team-applications/{applicationId}` | - | cookieHeader, oauthUserBearer | todo | -| `team-applications.readTeamApplicationIntake` | GET `/api/teams/{teamId}/application-intake` | - | none | todo | -| `team-applications.reviseTeamApplicationIntake` | PATCH `/api/teams/{teamId}/application-intake` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | -| `team-applications.submitTeamApplication` | POST `/api/teams/{teamId}/applications` | idempotencyKey | none | todo | +| `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/tools/e2e/golden-team-application.ts b/tools/e2e/golden-team-application.ts index a62e36cd..817d7c2b 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"))); From 216274cc861031f8fd400aa0c831540d25d36a7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:46:34 +0000 Subject: [PATCH 049/148] feat(content): port the content publication journey to RPC and record the slice Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/native-content-publication.spec.ts | 222 +++++++++------- .../run-real-native-content-publication.mjs | 244 +++++++++++++----- docs/specs/rpc-only.md | 69 ++++- 3 files changed, 370 insertions(+), 165 deletions(-) diff --git a/apps/dashboard/e2e/native-content-publication.spec.ts b/apps/dashboard/e2e/native-content-publication.spec.ts index 2a677722..a206c7cb 100644 --- a/apps/dashboard/e2e/native-content-publication.spec.ts +++ b/apps/dashboard/e2e/native-content-publication.spec.ts @@ -1,9 +1,16 @@ import AxeBuilder from "@axe-core/playwright"; 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/rpc"; +import { Option, Predicate, Schema } from "effect"; +import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { ContentArticleObservationSchema, ContentBridgeActionSchema } from "../app/foldkit/content/bridge"; +import { + nativeRpcOutcome, + nativeRpcRequestBody, + nativeRpcStatus, + nativeRpcTag, + nativeRpcValue, +} from "./native-operations.js"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; @@ -46,11 +53,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 +125,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" && url.pathname === nativeRpcPath + ? { 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, }); }); @@ -286,55 +346,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,38 +451,26 @@ 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", }); @@ -480,13 +502,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", @@ -527,8 +552,8 @@ test.describe("Native Content publication (spec 0062)", () => { (request) => request.pathname === "/dashboard/content", ); - const nativeContentRequests = browserRequests.filter((request) => - request.pathname.startsWith("/api/content/articles"), + const nativeContentRequests = browserRequests.filter( + (request) => request.pathname === nativeRpcPath && request.rpcTag?.startsWith("content.") === true, ); const publicRequests = browserRequests.filter( @@ -536,23 +561,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", }), ]); diff --git a/apps/dashboard/e2e/run-real-native-content-publication.mjs b/apps/dashboard/e2e/run-real-native-content-publication.mjs index 4fac147c..80e5ee5a 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"; @@ -16,8 +16,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 +172,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 +315,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 = url.pathname === "/api/rpc" ? 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 +366,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 +530,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 +594,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 +616,24 @@ try { ); } - const staffMutations = staffRequests.filter((entry) => ["PATCH", "POST"].includes(entry.method)); + assert.deepEqual( + ledger.filter( + (entry) => + entry.pathname === "/api/rpc" && 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 +642,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/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 7f8dab0c..4ded1c45 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -241,14 +241,67 @@ Notes (contact): | RPC tag | Replaces | Transport facts | Credentials | Status | | --- | --- | --- | --- | --- | -| `content.createArticle` | POST `/api/content/articles` | idempotencyKey | cookieHeader, oauthUserBearer | todo | -| `content.listNews` | GET `/api/news` | ifMatch; if-none-match (dropped); query: department | none | todo | -| `content.publishArticle` | POST `/api/content/articles/{articleId}:publish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | -| `content.readArticle` | GET `/api/content/articles/{articleId}` | ifMatch; if-none-match (dropped) | cookieHeader, oauthUserBearer | todo | -| `content.readContentWorkspace` | GET `/api/content/articles` | query: department | cookieHeader, oauthUserBearer | todo | -| `content.readNewsArticle` | GET `/api/news/{slug}` | ifMatch; if-none-match (dropped); query: version | none | todo | -| `content.reviseArticle` | PATCH `/api/content/articles/{articleId}` | idempotencyKey; ifMatch; merge patch | cookieHeader, oauthUserBearer | todo | -| `content.unpublishArticle` | POST `/api/content/articles/{articleId}:unpublish` | idempotencyKey; ifMatch | cookieHeader, oauthUserBearer | todo | +| `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`) From eea5744768b291240c5a3126074259ce1cd1ff4f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 07:53:36 +0000 Subject: [PATCH 050/148] chore(rpc): integrate the migration: retire the HttpApi patch and SDK steps Remove the effect patch of HttpApiEndpoint.ClientRequest and its patchedDependencies entry, which only the deleted SDK needed, with its exception EX-0010, and EX-0007 of the deleted HTTP security middleware. EX-0002 follows problemMapper to apps/backend/src/rpc/problem.ts. Journey drivers no longer build packages/sdk. The layout, guides, constructs, and exception checks pass; every package type checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- AGENTS.md | 4 +- README.md | 4 +- apps/backend/AGENTS.md | 3 +- apps/backend/src/admission/AGENTS.md | 8 +- apps/backend/src/authority.ts | 5 +- apps/backend/src/config.ts | 3 +- apps/backend/src/content/AGENTS.md | 6 +- apps/backend/src/mail/http.ts | 3 +- apps/backend/src/organization/AGENTS.md | 2 +- .../organization/team-interest.rpc.test.ts | 5 +- apps/backend/src/placements/AGENTS.md | 4 +- apps/backend/src/recruitment/AGENTS.md | 9 +- apps/backend/src/rpc/AGENTS.md | 40 +- apps/backend/src/rpc/problem.ts | 17 +- apps/backend/src/schools/AGENTS.md | 57 - apps/backend/src/schools/CLAUDE.md | 1 - apps/dashboard/AGENTS.md | 2 +- apps/dashboard/app/foldkit/schools/AGENTS.md | 1 - .../e2e/run-real-native-identity-browser.mjs | 5 - ...eal-native-organization-administration.mjs | 7 - .../e2e/run-real-native-schools-directory.mjs | 6 - .../e2e/run-real-native-social-events.mjs | 12 +- .../e2e/run-real-native-substitutes.mjs | 1 - apps/dashboard/vite.config.ts | 4 - apps/homepage/src/lib/api-types.ts | 20 - bun.lock | 5 - docs/constructs.md | 79 +- docs/constructs/http-problem.md | 1551 ----------------- docs/constructs/http-transport.md | 16 +- docs/constructs/request-ledger.md | 2 +- docs/constructs/rpc-problem.md | 1079 ++++++++++++ docs/effect-exceptions.json | 94 +- package.json | 3 - packages/database/src/schools/AGENTS.md | 1 - packages/domain/src/schools/AGENTS.md | 1 - packages/rpc/AGENTS.md | 26 +- patches/effect@4.0.0-rc.116.patch | 40 - tools/acceptance/password-recovery-check.ts | 1 - tools/conventions/src/layout.ts | 1 - tools/e2e/legacy-candidate-native-journey.ts | 14 - tools/e2e/placement-check.ts | 15 +- .../organization-import-rehearsal-main.ts | 3 +- 42 files changed, 1230 insertions(+), 1930 deletions(-) delete mode 100644 apps/backend/src/schools/AGENTS.md delete mode 100644 apps/backend/src/schools/CLAUDE.md delete mode 100644 docs/constructs/http-problem.md create mode 100644 docs/constructs/rpc-problem.md delete mode 100644 patches/effect@4.0.0-rc.116.patch diff --git a/AGENTS.md b/AGENTS.md index 6f3a8e37..81243379 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,8 +147,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 +159,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 | diff --git a/README.md b/README.md index 783110a4..1314a21e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 8e68f4ef..e15ea3d4 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/src/admission/AGENTS.md b/apps/backend/src/admission/AGENTS.md index f4b1c147..feb6c54a 100644 --- a/apps/backend/src/admission/AGENTS.md +++ b/apps/backend/src/admission/AGENTS.md @@ -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/authority.ts b/apps/backend/src/authority.ts index 72d1376a..5aa38e63 100644 --- a/apps/backend/src/authority.ts +++ b/apps/backend/src/authority.ts @@ -514,9 +514,8 @@ export const resolvePersonAuthority: { IdentityEngineError | UnauthenticatedActor | OrganizationResolutionError, Organization | Identity > => - Effect.flatMap( - Effect.map(currentInstant(options.now), decodeAuthorizationInstant), - (instant) => personAuthorityEffect(cookieHeader, instant), + Effect.flatMap(Effect.map(currentInstant(options.now), decodeAuthorizationInstant), (instant) => + personAuthorityEffect(cookieHeader, instant), ), ); diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index ea10ea34..e08584a4 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -390,7 +390,8 @@ const passwordResetDeliveryConfig = ( const transport = mailDeliveryConfig(env); - if (transport === undefined) 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, diff --git a/apps/backend/src/content/AGENTS.md b/apps/backend/src/content/AGENTS.md index a9e50d89..d1eb3bc6 100644 --- a/apps/backend/src/content/AGENTS.md +++ b/apps/backend/src/content/AGENTS.md @@ -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/mail/http.ts b/apps/backend/src/mail/http.ts index e0872ed7..689c2df0 100644 --- a/apps/backend/src/mail/http.ts +++ b/apps/backend/src/mail/http.ts @@ -12,7 +12,8 @@ export const mailDeliveryConfig = ( if (keys.every((key) => env[key] === undefined)) return undefined; - if (keys.some((key) => (env[key] ?? "").length === 0)) 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); diff --git a/apps/backend/src/organization/AGENTS.md b/apps/backend/src/organization/AGENTS.md index 9461ee6d..7a796b15 100644 --- a/apps/backend/src/organization/AGENTS.md +++ b/apps/backend/src/organization/AGENTS.md @@ -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/team-interest.rpc.test.ts b/apps/backend/src/organization/team-interest.rpc.test.ts index 47dacdc2..0bcb6af2 100644 --- a/apps/backend/src/organization/team-interest.rpc.test.ts +++ b/apps/backend/src/organization/team-interest.rpc.test.ts @@ -267,9 +267,8 @@ const oauthCredentialAuthority = OAuthCredentialAuthority.of({ const identity = Identity.of({ signIn: () => Effect.die("unexpected sign-in"), resolveSession: (cookieHeader: string | undefined) => { - const token = cookieHeader?.startsWith(tokenPrefix) === true - ? cookieHeader.slice(tokenPrefix.length) - : ""; + const token = + cookieHeader?.startsWith(tokenPrefix) === true ? cookieHeader.slice(tokenPrefix.length) : ""; return token.length === 0 ? Effect.fail(IdentitySessionNotFound.make()) diff --git a/apps/backend/src/placements/AGENTS.md b/apps/backend/src/placements/AGENTS.md index aac09b6f..1bd176d8 100644 --- a/apps/backend/src/placements/AGENTS.md +++ b/apps/backend/src/placements/AGENTS.md @@ -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/recruitment/AGENTS.md b/apps/backend/src/recruitment/AGENTS.md index e3763863..4204d0d8 100644 --- a/apps/backend/src/recruitment/AGENTS.md +++ b/apps/backend/src/recruitment/AGENTS.md @@ -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/rpc/AGENTS.md b/apps/backend/src/rpc/AGENTS.md index 8404adc1..e6e7ade9 100644 --- a/apps/backend/src/rpc/AGENTS.md +++ b/apps/backend/src/rpc/AGENTS.md @@ -1,8 +1,8 @@ [//]: # "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 +# apps/backend/src/rpc -This folder is not a bounded context. Transport shared by every context: problems, rate limits, JSON reading, receipt transactions. +This folder is not a bounded context. Transport shared by every context: credential middlewares, problems, rate limits, command receipts. 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 @@ -13,29 +13,19 @@ 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. -- [`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. +- [`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): HTTP 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. diff --git a/apps/backend/src/rpc/problem.ts b/apps/backend/src/rpc/problem.ts index e3fd6d5e..74f8147b 100644 --- a/apps/backend/src/rpc/problem.ts +++ b/apps/backend/src/rpc/problem.ts @@ -109,8 +109,6 @@ export const classifyCredential: { * * @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 rpc-problem */ export const semanticProblem: { ( @@ -462,7 +460,16 @@ export const personPresentation: { * @construct rpc-problem */ export const commandOutcome = - >(success: S) => + >( + success: S, + ): (( + outcome: NativeHttpCommandOutcome, + ) => Effect.Effect< + S["Type"], + | Problem<"idempotency.in-flight"> + | Problem<"idempotency.digest-conflict"> + | Problem<"idempotency.response-expired"> + >) => ( outcome: NativeHttpCommandOutcome, ): Effect.Effect< @@ -495,10 +502,8 @@ export const commandOutcome = * ``` * * @avoid Catching defects or rendering causes in a handler. Leave causes to this boundary. - * - * @construct rpc-problem */ -export const ProblemBoundaryLive = Layer.succeed(ProblemBoundary)( +export const ProblemBoundaryLive: Layer.Layer = Layer.succeed(ProblemBoundary)( ProblemBoundary.of((effect) => Effect.catchCause(effect, (cause) => Cause.hasFails(cause) || Cause.hasInterruptsOnly(cause) diff --git a/apps/backend/src/schools/AGENTS.md b/apps/backend/src/schools/AGENTS.md deleted file mode 100644 index 46acdd83..00000000 --- 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/CLAUDE.md b/apps/backend/src/schools/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/apps/backend/src/schools/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index a28a7c16..5cc9d1b3 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -32,7 +32,7 @@ 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. Local invariants, pitfalls, and recipes go below this generated part; `just guides write` keeps them. diff --git a/apps/dashboard/app/foldkit/schools/AGENTS.md b/apps/dashboard/app/foldkit/schools/AGENTS.md index 9ea85a84..6457bf8f 100644 --- a/apps/dashboard/app/foldkit/schools/AGENTS.md +++ b/apps/dashboard/app/foldkit/schools/AGENTS.md @@ -7,7 +7,6 @@ The dashboard layer holds authenticated journeys: one Foldkit Model per workflow 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/e2e/run-real-native-identity-browser.mjs b/apps/dashboard/e2e/run-real-native-identity-browser.mjs index 05662549..a77b15ae 100644 --- a/apps/dashboard/e2e/run-real-native-identity-browser.mjs +++ b/apps/dashboard/e2e/run-real-native-identity-browser.mjs @@ -495,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: { diff --git a/apps/dashboard/e2e/run-real-native-organization-administration.mjs b/apps/dashboard/e2e/run-real-native-organization-administration.mjs index f3e4df49..63e0f592 100644 --- a/apps/dashboard/e2e/run-real-native-organization-administration.mjs +++ b/apps/dashboard/e2e/run-real-native-organization-administration.mjs @@ -15,7 +15,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)); @@ -1028,12 +1027,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") { diff --git a/apps/dashboard/e2e/run-real-native-schools-directory.mjs b/apps/dashboard/e2e/run-real-native-schools-directory.mjs index 9de06627..9638a52c 100644 --- a/apps/dashboard/e2e/run-real-native-schools-directory.mjs +++ b/apps/dashboard/e2e/run-real-native-schools-directory.mjs @@ -18,7 +18,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; @@ -529,11 +528,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, diff --git a/apps/dashboard/e2e/run-real-native-social-events.mjs b/apps/dashboard/e2e/run-real-native-social-events.mjs index 37dbd12a..5dfae372 100644 --- a/apps/dashboard/e2e/run-real-native-social-events.mjs +++ b/apps/dashboard/e2e/run-real-native-social-events.mjs @@ -22,7 +22,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); @@ -1059,11 +1058,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, @@ -1121,15 +1115,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 +1133,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 fd43bfb6..6da5fd54 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/vite.config.ts b/apps/dashboard/vite.config.ts index 95370288..6aa4eaa5 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), diff --git a/apps/homepage/src/lib/api-types.ts b/apps/homepage/src/lib/api-types.ts index c5834f42..fd895d5f 100644 --- a/apps/homepage/src/lib/api-types.ts +++ b/apps/homepage/src/lib/api-types.ts @@ -1,6 +1,3 @@ -import { - SubmitContactMessageEndpoint, -} from "@vektorprogrammet/rpc"; import type { DepartmentJson, PublicApplicationCatalogSchema, @@ -15,15 +12,6 @@ import type { TeamApplicationInput, TeamApplicationIntakeListItem, } from "@vektorprogrammet/rpc"; -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 = DepartmentJson; @@ -35,14 +23,6 @@ export type PublishedNewsArticle = PublishedNewsArticleType; export type PublicApplicationCatalog = typeof PublicApplicationCatalogSchema.Type; -export type ContactMessagePayload = HttpApiEndpoint.Payload< - typeof SubmitContactMessageEndpoint ->["Type"]; - -export type ContactMessageHeaders = HttpApiEndpoint.Headers< - typeof SubmitContactMessageEndpoint ->["Type"]; - export type NewsArticleSlug = NewsArticleQuery["slug"]; export type HomepageTeam = TeamJson; diff --git a/bun.lock b/bun.lock index 5a26a2a9..e54472ee 100644 --- a/bun.lock +++ b/bun.lock @@ -335,9 +335,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", @@ -2954,8 +2951,6 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@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=="], "@parcel/watcher/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/docs/constructs.md b/docs/constructs.md index cf75a186..eda49198 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -13,7 +13,7 @@ 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. @@ -21,49 +21,38 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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. -- [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. +- [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): HTTP 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 a closed endpoint-specific Problem Details union. + - [`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. @@ -101,5 +90,5 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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. - [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/http-problem.md b/docs/constructs/http-problem.md deleted file mode 100644 index 099d1eee..00000000 --- a/docs/constructs/http-problem.md +++ /dev/null @@ -1,1551 +0,0 @@ -# http-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. - -## `authorizeAdmissionPerson` - -Evaluates one admission person AccessSpec. - -```ts -authorizeAdmissionPerson( - request: Request, - input: NativePersonAuthorization -): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> -``` - -- Inputs: - - `request: Request` - - `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) - -**How it works** - -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. - -**Use** - -```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. - -## `returningAuthorization` - -Resolves the current person and authorizes one returning-assistant operation on that person's own profile. - -```ts -returningAuthorization( - request: Request, - input: AdmissionApiHttpOptions, - endpoint: | typeof ReadReturningAssistantOptionsEndpoint | typeof RegisterReturningAssistantEndpoint -): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> -``` - -- Inputs: - - `request: Request` - - `input: AdmissionApiHttpOptions` - - `endpoint: | typeof ReadReturningAssistantOptionsEndpoint | typeof RegisterReturningAssistantEndpoint` -- 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) - -**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 -authority, whose instant the command uses for its own reads. - -**Use** - -```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. - -## `admissionActorForAuthority` - -The admission actor of one department scope. - -```ts -admissionActorForAuthority( - authority: OrganizationPersonAuthority, - departmentScope?: string -): Effect.Effect -``` - -- Inputs: - - `authority: OrganizationPersonAuthority` - - `departmentScope?: string` -- Output: `Effect.Effect` -- 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) - -**How it works** - -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. - -**Use** - -```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. - -## `admissionProblems` - -The one answer for every admission failure. - -```ts -admissionProblems( - request: Request, - unavailable: Unavailable -): ProblemMapper> -``` - -- Inputs: - - `request: Request` - - `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) - -**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. - -**Use** - -```ts -command.pipe(admissionProblems(request, "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. - -## `authorizeContentOperation` - -Evaluates a content endpoint'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 } -): 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 }` -- 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) - -**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 -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 }); -``` - -**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. - -## `contentProblems` - -The one answer for every content domain failure. - -```ts -const contentProblems: ProblemMapper -``` - -- Inputs: none -- Output: `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) - -**How it works** - -An inactive authority, a department outside the actor's scope, a missing publisher role, and -another author's draft answer authority.denied. The article, slug, department, and lifecycle -failures answer their content problems, an unavailable store answers content.unavailable, and a -stored value that does not decode answers internal.error. - -**Use** - -```ts -read.pipe(contentProblems, contentActorProblems(presentation)); -``` - -**Avoid** - -Mapping a content failure in a handler: the content endpoints 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. - -```ts -contentActorProblems( - presentation: CredentialPresentation -): ProblemMapper -``` - -- Inputs: `presentation: CredentialPresentation` -- Output: `ProblemMapper` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/content/http-problem.ts:101](../../apps/backend/src/content/http-problem.ts#L101) - -**How it works** - -A rejected staff person answers `Problem.unauthenticated(presentation)`: credential.missing or -credential.invalid, as the request's evidence says. An unavailable identity or organization -projection answers internal.error. - -**Use** - -```ts -read.pipe(contentProblems, contentActorProblems(presentation)); -``` - -**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. - -## `problemWebResponse` - -Renders one problem outside HttpApi encoding, with the encoder's body and headers. - -```ts -problemWebResponse(problem: Problem): Response -``` - -- Inputs: `problem: Problem` -- Output: `Response` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:77](../../apps/backend/src/http-api/problem.ts#L77) - -**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`. - -**Use** - -```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. - -## `jsonText` - -The JSON text of a representation, byte for byte what `JSON.stringify` writes. - -```ts -jsonText
(value: A): Effect.Effect -``` - -- Inputs: `value: A` -- Output: `Effect.Effect` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:105](../../apps/backend/src/http-api/problem.ts#L105) - -**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. - -**Use** - -```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`. - -## `webHandler` - -Runs one Effect-native Web transport operation. - -```ts -webHandler( - request: HttpServerRequest.HttpServerRequest, - handle: (request: Request) => Effect.Effect -): 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) - -**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. - -**Use** - -```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`. - -## `semanticProblem` - -Runs a throwing semantic parser. - -```ts -semanticProblem( - parse: () => A, - codes: ReadonlyArray -): Effect.Effect> -``` - -- 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) - -**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. - -**Use** - -```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. - -## `problemMapper` - -Builds the one failure-to-problem mapper of a domain. - -```ts -problemMapper(): (>(cases: ExactCases) => ProblemMapper) -``` - -- Inputs: none -- Output: `(>(cases: ExactCases) => 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) - -**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. - -**Use** - -```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. - -## `requireNoQuery` - -An operation that accepts no query answers any query as malformed. - -```ts -requireNoQuery(request: Request): Effect.Effect> -``` - -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"request.malformed">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:305](../../apps/backend/src/http-api/problem.ts#L305) - -**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. - -**Use** - -```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. - -## `idempotencyKeyOf` - -Decodes the one Idempotency-Key a replayable mutation requires. - -```ts -idempotencyKeyOf( - request: Request -): Effect.Effect> -``` - -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"idempotency-key.invalid">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:368](../../apps/backend/src/http-api/problem.ts#L368) - -**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. - -**Use** - -```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`. - -## `requiredIfMatchOf` - -Decodes the one strong If-Match an item mutation requires. - -```ts -requiredIfMatchOf( - request: Request -): Effect.Effect> -``` - -- Inputs: `request: Request` -- Output: `Effect.Effect>` -- Errors: `Problem<"precondition.invalid" | "precondition.required">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:396](../../apps/backend/src/http-api/problem.ts#L396) - -**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. - -**Use** - -```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`. - -## `httpIdentity` - -Derives a command's idempotency identity; a tuple outside the frozen grammar is a request problem. - -```ts -httpIdentity( - identity: NativeIdempotencyIdentity -): Effect.Effect> -``` - -- Inputs: `identity: NativeIdempotencyIdentity` -- Output: `Effect.Effect>` -- Errors: `Problem<"idempotency-key.invalid" | "request.malformed">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:425](../../apps/backend/src/http-api/problem.ts#L425) - -**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. - -**Use** - -```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. - -## `requireCurrentETag` - -Fails a mutation whose If-Match no longer names the current representation. - -```ts -requireCurrentETag( - current: StrongETag, - ifMatch: StrongETag -): Effect.Effect> -``` - -- Inputs: - - `current: StrongETag` - - `ifMatch: StrongETag` -- Output: `Effect.Effect>` -- 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) - -**How it works** - -`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. - -**Use** - -```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. - -## `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 -``` - -- Inputs: - - `cause: unknown` - - `depth: number = 0` -- 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) - -**How it works** - -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. - -**Use** - -```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. - -## `requestInvalid` - -The request as a whole fails validation; no single member is singled out. - -```ts -requestInvalid(): Problem<"validation.failed"> -``` - -- Inputs: none -- Output: `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) - -**How it works** - -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. - -**Use** - -```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. - -## `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. - -```ts -strictOutput>( - schema: S -): ((value: S["Type"]) => Effect.Effect) -``` - -- Inputs: `schema: S` -- Output: `((value: S["Type"]) => Effect.Effect)` -- 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) - -**How it works** - -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. - -**Use** - -```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. - -## `commandReceiptProblems` - -HTTP command receipts: the transport's own persistence failures. - -```ts -const commandReceiptProblems: ProblemMapper -``` - -- Inputs: none -- Output: `ProblemMapper` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:698](../../apps/backend/src/http-api/problem.ts#L698) - -**How it works** - -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. - -**Use** - -```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. - -## `commandOutcomeResponse` - -Answers a command receipt outcome: committed and replayed results, or an idempotency problem. - -```ts -commandOutcomeResponse( - 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">` -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:727](../../apps/backend/src/http-api/problem.ts#L727) - -**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. - -**Use** - -```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. - -## `authorizeAnonymous` - -An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. - -```ts -authorizeAnonymous( - spec: AccessSpec, - resolution: CanonicalScopeResolution, - now: string -): Effect.Effect -``` - -- Inputs: - - `spec: AccessSpec` - - `resolution: CanonicalScopeResolution` - - `now: string` -- Output: `Effect.Effect` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:766](../../apps/backend/src/http-api/problem.ts#L766) - -**How it works** - -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. - -**Use** - -```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. - -## `authorizePerson` - -A rejected person credential is answered from the ingress evidence, never by string choice. - -```ts -authorizePerson( - input: NativePersonAuthorization, - presentation: CredentialPresentation -): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> -``` - -- Inputs: - - `input: NativePersonAuthorization` - - `presentation: CredentialPresentation` -- Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>` -- 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) - -**How it works** - -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. - -**Use** - -```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. - -## `unreachable` - -Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. - -```ts -unreachable( - code: Code, - ...codes: ReadonlyArray -): ((effect: Effect.Effect) => Effect.Effect>>, R>) -``` - -- Inputs: - - `code: Code` - - `...codes: ReadonlyArray` -- Output: `((effect: Effect.Effect) => Effect.Effect>>, R>)` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [apps/backend/src/http-api/problem.ts:841](../../apps/backend/src/http-api/problem.ts#L841) - -**How it works** - -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. - -**Use** - -```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. - -## `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. - -```ts -problemUnion]>( - identifier: string, - codes: Codes -): Schema.Union>> -``` - -- Inputs: - - `identifier: string` - - `codes: Codes` -- Output: `Schema.Union>>` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1144](../../packages/http-api/src/http-semantics.ts#L1144) - -**How it works** - -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. - -**Use** - -```ts -export const SessionUnauthorizedProblem = problemUnion("SessionUnauthorizedProblem", ["credential.missing", "credential.invalid"]); -``` - -**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. - -## `Problem` - -One RFC 9457 failure in an Effect error channel. - -```ts -class Problem extends Data.Error> { - readonly _tag = "Problem";; - readonly [ProblemTypeId] = ProblemTypeId;; - get status(): (typeof NativeProblemRegistry)[Code]["status"]; - get [ErrorReporter.ignore](): boolean; - static make( - code: C, - options?: { readonly instance?: string } - ): Problem; - static validation( - code: C, - errors: ReadonlyArray - ): Problem; - static rateLimited(retryAfterSeconds: number): Problem>; - static credentialMissing(evidence: CredentialAbsent): Problem<"credential.missing">; - static credentialInvalid(evidence: CredentialPresented): Problem<"credential.invalid">; - static unauthenticated( - presentation: CredentialPresentation - ): Problem<"credential.missing"> | Problem<"credential.invalid">; - static fromWire( - body: WireProblemBody, - headers: WireProblemHeaders - ): Problem; -} -``` - -- Inputs: none -- Output: `Problem` -- Errors: none -- Requirements: none -- Side effects: none -- Source: [packages/http-api/src/http-semantics.ts:1263](../../packages/http-api/src/http-semantics.ts#L1263) - -**How it works** - -The registry owns its type, title, status, and detail, so a problem carries only its code and -the members that the code needs: an instance, the challenge of a credential problem, the delay -of a rate limit, or validation diagnostics. The static constructors require exactly those -members: `make` for a plain code, `validation` for a validation code, `rateLimited`, -`credentialMissing` and `credentialInvalid` for the ingress evidence of each, and -`unauthenticated`, which answers from that evidence. A problem below status 500 is an answered -client failure, which `ErrorReporter` ignores. - -**Use** - -```ts -return yield* Problem.make("precondition.failed"); -``` - -**Avoid** - -Failing with a status, a plain `Error`, or a body built by hand: the registry then no -longer decides the answer, and an endpoint cannot declare the failure. Fail with a `Problem` -that one of its constructors built. - -## `isProblem` - -Narrows a caught value to a `Problem`, also one that another copy of this module created. - -```ts -isProblem(u: unknown): u is Problem -``` - -- Inputs: `u: unknown` -- Output: `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) - -**How it works** - -It checks for the `Problem` type identifier property, not the class, so a problem that a -second copy of the module constructed, as a bundle or a test runner can load one, still -matches. - -**Use** - -```ts -const code = isProblem(cause) ? codes.find((declared) => declared === cause.code) : undefined; -``` - -**Avoid** - -`cause instanceof Problem`: a problem from another copy of the module fails it and is -treated as a defect. Narrow with this. - -## `problemBody` - -The frozen RFC 9457 body: the registry entry, then code, instance, and validation. - -```ts -problemBody(problem: Problem): ProblemWireRecord -``` - -- Inputs: `problem: Problem` -- Output: `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) - -**How it works** - -The body starts with the registry entry of the problem's code, its type, title, status, and -detail, then adds the code, and the instance and validation diagnostics when the problem has -them. Its members keep that order, so the JSON text is the same for every rendering. - -**Use** - -```ts -new Response(JSON.stringify(problemBody(problem)), { status: problem.status }); -``` - -**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. - -## `makeNativeProblem` - -Builds one safe fixed public problem value. - -```ts -makeNativeProblem( - code: Code, - expectedStatus?: number, - instance?: string -): (typeof NativeProblemRegistry)[Code] & { readonly code: Code; readonly instance?: string } -``` - -- Inputs: - - `code: Code` - - `expectedStatus?: number` - - `instance?: string` -- Output: `(typeof NativeProblemRegistry)[Code] & { readonly code: Code; readonly instance?: string }` -- 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) - -**How it works** - -It answers the registry entry of `code`, with `code` and, when given, `instance`: the body that -a server answers. When `expectedStatus` is given and differs from the registry status of the -code, it throws, so a fixture cannot pair a code with another status. - -**Use** - -```ts -return Response.json(makeNativeProblem("content.article-not-found", 404), { status: 404 }); -``` - -**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. diff --git a/docs/constructs/http-transport.md b/docs/constructs/http-transport.md index 190b30ec..1f1a50d2 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,18 +44,16 @@ identity with this. Fills a route template with its encoded identities; a missing identity is a malformed request. ```ts -normalizeTarget(routeTemplate: string, identities: Readonly>): string +const normalizeTarget: { (identities: Readonly>): (routeTemplate: string) => string; (routeTemplate: string, identities: Readonly>): string } ``` -- Inputs: - - `routeTemplate: string` - - `identities: Readonly>` -- Output: `string` +- Inputs: none +- Output: `{ (identities: Readonly>): (routeTemplate: string) => string; (routeTemplate: string, identities: Readonly>): string }` - Errors: none - Throws: The `Problem` request.malformed when `identities` lacks a name of the template, or an identity is not a string of Unicode scalar values. - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-semantics.ts:413](../../apps/backend/src/http-semantics.ts#L413) +- Source: [apps/backend/src/http-semantics.ts:425](../../apps/backend/src/http-semantics.ts#L425) **How it works** @@ -88,7 +86,7 @@ deriveHttpIdentity(identity: NativeIdempotencyIdentity): DerivedHttpIdentity - Throws: The `Problem` request.malformed when the subject, operation id, or target is outside its grammar, and idempotency-key.invalid when the key is. - Requirements: none - Side effects: none -- Source: [apps/backend/src/http-semantics.ts:463](../../apps/backend/src/http-semantics.ts#L463) +- Source: [apps/backend/src/http-semantics.ts:476](../../apps/backend/src/http-semantics.ts#L476) **How it works** diff --git a/docs/constructs/request-ledger.md b/docs/constructs/request-ledger.md index ac022a56..d6c0c4ae 100644 --- a/docs/constructs/request-ledger.md +++ b/docs/constructs/request-ledger.md @@ -20,7 +20,7 @@ isNativeRequest(method: string, pathname: string, body?: string): boolean - Errors: none - Requirements: none - Side effects: none -- Source: [apps/dashboard/e2e/native-operations.ts:82](../../apps/dashboard/e2e/native-operations.ts#L82) +- Source: [apps/dashboard/e2e/native-operations.ts:173](../../apps/dashboard/e2e/native-operations.ts#L173) **How it works** diff --git a/docs/constructs/rpc-problem.md b/docs/constructs/rpc-problem.md new file mode 100644 index 00000000..8a29d45b --- /dev/null +++ b/docs/constructs/rpc-problem.md @@ -0,0 +1,1079 @@ +# rpc-problem + +[//]: # "constructs: generated from the @construct tags and their JSDoc by just constructs write; do not edit" + +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` + +Evaluates one admission person AccessSpec. + +```ts +const authorizeAdmissionPerson: { (input: NativePersonAuthorization): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>; (headers: Headers.Headers, input: NativePersonAuthorization): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> } +``` + +- Inputs: none +- Output: `{ (input: NativePersonAuthorization): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>; (headers: Headers.Headers, input: NativePersonAuthorization): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/admission/access.ts:68](../../apps/backend/src/admission/access.ts#L68) + +**How it works** + +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. + +**Use** + +```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. + +## `returningAuthorization` + +Resolves the current person and authorizes one returning-assistant operation on that person's own profile. + +```ts +const returningAuthorization: { (options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority>; (headers: Headers.Headers, options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> } +``` + +- Inputs: none +- Output: `{ (options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority>; (headers: Headers.Headers, options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> }` +- Errors: none +- Requirements: none +- Side effects: Reads the person's session or token and organization authority in the caller's transaction. +- 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 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(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. + +## `admissionActorForAuthority` + +The admission actor of one department scope. + +```ts +const admissionActorForAuthority: { (authority: OrganizationPersonAuthority, departmentScope?: string): Effect.Effect; (departmentScope?: string): (authority: OrganizationPersonAuthority) => Effect.Effect } +``` + +- Inputs: none +- Output: `{ (authority: OrganizationPersonAuthority, departmentScope?: string): Effect.Effect; (departmentScope?: string): (authority: OrganizationPersonAuthority) => Effect.Effect }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/admission/context.ts:46](../../apps/backend/src/admission/context.ts#L46) + +**How it works** + +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. + +**Use** + +```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. + +## `admissionProblems` + +The one answer for every admission failure. + +```ts +const admissionProblems: { (unavailable: Unavailable): (headers: Headers.Headers) => ProblemMapper>; (headers: Headers.Headers, unavailable: Unavailable): ProblemMapper> } +``` + +- Inputs: none +- Output: `{ (unavailable: Unavailable): (headers: Headers.Headers) => ProblemMapper>; (headers: Headers.Headers, unavailable: Unavailable): ProblemMapper> }` +- Errors: none +- Requirements: none +- Side effects: none +- 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 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(headers, "dependency.unavailable")); +``` + +**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. + +## `authorizeContentOperation` + +Evaluates a content RPC's AccessSpec for one person with the content grant scope. + +```ts +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 | Problem<"credential.invalid"> | Problem<"credential.missing">> +``` + +- 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/access.ts:51](../../apps/backend/src/content/access.ts#L51) + +**How it works** + +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({ 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. + +## `contentProblems` + +The one answer for every content domain failure. + +```ts +const contentProblems: ProblemMapper +``` + +- Inputs: none +- Output: `ProblemMapper` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/content/problem.ts:61](../../apps/backend/src/content/problem.ts#L61) + +**How it works** + +An inactive authority, a department outside the actor's scope, a missing publisher role, and +another author's draft answer authority.denied. The article, slug, department, and lifecycle +failures answer their content problems, an unavailable store answers content.unavailable, and a +stored value that does not decode answers internal.error. + +**Use** + +```ts +read.pipe(contentProblems, contentActorProblems(presentation)); +``` + +**Avoid** + +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 the credential middleware is answered from the credential the request presented. + +```ts +contentActorProblems( + presentation: CredentialPresentation +): ProblemMapper +``` + +- Inputs: `presentation: CredentialPresentation` +- Output: `ProblemMapper` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/content/problem.ts:102](../../apps/backend/src/content/problem.ts#L102) + +**How it works** + +A rejected staff person answers `Problem.unauthenticated(presentation)`: credential.missing or +credential.invalid, as the request's evidence says. An unavailable identity or organization +projection answers internal.error. + +**Use** + +```ts +read.pipe(contentProblems, contentActorProblems(presentation)); +``` + +**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. + +## `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/problem.ts:80](../../apps/backend/src/receipt/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 RPCs 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/problem.ts:108](../../apps/backend/src/receipt/problem.ts#L108) + +**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/access.ts:74](../../apps/backend/src/recruitment/access.ts#L74) + +**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(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. + +## `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( + 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: `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** + +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 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. + +## `recruitmentProblems` + +The one answer for every recruitment failure. + +```ts +recruitmentProblems( + { presentation, unavailable }: { readonly presentation: CredentialPresentation; readonly unavailable: Unavailable } +): ProblemMapper> +``` + +- Inputs: `{ presentation, unavailable }: { readonly presentation: CredentialPresentation; readonly unavailable: Unavailable }` +- Output: `ProblemMapper>` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/recruitment/problem.ts:123](../../apps/backend/src/recruitment/problem.ts#L123) + +**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({ presentation: personPresentation(headers), unavailable: "recruitment.unavailable" })); +``` + +**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. + +## `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/problem.ts:162](../../apps/backend/src/recruitment/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, unavailable: "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. + +## `jsonText` + +The JSON text of a representation, byte for byte what `JSON.stringify` writes. + +```ts +jsonText(value: A): Effect.Effect +``` + +- Inputs: `value: A` +- Output: `Effect.Effect` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:64](../../apps/backend/src/rpc/problem.ts#L64) + +**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. + +**Use** + +```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`. + +## `problemMapper` + +Builds the one failure-to-problem mapper of a domain. + +```ts +problemMapper(): (>(cases: ExactCases) => ProblemMapper) +``` + +- Inputs: none +- Output: `(>(cases: ExactCases) => ProblemMapper)` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:203](../../apps/backend/src/rpc/problem.ts#L203) + +**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. + +**Use** + +```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. + +## `commandIdentity` + +Derives a command's idempotency identity: the receipt digest and the domain command ID. + +```ts +commandIdentity(identity: NativeIdempotencyIdentity): Effect.Effect +``` + +- Inputs: `identity: NativeIdempotencyIdentity` +- Output: `Effect.Effect` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:250](../../apps/backend/src/rpc/problem.ts#L250) + +**How it works** + +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* 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. + +## `requireCurrentETag` + +Fails a mutation whose If-Match no longer names the current representation. + +```ts +const requireCurrentETag: { (ifMatch: StrongETag): (current: StrongETag) => Effect.Effect>; (current: StrongETag, ifMatch: StrongETag): Effect.Effect> } +``` + +- Inputs: none +- Output: `{ (ifMatch: StrongETag): (current: StrongETag) => Effect.Effect>; (current: StrongETag, ifMatch: StrongETag): Effect.Effect> }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:278](../../apps/backend/src/rpc/problem.ts#L278) + +**How it works** + +`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. + +**Use** + +```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. + +## `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): boolean +``` + +- Inputs: `cause: unknown` +- Output: `boolean` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:325](../../apps/backend/src/rpc/problem.ts#L325) + +**How it works** + +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. + +**Use** + +```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. + +## `requestInvalid` + +The request as a whole fails validation; no single member is singled out. + +```ts +requestInvalid(): Problem<"validation.failed"> +``` + +- Inputs: none +- Output: `Problem<"validation.failed">` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:348](../../apps/backend/src/rpc/problem.ts#L348) + +**How it works** + +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. + +**Use** + +```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. + +## `strictOutput` + +Decodes one response value strictly. + +```ts +strictOutput>( + schema: S +): ((value: S["Type"]) => Effect.Effect) +``` + +- Inputs: `schema: S` +- Output: `((value: S["Type"]) => Effect.Effect)` +- Errors: none +- Requirements: none +- Side effects: none of its own; it runs the decoding services of `schema`. +- Source: [apps/backend/src/rpc/problem.ts:371](../../apps/backend/src/rpc/problem.ts#L371) + +**How it works** + +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. + +**Use** + +```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. + +## `commandReceiptProblems` + +HTTP command receipts: the transport's own persistence failures. + +```ts +const commandReceiptProblems: ProblemMapper +``` + +- Inputs: none +- Output: `ProblemMapper` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:406](../../apps/backend/src/rpc/problem.ts#L406) + +**How it works** + +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. + +**Use** + +```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. + +## `personPresentation` + +The credential evidence of an RPC request: which credential headers it presented. + +```ts +const personPresentation: { (headers: Headers.Headers, challenge?: string): CredentialPresentation; (challenge?: string): (headers: Headers.Headers) => CredentialPresentation } +``` + +- Inputs: none +- Output: `{ (headers: Headers.Headers, challenge?: string): CredentialPresentation; (challenge?: string): (headers: Headers.Headers) => 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` + +The value of a command receipt outcome: the committed or replayed success, or an idempotency problem. + +```ts +commandOutcome>( + success: S +): ((outcome: NativeHttpCommandOutcome) => Effect.Effect | 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/rpc/problem.ts:462](../../apps/backend/src/rpc/problem.ts#L462) + +**How it works** + +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* 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. + +## `authorizeAnonymous` + +An anonymous AccessSpec grants every caller and conceals nothing, so a denial means the spec and its scope resolution disagree: a defect. + +```ts +const authorizeAnonymous: { (resolution: CanonicalScopeResolution, now: string): (spec: AccessSpec) => Effect.Effect; (spec: AccessSpec, resolution: CanonicalScopeResolution, now: string): Effect.Effect } +``` + +- Inputs: none +- Output: `{ (resolution: CanonicalScopeResolution, now: string): (spec: AccessSpec) => Effect.Effect; (spec: AccessSpec, resolution: CanonicalScopeResolution, now: string): Effect.Effect }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:539](../../apps/backend/src/rpc/problem.ts#L539) + +**How it works** + +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. + +**Use** + +```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. + +## `authorizePerson` + +A rejected person credential is answered from the ingress evidence, never by string choice. + +```ts +const authorizePerson: { (presentation: CredentialPresentation): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>; (input: NativePersonAuthorization, presentation: CredentialPresentation): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> } +``` + +- Inputs: none +- Output: `{ (presentation: CredentialPresentation): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>; (input: NativePersonAuthorization, presentation: CredentialPresentation): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:585](../../apps/backend/src/rpc/problem.ts#L585) + +**How it works** + +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. + +**Use** + +```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. + +## `unreachable` + +Marks problems a shared mapper can produce but this operation cannot, such as a serialization conflict inside a read-only snapshot. + +```ts +unreachable( + code: Code, + ...codes: ReadonlyArray +): ((effect: Effect.Effect) => Effect.Effect>>, R>) +``` + +- Inputs: + - `code: Code` + - `...codes: ReadonlyArray` +- Output: `((effect: Effect.Effect) => Effect.Effect>>, R>)` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [apps/backend/src/rpc/problem.ts:652](../../apps/backend/src/rpc/problem.ts#L652) + +**How it works** + +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. + +**Use** + +```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. + +## `problemUnion` + +Creates a closed endpoint-specific Problem Details union. + +```ts +const problemUnion: { ]>(codes: Codes): (identifier: string) => Schema.Union>>; ]>(identifier: string, codes: Codes): Schema.Union>> } +``` + +- Inputs: none +- Output: `{ ]>(codes: Codes): (identifier: string) => Schema.Union>>; ]>(identifier: string, codes: Codes): Schema.Union>> }` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [packages/rpc/src/problem.ts:1008](../../packages/rpc/src/problem.ts#L1008) + +**How it works** + +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. + +**Use** + +```ts +export const SessionUnauthorizedProblem = problemUnion("SessionUnauthorizedProblem", ["credential.missing", "credential.invalid"]); +``` + +**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. + +## `Problem` + +One RFC 9457 failure in an Effect error channel. + +```ts +class Problem extends Data.Error> { + readonly _tag = "Problem";; + readonly [ProblemTypeId] = ProblemTypeId;; + get status(): (typeof NativeProblemRegistry)[Code]["status"]; + get [ErrorReporter.ignore](): boolean; + static make( + code: C, + options?: { readonly instance?: string } + ): Problem; + static validation( + code: C, + errors: ReadonlyArray + ): Problem; + static rateLimited(retryAfterSeconds: number): Problem>; + static credentialMissing(evidence: CredentialAbsent): Problem<"credential.missing">; + static credentialInvalid(evidence: CredentialPresented): Problem<"credential.invalid">; + static unauthenticated( + presentation: CredentialPresentation + ): Problem<"credential.missing"> | Problem<"credential.invalid">; + static fromWire( + body: WireProblemBody, + headers: WireProblemHeaders + ): Problem; +} +``` + +- Inputs: none +- Output: `Problem` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [packages/rpc/src/problem.ts:1136](../../packages/rpc/src/problem.ts#L1136) + +**How it works** + +The registry owns its type, title, status, and detail, so a problem carries only its code and +the members that the code needs: an instance, the challenge of a credential problem, the delay +of a rate limit, or validation diagnostics. The static constructors require exactly those +members: `make` for a plain code, `validation` for a validation code, `rateLimited`, +`credentialMissing` and `credentialInvalid` for the ingress evidence of each, and +`unauthenticated`, which answers from that evidence. A problem below status 500 is an answered +client failure, which `ErrorReporter` ignores. + +**Use** + +```ts +return yield* Problem.make("precondition.failed"); +``` + +**Avoid** + +Failing with a status, a plain `Error`, or a body built by hand: the registry then no +longer decides the answer, and an endpoint cannot declare the failure. Fail with a `Problem` +that one of its constructors built. + +## `isProblem` + +Narrows a caught value to a `Problem`, also one that another copy of this module created. + +```ts +isProblem(u: unknown): u is Problem +``` + +- Inputs: `u: unknown` +- Output: `u is Problem` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [packages/rpc/src/problem.ts:1239](../../packages/rpc/src/problem.ts#L1239) + +**How it works** + +It checks for the `Problem` type identifier property, not the class, so a problem that a +second copy of the module constructed, as a bundle or a test runner can load one, still +matches. + +**Use** + +```ts +const code = isProblem(cause) ? codes.find((declared) => declared === cause.code) : undefined; +``` + +**Avoid** + +`cause instanceof Problem`: a problem from another copy of the module fails it and is +treated as a defect. Narrow with this. + +## `problemBody` + +The frozen RFC 9457 body: the registry entry, then code, instance, and validation. + +```ts +problemBody(problem: Problem): ProblemWireRecord +``` + +- Inputs: `problem: Problem` +- Output: `ProblemWireRecord` +- Errors: none +- Requirements: none +- Side effects: none +- Source: [packages/rpc/src/problem.ts:1268](../../packages/rpc/src/problem.ts#L1268) + +**How it works** + +The body starts with the registry entry of the problem's code, its type, title, status, and +detail, then adds the code, and the instance and validation diagnostics when the problem has +them. Its members keep that order, so the JSON text is the same for every rendering. + +**Use** + +```ts +new Response(JSON.stringify(problemBody(problem)), { status: problem.status }); +``` + +**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. + +## `makeNativeProblem` + +Builds one safe fixed public problem value. + +```ts +const makeNativeProblem: { (code: Code, expectedStatus?: number, instance?: string): RegistryProblemBody; (expectedStatus?: number, instance?: string): (code: Code) => RegistryProblemBody } +``` + +- Inputs: none +- Output: `{ (code: Code, expectedStatus?: number, instance?: string): RegistryProblemBody; (expectedStatus?: number, instance?: string): (code: Code) => 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/rpc/src/problem.ts:1388](../../packages/rpc/src/problem.ts#L1388) + +**How it works** + +It answers the registry entry of `code`, with `code` and, when given, `instance`: the body that +a server answers. When `expectedStatus` is given and differs from the registry status of the +code, it throws, so a fixture cannot pair a code with another status. + +**Use** + +```ts +return Response.json(makeNativeProblem("content.article-not-found", 404), { status: 404 }); +``` + +**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. diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index 914519ed..e48804a0 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,7 +157,10 @@ "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." }, { @@ -192,35 +190,11 @@ "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." ], "owner": "tools/acceptance", - "examinedWith": { "@phibkro/oxlint-effect-plugin": "0.1.0", "effect": "4.0.0-rc.116" }, + "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." - }, - { - "id": "EX-0010", - "rules": ["FX001", "FX012"], - "scope": { - "files": [ - "packages/sdk/src/effect-client.ts", - "packages/sdk/src/__tests__/generated-native-client.test.ts" - ], - "symbols": ["EffectSdk"] - }, - "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.", - "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." - ], - "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." - ], - "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." } ] } diff --git a/package.json b/package.json index e4509cc2..00c3c121 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,5 @@ "foldkit": "0.163.0", "vitest": "5.0.0" } - }, - "patchedDependencies": { - "effect@4.0.0-rc.116": "patches/effect@4.0.0-rc.116.patch" } } diff --git a/packages/database/src/schools/AGENTS.md b/packages/database/src/schools/AGENTS.md index 1eb3ae84..411a2238 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/domain/src/schools/AGENTS.md b/packages/domain/src/schools/AGENTS.md index cc2f7bdd..e75ec822 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/rpc/AGENTS.md b/packages/rpc/AGENTS.md index 00bbdb10..41c70d54 100644 --- a/packages/rpc/AGENTS.md +++ b/packages/rpc/AGENTS.md @@ -1,26 +1,28 @@ [//]: # "guide: generated from docs/model/contexts.cml, the @construct tags, and package.json exports by just guides write; do not edit" -# packages/http-api +# packages/rpc -HTTP contracts, middleware declarations, and OpenAPI. -Package `@vektorprogrammet/http-api`. +The native RPC contract, its credential middlewares, problems, and client. +Package `@vektorprogrammet/rpc`. ## 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) | +| 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. -- [`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. +- [`problemUnion`](../../docs/constructs/rpc-problem.md#problemunion) (rpc-problem): Creates a closed endpoint-specific Problem Details union. +- [`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. 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 6ae6941b..00000000 --- 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/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index 04a80ac2..59508d83 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -237,7 +237,6 @@ try { 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); diff --git a/tools/conventions/src/layout.ts b/tools/conventions/src/layout.ts index 442b9c49..65a81bd9 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", diff --git a/tools/e2e/legacy-candidate-native-journey.ts b/tools/e2e/legacy-candidate-native-journey.ts index 44f7acb4..885ab9dc 100644 --- a/tools/e2e/legacy-candidate-native-journey.ts +++ b/tools/e2e/legacy-candidate-native-journey.ts @@ -181,20 +181,6 @@ export const observeLegacyCandidateNativeJourney = async ( checks.push(name); }; - const json = async >( - name: string, - path: string, - schema: S, - cookie: string, - ) => { - phase = name; - const response = await request(path, cookie); - assert.equal(response.status, 200); - const result = Schema.decodeUnknownSync(schema)(await response.json()); - - return result; - }; - /** 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; diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index 47de6188..b4f20351 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -914,7 +914,9 @@ try { }; const readBoard = async () => - valueOf(await native.call(session(leader), (client) => client["placements.readBoard"](query))); + valueOf( + await native.call(session(leader), (client) => client["placements.readBoard"](query)), + ); const command = async (payload: Schema.Json) => { const board = await readBoard(); @@ -988,9 +990,7 @@ try { assert.ok( valueOf( await native.call(session(leader), (client) => client["placements.listScopes"]()), - ).semesters.some( - (semester) => semester.semesterId === semesterId, - ), + ).semesters.some((semester) => semester.semesterId === semesterId), ); await expectStatus(await request(boardPath), 401); await expectStatus(await request(boardPath, volunteer), 403, "authority.denied"); @@ -1983,12 +1983,7 @@ try { recordCoverage(coveredAbsence.absenceId, leaderId), closeCoverageBoard.etag, ], - [ - ownCoveragePath, - volunteer, - coveredAbsenceCommand, - (await readOwnCoverage(volunteer)).etag, - ], + [ownCoveragePath, volunteer, coveredAbsenceCommand, (await readOwnCoverage(volunteer)).etag], ] as const) await expectStatus(await request(path, cookie, payload, etag), 409, "commitment.closed"); assert.equal( diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index c51133f9..9cf824c6 100644 --- a/tools/verification/organization-import-rehearsal-main.ts +++ b/tools/verification/organization-import-rehearsal-main.ts @@ -1524,7 +1524,8 @@ const makeRehearsalRuntime = ( const routerLayer = HttpRouter.layer; const httpLayer = Layer.merge(platformLayer, routerLayer); - const nativeApiLayer = ExternalNativeRpcRouterLive(config, { + const nativeApiLayer = ExternalNativeRpcRouterLive({ + config, now: () => SPEC_0067.authorizationInstant, }).pipe( HttpRouter.provideRequest(Layer.merge(servicesLayer, requestPlatformLayer)), From 96780282cfa6f5f69f2686f55ea1e4de7b0ceb15 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:10:15 +0000 Subject: [PATCH 051/148] test(rpc): assert the RPC contract and pin React to the catalog packages/rpc had no test files, so its vitest run failed. The contract test asserts the operation count, one AccessSpec per RPC, the operation-id grammar that receipts store, the ProblemBoundary middleware on every RPC, and that rpcProblems round-trips declared codes and refuses others. The rename's non-frozen install re-hoisted react-dom 19.3.0 (pinned by apps/docs) into the dashboard. Bun 1.3.13 cannot load its server.bun.js ("Expected CommonJS module to have a function wrapper"), so the dashboard server crashed in every browser journey. A `react` catalog now pins react, react-dom, their types, and vite for the dashboard and homepage. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/package.json | 10 +-- apps/homepage/package.json | 10 +-- bun.lock | 139 +++++++++++++++++++++++++---- package.json | 7 ++ packages/rpc/test/contract.test.ts | 82 +++++++++++++++++ 5 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 packages/rpc/test/contract.test.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 722b914b..1dcdbb06 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -77,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", @@ -94,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/homepage/package.json b/apps/homepage/package.json index 3fccb69e..de9e0f24 100644 --- a/apps/homepage/package.json +++ b/apps/homepage/package.json @@ -48,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", @@ -65,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/bun.lock b/bun.lock index e54472ee..ca27a5ca 100644 --- a/bun.lock +++ b/bun.lock @@ -71,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", @@ -88,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", }, }, @@ -152,15 +152,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", @@ -169,8 +169,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", }, }, @@ -349,6 +349,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=="], @@ -1365,9 +1372,9 @@ "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], - "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], @@ -2487,11 +2494,11 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-devtools-inline": ["react-devtools-inline@4.4.0", "", { "dependencies": { "es6-symbol": "^3" } }, "sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ=="], - "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "react-error-boundary": ["react-error-boundary@6.1.6", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A=="], @@ -2595,7 +2602,7 @@ "sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="], - "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "schema-utils": ["schema-utils@4.3.3", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.1.0" } }, "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA=="], @@ -2835,7 +2842,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "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=="], + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.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-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], "vite-plugin-arraybuffer": ["vite-plugin-arraybuffer@0.1.4", "", {}, "sha512-Ubfsw/g4PhdjlgsKYDm2Hlc0qmNJheX8VCeOMayq1k9fMVB1nNuqtWjDH1fj9hSlhlSnlndz/ehMKIuXpB0SeA=="], @@ -2951,6 +2958,16 @@ "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@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=="], + + "@monoweb/docs/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "@monoweb/docs/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "@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/placements-docs/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "@parcel/watcher/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -3165,6 +3182,10 @@ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "react-server-dom-webpack/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-server-dom-webpack/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "read-package-up/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -3207,10 +3228,20 @@ "vocs/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "vocs/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "vocs/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + "vocs/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "waku/@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=="], + "waku/react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "waku/react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "waku/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=="], + "wrangler/miniflare": ["miniflare@5.20260921.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260921.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vHH/unOYvV2jA1Q9SdkmzrQhhMoksdwg5jegu6ZeKaaRzgxZhVbt1NdTpQjHF2VTgiBjgP8SiUlUMfruB3N3SQ=="], "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], @@ -3247,6 +3278,10 @@ "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@monoweb/docs/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "@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=="], "@scalar/api-client/@scalar/workspace-store/@scalar/openapi-upgrader": ["@scalar/openapi-upgrader@0.4.0", "", { "dependencies": { "@scalar/helpers": "0.15.0", "@scalar/openapi-types": "0.9.7" } }, "sha512-Qq+cOhac8xSyV2uiwiqFdwc7ctmZ1uz2n8h6oWSahUDcz9cX8PSuM5vvH+f8w5PbSyaJHVBSN4b85/jf9EfjFw=="], @@ -3403,6 +3438,8 @@ "radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="], + "react-server-dom-webpack/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -3499,12 +3536,18 @@ "vocs/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "vocs/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + "waku/@vitejs/plugin-rsc/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "waku/@vitejs/plugin-rsc/obug": ["obug@3.0.0", "", {}, "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw=="], "waku/@vitejs/plugin-rsc/strip-literal": ["strip-literal@4.0.0", "", { "dependencies": { "js-tokens": "^10.0.0" } }, "sha512-PaqAvfUZKBwc/SLmNZtHmzK+v19Z4O4eS3cKPeGvbIv/U3pnyEq4Tuw3/4v/FwfM8VQaEawsyCcOQ0P+kpwWWw=="], + "waku/react-dom/scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "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=="], "wrangler/miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3559,6 +3602,38 @@ "@cloudflare/vite-plugin/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + "@monoweb/docs/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + + "@monoweb/docs/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=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + + "@monoweb/docs/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=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + + "@monoweb/docs/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], + "@scalar/api-client/@scalar/workspace-store/@scalar/json-magic/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "@scalar/blocks/@scalar/workspace-store/@scalar/json-magic/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], @@ -3595,6 +3670,38 @@ "waku/@vitejs/plugin-rsc/strip-literal/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], + "waku/vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.151.0", "", {}, "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA=="], + + "waku/vite/rolldown/@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], + + "waku/vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], + + "waku/vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ=="], + + "waku/vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w=="], + + "waku/vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.11", "", { "os": "freebsd", "cpu": "x64" }, "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow=="], + + "waku/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=="], + + "waku/vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw=="], + + "waku/vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg=="], + + "waku/vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.11", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw=="], + + "waku/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=="], + + "waku/vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A=="], + + "waku/vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg=="], + + "waku/vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.11", "", { "os": "none", "cpu": "arm64" }, "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA=="], + + "waku/vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw=="], + + "waku/vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA=="], + "wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], "wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], diff --git a/package.json b/package.json index 00c3c121..e3e3dab0 100644 --- a/package.json +++ b/package.json @@ -43,6 +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" } } } diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts new file mode 100644 index 00000000..a5cb310e --- /dev/null +++ b/packages/rpc/test/contract.test.ts @@ -0,0 +1,82 @@ +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); + }), + ); +}); From 4118048e1d876cedc4220544f724f7fa8d38a9cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:20:52 +0000 Subject: [PATCH 052/148] fix(backend): bound and strictly parse RPC bodies, and keep answers out of caches The RPC ingress read every body whole with request.text(), so a body with no Content-Length that never ended was read until the process failed (observed: RangeError after 274 s). The HTTP contract had bounded each body and refused duplicate JSON members. The ingress now reads through readBoundedJson within nativeRpcMaxBodyBytes (a receipt file at its bound as base64, plus 1 MiB), answers request.too-large or request.malformed before any handler, and marks every native answer Cache-Control: private, no-store, which the HTTP reads and commands sent. Red before the fix: the three new router tests failed; green after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/router.ts | 64 +++++++++++++++++++-------- apps/backend/src/rpc/router.test.ts | 67 ++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 19 deletions(-) diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index 469bb868..b172d6cd 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -2,6 +2,7 @@ import { BlockList, isIP } from "node:net"; 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 { InternalNativeRpcs, internalNativeRpcPath, @@ -30,6 +31,7 @@ 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, @@ -44,7 +46,8 @@ 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, and an unavailable health check. + * 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)), { @@ -100,18 +103,12 @@ const RpcRequestMessage = Schema.StructWithRest( const decodeRpcRequestMessage = Schema.decodeUnknownOption(RpcRequestMessage); -const RpcJsonBody = Schema.fromJsonString(Schema.Json); - /** - * Keeps only the caller message headers of every RPC request message in a JSON body, so the - * handlers see every other header from the HTTP request alone. A body that is not JSON passes + * 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. */ -export const restrictRpcMessageHeaders = (body: string): string => { - const decoded = Schema.decodeOption(RpcJsonBody)(body); - - if (Option.isNone(decoded)) return body; - +const restrictRpcMessageHeaders = (body: Schema.Json): Schema.Json => { const restrict = (message: Schema.Json): Schema.Json => Option.match(decodeRpcRequestMessage(message), { onNone: () => message, @@ -121,19 +118,28 @@ export const restrictRpcMessageHeaders = (body: string): string => { }), }); - const value = decoded.value; - - return JSON.stringify(Array.isArray(value) ? value.map(restrict) : restrict(value)); + 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; + const isInternalRpcPath = (pathname: string): boolean => pathname === internalNativeRpcPath || pathname === `${internalNativeRpcPath}/`; -/** The RPC request with its message headers restricted; any other request unchanged. */ +/** + * 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) || isInternalRpcPath(pathname)) ? Effect.succeed(request) - : Effect.promise(() => request.text()).pipe( + : readBoundedJson(request, nativeRpcMaxBodyBytes).pipe( Effect.map((body) => { const headers = new Headers(request.headers); @@ -142,11 +148,33 @@ const restrictedNativeRequest = (request: Request, pathname: string) => return new Request(request.url, { method: request.method, headers, - body: restrictRpcMessageHeaders(body), + 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 * answers a defect as it answers a rejected handler. @@ -497,7 +525,7 @@ export const backendHttpHandler: { const response = pathname === "/api/auth/" || pathname.startsWith("/api/auth/") ? yield* authHandler.handler(prepared.request, prepared.context) - : yield* nativeHandler(yield* restrictedNativeRequest(prepared.request, pathname)); + : yield* serveNative(nativeHandler, prepared.request, pathname); return withTrustedOriginCors(response, acceptedOrigin); }).pipe(Effect.orDie), @@ -551,7 +579,7 @@ export const internalBackendHttpHandler: { return yield* authHandler.oauthIntrospectionHandler(prepared.request, prepared.context); } - return yield* nativeHandler(yield* restrictedNativeRequest(prepared.request, pathname)); + return yield* serveNative(nativeHandler, prepared.request, pathname); }).pipe(Effect.orDie); }, ); diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts index 965e177c..3c1359ad 100644 --- a/apps/backend/src/rpc/router.test.ts +++ b/apps/backend/src/rpc/router.test.ts @@ -6,7 +6,7 @@ import { SemesterId } from "@vektorprogrammet/domain"; import { Effect, Layer, Schema } from "effect"; import { RpcClient } from "effect/unstable/rpc"; import { backendTestConfig } from "../../test/config.js"; -import { backendHttpHandler } from "../router.js"; +import { backendHttpHandler, nativeRpcMaxBodyBytes } from "../router.js"; import { makeBackendTestRpc, testAuthHandler } from "../test/native-rpc.js"; // The identity engine rejects every session, as it does a forged or expired one. @@ -83,6 +83,71 @@ it.effect("an RPC from an untrusted origin answers origin.denied before any hand }), ); +// 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 = []; From e6908e97a35ff839cae8ff74ac19a52496ee2d36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:23:23 +0000 Subject: [PATCH 053/148] fix(backend): admit only the headers an RPC reads in a browser preflight The preflight allowlist still named the HTTP headers If-Match, If-None-Match, Idempotency-Key, and X-Recruitment-Invitation-Capability. An RPC carries each of them in its payload, so a browser may now ask only for Authorization and Content-Type. Red before the change (the test admitted If-Match), green after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/http-semantics.test.ts | 24 +++++++++++++++++------- apps/backend/src/session-security.ts | 9 +-------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/http-semantics.test.ts b/apps/backend/src/http-semantics.test.ts index 9fda8f81..8fe30808 100644 --- a/apps/backend/src/http-semantics.test.ts +++ b/apps/backend/src/http-semantics.test.ts @@ -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/session-security.ts b/apps/backend/src/session-security.ts index a762c9af..ccfe6a39 100644 --- a/apps/backend/src/session-security.ts +++ b/apps/backend/src/session-security.ts @@ -194,14 +194,7 @@ export const decideTrustedOrigin: { * 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()), From 2de981263bc51e05163e2e0c9bf42dcb040ef7d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:24:04 +0000 Subject: [PATCH 054/148] docs: describe the RPC contract, its ingress, and what RPC does not carry AGENTS.md, the architecture document, and STATE.md named the HttpApi contract, the generated SDK, and OpenAPI. They now name packages/rpc and its clients, the HTTP ingress that wraps the RPC endpoint, and the facts of the former HTTP contract that RPC does not carry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- AGENTS.md | 25 +++++----- STATE.md | 8 +-- docs/architecture.md | 116 +++++++++++++++++++++++++------------------ 3 files changed, 84 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 81243379..95a6d1c0 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 @@ -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. @@ -185,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. @@ -232,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. @@ -254,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. | @@ -276,7 +275,7 @@ Record instances you cannot fix in `STATE.md` with their location. Remove the re ## 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/STATE.md b/STATE.md index d326778a..e486e863 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,7 +110,7 @@ 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`. - 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. @@ -128,7 +130,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/docs/architecture.md b/docs/architecture.md index 9606eb85..8bcb856d 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,14 +73,14 @@ 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`, +- Core Effect code, in `packages/domain`, `packages/database`, `packages/rpc`, and `apps/backend`, 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. @@ -92,7 +90,7 @@ Required rules: 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 +102,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 +171,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 +193,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 +300,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 +340,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 +391,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. From b343fcc92865f8cda72fe87d2051cf30cdec4a9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:29:09 +0000 Subject: [PATCH 055/148] style(rpc): bring packages/rpc under Oxfmt in place of the deleted http-api and sdk The Oxfmt scope still named packages/http-api and packages/sdk, so the RPC contract that replaced them was never formatted or checked. The reformatting of the six files changes whitespace only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .oxfmtrc.json | 15 +-- packages/rpc/src/admissions.ts | 56 +++++----- packages/rpc/src/api.ts | 36 +++--- packages/rpc/src/organization.ts | 118 +++++++++----------- packages/rpc/src/recruitment.ts | 157 ++++++++++++--------------- packages/rpc/src/team-application.ts | 11 +- packages/rpc/test/contract.test.ts | 8 +- 7 files changed, 179 insertions(+), 222 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 80b61b3e..93145a1f 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/packages/rpc/src/admissions.ts b/packages/rpc/src/admissions.ts index 3dba08de..bf8a639f 100644 --- a/packages/rpc/src/admissions.ts +++ b/packages/rpc/src/admissions.ts @@ -156,30 +156,27 @@ export const ReadReturningAssistantOptionsProblem = problemUnion( ); /** 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", - ], -); +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", { @@ -284,13 +281,10 @@ export const ReviseAdmissionPeriod = Rpc.make("admissions.reviseAdmissionPeriod" ); /** The identity, placement, department, and admission-period options of a returning assistant. */ -export const ReadReturningAssistantOptions = Rpc.make( - "admissions.readReturningAssistantOptions", - { - success: ReturningAssistantOptionsSchema, - error: rpcProblems(ReadReturningAssistantOptionsProblem), - }, -) +export const ReadReturningAssistantOptions = Rpc.make("admissions.readReturningAssistantOptions", { + success: ReturningAssistantOptionsSchema, + error: rpcProblems(ReadReturningAssistantOptionsProblem), +}) .middleware(PersonCredential) .pipe( withAccessSpec( diff --git a/packages/rpc/src/api.ts b/packages/rpc/src/api.ts index 2312000a..76f0ccd9 100644 --- a/packages/rpc/src/api.ts +++ b/packages/rpc/src/api.ts @@ -31,23 +31,25 @@ export const nativeRpcPath = "/api/rpc"; /** The path that serves `InternalNativeRpcs`, on the internal ingress only. */ export const internalNativeRpcPath = "/internal/rpc"; -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 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) diff --git a/packages/rpc/src/organization.ts b/packages/rpc/src/organization.ts index 7b419a4c..c3bfb0c0 100644 --- a/packages/rpc/src/organization.ts +++ b/packages/rpc/src/organization.ts @@ -93,61 +93,52 @@ export const MailingListResponse = Schema.Array( export type MailingListResponse = typeof MailingListResponse.Type; -export const ListDepartmentsProblem = problemUnion( - "ListDepartmentsProblem", - ["internal.error", "organization.unavailable"], -); +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 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", @@ -163,22 +154,19 @@ export const CreateTeamProblem = problemUnion("CreateTeamProblem", [ "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", - ], -); +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", [ diff --git a/packages/rpc/src/recruitment.ts b/packages/rpc/src/recruitment.ts index 462600a4..65fa24c3 100644 --- a/packages/rpc/src/recruitment.ts +++ b/packages/rpc/src/recruitment.ts @@ -86,9 +86,10 @@ export type { }; /** Interview invitation state visible to the invitee. */ -export const InvitationResponseObservation = RecruitmentInvitationResponseObservationSchema.annotate( - { identifier: "InvitationResponseObservation" }, -); +export const InvitationResponseObservation = + RecruitmentInvitationResponseObservationSchema.annotate({ + identifier: "InvitationResponseObservation", + }); /** * The invitation beside its strong entity tag, which a response takes as `ifMatch`. A response to @@ -193,10 +194,11 @@ export const CancelInterviewResult = Schema.Struct({ export type CancelInterviewResult = typeof CancelInterviewResult.Type; /** Problems of `recruitment.readInvitationResponse`. */ -export const ReadInvitationResponseProblem = problemUnion( - "ReadInvitationResponseProblem", - ["resource.not-found", "internal.error", "recruitment.unavailable"], -); +export const ReadInvitationResponseProblem = problemUnion("ReadInvitationResponseProblem", [ + "resource.not-found", + "internal.error", + "recruitment.unavailable", +]); const invitationResponseCodes = [ "authority.denied", @@ -226,43 +228,34 @@ export const RequestNewInvitationTimeProblem = problemUnion( ); /** 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", - ], -); +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", - ], -); +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", - ], -); +export const ReadInterviewReportProblem = problemUnion("ReadInterviewReportProblem", [ + "credential.missing", + "credential.invalid", + "authority.denied", + "transaction.conflict", + "internal.error", + "recruitment.unavailable", +]); const interviewCommandCodes = [ "credential.missing", @@ -278,45 +271,36 @@ const interviewCommandCodes = [ ] 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", - ], -); +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", - ], -); +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", - ], -); +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, @@ -328,22 +312,21 @@ const conductCommandCodes = [ ] as const; /** Problems of `recruitment.finalizeInterview`. */ -export const FinalizeInterviewProblem = problemUnion( - "FinalizeInterviewProblem", - [...conductCommandCodes, "recruitment.invitation-not-accepted", "recruitment.conduct-invalid"], -); +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"], -); +export const CorrectInterviewAssessmentProblem = problemUnion("CorrectInterviewAssessmentProblem", [ + ...conductCommandCodes, + "recruitment.invitation-not-accepted", + "recruitment.conduct-invalid", +]); /** Problems of `recruitment.cancelInterview`. */ -export const CancelInterviewProblem = problemUnion( - "CancelInterviewProblem", - conductCommandCodes, -); +export const CancelInterviewProblem = problemUnion("CancelInterviewProblem", conductCommandCodes); /** Problems of the maintenance reads and command, which share one vocabulary. */ export const RecruitmentMaintenanceProblem = problemUnion("RecruitmentMaintenanceProblem", [ diff --git a/packages/rpc/src/team-application.ts b/packages/rpc/src/team-application.ts index 562bb0b2..bca425ba 100644 --- a/packages/rpc/src/team-application.ts +++ b/packages/rpc/src/team-application.ts @@ -165,13 +165,10 @@ export const ReadTeamApplicationIntake = Rpc.make("team-applications.readTeamApp }).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"))); +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 diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts index a5cb310e..abc3240f 100644 --- a/packages/rpc/test/contract.test.ts +++ b/packages/rpc/test/contract.test.ts @@ -11,13 +11,11 @@ import { } from "../src/index.js"; import { isProblem, Problem, problemUnion, rpcProblems } from "../src/problem.js"; -const allRpcs = [ - ...NativeRpcs.requests.values(), - ...InternalNativeRpcs.requests.values(), -]; +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; +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", () => { From 8b5930ca87d162839890aa196e321d2fb0df6662 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:29:09 +0000 Subject: [PATCH 056/148] build: remove the generate, OpenAPI, and SDK entries of the deleted HTTP contract Turbo no longer runs generate or api:spec tasks, the docs site no longer builds an OpenAPI page from packages/http-api, the Claude hook type checks packages/rpc instead of packages/sdk, and Oxlint drops the source import exemption that only the SDK needed. The layout reasons, layer roles, and check descriptions name RPC; the guides and command tables are regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .claude/settings.json | 4 ++-- .github/workflows/checks.yml | 5 ++--- AGENTS.md | 4 ++-- README.md | 4 ++-- apps/backend/src/admission/AGENTS.md | 4 ++-- apps/backend/src/application/AGENTS.md | 2 +- apps/backend/src/contact/AGENTS.md | 2 +- apps/backend/src/content/AGENTS.md | 2 +- apps/backend/src/delivery/AGENTS.md | 2 +- apps/backend/src/directory/AGENTS.md | 2 +- apps/backend/src/mail/AGENTS.md | 2 +- apps/backend/src/onboarding/AGENTS.md | 2 +- apps/backend/src/organization/AGENTS.md | 2 +- apps/backend/src/password-recovery/AGENTS.md | 2 +- apps/backend/src/placements/AGENTS.md | 2 +- apps/backend/src/profile/AGENTS.md | 4 ++-- apps/backend/src/receipt/AGENTS.md | 4 ++-- apps/backend/src/recruitment/AGENTS.md | 2 +- apps/backend/src/rpc/AGENTS.md | 2 +- apps/backend/src/social-events/AGENTS.md | 2 +- apps/backend/src/team-application/AGENTS.md | 2 +- apps/backend/src/test/AGENTS.md | 4 ++-- apps/dashboard/app/foldkit/content/AGENTS.md | 2 +- .../dashboard/app/foldkit/dashboard/AGENTS.md | 2 +- .../foldkit/dated-school-service/AGENTS.md | 2 +- .../dashboard/app/foldkit/interview/AGENTS.md | 2 +- .../app/foldkit/organization/AGENTS.md | 2 +- apps/dashboard/app/foldkit/profile/AGENTS.md | 2 +- .../foldkit/recruitment-maintenance/AGENTS.md | 2 +- .../app/foldkit/recruitment/AGENTS.md | 2 +- .../app/foldkit/scheduling/AGENTS.md | 2 +- apps/dashboard/app/foldkit/schools/AGENTS.md | 2 +- .../app/foldkit/social-events/AGENTS.md | 2 +- .../app/foldkit/team-applications/AGENTS.md | 2 +- apps/dashboard/vite.config.ts | 2 +- apps/dashboard/vitest.config.ts | 2 +- apps/docs/package.json | 4 ++-- apps/docs/vocs.config.ts | 14 +++---------- apps/homepage/vite.config.ts | 2 +- apps/homepage/vitest.config.ts | 2 +- justfile | 4 ++-- oxlint.config.ts | 21 ------------------- tools/conventions/src/layout.ts | 12 +++++------ turbo.json | 11 ++-------- 44 files changed, 61 insertions(+), 98 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index a422dbb2..86e09e97 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 57220a72..c3f33c6a 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/AGENTS.md b/AGENTS.md index 95a6d1c0..ceb6ae0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | diff --git a/README.md b/README.md index 1314a21e..99860899 100644 --- a/README.md +++ b/README.md @@ -132,8 +132,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. | diff --git a/apps/backend/src/admission/AGENTS.md b/apps/backend/src/admission/AGENTS.md index feb6c54a..7dfc00e4 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: diff --git a/apps/backend/src/application/AGENTS.md b/apps/backend/src/application/AGENTS.md index 5e51b8ad..473750da 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/contact/AGENTS.md b/apps/backend/src/contact/AGENTS.md index 3d854729..cad1b169 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/content/AGENTS.md b/apps/backend/src/content/AGENTS.md index d1eb3bc6..6e524492 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: diff --git a/apps/backend/src/delivery/AGENTS.md b/apps/backend/src/delivery/AGENTS.md index a9fa5682..d74c4c29 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/directory/AGENTS.md b/apps/backend/src/directory/AGENTS.md index 354422fa..afd1a89e 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/mail/AGENTS.md b/apps/backend/src/mail/AGENTS.md index 734d2e2b..e4918dec 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/onboarding/AGENTS.md b/apps/backend/src/onboarding/AGENTS.md index 0238ba2f..a3a104a7 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/organization/AGENTS.md b/apps/backend/src/organization/AGENTS.md index 7a796b15..4e6bd4ee 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: diff --git a/apps/backend/src/password-recovery/AGENTS.md b/apps/backend/src/password-recovery/AGENTS.md index 5c44c306..c6202816 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/placements/AGENTS.md b/apps/backend/src/placements/AGENTS.md index 1bd176d8..52e5506b 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: diff --git a/apps/backend/src/profile/AGENTS.md b/apps/backend/src/profile/AGENTS.md index 516cb93e..5f43c42b 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/receipt/AGENTS.md b/apps/backend/src/receipt/AGENTS.md index 593729c5..b46354d3 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: diff --git a/apps/backend/src/recruitment/AGENTS.md b/apps/backend/src/recruitment/AGENTS.md index 4204d0d8..52bf9f5c 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: diff --git a/apps/backend/src/rpc/AGENTS.md b/apps/backend/src/rpc/AGENTS.md index e6e7ade9..b707c19f 100644 --- a/apps/backend/src/rpc/AGENTS.md +++ b/apps/backend/src/rpc/AGENTS.md @@ -3,7 +3,7 @@ # 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 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. ## Entry points diff --git a/apps/backend/src/social-events/AGENTS.md b/apps/backend/src/social-events/AGENTS.md index bcd9b246..b6be1cb6 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/team-application/AGENTS.md b/apps/backend/src/team-application/AGENTS.md index dce32f04..7a3f5f4a 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/test/AGENTS.md b/apps/backend/src/test/AGENTS.md index 7e0df3fe..9cf3a3ae 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/dashboard/app/foldkit/content/AGENTS.md b/apps/dashboard/app/foldkit/content/AGENTS.md index 5bd71c3b..cefcbb7b 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/dashboard/AGENTS.md b/apps/dashboard/app/foldkit/dashboard/AGENTS.md index 867ef047..013c7c5b 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/dated-school-service/AGENTS.md b/apps/dashboard/app/foldkit/dated-school-service/AGENTS.md index 47852979..3abca2ee 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/interview/AGENTS.md b/apps/dashboard/app/foldkit/interview/AGENTS.md index 5978bcfa..f39e3191 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/organization/AGENTS.md b/apps/dashboard/app/foldkit/organization/AGENTS.md index 97da6cd9..762219aa 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/profile/AGENTS.md b/apps/dashboard/app/foldkit/profile/AGENTS.md index b7e3dbbb..d2bd3a8a 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/recruitment-maintenance/AGENTS.md b/apps/dashboard/app/foldkit/recruitment-maintenance/AGENTS.md index 70ccbddd..11ba1bec 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/AGENTS.md b/apps/dashboard/app/foldkit/recruitment/AGENTS.md index 957399d0..a731f7f5 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/scheduling/AGENTS.md b/apps/dashboard/app/foldkit/scheduling/AGENTS.md index 85fe7835..a9115178 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/schools/AGENTS.md b/apps/dashboard/app/foldkit/schools/AGENTS.md index 6457bf8f..3169ca6e 100644 --- a/apps/dashboard/app/foldkit/schools/AGENTS.md +++ b/apps/dashboard/app/foldkit/schools/AGENTS.md @@ -3,7 +3,7 @@ # 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: diff --git a/apps/dashboard/app/foldkit/social-events/AGENTS.md b/apps/dashboard/app/foldkit/social-events/AGENTS.md index 9472f04a..6cd3ec01 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/team-applications/AGENTS.md b/apps/dashboard/app/foldkit/team-applications/AGENTS.md index 9f8f6f07..65558026 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/vite.config.ts b/apps/dashboard/vite.config.ts index 6aa4eaa5..095d28cf 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -58,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 c6cceaa7..3bcb41b3 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 57210059..56219e47 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -5,8 +5,8 @@ "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" }, diff --git a/apps/docs/vocs.config.ts b/apps/docs/vocs.config.ts index 63b96657..7e352049 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/vite.config.ts b/apps/homepage/vite.config.ts index f314888d..98057a18 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 0ec1b1dd..443a8753 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/justfile b/justfile index 3ec797ee..6ad72dcf 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 "$@" diff --git a/oxlint.config.ts b/oxlint.config.ts index 1076948d..747e2360 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -230,15 +230,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|rpc)|[^./][^/]*)/src(/|$)", - message: crossPackageSourceImportMessage, - }, -]; - const productImportPatterns = [ ...crossPackageSourceImportPatterns, { @@ -336,18 +327,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/**"], diff --git a/tools/conventions/src/layout.ts b/tools/conventions/src/layout.ts index 65a81bd9..6cefca62 100644 --- a/tools/conventions/src/layout.ts +++ b/tools/conventions/src/layout.ts @@ -147,7 +147,7 @@ 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." }, @@ -158,10 +158,10 @@ export const contextLayers = { 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." }, @@ -185,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/turbo.json b/turbo.json index 8d5edc49..77bce73e 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, From b4051440b21fb0545c018345c6a8c0fd151746d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:35:26 +0000 Subject: [PATCH 057/148] docs: describe the RPC contract where documents and comments named the HTTP contract, SDK, or OpenAPI The effect-house overlay, the RPC contract conventions, the walkthrough, the module documentation guide, the enterprise and responsibility models, and the Receipt and Placements guides now name packages/rpc, its handlers, and its clients. Dead links to packages/http-api, packages/sdk, and apps/backend/src/http-api point at the files that hold the same facts. Where OpenAPI or the generated SDK was a stated capability, the text says that RPC has none. Code comments and test names that named the SDK name the RPC client; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .agents/skills/effect-house/SKILL.md | 18 +++++------ .../effect-house/references/boundaries.md | 10 +++--- .claude/rules/constitution.md | 18 +++++------ README.md | 8 +---- apps/backend/src/rpc/AGENTS.md | 2 +- apps/backend/src/rpc/problem.ts | 2 +- .../app/foldkit/recruitment/bridge.ts | 2 +- .../app/lib/admission-period-view.ts | 4 +-- apps/dashboard/app/lib/native-problem.test.ts | 18 +++++------ apps/dashboard/app/lib/native-problem.ts | 2 +- apps/dashboard/e2e/receipt-approval.spec.ts | 2 +- .../run-real-native-receipt-settlement.mjs | 4 +-- apps/homepage/test/public-application.test.ts | 2 +- docs/constructs.md | 4 +-- docs/constructs/rpc-problem.md | 10 +++--- docs/enterprise-models.md | 6 ++-- docs/module-developer-documentation.md | 16 +++++----- docs/operational-responsibility-map.md | 2 +- docs/system-walkthrough.mdx | 32 +++++++++---------- packages/domain/src/organization/schema.ts | 2 +- packages/domain/src/placements/README.md | 12 +++---- packages/domain/src/receipt/README.md | 17 +++++----- packages/rpc/AGENTS.md | 2 +- packages/rpc/src/access.ts | 2 +- packages/rpc/src/problem.ts | 10 +++--- tools/e2e/placement-check.ts | 2 +- .../verification/receipt-import-rehearsal.ts | 2 +- 27 files changed, 102 insertions(+), 109 deletions(-) diff --git a/.agents/skills/effect-house/SKILL.md b/.agents/skills/effect-house/SKILL.md index 3ba6f1fc..d699916e 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 e5cd7667..5db7c2e6 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 692aeafd..ab8d5523 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/README.md b/README.md index 99860899..9cffabe1 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. @@ -109,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. @@ -184,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/apps/backend/src/rpc/AGENTS.md b/apps/backend/src/rpc/AGENTS.md index b707c19f..062fc858 100644 --- a/apps/backend/src/rpc/AGENTS.md +++ b/apps/backend/src/rpc/AGENTS.md @@ -20,7 +20,7 @@ The shared constructs defined here. Each name links to its contract; [docs/const - [`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): HTTP command receipts: the transport's own persistence failures. +- [`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. diff --git a/apps/backend/src/rpc/problem.ts b/apps/backend/src/rpc/problem.ts index 74f8147b..b5b8023f 100644 --- a/apps/backend/src/rpc/problem.ts +++ b/apps/backend/src/rpc/problem.ts @@ -384,7 +384,7 @@ const commandReceiptCases = { } satisfies ProblemCases; /** - * HTTP command receipts: the transport's own persistence failures. + * Command receipts: the transport's own persistence failures. * * @remarks * An invalid stored receipt answers internal.error. A receipt persistence failure answers diff --git a/apps/dashboard/app/foldkit/recruitment/bridge.ts b/apps/dashboard/app/foldkit/recruitment/bridge.ts index 812be29f..3c4ddabf 100644 --- a/apps/dashboard/app/foldkit/recruitment/bridge.ts +++ b/apps/dashboard/app/foldkit/recruitment/bridge.ts @@ -173,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/lib/admission-period-view.ts b/apps/dashboard/app/lib/admission-period-view.ts index fe126a11..3824acc4 100644 --- a/apps/dashboard/app/lib/admission-period-view.ts +++ b/apps/dashboard/app/lib/admission-period-view.ts @@ -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/native-problem.test.ts b/apps/dashboard/app/lib/native-problem.test.ts index d3eef8e4..26aae4e5 100644 --- a/apps/dashboard/app/lib/native-problem.test.ts +++ b/apps/dashboard/app/lib/native-problem.test.ts @@ -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 851ccaeb..745492e0 100644 --- a/apps/dashboard/app/lib/native-problem.ts +++ b/apps/dashboard/app/lib/native-problem.ts @@ -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/e2e/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index ddf5c448..691c5fc5 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -93,7 +93,7 @@ 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 and reject undeclared members. */ const exactDecoding = { onExcessProperty: "error" } as const; const decodeReceiptResource = Schema.decodeUnknownSync(ReceiptResource); diff --git a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index eb78c9af..873fc7fc 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -1859,7 +1859,7 @@ async function main() { assert.equal( deliverySettlement.receiptId, deliveryCandidate.receiptId, - "SDK settlement response identifies the receipt", + "RPC settlement response identifies the receipt", ); const failedDeliveryEvidence = await eventually( @@ -2028,7 +2028,7 @@ async function main() { evidence = { topology: { dashboard: "production-react-router-server", - backend: "native-effect-http-api", + backend: "native-effect-rpc", client: "native-rpc-@vektorprogrammet/rpc", browser: "real-headless-chromium", database: "disposable-postgresql-local", diff --git a/apps/homepage/test/public-application.test.ts b/apps/homepage/test/public-application.test.ts index 93cb0999..de2fd404 100644 --- a/apps/homepage/test/public-application.test.ts +++ b/apps/homepage/test/public-application.test.ts @@ -98,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(); diff --git a/docs/constructs.md b/docs/constructs.md index eda49198..2e87e117 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -42,13 +42,13 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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): HTTP command receipts: the transport's own persistence failures. + - [`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 a closed endpoint-specific Problem Details union. + - [`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. diff --git a/docs/constructs/rpc-problem.md b/docs/constructs/rpc-problem.md index 8a29d45b..2c965902 100644 --- a/docs/constructs/rpc-problem.md +++ b/docs/constructs/rpc-problem.md @@ -689,7 +689,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 @@ -890,7 +890,7 @@ contract then promises an answer that the operation never gives. Mark it unreach ## `problemUnion` -Creates a closed endpoint-specific Problem Details union. +Creates the closed Problem Details union of one RPC. ```ts const problemUnion: { ]>(codes: Codes): (identifier: string) => Schema.Union>>; ]>(identifier: string, codes: Codes): Schema.Union>> } @@ -907,8 +907,8 @@ const problemUnion: { 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 35e9451a..57e9e30c 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 7d5a58b2..6380c479 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/system-walkthrough.mdx b/docs/system-walkthrough.mdx index 8d00c9d6..2185e7cc 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/packages/domain/src/organization/schema.ts b/packages/domain/src/organization/schema.ts index ab218528..7e90d1e4 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/placements/README.md b/packages/domain/src/placements/README.md index 66c27c02..65396b05 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/receipt/README.md b/packages/domain/src/receipt/README.md index 673e273d..e5b5b194 100644 --- a/packages/domain/src/receipt/README.md +++ b/packages/domain/src/receipt/README.md @@ -36,8 +36,8 @@ 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 [RPC contract](../../../rpc/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. +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 @@ -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/rpc/AGENTS.md b/packages/rpc/AGENTS.md index 41c70d54..594d2e56 100644 --- a/packages/rpc/AGENTS.md +++ b/packages/rpc/AGENTS.md @@ -18,7 +18,7 @@ Package `@vektorprogrammet/rpc`. The shared constructs defined here. Each name links to its contract; [docs/constructs.md](../../docs/constructs.md) indexes them all. -- [`problemUnion`](../../docs/constructs/rpc-problem.md#problemunion) (rpc-problem): Creates a closed endpoint-specific Problem Details union. +- [`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. diff --git a/packages/rpc/src/access.ts b/packages/rpc/src/access.ts index 2937754c..da7de898 100644 --- a/packages/rpc/src/access.ts +++ b/packages/rpc/src/access.ts @@ -268,7 +268,7 @@ 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(",")}`, ); } }; diff --git a/packages/rpc/src/problem.ts b/packages/rpc/src/problem.ts index e6bf333b..24f952d4 100644 --- a/packages/rpc/src/problem.ts +++ b/packages/rpc/src/problem.ts @@ -985,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 * @@ -1001,7 +1001,7 @@ 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 rpc-problem */ @@ -1194,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, diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index b4f20351..f0016c48 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -2946,7 +2946,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", diff --git a/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index dbfda237..924ef351 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -4,7 +4,7 @@ import { sha256Hex, } from "@vektorprogrammet/domain/shared-kernel"; import { ReceiptOutboxRequestSchema } from "@vektorprogrammet/domain/receipt"; -/** 0095: owned local PostgreSQL/auth/SDK/files import and restore rehearsal. */ +/** 0095: owned local PostgreSQL/auth/RPC/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"; From ac606bc6fb2ca97d695020ee24c4fdcb71cd84fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:36:06 +0000 Subject: [PATCH 058/148] docs(specs): point active specs at the RPC files that replaced http-api paths Only mechanical path references change; the substance of each spec stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/architecture-consolidation.md | 6 +++--- docs/specs/authz-evidence.md | 2 +- docs/specs/certificates-days-served.md | 2 +- docs/specs/command-grammar.md | 2 +- docs/specs/commands-and-concurrency.md | 2 +- docs/specs/repository-conventions.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/specs/architecture-consolidation.md b/docs/specs/architecture-consolidation.md index 4e8508b5..6c52b5c8 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 index 36a81adc..73af03c5 100644 --- a/docs/specs/authz-evidence.md +++ b/docs/specs/authz-evidence.md @@ -173,7 +173,7 @@ Measured on `c2bb975`, with PostgreSQL 18: 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/http-api/problem.ts`. They check +- `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. diff --git a/docs/specs/certificates-days-served.md b/docs/specs/certificates-days-served.md index 6a6dae5f..75bfb58f 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 947b14ad..660473d7 100644 --- a/docs/specs/command-grammar.md +++ b/docs/specs/command-grammar.md @@ -68,7 +68,7 @@ Observed on 2026-09-27 at `8d1b7e09` and at `b3193cb1`, which adds the hook conf | 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` | | 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 de280770..da3d137a 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 36f17065..d60afb36 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. From 0c52d639ba349629a845595823872b35ae624668 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:43:49 +0000 Subject: [PATCH 059/148] fix(backend): answer each health probe with its own response body The health route answered with one web Response built when the router was built, so the second probe read a locked body and the backend process crashed ("ReadableStream is locked"). Journey runners probe health in their readiness loop, which crashed the backend in the schools, content-publication, and social-events journeys. The backend test harness builds a router per request, which hid the defect; backendTestRouterLayer lets a test serve several requests on one router, as the process does. Red before the fix, green after. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/router.ts | 3 ++- apps/backend/src/rpc/router.test.ts | 26 +++++++++++++++++++++++++- apps/backend/src/test/native-rpc.ts | 19 +++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index b172d6cd..3236370d 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -211,7 +211,8 @@ const healthRoute = HttpRouter.use((router) => "GET", "/health", databaseHealth.pipe( - Effect.as(HttpServerResponse.fromWeb(jsonResponse({ status: "ok" }))), + // 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"))), ), diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts index 3c1359ad..ba66b8de 100644 --- a/apps/backend/src/rpc/router.test.ts +++ b/apps/backend/src/rpc/router.test.ts @@ -4,10 +4,15 @@ 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 { makeBackendTestRpc, testAuthHandler } from "../test/native-rpc.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, { @@ -53,6 +58,25 @@ it.effect("the health probe stays plain HTTP", () => }), ); +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")); diff --git a/apps/backend/src/test/native-rpc.ts b/apps/backend/src/test/native-rpc.ts index 70685cc3..43c4bdc9 100644 --- a/apps/backend/src/test/native-rpc.ts +++ b/apps/backend/src/test/native-rpc.ts @@ -142,6 +142,20 @@ export interface BackendTestRpcOptions { * * 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, @@ -149,10 +163,7 @@ export const makeBackendTestRpc = ({ options = {}, transportHeaders = {}, }: BackendTestRpcOptions) => { - const routerLayer = ExternalNativeRpcRouterLive({ ...options, config }).pipe( - Layer.provideMerge(completeServices(services)), - Layer.provideMerge(platform), - ); + const routerLayer = backendTestRouterLayer({ config, services, options }); const native = serveEachRequest(() => HttpRouter.toWebHandler(routerLayer, { disableLogger: true }), From 1021575d1ec4398a6951e9da2f9c8a2a0a095a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:46:36 +0000 Subject: [PATCH 060/148] test(dashboard): export the RPC endpoint path predicate of the native recorder A recorder that matched the endpoint with its own comparison missed the trailing slash that the RPC client's HTTP client adds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/native-operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 30c04de2..30c04b58 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -116,7 +116,7 @@ export const nativeRpcStatus = (body: string): number | undefined => { }; /** The RPC client posts to the endpoint URL, which its HTTP client may end with one slash. */ -const isRpcPath = (pathname: string): boolean => +export const isRpcPath = (pathname: string): boolean => pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; /** From e62937131090dde8e47f2b21f93942c75b5d7ec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:46:45 +0000 Subject: [PATCH 061/148] test(e2e): export isNativeRpcPath for recorders that match the RPC endpoint The RPC client's HTTP client posts to /api/rpc/ (prependUrl inserts one slash), so recorders that compared the path with nativeRpcPath alone recorded no RPC tag and never forced their outage exit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/native-operations.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 30c04de2..4326bfde 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -115,8 +115,12 @@ export const nativeRpcStatus = (body: string): number | undefined => { ); }; -/** The RPC client posts to the endpoint URL, which its HTTP client may end with one slash. */ -const isRpcPath = (pathname: string): boolean => +/** + * Whether a request path addresses the RPC endpoint. The RPC client posts to the endpoint URL, and + * its HTTP client ends that URL with one slash (`/api/rpc/`), so a recorder that compares the path + * with `nativeRpcPath` alone misses every RPC. + */ +export const isNativeRpcPath = (pathname: string): boolean => pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; /** @@ -127,7 +131,7 @@ const isRpcPath = (pathname: string): boolean => export const isNativeOperation = (method: string, pathname: string, body?: string): boolean => { if (method === "GET" && pathname === "/health") return true; - if (method !== "POST" || !isRpcPath(pathname)) return false; + if (method !== "POST" || !isNativeRpcPath(pathname)) return false; if (body === undefined) return true; From 76999e4c3626271590bd8792031a47ef26fa8e00 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:47:16 +0000 Subject: [PATCH 062/148] chore(journeys): export the RPC endpoint path predicate for recorders The RPC client prepends the endpoint URL to an empty request URL, so the dashboard server posts to /api/rpc/ while script clients post to /api/rpc. Recorders that compared the path with one spelling missed every dashboard RPC. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/native-operations.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 30c04de2..6b4695af 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -115,8 +115,12 @@ export const nativeRpcStatus = (body: string): number | undefined => { ); }; -/** The RPC client posts to the endpoint URL, which its HTTP client may end with one slash. */ -const isRpcPath = (pathname: string): boolean => +/** + * Whether a request path is the RPC endpoint. The RPC client prepends the endpoint URL to an empty + * request URL, so it posts to `/api/rpc/`; a probe or script client posts to `/api/rpc`. A recorder + * asks this rather than comparing the path with one spelling. + */ +export const isNativeRpcPath = (pathname: string): boolean => pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; /** @@ -127,7 +131,7 @@ const isRpcPath = (pathname: string): boolean => export const isNativeOperation = (method: string, pathname: string, body?: string): boolean => { if (method === "GET" && pathname === "/health") return true; - if (method !== "POST" || !isRpcPath(pathname)) return false; + if (method !== "POST" || !isNativeRpcPath(pathname)) return false; if (body === undefined) return true; From ab040bcf4361fa2416127864358d69e8a7bd2c65 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:47:54 +0000 Subject: [PATCH 063/148] test(receipts): decode approval RPC values with the contract's JSON codec and run the owner journey on the toolchain node The approval journey decoded RPC success values with the Type side of the contract schemas, so a receipt file's base64 bytes failed Schema.Uint8Array. It now decodes with Schema.toCodecJson, the codec that RPC serialization derives. The owner runner wrapped Playwright in nix shell nixpkgs#nodejs_24, which copies a node major that devenv already derives from the manifest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/receipt-approval.spec.ts | 15 ++++++++++----- apps/dashboard/e2e/run-real-receipt-owner.mjs | 7 ++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/e2e/receipt-approval.spec.ts b/apps/dashboard/e2e/receipt-approval.spec.ts index 691c5fc5..f4ba5e2c 100644 --- a/apps/dashboard/e2e/receipt-approval.spec.ts +++ b/apps/dashboard/e2e/receipt-approval.spec.ts @@ -93,16 +93,21 @@ const PDF_RECEIPT_FILE = { name: "receipt.pdf", } as const; -/** Receipt bodies decode through the RPC 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 decodeReceiptFile = Schema.decodeUnknownSync(ReceiptFileContent); +const decodeReceiptFile = Schema.decodeUnknownSync(Schema.toCodecJson(ReceiptFileContent)); const fileIdentitySchema = z.array( z diff --git a/apps/dashboard/e2e/run-real-receipt-owner.mjs b/apps/dashboard/e2e/run-real-receipt-owner.mjs index b21225cc..bf38e4ab 100644 --- a/apps/dashboard/e2e/run-real-receipt-owner.mjs +++ b/apps/dashboard/e2e/run-real-receipt-owner.mjs @@ -709,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", From 2a5cde05507c4325435703a5c17f23b51aa90fdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:50:32 +0000 Subject: [PATCH 064/148] fix(e2e): answer an RPC defect as 500 in the replaced-route ledger A payload that its schema refuses fails in the RPC server as an exit whose cause is a Die, not a Fail with a problem. replacedAnswer decoded only a single Fail reason, so such an answer passed through at HTTP 200 and probes read a refused payload as a success. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/native-rpc-ledger.ts | 35 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/e2e/native-rpc-ledger.ts b/apps/dashboard/e2e/native-rpc-ledger.ts index 3d4693ed..16e01853 100644 --- a/apps/dashboard/e2e/native-rpc-ledger.ts +++ b/apps/dashboard/e2e/native-rpc-ledger.ts @@ -8,7 +8,7 @@ * 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 { Match, Option, Predicate, Schema } from "effect"; +import { Array as Arr, Match, Option, Predicate, Schema } from "effect"; type Route = readonly [method: "GET" | "POST", path: string]; @@ -137,13 +137,16 @@ const RpcAnswer = Schema.Tuple([ Schema.Struct({ exit: Schema.Union([ Schema.TaggedStruct("Success", { value: Schema.Json }), - Schema.TaggedStruct("Failure", { - cause: Schema.Tuple([Schema.Struct({ error: 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([ @@ -188,8 +191,9 @@ const unwrapTagged = (value: Schema.Json): ReplacedAnswer => /** * The answer of the RPC response that a proxied body carries: a success answers its value at 200, - * and a failure answers its problem at the registry status of the problem's code. `undefined` for - * a body that is no RPC response. + * 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), { @@ -197,14 +201,19 @@ export const replacedAnswer = (responseJson: Schema.Json | undefined): ReplacedA onSome: ([{ exit }]) => Match.value(exit).pipe( Match.tag("Success", ({ value }) => unwrapTagged(value)), - Match.tag("Failure", ({ cause: [{ error }] }) => ({ - status: Option.match(Schema.decodeUnknownOption(ProblemStatus)(error), { - onNone: () => 500, - onSome: ({ status }) => status, + 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, + }), }), - responseJson: error, - responseEtag: null, - })), + ), Match.exhaustive, ), }); From e45667e619763a4ed1e9ea5fae9dc8d3b87456bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:50:32 +0000 Subject: [PATCH 065/148] fix(acceptance): read recommendation refusals and report denials over RPC The finalization probe expects the server's payload defect (500, Die cause) where HTTP answered validation.failed; the correction boundary expects the protocol Defect message for a body that is no RPC message where HTTP answered request.malformed; the report denials read the problem code from the RPC problem rather than from the success value. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../interview-correction-boundaries.ts | 25 +++++++++++---- .../interview-report-observation.ts | 32 +++++++++++++------ tools/acceptance/recommendation-check.ts | 32 +++++++++++-------- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/tools/acceptance/interview-correction-boundaries.ts b/tools/acceptance/interview-correction-boundaries.ts index 4238b43b..df268e6b 100644 --- a/tools/acceptance/interview-correction-boundaries.ts +++ b/tools/acceptance/interview-correction-boundaries.ts @@ -5,7 +5,7 @@ import { replacedHttpResponse, } from "../../apps/dashboard/e2e/native-rpc-ledger.js"; import { RecruitmentInterviewConductObservationSchema } from "../../packages/domain/src/recruitment/schema.js"; -import { Schema } from "effect"; +import { Option, Schema } from "effect"; import assert from "node:assert/strict"; import { createHash, randomBytes } from "node:crypto"; import type { Pool, PoolClient } from "pg"; @@ -827,6 +827,10 @@ export async function assertInterviewCorrectionBoundaries( "missing-question-entry", ]); + const decodeProtocolDefect = Schema.decodeOption( + Schema.fromJsonString(Schema.Tuple([Schema.TaggedStruct("Defect", {})])), + ); + for (const [name, invalid] of invalidCases) { const before = await snapshot(); @@ -848,12 +852,21 @@ export async function assertInterviewCorrectionBoundaries( status(`invalid:${name}`, response.status); // 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), and a body - // that is no RPC message is refused by the server's transport. - const expected = semanticInvalidCases.has(name) ? 422 : name === "malformed-json" ? -1 : 500; + // outside its schema fails in the RPC server before the handler, as a defect (500). A body that + // is no RPC message is refused by the server's transport: the RPC protocol answers one Defect + // message and no RPC exit, where the HTTP route answered request.malformed (400). + if (name === "malformed-json") { + const refusal = await response.text(); + + assert.ok(Option.isSome(decodeProtocolDefect(refusal)), refusal); + } else { + assert.equal( + response.status, + semanticInvalidCases.has(name) ? 422 : 500, + await response.text(), + ); + } - if (expected === -1) assert.ok(response.status >= 400, await response.text()); - else assert.equal(response.status, expected, await response.text()); await assertSnapshot(before, `invalid ${name}`); } diff --git a/tools/acceptance/interview-report-observation.ts b/tools/acceptance/interview-report-observation.ts index 75d7b2dd..aca9e829 100644 --- a/tools/acceptance/interview-report-observation.ts +++ b/tools/acceptance/interview-report-observation.ts @@ -14,6 +14,7 @@ import { import { Match, Predicate, Schema } from "effect"; import { nativeRpcPath } from "../../packages/rpc/src/api.js"; import { + nativeRpcOutcome, nativeRpcRequestBody, nativeRpcStatus, nativeRpcValue, @@ -328,20 +329,31 @@ export async function observeInterviewReport(o: Options) { headers, body: nativeRpcRequestBody(tag, payload), }).then((response) => - response.text().then((text) => ({ - status: nativeRpcStatus(text) ?? response.status, - value: nativeRpcValue(text), - text, - })), + response.text().then((text) => { + const outcome = nativeRpcOutcome(text); + + return { + status: nativeRpcStatus(text) ?? response.status, + value: nativeRpcValue(text), + problem: Predicate.isTagged(outcome, "Problem") ? outcome.problem : undefined, + text, + }; + }), ); }; const get = (query: Record = {}, session = cookie) => rpc("recruitment.readInterviewReport", query, session); - /** The code of the problem that an RPC answered. */ - const problemCode = (value: Schema.Json | undefined) => - Schema.decodeUnknownSync(Schema.Struct({ code: Schema.String }))(value).code; + /** 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 read = async (query: Record = {}): Promise => { const response = await get(query); @@ -624,7 +636,7 @@ export async function observeInterviewReport(o: Options) { const status = denial.status; assert.ok([401, 403].includes(status)); - const code = problemCode(denial.value); + const code = problemCode(denial); assert.ok(["authority.denied", "credential.invalid"].includes(code)); authorityDenials.push({ condition: setup, status, code }); assert.deepEqual(await snapshot(), baseline); @@ -659,7 +671,7 @@ export async function observeInterviewReport(o: Options) { authorityDenials.push({ condition: "ambiguous-department", status: ambiguousStatus, - code: problemCode(ambiguousResponse.value), + code: problemCode(ambiguousResponse), }); await pool.query( `DELETE FROM public.organization_memberships WHERE membership_id='report-ambiguous-membership'`, diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index a560156a..672ffbe9 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -63,7 +63,7 @@ import { } 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 { Array as Arr, Option, Predicate, Schema } from "effect"; 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"; @@ -2176,21 +2176,27 @@ try { const before = await 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 }; - assert.equal( - ( - await post( - id, - body, - [fixtureKeys.invalid0, fixtureKeys.invalid1, fixtureKeys.invalid2, fixtureKeys.invalid3][ - i - ]!, - etag, - ) - ).status, - 422, + + const refused = await post( + id, + body, + [fixtureKeys.invalid0, fixtureKeys.invalid1, fixtureKeys.invalid2, fixtureKeys.invalid3][i]!, + etag, ); + + const refusal = await refused.text(); + + assert.equal(refused.status, 500, refusal); + + assert.ok(Option.isSome(decodeDieCause(refusal)), refusal); } assert.equal(await lifecycleSnapshot(), before); From ac5ae88a90dcfde72a4e687cdf60d86b0a1041c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:50:39 +0000 Subject: [PATCH 066/148] test(e2e): record the RPC endpoint with its trailing slash in the schools and content journeys, and port the social-events runner to RPC The RPC client posts to /api/rpc/, so the schools and content recorders read no RPC tag, never forced their outage exit, and the dashboard never showed its unavailable state. The social-events runner still called the deleted HTTP routes; it now calls each RPC through the script client and records each RPC by its tag, payload, credential facts, and exit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/native-content-publication.spec.ts | 5 +- .../e2e/native-schools-directory.spec.ts | 7 +- .../run-real-native-content-publication.mjs | 5 +- .../e2e/run-real-native-schools-directory.mjs | 3 +- .../e2e/run-real-native-social-events.mjs | 228 ++++++++++-------- 5 files changed, 138 insertions(+), 110 deletions(-) diff --git a/apps/dashboard/e2e/native-content-publication.spec.ts b/apps/dashboard/e2e/native-content-publication.spec.ts index a206c7cb..5d555c73 100644 --- a/apps/dashboard/e2e/native-content-publication.spec.ts +++ b/apps/dashboard/e2e/native-content-publication.spec.ts @@ -5,6 +5,7 @@ import { Option, Predicate, Schema } from "effect"; import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { ContentArticleObservationSchema, ContentBridgeActionSchema } from "../app/foldkit/content/bridge"; import { + isNativeRpcPath, nativeRpcOutcome, nativeRpcRequestBody, nativeRpcStatus, @@ -127,7 +128,7 @@ const openContext = async ( // An RPC names its operation and carries its key and entity tag in its payload. const rpc = - request.method() === "POST" && url.pathname === nativeRpcPath + request.method() === "POST" && isNativeRpcPath(url.pathname) ? { tag: nativeRpcTag(request.postData() ?? ""), facts: rpcPayloadFacts(request.postData()) } : undefined; @@ -553,7 +554,7 @@ test.describe("Native Content publication (spec 0062)", () => { ); const nativeContentRequests = browserRequests.filter( - (request) => request.pathname === nativeRpcPath && request.rpcTag?.startsWith("content.") === true, + (request) => isNativeRpcPath(request.pathname) && request.rpcTag?.startsWith("content.") === true, ); const publicRequests = browserRequests.filter( diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index 36149e56..08a9170d 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -5,6 +5,7 @@ import { Option, Schema } from "effect"; import { DepartmentId } from "@vektorprogrammet/domain"; import { NativeProblem, nativeRpcPath } from "@vektorprogrammet/rpc"; import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; +import { isNativeRpcPath } from "./native-operations.js"; import { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; const realNativeIdentity = process.env.REAL_NATIVE_IDENTITY_E2E === "1"; @@ -309,7 +310,7 @@ test.describe("Native Schools directory (spec 0061)", () => { const rejection = denied.page.waitForResponse( (response) => - new URL(response.url()).pathname === nativeRpcPath && + isNativeRpcPath(new URL(response.url()).pathname) && rpcTagOf(response.request().postData()) === listSchoolsTag, ); @@ -335,7 +336,7 @@ test.describe("Native Schools directory (spec 0061)", () => { const directoryRequests = browserRequests.filter( (request) => request.method === "POST" && - request.pathname === nativeRpcPath && + isNativeRpcPath(request.pathname) && request.rpcTag === listSchoolsTag, ); @@ -356,7 +357,7 @@ test.describe("Native Schools directory (spec 0061)", () => { rpcTag: listSchoolsTag, directoryRequests, directoryResponses: browserResponses.filter( - (response) => response.pathname === nativeRpcPath && response.rpcTag === listSchoolsTag, + (response) => isNativeRpcPath(response.pathname) && response.rpcTag === listSchoolsTag, ), observations, pageErrors, diff --git a/apps/dashboard/e2e/run-real-native-content-publication.mjs b/apps/dashboard/e2e/run-real-native-content-publication.mjs index 80e5ee5a..bd729a3b 100644 --- a/apps/dashboard/e2e/run-real-native-content-publication.mjs +++ b/apps/dashboard/e2e/run-real-native-content-publication.mjs @@ -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)); @@ -316,7 +317,7 @@ const startRecordingUpstream = async (ledger) => { try { const body = await requestBody(request); - const rpcRequest = url.pathname === "/api/rpc" ? parseRpcRequest(body) : null; + const rpcRequest = isNativeRpcPath(url.pathname) ? parseRpcRequest(body) : null; if (rpcRequest !== null) { entry.rpcTag = rpcRequest.tag; @@ -619,7 +620,7 @@ try { assert.deepEqual( ledger.filter( (entry) => - entry.pathname === "/api/rpc" && entry.method !== "OPTIONS" && entry.rpcTag === null, + isNativeRpcPath(entry.pathname) && entry.method !== "OPTIONS" && entry.rpcTag === null, ), [], "every request to the RPC endpoint must carry one RPC request", diff --git a/apps/dashboard/e2e/run-real-native-schools-directory.mjs b/apps/dashboard/e2e/run-real-native-schools-directory.mjs index 9638a52c..429ccda3 100644 --- a/apps/dashboard/e2e/run-real-native-schools-directory.mjs +++ b/apps/dashboard/e2e/run-real-native-schools-directory.mjs @@ -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)); @@ -362,7 +363,7 @@ const startRecordingUpstream = async (ledger) => { try { const body = await requestBody(request); - const rpcRequest = pathname === "/api/rpc" ? parseRpcRequest(body) : null; + const rpcRequest = isNativeRpcPath(pathname) ? parseRpcRequest(body) : null; entry.rpcTag = rpcRequest?.tag ?? null; entry.rpcPayload = rpcRequest?.payload ?? null; diff --git a/apps/dashboard/e2e/run-real-native-social-events.mjs b/apps/dashboard/e2e/run-real-native-social-events.mjs index 5dfae372..d4f25182 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; @@ -220,6 +223,16 @@ const requestBody = async (request) => { return chunks.length === 0 ? undefined : Buffer.concat(chunks); }; +/** The one RPC request that a request body carries: its tag and payload. */ +const RpcRequestBody = Schema.fromJsonString( + Schema.Struct({ tag: Schema.String, payload: Schema.Unknown }), +); + +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); @@ -236,22 +249,22 @@ const startRecordingProxy = async (ledger) => { } } - let requestJson = null; - - if (body !== undefined && headers.get("content-type")?.includes("json")) { - requestJson = JSON.parse(body.toString("utf8")); - } + // 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; 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: (headers.get("cookie") ?? "").includes("better-auth.session_token="), + authorizationHeader: headers.has("authorization"), status: 0, responseHeaders: {}, - responseJson: null, + outcome: null, }; ledger.push(entry); @@ -268,9 +281,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; @@ -388,24 +400,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, @@ -419,21 +442,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) => @@ -501,17 +521,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(); @@ -551,23 +571,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", ); @@ -606,8 +632,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], @@ -633,15 +659,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"); @@ -660,15 +686,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(); @@ -677,31 +702,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"); @@ -709,14 +727,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" }); @@ -725,13 +743,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(); @@ -762,10 +786,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); @@ -776,7 +800,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"; @@ -826,9 +850,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"); @@ -847,19 +871,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", ); @@ -947,14 +965,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, @@ -1087,6 +1110,7 @@ try { } finally { try { if (browser !== undefined) await browser.close(); + await native.dispose(); await stop(dashboard); await closeServer(proxy); await stop(backend); From c2d437644d5801a9768c159087df7c7090b20c9f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:00:21 +0000 Subject: [PATCH 067/148] test(e2e): post the schools empty-department probe from the browser with its own session cookie The HTTP-era probe fetched from the page; the port sent it through the script client, whose cookie travels in the RPC message headers, so the runner saw a listSchools request without a session cookie. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/native-schools-directory.spec.ts | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index 08a9170d..d409d3b5 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -4,8 +4,12 @@ import { expect, test, type Browser, type BrowserContext, type Page } from "@pla import { Option, Schema } from "effect"; import { DepartmentId } from "@vektorprogrammet/domain"; import { NativeProblem, nativeRpcPath } from "@vektorprogrammet/rpc"; -import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; -import { isNativeRpcPath } from "./native-operations.js"; +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"; @@ -18,8 +22,6 @@ const departments = { empty: DepartmentId.make("schools-e2e-0061-department-empty"), } as const; -const apiOrigin = process.env.API_URL ?? "http://127.0.0.1:8790"; - /** The RPC that the browser read the school directory with. */ const listSchoolsTag = "directory.listSchools"; @@ -156,7 +158,6 @@ test.describe("Native Schools directory (spec 0061)", () => { test("proves the authority matrix, Foldkit interactions, retry, and request confinement", async ({ browser, - baseURL, }) => { const browserRequests: BrowserRequest[] = []; const browserResponses: BrowserResponse[] = []; @@ -233,20 +234,28 @@ test.describe("Native Schools directory (spec 0061)", () => { administrator.page.getByRole("rowheader", { name: "Historisk Internasjonal" }), ).toBeVisible(); - const native = nativeScriptClient(apiOrigin); - - const emptyDepartment = await native - .call( - { - cookie: (await administrator.context.cookies()) - .map(({ name, value }) => `${name}=${value}`) - .join("; "), - origin: new URL(baseURL ?? administrator.page.url()).origin, - }, - (client) => client[listSchoolsTag]({ departmentId: departments.empty }), - ) - .finally(() => native.dispose()) - .then((result) => ({ status: result.status, body: result.ok ? result.value : result.code })); + // 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: { "content-type": "application/json" }, + body, + }); + + return response.text(); + }, + { + path: nativeRpcPath, + body: nativeRpcRequestBody(listSchoolsTag, { departmentId: departments.empty }), + }, + ); + + const emptyDepartment = { + status: nativeRpcStatus(emptyDepartmentText), + body: nativeRpcValue(emptyDepartmentText), + }; expect(emptyDepartment).toEqual({ status: 200, From b278fe8b2a494fcab7692f0d14f151bca038ef7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:00:48 +0000 Subject: [PATCH 068/148] test(receipts): record the dashboard server's RPCs at the endpoint path with its trailing slash The Effect RPC HTTP client posts to /api/rpc/, which the backend and the shared recorder accept. The approval and settlement recording proxies compared the path to /api/rpc exactly, so every RPC that the dashboard server sent (file reads, settlement) went unrecorded as its operation. They now ask isNativeOperation, as the shared recorder does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/run-real-native-receipt-settlement.mjs | 9 ++++++++- apps/dashboard/e2e/run-real-receipt-approval.mjs | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index 873fc7fc..1adf67f9 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -17,6 +17,7 @@ import { nativeRpcPath } from "@vektorprogrammet/rpc"; import { dashboardMount } from "../dashboard-base.ts"; import { journeyClock } from "../../../tools/e2e/journey-clock.ts"; import { + isNativeOperation, isNativeRequest, nativeRpcOutcome, nativeRpcRequestBody, @@ -382,7 +383,13 @@ async function startRecordingProxy(targetOrigin) { // 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. - const rpc = url.pathname === nativeRpcPath ? parseRpcRequest(parseJson(requestBody)) : undefined; + // 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] = diff --git a/apps/dashboard/e2e/run-real-receipt-approval.mjs b/apps/dashboard/e2e/run-real-receipt-approval.mjs index 496f4a4a..215d3a28 100644 --- a/apps/dashboard/e2e/run-real-receipt-approval.mjs +++ b/apps/dashboard/e2e/run-real-receipt-approval.mjs @@ -12,7 +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 { nativeRpcStatus, nativeRpcValue } from "./native-operations.ts"; +import { isNativeOperation, nativeRpcStatus, nativeRpcValue } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -403,8 +403,13 @@ async function startRecordingProxy(targetOrigin) { const requestBytes = Buffer.concat(chunks); // 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. - const rpc = url.pathname === "/api/rpc" ? parseRpcRequest(parseJsonBody(requestBytes)) : undefined; + // 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"; From 062251f495920f57340637054d377e65218aa519 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:51 +0000 Subject: [PATCH 069/148] test(dashboard): run the Profile journey runner with Bun Node cannot resolve the .js specifiers of the RPC package's TypeScript source, which the runner now imports through the native recorder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1dcdbb06..39925067 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -41,7 +41,7 @@ "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", From 6c0688b5535fdd38653d710dbc665ae60244cc25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:51 +0000 Subject: [PATCH 070/148] test(dashboard): record admission RPCs at the endpoint path with its trailing slash The ledger matched only /api/rpc, so it recorded no browser command. It now uses the recorder's endpoint predicate and RPC outcome decoder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../run-real-admission-period-management.mjs | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/dashboard/e2e/run-real-admission-period-management.mjs b/apps/dashboard/e2e/run-real-admission-period-management.mjs index 5cb61605..c400a4df 100644 --- a/apps/dashboard/e2e/run-real-admission-period-management.mjs +++ b/apps/dashboard/e2e/run-real-admission-period-management.mjs @@ -1,7 +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 { nativeRpcPath } from "@vektorprogrammet/rpc"; +import { isRpcPath, nativeRpcOutcome } from "./native-operations.ts"; import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { appendFileSync } from "node:fs"; @@ -396,25 +397,19 @@ const rpcRequestEntry = (bytes) => { /** * The outcome of one RPC answer: 200 for a success, the registry status and code of a declared - * problem, and 500 for a defect. + * problem, and 500 for a defect or a body that is no RPC answer. */ const rpcAnswerOutcome = (bytes) => { - const answers = parseJsonBody(bytes); - const answer = Array.isArray(answers) ? answers[0] : null; - const exit = Predicate.hasProperty(answer, "exit") ? answer.exit : null; - - if (Predicate.isTagged(exit, "Success")) return { status: 200, problemCode: null }; - - const cause = Predicate.hasProperty(exit, "cause") && Array.isArray(exit.cause) ? exit.cause : []; - const failure = cause.find((reason) => Predicate.isTagged(reason, "Fail")); - const problem = Predicate.hasProperty(failure, "error") ? failure.error : null; - - return Predicate.hasProperty(problem, "code") && - Predicate.isString(problem.code) && - Predicate.hasProperty(problem, "status") && - Predicate.isNumber(problem.status) - ? { status: problem.status, problemCode: problem.code } - : { status: 500, problemCode: "defect" }; + 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, + ); }; /** @@ -439,7 +434,7 @@ async function startRecordingProxy(ledgerPath) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); - const isRpc = method === "POST" && url.pathname === nativeRpcPath; + const isRpc = method === "POST" && isRpcPath(url.pathname); const entry = { method: isRpc ? "RPC" : method, From 4a7523c07a654788a421c3eaf918e98b80b7aabd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:52 +0000 Subject: [PATCH 071/148] test(dashboard): classify Organization RPCs at the endpoint path with its trailing slash Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/run-real-native-organization-administration.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/e2e/run-real-native-organization-administration.mjs b/apps/dashboard/e2e/run-real-native-organization-administration.mjs index 63e0f592..ee3c60e9 100644 --- a/apps/dashboard/e2e/run-real-native-organization-administration.mjs +++ b/apps/dashboard/e2e/run-real-native-organization-administration.mjs @@ -10,6 +10,7 @@ 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 { isRpcPath } from "./native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); @@ -496,7 +497,8 @@ async function startRecordingProxy(targetOrigin) { status: 0, }; - const rpc = url.pathname === "/api/rpc" ? parseRpcRequest(record.request) : undefined; + const rpc = + method === "POST" && isRpcPath(url.pathname) ? parseRpcRequest(record.request) : undefined; // A native RPC is recorded as the route it replaced, with its key and request from the payload. if (rpc !== undefined) { From e4dfce015cad6337972dc36b9f042c216a8af461 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:04:52 +0000 Subject: [PATCH 072/148] test(homepage): redirect only the contact command RPC in the contact journey Every native read is an RPC POST as well, so redirecting every POST broke the department read before the command and answered the action with 503. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/homepage/e2e/run-native-contact.mjs | 26 +++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/homepage/e2e/run-native-contact.mjs b/apps/homepage/e2e/run-native-contact.mjs index 45569adc..22122435 100644 --- a/apps/homepage/e2e/run-native-contact.mjs +++ b/apps/homepage/e2e/run-native-contact.mjs @@ -12,10 +12,21 @@ import { fileURLToPath } from "node:url"; import { reserveLoopbackPorts, startDisposablePostgres } from "@monoweb/postgres"; import { chromium, expect } from "@playwright/test"; import AxeBuilder from "@axe-core/playwright"; -import { Predicate, Schema } from "effect"; +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); @@ -275,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 } }); } @@ -283,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({ From 19eee93288df0b9d369c9c3deb850f7d31df8314 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:08:12 +0000 Subject: [PATCH 073/148] fix(journeys): record dashboard RPCs at /api/rpc/ in the recruitment runners The recruitment assignment, scheduling, conduct, and invitation-response recorders classified a request as an RPC only at /api/rpc, but the dashboard server's RPC client posts to /api/rpc/. Every dashboard RPC stayed unclassified, so request counts, transport order, and the stale finalize conflict were empty. Ask isNativeRpcPath instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/run-real-interview-response.mjs | 3 ++- .../e2e/run-real-native-recruitment-interview-conduct.mjs | 3 ++- .../e2e/run-real-native-recruitment-interview-scheduling.mjs | 3 ++- tools/e2e/run-real-native-recruitment-assignment.mjs | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/e2e/run-real-interview-response.mjs b/apps/dashboard/e2e/run-real-interview-response.mjs index 3d5ad240..186c2ac3 100644 --- a/apps/dashboard/e2e/run-real-interview-response.mjs +++ b/apps/dashboard/e2e/run-real-interview-response.mjs @@ -11,6 +11,7 @@ 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)); @@ -621,7 +622,7 @@ async function startRecordingProxy(targetOrigin, actorsByCapability) { for await (const chunk of request) chunks.push(Buffer.from(chunk)); const requestBytes = Buffer.concat(chunks); const rawRequestJson = parseJsonBody(requestBytes); - const rpc = path === "/api/rpc" ? replacedRequest(rawRequestJson) : undefined; + const rpc = isNativeRpcPath(path) ? replacedRequest(rawRequestJson) : undefined; // 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; 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 9aa82ee3..8eca0645 100644 --- a/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs +++ b/apps/dashboard/e2e/run-real-native-recruitment-interview-conduct.mjs @@ -1,5 +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"; @@ -251,7 +252,7 @@ const startProxy = async (targetOrigin) => { responseHasCapability: false, }; - const rpc = path === "/api/rpc" ? replacedRequest(parseJson(requestBytes)) : undefined; + const rpc = isNativeRpcPath(path) ? replacedRequest(parseJson(requestBytes)) : undefined; // A native RPC is recorded as the HTTP route it replaced. if (rpc !== undefined) { 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 5094cf2d..c92fef78 100644 --- a/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs +++ b/apps/dashboard/e2e/run-real-native-recruitment-interview-scheduling.mjs @@ -10,6 +10,7 @@ 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)); @@ -475,7 +476,7 @@ async function startRecordingProxy(targetOrigin) { status: 0, }; - const rpc = path === "/api/rpc" ? replacedRequest(requestJson) : undefined; + 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) { diff --git a/tools/e2e/run-real-native-recruitment-assignment.mjs b/tools/e2e/run-real-native-recruitment-assignment.mjs index 58bc2671..83969d23 100644 --- a/tools/e2e/run-real-native-recruitment-assignment.mjs +++ b/tools/e2e/run-real-native-recruitment-assignment.mjs @@ -13,6 +13,7 @@ import { } from "@vektorprogrammet/database/migrations"; import { Predicate } from "effect"; import { replacedAnswer, replacedRequest } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; +import { isNativeRpcPath } from "../../apps/dashboard/e2e/native-operations.ts"; const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); @@ -408,7 +409,7 @@ const startRecordingProxy = async (targetOrigin) => { responseEtag: null, }; - const rpc = path === "/api/rpc" ? replacedRequest(requestJson) : undefined; + 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) { From 2a8917d9af075b090be2c1c23f3e60b89af5c4ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:10:47 +0000 Subject: [PATCH 074/148] fix(acceptance): expect the ingress request.malformed for a non-JSON correction body The RPC ingress parses every body strictly and answers request.malformed (400) before any RPC runs, as the HTTP route did. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/acceptance/interview-correction-boundaries.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/acceptance/interview-correction-boundaries.ts b/tools/acceptance/interview-correction-boundaries.ts index df268e6b..8fc10e9e 100644 --- a/tools/acceptance/interview-correction-boundaries.ts +++ b/tools/acceptance/interview-correction-boundaries.ts @@ -827,8 +827,8 @@ export async function assertInterviewCorrectionBoundaries( "missing-question-entry", ]); - const decodeProtocolDefect = Schema.decodeOption( - Schema.fromJsonString(Schema.Tuple([Schema.TaggedStruct("Defect", {})])), + const decodeMalformed = Schema.decodeOption( + Schema.fromJsonString(Schema.Struct({ code: Schema.Literal("request.malformed") })), ); for (const [name, invalid] of invalidCases) { @@ -853,12 +853,13 @@ export async function assertInterviewCorrectionBoundaries( // 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 no RPC message is refused by the server's transport: the RPC protocol answers one Defect - // message and no RPC exit, where the HTTP route answered request.malformed (400). + // is not JSON is refused by the ingress before any RPC runs, as request.malformed (400). if (name === "malformed-json") { const refusal = await response.text(); - assert.ok(Option.isSome(decodeProtocolDefect(refusal)), refusal); + assert.equal(response.status, 400, refusal); + + assert.ok(Option.isSome(decodeMalformed(refusal)), refusal); } else { assert.equal( response.status, From 7f60828f00f1d981e31507178d4cdd681ba700ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:13:01 +0000 Subject: [PATCH 075/148] feat(rpc): add a native client layer that follows no redirect A server whose RPC messages carry a secret needs a client that fails on a redirect instead of repeating the message to the redirect target. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/rpc/src/client.ts | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts index 5be2bc88..5c2652cb 100644 --- a/packages/rpc/src/client.ts +++ b/packages/rpc/src/client.ts @@ -17,29 +17,41 @@ export class NativeRpcClient extends Context.Service< 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. - */ -export const nativeRpcClientLayer = (origin: string): Layer.Layer => +const clientLayer = (origin: string, requestInit: globalThis.RequestInit) => 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" })), + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)(requestInit)), ), ), ), ), ); +/** + * 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. + */ +export const nativeRpcClientLayer = (origin: string): Layer.Layer => + clientLayer(origin, { credentials: "include" }); + +/** + * The client of the backend at `origin` for a server whose calls carry a secret, such as the + * homepage's contact form. It follows no redirect: the RPC endpoint never redirects, and a followed + * redirect would repeat the request message, with the headers of its RPC, to the redirect target. + * A redirect answer fails the call. + */ +export const nativeRpcServerClientLayer = (origin: string): Layer.Layer => + clientLayer(origin, { redirect: "manual" }); + /** Runs `effect` with `headers` added to every RPC it sends. */ export const withForwardedHeaders = (headers: Headers.Input) => From 7a05b0e7beda5a47a2ca3e91cd8ca2130923747e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:13:01 +0000 Subject: [PATCH 076/148] fix(homepage): never follow a backend redirect with the contact secret The HTTP-era contact call rejected a redirect; the RPC port followed it and repeated the request message, contact secret included, to the redirect target. The contact journey's injected 307 observed one hit at the receiver (red) before this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/homepage/src/lib/api.server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/homepage/src/lib/api.server.ts b/apps/homepage/src/lib/api.server.ts index 7a786737..cb6511e2 100644 --- a/apps/homepage/src/lib/api.server.ts +++ b/apps/homepage/src/lib/api.server.ts @@ -1,11 +1,11 @@ /** * 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`. + * failure rejects with the RPC's `Problem`, and a redirect rejects without being followed. */ import { NativeRpcClient, - nativeRpcClientLayer, + nativeRpcServerClientLayer, withForwardedHeaders, } from "@vektorprogrammet/rpc/client"; import { type Effect, ManagedRuntime } from "effect"; @@ -27,7 +27,7 @@ const nativeRuntime = (origin: string) => { if (existing !== undefined) return existing; - const runtime = ManagedRuntime.make(nativeRpcClientLayer(origin)); + const runtime = ManagedRuntime.make(nativeRpcServerClientLayer(origin)); runtimes.set(origin, runtime); From 3a95f1fdb7d9a840603801ac136a93a254e2495d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:13:15 +0000 Subject: [PATCH 077/148] test(e2e): read the social-events credential facts from the RPC message headers too Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/run-real-native-social-events.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/e2e/run-real-native-social-events.mjs b/apps/dashboard/e2e/run-real-native-social-events.mjs index d4f25182..8bfc4a08 100644 --- a/apps/dashboard/e2e/run-real-native-social-events.mjs +++ b/apps/dashboard/e2e/run-real-native-social-events.mjs @@ -223,9 +223,13 @@ const requestBody = async (request) => { return chunks.length === 0 ? undefined : Buffer.concat(chunks); }; -/** The one RPC request that a request body carries: its tag and payload. */ +/** 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 }), + Schema.Struct({ + tag: Schema.String, + payload: Schema.Unknown, + headers: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + }), ); const parseRpcRequest = (bytes) => @@ -253,6 +257,12 @@ const startRecordingProxy = async (ledger) => { const rpcRequest = request.method === "POST" && isNativeRpcPath(url.pathname) ? parseRpcRequest(body) : null; + // 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", @@ -260,8 +270,8 @@ const startRecordingProxy = async (ledger) => { query: url.search, rpcTag: rpcRequest?.tag ?? null, rpcPayload: rpcRequest?.payload ?? null, - sessionCookie: (headers.get("cookie") ?? "").includes("better-auth.session_token="), - authorizationHeader: headers.has("authorization"), + sessionCookie: (rpcHeaders.get("cookie") ?? "").includes("better-auth.session_token="), + authorizationHeader: rpcHeaders.has("authorization"), status: 0, responseHeaders: {}, outcome: null, From 9f210cb5444cd5b6dd1e1ecd224faa32545738d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:15:31 +0000 Subject: [PATCH 078/148] test(receipts): read the settled receipt's fresh tag from the owner's list The settlement RPC answers its evidence without the ETag header that the HTTP route sent (rpc-only.md, receipts Notes, Dropped). The journey now proves the fresh tag through receipts.listReceipts, as a client reads it, and uses that tag for the already-settled probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../run-real-native-receipt-settlement.mjs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index 1adf67f9..df3d79c7 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -1497,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), @@ -1741,13 +1751,6 @@ async function main() { "stale settlement revision", ); - // A settlement answers its evidence alone; the settled receipt's tag is in the owner's list. - const settledEtag = receiptById( - (await listOwned()).items, - submittedReceipt.receiptId, - "owner after settlement", - ).etag; - const alreadySettledResponse = await requestSettlement( proxy.origin, settler.cookie, From 8d322c61cbf99831d38e972fcbc650358ca9cb52 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:17:51 +0000 Subject: [PATCH 079/148] docs(specs): record what social-events RPCs dropped from the HTTP contract Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/specs/rpc-only.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/specs/rpc-only.md b/docs/specs/rpc-only.md index 26377ae5..c65040ae 100644 --- a/docs/specs/rpc-only.md +++ b/docs/specs/rpc-only.md @@ -655,6 +655,17 @@ Notes (recruitment): | `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 | From be8002f71fd65be0267c9a53df5b95d8ce641477 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:26:52 +0000 Subject: [PATCH 080/148] test(dashboard): read Organization credential facts from the RPC message headers A caller forwards a person's cookie as a header of the RPC message, which the backend lays over the HTTP header, so the proxy saw no session and resolved no person. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- ...eal-native-organization-administration.mjs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/e2e/run-real-native-organization-administration.mjs b/apps/dashboard/e2e/run-real-native-organization-administration.mjs index ee3c60e9..239fa209 100644 --- a/apps/dashboard/e2e/run-real-native-organization-administration.mjs +++ b/apps/dashboard/e2e/run-real-native-organization-administration.mjs @@ -384,8 +384,12 @@ const replacedRoutes = new Map([ ["system.readSession", ["GET", "/api/session"]], ]); -/** The one RPC request that a request body carries: its tag and payload. */ -const RpcRequestMessage = Schema.Struct({ tag: Schema.String, payload: Schema.Unknown }); +/** 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)); @@ -469,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" && isRpcPath(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, @@ -487,7 +502,7 @@ 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, @@ -497,9 +512,6 @@ async function startRecordingProxy(targetOrigin) { status: 0, }; - const rpc = - method === "POST" && isRpcPath(url.pathname) ? parseRpcRequest(record.request) : undefined; - // 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); From 19d2ac732209e96faf3b9e8624321dd1734f03b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:35:26 +0000 Subject: [PATCH 081/148] fix(rpc): one endpoint predicate, a rule against path comparisons, and no followed redirects Five journey recorders compared request paths with "/api/rpc" while the RPC client posts to "/api/rpc/", so they recorded no RPC of the dashboard server. isNativeRpcPath and isInternalNativeRpcPath now live beside the paths in packages/rpc/src/api.ts; the ingress, the recorder, and the remaining specs, probes, and rehearsals ask them, and anti-slop/no-rpc-path-comparison rejects comparing a path with an endpoint constant or literal (RuleTester test with negative controls). The homepage worker fixed a contact-secret leak with a second client layer that follows no redirect. Every caller forwards credentials inside the RPC message, so the one client layer now follows no redirect, and the second layer is gone. Also: the schools spec records null for a missing RPC exit (a type error of the merged port), AGENTS.md gains the construction row, and STATE.md records the open RPC transport gaps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- AGENTS.md | 1 + STATE.md | 4 ++ apps/backend/src/router.ts | 11 +-- apps/backend/src/rpc/router.test.ts | 6 +- apps/dashboard/e2e/native-operations.ts | 11 +-- ...native-organization-administration.spec.ts | 4 +- .../e2e/native-schools-directory.spec.ts | 5 +- apps/homepage/src/lib/api.server.ts | 4 +- docs/constructs.md | 2 + docs/constructs/request-ledger.md | 2 +- docs/constructs/rpc-transport.md | 38 ++++++++++ oxlint.config.ts | 7 ++ packages/rpc/AGENTS.md | 1 + packages/rpc/src/api.ts | 34 +++++++++ packages/rpc/src/client.ts | 42 +++++------ .../acceptance/returning-assistant-journey.ts | 6 +- tools/conventions/src/constructs.ts | 2 + tools/oxlint/anti-slop/UPSTREAM.txt | 1 + tools/oxlint/anti-slop/index.ts | 2 + tools/oxlint/anti-slop/provenance.json | 3 +- .../rules/no-rpc-path-comparison.test.ts | 54 ++++++++++++++ .../anti-slop/rules/no-rpc-path-comparison.ts | 72 +++++++++++++++++++ .../organization-import-rehearsal-main.ts | 4 +- 23 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 docs/constructs/rpc-transport.md create mode 100644 tools/oxlint/anti-slop/rules/no-rpc-path-comparison.test.ts create mode 100644 tools/oxlint/anti-slop/rules/no-rpc-path-comparison.ts diff --git a/AGENTS.md b/AGENTS.md index ceb6ae0d..48c172e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -269,6 +269,7 @@ 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 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. | diff --git a/STATE.md b/STATE.md index e486e863..056e1fdd 100644 --- a/STATE.md +++ b/STATE.md @@ -111,6 +111,10 @@ Fix an instance when a change touches it (see [AGENTS.md](AGENTS.md#construction 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 `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. diff --git a/apps/backend/src/router.ts b/apps/backend/src/router.ts index 3236370d..d0450b31 100644 --- a/apps/backend/src/router.ts +++ b/apps/backend/src/router.ts @@ -6,6 +6,8 @@ import { RECEIPT_FILE_MAX_BYTES } from "@vektorprogrammet/domain/receipt"; import { InternalNativeRpcs, internalNativeRpcPath, + isInternalNativeRpcPath, + isNativeRpcPath, NativeRpcs, nativeRpcPath, } from "@vektorprogrammet/rpc"; @@ -71,10 +73,6 @@ const methodNotAllowed = (methods: ReadonlyArray): Response => { return response; }; -/** Whether `pathname` addresses the RPC endpoint; the client may add a trailing slash. */ -const isNativeRpcPath = (pathname: string): boolean => - pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; - /** 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"] : []; @@ -127,9 +125,6 @@ const restrictRpcMessageHeaders = (body: Schema.Json): Schema.Json => { */ export const nativeRpcMaxBodyBytes = Math.ceil((RECEIPT_FILE_MAX_BYTES * 4) / 3) + 1024 * 1024; -const isInternalRpcPath = (pathname: string): boolean => - pathname === internalNativeRpcPath || pathname === `${internalNativeRpcPath}/`; - /** * 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 @@ -137,7 +132,7 @@ const isInternalRpcPath = (pathname: string): boolean => * 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) || isInternalRpcPath(pathname)) + request.method !== "POST" || !(isNativeRpcPath(pathname) || isInternalNativeRpcPath(pathname)) ? Effect.succeed(request) : readBoundedJson(request, nativeRpcMaxBodyBytes).pipe( Effect.map((body) => { diff --git a/apps/backend/src/rpc/router.test.ts b/apps/backend/src/rpc/router.test.ts index ba66b8de..00481042 100644 --- a/apps/backend/src/rpc/router.test.ts +++ b/apps/backend/src/rpc/router.test.ts @@ -8,11 +8,7 @@ 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"; +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, { diff --git a/apps/dashboard/e2e/native-operations.ts b/apps/dashboard/e2e/native-operations.ts index 6b4695af..6eeef311 100644 --- a/apps/dashboard/e2e/native-operations.ts +++ b/apps/dashboard/e2e/native-operations.ts @@ -7,7 +7,7 @@ * prove that a journey sends no legacy, provider, recovery, or token request, rather than listing * such routes by name. */ -import { NativeProblem, NativeRpcs, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { isNativeRpcPath, NativeProblem, NativeRpcs } from "@vektorprogrammet/rpc"; import { Array as Arr, Match, Option, Predicate, Schema } from "effect"; /** One RPC request message, as the JSON serialization of the RPC client sends it over HTTP. */ @@ -115,13 +115,8 @@ export const nativeRpcStatus = (body: string): number | undefined => { ); }; -/** - * Whether a request path is the RPC endpoint. The RPC client prepends the endpoint URL to an empty - * request URL, so it posts to `/api/rpc/`; a probe or script client posts to `/api/rpc`. A recorder - * asks this rather than comparing the path with one spelling. - */ -export const isNativeRpcPath = (pathname: string): boolean => - pathname === nativeRpcPath || pathname === `${nativeRpcPath}/`; +/** 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 diff --git a/apps/dashboard/e2e/native-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 80dd0d36..9c4341ad 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -3,7 +3,7 @@ import AxeBuilder from "@axe-core/playwright"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { DepartmentId } from "@vektorprogrammet/domain"; -import { IdempotencyKey, nativeRpcPath } from "@vektorprogrammet/rpc"; +import { IdempotencyKey, isNativeRpcPath } from "@vektorprogrammet/rpc"; import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; import { expect, test, type Page, type Request } from "@playwright/test"; @@ -151,7 +151,7 @@ const observePage = ( if ( request.method() === "POST" && - url.pathname === nativeRpcPath && + isNativeRpcPath(url.pathname) && tag !== undefined && publicCatalogTags.has(tag) ) { diff --git a/apps/dashboard/e2e/native-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index d409d3b5..19ce9b77 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -252,9 +252,10 @@ test.describe("Native Schools directory (spec 0061)", () => { }, ); + // An answer that is no RPC exit records null, which the expectation below rejects. const emptyDepartment = { - status: nativeRpcStatus(emptyDepartmentText), - body: nativeRpcValue(emptyDepartmentText), + status: nativeRpcStatus(emptyDepartmentText) ?? null, + body: nativeRpcValue(emptyDepartmentText) ?? null, }; expect(emptyDepartment).toEqual({ diff --git a/apps/homepage/src/lib/api.server.ts b/apps/homepage/src/lib/api.server.ts index cb6511e2..e28fd946 100644 --- a/apps/homepage/src/lib/api.server.ts +++ b/apps/homepage/src/lib/api.server.ts @@ -5,7 +5,7 @@ */ import { NativeRpcClient, - nativeRpcServerClientLayer, + nativeRpcClientLayer, withForwardedHeaders, } from "@vektorprogrammet/rpc/client"; import { type Effect, ManagedRuntime } from "effect"; @@ -27,7 +27,7 @@ const nativeRuntime = (origin: string) => { if (existing !== undefined) return existing; - const runtime = ManagedRuntime.make(nativeRpcServerClientLayer(origin)); + const runtime = ManagedRuntime.make(nativeRpcClientLayer(origin)); runtimes.set(origin, runtime); diff --git a/docs/constructs.md b/docs/constructs.md index 2e87e117..01df1449 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -21,6 +21,8 @@ Tag a construct only when at least 2 modules outside its own module and the test - [`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. diff --git a/docs/constructs/request-ledger.md b/docs/constructs/request-ledger.md index d6c0c4ae..32c5220e 100644 --- a/docs/constructs/request-ledger.md +++ b/docs/constructs/request-ledger.md @@ -20,7 +20,7 @@ isNativeRequest(method: string, pathname: string, body?: string): boolean - Errors: none - Requirements: none - Side effects: none -- Source: [apps/dashboard/e2e/native-operations.ts:173](../../apps/dashboard/e2e/native-operations.ts#L173) +- Source: [apps/dashboard/e2e/native-operations.ts:172](../../apps/dashboard/e2e/native-operations.ts#L172) **How it works** diff --git a/docs/constructs/rpc-transport.md b/docs/constructs/rpc-transport.md new file mode 100644 index 00000000..d8691a00 --- /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/oxlint.config.ts b/oxlint.config.ts index 747e2360..70173cca 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -290,6 +290,8 @@ 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", "anti-slop/no-unknown-parameters": "error", @@ -353,6 +355,11 @@ export default defineConfig({ ], rules: { "anti-slop/no-zoneless-calendar-interval": "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/**"], diff --git a/packages/rpc/AGENTS.md b/packages/rpc/AGENTS.md index 594d2e56..5a7c4818 100644 --- a/packages/rpc/AGENTS.md +++ b/packages/rpc/AGENTS.md @@ -18,6 +18,7 @@ Package `@vektorprogrammet/rpc`. 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. diff --git a/packages/rpc/src/api.ts b/packages/rpc/src/api.ts index 76f0ccd9..83d88d94 100644 --- a/packages/rpc/src/api.ts +++ b/packages/rpc/src/api.ts @@ -31,6 +31,40 @@ 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, diff --git a/packages/rpc/src/client.ts b/packages/rpc/src/client.ts index 5c2652cb..dac5f7e8 100644 --- a/packages/rpc/src/client.ts +++ b/packages/rpc/src/client.ts @@ -17,41 +17,37 @@ export class NativeRpcClient extends Context.Service< Effect.Success >()("@vektorprogrammet/rpc/client/NativeRpcClient") {} -const clientLayer = (origin: string, requestInit: globalThis.RequestInit) => +/** + * 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)(requestInit)), + Layer.provide( + Layer.succeed(FetchHttpClient.RequestInit)({ + credentials: "include", + redirect: "manual", + }), + ), ), ), ), ), ); -/** - * 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. - */ -export const nativeRpcClientLayer = (origin: string): Layer.Layer => - clientLayer(origin, { credentials: "include" }); - -/** - * The client of the backend at `origin` for a server whose calls carry a secret, such as the - * homepage's contact form. It follows no redirect: the RPC endpoint never redirects, and a followed - * redirect would repeat the request message, with the headers of its RPC, to the redirect target. - * A redirect answer fails the call. - */ -export const nativeRpcServerClientLayer = (origin: string): Layer.Layer => - clientLayer(origin, { redirect: "manual" }); - /** Runs `effect` with `headers` added to every RPC it sends. */ export const withForwardedHeaders = (headers: Headers.Input) => diff --git a/tools/acceptance/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 4894c756..1e7fdb64 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -1,5 +1,5 @@ import { type RecruitmentInvitationDeliveryResult } from "../../packages/database/src/recruitment/index.js"; -import { nativeRpcPath } from "../../packages/rpc/src/api.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 { @@ -661,7 +661,7 @@ export const runReturningAssistantBrowserJourney = async ({ if ( url.pathname.includes("/dashboard/tidligere-assistenter") || - url.pathname === nativeRpcPath || + isNativeRpcPath(url.pathname) || url.pathname.includes("/api/auth/") ) { responses.push(`request ${request.method()} ${url.pathname}`); @@ -676,7 +676,7 @@ export const runReturningAssistantBrowserJourney = async ({ if ( !url.pathname.includes("/dashboard/tidligere-assistenter") && - url.pathname !== nativeRpcPath && + !isNativeRpcPath(url.pathname) && !url.pathname.includes("/api/auth/") ) return; diff --git a/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index be2d6939..76f0439a 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -43,6 +43,8 @@ export const constructCategories = { "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.", diff --git a/tools/oxlint/anti-slop/UPSTREAM.txt b/tools/oxlint/anti-slop/UPSTREAM.txt index 7d37b515..eacec96d 100644 --- a/tools/oxlint/anti-slop/UPSTREAM.txt +++ b/tools/oxlint/anti-slop/UPSTREAM.txt @@ -13,3 +13,4 @@ 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. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 69a54e98..a6388d3a 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"; @@ -38,6 +39,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, diff --git a/tools/oxlint/anti-slop/provenance.json b/tools/oxlint/anti-slop/provenance.json index 72481d79..91fdec62 100644 --- a/tools/oxlint/anti-slop/provenance.json +++ b/tools/oxlint/anti-slop/provenance.json @@ -15,6 +15,7 @@ "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." ] } 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 00000000..f83e425d --- /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 00000000..aabac448 --- /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/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index 9cf824c6..79307811 100644 --- a/tools/verification/organization-import-rehearsal-main.ts +++ b/tools/verification/organization-import-rehearsal-main.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { addressesAnyRoute, legacyRoutes } from "../../apps/dashboard/e2e/request-routes.js"; -import { nativeRpcPath } from "@vektorprogrammet/rpc"; +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"; @@ -1041,7 +1041,7 @@ const startRecordingProxy = async ( // A native RPC of the journey is recorded as the read it serves: the route that it replaced. const rpcRequirement = - transportMethod === "POST" && transportPath === nativeRpcPath + transportMethod === "POST" && isNativeRpcPath(transportPath) ? nativeBrowserJourneyRequirementOfRpc(rpcTagOf(requestBytes)) : undefined; From 241d9516b260a4056d6c20851c7b08cb67a51b18 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:28:43 +0000 Subject: [PATCH 082/148] test(verification): name the problem or defect of a failed owner list in the receipt rehearsal Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/verification/receipt-import-rehearsal.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index 924ef351..ded89c11 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -584,7 +584,10 @@ try { client["receipts.listReceipts"]({}), ); - assert.ok(list.ok, `listReceipts answered ${list.status}`); + 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); From 1d8f7b7fa4d81202d269a7f699e923f81dc939ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:29:11 +0000 Subject: [PATCH 083/148] fix(verification): keep the receipt rehearsal's script client until its last reconciliation The rehearsal disposed its RPC script client before the tamper checks, whose reconciliations read the owner projection through it. Every read failed with 'ManagedRuntime disposed', so a tamper check that expects a mismatch passed without observing one, and the next expected match failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/verification/receipt-import-rehearsal.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index ded89c11..bdfc640b 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -699,7 +699,6 @@ try { ); assert.equal(invalidBearer.status, 401); - await native.dispose(); const stable = await snapshot(); const fileDigest = async () => { @@ -744,6 +743,9 @@ try { tamper.receipt.receiptId, ]); assert.equal(await 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. + await native.dispose(); await observeNoEffects(); const authorityTables = ["economy_payment_authorities", "economy_receipt_approval_grants"]; const current = await snapshot(); From 0a200cbf007ac7c889bc74880e63ac6ee6ae8f53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:30:26 +0000 Subject: [PATCH 084/148] fix(verification): pass the named Chromium to the Organization import rehearsal browser The rehearsal's environment allowlist dropped PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, which the golden harness and the other journeys pass, so a machine without Playwright's own headless shell could not launch the browser. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/verification/organization-import-rehearsal-main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/verification/organization-import-rehearsal-main.ts b/tools/verification/organization-import-rehearsal-main.ts index 79307811..f5ad301c 100644 --- a/tools/verification/organization-import-rehearsal-main.ts +++ b/tools/verification/organization-import-rehearsal-main.ts @@ -465,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", From 351878e7f0fd10d98e2eb1172bf85823e6a79cee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:35:16 +0000 Subject: [PATCH 085/148] refactor(database): construct Schema errors with make in recruitment, organization, placements Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../organization/administration-postgres.ts | 8 ++--- .../src/organization/authority-postgres.ts | 26 +++++++------- .../src/organization/directory-postgres.ts | 6 ++-- .../src/organization/lifecycle-postgres.ts | 4 +-- .../organization/mailing-lists-postgres.ts | 8 ++--- .../database/src/organization/postgres.ts | 16 ++++----- .../src/placements/certificates.test.ts | 14 ++++---- .../database/src/placements/certificates.ts | 26 +++++++------- .../src/recruitment/completion-outbox.ts | 2 +- .../src/recruitment/conduct-identity.ts | 4 +-- .../src/recruitment/conduct-postgres.ts | 32 ++++++++--------- .../database/src/recruitment/http-postgres.ts | 12 +++---- .../invitation-response-postgres.ts | 22 ++++++------ .../src/recruitment/maintenance-postgres.ts | 4 +-- packages/database/src/recruitment/outbox.ts | 2 +- packages/database/src/recruitment/postgres.ts | 36 +++++++++---------- .../src/recruitment/report-postgres.ts | 12 +++---- .../src/recruitment/response-outbox.ts | 2 +- .../src/recruitment/scheduling-postgres.ts | 32 ++++++++--------- 19 files changed, 134 insertions(+), 134 deletions(-) diff --git a/packages/database/src/organization/administration-postgres.ts b/packages/database/src/organization/administration-postgres.ts index c31b045a..1dc379e4 100644 --- a/packages/database/src/organization/administration-postgres.ts +++ b/packages/database/src/organization/administration-postgres.ts @@ -62,13 +62,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" }), @@ -107,7 +107,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" })), ), ); @@ -358,7 +358,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. */ diff --git a/packages/database/src/organization/authority-postgres.ts b/packages/database/src/organization/authority-postgres.ts index cf3b5570..55420527 100644 --- a/packages/database/src/organization/authority-postgres.ts +++ b/packages/database/src/organization/authority-postgres.ts @@ -28,7 +28,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"; @@ -70,7 +70,7 @@ export const lockPersonAuthorization = ( lockAdvisory(sql, AdvisoryLockKey.personAuthorization(personId)).pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "lock person authorization", message: String(cause), cause, @@ -120,7 +120,7 @@ export const lockOrganizationGlobalAdministratorGrantForWrite = ( const observedPerson = observed[0]?.personId; if (observedPerson === undefined) { - return yield* new OrganizationAuthorityRecordNotFound({ grantId }); + return yield* OrganizationAuthorityRecordNotFound.make({ grantId }); } yield* lockPersonAuthorization(sql, observedPerson); @@ -155,14 +155,14 @@ export const lockOrganizationGlobalAdministratorGrantForWrite = ( grant.personId !== observedPerson || grant.revision !== expectedRevision ) { - return yield* new OrganizationAuthorityWriteConflict({ grantId, expectedRevision }); + return yield* OrganizationAuthorityWriteConflict.make({ grantId, expectedRevision }); } return grant; }).pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "lock Organization global-administrator grant", message: String(cause), }), @@ -223,7 +223,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 +282,7 @@ export const endOrganizationGlobalAdministratorGrant = ( `; if (updated.length !== 1) { - return yield* new OrganizationAuthorityWriteConflict({ + return yield* OrganizationAuthorityWriteConflict.make({ grantId: command.grantId, expectedRevision: command.expectedRevision, }); @@ -294,7 +294,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 +338,7 @@ export const removeOrganizationGlobalAdministratorGrant = ( `; if (removed.length !== 1) { - return yield* new OrganizationAuthorityWriteConflict({ + return yield* OrganizationAuthorityWriteConflict.make({ grantId: command.grantId, expectedRevision: command.expectedRevision, }); @@ -350,7 +350,7 @@ export const removeOrganizationGlobalAdministratorGrant = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "remove Organization global-administrator grant", message: String(cause), }), @@ -508,7 +508,7 @@ export const resolveOrganizationPersonAuthorityWithSql = ( }).pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "resolve Organization person authority", message: String(cause), cause, @@ -584,7 +584,7 @@ export const readGovernedDepartmentsWithSql = ( ), Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "read governed departments", message: String(cause), cause, @@ -668,7 +668,7 @@ export const certificateIssuerWithSql = ( }).pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + 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 6c134d4c..678bcd20 100644 --- a/packages/database/src/organization/directory-postgres.ts +++ b/packages/database/src/organization/directory-postgres.ts @@ -83,7 +83,7 @@ export const deriveOrganizationDirectoryFacts = ( `.pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "derive Organization directory memberships", message: String(cause), }), @@ -117,7 +117,7 @@ export const deriveOrganizationDirectoryFacts = ( `.pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new OrganizationPersistenceError({ + OrganizationPersistenceError.make({ operation: "derive Organization directory grants", message: String(cause), }), @@ -139,4 +139,4 @@ export const deriveOrganizationDirectoryFacts = ( }); 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 08a62a0c..d99755fe 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, diff --git a/packages/database/src/organization/mailing-lists-postgres.ts b/packages/database/src/organization/mailing-lists-postgres.ts index b74275e1..014a0c94 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.ts b/packages/database/src/organization/postgres.ts index 91262c2d..6cb9c9fa 100644 --- a/packages/database/src/organization/postgres.ts +++ b/packages/database/src/organization/postgres.ts @@ -35,13 +35,13 @@ 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({ + OrganizationDecodeError.make({ operation: "decode Department select", message: String(cause), }), @@ -52,7 +52,7 @@ const decodeTeam = flow( Schema.decodeUnknownEffect(Team, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new OrganizationDecodeError({ operation: "decode Team select", message: String(cause) }), + OrganizationDecodeError.make({ operation: "decode Team select", message: String(cause) }), ), ); @@ -60,7 +60,7 @@ const decodeMembership = flow( Schema.decodeUnknownEffect(MembershipInvariantSchema, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new OrganizationDecodeError({ + OrganizationDecodeError.make({ operation: "decode Membership select", message: String(cause), }), @@ -107,7 +107,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 +180,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 +308,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 = ( @@ -593,7 +593,7 @@ const decodeTeamInterestRegistration = flow( Schema.decodeUnknownEffect(TeamInterestRegistration, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new OrganizationDecodeError({ + OrganizationDecodeError.make({ operation: "decode TeamInterestRegistration select", message: String(cause), }), diff --git a/packages/database/src/placements/certificates.test.ts b/packages/database/src/placements/certificates.test.ts index c4b6222c..38783917 100644 --- a/packages/database/src/placements/certificates.test.ts +++ b/packages/database/src/placements/certificates.test.ts @@ -576,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); }), ); @@ -595,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. @@ -786,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); @@ -815,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" }), ); }), ); @@ -824,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 dc598047..62b67367 100644 --- a/packages/database/src/placements/certificates.ts +++ b/packages/database/src/placements/certificates.ts @@ -71,7 +71,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, @@ -452,14 +452,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, }), ), @@ -470,7 +470,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), ), ); @@ -502,13 +502,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; }); @@ -527,7 +527,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( @@ -615,7 +615,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")), @@ -700,7 +700,7 @@ 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 }; }); @@ -761,7 +761,7 @@ export const confirmDaysServed = ( 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); @@ -785,7 +785,7 @@ 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; }); @@ -857,7 +857,7 @@ 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); }); @@ -881,7 +881,7 @@ export const issueCertificate = ( 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")), diff --git a/packages/database/src/recruitment/completion-outbox.ts b/packages/database/src/recruitment/completion-outbox.ts index 51d9ef2b..76689c06 100644 --- a/packages/database/src/recruitment/completion-outbox.ts +++ b/packages/database/src/recruitment/completion-outbox.ts @@ -120,7 +120,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", diff --git a/packages/database/src/recruitment/conduct-identity.ts b/packages/database/src/recruitment/conduct-identity.ts index 8c0b9e30..cd75aa4c 100644 --- a/packages/database/src/recruitment/conduct-identity.ts +++ b/packages/database/src/recruitment/conduct-identity.ts @@ -19,7 +19,7 @@ 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]) return yield* RecruitmentInterviewNotFound.make({ interviewId }); return rows[0]; }), @@ -36,7 +36,7 @@ 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; }); diff --git a/packages/database/src/recruitment/conduct-postgres.ts b/packages/database/src/recruitment/conduct-postgres.ts index 237c45a0..f5fa7da3 100644 --- a/packages/database/src/recruitment/conduct-postgres.ts +++ b/packages/database/src/recruitment/conduct-postgres.ts @@ -142,7 +142,7 @@ interface CancellationRow { } const persistenceError = (operation: string, cause?: Error) => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: cause?.message ?? "recruitment persistence failed", @@ -153,7 +153,7 @@ const decode = (schema: Schema.ConstraintDecoder, operation: string Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new RecruitmentPersistenceError({ + 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", }); @@ -818,7 +818,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 +845,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 +855,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 +906,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 +933,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 +943,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 +989,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 +1031,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, diff --git a/packages/database/src/recruitment/http-postgres.ts b/packages/database/src/recruitment/http-postgres.ts index b8178111..b92457a3 100644 --- a/packages/database/src/recruitment/http-postgres.ts +++ b/packages/database/src/recruitment/http-postgres.ts @@ -49,10 +49,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 +70,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 +114,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 +190,7 @@ export const readRecruitmentApplicationHttpAccessPostgres = (input: { const row = rows[0]; if (row === undefined) { - return yield* new RecruitmentApplicationNotFound({ + return yield* RecruitmentApplicationNotFound.make({ applicationId: input.applicationId, }); } @@ -349,7 +349,7 @@ export const readRecruitmentInterviewHttpSourcePostgres = ( 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) => diff --git a/packages/database/src/recruitment/invitation-response-postgres.ts b/packages/database/src/recruitment/invitation-response-postgres.ts index 02f0a2c5..96567de7 100644 --- a/packages/database/src/recruitment/invitation-response-postgres.ts +++ b/packages/database/src/recruitment/invitation-response-postgres.ts @@ -64,7 +64,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", @@ -77,7 +77,7 @@ const decode = (schema: Schema.ConstraintDecoder, operation: string Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new RecruitmentDecodeError({ + 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 = ( @@ -198,7 +198,7 @@ const observationFromRow = ( }).pipe( Effect.mapError( (cause) => - new RecruitmentDecodeError({ + RecruitmentDecodeError.make({ message: cause instanceof Error ? cause.message : `invalid ${"invitation response observation"}`, }), @@ -263,7 +263,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 +272,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 +308,7 @@ const recordInvitationResponse = ( const responseRevision = updated[0]?.responseRevision; if (responseRevision !== 1) { - return yield* new RecruitmentInvitationAlreadyResponded({}); + return yield* RecruitmentInvitationAlreadyResponded.make({}); } yield* sql` @@ -376,7 +376,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( @@ -469,7 +469,7 @@ const recordInvitationResponse = ( return yield* responseEffect.pipe( Effect.mapError( (cause) => - new RecruitmentDecodeError({ + RecruitmentDecodeError.make({ message: SchemaIssue.isIssue(cause) ? formatIssue(cause) : String(cause), }), ), @@ -527,7 +527,7 @@ 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); }); diff --git a/packages/database/src/recruitment/maintenance-postgres.ts b/packages/database/src/recruitment/maintenance-postgres.ts index f2b472cc..a1cdafe9 100644 --- a/packages/database/src/recruitment/maintenance-postgres.ts +++ b/packages/database/src/recruitment/maintenance-postgres.ts @@ -38,10 +38,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, diff --git a/packages/database/src/recruitment/outbox.ts b/packages/database/src/recruitment/outbox.ts index 81a395f7..c9c459fa 100644 --- a/packages/database/src/recruitment/outbox.ts +++ b/packages/database/src/recruitment/outbox.ts @@ -153,7 +153,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", diff --git a/packages/database/src/recruitment/postgres.ts b/packages/database/src/recruitment/postgres.ts index 5fb1ed78..b769faa6 100644 --- a/packages/database/src/recruitment/postgres.ts +++ b/packages/database/src/recruitment/postgres.ts @@ -197,7 +197,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, @@ -208,7 +208,7 @@ const decode = (schema: Schema.ConstraintDecoder, operation: string Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new RecruitmentDecodeError({ + 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, }); @@ -983,7 +983,7 @@ const assignmentInTransaction = ( }).pipe( Effect.mapError( (cause) => - new RecruitmentDecodeError({ + RecruitmentDecodeError.make({ message: cause instanceof Error ? cause.message : `invalid ${"assignment observation"}`, }), ), diff --git a/packages/database/src/recruitment/report-postgres.ts b/packages/database/src/recruitment/report-postgres.ts index ad24ab27..87892608 100644 --- a/packages/database/src/recruitment/report-postgres.ts +++ b/packages/database/src/recruitment/report-postgres.ts @@ -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,7 +46,7 @@ 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; }); @@ -60,7 +60,7 @@ 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 +74,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, }); @@ -136,7 +136,7 @@ export const readCompletedInterviewReport = ( { onExcessProperty: "error" }, ).pipe( Effect.mapError( - () => new RecruitmentDecodeError({ message: "invalid persisted report row" }), + () => RecruitmentDecodeError.make({ message: "invalid persisted report row" }), ), ); } @@ -156,7 +156,7 @@ export const readCompletedInterviewReport = ( .pipe( Effect.catchTag("SqlError", (cause) => Effect.fail( - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation: "read completed interview report", message: "report unavailable", cause, diff --git a/packages/database/src/recruitment/response-outbox.ts b/packages/database/src/recruitment/response-outbox.ts index 0e81ed61..a8a370a7 100644 --- a/packages/database/src/recruitment/response-outbox.ts +++ b/packages/database/src/recruitment/response-outbox.ts @@ -134,7 +134,7 @@ export const RecruitmentInvitationResponseDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string, cause?: unknown): RecruitmentPersistenceError => - new RecruitmentPersistenceError({ + RecruitmentPersistenceError.make({ operation, cause, message: diff --git a/packages/database/src/recruitment/scheduling-postgres.ts b/packages/database/src/recruitment/scheduling-postgres.ts index 1f00974f..11212d08 100644 --- a/packages/database/src/recruitment/scheduling-postgres.ts +++ b/packages/database/src/recruitment/scheduling-postgres.ts @@ -137,7 +137,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 +152,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), ), ); @@ -162,7 +162,7 @@ const decode = (schema: Schema.ConstraintDecoder, operation: string Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), Effect.mapError( (cause) => - new RecruitmentDecodeError({ + 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, @@ -664,7 +664,7 @@ const writeScheduleRows = ( }).pipe( Effect.mapError( (cause) => - new RecruitmentDecodeError({ + RecruitmentDecodeError.make({ message: cause instanceof Error ? cause.message : `invalid ${"schedule observation"}`, }), ), @@ -721,7 +721,7 @@ const writeScheduleRows = ( }).pipe( Effect.mapError( (cause) => - new RecruitmentDecodeError({ + RecruitmentDecodeError.make({ message: cause instanceof Error ? cause.message @@ -783,20 +783,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 +806,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 +832,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, @@ -926,7 +926,7 @@ export const scheduleInterview = ( )(context.invitationId); if (!capabilityIsValid(context.responseCapability)) { - return yield* new RecruitmentInvalidContext({ message: "invalid response capability" }); + return yield* RecruitmentInvalidContext.make({ message: "invalid response capability" }); } const sql = yield* Database; From 77ad35a913d66a99ebd251cd78f0e00740ea7aed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:35:50 +0000 Subject: [PATCH 086/148] test(database): inline nested Effect.gen in recruitment Postgres suites Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../src/recruitment/conduct-postgres.test.ts | 66 +++++----- .../src/recruitment/http-postgres.test.ts | 116 +++++++++--------- .../recruitment/scheduling-postgres.test.ts | 42 +++---- 3 files changed, 109 insertions(+), 115 deletions(-) diff --git a/packages/database/src/recruitment/conduct-postgres.test.ts b/packages/database/src/recruitment/conduct-postgres.test.ts index 1bd2c674..6a2328f9 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/http-postgres.test.ts b/packages/database/src/recruitment/http-postgres.test.ts index 8c238f8f..ec6a41cf 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/scheduling-postgres.test.ts b/packages/database/src/recruitment/scheduling-postgres.test.ts index 58fa8ffc..d86ad85d 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([ From 9fc826df335bbeff023e141b2fd45da79c72f716 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:35:53 +0000 Subject: [PATCH 087/148] refactor(database): construct Schema errors and classes with make in the context folders Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/admission-period/postgres.ts | 30 +++---- packages/database/src/application/outbox.ts | 2 +- packages/database/src/application/postgres.ts | 36 ++++---- .../database/src/authz/delegation-postgres.ts | 4 +- packages/database/src/content/news.ts | 12 +-- packages/database/src/content/postgres.ts | 82 +++++++++---------- packages/database/src/profile/postgres.ts | 38 ++++----- .../src/receipt/authority-postgres.ts | 30 +++---- packages/database/src/receipt/outbox.ts | 2 +- packages/database/src/receipt/postgres.ts | 70 ++++++++-------- packages/database/src/receipt/projections.ts | 4 +- packages/database/src/receipt/settlement.ts | 36 ++++---- .../database/src/schools/administration.ts | 4 +- packages/database/src/schools/directory.ts | 12 +-- packages/database/src/schools/postgres.ts | 4 +- .../src/team-application/delivery.test.ts | 2 +- .../src/team-application/persistence.ts | 2 +- .../database/src/team-application/postgres.ts | 22 ++--- .../src/team-application/service.test.ts | 8 +- 19 files changed, 200 insertions(+), 200 deletions(-) diff --git a/packages/database/src/admission-period/postgres.ts b/packages/database/src/admission-period/postgres.ts index a9a39ed4..305b3a8f 100644 --- a/packages/database/src/admission-period/postgres.ts +++ b/packages/database/src/admission-period/postgres.ts @@ -56,7 +56,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 +197,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 +251,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 +356,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 +366,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,7 +375,7 @@ 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 = ( @@ -401,7 +401,7 @@ export const executeAdmissionPeriodCommand = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicateAdmissionPeriodCommandConflict({ + return yield* DuplicateAdmissionPeriodCommandConflict.make({ commandId: command.commandId, }); } @@ -409,7 +409,7 @@ export const executeAdmissionPeriodCommand = ( const original = yield* observationFromStored(stored.observation_json); if (!actorCanAccessDepartment(context.actor, original.period.departmentId)) { - return yield* new AdmissionScopeDenied({ + return yield* AdmissionScopeDenied.make({ personId: context.actor.personId, departmentId: original.period.departmentId, admissionPeriodId: original.period.id, @@ -434,26 +434,26 @@ export const executeAdmissionPeriodCommand = ( ); if (!(yield* departmentExists(sql, departmentId))) { - return yield* new DepartmentNotFound({ departmentId }); + return yield* DepartmentNotFound.make({ departmentId }); } previous = yield* findPeriodByPairForUpdate(sql, departmentId, command.semesterId); semester = yield* findSemester(sql, command.semesterId); if (semester === undefined) { - return yield* new SemesterNotFound({ semesterId: command.semesterId }); + return yield* SemesterNotFound.make({ semesterId: command.semesterId }); } } else { previous = yield* findPeriodForUpdate(sql, command.admissionPeriodId); if (previous === undefined) { - return yield* new AdmissionPeriodNotFound({ + return yield* AdmissionPeriodNotFound.make({ admissionPeriodId: command.admissionPeriodId, }); } if (!actorCanAccessDepartment(context.actor, previous.departmentId)) { - return yield* new AdmissionScopeDenied({ + return yield* AdmissionScopeDenied.make({ personId: context.actor.personId, departmentId: previous.departmentId, admissionPeriodId: previous.id, @@ -463,7 +463,7 @@ export const executeAdmissionPeriodCommand = ( semester = yield* findSemester(sql, previous.semesterId); if (semester === undefined) { - return yield* new SemesterNotFound({ semesterId: previous.semesterId }); + return yield* SemesterNotFound.make({ semesterId: previous.semesterId }); } } diff --git a/packages/database/src/application/outbox.ts b/packages/database/src/application/outbox.ts index d50804f8..71dd53d6 100644 --- a/packages/database/src/application/outbox.ts +++ b/packages/database/src/application/outbox.ts @@ -70,7 +70,7 @@ export const PublicApplicationOutboxDeliveryResult = Data.taggedEnum(); const persistenceError = (operation: string): PublicApplicationPersistenceError => - new PublicApplicationPersistenceError({ + PublicApplicationPersistenceError.make({ operation, message: "public application persistence failed", }); diff --git a/packages/database/src/application/postgres.ts b/packages/database/src/application/postgres.ts index 42fe481f..ebf67dc1 100644 --- a/packages/database/src/application/postgres.ts +++ b/packages/database/src/application/postgres.ts @@ -131,7 +131,7 @@ const persistenceError = ( operation: string, cause?: SqlError, ): PublicApplicationPersistenceError => { - const error = new PublicApplicationPersistenceError({ + const error = PublicApplicationPersistenceError.make({ operation, message: "public application persistence failed", }); @@ -225,7 +225,7 @@ const findEligiblePeriod = ( > => { if (rows.length > 1) { return Effect.fail( - new AmbiguousAdmissionPeriod({ departmentId: DepartmentId.make(departmentId) }), + AmbiguousAdmissionPeriod.make({ departmentId: DepartmentId.make(departmentId) }), ); } @@ -492,7 +492,7 @@ const executeCommandInTransaction = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicatePublicApplicationCommandConflict({ + return yield* DuplicatePublicApplicationCommandConflict.make({ commandId: command.commandId, }); } @@ -510,30 +510,30 @@ 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); @@ -549,7 +549,7 @@ const executeCommandInTransaction = ( context.activationToken, ).pipe( Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid activation token" }), + () => PublicApplicationDecodeError.make({ message: "invalid activation token" }), ), ) : undefined; @@ -576,7 +576,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 +586,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); @@ -795,7 +795,7 @@ export const findPublicApplicationConfirmation = ( applicationId.trim(), ).pipe( Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid application identifier" }), + () => PublicApplicationDecodeError.make({ message: "invalid application identifier" }), ), ); @@ -812,7 +812,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 +832,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, }); } @@ -842,7 +842,7 @@ export const readApplicantContacts = ( { onExcessProperty: "error" }, ).pipe( Effect.mapError( - () => new PublicApplicationDecodeError({ message: "invalid application identifier batch" }), + () => PublicApplicationDecodeError.make({ message: "invalid application identifier batch" }), ), ); @@ -884,14 +884,14 @@ export const readApplicantContacts = ( }).pipe( Effect.mapError( () => - new PublicApplicationDecodeError({ + 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 +905,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); diff --git a/packages/database/src/authz/delegation-postgres.ts b/packages/database/src/authz/delegation-postgres.ts index a9a0981a..95a6d9cf 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, diff --git a/packages/database/src/content/news.ts b/packages/database/src/content/news.ts index 0520f29f..4583d54a 100644 --- a/packages/database/src/content/news.ts +++ b/packages/database/src/content/news.ts @@ -20,7 +20,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 +108,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 +158,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}`, }); @@ -249,7 +249,7 @@ export const readPublishedArticlePostgres = ( const current = versions[0]; if (current === undefined) { - return yield* new ContentArticleNotFound({}); + return yield* ContentArticleNotFound.make({}); } const selected = @@ -258,7 +258,7 @@ export const readPublishedArticlePostgres = ( : versions.find((version) => version.versionNumber === versionNumber); if (selected === undefined) { - return yield* new ContentArticleNotFound({}); + return yield* ContentArticleNotFound.make({}); } const departments = yield* readDepartments(database, [selected.articleId]); @@ -270,7 +270,7 @@ export const readPublishedArticlePostgres = ( const author = profiles.find((entry) => entry.personId === selected.createdByPersonId); if (author === undefined) { - return yield* new ContentIntegrityError({ + return yield* ContentIntegrityError.make({ operation: "resolve news author", message: `no profile resolved for author ${selected.createdByPersonId}`, }); diff --git a/packages/database/src/content/postgres.ts b/packages/database/src/content/postgres.ts index 4f7905fd..b06da3ad 100644 --- a/packages/database/src/content/postgres.ts +++ b/packages/database/src/content/postgres.ts @@ -61,10 +61,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 +168,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 +179,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 +188,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 +254,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 +268,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 +351,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 +380,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 +393,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 +412,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 +610,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 +673,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 +714,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 +740,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 +824,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 +880,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 +896,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 +904,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 +1013,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 +1029,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 +1105,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 +1126,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 +1140,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 +1151,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 +1249,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", @@ -1434,7 +1434,7 @@ export const readPublishedNewsArticleHttpSourcePostgres = ( 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", diff --git a/packages/database/src/profile/postgres.ts b/packages/database/src/profile/postgres.ts index 30f0eb4e..3b1360b4 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/authority-postgres.ts b/packages/database/src/receipt/authority-postgres.ts index 7dfafade..c1991048 100644 --- a/packages/database/src/receipt/authority-postgres.ts +++ b/packages/database/src/receipt/authority-postgres.ts @@ -104,10 +104,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, @@ -255,7 +255,7 @@ export const lockReceiptPaymentAuthorityForWrite = ( const observedPerson = observed[0]?.personId; if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ + return yield* ReceiptAuthorityRecordNotFound.make({ entity: "PaymentAuthority", id: paymentAuthorityId, }); @@ -293,7 +293,7 @@ export const lockReceiptPaymentAuthorityForWrite = ( const row = locked[0]; if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "PaymentAuthority", id: paymentAuthorityId, expectedRevision, @@ -327,7 +327,7 @@ export const lockReceiptApprovalGrantForWrite = ( const observedPerson = observed[0]?.personId; if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ + return yield* ReceiptAuthorityRecordNotFound.make({ entity: "ApprovalGrant", id: approvalGrantId, }); @@ -363,7 +363,7 @@ export const lockReceiptApprovalGrantForWrite = ( const row = locked[0]; if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "ApprovalGrant", id: approvalGrantId, expectedRevision, @@ -399,7 +399,7 @@ export const lockReceiptSettlementGrantForWrite = ( const observedPerson = observed[0]?.personId; if (observedPerson === undefined) { - return yield* new ReceiptAuthorityRecordNotFound({ + return yield* ReceiptAuthorityRecordNotFound.make({ entity: "SettlementGrant", id: settlementGrantId, }); @@ -437,7 +437,7 @@ export const lockReceiptSettlementGrantForWrite = ( const row = locked[0]; if (row === undefined || row.personId !== observedPerson || row.revision !== expectedRevision) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "SettlementGrant", id: settlementGrantId, expectedRevision, @@ -547,7 +547,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 +593,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 +705,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 +751,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 +867,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 +913,7 @@ export const removeReceiptSettlementGrant = ( `; if (removed.length !== 1) { - return yield* new ReceiptAuthorityWriteConflict({ + return yield* ReceiptAuthorityWriteConflict.make({ entity: "SettlementGrant", id: command.settlementGrantId, expectedRevision: command.expectedRevision, @@ -953,7 +953,7 @@ export const resolveReceiptAuthorityWithSql = ( organizationProjection.personId !== personId || organizationProjection.evaluatedAt !== evaluatedAt ) { - return yield* new ReceiptAuthorityProjectionMismatch({ + return yield* ReceiptAuthorityProjectionMismatch.make({ personId, authorizationInstant: evaluatedAt, organizationPersonId: organizationProjection.personId, diff --git a/packages/database/src/receipt/outbox.ts b/packages/database/src/receipt/outbox.ts index c74bf215..9a2f32e4 100644 --- a/packages/database/src/receipt/outbox.ts +++ b/packages/database/src/receipt/outbox.ts @@ -38,7 +38,7 @@ interface ClaimIdRow { } const persistenceError = (operation: string, cause: unknown) => - new ReceiptPersistenceError({ operation, message: String(cause) }); + ReceiptPersistenceError.make({ operation, message: String(cause) }); const receiptOutbox: OutboxTable = { name: "economy_receipt_outbox", terminalPayload: "Retain" }; diff --git a/packages/database/src/receipt/postgres.ts b/packages/database/src/receipt/postgres.ts index f9096dac..f4dafe21 100644 --- a/packages/database/src/receipt/postgres.ts +++ b/packages/database/src/receipt/postgres.ts @@ -149,7 +149,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 +277,7 @@ const storeReceipt = ( rows.length === 1 ? Effect.void : Effect.fail( - new StaleReceiptRevision({ + StaleReceiptRevision.make({ receiptId: receipt.receiptId, expected: previous.revision, actual: previous.revision, @@ -561,7 +561,7 @@ export const reconcileReceiptImport = ( // The import result carries a plain receipt; Equal compares models only with models. const matches = - actual !== undefined && Equal.equals(actual, new Receipt(expected.receipt)); + actual !== undefined && Equal.equals(actual, Receipt.make(expected.receipt)); const observed = matches && actual !== undefined @@ -616,7 +616,7 @@ export const listReceiptsForApproval = ( "resolve Receipt approval list Organization authority", cause.message, ) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}`, }), ), @@ -632,7 +632,7 @@ export const listReceiptsForApproval = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -666,7 +666,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 +727,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: "", }); @@ -783,7 +783,7 @@ export const readReceiptFileForApproval = ( 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", @@ -808,7 +808,7 @@ export const readReceiptFileForApproval = ( Effect.mapError((cause) => Predicate.isTagged(cause, "OrganizationPersistenceError") ? persistenceError("resolve Receipt approval file Organization authority", cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}`, }), ), @@ -826,7 +826,7 @@ export const readReceiptFileForApproval = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -843,7 +843,7 @@ export const readReceiptFileForApproval = ( Effect.mapError((cause) => Predicate.isTagged(cause, "AuthzPersistenceError") ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.entity}: ${cause.message}`, }), ), @@ -867,17 +867,17 @@ export const readReceiptFileForApproval = ( 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: candidate.receiptId, departmentId: candidate.departmentId, }); } if (!decision.value.receiptIds.includes(candidate.receiptId)) { - return yield* new ReceiptScopeDenied({ + return yield* ReceiptScopeDenied.make({ receiptId: candidate.receiptId, departmentId: candidate.departmentId, }); @@ -897,7 +897,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 +912,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 +934,7 @@ const authorizeReceiptMutationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -945,7 +945,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 +980,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 +1007,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 +1026,7 @@ const authorizeReceiptMutationWithSql = ( ).pipe( Effect.mapError((cause: ReceiptAuthorityMappingError) => Predicate.isTagged(cause, "AmbiguousReceiptPaymentAuthority") - ? new AmbiguousPaymentSelection({ + ? AmbiguousPaymentSelection.make({ personId: cause.personId, departmentIds: cause.departmentIds, }) @@ -1059,7 +1059,7 @@ const authorizeReceiptMutationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -1083,7 +1083,7 @@ const authorizeReceiptMutationWithSql = ( Effect.mapError((cause) => Predicate.isTagged(cause, "AuthzPersistenceError") ? persistenceError(cause.operation, cause.message) - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `${cause.entity}: ${cause.message}`, }), ), @@ -1164,7 +1164,7 @@ export const authorizeReceiptMutation = ( const principal = yield* Schema.decodeEffect(ReceiptCommandPrincipalSchema)(principalInput, { onExcessProperty: "error", - }).pipe(Effect.mapError((cause) => new ReceiptDecodeError({ message: String(cause) }))); + }).pipe(Effect.mapError((cause) => ReceiptDecodeError.make({ message: String(cause) }))); return yield* authorizeReceiptMutationWithSql(sql, target, principal); }); @@ -1173,13 +1173,13 @@ 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 +1215,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 +1239,7 @@ const executeAuthorizedReceiptCommandWithSql = ( if (stored !== undefined) { if (stored.command_sha256 !== commandDigest) { - return yield* new DuplicateReceiptCommandConflict({ + return yield* DuplicateReceiptCommandConflict.make({ commandId: command.commandId, }); } @@ -1252,7 +1252,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 { @@ -1269,7 +1269,7 @@ const executeAuthorizedReceiptCommandWithSql = ( }).pipe( Effect.mapError( (cause) => - new ReceiptDecodeError({ + ReceiptDecodeError.make({ message: `decode Receipt submission allocation: ${String(cause)}`, }), ), @@ -1282,13 +1282,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 +1297,7 @@ const executeAuthorizedReceiptCommandWithSql = ( previous = yield* findReceipt(sql, receiptId); if (previous !== undefined) { - return yield* new ReceiptAlreadyExists({ receiptId }); + return yield* ReceiptAlreadyExists.make({ receiptId }); } authorizedCommand = { @@ -1308,7 +1308,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", }); } diff --git a/packages/database/src/receipt/projections.ts b/packages/database/src/receipt/projections.ts index fa865665..e28fb4d4 100644 --- a/packages/database/src/receipt/projections.ts +++ b/packages/database/src/receipt/projections.ts @@ -26,7 +26,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, { @@ -262,7 +262,7 @@ export const readReceiptLifecycleEvidence = ( const receipt = receipts[0]; - if (receipt === undefined) return yield* new ReceiptNotFound({ receiptId }); + if (receipt === undefined) return yield* ReceiptNotFound.make({ receiptId }); const settlement = yield* selectSettlementEvidence(sql, receiptId); const outbox = yield* sql` diff --git a/packages/database/src/receipt/settlement.ts b/packages/database/src/receipt/settlement.ts index 909bd29a..7781906a 100644 --- a/packages/database/src/receipt/settlement.ts +++ b/packages/database/src/receipt/settlement.ts @@ -76,7 +76,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 +204,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 +221,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 +234,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 +251,7 @@ const resolveSettlementAuthorizationWithSql = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -300,7 +300,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 +334,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 +346,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 +356,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 +384,7 @@ const executeAuthorizedReceiptSettlementWithSql = ( ); if (duplicateExternal[0] !== undefined) { - return yield* new DuplicateExternalSettlementReference({ + return yield* DuplicateExternalSettlementReference.make({ externalAuthority: command.externalAuthority, externalReference: command.externalReference, }); @@ -405,7 +405,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, @@ -579,7 +579,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 +593,7 @@ export const listReceiptsForSettlement = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), @@ -697,7 +697,7 @@ export const readReceiptSettlementForFinance = ( const row = rows[0]; - if (row === undefined) return yield* new ReceiptNotFound({ receiptId }); + if (row === undefined) return yield* ReceiptNotFound.make({ receiptId }); const settlement = yield* decodeSettlementEvidence(row.settlement); const organization = yield* resolveOrganizationPersonAuthorityForRead( @@ -710,7 +710,7 @@ export const readReceiptSettlementForFinance = ( "resolve Receipt finance settlement Organization authority", cause.message, ) - : new ReceiptDecodeError({ message: `${cause.operation}: ${cause.message}` }), + : ReceiptDecodeError.make({ message: `${cause.operation}: ${cause.message}` }), ), ); @@ -724,7 +724,7 @@ export const readReceiptSettlementForFinance = ( ? cause : Predicate.isTagged(cause, "ReceiptDecodeError") ? cause - : new ReceiptDecodeError({ + : ReceiptDecodeError.make({ message: `Receipt authority projection mismatch for ${cause.personId}`, }), ), diff --git a/packages/database/src/schools/administration.ts b/packages/database/src/schools/administration.ts index 2e2c38b2..87206e8f 100644 --- a/packages/database/src/schools/administration.ts +++ b/packages/database/src/schools/administration.ts @@ -25,10 +25,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, diff --git a/packages/database/src/schools/directory.ts b/packages/database/src/schools/directory.ts index f5c81479..746dec2c 100644 --- a/packages/database/src/schools/directory.ts +++ b/packages/database/src/schools/directory.ts @@ -21,10 +21,10 @@ 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 @@ -75,8 +75,8 @@ export const readSchoolsDirectory = ( if (Predicate.isTagged(decision, "Deny")) { return yield* decision.reason === "AuthorityInactive" - ? new SchoolsAuthorityInactive({}) - : new SchoolsNotInScope({}); + ? SchoolsAuthorityInactive.make({}) + : SchoolsNotInScope.make({}); } const scope = decision.value; @@ -88,7 +88,7 @@ export const readSchoolsDirectory = ( Effect.mapError((cause) => { return Match.value(cause).pipe( Match.tag("DepartmentNotFound", () => { - return new SchoolsDepartmentNotFound({ departmentId }); + return SchoolsDepartmentNotFound.make({ departmentId }); }), Match.tag("OrganizationPersistenceError", (cause) => { return persistenceError("read Schools directory department", cause); @@ -104,7 +104,7 @@ export const readSchoolsDirectory = ( Predicate.isTagged(scope, "DepartmentIds") && !scope.departmentIds.includes(departmentId) ) { - return yield* new SchoolsDepartmentOutOfScope({ departmentId }); + return yield* SchoolsDepartmentOutOfScope.make({ departmentId }); } } diff --git a/packages/database/src/schools/postgres.ts b/packages/database/src/schools/postgres.ts index f1bba329..56dbc447 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/team-application/delivery.test.ts b/packages/database/src/team-application/delivery.test.ts index d18f76ef..1c4181b0 100644 --- a/packages/database/src/team-application/delivery.test.ts +++ b/packages/database/src/team-application/delivery.test.ts @@ -166,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/persistence.ts b/packages/database/src/team-application/persistence.ts index e369f5db..64697c45 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 876c7634..304b9b92 100644 --- a/packages/database/src/team-application/postgres.ts +++ b/packages/database/src/team-application/postgres.ts @@ -279,7 +279,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( @@ -372,13 +372,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; @@ -399,7 +399,7 @@ 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), }), ), @@ -440,7 +440,7 @@ export const readPublicTeamApplicationIntake = (teamId: TeamId) => 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; @@ -487,14 +487,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); @@ -550,7 +550,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; @@ -587,7 +587,7 @@ export const readTeamApplication = ( 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; @@ -628,7 +628,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); @@ -690,7 +690,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; diff --git a/packages/database/src/team-application/service.test.ts b/packages/database/src/team-application/service.test.ts index 2bdd34e9..2ef27fee 100644 --- a/packages/database/src/team-application/service.test.ts +++ b/packages/database/src/team-application/service.test.ts @@ -308,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); @@ -344,7 +344,7 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" .toReversed(), ); expect(yield* Effect.flip(page("not-a-cursor!"))).toEqual( - new TeamApplicationInvalidCursor(), + TeamApplicationInvalidCursor.make(), ); }), ); @@ -377,7 +377,7 @@ layer(teamApplicationsLayer, { excludeTestServices: true, timeout: "30 seconds" ); 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}'`), @@ -464,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); }), From 703e2589a06b7c0be73862389a353ff0e4203049 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:36:24 +0000 Subject: [PATCH 088/148] refactor(database): compare explicitly in context folder conditions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/application/returning-postgres.ts | 2 +- packages/database/src/authz/delegation-postgres.ts | 2 +- packages/database/src/onboarding/postgres.ts | 10 +++++----- packages/database/src/receipt/reviewed-cohort.ts | 14 +++++++------- packages/database/src/schools/administration.ts | 8 ++++---- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/database/src/application/returning-postgres.ts b/packages/database/src/application/returning-postgres.ts index c9df3593..2bc50cc2 100644 --- a/packages/database/src/application/returning-postgres.ts +++ b/packages/database/src/application/returning-postgres.ts @@ -529,7 +529,7 @@ export const registerReturningAssistant = ( 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)), diff --git a/packages/database/src/authz/delegation-postgres.ts b/packages/database/src/authz/delegation-postgres.ts index 95a6d9cf..e71962bf 100644 --- a/packages/database/src/authz/delegation-postgres.ts +++ b/packages/database/src/authz/delegation-postgres.ts @@ -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/onboarding/postgres.ts b/packages/database/src/onboarding/postgres.ts index 8c80c800..08056bec 100644 --- a/packages/database/src/onboarding/postgres.ts +++ b/packages/database/src/onboarding/postgres.ts @@ -19,7 +19,7 @@ export const onboardingApplication = (applicationId: string, departmentId: Depar 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]; }), @@ -68,7 +68,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; @@ -106,7 +106,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); @@ -123,7 +123,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 }); @@ -143,6 +143,6 @@ export const checkOnboardingClaim = (digest: string, now: string) => 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); }), ); diff --git a/packages/database/src/receipt/reviewed-cohort.ts b/packages/database/src/receipt/reviewed-cohort.ts index 2c472e6c..2c0a77cf 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/schools/administration.ts b/packages/database/src/schools/administration.ts index 87206e8f..c1337887 100644 --- a/packages/database/src/schools/administration.ts +++ b/packages/database/src/schools/administration.ts @@ -116,7 +116,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 ( @@ -242,7 +242,7 @@ export const executeSchoolCommand = (authorized: SchoolCommandAuthorization) => 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); @@ -357,7 +357,7 @@ export const executeSchoolCommand = (authorized: SchoolCommandAuthorization) => 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; @@ -369,7 +369,7 @@ export const executeSchoolCommand = (authorized: SchoolCommandAuthorization) => ); 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; From 9cea8e65c3231e7b3d8bdba3c226847edd5b7246 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:36:57 +0000 Subject: [PATCH 089/148] refactor(database): use concise arrow bodies in context folders Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/authz/disposable-backfill.ts | 17 ++++--------- packages/database/src/receipt/outbox.ts | 15 +++++------- packages/database/src/schools/directory.ts | 24 +++++++++---------- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/packages/database/src/authz/disposable-backfill.ts b/packages/database/src/authz/disposable-backfill.ts index 0bccffae..a5d80db4 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/receipt/outbox.ts b/packages/database/src/receipt/outbox.ts index 9a2f32e4..ce995b8c 100644 --- a/packages/database/src/receipt/outbox.ts +++ b/packages/database/src/receipt/outbox.ts @@ -220,24 +220,21 @@ 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, diff --git a/packages/database/src/schools/directory.ts b/packages/database/src/schools/directory.ts index 746dec2c..ab0cb03d 100644 --- a/packages/database/src/schools/directory.ts +++ b/packages/database/src/schools/directory.ts @@ -85,19 +85,17 @@ export const readSchoolsDirectory = ( if (departmentId !== undefined) { yield* organization.readDepartment(departmentId).pipe( Effect.asVoid, - Effect.mapError((cause) => { - return Match.value(cause).pipe( - Match.tag("DepartmentNotFound", () => { - return SchoolsDepartmentNotFound.make({ departmentId }); - }), - Match.tag("OrganizationPersistenceError", (cause) => { - return persistenceError("read Schools directory department", cause); - }), - Match.orElse((cause) => { - return decodeError("read Schools directory department", cause); - }), - ); - }), + 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 ( From abd29f30b611850b783a62f31d2da49eb5f24811 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:37:37 +0000 Subject: [PATCH 090/148] refactor(database): write explicit condition tests in recruitment, organization, placements Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../src/organization/lifecycle-postgres.ts | 18 +++-- .../database/src/organization/postgres.ts | 38 +++++----- .../src/organization/reviewed-cohort.ts | 58 +++++++++----- .../placements/current-assignment-cohort.ts | 75 ++++++++++++------- packages/database/src/placements/postgres.ts | 13 ++-- .../src/recruitment/conduct-identity.ts | 2 +- .../src/recruitment/conduct-postgres.ts | 11 ++- .../invitation-response-postgres.ts | 29 ++++--- .../src/recruitment/maintenance-postgres.ts | 4 +- packages/database/src/recruitment/postgres.ts | 18 ++--- .../src/recruitment/report-postgres.ts | 8 +- .../src/recruitment/scheduling-postgres.ts | 33 ++++---- 12 files changed, 174 insertions(+), 133 deletions(-) diff --git a/packages/database/src/organization/lifecycle-postgres.ts b/packages/database/src/organization/lifecycle-postgres.ts index d99755fe..820ca3b3 100644 --- a/packages/database/src/organization/lifecycle-postgres.ts +++ b/packages/database/src/organization/lifecycle-postgres.ts @@ -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/postgres.ts b/packages/database/src/organization/postgres.ts index 6cb9c9fa..3344629a 100644 --- a/packages/database/src/organization/postgres.ts +++ b/packages/database/src/organization/postgres.ts @@ -39,31 +39,28 @@ const persistenceError = (operation: string, cause: unknown) => const decodeDepartment = flow( Schema.decodeUnknownEffect(Department, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - OrganizationDecodeError.make({ - 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) => - OrganizationDecodeError.make({ 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) => - OrganizationDecodeError.make({ - operation: "decode Membership select", - message: String(cause), - }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ + operation: "decode Membership select", + message: String(cause), + }), ), ); @@ -591,12 +588,11 @@ export const importOrganizationSnapshot = ( const decodeTeamInterestRegistration = flow( Schema.decodeUnknownEffect(TeamInterestRegistration, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - OrganizationDecodeError.make({ - operation: "decode TeamInterestRegistration select", - message: String(cause), - }), + Effect.mapError((cause) => + OrganizationDecodeError.make({ + operation: "decode TeamInterestRegistration select", + message: String(cause), + }), ), ); diff --git a/packages/database/src/organization/reviewed-cohort.ts b/packages/database/src/organization/reviewed-cohort.ts index 32f37639..1f473e5d 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/placements/current-assignment-cohort.ts b/packages/database/src/placements/current-assignment-cohort.ts index 3d8ee94f..69e26ac7 100644 --- a/packages/database/src/placements/current-assignment-cohort.ts +++ b/packages/database/src/placements/current-assignment-cohort.ts @@ -12,6 +12,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; @@ -213,7 +217,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 +294,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 +339,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 +364,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 +380,7 @@ const validateReconciledProvenance = Effect.fnUntraced(function* ( ], ); - if (!personSnapshot.rowCount) + if (!hasRowCount(personSnapshot.rowCount)) return yield* new CurrentAssignmentFailure({ code: "PersonSnapshotConflict" }); return relationships; @@ -453,14 +460,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 +484,7 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( ], ); - if (!review.rowCount) + if (!hasRowCount(review.rowCount)) return yield* new CurrentAssignmentFailure({ code: "SnapshotConflict" }); } @@ -487,13 +494,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 +534,21 @@ 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 +592,22 @@ 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 +636,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 +705,7 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( ], ); - if (affiliation) { + if (affiliation !== undefined) { if ( affiliation.status !== "Active" || affiliation.revision !== 1 || @@ -701,12 +713,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 +740,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/postgres.ts b/packages/database/src/placements/postgres.ts index 0ccef8ec..3d4207fc 100644 --- a/packages/database/src/placements/postgres.ts +++ b/packages/database/src/placements/postgres.ts @@ -90,7 +90,7 @@ 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"); }), ); @@ -100,7 +100,7 @@ export const readOwnAffiliation = (personId: PersonId, departmentId: DepartmentI 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}`; @@ -138,7 +138,7 @@ export const readPlacementBoard = (scope: PlacementScope) => 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`; @@ -380,9 +380,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 +400,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}`; diff --git a/packages/database/src/recruitment/conduct-identity.ts b/packages/database/src/recruitment/conduct-identity.ts index cd75aa4c..ee2c07e0 100644 --- a/packages/database/src/recruitment/conduct-identity.ts +++ b/packages/database/src/recruitment/conduct-identity.ts @@ -19,7 +19,7 @@ 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* RecruitmentInterviewNotFound.make({ interviewId }); + if (rows[0] === undefined) return yield* RecruitmentInterviewNotFound.make({ interviewId }); return rows[0]; }), diff --git a/packages/database/src/recruitment/conduct-postgres.ts b/packages/database/src/recruitment/conduct-postgres.ts index f5fa7da3..9e68feda 100644 --- a/packages/database/src/recruitment/conduct-postgres.ts +++ b/packages/database/src/recruitment/conduct-postgres.ts @@ -151,12 +151,11 @@ const persistenceError = (operation: string, cause?: Error) => const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - RecruitmentPersistenceError.make({ - operation: `decode ${operation}`, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentPersistenceError.make({ + operation: `decode ${operation}`, + message: String(cause), + }), ), ); diff --git a/packages/database/src/recruitment/invitation-response-postgres.ts b/packages/database/src/recruitment/invitation-response-postgres.ts index 96567de7..2f067209 100644 --- a/packages/database/src/recruitment/invitation-response-postgres.ts +++ b/packages/database/src/recruitment/invitation-response-postgres.ts @@ -75,11 +75,10 @@ const formatIssue = SchemaIssue.makeFormatterDefault(); const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -196,12 +195,11 @@ const observationFromRow = ( responseState: row.responseState, responseMessage: row.responseMessage, }).pipe( - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - 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"}`, + }), ), ); @@ -467,11 +465,10 @@ const recordInvitationResponse = ( ); return yield* responseEffect.pipe( - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - message: SchemaIssue.isIssue(cause) ? formatIssue(cause) : String(cause), - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: SchemaIssue.isIssue(cause) ? formatIssue(cause) : String(cause), + }), ), ); }); diff --git a/packages/database/src/recruitment/maintenance-postgres.ts b/packages/database/src/recruitment/maintenance-postgres.ts index a1cdafe9..8c7b22a5 100644 --- a/packages/database/src/recruitment/maintenance-postgres.ts +++ b/packages/database/src/recruitment/maintenance-postgres.ts @@ -263,7 +263,7 @@ export const maintainRecruitment = (authorization: RecruitmentMaintenanceAuthori 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)( @@ -277,7 +277,7 @@ export const maintainRecruitment = (authorization: RecruitmentMaintenanceAuthori 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/postgres.ts b/packages/database/src/recruitment/postgres.ts index b769faa6..40cf95ea 100644 --- a/packages/database/src/recruitment/postgres.ts +++ b/packages/database/src/recruitment/postgres.ts @@ -206,11 +206,10 @@ const persistenceError = (operation: string, cause?: unknown): RecruitmentPersis const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -981,11 +980,10 @@ const assignmentInTransaction = ( commandId: command.commandId, interview, }).pipe( - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - message: cause instanceof Error ? cause.message : `invalid ${"assignment observation"}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${"assignment observation"}`, + }), ), ); diff --git a/packages/database/src/recruitment/report-postgres.ts b/packages/database/src/recruitment/report-postgres.ts index 87892608..18e51c55 100644 --- a/packages/database/src/recruitment/report-postgres.ts +++ b/packages/database/src/recruitment/report-postgres.ts @@ -60,7 +60,9 @@ export const readCompletedInterviewReport = ( Effect.gen(function* () { const query = yield* Schema.decodeEffect(InterviewReportQuery)(input, { onExcessProperty: "error", - }).pipe(Effect.mapError(() => RecruitmentDecodeError.make({ message: "invalid report query" }))); + }).pipe( + Effect.mapError(() => RecruitmentDecodeError.make({ message: "invalid report query" })), + ); const sql = yield* Database; @@ -135,8 +137,8 @@ export const readCompletedInterviewReport = ( .map(({ linkedPersonId: _identity, ...row }) => row), { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - () => RecruitmentDecodeError.make({ message: "invalid persisted report row" }), + Effect.mapError(() => + RecruitmentDecodeError.make({ message: "invalid persisted report row" }), ), ); } diff --git a/packages/database/src/recruitment/scheduling-postgres.ts b/packages/database/src/recruitment/scheduling-postgres.ts index 11212d08..caf225ff 100644 --- a/packages/database/src/recruitment/scheduling-postgres.ts +++ b/packages/database/src/recruitment/scheduling-postgres.ts @@ -160,11 +160,10 @@ const readApplicantContacts = ( const decode = (schema: Schema.ConstraintDecoder, operation: string) => flow( Schema.decodeUnknownEffect(schema, { onExcessProperty: "error" }), - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - message: cause instanceof Error ? cause.message : `invalid ${operation}`, - }), + Effect.mapError((cause) => + RecruitmentDecodeError.make({ + message: cause instanceof Error ? cause.message : `invalid ${operation}`, + }), ), ); @@ -662,11 +661,10 @@ const writeScheduleRows = ( responseState: "Pending", notificationState: "Pending", }).pipe( - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - 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 +717,13 @@ const writeScheduleRows = ( message: command.message, responseCapability: context.responseCapability, }).pipe( - Effect.mapError( - (cause) => - RecruitmentDecodeError.make({ - 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"}`, + }), ), ); From b7f0931e38469da3b42b0bdbbbc6285dc34024cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:37:44 +0000 Subject: [PATCH 091/148] refactor(database): use deterministic service keys in team-application Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/team-application/delivery-pgbouncer.test.ts | 2 +- packages/database/src/team-application/outbox.ts | 2 +- packages/database/src/team-application/queue-migration.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/database/src/team-application/delivery-pgbouncer.test.ts b/packages/database/src/team-application/delivery-pgbouncer.test.ts index 1e3670e1..2aa2cfaa 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/outbox.ts b/packages/database/src/team-application/outbox.ts index ecf72c16..a32c4e30 100644 --- a/packages/database/src/team-application/outbox.ts +++ b/packages/database/src/team-application/outbox.ts @@ -70,7 +70,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 diff --git a/packages/database/src/team-application/queue-migration.test.ts b/packages/database/src/team-application/queue-migration.test.ts index 3e885490..e9eb03ec 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( From e1b8b003a3bc99c4cb69962dab55ba143f1d3de9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:38:37 +0000 Subject: [PATCH 092/148] fix(database): compare explicitly in conditions of the root database modules Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../historical-service-cohort-rehearsal.ts | 2 +- .../runtime/person-cohort-rehearsal.ts | 2 +- packages/database/src/auth-engine.ts | 33 ++++++------ packages/database/src/cohort-cli.ts | 8 +-- .../database/src/historical-service-cohort.ts | 36 +++++++++---- packages/database/src/identity-access.ts | 2 +- packages/database/src/identity-cohort.ts | 47 +++++++++-------- packages/database/src/onboarding-account.ts | 2 +- packages/database/src/password-recovery.ts | 23 +++++--- packages/database/src/person-cohort.ts | 52 ++++++++++++------- .../disposable-person-authority-backfill.ts | 2 +- 11 files changed, 126 insertions(+), 83 deletions(-) diff --git a/packages/database/runtime/historical-service-cohort-rehearsal.ts b/packages/database/runtime/historical-service-cohort-rehearsal.ts index cfcc2472..07a24fad 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/person-cohort-rehearsal.ts b/packages/database/runtime/person-cohort-rehearsal.ts index 864b3a1e..ed48db5e 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/src/auth-engine.ts b/packages/database/src/auth-engine.ts index dae5f8f9..af4058db 100644 --- a/packages/database/src/auth-engine.ts +++ b/packages/database/src/auth-engine.ts @@ -110,7 +110,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 +122,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 +142,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 +197,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 +224,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 +290,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 +339,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 +351,7 @@ const makeAccessDatabaseHooks = ( [personId], )).rows[0]; - if (row) return row.revision; + if (row !== undefined) return row.revision; } return yield* Effect.fail( diff --git a/packages/database/src/cohort-cli.ts b/packages/database/src/cohort-cli.ts index a9d6874b..64505932 100644 --- a/packages/database/src/cohort-cli.ts +++ b/packages/database/src/cohort-cli.ts @@ -13,10 +13,10 @@ export const parseDisposableCohortDatabaseUrl = ( 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(); @@ -40,7 +40,7 @@ export const readPrivateCohortJson = ( ): 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)), diff --git a/packages/database/src/historical-service-cohort.ts b/packages/database/src/historical-service-cohort.ts index 5b38d2e8..2c59885b 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 5df75111..cbd2effc 100644 --- a/packages/database/src/identity-access.ts +++ b/packages/database/src/identity-access.ts @@ -59,7 +59,7 @@ export const changeNativeAccountAccess = Effect.fn("changeNativeAccountAccess")( const current = rows[0]; - if (!current) return yield* new AccountAccessFailure({ code: "NotFound" }); + if (current === undefined) return yield* new AccountAccessFailure({ 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 b67165b4..ad2d6cf0 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/onboarding-account.ts b/packages/database/src/onboarding-account.ts index b6691e19..5723301d 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/password-recovery.ts b/packages/database/src/password-recovery.ts index 319c9e32..d6e4f94f 100644 --- a/packages/database/src/password-recovery.ts +++ b/packages/database/src/password-recovery.ts @@ -186,7 +186,7 @@ export const makePasswordRecovery = ( }) => { const state = local.getStore(); - if (!state) { + if (state === undefined) { return Effect.fail( new IdentityEngineError({ operation: "sendResetPassword", @@ -223,7 +223,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: ( @@ -290,7 +290,7 @@ 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({ @@ -409,7 +409,7 @@ export const drainPasswordResetMail = ( }), ).pipe(Effect.orDie); - if (!row) return "Empty"; + if (row === undefined) return "Empty"; const verification = (yield* pgQuery<{ identifier: string; @@ -435,9 +435,16 @@ export const drainPasswordResetMail = ( let interruptedCause: Cause.Cause | undefined; let quarantined = false; - if (!verification || !/^reset-password:[A-Za-z0-9_-]+$/.test(verification.identifier)) { + if ( + verification === undefined || + !/^reset-password:[A-Za-z0-9_-]+$/.test(verification.identifier) + ) { failure = "verification-invalid"; - } else if (verification.value !== row.subject_person_id || !verification.email) { + } 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"; @@ -445,7 +452,7 @@ export const drainPasswordResetMail = ( quarantined = failure !== null; - if (!failure && verification) { + if (failure === null && verification !== undefined) { const token = verification.identifier.slice("reset-password:".length); const request = { @@ -533,7 +540,7 @@ export const drainPasswordResetMail = ( }), ).pipe(Effect.orDie); - if (interruptedCause) return yield* Effect.failCause(interruptedCause); + if (interruptedCause !== undefined) return yield* Effect.failCause(interruptedCause); return outcome; }), diff --git a/packages/database/src/person-cohort.ts b/packages/database/src/person-cohort.ts index 5fb45f6a..c56b2ed6 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/test-support/disposable-person-authority-backfill.ts b/packages/database/src/test-support/disposable-person-authority-backfill.ts index 117ddb96..ec37a1df 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) ) { From 4aaf465b2d6250561adf96cb0c60ee7f0f4b7b11 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:38:55 +0000 Subject: [PATCH 093/148] fix(domain): name each service by its deterministic key The effecttsgo deterministic-keys rule derives a service key from its module path and class name. Each key is referenced only by its own class, so no lookup changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/domain/runtime/tutor-d1-proof-main.ts | 2 +- packages/domain/src/admissions/service.ts | 2 +- packages/domain/src/application/returning.ts | 2 +- packages/domain/src/authz/service-principal-grants.ts | 2 +- packages/domain/src/contact/service.ts | 4 ++-- packages/domain/src/content/content-service.ts | 2 +- packages/domain/src/content/service.ts | 2 +- packages/domain/src/identity/service.ts | 2 +- packages/domain/src/mail.ts | 2 +- packages/domain/src/notification/service.ts | 2 +- packages/domain/src/organization/service.ts | 2 +- packages/domain/src/placements/service.ts | 2 +- packages/domain/src/profile/service.ts | 2 +- packages/domain/src/receipt/auxiliary-service.ts | 2 +- packages/domain/src/receipt/file-service.ts | 2 +- packages/domain/src/receipt/service.ts | 2 +- packages/domain/src/recruitment/service.ts | 2 +- packages/domain/src/runtime-services.ts | 4 ++-- packages/domain/src/schools/service.ts | 2 +- packages/domain/src/social-events/service.ts | 2 +- packages/domain/src/team-application/service.ts | 2 +- packages/domain/src/tutor/d1-proof.ts | 2 +- 22 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/domain/runtime/tutor-d1-proof-main.ts b/packages/domain/runtime/tutor-d1-proof-main.ts index 07fe8e83..85043f80 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/src/admissions/service.ts b/packages/domain/src/admissions/service.ts index 5cead189..0834a84f 100644 --- a/packages/domain/src/admissions/service.ts +++ b/packages/domain/src/admissions/service.ts @@ -95,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/returning.ts b/packages/domain/src/application/returning.ts index 62b3adf9..b759c9ec 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/authz/service-principal-grants.ts b/packages/domain/src/authz/service-principal-grants.ts index 16660d67..ee84f7bd 100644 --- a/packages/domain/src/authz/service-principal-grants.ts +++ b/packages/domain/src/authz/service-principal-grants.ts @@ -182,7 +182,7 @@ 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, diff --git a/packages/domain/src/contact/service.ts b/packages/domain/src/contact/service.ts index 0f73532a..338d2bd9 100644 --- a/packages/domain/src/contact/service.ts +++ b/packages/domain/src/contact/service.ts @@ -19,14 +19,14 @@ 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) => diff --git a/packages/domain/src/content/content-service.ts b/packages/domain/src/content/content-service.ts index edbd459a..17354fc9 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/service.ts b/packages/domain/src/content/service.ts index deb5a7c2..a7d0e9a9 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/service.ts b/packages/domain/src/identity/service.ts index 1418ea06..80a0656e 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/mail.ts b/packages/domain/src/mail.ts index 8d4e79cd..3dcae363 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 f37e9df1..20094d6c 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/service.ts b/packages/domain/src/organization/service.ts index b0d3a174..dde9ef42 100644 --- a/packages/domain/src/organization/service.ts +++ b/packages/domain/src/organization/service.ts @@ -210,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/placements/service.ts b/packages/domain/src/placements/service.ts index a28eae88..b9a519c7 100644 --- a/packages/domain/src/placements/service.ts +++ b/packages/domain/src/placements/service.ts @@ -222,5 +222,5 @@ export interface PlacementsOperations { /** 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/service.ts b/packages/domain/src/profile/service.ts index 677a66ff..9893b474 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/auxiliary-service.ts b/packages/domain/src/receipt/auxiliary-service.ts index d04c5128..10f2ac6f 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; diff --git a/packages/domain/src/receipt/file-service.ts b/packages/domain/src/receipt/file-service.ts index 04092022..dfa948d3 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; diff --git a/packages/domain/src/receipt/service.ts b/packages/domain/src/receipt/service.ts index 37a12607..c508c464 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/recruitment/service.ts b/packages/domain/src/recruitment/service.ts index 87e756d8..c4db6cb1 100644 --- a/packages/domain/src/recruitment/service.ts +++ b/packages/domain/src/recruitment/service.ts @@ -240,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/runtime-services.ts b/packages/domain/src/runtime-services.ts index 3de82ad0..e4ec31af 100644 --- a/packages/domain/src/runtime-services.ts +++ b/packages/domain/src/runtime-services.ts @@ -21,7 +21,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 +29,7 @@ export interface DomainProcessOperations { } export class DomainProcess extends Context.Service()( - "@vektorprogrammet/domain/DomainProcess", + "@vektorprogrammet/domain/runtime-services/DomainProcess", ) {} export const readTextFile = ( diff --git a/packages/domain/src/schools/service.ts b/packages/domain/src/schools/service.ts index f5ca146f..7380642b 100644 --- a/packages/domain/src/schools/service.ts +++ b/packages/domain/src/schools/service.ts @@ -29,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/service.ts b/packages/domain/src/social-events/service.ts index 95f4735c..7d32b8e4 100644 --- a/packages/domain/src/social-events/service.ts +++ b/packages/domain/src/social-events/service.ts @@ -45,5 +45,5 @@ export interface SocialEventsOperations { } export class SocialEvents extends Context.Service()( - "@vektorprogrammet/domain/SocialEvents", + "@vektorprogrammet/domain/social-events/service/SocialEvents", ) {} diff --git a/packages/domain/src/team-application/service.ts b/packages/domain/src/team-application/service.ts index 1793baf5..89068dae 100644 --- a/packages/domain/src/team-application/service.ts +++ b/packages/domain/src/team-application/service.ts @@ -170,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/tutor/d1-proof.ts b/packages/domain/src/tutor/d1-proof.ts index a3ca022d..05a7ce30 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( From 2d088b7edac8ddd2c5f63ec76e040890811a5020 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:39:05 +0000 Subject: [PATCH 094/148] test(database): inline nested Effect generators in context folder tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/application/postgres.test.ts | 28 +- packages/database/src/content.test.ts | 140 ++++---- .../database/src/content/postgres.test.ts | 284 +++++++-------- .../database/src/profile/postgres.test.ts | 22 +- .../src/receipt-approval-list.test.ts | 170 +++++---- .../database/src/receipt-settlement.test.ts | 336 +++++++++--------- packages/database/src/schools.test.ts | 254 +++++++------ .../database/src/schools/directory.test.ts | 30 +- .../database/src/schools/postgres.test.ts | 18 +- 9 files changed, 619 insertions(+), 663 deletions(-) diff --git a/packages/database/src/application/postgres.test.ts b/packages/database/src/application/postgres.test.ts index 5bfd6857..39876b8f 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/content.test.ts b/packages/database/src/content.test.ts index e7cdda67..bdfd3e28 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/postgres.test.ts b/packages/database/src/content/postgres.test.ts index 95b4589a..6a929a6d 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/profile/postgres.test.ts b/packages/database/src/profile/postgres.test.ts index 35c10867..7984e7a8 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/receipt-approval-list.test.ts b/packages/database/src/receipt-approval-list.test.ts index 320723f5..5bcfe231 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,57 +226,56 @@ 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; expect(authorityEvidence).toEqual({ diff --git a/packages/database/src/receipt-settlement.test.ts b/packages/database/src/receipt-settlement.test.ts index 43699d19..7fd17455 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/schools.test.ts b/packages/database/src/schools.test.ts index b090492a..71162874 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,40 +213,39 @@ 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; - }); + const directory = directory; expect(directory).toEqual({ activeSchools: [ @@ -278,12 +272,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 +290,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 +324,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 +338,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/directory.test.ts b/packages/database/src/schools/directory.test.ts index 7cae807f..cb9d79bc 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/postgres.test.ts b/packages/database/src/schools/postgres.test.ts index 026a151d..e19d6184 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"); From 828e5b2450ca77be9925127c5416fa3009217d1a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:39:19 +0000 Subject: [PATCH 095/148] fix(domain): construct schema errors with make, use concise arrows, and test conditions exactly - new-schema-class: X.make(...) for every Schema tagged error; make validates like the constructor did. - unnecessary-arrow-block: a block that only returns becomes a concise body. - strict-boolean-expressions: each condition states the test its truthiness made (!== undefined for objects, === undefined || === "" for identity strings, === true for an optional-chained predicate). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../domain/src/admission-period/update.ts | 26 ++-- packages/domain/src/application/effects.ts | 2 +- packages/domain/src/application/validation.ts | 6 +- packages/domain/src/authz/access.ts | 130 ++++++++---------- packages/domain/src/authz/delegation.ts | 8 +- packages/domain/src/authz/rules.ts | 55 ++++---- packages/domain/src/contact/schema.ts | 3 +- packages/domain/src/content/actor.ts | 21 ++- packages/domain/src/content/journeys.ts | 16 +-- packages/domain/src/content/sanitize.ts | 8 +- packages/domain/src/identity/access.ts | 8 +- .../domain/src/organization/administration.ts | 2 +- packages/domain/src/organization/authority.ts | 2 +- packages/domain/src/organization/import.ts | 20 ++- packages/domain/src/organization/lifecycle.ts | 25 ++-- .../src/organization/review-classification.ts | 8 +- packages/domain/src/organization/review.ts | 2 +- packages/domain/src/placements/days-served.ts | 2 +- .../domain/src/profile/directory-cursor.ts | 4 +- packages/domain/src/receipt/authority.ts | 17 ++- .../domain/src/receipt/auxiliary-service.ts | 2 +- packages/domain/src/receipt/errors.ts | 7 +- packages/domain/src/receipt/file-service.ts | 12 +- packages/domain/src/receipt/pagination.ts | 2 +- packages/domain/src/receipt/review.ts | 2 +- packages/domain/src/receipt/update.ts | 38 ++--- packages/domain/src/recruitment/conduct.ts | 83 ++++++----- packages/domain/src/recruitment/scheduling.ts | 4 +- packages/domain/src/schema.ts | 51 +++---- .../domain/src/team-application/pagination.ts | 2 +- 30 files changed, 265 insertions(+), 303 deletions(-) diff --git a/packages/domain/src/admission-period/update.ts b/packages/domain/src/admission-period/update.ts index 21036e04..f6ed6158 100644 --- a/packages/domain/src/admission-period/update.ts +++ b/packages/domain/src/admission-period/update.ts @@ -44,7 +44,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 +56,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 +67,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 +77,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 +90,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 +106,7 @@ const checkWindow = ( compareRfc3339Instants(endAt, semester.endAt) > 0 ) { return Effect.fail( - new AdmissionWindowOutsideSemester({ + AdmissionWindowOutsideSemester.make({ semesterId: semester.semesterId, startAt, endAt, @@ -147,7 +149,7 @@ export const decideAdmissionPeriod = ( yield* managementActor(context.actor); if (!isRfc3339Instant(context.now)) { - return yield* new InvalidAdmissionPeriodWindow({ + return yield* InvalidAdmissionPeriodWindow.make({ startAt: context.now, endAt: context.now, reason: "EqualBounds", @@ -159,7 +161,7 @@ export const decideAdmissionPeriod = ( yield* checkWindow(command.startAt, command.endAt, context.semester); if (existing !== undefined) { - return yield* new AdmissionPeriodAlreadyExists({ + return yield* AdmissionPeriodAlreadyExists.make({ departmentId, semesterId: command.semesterId, }); @@ -186,7 +188,7 @@ export const decideAdmissionPeriod = ( const current = existing; if (current === undefined) { - return yield* new AdmissionPeriodNotFound({ admissionPeriodId: command.admissionPeriodId }); + return yield* AdmissionPeriodNotFound.make({ admissionPeriodId: command.admissionPeriodId }); } const actorDepartment = Predicate.isTagged(context.actor, "DepartmentAdministrator") @@ -194,7 +196,7 @@ export const decideAdmissionPeriod = ( : current.departmentId; if (actorDepartment !== current.departmentId) { - return yield* new AdmissionScopeDenied({ + return yield* AdmissionScopeDenied.make({ personId: context.actor.personId, departmentId: current.departmentId, admissionPeriodId: current.id, @@ -202,7 +204,7 @@ export const decideAdmissionPeriod = ( } if (current.revision !== command.expectedRevision) { - return yield* new StaleAdmissionPeriodRevision({ + return yield* StaleAdmissionPeriodRevision.make({ admissionPeriodId: current.id, expected: command.expectedRevision, actual: current.revision, diff --git a/packages/domain/src/application/effects.ts b/packages/domain/src/application/effects.ts index e199aba5..efa78e62 100644 --- a/packages/domain/src/application/effects.ts +++ b/packages/domain/src/application/effects.ts @@ -268,7 +268,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); diff --git a/packages/domain/src/application/validation.ts b/packages/domain/src/application/validation.ts index df1b6315..509fafb2 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/access.ts b/packages/domain/src/authz/access.ts index f35c43d2..7c6d5347 100644 --- a/packages/domain/src/authz/access.ts +++ b/packages/domain/src/authz/access.ts @@ -966,24 +966,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) && @@ -1005,21 +996,16 @@ export const credentialMatchesAccessSpec = ( 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,9 +1144,7 @@ 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 = ( @@ -1168,54 +1152,53 @@ export const expandAuthorityMacros = ( 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 = (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, ); }; @@ -1327,15 +1310,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 +1349,6 @@ const expressionForContext = ( }), Match.exhaustive, ); -}; export const evaluateAccess = (input: { readonly spec: AccessSpec; diff --git a/packages/domain/src/authz/delegation.ts b/packages/domain/src/authz/delegation.ts index 55efc639..3a7812a6 100644 --- a/packages/domain/src/authz/delegation.ts +++ b/packages/domain/src/authz/delegation.ts @@ -359,7 +359,7 @@ 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 @@ -381,7 +381,7 @@ export const transitionDelegation = ( if (current !== undefined) return invalid(); if (input.team === undefined) - return Result.fail(new DelegationTransitionFailure({ code: "NotFound" })); + return Result.fail(DelegationTransitionFailure.make({ code: "NotFound" })); if ( !input.team.active || @@ -404,10 +404,10 @@ export const transitionDelegation = ( }), Match.tag("EndDelegation", (command) => { if (current === undefined) - return Result.fail(new DelegationTransitionFailure({ code: "NotFound" })); + return Result.fail(DelegationTransitionFailure.make({ code: "NotFound" })); if (current.revision !== command.expectedRevision) - return Result.fail(new DelegationTransitionFailure({ code: "Stale" })); + return Result.fail(DelegationTransitionFailure.make({ code: "Stale" })); if ( delegationStateAt(current, input.now) === "Ended" || diff --git a/packages/domain/src/authz/rules.ts b/packages/domain/src/authz/rules.ts index 4b9e0bd3..81ecee07 100644 --- a/packages/domain/src/authz/rules.ts +++ b/packages/domain/src/authz/rules.ts @@ -57,25 +57,27 @@ export const authzRuleSubjectApplies = ( 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 ( + 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) => { - return ( + 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, ); }; @@ -83,28 +85,21 @@ export const authzRuleSubjectApplies = ( export const authzRuleScopeApplies = ( rule: AuthzRule, context: CanonicalResourceContext, -): boolean => { - return Match.value(rule.scope).pipe( +): 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) && diff --git a/packages/domain/src/contact/schema.ts b/packages/domain/src/contact/schema.ts index 17f0ed0c..82de9288 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/content/actor.ts b/packages/domain/src/content/actor.ts index 5a468de1..9eebb8ab 100644 --- a/packages/domain/src/content/actor.ts +++ b/packages/domain/src/content/actor.ts @@ -81,21 +81,18 @@ 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 = ( diff --git a/packages/domain/src/content/journeys.ts b/packages/domain/src/content/journeys.ts index d3077232..36ca4610 100644 --- a/packages/domain/src/content/journeys.ts +++ b/packages/domain/src/content/journeys.ts @@ -57,18 +57,10 @@ 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, ); }); diff --git a/packages/domain/src/content/sanitize.ts b/packages/domain/src/content/sanitize.ts index c977c317..3e1fef59 100644 --- a/packages/domain/src/content/sanitize.ts +++ b/packages/domain/src/content/sanitize.ts @@ -145,16 +145,16 @@ export const sanitizeArticleBodyHtml = ( ): Effect.Effect => Effect.try({ try: () => sanitize(bodyHtml), - catch: () => new ContentDecodeError({ operation, message: "sanitizer failure" }), + catch: () => ContentDecodeError.make({ operation, message: "sanitizer failure" }), }).pipe( Effect.flatMap((result) => { if (result.rejection !== undefined) { - return Effect.fail(new ContentDecodeError({ operation, message: result.rejection })); + return Effect.fail(ContentDecodeError.make({ operation, message: result.rejection })); } if (result.html.trim().length === 0) { return Effect.fail( - new ContentDecodeError({ + ContentDecodeError.make({ operation, message: "sanitized article body must contain non-empty content", }), @@ -163,7 +163,7 @@ export const sanitizeArticleBodyHtml = ( if (new TextEncoder().encode(result.html).byteLength > 100000) { return Effect.fail( - new ContentDecodeError({ + ContentDecodeError.make({ operation, message: "sanitized article body exceeds the 100000-byte limit", }), diff --git a/packages/domain/src/identity/access.ts b/packages/domain/src/identity/access.ts index 647a0cd7..3bbddc57 100644 --- a/packages/domain/src/identity/access.ts +++ b/packages/domain/src/identity/access.ts @@ -23,16 +23,16 @@ export const transitionAccountAccess = ( context: { readonly actorPersonId: PersonId; readonly anotherUsableAdministrator: boolean }, ): Result.Result => { if (current.revision !== input.expectedRevision) - return Result.fail(new AccountAccessFailure({ code: "Stale" })); + return Result.fail(AccountAccessFailure.make({ code: "Stale" })); if (input.disabled && current.personId === context.actorPersonId) - return Result.fail(new AccountAccessFailure({ code: "SelfDisable" })); + return Result.fail(AccountAccessFailure.make({ code: "SelfDisable" })); if (input.disabled && !context.anotherUsableAdministrator) - return Result.fail(new AccountAccessFailure({ code: "LastAdministrator" })); + return Result.fail(AccountAccessFailure.make({ code: "LastAdministrator" })); if (input.disabled === current.disabled) - return Result.fail(new AccountAccessFailure({ code: "Invalid" })); + return Result.fail(AccountAccessFailure.make({ code: "Invalid" })); return Result.succeed({ ...current, disabled: input.disabled, revision: current.revision + 1 }); }; diff --git a/packages/domain/src/organization/administration.ts b/packages/domain/src/organization/administration.ts index 7d4229d2..2121c16d 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -20,7 +20,7 @@ import { } from "./schema.js"; const decodeError = (operation: string, cause: unknown) => - new OrganizationDecodeError({ operation, message: String(cause) }); + OrganizationDecodeError.make({ operation, message: String(cause) }); export const decodeCreateDepartmentCommand = flow( Schema.decodeUnknownEffect(CreateDepartmentCommandSchema, { diff --git a/packages/domain/src/organization/authority.ts b/packages/domain/src/organization/authority.ts index 60451fcc..c43530b4 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -281,7 +281,7 @@ Result.Result => } as OrganizationAdministratorEvidence, ) : Result.fail( - new OrganizationRoleDenied({ + OrganizationRoleDenied.make({ actorPersonId: authority.personId, requiredRole: "OrganizationAdministrator", }), diff --git a/packages/domain/src/organization/import.ts b/packages/domain/src/organization/import.ts index 979687f0..6d10f039 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 0c513f6e..2809d695 100644 --- a/packages/domain/src/organization/lifecycle.ts +++ b/packages/domain/src/organization/lifecycle.ts @@ -219,8 +219,8 @@ export const transitionAppointment = ( Match.value(command).pipe( Match.withReturnType>(), Match.tag("Appoint", (command) => - current - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) + current !== undefined + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) : Result.succeed({ appointmentId, personId: command.personId, @@ -235,10 +235,11 @@ export const transitionAppointment = ( }), ), Match.orElse((command) => { - if (!current) return Result.fail(new AppointmentTransitionFailure({ code: "NotFound" })); + if (current === undefined) + return Result.fail(AppointmentTransitionFailure.make({ code: "NotFound" })); if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); + return Result.fail(AppointmentTransitionFailure.make({ code: "Stale" })); return Match.value(command).pipe( Match.withReturnType>(), @@ -254,17 +255,17 @@ export const transitionAppointment = ( ), Match.tag("EndAppointment", (command) => current.endAt !== null && compareRfc3339Instants(current.endAt, now) <= 0 - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) : Result.succeed({ ...current, endAt: command.endAt, revision: current.revision + 1 }), ), Match.tag("SuspendAppointment", () => current.suspended - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) : Result.succeed({ ...current, suspended: true, revision: current.revision + 1 }), ), Match.tag("ReinstateAppointment", () => !current.suspended - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) : Result.succeed({ ...current, suspended: false, revision: current.revision + 1 }), ), Match.exhaustive, @@ -272,7 +273,7 @@ export const transitionAppointment = ( }), Result.flatMap((next) => next.endAt !== null && compareRfc3339Instants(next.endAt, next.startAt) <= 0 - ? Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })) + ? Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })) : Result.succeed({ ...next, state: appointmentStateAt(next, now) }), ), ); @@ -294,14 +295,14 @@ export const transitionTeamClassification = ( anotherBoardInDepartment: boolean, ): Result.Result => { if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); + 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(new AppointmentTransitionFailure({ code: "Invalid" })); + return Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })); return Result.succeed({ ...current, @@ -317,10 +318,10 @@ export const transitionDepartmentRecognition = ( command: RecogniseDepartmentCommand, ): Result.Result => { if (current.revision !== command.expectedRevision) - return Result.fail(new AppointmentTransitionFailure({ code: "Stale" })); + return Result.fail(AppointmentTransitionFailure.make({ code: "Stale" })); if (command.independent === current.independent) - return Result.fail(new AppointmentTransitionFailure({ code: "Invalid" })); + return Result.fail(AppointmentTransitionFailure.make({ code: "Invalid" })); return Result.succeed({ ...current, diff --git a/packages/domain/src/organization/review-classification.ts b/packages/domain/src/organization/review-classification.ts index 28c91ad9..9d8f59f9 100644 --- a/packages/domain/src/organization/review-classification.ts +++ b/packages/domain/src/organization/review-classification.ts @@ -285,7 +285,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 +299,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 +351,7 @@ export const classifyReviewedOrganization = ( const board = boards.find((board) => board.id === decoded.value.boardId); - if (!board) { + if (board === undefined) { reject("ReferenceMissing"); continue; } diff --git a/packages/domain/src/organization/review.ts b/packages/domain/src/organization/review.ts index 20df3fb3..2235a5c4 100644 --- a/packages/domain/src/organization/review.ts +++ b/packages/domain/src/organization/review.ts @@ -139,7 +139,7 @@ export const validateOrganizationReview = ( if ( keys.has(key) || - !entry || + entry === undefined || entry.sourceRowDigest !== occurrence.sourceRowDigest || occurrence.sourceRowDigest !== organizationEvidenceDigest(occurrence.row) ) diff --git a/packages/domain/src/placements/days-served.ts b/packages/domain/src/placements/days-served.ts index 8d9ae559..d85e06d0 100644 --- a/packages/domain/src/placements/days-served.ts +++ b/packages/domain/src/placements/days-served.ts @@ -216,7 +216,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; diff --git a/packages/domain/src/profile/directory-cursor.ts b/packages/domain/src/profile/directory-cursor.ts index f8233060..a3e17295 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/receipt/authority.ts b/packages/domain/src/receipt/authority.ts index a4a2779a..adb762c7 100644 --- a/packages/domain/src/receipt/authority.ts +++ b/packages/domain/src/receipt/authority.ts @@ -466,13 +466,13 @@ 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) @@ -651,12 +651,11 @@ export const mapExistingReceiptApprovalActor = ( receiptDepartmentId: DepartmentId, ): Effect.Effect => mapReceiptApprovalActor(authority, receiptDepartmentId).pipe( - Effect.mapError( - () => - new ReceiptScopeDenied({ - receiptId, - departmentId: receiptDepartmentId, - }), + Effect.mapError(() => + ReceiptScopeDenied.make({ + receiptId, + departmentId: receiptDepartmentId, + }), ), ); @@ -694,7 +693,7 @@ export const mapExistingReceiptSettlementActor = ( ): Effect.Effect => { const selected = selectReceiptSettlementGrant(authority, receiptDepartmentId); - if (selected?.active !== true) return Effect.fail(new ReceiptNotFound({ receiptId })); + if (selected?.active !== true) return Effect.fail(ReceiptNotFound.make({ receiptId })); return Effect.succeed({ personId: authority.personId, diff --git a/packages/domain/src/receipt/auxiliary-service.ts b/packages/domain/src/receipt/auxiliary-service.ts index 10f2ac6f..87e8edf8 100644 --- a/packages/domain/src/receipt/auxiliary-service.ts +++ b/packages/domain/src/receipt/auxiliary-service.ts @@ -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/errors.ts b/packages/domain/src/receipt/errors.ts index ab51be0a..66e54d27 100644 --- a/packages/domain/src/receipt/errors.ts +++ b/packages/domain/src/receipt/errors.ts @@ -120,10 +120,9 @@ export const receiptCompositionFailure = ( capabilityId: ReceiptComposedCapability, ): AmbiguousParameterFill | FailedComposedRequirement | undefined => Match.value(reason).pipe( - Match.when("Ambiguous", () => new AmbiguousParameterFill({ personId, capabilityId })), - Match.when( - "RequirementFailed", - () => new FailedComposedRequirement({ personId, capabilityId }), + Match.when("Ambiguous", () => AmbiguousParameterFill.make({ personId, capabilityId })), + Match.when("RequirementFailed", () => + FailedComposedRequirement.make({ personId, capabilityId }), ), Match.orElse(() => undefined), ); diff --git a/packages/domain/src/receipt/file-service.ts b/packages/domain/src/receipt/file-service.ts index dfa948d3..257b9a97 100644 --- a/packages/domain/src/receipt/file-service.ts +++ b/packages/domain/src/receipt/file-service.ts @@ -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/pagination.ts b/packages/domain/src/receipt/pagination.ts index 738b8b99..e0ef6498 100644 --- a/packages/domain/src/receipt/pagination.ts +++ b/packages/domain/src/receipt/pagination.ts @@ -56,7 +56,7 @@ 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, diff --git a/packages/domain/src/receipt/review.ts b/packages/domain/src/receipt/review.ts index ef72a346..db6e728f 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/update.ts b/packages/domain/src/receipt/update.ts index 6c8961ae..11c83db2 100644 --- a/packages/domain/src/receipt/update.ts +++ b/packages/domain/src/receipt/update.ts @@ -71,13 +71,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 +88,7 @@ const currentRevision = ( receipt.revision === expected ? Effect.void : Effect.fail( - new StaleReceiptRevision({ + StaleReceiptRevision.make({ receiptId: receipt.receiptId, expected, actual: receipt.revision, @@ -100,7 +102,7 @@ const pending = ( receipt.status === "Pending" ? Effect.void : Effect.fail( - new InvalidReceiptTransition({ + InvalidReceiptTransition.make({ receiptId: receipt.receiptId, status: receipt.status, command, @@ -111,7 +113,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 +207,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 +322,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,14 +370,14 @@ 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 = ( diff --git a/packages/domain/src/recruitment/conduct.ts b/packages/domain/src/recruitment/conduct.ts index 51a23b91..bfc18e98 100644 --- a/packages/domain/src/recruitment/conduct.ts +++ b/packages/domain/src/recruitment/conduct.ts @@ -55,7 +55,7 @@ type ConductFailure = const invalid = (state: RecruitmentConductState, message: string) => Effect.fail( - new RecruitmentConductValidationError({ + RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message, }), @@ -66,7 +66,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 +74,7 @@ const checkActor = ( actor.departmentId !== state.interview.departmentId ) { return Effect.fail( - new RecruitmentScopeDenied({ + RecruitmentScopeDenied.make({ personId: actor.personId, departmentId: state.interview.departmentId, }), @@ -91,13 +91,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,12 +225,11 @@ 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", + }), ), ); @@ -245,19 +244,19 @@ export const finalizeInterview = ( yield* checkBase(state, actor, command.expectedRevision); if (state.invitationResponse !== "Accepted") { - return yield* new RecruitmentInvitationNotAccepted({ + return yield* RecruitmentInvitationNotAccepted.make({ interviewId: state.interview.interviewId, responseState: state.invitationResponse ?? "Absent", }); } if (state.conduct !== null) - return yield* new RecruitmentInterviewAlreadyFinalized({ + return yield* RecruitmentInterviewAlreadyFinalized.make({ interviewId: state.interview.interviewId, }); if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ + return yield* RecruitmentInterviewAlreadyCancelled.make({ interviewId: state.interview.interviewId, }); const answers = yield* validateAnswers(state, command.answers); @@ -267,7 +266,7 @@ export const finalizeInterview = ( return yield* invalid(state, "an explicit interviewer recommendation is required"); const revision = state.revision + 1; - const conduct = new RecruitmentInterviewConduct({ + const conduct = RecruitmentInterviewConduct.make({ interviewId: state.interview.interviewId, answers, score: command.score, @@ -289,12 +288,11 @@ export const finalizeInterview = ( }), { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), ), ); @@ -312,17 +310,17 @@ export const cancelInterview = ( yield* checkBase(state, actor, command.expectedRevision); if (state.conduct !== null) - return yield* new RecruitmentInterviewAlreadyFinalized({ + return yield* RecruitmentInterviewAlreadyFinalized.make({ interviewId: state.interview.interviewId, }); if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ + return yield* RecruitmentInterviewAlreadyCancelled.make({ interviewId: state.interview.interviewId, }); const revision = state.revision + 1; - const cancellation = new RecruitmentInterviewCancellation({ + const cancellation = RecruitmentInterviewCancellation.make({ interviewId: state.interview.interviewId, cancelledByPersonId: actor.personId, cancelledAt: now, @@ -340,12 +338,11 @@ export const cancelInterview = ( }), { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), ), ); @@ -369,12 +366,12 @@ export const correctInterviewAssessment = ( yield* checkBase(state, actor, command.expectedRevision); if (state.cancellation !== null) - return yield* new RecruitmentInterviewAlreadyCancelled({ + return yield* RecruitmentInterviewAlreadyCancelled.make({ interviewId: state.interview.interviewId, }); if (state.conduct === null) - return yield* new RecruitmentConductValidationError({ + return yield* RecruitmentConductValidationError.make({ interviewId: state.interview.interviewId, message: "the interview is not completed", }); @@ -399,12 +396,11 @@ export const correctInterviewAssessment = ( }, { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), ), ); @@ -417,12 +413,11 @@ export const correctInterviewAssessment = ( }), { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - (cause) => - new RecruitmentConductValidationError({ - interviewId: state.interview.interviewId, - message: String(cause), - }), + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), ), ); diff --git a/packages/domain/src/recruitment/scheduling.ts b/packages/domain/src/recruitment/scheduling.ts index e51ccac8..8a741fcb 100644 --- a/packages/domain/src/recruitment/scheduling.ts +++ b/packages/domain/src/recruitment/scheduling.ts @@ -20,12 +20,12 @@ export const validateInterviewScheduling = ( (current.scheduled && current.responseState !== "RequestedNewTime") ) { return Effect.fail( - new RecruitmentInterviewAlreadyScheduled({ interviewId: command.interviewId }), + RecruitmentInterviewAlreadyScheduled.make({ interviewId: command.interviewId }), ); } if (compareRfc3339Instants(command.scheduledAt, now) <= 0) { - return Effect.fail(new RecruitmentScheduleInPast({ interviewId: command.interviewId })); + return Effect.fail(RecruitmentScheduleInPast.make({ interviewId: command.interviewId })); } return Effect.void; diff --git a/packages/domain/src/schema.ts b/packages/domain/src/schema.ts index d542e099..fa838248 100644 --- a/packages/domain/src/schema.ts +++ b/packages/domain/src/schema.ts @@ -75,8 +75,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 +85,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 +132,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 +298,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 +322,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,8 +343,7 @@ export const decodeGlobalMembership = flow( boardId: decoded.value.boardId, }, } - : decoded; - }, + : decoded, ); export const decodeRows = ( diff --git a/packages/domain/src/team-application/pagination.ts b/packages/domain/src/team-application/pagination.ts index 69445502..6614b6b2 100644 --- a/packages/domain/src/team-application/pagination.ts +++ b/packages/domain/src/team-application/pagination.ts @@ -56,7 +56,7 @@ 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 = ( From 69fdf56b22f46252afb64fa71e67511513f7ffbb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:39:26 +0000 Subject: [PATCH 096/148] fix(database): construct Schema classes with make in the root database modules Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- ...invitation-response-postgres-proof-main.ts | 6 +- .../runtime/oauth-postgres-tracer-main.ts | 2 +- .../runtime/schools-postgres-proof-main.ts | 2 +- ...e-principal-grants-postgres-tracer-main.ts | 2 +- packages/database/src/auth-live.test.ts | 10 ++-- packages/database/src/auth-live.ts | 56 +++++++++---------- packages/database/src/database.test.ts | 8 +-- packages/database/src/identity-access.ts | 2 +- .../database/src/password-recovery.test.ts | 2 +- packages/database/src/password-recovery.ts | 12 ++-- .../disposable-person-authority-backfill.ts | 32 +++++------ 11 files changed, 66 insertions(+), 68 deletions(-) diff --git a/packages/database/runtime/invitation-response-postgres-proof-main.ts b/packages/database/runtime/invitation-response-postgres-proof-main.ts index 73b1f57e..5e869e52 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 1182fa9a..98473de0 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/schools-postgres-proof-main.ts b/packages/database/runtime/schools-postgres-proof-main.ts index 9971cba9..a814c3a7 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 4135919c..ee57ea71 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/auth-live.test.ts b/packages/database/src/auth-live.test.ts index d76ed034..d1ee3e4a 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 3fba4efb..6bada807 100644 --- a/packages/database/src/auth-live.ts +++ b/packages/database/src/auth-live.ts @@ -183,7 +183,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 +238,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 +310,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 +323,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 +336,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 +356,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 +380,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 +429,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 +458,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 +496,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 +509,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 +542,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 +587,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 +596,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 +627,7 @@ const identityOperations = ( ); if (deleted.rowCount !== 1) { - return yield* new IdentityOwnedSessionNotFound({ sessionId }); + return yield* IdentityOwnedSessionNotFound.make({ sessionId }); } yield* appendAudit( @@ -638,7 +638,7 @@ const identityOperations = ( subjectPersonId: actor.personId, sessionId, context: request, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "owned-session-revoked", affectedSessionCount: 1, }), @@ -683,7 +683,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 +711,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 +720,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, }), @@ -769,7 +769,7 @@ export const auditedAuthHandler = const [setCookie] = response.headers.getSetCookie(); if (setCookie === undefined) { - return yield* new IdentityEngineError({ + return yield* IdentityEngineError.make({ operation: "auditSignIn", message: "successful sign-in returned no cookie", }); @@ -783,7 +783,7 @@ export const auditedAuthHandler = subjectPersonId: actor.personId, sessionId: actor.sessionId, context, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "credential-accepted", affectedSessionCount: 1, }), @@ -797,7 +797,7 @@ export const auditedAuthHandler = subjectPersonId: null, sessionId: null, context, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "credential-rejected", affectedSessionCount: 0, }), @@ -812,7 +812,7 @@ export const auditedAuthHandler = subjectPersonId: null, sessionId: null, context, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "public-sign-up-disabled", affectedSessionCount: 0, }), @@ -826,7 +826,7 @@ export const auditedAuthHandler = subjectPersonId: signOutActor.personId, sessionId: signOutActor.sessionId, context, - details: new IdentitySecurityEventDetails({ + details: IdentitySecurityEventDetails.make({ outcomeCode: "current-session-ended", affectedSessionCount: 1, }), @@ -919,7 +919,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/database.test.ts b/packages/database/src/database.test.ts index b1e4e88c..e9e37acf 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -3856,21 +3856,21 @@ 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", }), @@ -5314,7 +5314,7 @@ describe("claim-fenced outbox delivery", () => { loseClaim.pipe( Effect.andThen( Effect.fail( - new PublicApplicationEffectDeliveryError({ effectId: request.effectId }), + PublicApplicationEffectDeliveryError.make({ effectId: request.effectId }), ), ), ), diff --git a/packages/database/src/identity-access.ts b/packages/database/src/identity-access.ts index cbd2effc..f12d7dc6 100644 --- a/packages/database/src/identity-access.ts +++ b/packages/database/src/identity-access.ts @@ -59,7 +59,7 @@ export const changeNativeAccountAccess = Effect.fn("changeNativeAccountAccess")( const current = rows[0]; - if (current === undefined) 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/password-recovery.test.ts b/packages/database/src/password-recovery.test.ts index 82b8ab49..715fa1dd 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 d6e4f94f..fd8f8644 100644 --- a/packages/database/src/password-recovery.ts +++ b/packages/database/src/password-recovery.ts @@ -109,7 +109,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 +146,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", }); @@ -188,7 +188,7 @@ export const makePasswordRecovery = ( if (state === undefined) { return Effect.fail( - new IdentityEngineError({ + IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery requires an owned request", }), @@ -208,7 +208,7 @@ export const makePasswordRecovery = ( }).pipe( Effect.andThen( Effect.fail( - new IdentityEngineError({ + IdentityEngineError.make({ operation: "sendResetPassword", message: "Recovery enqueue unavailable", }), @@ -245,7 +245,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", }), @@ -296,7 +296,7 @@ export const makePasswordRecovery = ( 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", }), 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 ec37a1df..01f814ad 100644 --- a/packages/database/src/test-support/disposable-person-authority-backfill.ts +++ b/packages/database/src/test-support/disposable-person-authority-backfill.ts @@ -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", }), From 93ea30786728b75aa1d6370ac07209686e6c2772 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:40:27 +0000 Subject: [PATCH 097/148] refactor(database): give exported recruitment, organization, placements functions a pipeable dual form Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../organization/administration-postgres.ts | 378 +++++++------ .../src/organization/authority-postgres.ts | 501 +++++++++++------- .../src/organization/directory-postgres.ts | 122 +++-- .../database/src/organization/postgres.ts | 66 ++- .../database/src/placements/certificates.ts | 133 ++++- packages/database/src/placements/coverage.ts | 68 ++- .../placements/current-assignment-cohort.ts | 14 +- packages/database/src/placements/outbox.ts | 141 +++-- packages/database/src/placements/postgres.ts | 44 +- .../src/recruitment/completion-outbox.ts | 253 +++++---- .../src/recruitment/conduct-identity.ts | 17 +- .../src/recruitment/conduct-postgres.ts | 363 ++++++++----- .../database/src/recruitment/http-postgres.ts | 95 ++-- .../invitation-response-postgres.ts | 172 ++++-- .../src/recruitment/maintenance-postgres.ts | 13 +- packages/database/src/recruitment/outbox.ts | 237 ++++++--- packages/database/src/recruitment/postgres.ts | 181 ++++--- .../src/recruitment/report-postgres.ts | 26 +- .../src/recruitment/response-outbox.ts | 247 +++++---- .../src/recruitment/scheduling-postgres.ts | 128 +++-- 20 files changed, 2060 insertions(+), 1139 deletions(-) diff --git a/packages/database/src/organization/administration-postgres.ts b/packages/database/src/organization/administration-postgres.ts index 1dc379e4..0529a783 100644 --- a/packages/database/src/organization/administration-postgres.ts +++ b/packages/database/src/organization/administration-postgres.ts @@ -1,6 +1,7 @@ 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, @@ -541,175 +542,216 @@ const insertFieldOfStudy = ( ); }; -export const createOrganizationDepartment = ( - 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)), - ), - ); - }); - -export const createOrganizationTeam = ( - 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, - team: inserted, - }); - - 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 = ( - 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); +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, - 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)), - ), - ); - }); + 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 55420527..5a503d40 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"; @@ -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( - OrganizationPersistenceError.make({ - 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* OrganizationAuthorityRecordNotFound.make({ 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* OrganizationAuthorityWriteConflict.make({ grantId, expectedRevision }); - } - - return grant; - }).pipe( - Effect.catchTag("SqlError", (cause) => - Effect.fail( - OrganizationPersistenceError.make({ - 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, @@ -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( - OrganizationPersistenceError.make({ - 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( - OrganizationPersistenceError.make({ - 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( - OrganizationPersistenceError.make({ - 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 678bcd20..434a36a5 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( - OrganizationPersistenceError.make({ - 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( - OrganizationPersistenceError.make({ - 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) => OrganizationDecodeError.make({ operation, message: String(cause) }); diff --git a/packages/database/src/organization/postgres.ts b/packages/database/src/organization/postgres.ts index 3344629a..bbbb1d14 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, @@ -618,20 +619,40 @@ const teamInterestPredicate = ( return Statement.and(clauses); }; -export const listOrganizationTeamInterestRegistrations = ( - 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` +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", @@ -649,11 +670,12 @@ export const listOrganizationTeamInterestRegistrations = ( 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/placements/certificates.ts b/packages/database/src/placements/certificates.ts index 62b67367..805f502d 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"; @@ -631,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, @@ -672,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, @@ -705,11 +718,7 @@ const authorizeIssue = ( return { department, issuer }; }); -/** - * 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 authorizeDaysServedConfirmation = ( +const authorizeDaysServedConfirmationImpl = ( principal: CertificatePrincipal, scope: PlacementScope, ) => @@ -726,11 +735,20 @@ export const authorizeDaysServedConfirmation = ( }); /** - * 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. + * 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 authorizeCertificateIssue = ( +export const authorizeDaysServedConfirmation: { + ( + scope: PlacementScope, + ): (principal: CertificatePrincipal) => ReturnType; + ( + principal: CertificatePrincipal, + scope: PlacementScope, + ): ReturnType; +} = dual(2, authorizeDaysServedConfirmationImpl); + +const authorizeCertificateIssueImpl = ( principal: CertificatePrincipal, departmentId: DepartmentId, personId: PersonId, @@ -744,10 +762,23 @@ export const authorizeCertificateIssue = ( }); /** - * 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 = ( +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, @@ -790,8 +821,25 @@ export const confirmDaysServed = ( 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, @@ -844,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, @@ -862,11 +922,20 @@ export const readCertificate = ( 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 = ( +/** 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, @@ -911,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 badaada2..e3047402 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 69e26ac7..909521a2 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"; @@ -126,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 })), diff --git a/packages/database/src/placements/outbox.ts b/packages/database/src/placements/outbox.ts index 960a4431..656bf3d7 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 3d4207fc..055f1a95 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"; @@ -94,7 +95,7 @@ export const lockPlacementDepartment = (departmentId: DepartmentId) => }), ); -export const readOwnAffiliation = (personId: PersonId, departmentId: DepartmentId) => +const readOwnAffiliationImpl = (personId: PersonId, departmentId: DepartmentId) => Database.use((sql) => Effect.gen(function* () { const departments = @@ -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,6 +138,20 @@ 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* () { @@ -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, @@ -411,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/recruitment/completion-outbox.ts b/packages/database/src/recruitment/completion-outbox.ts index 76689c06..448c98df 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, @@ -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 ee2c07e0..5dcb7726 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"; @@ -25,8 +26,7 @@ export const readInterviewApplicantIdentity = (interviewId: RecruitmentInterview }), ); -/** Caller holds a transaction. Applicant custody precedes interview/receipt locks. */ -export const guardInterviewApplicantIdentity = ( +const guardInterviewApplicantIdentityImpl = ( interviewId: RecruitmentInterviewId, personId: PersonId, ) => @@ -40,3 +40,16 @@ export const guardInterviewApplicantIdentity = ( 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.ts b/packages/database/src/recruitment/conduct-postgres.ts index 9e68feda..69084c7f 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, @@ -726,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, @@ -1077,84 +1128,126 @@ 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.ts b/packages/database/src/recruitment/http-postgres.ts index b92457a3..2908a51c 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, @@ -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* RecruitmentInterviewNotFound.make({ 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 2f067209..64ab5085 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, @@ -529,57 +530,124 @@ export const readInvitationResponse = ( 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 8c7b22a5..77fb390f 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 { @@ -235,7 +236,7 @@ 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"))); @@ -245,6 +246,16 @@ export const authorizeMaintenance = (command: RecruitmentMaintenanceCommand, per return { personId, command: decoded } as RecruitmentMaintenanceAuthorization; }).pipe(mapFailure); +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, personId } = authorization; diff --git a/packages/database/src/recruitment/outbox.ts b/packages/database/src/recruitment/outbox.ts index c9c459fa..082e9388 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, @@ -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.ts b/packages/database/src/recruitment/postgres.ts index 40cf95ea..1a6dc117 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, @@ -996,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 18e51c55..af7caa4a 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); @@ -51,8 +51,13 @@ export const resolveInterviewReportLeader = (personId: PersonId, now: string) => 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, @@ -167,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 a8a370a7..961ca61d 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, @@ -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.ts b/packages/database/src/recruitment/scheduling-postgres.ts index caf225ff..df309b71 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, @@ -901,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* RecruitmentInvalidContext.make({ 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)), + ), + ); + }), +); From 7a2832315d82adc7a03af801dc44e6585f3a8df9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:41:33 +0000 Subject: [PATCH 098/148] test(database): inline nested Effect generators in the root database suites Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../database/src/authority-writers.test.ts | 216 +- packages/database/src/database.test.ts | 3178 ++++++++--------- packages/database/src/migrations.test.ts | 594 ++- .../database/src/outbox-lifecycle.test.ts | 64 +- ...service-principal-grants-migration.test.ts | 116 +- ...sposable-person-authority-backfill.test.ts | 10 +- 6 files changed, 2046 insertions(+), 2132 deletions(-) diff --git a/packages/database/src/authority-writers.test.ts b/packages/database/src/authority-writers.test.ts index 53395212..e0b05562 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/database.test.ts b/packages/database/src/database.test.ts index e9e37acf..5044764b 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -437,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' @@ -492,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, @@ -566,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", @@ -738,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 ( @@ -753,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 ( @@ -766,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 @@ -775,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, @@ -992,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'), @@ -1012,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 ( @@ -1020,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 @@ -1039,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 @@ -1048,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 @@ -1073,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", @@ -1151,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", @@ -1421,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"); @@ -1458,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({ @@ -1525,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, @@ -1557,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({ @@ -1600,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) - VALUES ( - 'outbox-second-semester', + 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 @@ -1644,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 ( @@ -1684,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', @@ -1698,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({ @@ -1728,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({ @@ -1826,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', @@ -1841,7 +1820,7 @@ describe("DatabaseTest", () => { '2032-01-01T00:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO admission_periods ( admission_period_id, department_id, @@ -1859,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, @@ -1867,7 +1846,7 @@ describe("DatabaseTest", () => { ) VALUES ('recruitment-field', 'recruitment-department', 'Computer Science') `; - yield* database` + yield* database` INSERT INTO admission_applicants ( applicant_id, normalized_email, @@ -1891,7 +1870,7 @@ describe("DatabaseTest", () => { 2 ) `; - yield* database` + yield* database` INSERT INTO admission_applications ( application_id, applicant_id, @@ -1911,7 +1890,7 @@ describe("DatabaseTest", () => { '2031-09-10T12:00:00.000Z' ) `; - yield* database` + yield* database` INSERT INTO organization_departments ( department_id, name, @@ -1927,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, @@ -1964,7 +1943,7 @@ describe("DatabaseTest", () => { FALSE ) `; - yield* database` + yield* database` INSERT INTO recruitment_interview_schemas ( interview_schema_id, name, @@ -1972,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 ) @@ -1987,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 ) @@ -2070,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, @@ -2256,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({ @@ -2372,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 @@ -2437,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, @@ -2472,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`, @@ -2602,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, @@ -2656,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, @@ -2685,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); @@ -2738,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 @@ -2784,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({ @@ -2909,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 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 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 pending = yield* recruitment.readInvitationResponse(capability); - const context = { now: "2031-09-15T12:03:00.000Z" }; + const capability = RecruitmentInvitationCapabilitySchema.make( + fixture.responseCapability, + ); - 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, - ), + 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", @@ -3013,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", @@ -3161,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", @@ -3214,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}, @@ -3223,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, @@ -3243,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, @@ -3295,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, @@ -3331,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", @@ -3361,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", @@ -3388,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" }); @@ -3433,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, @@ -3467,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, @@ -3483,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, @@ -3502,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, @@ -3518,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, @@ -3537,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, @@ -3562,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", @@ -3597,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([ @@ -3632,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 @@ -3691,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, @@ -3759,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, @@ -3778,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", @@ -3805,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, @@ -3814,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); @@ -3880,34 +3842,33 @@ describe("DatabaseTest", () => { 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 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 failedDelivery = yield* deliverNextRecruitmentInvitationResponse( - "response-failure-claim", - "2031-09-15T12:04:00.000Z", - ).pipe(Effect.provide(failingGateway)); + const recorded = yield* recruitment.rejectInvitation( + RecruitmentInvitationCapabilitySchema.make(fixture.responseCapability), + { message: "Cannot attend." }, + { now: "2031-09-15T12:03:00.000Z" }, + ); - const afterFailure = yield* database<{ - readonly responseState: string; - readonly responseRevision: number; - readonly audits: string; - readonly outboxStatus: string; - }>` + 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", @@ -3925,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"); @@ -3953,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, @@ -3982,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 @@ -4011,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", @@ -4041,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, @@ -4086,30 +4043,29 @@ describe("DatabaseTest", () => { ) `; - // An organization-wide reader, narrowed to one department for the filtered read. - const reader = activeAdministratorAuthority("team-interest-scope-reader"); + // An organization-wide reader, narrowed to one department for the filtered read. + const reader = activeAdministratorAuthority("team-interest-scope-reader"); - const authorizedRows = yield* listOrganizationTeamInterestRegistrations( - Result.getOrThrow( - requireTeamInterestScope(reader, { - requested: undefined, - departments: [departmentA, departmentB], - }), - ), - ); + const authorizedRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: undefined, + departments: [departmentA, departmentB], + }), + ), + ); - const filteredRows = yield* listOrganizationTeamInterestRegistrations( - Result.getOrThrow( - requireTeamInterestScope(reader, { - requested: departmentB, - departments: [departmentA, departmentB], - }), - ), - SemesterId.make("semester-scope"), - ); + const filteredRows = yield* listOrganizationTeamInterestRegistrations( + Result.getOrThrow( + requireTeamInterestScope(reader, { + requested: departmentB, + departments: [departmentA, departmentB], + }), + ), + SemesterId.make("semester-scope"), + ); - return { authorizedRows, filteredRows }; - }); + const rows = { authorizedRows, filteredRows }; expect(rows.authorizedRows.map((row) => row.submitterName)).toEqual([ "Interested A", @@ -4127,144 +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 = 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 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 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, + administrator, + ); - 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}) @@ -4281,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, @@ -4306,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, @@ -4330,7 +4285,7 @@ describe("DatabaseTest", () => { 'Bergen' ) `; - yield* database` + yield* database` INSERT INTO organization_teams (team_id, department_id, name) VALUES ( 'organization-pglite-imported-team', @@ -4339,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" @@ -4353,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), @@ -4412,15 +4367,15 @@ 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; + 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; - }>` + 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", @@ -4433,46 +4388,43 @@ describe("DatabaseTest", () => { ) `; - return { - 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, - ), - }, - }; - }); + 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); // The audit identity keeps its stored shape: evidence records the administrator actor. @@ -4521,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 @@ -4549,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, @@ -4614,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, @@ -4673,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 @@ -4701,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, @@ -4738,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, diff --git a/packages/database/src/migrations.test.ts b/packages/database/src/migrations.test.ts index 49cbda0d..f2138922 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/outbox-lifecycle.test.ts b/packages/database/src/outbox-lifecycle.test.ts index d527b8d0..901d4401 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/service-principal-grants-migration.test.ts b/packages/database/src/service-principal-grants-migration.test.ts index 48a35f09..1baad433 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/test-support/disposable-person-authority-backfill.test.ts b/packages/database/src/test-support/disposable-person-authority-backfill.test.ts index a326e5b0..0fbb5ded 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( From 89f9faa3adac5cfde81459eaa7cbcb7904c656f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:41:56 +0000 Subject: [PATCH 099/148] style(database): keep readable spacing after the rewritten statements Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/database/src/placements/current-assignment-cohort.ts | 4 ++++ packages/database/src/recruitment/conduct-postgres.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/database/src/placements/current-assignment-cohort.ts b/packages/database/src/placements/current-assignment-cohort.ts index 909521a2..f1a3637f 100644 --- a/packages/database/src/placements/current-assignment-cohort.ts +++ b/packages/database/src/placements/current-assignment-cohort.ts @@ -544,10 +544,12 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( : undefined; if (previousDigest === undefined) continue; + const mappings = sourceAssignmentId !== undefined && sourceAssignmentId !== "" ? (mappingsBySource.get(sourceAssignmentId) ?? []) : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; if ( @@ -596,8 +598,10 @@ const importAssignmentCohort = Effect.fnUntraced(function* ( for (const occurrence of decoded) { const row = occurrence.value; + const mappings = row !== undefined ? (mappingsBySource.get(row.sourceAssignmentId) ?? []) : []; + const mapping = mappings.length === 1 ? mappings[0] : undefined; let reason: CurrentAssignmentReason; let sourceDigest: string | undefined; diff --git a/packages/database/src/recruitment/conduct-postgres.ts b/packages/database/src/recruitment/conduct-postgres.ts index 69084c7f..40e20d83 100644 --- a/packages/database/src/recruitment/conduct-postgres.ts +++ b/packages/database/src/recruitment/conduct-postgres.ts @@ -1192,6 +1192,7 @@ export const finalizeInterview: { FinalizeInterviewCommandSchema, "finalization command", )(command); + const sql = yield* Database; const organization = yield* Organization; From b55550d73b42633fe9742bdd8bcd1af41f85b474 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:41:59 +0000 Subject: [PATCH 100/148] fix(database): derive the service keys of the root database modules from their paths Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/database/src/auth-engine.ts | 2 +- packages/database/src/auth-live.ts | 4 ++-- packages/database/src/database.test.ts | 2 +- packages/database/src/oauth-live.ts | 6 +++--- packages/database/src/oauth-refresh-window.test.ts | 2 +- packages/database/src/password-recovery.ts | 2 +- packages/database/src/pg-pool.ts | 2 +- packages/database/src/service.ts | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/database/src/auth-engine.ts b/packages/database/src/auth-engine.ts index af4058db..b62ea5e4 100644 --- a/packages/database/src/auth-engine.ts +++ b/packages/database/src/auth-engine.ts @@ -507,7 +507,7 @@ export const makeAuthEngine = ( export type AuthEngine = ReturnType; 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.ts b/packages/database/src/auth-live.ts index 6bada807..0c1ed819 100644 --- a/packages/database/src/auth-live.ts +++ b/packages/database/src/auth-live.ts @@ -69,7 +69,7 @@ export interface AuthEngineService { } export class AuthEngine extends Context.Service()( - "@vektorprogrammet/database/AuthEngine", + "@vektorprogrammet/database/auth-live/AuthEngine", ) {} export interface IdentitySnapshotService { @@ -120,7 +120,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 { diff --git a/packages/database/src/database.test.ts b/packages/database/src/database.test.ts index 5044764b..3ed02cc6 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -138,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( diff --git a/packages/database/src/oauth-live.ts b/packages/database/src/oauth-live.ts index 9078eec7..a68a0142 100644 --- a/packages/database/src/oauth-live.ts +++ b/packages/database/src/oauth-live.ts @@ -89,7 +89,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 +164,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; @@ -2411,7 +2411,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 860f14fb..1c02437d 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/password-recovery.ts b/packages/database/src/password-recovery.ts index fd8f8644..f024098e 100644 --- a/packages/database/src/password-recovery.ts +++ b/packages/database/src/password-recovery.ts @@ -547,7 +547,7 @@ export const drainPasswordResetMail = ( ); 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/pg-pool.ts b/packages/database/src/pg-pool.ts index a9bffb8d..f7a31aac 100644 --- a/packages/database/src/pg-pool.ts +++ b/packages/database/src/pg-pool.ts @@ -4,7 +4,7 @@ import { Pool, type PoolClient, type PoolConfig, type QueryResult, type QueryRes /** 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. */ diff --git a/packages/database/src/service.ts b/packages/database/src/service.ts index 0eb89b13..3897592d 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); From 326af72b1691bd2b5eed66bc2dd49f804f404603 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:42:41 +0000 Subject: [PATCH 101/148] test(database): keep one binding per name after inlining nested generators Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/database/src/receipt-approval-list.test.ts | 2 +- packages/database/src/schools.test.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/database/src/receipt-approval-list.test.ts b/packages/database/src/receipt-approval-list.test.ts index 5bcfe231..b1719059 100644 --- a/packages/database/src/receipt-approval-list.test.ts +++ b/packages/database/src/receipt-approval-list.test.ts @@ -277,7 +277,7 @@ layer(suiteLayer, { excludeTestServices: true, timeout: "30 seconds" })( 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/schools.test.ts b/packages/database/src/schools.test.ts index 71162874..6a4b6a5b 100644 --- a/packages/database/src/schools.test.ts +++ b/packages/database/src/schools.test.ts @@ -245,8 +245,6 @@ layer(schoolsLayer, { excludeTestServices: true, timeout: "15 seconds" })( WHERE department.department_id = ${departmentId} `; - const directory = directory; - expect(directory).toEqual({ activeSchools: [ { From 38151b04b4e24b1666e3537171fe3ed940c11d59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:45:09 +0000 Subject: [PATCH 102/148] fix(backend): satisfy the Effect rules in the receipt adapters Explicit comparisons for strict-boolean-expressions, Schema error make for new-schema-class, the deterministic key of ReceiptFileStoreResource, and dual forms for the exported receipt drain, import, and E2E-composition functions with unchanged data-first calls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/config.ts | 21 +++++-- apps/backend/src/receipt/delivery.ts | 33 +++++------ apps/backend/src/receipt/drain-main.ts | 11 +++- apps/backend/src/receipt/filesystem.ts | 16 +++--- apps/backend/src/receipt/import-snapshot.ts | 24 +++++++- apps/backend/src/receipt/outbox-drain.test.ts | 2 +- apps/backend/src/receipt/outbox-drain.ts | 56 ++++++++++++++++--- apps/backend/src/receipt/r2.test.ts | 2 +- apps/backend/src/receipt/r2.ts | 18 +++--- apps/backend/src/receipt/reviewed-import.ts | 32 ++++++++--- 10 files changed, 153 insertions(+), 62 deletions(-) diff --git a/apps/backend/src/receipt/config.ts b/apps/backend/src/receipt/config.ts index c4832e64..f27bbedc 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/delivery.ts b/apps/backend/src/receipt/delivery.ts index 7f37c9eb..6b61afbf 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 3ab22ddd..b4f80dd8 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), diff --git a/apps/backend/src/receipt/filesystem.ts b/apps/backend/src/receipt/filesystem.ts index 0bc11590..402c09cf 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/import-snapshot.ts b/apps/backend/src/receipt/import-snapshot.ts index d09a2f85..203f9955 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"; @@ -137,7 +138,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 +177,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 +245,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 +419,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 7fdef33a..cb6a8bdb 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 abea3d4b..94b9d6eb 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/r2.test.ts b/apps/backend/src/receipt/r2.test.ts index 1676a877..8e738931 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 a9b20b1e..24d11c47 100644 --- a/apps/backend/src/receipt/r2.ts +++ b/apps/backend/src/receipt/r2.ts @@ -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/reviewed-import.ts b/apps/backend/src/receipt/reviewed-import.ts index f4f5687e..19232771 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); From ac10443f7973c3682ef4e5a43a5051e1204dc198 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:45:09 +0000 Subject: [PATCH 103/148] fix(backend): satisfy the Effect rules in the onboarding and recruitment delivery Explicit comparisons for strict-boolean-expressions, RecruitmentNotificationDeliveryError.make, and a dual form of drainOnboardingDelivery with an unchanged data-first call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/onboarding/delivery.ts | 44 ++++++++++++++++-------- apps/backend/src/recruitment/delivery.ts | 27 +++++++++------ 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/apps/backend/src/onboarding/delivery.ts b/apps/backend/src/onboarding/delivery.ts index be8c16b8..20fe5265 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/recruitment/delivery.ts b/apps/backend/src/recruitment/delivery.ts index fe071e6d..ef5e1ed4 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", + }), ), ); From 58a209cd3c379c9f9d015be28c7d5ea8c099fe9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:45:09 +0000 Subject: [PATCH 104/148] fix(backend): satisfy the Effect rules in placements and the PostgreSQL test helper Dual forms of renderCertificatePdf and runSchoolServiceNotificationWorker, deterministic service keys, and a scoped isolated build instead of Effect.provide with a Layer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../backend/src/placements/certificate-pdf.ts | 23 +++++++++++----- apps/backend/src/placements/notification.ts | 27 ++++++++++++++----- apps/backend/test/postgres.ts | 8 ++++-- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/placements/certificate-pdf.ts b/apps/backend/src/placements/certificate-pdf.ts index 51599ae2..389e2d5b 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/notification.ts b/apps/backend/src/placements/notification.ts index e65a0926..d1b14b17 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/test/postgres.ts b/apps/backend/test/postgres.ts index bf413f11..fe5e056c 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 From 75436d5d8e5d3f4aabc5810ec56ae1fb63e251f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:45:10 +0000 Subject: [PATCH 105/148] fix(dashboard): name schema class types instead of typeof queries Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../app/foldkit/recruitment/browser-client.ts | 2 +- apps/dashboard/app/foldkit/scheduling/update.ts | 4 ++-- apps/dashboard/app/lib/receipt-view.ts | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/dashboard/app/foldkit/recruitment/browser-client.ts b/apps/dashboard/app/foldkit/recruitment/browser-client.ts index d97b5a1b..bab5b2b9 100644 --- a/apps/dashboard/app/foldkit/recruitment/browser-client.ts +++ b/apps/dashboard/app/foldkit/recruitment/browser-client.ts @@ -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/scheduling/update.ts b/apps/dashboard/app/foldkit/scheduling/update.ts index d006e878..28c0885a 100644 --- a/apps/dashboard/app/foldkit/scheduling/update.ts +++ b/apps/dashboard/app/foldkit/scheduling/update.ts @@ -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/lib/receipt-view.ts b/apps/dashboard/app/lib/receipt-view.ts index bd5db304..fd601be0 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/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; From fa2a13580f6ba5c8aac6323b0658c8ca28341abc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:45:10 +0000 Subject: [PATCH 106/148] fix(docs): compare explicitly instead of relying on truthiness Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/docs/remark-repository-links.ts | 7 +++++-- apps/docs/site.ts | 7 ++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/docs/remark-repository-links.ts b/apps/docs/remark-repository-links.ts index 0b3fa95a..da8ef586 100644 --- a/apps/docs/remark-repository-links.ts +++ b/apps/docs/remark-repository-links.ts @@ -14,9 +14,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/site.ts b/apps/docs/site.ts index 5f89c28d..4ca19cbd 100644 --- a/apps/docs/site.ts +++ b/apps/docs/site.ts @@ -72,9 +72,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(); } From f27b127e3b6db8a89b078379d5495807d0a2b13f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:49:38 +0000 Subject: [PATCH 107/148] fix(database): give the exported functions of the root database modules pipeable forms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/sql-lifecycle.md | 129 +-- docs/constructs/sql-lock.md | 19 +- .../database/runtime/rehearsal-platform.ts | 83 +- packages/database/src/advisory-lock.ts | 49 +- packages/database/src/auth-engine.ts | 41 +- packages/database/src/auth-live.ts | 179 ++-- packages/database/src/cohort-cli.ts | 73 +- .../database/src/database-construction.ts | 19 +- packages/database/src/identity-access.ts | 34 +- packages/database/src/layers.ts | 26 +- packages/database/src/migration-registry.ts | 181 ++-- packages/database/src/oauth-config.ts | 17 +- packages/database/src/oauth-live.ts | 964 +++++++++--------- packages/database/src/outbox-lifecycle.ts | 267 +++-- packages/database/src/password-recovery.ts | 327 +++--- packages/database/src/pg-pool.ts | 88 +- .../src/test-support/observe-postgres.ts | 76 +- .../database/src/test-support/pglite-layer.ts | 25 +- .../database/src/test-support/platform.ts | 4 +- .../database/src/test-support/postgres.ts | 116 ++- 20 files changed, 1546 insertions(+), 1171 deletions(-) diff --git a/docs/constructs/sql-lifecycle.md b/docs/constructs/sql-lifecycle.md index 17d6fbc5..39d7d634 100644 --- a/docs/constructs/sql-lifecycle.md +++ b/docs/constructs/sql-lifecycle.md @@ -9,22 +9,15 @@ 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( - sql: DatabaseOperations, - personId: PersonId, - lock: "None" | "ForShare" -): Effect.Effect +const accountAccessEnabled: { (personId: PersonId, lock: "None" | "ForShare"): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId, lock: "None" | "ForShare"): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `personId: PersonId` - - `lock: "None" | "ForShare"` -- Output: `Effect.Effect` -- Errors: `SqlError` +- Inputs: none +- Output: `{ (personId: PersonId, lock: "None" | "ForShare"): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId, lock: "None" | "ForShare"): Effect.Effect }` +- Errors: none - 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,24 +43,15 @@ inside the transaction that writes. SET list for the aggregate's claim UPDATE; `targetAlias` names the updated outbox row. ```ts -outboxClaimAssignments( - sql: DatabaseOperations, - targetAlias: string, - claimId: string, - claimedAt: string -): Statement.Fragment +const outboxClaimAssignments: { (targetAlias: string, claimId: string, claimedAt: string): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, targetAlias: string, claimId: string, claimedAt: string): Statement.Fragment } ``` -- Inputs: - - `sql: DatabaseOperations` - - `targetAlias: string` - - `claimId: string` - - `claimedAt: string` -- Output: `Statement.Fragment` +- Inputs: none +- Output: `{ (targetAlias: string, claimId: string, claimedAt: string): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, targetAlias: string, claimId: string, claimedAt: string): Statement.Fragment }` - 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,24 +77,15 @@ list in every claim UPDATE. Settles the claimed row as Delivered, with delivery evidence when the table records it. ```ts -markOutboxDelivered( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - evidence?: OutboxDeliveryEvidence -): Effect.Effect +const markOutboxDelivered: { (table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `table: OutboxTable` - - `claim: OutboxClaim` - - `evidence?: OutboxDeliveryEvidence` -- Output: `Effect.Effect` -- Errors: `OutboxClaimLost | SqlError` +- Inputs: none +- Output: `{ (table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): Effect.Effect }` +- Errors: none - 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,24 +112,15 @@ 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( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string -): Effect.Effect +const markOutboxFailed: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `table: OutboxTable` - - `claim: OutboxClaim` - - `failureTag: string` -- Output: `Effect.Effect` -- Errors: `OutboxClaimLost | SqlError` +- Inputs: none +- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` +- Errors: none - 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,24 +144,15 @@ later claim takes it again. Quarantine it with `quarantineOutboxClaim`. Settles the claimed row as Quarantined, a terminal status, with its failure tag. ```ts -quarantineOutboxClaim( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string -): Effect.Effect +const quarantineOutboxClaim: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `table: OutboxTable` - - `claim: OutboxClaim` - - `failureTag: string` -- Output: `Effect.Effect` -- Errors: `OutboxClaimLost | SqlError` +- Inputs: none +- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` +- Errors: none - 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,24 +177,15 @@ 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( - sql: DatabaseOperations, - table: OutboxTable, - claim: OutboxClaim, - failureTag: string -): Effect.Effect +const releaseOutboxClaim: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `table: OutboxTable` - - `claim: OutboxClaim` - - `failureTag: string` -- Output: `Effect.Effect` -- Errors: `SqlError` +- Inputs: none +- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` +- Errors: none - 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,24 +210,15 @@ Settle an answered delivery with `markOutboxDelivered` or `markOutboxFailed`. Recovers every Processing row claimed before `claimedBefore`. ```ts -recoverStaleOutboxClaims( - sql: DatabaseOperations, - table: OutboxTable, - claimedBefore: string, - recovery: OutboxStaleRecovery -): Effect.Effect +const recoverStaleOutboxClaims: { (table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `table: OutboxTable` - - `claimedBefore: string` - - `recovery: OutboxStaleRecovery` -- Output: `Effect.Effect` -- Errors: `SqlError` +- Inputs: none +- Output: `{ (table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): Effect.Effect }` +- Errors: none - 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 14edc560..f2b45687 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,15 @@ 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( - sql: DatabaseOperations, - key: AdvisoryLockKey, - mode: AdvisoryLockMode = "exclusive" -): Effect.Effect +const lockAdvisory: { (key: AdvisoryLockKey, mode?: AdvisoryLockMode): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, key: AdvisoryLockKey, mode?: AdvisoryLockMode): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `key: AdvisoryLockKey` - - `mode: AdvisoryLockMode = "exclusive"` -- Output: `Effect.Effect` -- Errors: `SqlError` +- Inputs: none +- Output: `{ (key: AdvisoryLockKey, mode?: AdvisoryLockMode): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, key: AdvisoryLockKey, mode?: AdvisoryLockMode): Effect.Effect }` +- Errors: none - 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** diff --git a/packages/database/runtime/rehearsal-platform.ts b/packages/database/runtime/rehearsal-platform.ts index 9cd2722c..2e8e9a76 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/src/advisory-lock.ts b/packages/database/src/advisory-lock.ts index 792b5875..278936e5 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/auth-engine.ts b/packages/database/src/auth-engine.ts index b62ea5e4..10866fde 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; @@ -416,7 +417,7 @@ const makeAccessDatabaseHooks = ( }; }; -export const makeAuthEngineOptions = ( +const authEngineOptions = ( config: AuthEngineConfig, database: Pool, run: BetterAuthCallbackRunner, @@ -497,14 +498,44 @@ 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/auth-engine/NativeAuthEngine", diff --git a/packages/database/src/auth-live.ts b/packages/database/src/auth-live.ts index 0c1ed819..3065c63f 100644 --- a/packages/database/src/auth-live.ts +++ b/packages/database/src/auth-live.ts @@ -6,6 +6,7 @@ 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"; @@ -745,112 +746,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* IdentityEngineError.make({ - 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: IdentitySecurityEventDetails.make({ - outcomeCode: "credential-accepted", - affectedSessionCount: 1, + 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: IdentitySecurityEventDetails.make({ - outcomeCode: "credential-rejected", - affectedSessionCount: 0, + 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: IdentitySecurityEventDetails.make({ - 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: IdentitySecurityEventDetails.make({ - 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 diff --git a/packages/database/src/cohort-cli.ts b/packages/database/src/cohort-cli.ts index 64505932..46d6010d 100644 --- a/packages/database/src/cohort-cli.ts +++ b/packages/database/src/cohort-cli.ts @@ -1,12 +1,12 @@ // 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!); @@ -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 === undefined || 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/database-construction.ts b/packages/database/src/database-construction.ts index 6f6cdd78..6bda5e5a 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/identity-access.ts b/packages/database/src/identity-access.ts index f12d7dc6..86ece072 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* ( diff --git a/packages/database/src/layers.ts b/packages/database/src/layers.ts index e35c5891..b4c81013 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 0219eb54..4ed3e86d 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/oauth-config.ts b/packages/database/src/oauth-config.ts index c57b32e2..55b0c7b3 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 a68a0142..cb244954 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, @@ -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, diff --git a/packages/database/src/outbox-lifecycle.ts b/packages/database/src/outbox-lifecycle.ts index ecc26042..ebcd177c 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.ts b/packages/database/src/password-recovery.ts index f024098e..a51bc2d2 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`; @@ -375,176 +376,192 @@ 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(); - - 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, + 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( - 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 === undefined) 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; + 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 ( - verification === undefined || - !/^reset-password:[A-Za-z0-9_-]+$/.test(verification.identifier) - ) { - failure = "verification-invalid"; - } 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"; - } + if (row === undefined) return "Empty"; - 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( + 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 !== undefined) 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/password-recovery/PasswordRecovery", diff --git a/packages/database/src/pg-pool.ts b/packages/database/src/pg-pool.ts index f7a31aac..9308c7f0 100644 --- a/packages/database/src/pg-pool.ts +++ b/packages/database/src/pg-pool.ts @@ -1,5 +1,6 @@ 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. */ @@ -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/test-support/observe-postgres.ts b/packages/database/src/test-support/observe-postgres.ts index 63e68f8a..c088924d 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 e615467b..425576b3 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 74c16b2e..8de0c048 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 05597a21..78de9693 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); + }), + ), +); From c1a51373409ec00294f1dd02ce7d8f5b24c2ea60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:53:32 +0000 Subject: [PATCH 108/148] refactor(database): give exported context folder functions a pipeable dual form Fixed-arity functions take dual(n, impl); the two published-article reads and listTeamApplications take the predicate form on their first argument. The construct page of receiptCursorTimestamp is regenerated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/pagination.md | 14 +- .../database/src/admission-period/postgres.ts | 286 ++++++----- packages/database/src/application/outbox.ts | 416 ++++++++------- packages/database/src/application/postgres.ts | 251 +++++---- .../src/application/returning-postgres.ts | 29 +- packages/database/src/authz/postgres.ts | 237 +++++---- packages/database/src/content/news.ts | 196 +++---- packages/database/src/content/postgres.ts | 70 ++- packages/database/src/onboarding/postgres.ts | 17 +- .../src/receipt/authority-postgres.ts | 486 +++++++++++------- packages/database/src/receipt/cursor.ts | 14 +- packages/database/src/receipt/file-proof.ts | 19 +- packages/database/src/receipt/outbox.ts | 67 ++- packages/database/src/receipt/postgres.ts | 400 ++++++++------ packages/database/src/receipt/projections.ts | 88 ++-- packages/database/src/receipt/settlement.ts | 240 +++++---- .../database/src/schools/administration.ts | 8 +- packages/database/src/schools/directory.ts | 164 +++--- .../database/src/social-events/postgres.ts | 90 ++-- .../database/src/team-application/outbox.ts | 45 +- .../database/src/team-application/postgres.ts | 87 +++- 21 files changed, 1946 insertions(+), 1278 deletions(-) diff --git a/docs/constructs/pagination.md b/docs/constructs/pagination.md index 00b09216..173bf79d 100644 --- a/docs/constructs/pagination.md +++ b/docs/constructs/pagination.md @@ -17,7 +17,7 @@ type CursorPositioned = 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,17 +42,15 @@ 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(sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment +const receiptCursorTimestamp: { (column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment } ``` -- Inputs: - - `sql: DatabaseOperations` - - `column: Statement.Fragment` -- Output: `Statement.Fragment` +- Inputs: none +- Output: `{ (column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment }` - 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 +86,7 @@ receiptCursorPage 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* 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); - } - - 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* 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 }); + 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* AdmissionPeriodNotFound.make({ - 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* AdmissionScopeDenied.make({ - 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* SemesterNotFound.make({ 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/application/outbox.ts b/packages/database/src/application/outbox.ts index 71dd53d6..7767b6ff 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, @@ -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.ts b/packages/database/src/application/postgres.ts index ebf67dc1..8ca6dd4f 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"; @@ -510,7 +511,9 @@ const executeCommandInTransaction = ( ); if (!(yield* departmentExists(sql, command.departmentId))) { - return yield* PublicApplicationDepartmentNotFound.make({ departmentId: command.departmentId }); + return yield* PublicApplicationDepartmentNotFound.make({ + departmentId: command.departmentId, + }); } const period = yield* findEligiblePeriod(sql, command.departmentId, now); @@ -548,8 +551,8 @@ const executeCommandInTransaction = ( ? yield* Schema.decodeEffect(PublicApplicationActivationTokenSchema)( context.activationToken, ).pipe( - Effect.mapError( - () => PublicApplicationDecodeError.make({ message: "invalid activation token" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid activation token" }), ), ) : undefined; @@ -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( - () => PublicApplicationDecodeError.make({ message: "invalid application identifier" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid application identifier" }), ), ); @@ -841,8 +870,8 @@ export const readApplicantContacts = ( applicationIds, { onExcessProperty: "error" }, ).pipe( - Effect.mapError( - () => PublicApplicationDecodeError.make({ message: "invalid application identifier batch" }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ message: "invalid application identifier batch" }), ), ); @@ -882,11 +911,10 @@ export const readApplicantContacts = ( const contact = yield* Schema.decodeEffect(ApplicantContactProjectionSchema)(row, { onExcessProperty: "error", }).pipe( - Effect.mapError( - () => - PublicApplicationDecodeError.make({ - message: "invalid persisted applicant contact projection", - }), + Effect.mapError(() => + PublicApplicationDecodeError.make({ + message: "invalid persisted applicant contact projection", + }), ), ); @@ -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 2bc50cc2..095b8639 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,7 +535,7 @@ const registerInTransaction = ( ), ); -export const registerReturningAssistant = ( +const registerReturningAssistantImpl = ( input: ReturningAssistantRegistrationInput, context: RegistrationContext, ) => @@ -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/authz/postgres.ts b/packages/database/src/authz/postgres.ts index 8017e3dd..17295fbd 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/content/news.ts b/packages/database/src/content/news.ts index 4583d54a..dcaab08f 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"; @@ -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* ContentArticleNotFound.make({}); - } + 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* ContentArticleNotFound.make({}); - } + 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* ContentIntegrityError.make({ - 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.ts b/packages/database/src/content/postgres.ts index b06da3ad..3d2a585e 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"; @@ -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* ContentArticleNotFound.make({}); + 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/onboarding/postgres.ts b/packages/database/src/onboarding/postgres.ts index 08056bec..69ef3b85 100644 --- a/packages/database/src/onboarding/postgres.ts +++ b/packages/database/src/onboarding/postgres.ts @@ -1,3 +1,4 @@ +import { dual } from "effect/Function"; import { DateTime, Effect } from "effect"; import { AdvisoryLockKey, lockAdvisory } from "../advisory-lock.js"; import { Database } from "../service.js"; @@ -12,7 +13,7 @@ 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<{ @@ -25,6 +26,13 @@ export const onboardingApplication = (applicationId: string, departmentId: Depar }), ); +export const onboardingApplication: { + ( + departmentId: DepartmentId, + ): (applicationId: string) => ReturnType; + (applicationId: string, departmentId: DepartmentId): ReturnType; +} = dual(2, onboardingApplicationImpl); + export const lockOnboardingApplicant = (applicantId: string) => Database.use( (sql) => @@ -137,7 +145,7 @@ 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 = @@ -146,3 +154,8 @@ export const checkOnboardingClaim = (digest: string, now: string) => 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/receipt/authority-postgres.ts b/packages/database/src/receipt/authority-postgres.ts index c1991048..7714e558 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 { @@ -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* ReceiptAuthorityRecordNotFound.make({ - 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* ReceiptAuthorityWriteConflict.make({ - 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* ReceiptAuthorityRecordNotFound.make({ - 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* ReceiptAuthorityWriteConflict.make({ - 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* ReceiptAuthorityRecordNotFound.make({ - 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* ReceiptAuthorityWriteConflict.make({ - 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, @@ -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* ReceiptAuthorityProjectionMismatch.make({ - 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 5b80ab25..52765267 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 ae8b7b9a..e4b3b214 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"; @@ -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>, @@ -793,3 +794,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 ce995b8c..5ecc1000 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, @@ -157,15 +158,28 @@ 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, @@ -198,20 +212,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, diff --git a/packages/database/src/receipt/postgres.ts b/packages/database/src/receipt/postgres.ts index f4dafe21..ac472e40 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, @@ -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, Receipt.make(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,15 +602,16 @@ 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)), + ), + ); + }), +); /** * Rule-aware approval projection. Session identity and the instant are explicit @@ -751,15 +778,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 +815,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* ReceiptNotFound.make({ 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) - : ReceiptDecodeError.make({ - 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 + : 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: `Receipt authority projection mismatch for ${cause.personId}`, + 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) - : ReceiptDecodeError.make({ - 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* InactiveActor.make({ personId }); + return yield* ReceiptScopeDenied.make({ + receiptId: candidate.receiptId, + departmentId: candidate.departmentId, + }); } - return yield* ReceiptScopeDenied.make({ - receiptId: candidate.receiptId, - departmentId: candidate.departmentId, - }); - } - - if (!decision.value.receiptIds.includes(candidate.receiptId)) { - return yield* ReceiptScopeDenied.make({ - 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, @@ -1155,19 +1200,32 @@ 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) => ReceiptDecodeError.make({ 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, { @@ -1267,11 +1325,10 @@ const executeAuthorizedReceiptCommandWithSql = ( ? yield* Schema.decodeUnknownEffect(ReceiptSubmissionAllocationSchema)(allocationInput, { onExcessProperty: "error", }).pipe( - Effect.mapError( - (cause) => - ReceiptDecodeError.make({ - message: `decode Receipt submission allocation: ${String(cause)}`, - }), + Effect.mapError((cause) => + ReceiptDecodeError.make({ + message: `decode Receipt submission allocation: ${String(cause)}`, + }), ), ) : undefined; @@ -1419,8 +1476,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 +1498,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 e28fb4d4..e91abe09 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"; @@ -241,18 +242,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 +281,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* ReceiptNotFound.make({ 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 +295,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 +303,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/settlement.ts b/packages/database/src/receipt/settlement.ts index 7781906a..2cc93b53 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 { @@ -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, @@ -525,34 +539,47 @@ 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); - - const authorization = yield* resolveSettlementAuthorizationWithSql( - sql, - command.receiptId, - principal, - ); +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, + ); - 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)), + ), + ); + }), +); export const listReceiptsForSettlement = ( personId: PersonId, @@ -654,22 +681,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 +731,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* ReceiptNotFound.make({ 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, - ) - : ReceiptDecodeError.make({ 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 - : ReceiptDecodeError.make({ - 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/schools/administration.ts b/packages/database/src/schools/administration.ts index c1337887..0baa1ca5 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"; @@ -151,7 +152,7 @@ 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"))); @@ -161,6 +162,11 @@ export const authorizeSchoolCommand = (command: SchoolCommand, personId: PersonI 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; diff --git a/packages/database/src/schools/directory.ts b/packages/database/src/schools/directory.ts index ab0cb03d..dae1ecb7 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"; @@ -31,87 +32,112 @@ const persistenceError = (operation: string, cause: unknown): SchoolsPersistence * 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" - ? SchoolsAuthorityInactive.make({}) - : SchoolsNotInScope.make({}); - } + 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) => - Match.value(cause).pipe( - Match.tag("DepartmentNotFound", () => - SchoolsDepartmentNotFound.make({ departmentId }), - ), - Match.tag("OrganizationPersistenceError", (cause) => - persistenceError("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), + ), ), - Match.orElse((cause) => decodeError("read Schools directory department", cause)), ), - ), - ); + ); - if ( - Predicate.isTagged(scope, "DepartmentIds") && - !scope.departmentIds.includes(departmentId) - ) { - return yield* SchoolsDepartmentOutOfScope.make({ 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/social-events/postgres.ts b/packages/database/src/social-events/postgres.ts index dcb49725..94610a2c 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"; @@ -283,24 +284,38 @@ export const readSocialEventListPostgres = ( * 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 = ( - 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, - }); +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, @@ -339,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, @@ -376,7 +391,7 @@ export const createSocialEventPostgres = ( ${command.occurredAt} ) `; - yield* sql` + yield* sql` INSERT INTO public.social_event_audit ( command_id, event_id, @@ -392,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/outbox.ts b/packages/database/src/team-application/outbox.ts index a32c4e30..35cff31b 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, @@ -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/postgres.ts b/packages/database/src/team-application/postgres.ts index 304b9b92..95dbbdd8 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"; @@ -419,11 +420,7 @@ const resolveActionActor = (principal: TeamApplicationPrincipal, action: TeamApp ), }); -/** - * 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 = ( +const authorizeTeamApplicationActionImpl = ( principal: TeamApplicationPrincipal, action: A, ) => @@ -435,6 +432,22 @@ export const authorizeTeamApplicationAction = ( ), ); +/** + * 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" }); @@ -538,7 +551,7 @@ export const submitTeamApplication = (command: SubmitTeamApplicationCommand) => return { confirmation, replayed: false }; }); -export const listTeamApplications = ( +const listTeamApplicationsImpl = ( principal: TeamApplicationPrincipal, teamId: TeamId, cursor?: string, @@ -575,7 +588,19 @@ 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, ) => @@ -594,7 +619,17 @@ export const readTeamApplication = ( return { application, teamName, actor }; }); -export const deleteTeamApplication = ( +export const readTeamApplication: { + ( + applicationId: TeamApplicationId, + ): (principal: TeamApplicationPrincipal) => ReturnType; + ( + principal: TeamApplicationPrincipal, + applicationId: TeamApplicationId, + ): ReturnType; +} = dual(2, readTeamApplicationImpl); + +const deleteTeamApplicationImpl = ( authorization: TeamApplicationAuthorization< Extract >, @@ -657,7 +692,23 @@ export const deleteTeamApplication = ( }); }); -export const reviseTeamApplicationIntake = ( +export const deleteTeamApplication: { + ( + input: Pick, + ): ( + authorization: TeamApplicationAuthorization< + Extract + >, + ) => ReturnType; + ( + authorization: TeamApplicationAuthorization< + Extract + >, + input: Pick, + ): ReturnType; +} = dual(2, deleteTeamApplicationImpl); + +const reviseTeamApplicationIntakeImpl = ( authorization: TeamApplicationAuthorization< Extract >, @@ -758,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); From 3a22072d2cbf2f715d4282b7762f90badfdd8472 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:54:14 +0000 Subject: [PATCH 109/148] fix(domain): give each exported multi-argument function a pipeable form missing-pipeable-signature: every exported function of two or more parameters is a dual from effect/Function, typed as an overload pair whose data-first call is unchanged. - Fixed arity: dual(n, impl). - Optional trailing parameters: the predicate form, which tells the data-first call by its first argument (draftPlacements, buildPlacementDraft, runSDep2Team, projectReceiptAuthority, mapReceiptSubmissionPrincipal, recordPublicApplicationEffects) or by the type of the argument where the two calls differ (makePublicApplicationOutboxRequests, receiptOutboxRequest). - An inferred return type is named through its implementation (ReturnType). - receiptOutboxRequest names its generic result with a local alias, which the rule's shape matcher pairs where the inline Extract did not. The construct page of authority-evidence renders the new signatures (just constructs write). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/authority-evidence.md | 34 +- packages/domain/runtime/tutor-d1.ts | 59 +- .../domain/src/admission-period/effects.ts | 38 +- packages/domain/src/admission-period/proof.ts | 39 +- .../domain/src/admission-period/update.ts | 191 +++-- packages/domain/src/admissions/outcome.ts | 44 +- packages/domain/src/application/effects.ts | 138 ++-- packages/domain/src/authz/access.ts | 174 +++-- packages/domain/src/authz/delegation.ts | 237 +++--- packages/domain/src/authz/reach.ts | 327 ++++++--- packages/domain/src/authz/rules.ts | 545 ++++++++------ .../src/authz/service-principal-grants.ts | 364 +++++---- packages/domain/src/contact/service.ts | 77 +- packages/domain/src/content/actor.ts | 74 +- packages/domain/src/content/journeys.ts | 43 +- packages/domain/src/content/projection.ts | 130 ++-- packages/domain/src/content/sanitize.ts | 66 +- packages/domain/src/identity/access.ts | 52 +- packages/domain/src/laws.ts | 447 +++++------ .../domain/src/organization/administration.ts | 11 +- .../authority-fixtures.test-support.ts | 36 +- packages/domain/src/organization/authority.ts | 209 +++--- packages/domain/src/organization/directory.ts | 11 +- packages/domain/src/organization/lifecycle.ts | 285 +++++--- .../domain/src/organization/mailing-lists.ts | 25 +- .../src/organization/review-classification.ts | 123 ++-- packages/domain/src/organization/review.ts | 137 ++-- .../domain/src/organization/transitions.ts | 8 +- packages/domain/src/placements/days-served.ts | 97 +-- packages/domain/src/placements/policy.ts | 116 +-- packages/domain/src/placements/scheduler.ts | 335 +++++---- packages/domain/src/receipt/approval-list.ts | 286 +++++--- packages/domain/src/receipt/authority.ts | 691 ++++++++++-------- packages/domain/src/receipt/effects.ts | 83 ++- packages/domain/src/receipt/errors.ts | 36 +- packages/domain/src/receipt/import.ts | 24 +- packages/domain/src/receipt/pagination.ts | 24 +- packages/domain/src/receipt/update.ts | 34 +- .../src/recruitment/applicant-identity.ts | 14 +- packages/domain/src/recruitment/conduct.ts | 370 ++++++---- packages/domain/src/recruitment/report.ts | 13 +- packages/domain/src/recruitment/scheduling.ts | 74 +- packages/domain/src/report.ts | 11 +- packages/domain/src/runtime-services.ts | 35 +- packages/domain/src/schema.ts | 70 +- packages/domain/src/schools/administration.ts | 28 +- .../domain/src/social-events/authority.ts | 102 ++- .../src/team-application/notification.ts | 144 ++-- .../domain/src/team-application/pagination.ts | 38 +- .../domain/src/team-application/policy.ts | 58 +- packages/domain/src/time.ts | 8 +- packages/domain/src/tutor/tracer.ts | 167 +++-- 52 files changed, 4043 insertions(+), 2739 deletions(-) diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index 13f711d2..a2ab40ea 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -9,22 +9,15 @@ Checks a resolved authority and returns the evidence that a command which needs Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. ```ts -requireDepartmentReach( - authority: OrganizationPersonAuthority, - capability: C, - departmentId: DepartmentId -): Result.Result, DepartmentReachDenied> +const requireDepartmentReach: { (capability: C, departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied>; (authority: OrganizationPersonAuthority, capability: C, departmentId: DepartmentId): Result.Result, DepartmentReachDenied> } ``` -- Inputs: - - `authority: OrganizationPersonAuthority` - - `capability: C` - - `departmentId: DepartmentId` -- Output: `Result.Result, DepartmentReachDenied>` -- Errors: `DepartmentReachDenied` +- Inputs: none +- Output: `{ (capability: C, departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied>; (authority: OrganizationPersonAuthority, capability: C, departmentId: DepartmentId): Result.Result, DepartmentReachDenied> }` +- Errors: none - Requirements: none - Side effects: none -- Source: [packages/domain/src/authz/reach.ts:194](../../packages/domain/src/authz/reach.ts#L194) +- Source: [packages/domain/src/authz/reach.ts:234](../../packages/domain/src/authz/reach.ts#L234) **How it works** @@ -63,7 +56,7 @@ requireOrganizationAdministrator( - Errors: `OrganizationRoleDenied` - Requirements: none - Side effects: none -- Source: [packages/domain/src/organization/authority.ts:271](../../packages/domain/src/organization/authority.ts#L271) +- Source: [packages/domain/src/organization/authority.ts:285](../../packages/domain/src/organization/authority.ts#L285) **How it works** @@ -90,20 +83,15 @@ require the evidence here. Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. ```ts -requireTeamInterestScope( - authority: OrganizationPersonAuthority, - input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray } -): Result.Result +const requireTeamInterestScope: { (input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): (authority: OrganizationPersonAuthority) => Result.Result; (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` +- Inputs: none +- Output: `{ (input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): (authority: OrganizationPersonAuthority) => Result.Result; (authority: OrganizationPersonAuthority, input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): Result.Result }` +- Errors: none - Requirements: none - Side effects: none -- Source: [packages/domain/src/organization/authority.ts:366](../../packages/domain/src/organization/authority.ts#L366) +- Source: [packages/domain/src/organization/authority.ts:380](../../packages/domain/src/organization/authority.ts#L380) **How it works** diff --git a/packages/domain/runtime/tutor-d1.ts b/packages/domain/runtime/tutor-d1.ts index 0a9a03b7..9d1efed8 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 4fbe1eab..9f06af7a 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 b46c8775..a1b53ecf 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/update.ts b/packages/domain/src/admission-period/update.ts index f6ed6158..eef00800 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 { @@ -140,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* InvalidAdmissionPeriodWindow.make({ - 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* AdmissionPeriodAlreadyExists.make({ + 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, }); } - const period: AdmissionPeriod = { - id: periodIdForCreate(command, context), - departmentId, - semesterId: command.semesterId, + if (current.revision !== command.expectedRevision) { + return yield* StaleAdmissionPeriodRevision.make({ + 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: 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* 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, - }); - } - - 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 8b3b1940..6f197d1b 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/application/effects.ts b/packages/domain/src/application/effects.ts index efa78e62..b0f59a04 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; @@ -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/authz/access.ts b/packages/domain/src/authz/access.ts index 7c6d5347..d4ba4967 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, @@ -987,14 +988,25 @@ 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 => Match.value(expression).pipe( @@ -1147,12 +1159,21 @@ export const decodeGrant = flow( (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 => +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", () => true), @@ -1177,7 +1198,8 @@ export const scopeMatches = (scope: Scope, context: CanonicalResourceContext): b (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; @@ -1207,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"; @@ -1432,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 }); - } - - if (!credentialMatchesAccessSpec(spec, credential)) { - return AccessEvaluation.CredentialRejected({ reason: "WrongMechanism" }); - } - - const resolution = yield* services.resolveScope( - input, - credential.principal, - authorizationInstant, - spec.decisionTime, - ); +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 }); + } - const grants = yield* services.resolveGrants( - credential.principal, - resolution, - authorizationInstant, - spec.decisionTime, - ); + if (!credentialMatchesAccessSpec(spec, credential)) { + return AccessEvaluation.CredentialRejected({ reason: "WrongMechanism" }); + } - return evaluateAccess({ spec, credential, resolution, grants, authorizationInstant }); - }); + const resolution = yield* services.resolveScope( + input, + credential.principal, + authorizationInstant, + spec.decisionTime, + ); + + const grants = yield* services.resolveGrants( + credential.principal, + resolution, + authorizationInstant, + spec.decisionTime, + ); + + 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")) { @@ -1482,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 3a7812a6..b2e4aed3 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 }; @@ -366,58 +388,79 @@ const invalid = () => Result.fail(DelegationTransitionFailure.make({ code: "Inva * 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(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, - ); +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.ts b/packages/domain/src/authz/reach.ts index 8dcf2906..561ea334 100644 --- a/packages/domain/src/authz/reach.ts +++ b/packages/domain/src/authz/reach.ts @@ -4,6 +4,7 @@ * asks this module; no other product code reads a leadership flag. */ 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,40 @@ 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; @@ -191,23 +231,46 @@ export class DepartmentReachDenied extends Data.TaggedError("DepartmentReachDeni * * @construct authority-evidence */ -export const requireDepartmentReach = ( - /** 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 }), - ); +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<{ @@ -217,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. @@ -262,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 => @@ -355,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 81ecee07..49ac7bfb 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,59 +34,87 @@ 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) => - 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 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 = ( - rule: AuthzRule, - context: CanonicalResourceContext, -): boolean => +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", () => true), @@ -99,22 +128,33 @@ export const authzRuleScopeApplies = ( 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; @@ -170,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; @@ -236,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 = []; + const approvalContributions: Array = []; + const paymentContributions: Array = []; + const requirementContributions: Array = []; - for (const rule of applicableRules) { - if (Predicate.isTagged(rule.subject, "ServicePrincipal") && rule.effectKind !== "requirement") - continue; + for (const rule of applicableRules) { + if (Predicate.isTagged(rule.subject, "ServicePrincipal") && rule.effectKind !== "requirement") + continue; - if (rule.effectKind === "requirement") { - if ( - rule.capabilityId !== "approveReceipt" || - !CAPABILITY_IDS.approveReceipt.requirementSlots.includes(rule.params.requirementId) - ) { + 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; } - 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.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({ + 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, + }, + }); + } - 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({ + 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: { - 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 ee84f7bd..f192788c 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( @@ -191,140 +192,169 @@ export const makeServicePrincipalReceiptGrant = Schema.decodeUnknownSync( }, ); -export const composeServicePrincipalReceiptRuleRequirements = ( - authority: ServicePrincipalReceiptGrantAuthority, - context: CanonicalResourceContext, - authorizationInstant: AuthorizationInstant, -) => - composeCapabilityEvidence("approveReceipt", {}, authority.rules, { - principal: PrincipalSchema.cases.ServicePrincipal.make({ - servicePrincipalId: authority.servicePrincipalId, +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: [], }), - 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 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 +381,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/service.ts b/packages/domain/src/contact/service.ts index 338d2bd9..648f3f00 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"; @@ -29,33 +30,49 @@ export class ContactQuota extends Context.Service< >()("@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 9eebb8ab..f752dacd 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"; @@ -95,10 +96,10 @@ export const contentScopeFor = (actor: ContentActor): ContentScope => ); /** 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; @@ -107,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/journeys.ts b/packages/domain/src/content/journeys.ts index 36ca4610..1765c5e4 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, @@ -65,6 +90,18 @@ export const runPublicationTransition = ( ); }); +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 d1bdf356..17c70a25 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 3e1fef59..5f99106e 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: () => ContentDecodeError.make({ operation, message: "sanitizer failure" }), - }).pipe( - Effect.flatMap((result) => { - if (result.rejection !== undefined) { - return Effect.fail(ContentDecodeError.make({ 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( - ContentDecodeError.make({ - 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( - ContentDecodeError.make({ - 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/identity/access.ts b/packages/domain/src/identity/access.ts index 3bbddc57..8afa3c35 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(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 }); -}; +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/laws.ts b/packages/domain/src/laws.ts index b9e7435e..f7af1914 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/organization/administration.ts b/packages/domain/src/organization/administration.ts index 2121c16d..a3411ab5 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -1,4 +1,5 @@ import { flow, Effect, Schema } from "effect"; +import { dual } from "effect/Function"; import { canonicalJsonBytes, sha256Hex } from "../shared-kernel/index.js"; import { OrganizationDecodeError } from "./errors.js"; import { @@ -56,10 +57,12 @@ 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 const organizationEntityDigest: { + (commandId: OrganizationCommandId): (entityKind: OrganizationEntityKind) => string; + (entityKind: OrganizationEntityKind, commandId: OrganizationCommandId): string; +} = dual(2, (entityKind: OrganizationEntityKind, commandId: OrganizationCommandId): string => + sha256Hex(canonicalJsonBytes({ entityKind, commandId })), +); export function organizationEntityIdForCommand( entityKind: "Department", diff --git a/packages/domain/src/organization/authority-fixtures.test-support.ts b/packages/domain/src/organization/authority-fixtures.test-support.ts index b7c8fde6..e23dcca6 100644 --- a/packages/domain/src/organization/authority-fixtures.test-support.ts +++ b/packages/domain/src/organization/authority-fixtures.test-support.ts @@ -3,6 +3,7 @@ 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; @@ -134,15 +135,28 @@ export const activeAdministratorAuthority = (personId: string): OrganizationPers * 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 = ( - personId: string, - capability: C, - departmentId: string, -): DepartmentReach => - Result.getOrThrow( - requireDepartmentReach( - activeAdministratorAuthority(personId), - capability, - DepartmentId.make(departmentId), +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 c43530b4..27263f65 100644 --- a/packages/domain/src/organization/authority.ts +++ b/packages/domain/src/organization/authority.ts @@ -1,4 +1,5 @@ import { Data, Result, Schema } from "effect"; +import { dual } from "effect/Function"; import { type AdmissionPeriodActor, AdmissionPeriodActorSchema, @@ -180,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, + }), + ); + } - 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, ); - } - 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, + }), + ); + } - 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", ); - } - - return deny( - memberships.length > 0 || authority.globalAdministrator === "Inactive" - ? "AuthorityInactive" - : "NotInScope", - ); -}; + }, +); export const mapOrganizationAuthorityToOrganizationActor = ( authority: OrganizationPersonAuthority, @@ -363,54 +377,79 @@ export class TeamInterestScopeDenied extends Data.TaggedError("TeamInterestScope * * @construct authority-evidence */ -export const requireTeamInterestScope = ( - /** The person's authority, resolved from current facts at the request's instant. */ - authority: OrganizationPersonAuthority, - input: { +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 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), - ); + }): ( + /** 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, - ); -}; + 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 95c83eb7..62dc9ced 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/lifecycle.ts b/packages/domain/src/organization/lifecycle.ts index 2809d695..7fdfda68 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,73 +222,92 @@ export class AppointmentTransitionFailure extends Schema.TaggedError => - 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) }), +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; @@ -289,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(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, - }); -}; +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(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, - }); -}; +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 f6e9ddff..c4825937 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 9d8f59f9..e8585d8c 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, @@ -441,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 2235a5c4..de525cac 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 === undefined || - 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/transitions.ts b/packages/domain/src/organization/transitions.ts index 18f79826..5237c3e4 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/days-served.ts b/packages/domain/src/placements/days-served.ts index d85e06d0..35a3d757 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 => @@ -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 64e76090..996f3b5c 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 9d442fe6..508f6e70 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/receipt/approval-list.ts b/packages/domain/src/receipt/approval-list.ts index a2d8c904..36ce9a66 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 adb762c7..c9cde092 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; @@ -479,81 +495,105 @@ const ambiguousPayment = ( .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,132 +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(() => - ReceiptScopeDenied.make({ - receiptId, - departmentId: receiptDepartmentId, - }), +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(ReceiptNotFound.make({ 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 = ( @@ -716,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/effects.ts b/packages/domain/src/receipt/effects.ts index f21e7b87..00bb0642 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 66e54d27..935bdd4f 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,18 +115,31 @@ export class FailedComposedRequirement extends Schema.TaggedError - Match.value(reason).pipe( - Match.when("Ambiguous", () => AmbiguousParameterFill.make({ personId, capabilityId })), - Match.when("RequirementFailed", () => - FailedComposedRequirement.make({ 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/import.ts b/packages/domain/src/receipt/import.ts index 13ee265c..20836dcd 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 e0ef6498..90466720 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; @@ -58,13 +59,16 @@ export const decodeReceiptCursor = ( return { timestamp, receiptId }; }).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/update.ts b/packages/domain/src/receipt/update.ts index 11c83db2..cbb61116 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, @@ -380,15 +381,28 @@ const decodeReceiptDecisionContext = flow( 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 8d8f2533..c9ac9681 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 bfc18e98..57829fc8 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, @@ -233,121 +234,151 @@ const validateScore = ( ), ); -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* RecruitmentInvitationNotAccepted.make({ - 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* RecruitmentInterviewAlreadyFinalized.make({ - interviewId: state.interview.interviewId, - }); + if (state.conduct !== null) + return yield* RecruitmentInterviewAlreadyFinalized.make({ + interviewId: state.interview.interviewId, + }); - 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 (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 = RecruitmentInterviewConduct.make({ - 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) => - RecruitmentConductValidationError.make({ + interviewRevision: revision, + }); + + const observation = yield* Schema.decodeEffect(FinalizeInterviewObservationSchema)( + FinalizeInterviewObservationSchema.make({ + commandId: command.commandId, interviewId: state.interview.interviewId, - message: String(cause), + interviewRevision: revision, + finalizedAt: now, + completionState: "Completed", + cancellationState: "NotCancelled", + notificationState: "Pending", }), - ), - ); - - return { observation, state: { ...state, conduct, revision } }; - }); + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), + ), + ); + + 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* RecruitmentInterviewAlreadyFinalized.make({ - 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* RecruitmentInterviewAlreadyCancelled.make({ + const cancellation = RecruitmentInterviewCancellation.make({ interviewId: state.interview.interviewId, + cancelledByPersonId: actor.personId, + cancelledAt: now, + interviewRevision: revision, }); - const revision = state.revision + 1; - const cancellation = RecruitmentInterviewCancellation.make({ - 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) => - RecruitmentConductValidationError.make({ + const observation = yield* Schema.decodeEffect(CancelInterviewObservationSchema)( + CancelInterviewObservationSchema.make({ + commandId: command.commandId, interviewId: state.interview.interviewId, - message: String(cause), + interviewRevision: revision, + cancelledAt: now, + completionState: "NotCompleted", + cancellationState: "Cancelled", }), - ), - ); - - return { observation, state: { ...state, cancellation, revision } }; - }); + { onExcessProperty: "error" }, + ).pipe( + Effect.mapError((cause) => + RecruitmentConductValidationError.make({ + interviewId: state.interview.interviewId, + message: String(cause), + }), + ), + ); + + return { observation, state: { ...state, cancellation, revision } }; + }), +); export interface CorrectionTransition { readonly observation: CorrectInterviewAssessmentObservation; @@ -355,74 +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* RecruitmentInterviewAlreadyCancelled.make({ - 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* 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 (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) => - RecruitmentConductValidationError.make({ + const correction = yield* Schema.decodeEffect(RecruitmentInterviewCorrectionSchema)( + { 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) => - RecruitmentConductValidationError.make({ + 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, - message: String(cause), + predecessorRevision: state.revision, + resultingRevision, }), - ), - ); - - return { observation, correction, state: { ...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 } }; + }), +); export const recruitmentInterviewAnswerIsValid = ( value: unknown, diff --git a/packages/domain/src/recruitment/report.ts b/packages/domain/src/recruitment/report.ts index 992877b6..790d9a81 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 8a741fcb..16bbf0dc 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( - RecruitmentInterviewAlreadyScheduled.make({ 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(RecruitmentScheduleInPast.make({ 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/report.ts b/packages/domain/src/report.ts index 971d76db..2d37f6a9 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 e4ec31af..22b61e42 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; @@ -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 fa838248..0ae86d29 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" @@ -346,29 +347,50 @@ export const decodeGlobalMembership = flow( : 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/administration.ts b/packages/domain/src/schools/administration.ts index 403c97e6..c8240cf6 100644 --- a/packages/domain/src/schools/administration.ts +++ b/packages/domain/src/schools/administration.ts @@ -1,4 +1,5 @@ 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"; @@ -152,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/social-events/authority.ts b/packages/domain/src/social-events/authority.ts index 9f64d810..9ea566fa 100644 --- a/packages/domain/src/social-events/authority.ts +++ b/packages/domain/src/social-events/authority.ts @@ -1,4 +1,5 @@ import { Array, Data, Predicate, Result } from "effect"; +import { dual } from "effect/Function"; import { PrincipalSchema, AuthorityRef, @@ -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,13 +131,16 @@ 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; @@ -163,20 +178,39 @@ export class SocialEventCreationDenied extends Data.TaggedError("SocialEventCrea * @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 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 })); +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/team-application/notification.ts b/packages/domain/src/team-application/notification.ts index 02445a70..a83b719a 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 6614b6b2..303cb7bd 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; @@ -59,18 +60,29 @@ export const decodeTeamApplicationCursor = ( }).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 93f9b026..3196c84f 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/time.ts b/packages/domain/src/time.ts index cf0e1609..d4827976 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/tracer.ts b/packages/domain/src/tutor/tracer.ts index 83c58445..aa3adcf0 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, - }); - }); + }), +); From 553bd6e3adf4034aaaa7f169fcf876f1b145b604 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:56:51 +0000 Subject: [PATCH 110/148] docs(constructs): regenerate the pages after the merged lint cleanup Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/sql-lock.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/constructs/sql-lock.md b/docs/constructs/sql-lock.md index f2b45687..f253320a 100644 --- a/docs/constructs/sql-lock.md +++ b/docs/constructs/sql-lock.md @@ -81,20 +81,15 @@ guards nothing, and hand-written `pg_advisory_xact_lock` SQL, which Serializes one person's protected command with person-keyed authority writers. ```ts -lockPersonAuthorization( - sql: DatabaseOperations, - personId: PersonId -): Effect.Effect +const lockPersonAuthorization: { (personId: PersonId): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId): Effect.Effect } ``` -- Inputs: - - `sql: DatabaseOperations` - - `personId: PersonId` -- Output: `Effect.Effect` -- Errors: `OrganizationPersistenceError` +- Inputs: none +- Output: `{ (personId: PersonId): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId): Effect.Effect }` +- Errors: none - 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** From 8efb6756f636656fc22e0a5f5b5928e29ad020ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:58:27 +0000 Subject: [PATCH 111/148] chore(lint): declare the proof, rehearsal, and example programs of domain and database as entry points strict-effect-provide is off at entry points, which the rule cannot recognise itself. The runtime folders of packages/domain and packages/database, the database examples, and two proof modules that provide each proof its own database run as programs, so they join the entry points. This clears the 10 remaining strict-effect-provide findings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/oxlint.config.ts b/oxlint.config.ts index 70173cca..55fd68a6 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -257,6 +257,13 @@ const entryPointFiles = [ .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({ From d54377de2811f4d6bc25a4fbda9e330a09907d40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:02:16 +0000 Subject: [PATCH 112/148] fix(conventions): describe a dual construct by its data-first overload A const typed as an overload set, such as the pair that `dual` declares, rendered as a value: "Inputs: none" and the whole overload type as its output. The contract now takes the parameters and return type of the overload with the most parameters, lists every overload in the signature block, and splits errors and requirements from that return type. The untagged-candidate scan treats such a const as a function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/authority-evidence.md | 35 +++-- docs/constructs/delivery.md | 21 ++- docs/constructs/http-transport.md | 9 +- docs/constructs/pagination.md | 9 +- docs/constructs/rpc-problem.md | 156 ++++++++++++++++----- docs/constructs/sql-lifecycle.md | 149 ++++++++++++++++---- docs/constructs/sql-lock.md | 35 +++-- docs/constructs/worker.md | 18 ++- tools/conventions/src/contracts.ts | 52 ++++++- tools/conventions/tests/constructs.test.ts | 79 +++++++++++ 10 files changed, 466 insertions(+), 97 deletions(-) diff --git a/docs/constructs/authority-evidence.md b/docs/constructs/authority-evidence.md index a2ab40ea..1c9d3f65 100644 --- a/docs/constructs/authority-evidence.md +++ b/docs/constructs/authority-evidence.md @@ -9,12 +9,23 @@ Checks a resolved authority and returns the evidence that a command which needs Checks that a resolved authority reaches one department with a capability, and returns the evidence that a department-scoped command requires. ```ts -const requireDepartmentReach: { (capability: C, departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied>; (authority: OrganizationPersonAuthority, capability: C, departmentId: DepartmentId): Result.Result, DepartmentReachDenied> } +requireDepartmentReach( + capability: C, + departmentId: DepartmentId +): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied> +requireDepartmentReach( + authority: OrganizationPersonAuthority, + capability: C, + departmentId: DepartmentId +): Result.Result, DepartmentReachDenied> ``` -- Inputs: none -- Output: `{ (capability: C, departmentId: DepartmentId): (authority: OrganizationPersonAuthority) => Result.Result, DepartmentReachDenied>; (authority: OrganizationPersonAuthority, capability: C, departmentId: DepartmentId): Result.Result, DepartmentReachDenied> }` -- Errors: none +- 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) @@ -83,12 +94,20 @@ require the evidence here. Checks what team interest a resolved authority may read, narrowed to one requested department, and returns the scope that the listing requires. ```ts -const requireTeamInterestScope: { (input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): (authority: OrganizationPersonAuthority) => Result.Result; (authority: OrganizationPersonAuthority, input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): Result.Result } +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: none -- Output: `{ (input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): (authority: OrganizationPersonAuthority) => Result.Result; (authority: OrganizationPersonAuthority, input: { readonly requested: DepartmentId | undefined; readonly departments: ReadonlyArray }): Result.Result }` -- Errors: none +- 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) diff --git a/docs/constructs/delivery.md b/docs/constructs/delivery.md index 53d6efe0..dcb28767 100644 --- a/docs/constructs/delivery.md +++ b/docs/constructs/delivery.md @@ -9,13 +9,24 @@ Delivers committed effects to providers after the transaction. The [index](../co Shared acknowledged JSON transport; deliberately no retry on ambiguous acceptance. ```ts -const deliverJson: { (body: Schema.Json, config: HttpDeliveryConfig, headers?: Readonly>): Effect.Effect; (config: HttpDeliveryConfig, headers?: Readonly>): (body: Schema.Json) => Effect.Effect } +deliverJson( + body: Schema.Json, + config: HttpDeliveryConfig, + headers?: Readonly> +): Effect.Effect +deliverJson( + config: HttpDeliveryConfig, + headers?: Readonly> +): (body: Schema.Json) => Effect.Effect ``` -- Inputs: none -- Output: `{ (body: Schema.Json, config: HttpDeliveryConfig, headers?: Readonly>): Effect.Effect; (config: HttpDeliveryConfig, headers?: Readonly>): (body: Schema.Json) => Effect.Effect }` -- Errors: none -- Requirements: none +- Inputs: + - `body: Schema.Json` + - `config: HttpDeliveryConfig` + - `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:57](../../apps/backend/src/delivery/http.ts#L57) diff --git a/docs/constructs/http-transport.md b/docs/constructs/http-transport.md index 1f1a50d2..fbc06464 100644 --- a/docs/constructs/http-transport.md +++ b/docs/constructs/http-transport.md @@ -44,11 +44,14 @@ identity with this. Fills a route template with its encoded identities; a missing identity is a malformed request. ```ts -const normalizeTarget: { (identities: Readonly>): (routeTemplate: string) => string; (routeTemplate: string, identities: Readonly>): string } +normalizeTarget(identities: Readonly>): (routeTemplate: string) => string +normalizeTarget(routeTemplate: string, identities: Readonly>): string ``` -- Inputs: none -- Output: `{ (identities: Readonly>): (routeTemplate: string) => string; (routeTemplate: string, identities: Readonly>): string }` +- Inputs: + - `routeTemplate: string` + - `identities: Readonly>` +- Output: `string` - Errors: none - Throws: The `Problem` request.malformed when `identities` lacks a name of the template, or an identity is not a string of Unicode scalar values. - Requirements: none diff --git a/docs/constructs/pagination.md b/docs/constructs/pagination.md index 173bf79d..84556565 100644 --- a/docs/constructs/pagination.md +++ b/docs/constructs/pagination.md @@ -42,11 +42,14 @@ 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 -const receiptCursorTimestamp: { (column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment } +receiptCursorTimestamp(column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment +receiptCursorTimestamp(sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment ``` -- Inputs: none -- Output: `{ (column: Statement.Fragment): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, column: Statement.Fragment): Statement.Fragment }` +- Inputs: + - `sql: DatabaseOperations` + - `column: Statement.Fragment` +- Output: `Statement.Fragment` - Errors: none - Requirements: none - Side effects: none: it builds a fragment, and the statement that embeds it reads the rows. diff --git a/docs/constructs/rpc-problem.md b/docs/constructs/rpc-problem.md index 2c965902..a71022f5 100644 --- a/docs/constructs/rpc-problem.md +++ b/docs/constructs/rpc-problem.md @@ -9,12 +9,20 @@ Answers a native RPC with a declared problem: failure mapping, credential classi Evaluates one admission person AccessSpec. ```ts -const authorizeAdmissionPerson: { (input: NativePersonAuthorization): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>; (headers: Headers.Headers, input: NativePersonAuthorization): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> } +authorizeAdmissionPerson( + 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: none -- Output: `{ (input: NativePersonAuthorization): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">>; (headers: Headers.Headers, input: NativePersonAuthorization): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">> }` -- Errors: none +- Inputs: + - `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/access.ts:68](../../apps/backend/src/admission/access.ts#L68) @@ -41,13 +49,24 @@ RPC: admission specs reveal their denials, so that problem never occurs. Call th Resolves the current person and authorizes one returning-assistant operation on that person's own profile. ```ts -const returningAuthorization: { (options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority>; (headers: Headers.Headers, options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> } +returningAuthorization( + 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: none -- Output: `{ (options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): (headers: Headers.Headers) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority>; (headers: Headers.Headers, options: NativeRpcOptions, rpc: typeof ReadReturningAssistantOptions | typeof RegisterReturningAssistant): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing">, Database | Organization | IdentitySnapshot | OAuthCredentialAuthority> }` -- Errors: none -- Requirements: none +- Inputs: + - `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/access.ts:118](../../apps/backend/src/admission/access.ts#L118) @@ -74,12 +93,20 @@ operation acts on the person's own profile, not on a department. Resolve it with The admission actor of one department scope. ```ts -const admissionActorForAuthority: { (authority: OrganizationPersonAuthority, departmentScope?: string): Effect.Effect; (departmentScope?: string): (authority: OrganizationPersonAuthority) => Effect.Effect } +admissionActorForAuthority( + authority: OrganizationPersonAuthority, + departmentScope?: string +): Effect.Effect +admissionActorForAuthority( + departmentScope?: string +): (authority: OrganizationPersonAuthority) => Effect.Effect ``` -- Inputs: none -- Output: `{ (authority: OrganizationPersonAuthority, departmentScope?: string): Effect.Effect; (departmentScope?: string): (authority: OrganizationPersonAuthority) => Effect.Effect }` -- Errors: none +- Inputs: + - `authority: OrganizationPersonAuthority` + - `departmentScope?: string` +- Output: `Effect.Effect` +- Errors: `AdmissionRoleDenied | AdmissionScopeDenied | InactiveActor` - Requirements: none - Side effects: none - Source: [apps/backend/src/admission/context.ts:46](../../apps/backend/src/admission/context.ts#L46) @@ -107,11 +134,19 @@ throws, which escape the error channel as defects. Map it with this. The one answer for every admission failure. ```ts -const admissionProblems: { (unavailable: Unavailable): (headers: Headers.Headers) => ProblemMapper>; (headers: Headers.Headers, unavailable: Unavailable): ProblemMapper> } +admissionProblems( + unavailable: Unavailable +): (headers: Headers.Headers) => ProblemMapper> +admissionProblems( + headers: Headers.Headers, + unavailable: Unavailable +): ProblemMapper> ``` -- Inputs: none -- Output: `{ (unavailable: Unavailable): (headers: Headers.Headers) => ProblemMapper>; (headers: Headers.Headers, unavailable: Unavailable): ProblemMapper> }` +- Inputs: + - `headers: Headers.Headers` + - `unavailable: Unavailable` +- Output: `ProblemMapper>` - Errors: none - Requirements: none - Side effects: none @@ -561,12 +596,20 @@ receipt, and the domain command runs twice. Pass the old route path. Fails a mutation whose If-Match no longer names the current representation. ```ts -const requireCurrentETag: { (ifMatch: StrongETag): (current: StrongETag) => Effect.Effect>; (current: StrongETag, ifMatch: StrongETag): Effect.Effect> } +requireCurrentETag( + ifMatch: StrongETag +): (current: StrongETag) => Effect.Effect> +requireCurrentETag( + current: StrongETag, + ifMatch: StrongETag +): Effect.Effect> ``` -- Inputs: none -- Output: `{ (ifMatch: StrongETag): (current: StrongETag) => Effect.Effect>; (current: StrongETag, ifMatch: StrongETag): Effect.Effect> }` -- Errors: none +- Inputs: + - `current: StrongETag` + - `ifMatch: StrongETag` +- Output: `Effect.Effect>` +- Errors: `Problem<"precondition.failed">` - Requirements: none - Side effects: none - Source: [apps/backend/src/rpc/problem.ts:278](../../apps/backend/src/rpc/problem.ts#L278) @@ -724,11 +767,14 @@ failures differently. Pipe this after the domain's mapper. The credential evidence of an RPC request: which credential headers it presented. ```ts -const personPresentation: { (headers: Headers.Headers, challenge?: string): CredentialPresentation; (challenge?: string): (headers: Headers.Headers) => CredentialPresentation } +personPresentation(headers: Headers.Headers, challenge?: string): CredentialPresentation +personPresentation(challenge?: string): (headers: Headers.Headers) => CredentialPresentation ``` -- Inputs: none -- Output: `{ (headers: Headers.Headers, challenge?: string): CredentialPresentation; (challenge?: string): (headers: Headers.Headers) => CredentialPresentation }` +- Inputs: + - `headers: Headers.Headers` + - `challenge?: string` +- Output: `CredentialPresentation` - Errors: none - Requirements: none - Side effects: none @@ -789,11 +835,22 @@ 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 -const authorizeAnonymous: { (resolution: CanonicalScopeResolution, now: string): (spec: AccessSpec) => Effect.Effect; (spec: AccessSpec, resolution: CanonicalScopeResolution, now: string): Effect.Effect } +authorizeAnonymous( + resolution: CanonicalScopeResolution, + now: string +): (spec: AccessSpec) => Effect.Effect +authorizeAnonymous( + spec: AccessSpec, + resolution: CanonicalScopeResolution, + now: string +): Effect.Effect ``` -- Inputs: none -- Output: `{ (resolution: CanonicalScopeResolution, now: string): (spec: AccessSpec) => Effect.Effect; (spec: AccessSpec, resolution: CanonicalScopeResolution, now: string): Effect.Effect }` +- Inputs: + - `spec: AccessSpec` + - `resolution: CanonicalScopeResolution` + - `now: string` +- Output: `Effect.Effect` - Errors: none - Requirements: none - Side effects: none @@ -821,12 +878,20 @@ 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 -const authorizePerson: { (presentation: CredentialPresentation): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>; (input: NativePersonAuthorization, presentation: CredentialPresentation): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> } +authorizePerson( + presentation: CredentialPresentation +): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> +authorizePerson( + input: NativePersonAuthorization, + presentation: CredentialPresentation +): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> ``` -- Inputs: none -- Output: `{ (presentation: CredentialPresentation): (input: NativePersonAuthorization) => Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>; (input: NativePersonAuthorization, presentation: CredentialPresentation): Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">> }` -- Errors: none +- Inputs: + - `input: NativePersonAuthorization` + - `presentation: CredentialPresentation` +- Output: `Effect.Effect | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">>` +- Errors: `| Problem<"authority.denied"> | Problem<"credential.invalid"> | Problem<"credential.missing"> | Problem<"resource.not-found">` - Requirements: none - Side effects: none - Source: [apps/backend/src/rpc/problem.ts:585](../../apps/backend/src/rpc/problem.ts#L585) @@ -893,11 +958,19 @@ contract then promises an answer that the operation never gives. Mark it unreach Creates the closed Problem Details union of one RPC. ```ts -const problemUnion: { ]>(codes: Codes): (identifier: string) => Schema.Union>>; ]>(identifier: string, codes: Codes): Schema.Union>> } +problemUnion]>( + codes: Codes +): (identifier: string) => Schema.Union>> +problemUnion]>( + identifier: string, + codes: Codes +): Schema.Union>> ``` -- Inputs: none -- Output: `{ ]>(codes: Codes): (identifier: string) => Schema.Union>>; ]>(identifier: string, codes: Codes): Schema.Union>> }` +- Inputs: + - `identifier: string` + - `codes: Codes` +- Output: `Schema.Union>>` - Errors: none - Requirements: none - Side effects: none @@ -1050,11 +1123,22 @@ leak into the body, and the registry members go missing. Render the body with th Builds one safe fixed public problem value. ```ts -const makeNativeProblem: { (code: Code, expectedStatus?: number, instance?: string): RegistryProblemBody; (expectedStatus?: number, instance?: string): (code: Code) => RegistryProblemBody } +makeNativeProblem( + code: Code, + expectedStatus?: number, + instance?: string +): RegistryProblemBody +makeNativeProblem( + expectedStatus?: number, + instance?: string +): (code: Code) => RegistryProblemBody ``` -- Inputs: none -- Output: `{ (code: Code, expectedStatus?: number, instance?: string): RegistryProblemBody; (expectedStatus?: number, instance?: string): (code: Code) => RegistryProblemBody }` +- Inputs: + - `code: Code` + - `expectedStatus?: number` + - `instance?: string` +- Output: `RegistryProblemBody` - Errors: none - Throws: An `Error` when `expectedStatus` differs from the status that the registry freezes for `code`. - Requirements: none diff --git a/docs/constructs/sql-lifecycle.md b/docs/constructs/sql-lifecycle.md index 39d7d634..480c3cae 100644 --- a/docs/constructs/sql-lifecycle.md +++ b/docs/constructs/sql-lifecycle.md @@ -9,12 +9,23 @@ 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 -const accountAccessEnabled: { (personId: PersonId, lock: "None" | "ForShare"): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId, lock: "None" | "ForShare"): Effect.Effect } +accountAccessEnabled( + personId: PersonId, + lock: "None" | "ForShare" +): (sql: DatabaseOperations) => Effect.Effect +accountAccessEnabled( + sql: DatabaseOperations, + personId: PersonId, + lock: "None" | "ForShare" +): Effect.Effect ``` -- Inputs: none -- Output: `{ (personId: PersonId, lock: "None" | "ForShare"): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId, lock: "None" | "ForShare"): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `personId: PersonId` + - `lock: "None" | "ForShare"` +- Output: `Effect.Effect` +- 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:35](../../packages/database/src/identity-access.ts#L35) @@ -43,11 +54,25 @@ inside the transaction that writes. SET list for the aggregate's claim UPDATE; `targetAlias` names the updated outbox row. ```ts -const outboxClaimAssignments: { (targetAlias: string, claimId: string, claimedAt: string): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, targetAlias: string, claimId: string, claimedAt: string): Statement.Fragment } +outboxClaimAssignments( + targetAlias: string, + claimId: string, + claimedAt: string +): (sql: DatabaseOperations) => Statement.Fragment +outboxClaimAssignments( + sql: DatabaseOperations, + targetAlias: string, + claimId: string, + claimedAt: string +): Statement.Fragment ``` -- Inputs: none -- Output: `{ (targetAlias: string, claimId: string, claimedAt: string): (sql: DatabaseOperations) => Statement.Fragment; (sql: DatabaseOperations, targetAlias: string, claimId: string, claimedAt: string): Statement.Fragment }` +- Inputs: + - `sql: DatabaseOperations` + - `targetAlias: string` + - `claimId: string` + - `claimedAt: string` +- Output: `Statement.Fragment` - Errors: none - Requirements: none - Side effects: none: it builds a fragment, and the aggregate's UPDATE writes the row. @@ -77,12 +102,26 @@ list in every claim UPDATE. Settles the claimed row as Delivered, with delivery evidence when the table records it. ```ts -const markOutboxDelivered: { (table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): Effect.Effect } +markOutboxDelivered( + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence +): (sql: DatabaseOperations) => Effect.Effect +markOutboxDelivered( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + evidence?: OutboxDeliveryEvidence +): Effect.Effect ``` -- Inputs: none -- Output: `{ (table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, evidence?: OutboxDeliveryEvidence): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `table: OutboxTable` + - `claim: OutboxClaim` + - `evidence?: OutboxDeliveryEvidence` +- Output: `Effect.Effect` +- Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. - Source: [packages/database/src/outbox-lifecycle.ts:188](../../packages/database/src/outbox-lifecycle.ts#L188) @@ -112,12 +151,26 @@ 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 -const markOutboxFailed: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } +markOutboxFailed( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect +markOutboxFailed( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): Effect.Effect ``` -- Inputs: none -- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `table: OutboxTable` + - `claim: OutboxClaim` + - `failureTag: string` +- Output: `Effect.Effect` +- Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. - Source: [packages/database/src/outbox-lifecycle.ts:247](../../packages/database/src/outbox-lifecycle.ts#L247) @@ -144,12 +197,26 @@ later claim takes it again. Quarantine it with `quarantineOutboxClaim`. Settles the claimed row as Quarantined, a terminal status, with its failure tag. ```ts -const quarantineOutboxClaim: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } +quarantineOutboxClaim( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect +quarantineOutboxClaim( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): Effect.Effect ``` -- Inputs: none -- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `table: OutboxTable` + - `claim: OutboxClaim` + - `failureTag: string` +- Output: `Effect.Effect` +- Errors: `OutboxClaimLost | SqlError` - Requirements: none - Side effects: Writes the outbox row that the claim still owns. - Source: [packages/database/src/outbox-lifecycle.ts:291](../../packages/database/src/outbox-lifecycle.ts#L291) @@ -177,12 +244,26 @@ 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 -const releaseOutboxClaim: { (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect } +releaseOutboxClaim( + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): (sql: DatabaseOperations) => Effect.Effect +releaseOutboxClaim( + sql: DatabaseOperations, + table: OutboxTable, + claim: OutboxClaim, + failureTag: string +): Effect.Effect ``` -- Inputs: none -- Output: `{ (table: OutboxTable, claim: OutboxClaim, failureTag: string): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claim: OutboxClaim, failureTag: string): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `table: OutboxTable` + - `claim: OutboxClaim` + - `failureTag: string` +- Output: `Effect.Effect` +- Errors: `SqlError` - Requirements: none - Side effects: Writes the outbox row when the claim still owns it. - Source: [packages/database/src/outbox-lifecycle.ts:343](../../packages/database/src/outbox-lifecycle.ts#L343) @@ -210,12 +291,26 @@ Settle an answered delivery with `markOutboxDelivered` or `markOutboxFailed`. Recovers every Processing row claimed before `claimedBefore`. ```ts -const recoverStaleOutboxClaims: { (table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): Effect.Effect } +recoverStaleOutboxClaims( + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery +): (sql: DatabaseOperations) => Effect.Effect +recoverStaleOutboxClaims( + sql: DatabaseOperations, + table: OutboxTable, + claimedBefore: string, + recovery: OutboxStaleRecovery +): Effect.Effect ``` -- Inputs: none -- Output: `{ (table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, table: OutboxTable, claimedBefore: string, recovery: OutboxStaleRecovery): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `table: OutboxTable` + - `claimedBefore: string` + - `recovery: OutboxStaleRecovery` +- Output: `Effect.Effect` +- Errors: `SqlError` - Requirements: none - Side effects: Writes every stale Processing row of the table. - Source: [packages/database/src/outbox-lifecycle.ts:393](../../packages/database/src/outbox-lifecycle.ts#L393) diff --git a/docs/constructs/sql-lock.md b/docs/constructs/sql-lock.md index f253320a..9b9bc813 100644 --- a/docs/constructs/sql-lock.md +++ b/docs/constructs/sql-lock.md @@ -45,12 +45,23 @@ 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 -const lockAdvisory: { (key: AdvisoryLockKey, mode?: AdvisoryLockMode): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, key: AdvisoryLockKey, mode?: AdvisoryLockMode): Effect.Effect } +lockAdvisory( + key: AdvisoryLockKey, + mode?: AdvisoryLockMode +): (sql: DatabaseOperations) => Effect.Effect +lockAdvisory( + sql: DatabaseOperations, + key: AdvisoryLockKey, + mode?: AdvisoryLockMode +): Effect.Effect ``` -- Inputs: none -- Output: `{ (key: AdvisoryLockKey, mode?: AdvisoryLockMode): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, key: AdvisoryLockKey, mode?: AdvisoryLockMode): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `key: AdvisoryLockKey` + - `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:204](../../packages/database/src/advisory-lock.ts#L204) @@ -81,12 +92,20 @@ guards nothing, and hand-written `pg_advisory_xact_lock` SQL, which Serializes one person's protected command with person-keyed authority writers. ```ts -const lockPersonAuthorization: { (personId: PersonId): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId): Effect.Effect } +lockPersonAuthorization( + personId: PersonId +): (sql: DatabaseOperations) => Effect.Effect +lockPersonAuthorization( + sql: DatabaseOperations, + personId: PersonId +): Effect.Effect ``` -- Inputs: none -- Output: `{ (personId: PersonId): (sql: DatabaseOperations) => Effect.Effect; (sql: DatabaseOperations, personId: PersonId): Effect.Effect }` -- Errors: none +- Inputs: + - `sql: DatabaseOperations` + - `personId: PersonId` +- Output: `Effect.Effect` +- 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:67](../../packages/database/src/organization/authority-postgres.ts#L67) diff --git a/docs/constructs/worker.md b/docs/constructs/worker.md index 9c097b89..d74f8157 100644 --- a/docs/constructs/worker.md +++ b/docs/constructs/worker.md @@ -9,13 +9,21 @@ Runs background workers on the Effect clock. The [index](../constructs.md) lists Runs `tick` at once, then again after each success. ```ts -const pollForever: { (options: PollOptions): (tick: Effect.Effect) => Effect.Effect; (tick: Effect.Effect, options: PollOptions): Effect.Effect } +pollForever( + options: PollOptions +): (tick: Effect.Effect) => Effect.Effect +pollForever( + tick: Effect.Effect, + options: PollOptions +): Effect.Effect ``` -- Inputs: none -- Output: `{ (options: PollOptions): (tick: Effect.Effect) => Effect.Effect; (tick: Effect.Effect, options: PollOptions): Effect.Effect }` -- Errors: none -- Requirements: none +- Inputs: + - `tick: Effect.Effect` + - `options: PollOptions` +- Output: `Effect.Effect` +- 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:45](../../apps/backend/src/worker-support.ts#L45) diff --git a/tools/conventions/src/contracts.ts b/tools/conventions/src/contracts.ts index 6a2e9636..602b9a5e 100644 --- a/tools/conventions/src/contracts.ts +++ b/tools/conventions/src/contracts.ts @@ -7,6 +7,8 @@ * 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`. @@ -41,7 +43,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 +288,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 +334,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 +489,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), })), ); diff --git a/tools/conventions/tests/constructs.test.ts b/tools/conventions/tests/constructs.test.ts index 4e19ac98..6a6c53a1 100644 --- a/tools/conventions/tests/constructs.test.ts +++ b/tools/conventions/tests/constructs.test.ts @@ -159,6 +159,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) => From 9e10d7eda34086a0685db75c506399c911436e76 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 11:59:25 +0000 Subject: [PATCH 113/148] fix(e2e): audit the settled owner page in the golden reimbursement journey The owner accessibility audit failed once with color-contrast right after submission and passed on rerun. The submit button carries transition-all and disabled:opacity-50, so when it leaves the disabled state it is enabled and half transparent for its 150 ms transition, which axe reads as low contrast. The audit now waits for every running animation to finish, and a failure names the violating nodes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/e2e/golden-reimbursement-browser.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index bb68a5a9..4cf81f00 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -138,6 +138,11 @@ export const runReimbursementBrowser = async ({ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${surface} page overflow`, ); + // Axe reads computed colours, so a colour transition still running (such as the focus and + // state styles of the button that just submitted) reads as its midpoint: audit the settled page. + await page.evaluate(() => + Promise.all(document.getAnimations().map((animation) => animation.finished)), + ); const audit = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); const serious = audit.violations.filter( @@ -147,7 +152,12 @@ export const runReimbursementBrowser = async ({ assert.deepEqual( serious.map(({ id }) => id), [], - `${surface} accessibility`, + `${surface} accessibility: ${JSON.stringify( + serious.map(({ id, nodes }) => ({ + id, + nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })), + })), + )}`, ); checks.push({ kind: "surface", surface, layout, width, seriousViolations: 0 }); } From 7fc1201d9e5787981cec8839c9ff428bd25d5bb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:06:49 +0000 Subject: [PATCH 114/148] build(tools): give every tool package Effect and the Bun platform, and state the full-rules scope The operator decided that tools run the full Effect rule set too. The architecture document said tools are not Effect programs; it now says every Effect rule is an error outside the two React apps, and that a journey driver or tool is an Effect program whose composition root provides the Bun platform. tools/scripts, tools/source-safety, tools/postgres, tools/placements-docs, and tools/conventions gain the catalog's effect and @effect/platform-bun, so the rewrite needs no lockfile change per branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- bun.lock | 11 +++++++++++ docs/architecture.md | 5 +++-- tools/conventions/package.json | 1 + tools/placements-docs/package.json | 1 + tools/postgres/package.json | 1 + tools/scripts/package.json | 4 ++++ tools/source-safety/package.json | 4 ++++ 7 files changed, 25 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index ca27a5ca..95107563 100644 --- a/bun.lock +++ b/bun.lock @@ -240,6 +240,7 @@ "name": "@monoweb/conventions", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", "rolldown": "1.2.6", }, @@ -280,6 +281,7 @@ "name": "@monoweb/placements-docs", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", }, "devDependencies": { @@ -292,6 +294,7 @@ "name": "@monoweb/postgres", "version": "0.1.0", "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4", }, "devDependencies": { @@ -303,10 +306,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", diff --git a/docs/architecture.md b/docs/architecture.md index 8bcb856d..767056f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,10 +80,11 @@ Required rules: - 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/rpc`, 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. diff --git a/tools/conventions/package.json b/tools/conventions/package.json index 87dff0cc..8469c45a 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/placements-docs/package.json b/tools/placements-docs/package.json index 88440d98..4022e7c5 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/postgres/package.json b/tools/postgres/package.json index ed973c4b..3876367a 100644 --- a/tools/postgres/package.json +++ b/tools/postgres/package.json @@ -11,6 +11,7 @@ "test": "vitest run" }, "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", "effect": "catalog:effect-v4" }, "devDependencies": { diff --git a/tools/scripts/package.json b/tools/scripts/package.json index e465e803..0b34c3a1 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/source-safety/package.json b/tools/source-safety/package.json index 83921ccd..6ad09c5e 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" } } From 9e2dc34fc68f96dc1eeeda433532d23f568b67f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:19:04 +0000 Subject: [PATCH 115/148] refactor(conventions): give the checks pipeable signatures and repository-relative path arithmetic The pure checks no longer import node:path: repositoryPath does POSIX arithmetic on repository-relative paths without reading the working directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/conventions/src/check.ts | 21 +- tools/conventions/src/constructs.ts | 180 ++++++------ tools/conventions/src/contracts.ts | 15 +- tools/conventions/src/exceptions.ts | 8 +- tools/conventions/src/guides.ts | 83 +++--- tools/conventions/src/journeys.ts | 338 ++++++++++++----------- tools/conventions/src/modules.ts | 37 ++- tools/conventions/src/repository-path.ts | 76 +++++ tools/conventions/src/repository.ts | 8 +- tools/conventions/src/sections.ts | 156 ++++++----- 10 files changed, 542 insertions(+), 380 deletions(-) create mode 100644 tools/conventions/src/repository-path.ts diff --git a/tools/conventions/src/check.ts b/tools/conventions/src/check.ts index ebe1d6a4..635ffd15 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/constructs.ts b/tools/conventions/src/constructs.ts index 76f0439a..4e663e99 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, @@ -225,25 +226,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 => @@ -259,40 +263,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 @@ -420,7 +437,7 @@ const renderIndex = ( categories: ReadonlyArray, links: ReadonlyMap, ): string => { - const from = posix.dirname(constructPages.index); + const from = repositoryPath.dirname(constructPages.index); return [ "# Shared constructs", @@ -439,10 +456,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}`, ), ]), "", @@ -455,7 +472,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"); @@ -474,40 +491,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 602b9a5e..54666ee8 100644 --- a/tools/conventions/src/contracts.ts +++ b/tools/conventions/src/contracts.ts @@ -13,6 +13,7 @@ * 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. @@ -574,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; @@ -613,7 +617,7 @@ export const readDeclarations = (path: string, text: string): ModuleDeclarations : [], ), }; -}; +}); // --------------------------------------------------------------------------------------------- // Documentation @@ -683,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 a059ce66..95a0fd52 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 eb3bb362..c8b55a6e 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 7a3abf36..8d497688 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/modules.ts b/tools/conventions/src/modules.ts index 5b36abde..134d6367 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,9 @@ 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 00000000..a183335e --- /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 5b105a49..c34fe813 100644 --- a/tools/conventions/src/repository.ts +++ b/tools/conventions/src/repository.ts @@ -3,6 +3,7 @@ * 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. */ +import { dual } from "effect/Function"; import { spawnSync } from "node:child_process"; import { lstatSync, readFileSync, readlinkSync, type Stats } from "node:fs"; import { join } from "node:path"; @@ -36,7 +37,10 @@ export const repositoryRoot = (directory: string): string => git(directory, ["rev-parse", "--show-toplevel"]).trim(); /** The staged view with `staged`, the working tree view otherwise. */ -export const readRepository = (root: string, staged: boolean): Repository => { +export const readRepository: { + (staged: boolean): (root: string) => Repository; + (root: string, staged: boolean): Repository; +} = dual(2, (root: string, staged: boolean): Repository => { const listed = git(root, [ "ls-files", "-z", @@ -73,4 +77,4 @@ export const readRepository = (root: string, staged: boolean): Repository => { read: (path) => readFileSync(join(root, path), "utf8"), readLink: (path) => readlinkSync(join(root, path)), }; -}; +}); diff --git a/tools/conventions/src/sections.ts b/tools/conventions/src/sections.ts index a89f60b2..3fea1813 100644 --- a/tools/conventions/src/sections.ts +++ b/tools/conventions/src/sections.ts @@ -5,6 +5,7 @@ * 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 { dual } from "effect/Function"; import { constructPages } from "./constructs.js"; import { exclusionOf, @@ -50,10 +51,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 +67,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 +214,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) => typeof args[0] === "string", + (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 +275,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, + ), + }; + }); + }, +); From fe909142f44403b457723093a64e76df3edbc9df Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:21:39 +0000 Subject: [PATCH 116/148] refactor(e2e): make the golden harness and its self-test lint clean under the Effect rules Readiness probes go through HttpClient, the environment through Config, paths through Path, and artifacts through the Schema JSON encoder; exported helpers gain pipeable forms. The loopback provider keeps node:http under EX-0010. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/effect-exceptions.json | 24 ++ tools/e2e/golden-harness-self-test.ts | 79 +++-- tools/e2e/golden-harness.ts | 432 +++++++++++++++----------- 3 files changed, 332 insertions(+), 203 deletions(-) diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index e48804a0..d62532c8 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -195,6 +195,30 @@ "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." + }, + { + "id": "EX-0010", + "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." } ] } diff --git a/tools/e2e/golden-harness-self-test.ts b/tools/e2e/golden-harness-self-test.ts index 03562a3b..93eb1ca9 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 ab3279e2..ffef8898 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-0010: 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,49 +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))); - // Bun's `destroy()` emits `close` but no `error`, so an attempt that timed out resumes - // here; without it one slow attempt outlives the deadline. A later resume is ignored. - request.once("close", () => 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; @@ -523,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; @@ -569,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"]; @@ -605,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, @@ -627,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; @@ -646,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))); @@ -680,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, }), @@ -706,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"; }), ); @@ -722,7 +786,7 @@ const startPostgres = return Deferred.succeed( ledger.supervision, - new HarnessFailure({ + HarnessFailure.make({ stage: "postgres", message: ledger.redactor.apply(tail(cause.message, failureLogCharacters)), }), @@ -1028,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}`, }); @@ -1106,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}`, }); @@ -1125,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( @@ -1144,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)), @@ -1155,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`, }), @@ -1172,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), }); }), ); @@ -1248,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)); @@ -1268,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 [ { @@ -1323,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`}` + @@ -1352,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)), @@ -1368,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, + ); + }), +); From 940b2e4fa44b5c4fc06cb6af8da14b54dd36cbfb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:23:44 +0000 Subject: [PATCH 117/148] refactor(acceptance): run the substitutes probe as an Effect program on the Bun platform tools/acceptance gains a manifest with effect and @effect/platform-bun, so its probes can provide BunServices at their composition roots. The new acceptance-process module holds the Effect forms that the probes share: commands to completion, owned process groups, withheld variables, the readiness probe, and JSON text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- bun.lock | 10 + oxlint.config.ts | 2 + tools/acceptance/acceptance-process.ts | 197 +++ tools/acceptance/package.json | 10 + tools/acceptance/substitute-outcome-check.ts | 1268 ++++++++++-------- 5 files changed, 910 insertions(+), 577 deletions(-) create mode 100644 tools/acceptance/acceptance-process.ts create mode 100644 tools/acceptance/package.json diff --git a/bun.lock b/bun.lock index 95107563..8c1fdd51 100644 --- a/bun.lock +++ b/bun.lock @@ -236,6 +236,14 @@ "vitest": "catalog:effect-v4", }, }, + "tools/acceptance": { + "name": "@monoweb/acceptance", + "version": "0.1.0", + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4", + }, + }, "tools/conventions": { "name": "@monoweb/conventions", "version": "0.1.0", @@ -753,6 +761,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"], diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..85c48461 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -168,7 +168,9 @@ const effectConfig = { files: [ "apps/backend/src/main.ts", "apps/backend/src/**/*-main.ts", + "tools/acceptance/onboarding-check.ts", "tools/acceptance/password-recovery-check.ts", + "tools/acceptance/substitute-outcome-check.ts", "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", "tools/e2e/legacy-candidate-native-journey.ts", diff --git a/tools/acceptance/acceptance-process.ts b/tools/acceptance/acceptance-process.ts new file mode 100644 index 00000000..6779d12e --- /dev/null +++ b/tools/acceptance/acceptance-process.ts @@ -0,0 +1,197 @@ +/** 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 JsonText = Schema.fromJsonString(Schema.Unknown); + +const IndentedJsonText = Schema.fromJsonString(Schema.Unknown, { space: 2 }); + +/** The JSON text of a value, byte for byte what `JSON.stringify` writes. */ +export const jsonText = (value: A): Effect.Effect => + Schema.encodeEffect(JsonText)(value).pipe(Effect.orDie); + +/** The JSON text of a value indented by two spaces, as `JSON.stringify(value, null, 2)` writes it. */ +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/package.json b/tools/acceptance/package.json new file mode 100644 index 00000000..2c282bad --- /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/substitute-outcome-check.ts b/tools/acceptance/substitute-outcome-check.ts index 439fa0e3..613c6eec 100644 --- a/tools/acceptance/substitute-outcome-check.ts +++ b/tools/acceptance/substitute-outcome-check.ts @@ -4,14 +4,29 @@ * 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 process from "node:process"; +import * as BunRuntime from "@effect/platform-bun/BunRuntime"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { + 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, @@ -22,14 +37,16 @@ import { IdempotencyKey, type StrongETag } from "../../packages/rpc/src/problem. 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 { - type DisposablePostgres, - reserveLoopbackPorts, - startDisposablePostgres, -} from "../postgres/index.ts"; -import { stopOwnedProcess } from "./owned-process.js"; - -const root = new URL("../../", import.meta.url).pathname; + answersOk, + commandOutput, + indentedJsonText, + jsonText, + 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", { @@ -48,647 +65,744 @@ const RejectedBeforeHandler = Schema.Tuple([ }), ]); +/** 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), ); 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), + + yield* startOwnedProcess({ + command: "bun", + args: ["run", "--cwd", "apps/backend", "start"], + cwd: root, + env: environment, + output: (text) => outputs.push(text), }); - assert.equal(response.status, 200, `login: ${await response.text()}`); - const cookie = response.headers.get("set-cookie"); - assert.ok(cookie); + 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 cookie.split(";")[0]!; - }; + 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 leader = await login(persons.leader), - member = await login(persons.member), - other = await login(persons.otherDepartment); + /** 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], + ), + (result) => result.rows, + ); - const native = nativeScriptClient(backendOrigin); + const gates: string[] = []; - /** 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 listScopes = (cookie?: string) => + call(as(cookie), (client) => client["admissionOutcomes.listScopes"]()); - const newKey = () => IdempotencyKey.make(randomBytes(18).toString("base64url")); + const scopeDepartments = (cookie: string) => + Effect.map(listScopes(cookie), (answer) => + expectValue(answer, "scopes").departments.map((item) => item.departmentId), + ); - const expectValue = (answer: ScriptCallResult, gate: string): A => { - if (!answer.ok) assert.fail(`${gate}: ${answer.code}`); + const scopeOf = (semesterId: string) => + Schema.decodeSync(AdmissionOutcomeScope)({ departmentId, semesterId }); - return answer.value; - }; + const readBoard = (semesterId: string, cookie?: string) => + call(as(cookie), (client) => client["admissionOutcomes.readOutcomes"](scopeOf(semesterId))); - const expectProblem = (answer: ScriptCallResult, status: number, code: string, gate: string) => { - assert.equal(answer.ok, false, gate); + const board = (semesterId: string, cookie = leader) => + Effect.map(readBoard(semesterId, cookie), (answer) => + expectValue(answer, `board ${semesterId}`), + ); - if (answer.ok) return; - assert.equal(answer.status, status, `${gate}: ${answer.code}`); - assert.equal(answer.code, code, gate); - }; + 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"); - 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 submission = yield* Schema.decodeEffect(SubmitApplicationRequest)({ + 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 openScopeOutcomes = async () => - ( - await database.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; + const { applicationId } = expectValue( + yield* call(as(), (client) => + client["admissions.submitApplication"]({ idempotencyKey: newKey(), request: submission }), + ), + "submission", + ); - const gates: string[] = []; + 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], + ); - const listScopes = (cookie?: string) => - native.call(as(cookie), (client) => client["admissionOutcomes.listScopes"]()); + const openBoard = yield* board(openSemesterId); - const scopeDepartments = async (cookie: string) => - expectValue(await listScopes(cookie), "scopes").departments.map((item) => item.departmentId); + if (!Predicate.isTagged(openBoard, "Decide")) + assert.fail("admission management decides the open board"); + assert.equal(openBoard.admissionPeriodId, openPeriodId); - const scopeOf = (semesterId: string) => - Schema.decodeSync(AdmissionOutcomeScope)({ departmentId, semesterId }); + 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); - const readBoard = (semesterId: string, cookie?: string) => - native.call(as(cookie), (client) => - client["admissionOutcomes.readOutcomes"](scopeOf(semesterId)), + 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", ); - const board = async (semesterId: string, cookie = leader) => - expectValue(await readBoard(semesterId, cookie), `board ${semesterId}`); + const initial = openBoard.entries.find((entry) => entry.applicationId === applicationId); - const leaderScopes = expectValue(await listScopes(leader), "leader scopes"); - assert.deepEqual(await scopeDepartments(leader), [departmentId]); - assert.ok( - [historicalSemesterId, openSemesterId, noPeriodSemesterId].every((semesterId) => - leaderScopes.semesters.some((item) => item.semesterId === semesterId), - ), - ); - assert.deepEqual(await scopeDepartments(member), [departmentId]); - assert.deepEqual(await scopeDepartments(other), [otherDepartmentId]); - 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 = Schema.decodeSync(SubmitApplicationRequest)({ - 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 { applicationId } = expectValue( - await native.call(as(), (client) => - client["admissions.submitApplication"]({ idempotencyKey: newKey(), request: submission }), - ), - "submission", - ); + 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, + }, + ); - 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 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, + 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); + } + + assert.deepEqual(yield* outcomeHistory(applicationId), [], "rejected commands record nothing"); + gates.push("invalid outcomes are rejected before the handler and record nothing"); + + const key = newKey(); + + const substitute = expectValue( + yield* record(leader, { outcome: "Substitute" }, item.etag, key), + "record", + ); - const openBoard = await board(openSemesterId); + 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); - if (!Predicate.isTagged(openBoard, "Decide")) - assert.fail("admission management decides the open board"); - assert.equal(openBoard.admissionPeriodId, openPeriodId); + 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", + ); - 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); + expectProblem( + yield* record(leader, { outcome: "Rejected" }, item.etag), + 412, + "precondition.failed", + "stale version", + ); - 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", - ); + const staleKey = newKey(); - const initial = openBoard.entries.find((entry) => entry.applicationId === applicationId); - - 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, - }, - ); + for (const attempt of ["initial stale record", "unchanged rejected retry"]) + expectProblem( + yield* record(leader, { outcome: "Rejected" }, item.etag, staleKey), + 412, + "precondition.failed", + attempt, + ); - const readItem = (cookie?: string) => - native.call(as(cookie), (client) => client["admissionOutcomes.readOutcome"]({ applicationId })); - - const record = ( - cookie: string | undefined, - request: AdmissionOutcomeCommand, - ifMatch: StrongETag, - idempotencyKey = newKey(), - ) => - native.call(as(cookie), (client) => - client["admissionOutcomes.recordOutcome"]({ applicationId, idempotencyKey, ifMatch, request }), - ); - - const item = expectValue(await 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 = await fetch(`${backendOrigin}${nativeRpcPath}`, { - method: "POST", - headers: { "content-type": "application/json", cookie: leader, origin: dashboardOrigin }, - body: JSON.stringify( - WireRequest.make({ - id: "1", - tag: "admissionOutcomes.recordOutcome", - payload: { applicationId, idempotencyKey: newKey(), ifMatch: item.etag, request: invalid }, - headers: [], - }), - ), - }); + assert.deepEqual(yield* outcomeHistory(applicationId), substituteHistory); + gates.push("a stale version is refused (412), also on an unchanged retry, and records nothing"); - assert.equal(response.status, 200, `invalid ${JSON.stringify(invalid)}`); - Schema.decodeUnknownSync(RejectedBeforeHandler)(await response.json()); - } - - assert.deepEqual(await outcomeHistory(applicationId), [], "rejected commands record nothing"); - gates.push("invalid outcomes are rejected before the handler and record nothing"); - - const key = newKey(); - const substitute = expectValue(await record(leader, { outcome: "Substitute" }, item.etag, key), "record"); - 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); - - assert.deepEqual( - expectValue(await record(leader, { outcome: "Substitute" }, item.etag, key), "replay"), - substitute, - "an exact replay answers the stored result", - ); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory, "a replay adds no revision"); - expectProblem( - await record(leader, { outcome: "Rejected" }, item.etag, key), - 409, - "idempotency.digest-conflict", - "another command under a used key", - ); - assert.deepEqual( - expectValue(await record(leader, { outcome: "Substitute" }, substitute.etag), "again"), - substitute, - "recording the current outcome again changes nothing", - ); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push( - "Substitute is recorded once with ifMatch and an idempotency key; replay and a repeated outcome add no revision", - ); - - expectProblem( - await record(leader, { outcome: "Rejected" }, item.etag), - 412, - "precondition.failed", - "stale version", - ); + 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", + ); + expectProblem(yield* readItem(member), 403, "authority.denied", "member single read"); + expectProblem( + yield* record(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)"); - const staleKey = newKey(); + 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", + ); - for (const attempt of ["initial stale record", "unchanged rejected retry"]) + 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( - await record(leader, { outcome: "Rejected" }, item.etag, staleKey), - 412, - "precondition.failed", - attempt, + 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"); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push("a stale version is refused (412), also on an unchanged retry, and records 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"); - assert.deepEqual( - await 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, - }, + const concurrent = yield* Effect.all( + [ + record(leader, { outcome: "Rejected" }, substitute.etag), + record(leader, { outcome: "Rejected" }, substitute.etag), ], - }), - "a member reads only who is on call, with name, e-mail and phone", - ); - expectProblem(await readItem(member), 403, "authority.denied", "member single read"); - expectProblem( - await record(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)"); - - expectProblem(await readBoard(openSemesterId, other), 403, "authority.denied", "other department board"); - expectProblem(await readItem(other), 403, "authority.denied", "other department item"); - expectProblem( - await record(other, { outcome: "Rejected" }, substitute.etag), - 403, - "authority.denied", - "other department record", - ); - - expectProblem(await listScopes(), 401, "credential.missing", "anonymous scopes"); - expectProblem(await readBoard(openSemesterId), 401, "credential.missing", "anonymous board"); - expectProblem(await readItem(), 401, "credential.missing", "anonymous item"); - expectProblem( - await record(undefined, { outcome: "Rejected" }, substitute.etag), - 401, - "credential.missing", - "anonymous record", - ); - assert.deepEqual(await outcomeHistory(applicationId), substituteHistory); - gates.push("another department (403) and anonymous callers (401) read and record nothing"); - - 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 board(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'", - ); - expectProblem(await readBoard(openSemesterId, other), 403, "authority.denied", "ended grant"); - gates.push("an active global administrator decides in every department"); - - const concurrent = await Promise.all([ - record(leader, { outcome: "Rejected" }, substitute.etag), - record(leader, { outcome: "Rejected" }, substitute.etag), - ]); - - 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 }, - ]; + { concurrency: "unbounded" }, + ); - assert.deepEqual(await outcomeHistory(applicationId), rejectedHistory); - const memberAfterReject = await board(openSemesterId, member); + 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(",")}`, + ); - assert.deepEqual( - Predicate.isTagged(memberAfterReject, "ReadOnly") ? memberAfterReject.substitutes : null, - [], - "a rejected applicant is no longer on call", - ); + const rejectedHistory = [ + ...substituteHistory, + { revision: 2, outcome: "Rejected", decidedBy: leaderPersonId }, + ]; - const leaderAfterReject = await board(openSemesterId); + assert.deepEqual(yield* outcomeHistory(applicationId), rejectedHistory); + const memberAfterReject = yield* board(openSemesterId, member); - 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"); + assert.deepEqual( + Predicate.isTagged(memberAfterReject, "ReadOnly") ? memberAfterReject.substitutes : null, + [], + "a rejected applicant is no longer on call", + ); - await database.query( - `UPDATE public.organization_memberships SET is_suspended=true WHERE person_id='${leaderPersonId}'`, - ); - expectProblem( - await record(leader, { outcome: "Substitute" }, item.etag, key), - 403, - "authority.denied", - "revoked authority cannot replay a stored result", - ); - expectProblem( - await readBoard(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"); - - assert.deepEqual( - await board(noPeriodSemesterId), - AdmissionOutcomeBoardResource.cases.Decide.make({ - ...scopeOf(noPeriodSemesterId), - admissionPeriodId: null, - entries: [], - }), - ); - assert.deepEqual( - await board(noPeriodSemesterId, member), - AdmissionOutcomeBoardResource.cases.ReadOnly.make({ - ...scopeOf(noPeriodSemesterId), - admissionPeriodId: null, - substitutes: [], - }), - ); - expectProblem( - await 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 leaderAfterReject = yield* board(openSemesterId); - 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); + 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", + ); - 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"); - await native.dispose(); - - 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, + yield* query( + `UPDATE public.organization_memberships SET is_suspended=true WHERE person_id='${leaderPersonId}'`, ); - browserEvidence = Schema.decodeSync(Schema.fromJsonString(Schema.JsonObject))( - await readFile(join(artifacts, "browser-evidence.json"), "utf8"), + expectProblem( + yield* record(leader, { outcome: "Substitute" }, item.etag, key), + 403, + "authority.denied", + "revoked authority cannot replay a stored result", ); - assert.equal(browserEvidence.passed, true); - assert.equal(browserEvidence.revision, revision); - assert.deepEqual( - await outcomeHistory(browserApplicationId), - browserEvidence.finalHistory, - "browser outcomes independently observed in PostgreSQL", + 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 outcomeHistory(undecidedBrowserApplicationId), - [], - "an application nobody decided keeps no outcome", + yield* board(noPeriodSemesterId), + AdmissionOutcomeBoardResource.cases.Decide.make({ + ...scopeOf(noPeriodSemesterId), + admissionPeriodId: null, + entries: [], + }), ); assert.deepEqual( - await openScopeOutcomes(), - openScopeBefore, - "browser commands do not change another semester", + 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))), +); From b20b8d20933c72138ff793d410344ded59165f7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:25:42 +0000 Subject: [PATCH 118/148] refactor(e2e): make the recording drivers, outbox driver, and receipt delivery sink Effect programs The outbox and recording drivers read their settings through Config and run at their composition root with BunRuntime.runMain; the recording drivers scope their network trap. The receipt delivery sink is a layer on the portable HttpServer, and its Promise bridge builds it on a loopback Bun server. The team-application evidence helpers gain pipeable forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 1 + tools/e2e/golden-team-application-evidence.ts | 111 +++++---- tools/e2e/public-application-outbox-driver.ts | 72 ++++-- tools/e2e/receipt-delivery-sink.ts | 231 +++++++++++------- ...-native-recruitment-invitation-response.ts | 141 +++++++---- .../record-native-recruitment-invitation.ts | 146 ++++++----- 6 files changed, 432 insertions(+), 270 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..6bf27995 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -174,6 +174,7 @@ const effectConfig = { "tools/e2e/legacy-candidate-native-journey.ts", "tools/e2e/legacy-organization-rehearsal-runtime.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", diff --git a/tools/e2e/golden-team-application-evidence.ts b/tools/e2e/golden-team-application-evidence.ts index 48340251..6da34e0b 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/public-application-outbox-driver.ts b/tools/e2e/public-application-outbox-driver.ts index 8e08e956..005e8aeb 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 1e5a189d..02f5260d 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 6e2282b2..f79e6869 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 5096305e..d75732b0 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))); From 6e9e3dfa6c1663948b99cf295d81a36e3564ad8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:29:36 +0000 Subject: [PATCH 119/148] refactor(domain): give the Schema decoders one JSON parameter and drop unused ones Decoders with parse options kept a second parameter that missing-pipeable-signature reads as a data-last subject. The applicant progress and service grant decoders take Schema.Json, the identity decoders move to their one consumer, the unused admission period and confirmation decoders go, and the organization entity ids derive per kind without the overloaded helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- packages/database/src/auth-live.ts | 10 ++++-- .../domain/src/admission-period/schema.ts | 21 ------------ packages/domain/src/application/schema.ts | 16 ++------- .../src/authz/service-principal-grants.ts | 11 +++--- packages/domain/src/identity.test.ts | 9 ++--- packages/domain/src/identity/index.ts | 2 -- packages/domain/src/identity/schema.ts | 5 --- .../domain/src/organization/administration.ts | 34 ++----------------- 8 files changed, 22 insertions(+), 86 deletions(-) diff --git a/packages/database/src/auth-live.ts b/packages/database/src/auth-live.ts index 3065c63f..826cc8a2 100644 --- a/packages/database/src/auth-live.ts +++ b/packages/database/src/auth-live.ts @@ -12,8 +12,6 @@ import type { AuthorizationInstant } from "@vektorprogrammet/domain/authz"; import { ServicePrincipalGrantAuthority } from "@vektorprogrammet/domain/authz"; import { Database } from "./service.js"; import { - decodeIdentityActor, - decodeIdentitySession, Identity, IdentityEngineError, IdentityInvalidCredentials, @@ -22,7 +20,8 @@ import { IdentitySecurityEvent, IdentitySecurityEventDetails, IdentitySessionNotFound, - type IdentityActor, + IdentityActor, + IdentitySession, type IdentityRequestContext, type IdentitySessionId, type IdentitySessionMutationSuccess, @@ -31,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, diff --git a/packages/domain/src/admission-period/schema.ts b/packages/domain/src/admission-period/schema.ts index ba0ef374..458c9e75 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/application/schema.ts b/packages/domain/src/application/schema.ts index 449193e7..83a7223e 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/authz/service-principal-grants.ts b/packages/domain/src/authz/service-principal-grants.ts index f192788c..85733ff9 100644 --- a/packages/domain/src/authz/service-principal-grants.ts +++ b/packages/domain/src/authz/service-principal-grants.ts @@ -185,12 +185,13 @@ export class ServicePrincipalGrantAuthority extends Context.Service< ServicePrincipalGrantAuthorityOperations >()("@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: { ( diff --git a/packages/domain/src/identity.test.ts b/packages/domain/src/identity.test.ts index d9bbb563..3ec634d2 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/index.ts b/packages/domain/src/identity/index.ts index 5313779d..37ef515e 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 323e618c..fb9e35d9 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/organization/administration.ts b/packages/domain/src/organization/administration.ts index a3411ab5..2f0b6ae1 100644 --- a/packages/domain/src/organization/administration.ts +++ b/packages/domain/src/organization/administration.ts @@ -64,39 +64,11 @@ export const organizationEntityDigest: { 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 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); + FieldOfStudyId.make(`field-of-study-${organizationEntityDigest("FieldOfStudy", commandId)}`); From 8ae141a0ce475069fc954f615b32882a16738a55 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:29:36 +0000 Subject: [PATCH 120/148] refactor(database): take one named input in the receipt persistence functions The outbox, projection, approval, settlement, and command functions take one named input object instead of several positional strings; the command functions still decode their encoded input inside the transaction. The authorization proof extracts its four race scenarios into named functions, and the migration proof filters the SQL failure with Option.filter, keeping the narrowing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/drain-main.ts | 8 +- apps/backend/src/receipt/worker.ts | 7 +- ...authorization-rules-postgres-proof-main.ts | 2381 +++++++++-------- packages/database/src/database.test.ts | 44 +- packages/database/src/receipt/file-proof.ts | 126 +- packages/database/src/receipt/outbox.ts | 47 +- .../database/src/receipt/postgres-layer.ts | 14 +- .../database/src/receipt/postgres-proof.ts | 104 +- packages/database/src/receipt/postgres.ts | 64 +- packages/database/src/receipt/projections.ts | 35 +- packages/database/src/receipt/settlement.ts | 21 +- ...reconciliation-migration-postgres-proof.ts | 7 +- ...ule-reconciliation-postgres-tracer-main.ts | 8 +- .../verification/receipt-import-rehearsal.ts | 2 +- 14 files changed, 1485 insertions(+), 1383 deletions(-) diff --git a/apps/backend/src/receipt/drain-main.ts b/apps/backend/src/receipt/drain-main.ts index b4f80dd8..2cb6729a 100644 --- a/apps/backend/src/receipt/drain-main.ts +++ b/apps/backend/src/receipt/drain-main.ts @@ -65,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/worker.ts b/apps/backend/src/receipt/worker.ts index d5d18a39..e06d6a82 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/packages/database/runtime/authorization-rules-postgres-proof-main.ts b/packages/database/runtime/authorization-rules-postgres-proof-main.ts index e867326a..c725c81b 100644 --- a/packages/database/runtime/authorization-rules-postgres-proof-main.ts +++ b/packages/database/runtime/authorization-rules-postgres-proof-main.ts @@ -2159,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); @@ -2224,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); @@ -2416,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); @@ -2441,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); @@ -2465,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( @@ -2553,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)); + + 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.deepEqual(afterInsert.applicableRuleIds, []); - assert.deepEqual(afterInsert.composedPaymentAuthorityIds, [ + 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); - 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 { + durable, + removed: { + paymentAuthorityId: removed.paymentAuthorityId, + revision: removed.revision, + }, + remainingRows: remaining.count, + }; + }).pipe( + Effect.provide( + makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-cleanup`), + ), + ); - assert(remaining); + assert.deepEqual(cleanup.durable, { + commandReceiptRows: 1, + auditRows: 1, + outboxRows: 3, + outboxCommandRows: 1, + }); + assert.equal(cleanup.remainingRows, 0); - return { - durable, - removed: { - paymentAuthorityId: removed.paymentAuthorityId, - revision: removed.revision, - }, - remainingRows: remaining.count, - }; - }).pipe( - Effect.provide( - makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-economy-cleanup`), - ), - ); + 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.deepEqual(cleanup.durable, { - commandReceiptRows: 1, - auditRows: 1, - outboxRows: 3, - outboxCommandRows: 1, - }); - assert.equal(cleanup.remainingRows, 0); +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 { - 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, + const before = yield* approvalCompositionFacts( + ids.persons.phantomOrganization, + exactEnd, + ids.departments.alpha, + ).pipe( + Effect.provide(makeProofLayer(databaseUrl, `${proofApplicationPrefix}-phantom-org-before`)), + ); + + 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, }, - 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 }; }), @@ -3364,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); @@ -3588,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); @@ -3745,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); @@ -3761,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); @@ -3831,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 }; }), @@ -4327,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); @@ -4400,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); @@ -4591,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/src/database.test.ts b/packages/database/src/database.test.ts index 3ed02cc6..a585e21d 100644 --- a/packages/database/src/database.test.ts +++ b/packages/database/src/database.test.ts @@ -799,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"); @@ -5186,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"), @@ -5202,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", @@ -5407,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); @@ -5424,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, @@ -5437,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, @@ -5502,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/receipt/file-proof.ts b/packages/database/src/receipt/file-proof.ts index e4b3b214..284a83ab 100644 --- a/packages/database/src/receipt/file-proof.ts +++ b/packages/database/src/receipt/file-proof.ts @@ -181,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 = ( @@ -215,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. */ @@ -406,8 +406,8 @@ const runReceiptFileProofImpl = ( ); 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, @@ -416,20 +416,20 @@ const runReceiptFileProofImpl = ( 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"); @@ -453,28 +453,28 @@ const runReceiptFileProofImpl = ( 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( @@ -493,8 +493,14 @@ const runReceiptFileProofImpl = ( 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" }, ); @@ -520,24 +526,24 @@ const runReceiptFileProofImpl = ( 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" }, @@ -579,18 +585,18 @@ const runReceiptFileProofImpl = ( 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" }, @@ -611,26 +617,26 @@ const runReceiptFileProofImpl = ( 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" }, diff --git a/packages/database/src/receipt/outbox.ts b/packages/database/src/receipt/outbox.ts index 5ecc1000..70e0a7dd 100644 --- a/packages/database/src/receipt/outbox.ts +++ b/packages/database/src/receipt/outbox.ts @@ -41,17 +41,30 @@ interface ClaimIdRow { const persistenceError = (operation: string, cause: unknown) => 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" }; /** * 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 @@ -181,10 +194,14 @@ export const failReceiptOutbox: { ), ); -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; @@ -261,11 +278,11 @@ const interpretReceiptOutbox = ( 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 @@ -281,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 d08854c5..e0d2a9fb 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 36794ffd..6fd91aee 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 ac472e40..13cbc3f0 100644 --- a/packages/database/src/receipt/postgres.ts +++ b/packages/database/src/receipt/postgres.ts @@ -613,16 +613,28 @@ export const reconcileReceiptImport: { }), ); +/** 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; @@ -679,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( @@ -1424,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); @@ -1441,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; diff --git a/packages/database/src/receipt/projections.ts b/packages/database/src/receipt/projections.ts index e91abe09..9115c870 100644 --- a/packages/database/src/receipt/projections.ts +++ b/packages/database/src/receipt/projections.ts @@ -68,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}`; @@ -196,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 diff --git a/packages/database/src/receipt/settlement.ts b/packages/database/src/receipt/settlement.ts index 2cc93b53..ace4645d 100644 --- a/packages/database/src/receipt/settlement.ts +++ b/packages/database/src/receipt/settlement.ts @@ -581,11 +581,22 @@ export const recordReceiptSettlement: { }), ); -export const listReceiptsForSettlement = ( - personId: PersonId, - authorizationInstant: OrganizationAuthorityInstant, - after?: string, -): Effect.Effect, ReceiptSettlementListFailure, Database> => +/** 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, + authorizationInstant, + after, +}: ReceiptSettlementQueueInput): Effect.Effect< + ReceiptPage, + ReceiptSettlementListFailure, + Database +> => Effect.gen(function* () { const sql = yield* Database; diff --git a/packages/database/src/rule-reconciliation-migration-postgres-proof.ts b/packages/database/src/rule-reconciliation-migration-postgres-proof.ts index dc863254..a2880a54 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 a62a649a..1e8e91e0 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/tools/verification/receipt-import-rehearsal.ts b/tools/verification/receipt-import-rehearsal.ts index bdfc640b..48ca2573 100644 --- a/tools/verification/receipt-import-rehearsal.ts +++ b/tools/verification/receipt-import-rehearsal.ts @@ -474,7 +474,7 @@ try { const observeNoEffects = async () => { const result = await run( - deliverNextReceiptOutbox("0095-guard", new Date().toISOString()).pipe( + deliverNextReceiptOutbox({ claimId: "0095-guard", claimedAt: new Date().toISOString() }).pipe( Effect.provideService(ReceiptAuxiliaryEffects, guard), Effect.provideService(ReceiptFileService, files.service), ), From 83bdc6a2e1fd651e0197ff2380e9bed0106be69c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:29:36 +0000 Subject: [PATCH 121/148] refactor(docs): run the page mirror as an Effect program, and register the remaining exceptions sync-pages.ts reads, writes, and watches through FileSystem and Path under the Bun runtime and joins the Bun composition roots. site.ts and the remark plugin run synchronously inside the Vocs configuration, so they keep node:fs under EX-0011; decodeSnapshot keeps its Schema decoder form under EX-0010. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/backend/src/receipt/import-snapshot.ts | 1 + apps/docs/package.json | 2 + apps/docs/remark-repository-links.ts | 2 + apps/docs/scripts/sync-pages.ts | 115 +++++++++++++------- apps/docs/site.ts | 2 + bun.lock | 2 + docs/effect-exceptions.json | 50 +++++++++ oxlint.config.ts | 1 + 8 files changed, 138 insertions(+), 37 deletions(-) diff --git a/apps/backend/src/receipt/import-snapshot.ts b/apps/backend/src/receipt/import-snapshot.ts index 203f9955..517e6bcf 100644 --- a/apps/backend/src/receipt/import-snapshot.ts +++ b/apps/backend/src/receipt/import-snapshot.ts @@ -73,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", }); diff --git a/apps/docs/package.json b/apps/docs/package.json index 56219e47..a2022bf5 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,7 +11,9 @@ "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 da8ef586..92ef1f23 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"; diff --git a/apps/docs/scripts/sync-pages.ts b/apps/docs/scripts/sync-pages.ts index 16ea83f3..f6d22660 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 4ca19cbd..d699e27d 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"; diff --git a/bun.lock b/bun.lock index 95107563..50f17bef 100644 --- a/bun.lock +++ b/bun.lock @@ -103,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", diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index e48804a0..baed0be9 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -195,6 +195,56 @@ "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." + }, + { + "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": ["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." } ] } diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..3214c297 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -168,6 +168,7 @@ const effectConfig = { files: [ "apps/backend/src/main.ts", "apps/backend/src/**/*-main.ts", + "apps/docs/scripts/sync-pages.ts", "tools/acceptance/password-recovery-check.ts", "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", From f568567cd0279b01463237a7f4d1dffdbaaea754 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:29:55 +0000 Subject: [PATCH 122/148] refactor(scripts): run changelog, land, and the legacy profile check as Effect programs They spawn through ChildProcessSpawner, read files through FileSystem and the profile flag through Config, and exit with the code that the program returns. The three join the Bun composition-root group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 3 + tools/conventions/src/modules.ts | 1 + tools/conventions/src/sections.ts | 3 +- tools/scripts/changelog.ts | 95 +++++---- tools/scripts/command.ts | 32 +++ tools/scripts/exit-code.ts | 14 ++ tools/scripts/land.ts | 202 ++++++++++++------- tools/scripts/require-legacy-data-profile.ts | 21 +- 8 files changed, 243 insertions(+), 128 deletions(-) create mode 100644 tools/scripts/command.ts create mode 100644 tools/scripts/exit-code.ts diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..34f6f3e9 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -177,6 +177,9 @@ const effectConfig = { "tools/e2e/record-native-recruitment-invitation-response.ts", "tools/e2e/record-native-recruitment-invitation.ts", "tools/e2e/run-legacy-*.ts", + "tools/scripts/changelog.ts", + "tools/scripts/land.ts", + "tools/scripts/require-legacy-data-profile.ts", "tools/verification/completion-receipt-postgres-proof-main.ts", "tools/verification/current-assignment-cohort-cli.ts", "tools/verification/current-assignment-cohort-rehearsal.ts", diff --git a/tools/conventions/src/modules.ts b/tools/conventions/src/modules.ts index 134d6367..2d350e29 100644 --- a/tools/conventions/src/modules.ts +++ b/tools/conventions/src/modules.ts @@ -404,6 +404,7 @@ const readResolver = ( return (importer, specifier) => { const relative = specifier.startsWith("./") || specifier.startsWith("../"); + const key = relative ? repositoryPath.join(repositoryPath.dirname(importer), specifier) : specifier; diff --git a/tools/conventions/src/sections.ts b/tools/conventions/src/sections.ts index 3fea1813..1bcea8c6 100644 --- a/tools/conventions/src/sections.ts +++ b/tools/conventions/src/sections.ts @@ -5,6 +5,7 @@ * 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 { @@ -218,7 +219,7 @@ export const spliceSections: { (sections: ReadonlyArray
, comment?: Comment): (text: string) => Spliced; (text: string, sections: ReadonlyArray
, comment?: Comment): Spliced; } = dual( - (args) => typeof args[0] === "string", + (args) => Predicate.isString(args[0]), (text: string, sections: ReadonlyArray
, comment: Comment = markdown): Spliced => { const lines = text.split("\n"); const missing: Array = []; diff --git a/tools/scripts/changelog.ts b/tools/scripts/changelog.ts index 1c549b75..b2945ed5 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 00000000..5a725588 --- /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/exit-code.ts b/tools/scripts/exit-code.ts new file mode 100644 index 00000000..8da8f982 --- /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/land.ts b/tools/scripts/land.ts index eaf3d81e..24a91bdb 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/require-legacy-data-profile.ts b/tools/scripts/require-legacy-data-profile.ts index 773111a3..5ebf7ceb 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); From c2d0493dd481bb661fc8a97392c6325e537afa7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:30:11 +0000 Subject: [PATCH 123/148] refactor(e2e): compare explicitly in the golden school-service drivers and record golden HTTP diagnostics through Effect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/e2e/golden-http-diagnostics.mjs | 40 ++++++++++++-------- tools/e2e/golden-school-service-evidence.mjs | 22 +++++++---- tools/e2e/golden-school-service.mjs | 18 ++++----- tools/e2e/placement-check.ts | 18 ++++----- 4 files changed, 56 insertions(+), 42 deletions(-) diff --git a/tools/e2e/golden-http-diagnostics.mjs b/tools/e2e/golden-http-diagnostics.mjs index d9254d3f..b910e5ff 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-school-service-evidence.mjs b/tools/e2e/golden-school-service-evidence.mjs index de69f91f..d30b6cb3 100644 --- a/tools/e2e/golden-school-service-evidence.mjs +++ b/tools/e2e/golden-school-service-evidence.mjs @@ -48,9 +48,9 @@ export const dashboardBuildInventory = async (root) => { 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"); + assert.ok(info.isSymbolicLink() !== true, "build must not contain symlinks"); - if (info.isDirectory()) { + if (info.isDirectory() === true) { for (const name of (await readdir(path)).sort()) await visit(join(relative, name)); } else { assert.ok(info.isFile(), "build contains a non-file"); @@ -74,7 +74,7 @@ export const dashboardBuildInventory = async (root) => { const safeBytes = (bytes) => { const text = bytes.toString("utf8"); - assert.ok(!text.includes("\u0000"), "binary diagnostic rejected"); + assert.ok(text.includes("\u0000") !== true, "binary diagnostic rejected"); assert.ok( !/journey-secret-0123456789abcdef|synthetic-school-service-token/.test(text), "synthetic secret rejected", @@ -116,16 +116,22 @@ const safeJson = (value) => { // 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"); + assert.ok( + (await lstat(directory)).isSymbolicLink() !== true, + "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.isFile() === true && info.isSymbolicLink() !== true, + "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)); + if (name.endsWith(".json") === true) safeJson(JSON.parse(bytes)); return bytes; }; @@ -185,7 +191,7 @@ export const inspectGoldenEvidence = async ({ directory, root, revision, sourceT ); files.set(item.path, bytes); - if (item.path.endsWith(".json")) documents.set(item.path, JSON.parse(bytes)); + if (item.path.endsWith(".json") === true) documents.set(item.path, JSON.parse(bytes)); } assert.deepEqual( @@ -195,7 +201,7 @@ export const inspectGoldenEvidence = async ({ directory, root, revision, sourceT ); const build = documents.get("browser-build.json"); - if (build) { + if (Predicate.isTruthy(build)) { assert.ok(build.revision === revision && build.sourceTree === sourceTree, "wrong build source"); assert.ok( build.digest === "sha256:" + sha256(JSON.stringify(build.files)), diff --git a/tools/e2e/golden-school-service.mjs b/tools/e2e/golden-school-service.mjs index aa9da068..aa707fa7 100644 --- a/tools/e2e/golden-school-service.mjs +++ b/tools/e2e/golden-school-service.mjs @@ -94,7 +94,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.person_id === candidateId) !== true); const [scheduled] = baseline.commitments; assert.equal(facts.commitments.length, 2); assert.deepEqual(facts.commitments[0], scheduled); @@ -143,7 +143,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, @@ -212,10 +212,10 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { ); }; - if (recorded) + if (recorded !== undefined) assertRecord(recorded, candidateId, volunteerId, reached("coverage-replaced") ? leaderId : null); - if (replaced) { + if (replaced !== undefined) { assertRecord( replaced, secondSubstituteId, @@ -229,7 +229,7 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { ); } - if (rerecorded) assertRecord(rerecorded, candidateId, volunteerId, null); + if (rerecorded !== undefined) assertRecord(rerecorded, candidateId, volunteerId, null); const current = facts.coverage.filter((row) => row.withdrawn_at === null); assertReservations(step, facts, [ reservation(scheduled.commitment_id, volunteerId), @@ -395,7 +395,7 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { 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) => previous.receipts.some((old) => old.identity_sha256 === row.identity_sha256) !== true, ); assert.deepEqual( @@ -799,7 +799,7 @@ export const createGoldenObserver = (pool, fixture, deliveries) => { ); } else { const added = facts.receipts.filter( - (row) => !previous.receipts.some((old) => old.identity_sha256 === row.identity_sha256), + (row) => previous.receipts.some((old) => old.identity_sha256 === row.identity_sha256) !== true, ); assert.equal( @@ -873,7 +873,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 +883,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( [ diff --git a/tools/e2e/placement-check.ts b/tools/e2e/placement-check.ts index f0016c48..94350342 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -198,7 +198,7 @@ const cleanup = () => } try { - if (pool) await pool.end(); + if (pool !== undefined) await pool.end(); } catch (error) { errors.push(sanitize(String(error))); } @@ -210,9 +210,9 @@ const cleanup = () => } try { - if (notificationServer?.listening) + if (notificationServer?.listening === true) await new Promise((resolve, reject) => - notificationServer!.close((error) => (error ? reject(error) : resolve())), + notificationServer!.close((error) => (error !== undefined ? reject(error) : resolve())), ); } catch (error) { errors.push(sanitize(String(error))); @@ -232,7 +232,7 @@ const cleanup = () => for (const number of ownedPorts) if (!(await loopbackPortFree(number))) errors.push("owned port remains occupied: " + number); - if (errors.length && failure === undefined) failure = "Resource cleanup failed"; + if (errors.length > 0 && failure === undefined) failure = "Resource cleanup failed"; const result = Struct.assign(evidence ?? {}, { passed: (evidence?.passed === true || evidence?.apiPassed === true) && failure === undefined, @@ -271,7 +271,7 @@ const cleanup = () => portsReleased: errors.length === 0, postgresRemoved: removed.includes("postgres"), credentialManifestRemoved: removed.includes("manifest.json"), - receiverClosed: !notificationServer?.listening, + receiverClosed: notificationServer?.listening !== true, errors, }, }); @@ -287,7 +287,7 @@ const cleanup = () => const retained = []; for (const name of (await readdir(artifacts)).sort()) { - if (!goldenArtifactName.test(name)) continue; + if (goldenArtifactName.test(name) !== true) continue; const bytes = await readFile(join(artifacts, name)); retained.push({ path: name, @@ -319,7 +319,7 @@ const cleanup = () => result: result.passed ? "passed" : "failed", exit_code: result.passed ? 0 - : errors.length + : errors.length > 0 ? 1 : interruptedSignal === "SIGINT" ? 130 @@ -345,7 +345,7 @@ const cleanup = () => }); process.stdout.write(artifacts + "/receipt.json\n"); - if (errors.length) throw Error(errors.join("; ")); + if (errors.length > 0) throw Error(errors.join("; ")); })()); for (const signal of ["SIGTERM", "SIGINT"] as const) @@ -373,7 +373,7 @@ try { const [pgPort, backendPort, dashboardPort, notificationPort, homepagePort] = ownedPorts; const server = createHttpServer(async (request, response) => { - if (request.method === "POST" && request.url?.startsWith("/observe/")) { + if (request.method === "POST" && request.url?.startsWith("/observe/") === true) { try { assert.ok(checkpoint, "observer not ready"); const step = request.url.slice("/observe/".length); From f4c7b3e314c161e570919a48b0ae2b2814ff1ba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:44:10 +0000 Subject: [PATCH 124/148] refactor(acceptance): run the password recovery probe as an Effect program Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/acceptance/password-recovery-check.ts | 1129 ++++++++++--------- 1 file changed, 603 insertions(+), 526 deletions(-) diff --git a/tools/acceptance/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index 59508d83..4341d0cf 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -1,28 +1,42 @@ -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"; import { nativeRpcRequestBody, nativeRpcStatus } from "../../apps/dashboard/e2e/native-operations.js"; - -const root = new URL("../../", import.meta.url).pathname; +import { + answersOk, + commandOutput, + indentedJsonText, + jsonText, + ProbeFailure, + startOwnedProcess, +} from "./acceptance-process.ts"; const requireDatabase = createRequire( new URL("../../packages/database/package.json", import.meta.url), @@ -36,524 +50,586 @@ const { Pool } = requireDatabase("pg"); const { chromium } = requireDashboard("@playwright/test"); -const logs: string[] = []; +/** A PostgreSQL call, or the disposable cluster, that failed. */ +class DatabaseFailure extends Data.TaggedError("DatabaseFailure")<{ readonly cause: unknown }> {} -const run = (command: string, args: string[], env = process.env, cwd = root) => - execFileSync(command, args, { - cwd, - env, - encoding: "utf8", - timeout: 180000, - stdio: ["ignore", "pipe", "pipe"], - }); +/** A Playwright call that failed. */ +class BrowserFailure extends Data.TaggedError("BrowserFailure")<{ readonly cause: unknown }> {} -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "committed clean tree required"); +/** The rows of one query; the probe reads the columns that its statement selects. */ +interface QueryResult { + readonly rows: Array; +} -const revision = run("git", ["rev-parse", "HEAD"]).trim(); +/** 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 artifacts = await mkdtemp(join(tmpdir(), "vektor-recovery-0054-")); + return cookie === undefined ? undefined : `${cookie.name}=${cookie.valueEncoded}`; +}; -const children: ReturnType[] = []; +/** 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 }) }); -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))); +const MailboxMessages = Schema.Array(Schema.Struct({ text: Schema.String })); - return child; -}; +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[] = []; -const wait = async (test: () => Promise) => { - for (let i = 0; i < 150; i++) { - try { - if (await test()) return; - } catch {} + const run = (command: string, args: ReadonlyArray, env = {}, cwd = root) => + commandOutput({ command, args, cwd, env, deadline: "180 seconds", stderr: "pipe" }); - await new Promise((ok) => setTimeout(ok, 100)); - } + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "committed clean tree required", + ); - throw new Error("Readiness timeout"); -}; + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-recovery-0054-" }); -let postgres: DisposablePostgres | undefined; - -let pool: InstanceType; - -let browser: any; - -let page: any; - -let mailbox: Server | undefined; - -const secrets: string[] = []; - -const gates: string[] = []; - -const submissions: { tokenPresent: boolean; queryAbsent: boolean }[] = []; - -try { - const [apiPort] = await reserveLoopbackPorts(1), - uiPort = 5174; - - assert.ok(await loopbackPortFree(uiPort), `loopback port ${uiPort} is in use`); - - postgres = await startDisposablePostgres(); - const pg = postgres.url; - pool = new Pool({ connectionString: pg }); - - 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 = async (cookie: string) => - nativeRpcStatus( - await ( - await fetch(`${canonicalOrigin}/api/rpc`, { - method: "POST", - headers: { cookie, origin: dashboardOrigin, "content-type": "application/json" }, - body: nativeRpcRequestBody("system.readSession"), - }) - ).text(), - ); - - 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: "/", - }; - - 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`); - - return r.ok; - }); + const start = (command: string, args: ReadonlyArray, env = {}, cwd = root) => + startOwnedProcess({ command, args, cwd, env, output: (text) => logs.push(text) }); - 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", - }); + 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 }), + ); - 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 secrets: string[] = []; + const gates: string[] = []; + const submissions: { tokenPresent: boolean; queryAbsent: boolean }[] = []; + let page: any; + + const checks = Effect.gen(function* () { + const [apiPort] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(1), + catch: (cause) => new DatabaseFailure({ cause }), + }); - throw new Error("Credential rate limit did not clear"); - }; + const uiPort = 5174; - const login = async (password: string) => { - const r = await post("sign-in/email", { email, password }); - assert.equal(r.status, 200); + assert.ok( + yield* Effect.promise(() => loopbackPortFree(uiPort)), + `loopback port ${uiPort} is in use`, + ); - return r.headers.getSetCookie()[0]!.split(";")[0]!; - }; + const postgres = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => startDisposablePostgres(), + catch: (cause) => new DatabaseFailure({ cause }), + }), + (cluster) => Effect.promise(() => cluster.stop()), + ); - const cookie1 = await login(oldPassword), - cookie2 = await login(oldPassword); + const pg = postgres.url; - 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 }); + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: pg })), + (owned) => Effect.promise(() => owned.end()), + ); - if (request.method === "GET") return Response.json([...messages.values()]); + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => pool.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); - if (rejectMail) return new Response(null, { status: 503 }); + 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" }); + }); - const body = Schema.decodeUnknownSync(MailDeliveryRequest)(await request.json()); + 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); - messages.set(body.deliveryId, body); + return cookie; + }); - return Response.json({ acknowledged: true }); - }, - }); + const cookie1 = yield* login(oldPassword), + cookie2 = yield* login(oldPassword); - const delivery = HttpMailLive({ - endpoint: new URL(`http://127.0.0.1:${mailbox.port}/mail`), - token: mailboxToken, - deliveryTimeoutMilliseconds: 2000, - }); + secrets.push(cookie1, cookie2); + const messages = new Map(); + let rejectMail = false; + const mailboxToken = randomBytes(24).toString("hex"); + secrets.push(mailboxToken); + + 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 }); + + if (request.method === "GET") return Response.json([...messages.values()]); + + if (rejectMail) return new Response(null, { status: 503 }); + + return request.json().then((json) => { + const body = Schema.decodeUnknownSync(MailDeliveryRequest)(json); + messages.set(body.deliveryId, body); + + return Response.json({ acknowledged: true }); + }); + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ); + + const delivery = HttpMailLive({ + endpoint: new URL(`http://127.0.0.1:${mailbox.port}/mail`), + token: mailboxToken, + deliveryTimeoutMilliseconds: 2000, + }); - const drainWith = (provider: Layer.Layer) => - Effect.runPromise( + const drainWith = (provider: Layer.Layer) => Mail.use((mail) => - drainPasswordResetMail(pool, - { - oauth: { - canonicalOrigin, - dashboardOrigin, - nativeApiResource: "urn:vektorprogrammet:native-api", + drainPasswordResetMail( + pool, + { + oauth: { + canonicalOrigin, + dashboardOrigin, + nativeApiResource: "urn:vektorprogrammet:native-api", + }, }, - }, - mail, - "recovery@example.invalid",), - ).pipe(Effect.provide(provider.pipe(Layer.provide(FetchHttpClient.layer)))), + mail, + "recovery@example.invalid", + ), + ).pipe(Effect.provide(provider.pipe(Layer.provide(FetchHttpClient.layer)))); + + const drain = drainWith(delivery); + + 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 executablePath = yield* Config.String("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH").pipe( + Config.withDefault("/etc/profiles/per-user/nori/bin/chromium"), ); - const drain = () => drainWith(delivery); + const browser = yield* Effect.acquireRelease( + browserStep(() => chromium.launch({ headless: true, executablePath })), + (owned) => Effect.promise(() => owned.close()), + ); - 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 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", + }, + ); - 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", - ); + 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 received = Schema.decodeUnknownSync(Schema.Array(Schema.Struct({ text: Schema.String })))( - await ( - await fetch(`http://127.0.0.1:${mailbox.port}/mail`, { + const received = yield* HttpClient.execute( + HttpClientRequest.get(`http://127.0.0.1:${mailbox.port}/mail`, { headers: { authorization: `Bearer ${mailboxToken}` }, - }) - ).json(), - ); + }), + ).pipe( + Effect.flatMap((response) => response.json), + Effect.flatMap(Schema.decodeUnknownEffect(MailboxMessages)), + ); - 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]) { - assert.equal(await 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", - ); + 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); + } - const requestReset = () => - post("request-password-reset", { + 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", { 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]; + )).rows[0]; - const value = row.identifier.slice("reset-password:".length); - secrets.push(value); + const value: string = row.identifier.slice("reset-password:".length); + secrets.push(value); - return 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()`, + ); - assert.equal(await readSessionStatus(partialCookie), 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( + 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 AxeBuilder = requireDashboard("@axe-core/playwright").default; + const axe = yield* browserStep(() => 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", + ], + ] 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], + ); + } + + // 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) => ({ @@ -563,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 }, +); From 798fc471afd53f36c4c06d1bcb4712f0b9cb72f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:44:11 +0000 Subject: [PATCH 125/148] refactor(source-safety): scan the index as an Effect program scanIndex spawns Git through ChildProcessSpawner and fails with SourceSafetyGitError; the command line joins the Bun composition-root group, and the tests build their Git fixtures through FileSystem and Path on the scan's platform. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 1 + tools/source-safety/src/check.ts | 276 ++++++++++++------ tools/source-safety/src/source-safety.ts | 18 +- .../source-safety/tests/source-safety.test.ts | 254 ++++++++++------ 4 files changed, 359 insertions(+), 190 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 34f6f3e9..9b94d994 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -180,6 +180,7 @@ const effectConfig = { "tools/scripts/changelog.ts", "tools/scripts/land.ts", "tools/scripts/require-legacy-data-profile.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-rehearsal.ts", diff --git a/tools/source-safety/src/check.ts b/tools/source-safety/src/check.ts index acb92c3f..84398fee 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 3bfceeef..41381f62 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 7bdf68d0..0b5da715 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" }], + }); + }), + ), + )); }); From 12b1bf257456ea589d3bb24b88be99a9d07691dc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:47:11 +0000 Subject: [PATCH 126/148] refactor(acceptance): run the recommendation journey helpers as Effect programs The pre-upgrade fixture, the correction integrity check, and the applicant progress journey become Effect programs: promise-based driver calls run as typed journey steps, pool connections and transactions are scoped, evidence goes through FileSystem and Schema, and HTTP through HttpClient. The recommendation entry runs them with the Bun platform and joins the Bun composition-root group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- bun.lock | 10 + oxlint.config.ts | 1 + tools/acceptance/applicant-progress-0107.ts | 595 ++++++++++-------- .../co-interviewer-correction-0106.ts | 17 +- .../interview-correction-integrity.ts | 333 +++++----- tools/acceptance/journey-step.ts | 156 +++++ tools/acceptance/package.json | 10 + tools/acceptance/postgres-observation.ts | 5 +- tools/acceptance/recommendation-check.ts | 43 +- .../recommendation-preupgrade-fixture.ts | 567 +++++++++-------- 10 files changed, 1037 insertions(+), 700 deletions(-) create mode 100644 tools/acceptance/journey-step.ts create mode 100644 tools/acceptance/package.json diff --git a/bun.lock b/bun.lock index 95107563..8c1fdd51 100644 --- a/bun.lock +++ b/bun.lock @@ -236,6 +236,14 @@ "vitest": "catalog:effect-v4", }, }, + "tools/acceptance": { + "name": "@monoweb/acceptance", + "version": "0.1.0", + "dependencies": { + "@effect/platform-bun": "catalog:effect-v4", + "effect": "catalog:effect-v4", + }, + }, "tools/conventions": { "name": "@monoweb/conventions", "version": "0.1.0", @@ -753,6 +761,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"], diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..64f17136 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -169,6 +169,7 @@ const effectConfig = { "apps/backend/src/main.ts", "apps/backend/src/**/*-main.ts", "tools/acceptance/password-recovery-check.ts", + "tools/acceptance/recommendation-check.ts", "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", "tools/e2e/legacy-candidate-native-journey.ts", diff --git a/tools/acceptance/applicant-progress-0107.ts b/tools/acceptance/applicant-progress-0107.ts index 787e8ded..1f37a2da 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 { 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,7 +387,7 @@ export const runApplicantProgress0107 = async (input: { readonly artifacts: string; readonly errors: string[]; readonly audit: (page: Page) => Promise>; -}) => { +}) { const native = nativeScriptClient(input.api); /** Reads the progress as the person whose session `cookie` names, or anonymously. */ @@ -358,45 +397,54 @@ export const runApplicantProgress0107 = async (input: { ); /** The progress that a successful read answered, decoded again by the domain decoder. */ - const progressOf = async (cookie: string = input.cookie) => { - const answer = await readProgress(cookie); + 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((await readProgress(null)).status, 401); - const body = await progressOf(); - - 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, - }), }); + 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); - assert.deepEqual((await progressOf(unlinkedCookie)).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 readProgress(unlinkedCookie)).status, 401); + 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 = await progressOf(); + const readCompletedApplicationState = Effect.fnUntraced(function* () { + const current = yield* progressOf(); const application = current.applications.find( (candidate) => candidate.applicationId === "application-progress-completed-0107", @@ -406,8 +454,7 @@ export const runApplicantProgress0107 = async (input: { observedStates.add(application.progress._tag); return application.progress._tag; - }; - + }); for (const tag of [ "ApplicationReceived", @@ -437,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", @@ -452,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 }), @@ -486,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", @@ -502,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, ); @@ -511,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", { @@ -533,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", { @@ -586,46 +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 readProgress()).status, 401); - await native.dispose(); + 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(), @@ -633,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 0fbe9bce..db82a584 100644 --- a/tools/acceptance/co-interviewer-correction-0106.ts +++ b/tools/acceptance/co-interviewer-correction-0106.ts @@ -13,7 +13,8 @@ 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, Predicate, Schema } from "effect"; +import { surfaceStepFailure } from "./journey-step.ts"; import { replacedFetch } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; import { admissionJourneyClock } from "../e2e/journey-clock.ts"; @@ -815,10 +816,16 @@ export async function runCoInterviewerCorrectionJourney( recordGate, }); - await assertInterviewCorrectionIntegrity(pool, fixture.targetInterviewId, { - expectedCorrectedByPersonId: fixture.coInterviewer.personId, - expectedCoInterviewerPersonId: fixture.coInterviewer.personId, - }); + await Effect.runPromise( + surfaceStepFailure( + 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); diff --git a/tools/acceptance/interview-correction-integrity.ts b/tools/acceptance/interview-correction-integrity.ts index 7013e3c2..a8cdd8dc 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/journey-step.ts b/tools/acceptance/journey-step.ts new file mode 100644 index 00000000..016ade86 --- /dev/null +++ b/tools/acceptance/journey-step.ts @@ -0,0 +1,156 @@ +import { 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>; + +/** 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/package.json b/tools/acceptance/package.json new file mode 100644 index 00000000..2c282bad --- /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/postgres-observation.ts b/tools/acceptance/postgres-observation.ts index 6f53560d..9f814185 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 672ffbe9..608a791d 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -63,11 +63,14 @@ import { } 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, Option, Predicate, Schema } from "effect"; +import { Array as Arr, Effect, Layer, Option, Predicate, Redacted, Schema } from "effect"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import process from "node:process"; +import { type JourneyStepFailed, surfaceStepFailure } from "./journey-step.ts"; 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 { FetchHttpClient } from "effect/unstable/http"; +import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -77,7 +80,22 @@ const uiRequire = createRequire(new URL("../../apps/dashboard/package.json", imp const { Pool } = dbRequire("pg"); -const { Effect, Layer, Redacted } = dbRequire("effect"); +/** + * Runs one Effect program of the journey helpers with the Bun platform and a fetch HTTP client. + * A failed driver step rejects with the error that the driver raised. + */ +const runJourneyEffect = ( + effect: Effect.Effect< + A, + E | JourneyStepFailed, + BunServices.BunServices | HttpClient.HttpClient + >, +): Promise => + Effect.runPromise( + surfaceStepFailure(effect).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), + ); const fixtureKeys = { invalid0: "invalid-recommendation-0101-0", @@ -454,8 +472,7 @@ const deliverRecruitmentInvitationOnce = async ({ return Effect.runPromise( Effect.scoped( deliverNextRecruitmentInvitation(claimId, now()).pipe( - Effect.provide(gateway), - Effect.provide(authorityLayers), + Effect.provide(Layer.merge(gateway, authorityLayers)), ), ), ); @@ -524,7 +541,7 @@ try { ).rows[0].value; recordGate("previous-schema history fixture migrated"); - correctionPre0039Fixture = await seedInterviewCorrectionPre0039Fixture({ pool }); + correctionPre0039Fixture = await runJourneyEffect(seedInterviewCorrectionPre0039Fixture({ pool })); recordGate("seeded 0105 pre-0039 correction rows from existing native base"); run( "bun", @@ -548,7 +565,7 @@ try { ); if (coInterviewerMode) { - coInterviewerFixture = await seedCoInterviewerCorrection0106Fixture({ pool }); + coInterviewerFixture = await runJourneyEffect(seedCoInterviewerCorrection0106Fixture({ pool })); recordGate( "seeded synthetic 0106 co-interviewer designation after the canonical migration chain", ); @@ -612,7 +629,7 @@ try { await link("maybe", "recommendation-other-0101"); if (applicantProgressMode) { - await seedApplicantProgress0107(pool); + await runJourneyEffect(seedApplicantProgress0107(pool)); recordGate("seeded synthetic current-semester applicant progress states"); secrets.push( applicantProgressUnlinkedIdentity.email, @@ -642,7 +659,9 @@ try { "immutable historical row survived actual0037 upgrade without invented recommendation", ); assert.ok(correctionPre0039Fixture); - await assertInterviewCorrectionPre0039Preserved(pool, correctionPre0039Fixture); + await runJourneyEffect( + assertInterviewCorrectionPre0039Preserved({ connection: pool, fixture: correctionPre0039Fixture }), + ); recordGate( "0039/0040 upgrades preserved original interview/schedule/invitation/conduct/lifecycle rows", ); @@ -702,7 +721,7 @@ try { WHERE membership_id='membership-native-conduct-leader-0063'`, ); - const journey = await runApplicantProgress0107({ + const journey = await runJourneyEffect(runApplicantProgress0107({ pool, page, cookie, @@ -711,7 +730,7 @@ try { artifacts, errors, audit: (journeyPage) => auditPage(journeyPage, "applicant-progress"), - }); + })); recordGate("observed applicant-owned progress through the real browser/API/PostgreSQL path"); @@ -2057,7 +2076,7 @@ try { DROP FUNCTION public.test_correction_audit_failure(); `); stage("synthetic audit failure rolls back correction assessment, aggregate, receipt and audit"); - await assertInterviewCorrectionIntegrity(pool, correctionId); + await runJourneyEffect(assertInterviewCorrectionIntegrity({ pool, interviewId: correctionId })); recordGate( "direct SQL correction integrity rejects immutable mutations, invalid predecessor, aggregate mismatch, and cross-command receipt/audit tuples without writes", ); diff --git a/tools/acceptance/recommendation-preupgrade-fixture.ts b/tools/acceptance/recommendation-preupgrade-fixture.ts index 975eb97f..42af0d33 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; -}; +}); From 5e90849b83e9b4e0ff570b9c4f23bc6b344f76ca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:09:00 +0000 Subject: [PATCH 127/148] refactor(verification): make the delivery tests and the current assignment CLI Effect programs The worker and onboarding delivery suites run on @effect/vitest with their database as a suite layer. The current assignment CLI reads its configuration through Config and runs under BunRuntime from its main, which joins the Bun composition-root group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- bun.lock | 1 + oxlint.config.ts | 1 + tools/verification/application-worker.test.ts | 204 ++++++------ .../controlled-test-runtime.test.ts | 60 ++-- tools/verification/controlled-test-runtime.ts | 7 +- .../current-assignment-cohort-cli.test.ts | 72 +++-- .../current-assignment-cohort-cli.ts | 108 ++++--- .../current-assignment-cohort-main.ts | 33 +- .../verification/onboarding-delivery.test.ts | 295 +++++++++--------- tools/verification/package.json | 1 + 10 files changed, 409 insertions(+), 373 deletions(-) diff --git a/bun.lock b/bun.lock index 95107563..6df57266 100644 --- a/bun.lock +++ b/bun.lock @@ -339,6 +339,7 @@ "pg": "8", }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "@types/pg": "^8.23.1", "typescript": "7.0.2", diff --git a/oxlint.config.ts b/oxlint.config.ts index 55fd68a6..ff835f95 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -179,6 +179,7 @@ const effectConfig = { "tools/e2e/run-legacy-*.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", diff --git a/tools/verification/application-worker.test.ts b/tools/verification/application-worker.test.ts index 54fc5585..68f4253e 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/controlled-test-runtime.test.ts b/tools/verification/controlled-test-runtime.test.ts index 4b8e6ea8..e07283cc 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 641fd0d8..215eb5b1 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 20291ad3..09eb576e 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 79874f29..8b750721 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 99dd8784..6719375e 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/onboarding-delivery.test.ts b/tools/verification/onboarding-delivery.test.ts index eed29bd2..0c963456 100644 --- a/tools/verification/onboarding-delivery.test.ts +++ b/tools/verification/onboarding-delivery.test.ts @@ -1,24 +1,19 @@ 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"); @@ -28,159 +23,153 @@ const coordinator = administratorDepartmentReach("onboarding-leader", "admission // 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({ - 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), - }), - ), + 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({ - coordinator, - 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, }, - 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/package.json b/tools/verification/package.json index 844db293..0d49489c 100644 --- a/tools/verification/package.json +++ b/tools/verification/package.json @@ -25,6 +25,7 @@ "pg": "8" }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "@types/pg": "^8.23.1", "typescript": "7.0.2", From eec2c6a9c8f4042099aab21ae665c4f055029968 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:12:07 +0000 Subject: [PATCH 128/148] refactor(acceptance): run the onboarding probe as an Effect program The probes write compact JSON through the backend's jsonText construct; acceptance-process keeps only the indented form of evidence files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/acceptance/acceptance-process.ts | 11 +- tools/acceptance/onboarding-check.ts | 1414 ++++++++++-------- tools/acceptance/password-recovery-check.ts | 2 +- tools/acceptance/substitute-outcome-check.ts | 2 +- 4 files changed, 759 insertions(+), 670 deletions(-) diff --git a/tools/acceptance/acceptance-process.ts b/tools/acceptance/acceptance-process.ts index 6779d12e..917b20ce 100644 --- a/tools/acceptance/acceptance-process.ts +++ b/tools/acceptance/acceptance-process.ts @@ -181,15 +181,12 @@ export const answersOk = (url: string): Effect.Effect false), ); -const JsonText = Schema.fromJsonString(Schema.Unknown); - const IndentedJsonText = Schema.fromJsonString(Schema.Unknown, { space: 2 }); -/** The JSON text of a value, byte for byte what `JSON.stringify` writes. */ -export const jsonText = (value: A): Effect.Effect => - Schema.encodeEffect(JsonText)(value).pipe(Effect.orDie); - -/** The JSON text of a value indented by two spaces, as `JSON.stringify(value, null, 2)` writes it. */ +/** + * 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); diff --git a/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index 6fc4ce94..d5f6c6c9 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -1,26 +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"; + 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"; - -const root = new URL("../../", import.meta.url).pathname; +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), @@ -28,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 }> {} -const revision = run("git", ["rev-parse", "HEAD"]).trim(); +/** 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 }); -assert.equal(run("git", ["status", "--porcelain"]).trim(), "", "requires committed clean artifact"); +type MailMessage = typeof MailMessage.Type; -const artifacts = await mkdtemp(join(tmpdir(), "vektor-onboarding-0099-")); +const JsonBody = Schema.fromJsonString(Schema.Json); + +/** 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(); @@ -63,735 +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 journey = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dirname, "../.."); -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 run = (command: string, args: ReadonlyArray, env = {}) => + commandOutput({ command, args, cwd: root, env, deadline: "60 seconds" }); - return child; -}; - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); -let postgres: DisposablePostgres | undefined; + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed clean artifact", + ); -let pool: InstanceType; + 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), + ); -let evidence: Schema.JsonObject | undefined; + const [backendPort, mailboxPort] = yield* Effect.tryPromise({ + try: () => reserveLoopbackPorts(2), + catch: (cause) => new DatabaseFailure({ cause }), + }); -const mailboxToken = randomBytes(32).toString("hex"); + const dashboardPort = 5174; -secrets.add(mailboxToken); + assert.ok( + yield* Effect.promise(() => loopbackPortFree(dashboardPort)), + `loopback port ${dashboardPort} is in use`, + ); -const mail = new Map(); + const postgres = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => startDisposablePostgres(), + catch: (cause) => new DatabaseFailure({ cause }), + }), + (cluster) => Effect.promise(() => cluster.stop()), + ); -let mailbox: ReturnType | undefined; + const postgresUrl = postgres.url; + secrets.add(postgresUrl); -let attempts = 0; + const pool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: postgresUrl })), + (owned) => Effect.promise(() => owned.end()), + ); -let rejectNext = false; + const query = (text: string, values?: ReadonlyArray) => + Effect.tryPromise({ + try: (): Promise => pool.query(text, values), + catch: (cause) => new DatabaseFailure({ cause }), + }); -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 backendOrigin = `http://127.0.0.1:${backendPort}`; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - 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(); + 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 }); - return; - } + return request.text().then((body) => { + if (request.method !== "POST") return Response.json([...mail.values()].map((entry) => entry.json)); - let body = ""; + attempts++; - for await (const chunk of req) body += chunk; + if (rejectNext) { + rejectNext = false; - if (req.method === "POST") { - attempts++; + return new Response(null, { status: 503 }); + } - if (rejectNext) { - rejectNext = false; - res.writeHead(503).end(); + const json = Schema.decodeUnknownSync(JsonBody)(body); + const text = Schema.encodeSync(JsonBody)(json); + const message = Schema.decodeUnknownSync(MailMessage)(json); + const old = mail.get(message.deliveryId); - return; - } + if (old !== undefined && old.text !== text) return new Response(null, { status: 409 }); - const parsed = JSON.parse(body); - const old = mail.get(parsed.deliveryId); + mail.set(message.deliveryId, { message, json, text }); - if (old && JSON.stringify(old) !== JSON.stringify(parsed)) { - res.writeHead(409).end(); + return new Response(null, { status: 204 }); + }); + }, + }), + ), + (server) => Effect.promise(() => server.stop(true)), + ); - return; - } + 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", + }, + }; - mail.set(parsed.deliveryId, parsed); - res.writeHead(204).end(); + 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 ready = async () => { - for (let n = 0; ; n++) { - try { - if ((await fetch(backendOrigin + "/health")).ok) return; - } catch {} + 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", + ); - if (n > 150) throw Error("backend startup failed"); - await delay(200); - } - }; + return body; + }); - await ready(); + 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 !== ""); - const request = ( - path: string, - cookie?: string, - body?: Schema.Json, - etag?: string, - key = randomBytes(18).toString("hex"), - ) => { - const nativeHeaders = new Headers(); - nativeHeaders.set("origin", dashboardOrigin); + return cookie; + }); - if (cookie) { - nativeHeaders.set("cookie", cookie); - } + 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 (!(body === undefined)) { - nativeHeaders.set("content-type", "application/json"); - nativeHeaders.set("idempotency-key", key); + // 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 (etag) { - nativeHeaders.set("if-match", etag); - } - } + 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 requestBody: Pick = - body === undefined ? {} : { body: JSON.stringify(body) }; + const answer = yield* Effect.promise(() => + applications.call({}, (client) => + client["admissions.submitApplication"]({ + idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), + request: submission, + }), + ), + ); - const url = new URL(path, backendOrigin); + assert.ok(answer.ok, yield* jsonText(answer)); - // 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 fetch(backendOrigin + path, { - method: body === undefined ? "GET" : "POST", - headers: nativeHeaders, - ...requestBody, + return answer.value; }); - } - const rpcHeaders = { origin: dashboardOrigin, cookie }; + 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, + ); - if (url.pathname === "/api/onboarding/claim") { - return replacedFetch({ - origin: backendOrigin, - tag: "onboarding.claim", - payload: body ?? null, - headers: rpcHeaders, + return lastMail(); }); - } - const scope = { departmentId: url.searchParams.get("departmentId") ?? "" }; - - return body === undefined - ? replacedFetch({ - origin: backendOrigin, - tag: "onboarding.readBoard", - payload: scope, - headers: rpcHeaders, - }) - : replacedFetch({ - origin: backendOrigin, - tag: "onboarding.command", - payload: { ...scope, idempotencyKey: key, ifMatch: etag ?? "", request: body }, - headers: rpcHeaders, - }); - }; + 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); - - return cookie; - }; - - 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); - - // Public applications are served as an RPC; an applicant submits anonymously. - const applications = nativeScriptClient(backendOrigin); + const existingApplication = yield* submit("onboarding-existing@example.invalid", "Existing"); - const submit = async (email: string, firstName: string) => { - const request = Schema.decodeSync(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 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 answer = await applications.call({}, (client) => - client["admissions.submitApplication"]({ - idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), - request, + 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, }), + 409, ); - assert.ok(answer.ok, JSON.stringify(answer)); + const concurrentApplication = yield* submit( + "onboarding-concurrent@example.invalid", + "Concurrent", + ); - return answer.value; - }; + const concurrent = yield* issue(concurrentApplication.applicationId); + const concurrentToken = claimToken(concurrent); - const browserApplication = await submit(persons.applicant.email, "Onboarding"); - let revokedReplayObserved = false; - - 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); - - 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, + 200, ); - assert.equal( - ( - await pool.query( - `SELECT count(*)::int AS count FROM person_contact_profiles 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], ); - assert.equal( - ( - await pool.query( - `SELECT count(*)::int AS count FROM auth."user" WHERE email='onboarding-rollback@example.invalid'`, - ) - ).rows[0].count, - 0, + yield* query( + `INSERT INTO applicant_account_delivery(invitation_id,state,recipient) VALUES('expiry-race','Delivered','onboarding-expiry@example.invalid')`, ); - } finally { - await pool.query( - `DROP TRIGGER reject_onboarding_test_credential ON auth."account"; DROP FUNCTION public.reject_onboarding_test_credential()`, - ); - } - - 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(); - const observed = ( - await pool.query( + browserEvidence = yield* Schema.decodeEffect(JsonBody)( + yield* fs.readFileString(path.join(artifacts, "browser-evidence.json")), + ); + + 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, - ); - await applications.dispose(); - - 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/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index 4341d0cf..eaff017c 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -29,11 +29,11 @@ import { loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres } from import { drainPasswordResetMail } from "../../packages/database/src/password-recovery.js"; import { HttpMailLive } from "../../apps/backend/src/mail/http.js"; import { nativeRpcRequestBody, nativeRpcStatus } from "../../apps/dashboard/e2e/native-operations.js"; +import { jsonText } from "../../apps/backend/src/rpc/problem.js"; import { answersOk, commandOutput, indentedJsonText, - jsonText, ProbeFailure, startOwnedProcess, } from "./acceptance-process.ts"; diff --git a/tools/acceptance/substitute-outcome-check.ts b/tools/acceptance/substitute-outcome-check.ts index 613c6eec..7552cb26 100644 --- a/tools/acceptance/substitute-outcome-check.ts +++ b/tools/acceptance/substitute-outcome-check.ts @@ -38,11 +38,11 @@ import { nativeScriptClient, type ScriptCallResult } from "../../packages/rpc/sr 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 { answersOk, commandOutput, indentedJsonText, - jsonText, ProbeFailure, startOwnedProcess, withheldVariables, From ff4f736a152c8ea8bec4487ad1926bd7dbcc66d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:16:30 +0000 Subject: [PATCH 129/148] refactor(verification): give the organization import SQL observer and byte-set comparison pipeable forms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../organization-import-rehearsal-postgres.ts | 36 ++++++++++++------- .../organization-import-rehearsal.ts | 18 ++++++---- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/tools/verification/organization-import-rehearsal-postgres.ts b/tools/verification/organization-import-rehearsal-postgres.ts index 3c312985..da50d76a 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.ts b/tools/verification/organization-import-rehearsal.ts index 342bf97c..fd6fcb98 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"; @@ -614,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"), From df366b30718a62aba1e448f209545e70f94b1ef6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:21:16 +0000 Subject: [PATCH 130/148] docs(acceptance): render the guide of the new acceptance manifest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- tools/acceptance/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/acceptance/AGENTS.md b/tools/acceptance/AGENTS.md index c3831da4..0bc42701 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 From eb5c75b620e2329f8885f809e958e861e723e316 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:22:49 +0000 Subject: [PATCH 131/148] refactor(verification): make the recommendation pre-upgrade fixture an Effect program It reads its configuration through Config, its migrations through FileSystem, and runs the conduct seed through ChildProcess; it joins the Bun composition-root group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 1 + .../recommendation-preupgrade-fixture.ts | 126 ++++++++++++------ 2 files changed, 87 insertions(+), 40 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index ff835f95..7a3da686 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -184,6 +184,7 @@ const effectConfig = { "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", ], role: "composition-root", platform: "bun", diff --git a/tools/verification/recommendation-preupgrade-fixture.ts b/tools/verification/recommendation-preupgrade-fixture.ts index 6e3b9eaf..fd54b22c 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))); From 7ee5421a1aacfb3648bb99e356cbc58a0a20e01f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:31:18 +0000 Subject: [PATCH 132/148] refactor(verification): make the current assignment rehearsal an Effect program It runs its commands through ChildProcess, reads ambient configuration through Config, owns its temporary directory, cluster, and pool as scoped resources, and runs at a Bun composition root. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../current-assignment-cohort-rehearsal.ts | 1355 +++++++++-------- 1 file changed, 710 insertions(+), 645 deletions(-) diff --git a/tools/verification/current-assignment-cohort-rehearsal.ts b/tools/verification/current-assignment-cohort-rehearsal.ts index ad8df8a8..7cb69779 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), + ), +); From 7462a62993e92e78712c23e88717ca9ca96aa13c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:33:50 +0000 Subject: [PATCH 133/148] refactor(conventions): read the repository and the justfile through Effect services The conventions CLI is a Bun composition root. Reading a repository loads its files through FileSystem once, so the checks keep their synchronous reads. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 1 + tools/conventions/src/cli.ts | 327 ++++++++++-------- tools/conventions/src/justfile.ts | 57 ++- tools/conventions/src/repository.ts | 160 +++++++-- tools/conventions/tests/constructs.test.ts | 9 +- tools/conventions/tests/exceptions.test.ts | 10 +- tools/conventions/tests/guides.test.ts | 9 +- tools/conventions/tests/journeys.test.ts | 12 +- tools/conventions/tests/layout.test.ts | 12 +- tools/conventions/tests/oxlint-groups.test.ts | 15 +- 10 files changed, 390 insertions(+), 222 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 22e20d06..d38caceb 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -182,6 +182,7 @@ const effectConfig = { "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/scripts/changelog.ts", "tools/scripts/land.ts", "tools/scripts/require-legacy-data-profile.ts", diff --git a/tools/conventions/src/cli.ts b/tools/conventions/src/cli.ts index ffa791da..350d4c32 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/justfile.ts b/tools/conventions/src/justfile.ts index 41700ca1..f9f49e34 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/repository.ts b/tools/conventions/src/repository.ts index c34fe813..6c090e6f 100644 --- a/tools/conventions/src/repository.ts +++ b/tools/conventions/src/repository.ts @@ -2,11 +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 { Data, Effect, FileSystem, Option, Path, Result, Stream } from "effect"; import { dual } from "effect/Function"; -import { spawnSync } from "node:child_process"; -import { lstatSync, readFileSync, readlinkSync, type Stats } from "node:fs"; -import { join } from "node:path"; +import type { PlatformError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export interface Repository { readonly root: string; @@ -19,62 +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: { - (staged: boolean): (root: string) => Repository; - (root: string, staged: boolean): Repository; -} = dual(2, (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/tests/constructs.test.ts b/tools/conventions/tests/constructs.test.ts index 6a6c53a1..61c15830 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, diff --git a/tools/conventions/tests/exceptions.test.ts b/tools/conventions/tests/exceptions.test.ts index 77189cbc..f07d7966 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 21b772a2..48968a05 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 a291d804..309b0f15 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 74ac4d3d..4892102c 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 d5b08a2a..0d3c0637 100644 --- a/tools/conventions/tests/oxlint-groups.test.ts +++ b/tools/conventions/tests/oxlint-groups.test.ts @@ -2,15 +2,20 @@ // silently: the Effect domain groups once used `!(…)` patterns and no core rule ever ran. 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 ?? []; From bbfddd8e3d3940ff732b1ef3759dbfde584b12f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:38:04 +0000 Subject: [PATCH 134/148] refactor(verification): make the completion receipt proof an Effect program Its loopback receiver is a Bun HttpServer in the proof scope, it reads its database URL through Config, and it queries through SqlClient. The Bun server types conflict with the Node request types of the other verification programs, so the proof type-checks in its own tsconfig.bun.json, as tools/e2e does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../completion-receipt-postgres-proof-main.ts | 446 +++++++++--------- tools/verification/package.json | 2 +- tools/verification/tsconfig.bun.json | 9 + tools/verification/tsconfig.json | 4 +- 4 files changed, 237 insertions(+), 224 deletions(-) create mode 100644 tools/verification/tsconfig.bun.json diff --git a/tools/verification/completion-receipt-postgres-proof-main.ts b/tools/verification/completion-receipt-postgres-proof-main.ts index 1a2df799..2c389451 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/package.json b/tools/verification/package.json index 0d49489c..e676f7de 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", diff --git a/tools/verification/tsconfig.bun.json b/tools/verification/tsconfig.bun.json new file mode 100644 index 00000000..fc2278cd --- /dev/null +++ b/tools/verification/tsconfig.bun.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "bun"] + }, + "files": ["completion-receipt-postgres-proof-main.ts"], + "include": [], + "exclude": [] +} diff --git a/tools/verification/tsconfig.json b/tools/verification/tsconfig.json index 3e6fea70..9c9d17e7 100644 --- a/tools/verification/tsconfig.json +++ b/tools/verification/tsconfig.json @@ -4,5 +4,7 @@ "noEmit": true, "types": ["node"] }, - "include": ["*.ts"] + "include": ["*.ts"], + // The loopback receiver of this proof is a Bun HTTP server, whose types tsconfig.bun.json adds. + "exclude": ["completion-receipt-postgres-proof-main.ts"] } From f18fc1189b7a139b32e07b3e797e3ad85d07fca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:38:24 +0000 Subject: [PATCH 135/148] refactor(e2e): make the golden reimbursement journey an Effect program The runner and its evidence module become TypeScript. Processes run through ChildProcess in owned scopes, the provider and browser callbacks run through a FiberSet runtime, and time, environment, files, and JSON go through Clock, DateTime, Config, FileSystem, Path, and Schema. A declaration file types the browser driver's hooks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../e2e/golden-reimbursement-browser.d.mts | 39 + .../e2e/golden-reimbursement-browser.mjs | 2 +- justfile | 2 +- oxlint.config.ts | 1 + tools/e2e/golden-reimbursement-evidence.mjs | 268 ----- tools/e2e/golden-reimbursement-evidence.ts | 367 +++++++ tools/e2e/golden-reimbursement.mjs | 725 ------------- tools/e2e/golden-reimbursement.ts | 998 ++++++++++++++++++ tools/e2e/tsconfig.bun.json | 2 +- tools/e2e/tsconfig.json | 3 +- 10 files changed, 1410 insertions(+), 997 deletions(-) create mode 100644 apps/dashboard/e2e/golden-reimbursement-browser.d.mts delete mode 100644 tools/e2e/golden-reimbursement-evidence.mjs create mode 100644 tools/e2e/golden-reimbursement-evidence.ts delete mode 100644 tools/e2e/golden-reimbursement.mjs create mode 100644 tools/e2e/golden-reimbursement.ts 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 00000000..bf956b1c --- /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 4cf81f00..f27a3973 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -16,7 +16,7 @@ import { nativeRpcStatus, nativeRpcValue, } from "./native-operations.ts"; -import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.mjs"; +import { fixture, receiptBytes } from "../../../tools/e2e/golden-reimbursement-evidence.ts"; import { sha256 } from "../../../tools/e2e/golden-school-service-evidence.mjs"; export const runReimbursementBrowser = async ({ diff --git a/justfile b/justfile index 6ad72dcf..7c9e1312 100644 --- a/justfile +++ b/justfile @@ -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 22e20d06..e0f211bb 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -175,6 +175,7 @@ const effectConfig = { "tools/acceptance/recommendation-check.ts", "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", + "tools/e2e/golden-reimbursement.ts", "tools/e2e/legacy-candidate-native-journey.ts", "tools/e2e/legacy-organization-rehearsal-runtime.ts", "tools/e2e/public-application-outbox-driver.ts", diff --git a/tools/e2e/golden-reimbursement-evidence.mjs b/tools/e2e/golden-reimbursement-evidence.mjs deleted file mode 100644 index d14f50b7..00000000 --- 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 00000000..107b9494 --- /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 e7df8e01..00000000 --- a/tools/e2e/golden-reimbursement.mjs +++ /dev/null @@ -1,725 +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/rpc", - "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", - }; - - 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 00000000..2f6c2aea --- /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/tsconfig.bun.json b/tools/e2e/tsconfig.bun.json index 6e06a074..7ea7361b 100644 --- a/tools/e2e/tsconfig.bun.json +++ b/tools/e2e/tsconfig.bun.json @@ -3,6 +3,6 @@ "compilerOptions": { "types": ["node", "bun"] }, - "files": ["safe-file-io.ts"], + "files": ["safe-file-io.ts", "golden-reimbursement.ts"], "include": [] } diff --git a/tools/e2e/tsconfig.json b/tools/e2e/tsconfig.json index 69753582..7dbe0958 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" ] } From a937287f3252c1baff708d526c56c78c52826b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:52:51 +0000 Subject: [PATCH 136/148] refactor(scripts): run the heavy lock, measure-job, hook slots, and Vitest admission as Effect programs The lock keeps its file protocol and flock(1) on the descriptor that it holds (EX-0013). Vitest admission writes the holder into the environment of its main process for its workers (EX-0014). A measured job runs through node spawn so that the ledger and the exit keep its terminating signal (EX-0015). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/effect-exceptions.json | 71 +++ oxlint.config.ts | 3 + tools/scripts/heavy-lock.ts | 405 +++++++++----- tools/scripts/hook-slot.ts | 171 +++--- tools/scripts/job-process.ts | 96 ++++ tools/scripts/measure-job.ts | 712 ++++++++++++++----------- tools/scripts/tests/heavy-lock.test.ts | 392 ++++++++------ tools/scripts/tests/land.test.ts | 283 ++++++---- tools/scripts/vitest-admission.ts | 46 +- 9 files changed, 1351 insertions(+), 828 deletions(-) create mode 100644 tools/scripts/job-process.ts diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index d61b942b..050cba11 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -269,6 +269,77 @@ "@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." } ] } diff --git a/oxlint.config.ts b/oxlint.config.ts index d38caceb..7b6e432b 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -184,8 +184,11 @@ const effectConfig = { "tools/e2e/run-legacy-*.ts", "tools/conventions/src/cli.ts", "tools/scripts/changelog.ts", + "tools/scripts/hook-slot.ts", "tools/scripts/land.ts", + "tools/scripts/measure-job.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", diff --git a/tools/scripts/heavy-lock.ts b/tools/scripts/heavy-lock.ts index 1a676d77..43ed9179 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 c84c3958..2c78f9c7 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 00000000..83ed408c --- /dev/null +++ b/tools/scripts/job-process.ts @@ -0,0 +1,96 @@ +/** + * 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; +} + +/** The job's program could not start. */ +export class JobStartFailure extends Data.TaggedError("JobStartFailure")<{ + readonly message: string; +}> {} + +/** A job, the variables that it adds to the environment, and the signals forwarded to it. */ +export interface Job { + readonly command: string; + readonly arguments: ReadonlyArray; + readonly variables: Readonly>; + 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, + forwardedSignals, +}: Job): Effect.Effect => + Effect.uninterruptible( + Effect.callback((resume) => { + const child = spawn(command, jobArguments, { + stdio: "inherit", + env: { ...process.env, ...variables }, + }); + + const handlers = forwardedSignals.map( + (signal) => [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 })); + }); + }), + ); + +/** + * 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/measure-job.ts b/tools/scripts/measure-job.ts index 3fc32606..a50ea08d 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/tests/heavy-lock.test.ts b/tools/scripts/tests/heavy-lock.test.ts index c3d1ac7b..9118f36e 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 6436d4f4..b3657d5f 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 7c3bb84d..d2775b3c 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; + }); From 3ae2dfce204178b46ffff878c1507c108bced91c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:01 +0000 Subject: [PATCH 137/148] fix(e2e): audit accessibility only on a settled page Two journeys failed at random on color-contrast for a button: the golden reimbursement owner page and the onboarding spec. The shared button has transition-all and disabled:opacity-50, so right after a submission it is enabled but still half transparent, and axe reads the midpoint colours. auditSettledPage in apps/dashboard/e2e/settled-axe.ts waits in the page for every animation with a finite end, then runs axe with the caller's tags and regions and returns each violation's rule, impact, targets, and failure summary, without markup. It lives beside the dashboard journeys because the dashboard owns @axe-core/playwright and Playwright; the homepage journeys import it by path as the homepage Vite config already imports dashboard source, and the tools import it as they import native-operations.ts. All 24 modules that ran axe now audit through it, each with its own tags, regions, and assertion. The construct joins the new browser-audit category. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/AGENTS.md | 1 + apps/dashboard/app/__tests__/home.spec.ts | 6 +- .../e2e/admission-period-management.spec.ts | 10 +- .../e2e/golden-reimbursement-browser.mjs | 15 +-- .../e2e/golden-team-application-browser.ts | 8 +- .../e2e/native-content-publication.spec.ts | 20 ++-- .../e2e/native-identity-browser.spec.ts | 6 +- apps/dashboard/e2e/native-onboarding.spec.ts | 11 +- ...native-organization-administration.spec.ts | 18 +-- apps/dashboard/e2e/native-placement.spec.ts | 13 +-- .../e2e/native-profile-self-edit.spec.ts | 6 +- .../e2e/native-recruitment-assignment.spec.ts | 10 +- ...native-recruitment-first-placement.spec.ts | 4 +- ...tive-recruitment-interview-conduct.spec.ts | 38 +++--- ...e-recruitment-interview-scheduling.spec.ts | 10 +- ...native-recruitment-session-journey.spec.ts | 12 +- .../e2e/native-schools-directory.spec.ts | 8 +- apps/dashboard/e2e/native-substitutes.spec.ts | 12 +- .../e2e/real-interview-response.spec.ts | 26 ++--- .../run-real-native-receipt-settlement.mjs | 12 +- apps/dashboard/e2e/settled-axe.ts | 108 ++++++++++++++++++ .../e2e/public-applicant-admission.spec.ts | 8 +- apps/homepage/e2e/run-native-contact.mjs | 11 +- docs/constructs.md | 2 + docs/constructs/browser-audit.md | 48 ++++++++ tools/acceptance/password-recovery-check.ts | 6 +- tools/acceptance/recommendation-check.ts | 14 +-- tools/conventions/src/constructs.ts | 2 + .../receipt-reopen-observation.ts | 11 +- 29 files changed, 286 insertions(+), 170 deletions(-) create mode 100644 apps/dashboard/e2e/settled-axe.ts create mode 100644 docs/constructs/browser-audit.md diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index 5cc9d1b3..841f40e0 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -34,6 +34,7 @@ The shared constructs defined here. Each name links to its contract; [docs/const - [`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 d593b2b6..f95d1364 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/e2e/admission-period-management.spec.ts b/apps/dashboard/e2e/admission-period-management.spec.ts index b84bd6c5..74699d1c 100644 --- a/apps/dashboard/e2e/admission-period-management.spec.ts +++ b/apps/dashboard/e2e/admission-period-management.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 { expect, test, type Locator } from "@playwright/test"; import { @@ -296,11 +296,11 @@ test.describe("Native admission-period management", () => { if (admissionPeriodId === null) throw new Error("created admission-period ID was absent"); - 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. diff --git a/apps/dashboard/e2e/golden-reimbursement-browser.mjs b/apps/dashboard/e2e/golden-reimbursement-browser.mjs index 4cf81f00..14db44b5 100644 --- a/apps/dashboard/e2e/golden-reimbursement-browser.mjs +++ b/apps/dashboard/e2e/golden-reimbursement-browser.mjs @@ -3,7 +3,7 @@ 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 { nativeRpcPath } from "@vektorprogrammet/rpc"; import { @@ -138,14 +138,9 @@ export const runReimbursementBrowser = async ({ await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${surface} page overflow`, ); - // Axe reads computed colours, so a colour transition still running (such as the focus and - // state styles of the button that just submitted) reads as its midpoint: audit the settled page. - await page.evaluate(() => - Promise.all(document.getAnimations().map((animation) => animation.finished)), - ); - 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", ); @@ -153,9 +148,9 @@ export const runReimbursementBrowser = async ({ serious.map(({ id }) => id), [], `${surface} accessibility: ${JSON.stringify( - serious.map(({ id, nodes }) => ({ + serious.map(({ id, targets }) => ({ id, - nodes: nodes.map(({ target, failureSummary }) => ({ target, failureSummary })), + targets: targets.map(({ target, failureSummary }) => ({ target, failureSummary })), })), )}`, ); diff --git a/apps/dashboard/e2e/golden-team-application-browser.ts b/apps/dashboard/e2e/golden-team-application-browser.ts index 088b71f7..db2e2e34 100644 --- a/apps/dashboard/e2e/golden-team-application-browser.ts +++ b/apps/dashboard/e2e/golden-team-application-browser.ts @@ -12,7 +12,7 @@ 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 { nativeRpcPath, OrganizationLifecycleCommand } from "@vektorprogrammet/rpc"; import { Match, Schema } from "effect"; @@ -383,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(" | ")}`] : [], ); diff --git a/apps/dashboard/e2e/native-content-publication.spec.ts b/apps/dashboard/e2e/native-content-publication.spec.ts index 5d555c73..636f3dbe 100644 --- a/apps/dashboard/e2e/native-content-publication.spec.ts +++ b/apps/dashboard/e2e/native-content-publication.spec.ts @@ -1,4 +1,4 @@ -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, Predicate, Schema } from "effect"; @@ -265,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); @@ -476,8 +476,8 @@ test.describe("Native Content publication (spec 0062)", () => { 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 [ @@ -542,11 +542,11 @@ 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( @@ -604,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 d9ebc494..dd9691c8 100644 --- a/apps/dashboard/e2e/native-identity-browser.spec.ts +++ b/apps/dashboard/e2e/native-identity-browser.spec.ts @@ -3,7 +3,7 @@ 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"; @@ -106,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", ); }; diff --git a/apps/dashboard/e2e/native-onboarding.spec.ts b/apps/dashboard/e2e/native-onboarding.spec.ts index 5f7183ae..3100c834 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-organization-administration.spec.ts b/apps/dashboard/e2e/native-organization-administration.spec.ts index 9c4341ad..c6cbbbf7 100644 --- a/apps/dashboard/e2e/native-organization-administration.spec.ts +++ b/apps/dashboard/e2e/native-organization-administration.spec.ts @@ -1,5 +1,5 @@ import { Option, Schema } 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 { DepartmentId } from "@vektorprogrammet/domain"; @@ -390,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); @@ -405,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([]); diff --git a/apps/dashboard/e2e/native-placement.spec.ts b/apps/dashboard/e2e/native-placement.spec.ts index 856e1028..eefbe62c 100644 --- a/apps/dashboard/e2e/native-placement.spec.ts +++ b/apps/dashboard/e2e/native-placement.spec.ts @@ -1,4 +1,4 @@ -import AxeBuilder from "@axe-core/playwright"; +import { auditSettledPage } from "./settled-axe.js"; import type { AdmissionOutcomeCommand } from "@vektorprogrammet/domain/admissions"; import { CoverageCommand, @@ -45,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) => ({ @@ -54,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") => { diff --git a/apps/dashboard/e2e/native-profile-self-edit.spec.ts b/apps/dashboard/e2e/native-profile-self-edit.spec.ts index 0f3e2e23..1f5d8c7d 100644 --- a/apps/dashboard/e2e/native-profile-self-edit.spec.ts +++ b/apps/dashboard/e2e/native-profile-self-edit.spec.ts @@ -1,7 +1,7 @@ import { IdempotencyKey, type NativeRpcClient, StrongETag } from "@vektorprogrammet/rpc"; import { nativeScriptClient } from "@vektorprogrammet/rpc/script"; import { type Effect, Predicate, Schema } from "effect"; -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 { addressesAnyRoute, legacyRoutes } from "./request-routes.js"; @@ -121,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", ); diff --git a/apps/dashboard/e2e/native-recruitment-assignment.spec.ts b/apps/dashboard/e2e/native-recruitment-assignment.spec.ts index 3b4e0484..8080b740 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 21a471f2..cd14c4f1 100644 --- a/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-first-placement.spec.ts @@ -1,7 +1,7 @@ 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 { auditSettledPage } from "./settled-axe.js"; import { PublicApplicationIdSchema } from "@vektorprogrammet/domain/application"; import { DepartmentId, PersonId } from "@vektorprogrammet/domain/organization"; import { PlacementScope } from "@vektorprogrammet/domain/placements"; @@ -389,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 689bf583..c65c77cd 100644 --- a/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-interview-conduct.spec.ts @@ -3,7 +3,7 @@ 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"; @@ -254,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(); @@ -284,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, @@ -343,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. @@ -368,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(); @@ -466,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 b8ddfccb..043be085 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 8333e548..fe01a126 100644 --- a/apps/dashboard/e2e/native-recruitment-session-journey.spec.ts +++ b/apps/dashboard/e2e/native-recruitment-session-journey.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 Request } from "@playwright/test"; @@ -227,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", @@ -270,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-schools-directory.spec.ts b/apps/dashboard/e2e/native-schools-directory.spec.ts index 19ce9b77..ff658a89 100644 --- a/apps/dashboard/e2e/native-schools-directory.spec.ts +++ b/apps/dashboard/e2e/native-schools-directory.spec.ts @@ -1,4 +1,4 @@ -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"; @@ -269,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, @@ -371,7 +371,7 @@ test.describe("Native Schools directory (spec 0061)", () => { ), 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 76587bfd..a8c2a285 100644 --- a/apps/dashboard/e2e/native-substitutes.spec.ts +++ b/apps/dashboard/e2e/native-substitutes.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"; @@ -29,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) => diff --git a/apps/dashboard/e2e/real-interview-response.spec.ts b/apps/dashboard/e2e/real-interview-response.spec.ts index c9d05796..9464d5dc 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/run-real-native-receipt-settlement.mjs b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs index df3d79c7..ee9f766f 100644 --- a/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs +++ b/apps/dashboard/e2e/run-real-native-receipt-settlement.mjs @@ -9,7 +9,7 @@ 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 { nativeRpcPath } from "@vektorprogrammet/rpc"; @@ -1401,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", ); @@ -2094,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/settled-axe.ts b/apps/dashboard/e2e/settled-axe.ts new file mode 100644 index 00000000..23f31455 --- /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/homepage/e2e/public-applicant-admission.spec.ts b/apps/homepage/e2e/public-applicant-admission.spec.ts index 48dfacfe..1e800360 100644 --- a/apps/homepage/e2e/public-applicant-admission.spec.ts +++ b/apps/homepage/e2e/public-applicant-admission.spec.ts @@ -1,4 +1,4 @@ -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 { @@ -218,14 +218,14 @@ async function submitUntyped( /** 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([]); diff --git a/apps/homepage/e2e/run-native-contact.mjs b/apps/homepage/e2e/run-native-contact.mjs index 22122435..91aa9c9b 100644 --- a/apps/homepage/e2e/run-native-contact.mjs +++ b/apps/homepage/e2e/run-native-contact.mjs @@ -11,7 +11,7 @@ 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)); @@ -564,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/docs/constructs.md b/docs/constructs.md index 01df1449..1f401443 100644 --- a/docs/constructs.md +++ b/docs/constructs.md @@ -91,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 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/browser-audit.md b/docs/constructs/browser-audit.md new file mode 100644 index 00000000..c081ad5f --- /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/tools/acceptance/password-recovery-check.ts b/tools/acceptance/password-recovery-check.ts index eaff017c..6b37a63f 100644 --- a/tools/acceptance/password-recovery-check.ts +++ b/tools/acceptance/password-recovery-check.ts @@ -29,6 +29,7 @@ import { loopbackPortFree, reserveLoopbackPorts, startDisposablePostgres } from import { drainPasswordResetMail } from "../../packages/database/src/password-recovery.js"; import { HttpMailLive } from "../../apps/backend/src/mail/http.js"; 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, @@ -564,9 +565,8 @@ const journey = Effect.gen(function* () { yield* browserStep(() => page.screenshot({ path: path.join(artifacts, "invalid-link-mobile.png") }), ); - const AxeBuilder = requireDashboard("@axe-core/playwright").default; - const axe = yield* browserStep(() => new AxeBuilder({ page }).analyze()); - assert.deepEqual(axe.violations, []); + 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 [ diff --git a/tools/acceptance/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index 608a791d..f595ebf8 100644 --- a/tools/acceptance/recommendation-check.ts +++ b/tools/acceptance/recommendation-check.ts @@ -70,6 +70,7 @@ import { type JourneyStepFailed, surfaceStepFailure } from "./journey-step.ts"; 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"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -155,8 +156,6 @@ if (process.argv.includes("--validate-fixture")) { 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, { @@ -240,16 +239,7 @@ 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 })), - })), - }), - ); + const violations = await auditSettledPage(page); accessibility.push({ state, violations }); let evidence = JSON.stringify(accessibility, null, 2); diff --git a/tools/conventions/src/constructs.ts b/tools/conventions/src/constructs.ts index 4e663e99..e43fef4e 100644 --- a/tools/conventions/src/constructs.ts +++ b/tools/conventions/src/constructs.ts @@ -57,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>; diff --git a/tools/verification/receipt-reopen-observation.ts b/tools/verification/receipt-reopen-observation.ts index 6baae9ce..2d8bfd33 100644 --- a/tools/verification/receipt-reopen-observation.ts +++ b/tools/verification/receipt-reopen-observation.ts @@ -10,6 +10,7 @@ import { Match } from "effect"; 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"; type ReceiptAction = "approve" | "reject" | "reopen" | "withdraw"; @@ -258,17 +259,9 @@ export async function observeReceiptReopening(options: { 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 })), - })); + const violations = await auditSettledPage(page); if (violations.length > 0) { await page.screenshot({ From 30bd698ebc5159ccee22979684c6cf5c96a89625 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:01 +0000 Subject: [PATCH 138/148] feat(oxlint): reject axe audits outside auditSettledPage anti-slop/no-unsettled-axe rejects new AxeBuilder(...) and any load of @axe-core/playwright (import, dynamic import, or a require call such as requireDashboard) outside apps/dashboard/e2e/settled-axe.ts, so no audit can run on a page whose transitions still move. The RuleTester test keeps negative controls beside the invalid cases. AGENTS.md records the defect class in Construction over trust with the flaky runs of 2026-09-28. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- AGENTS.md | 1 + oxlint.config.ts | 7 ++ tools/oxlint/anti-slop/UPSTREAM.txt | 1 + tools/oxlint/anti-slop/index.ts | 2 + tools/oxlint/anti-slop/provenance.json | 3 +- .../anti-slop/rules/no-unsettled-axe.test.ts | 57 +++++++++++++++++ .../anti-slop/rules/no-unsettled-axe.ts | 64 +++++++++++++++++++ 7 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 tools/oxlint/anti-slop/rules/no-unsettled-axe.test.ts create mode 100644 tools/oxlint/anti-slop/rules/no-unsettled-axe.ts diff --git a/AGENTS.md b/AGENTS.md index 48c172e2..ce1a8786 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,6 +270,7 @@ Record instances you cannot fix in `STATE.md` with their location. Remove the re | 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. | diff --git a/oxlint.config.ts b/oxlint.config.ts index 22e20d06..101983cd 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -312,6 +312,8 @@ export default defineConfig({ "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", @@ -373,6 +375,11 @@ 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"], diff --git a/tools/oxlint/anti-slop/UPSTREAM.txt b/tools/oxlint/anti-slop/UPSTREAM.txt index eacec96d..cb2365db 100644 --- a/tools/oxlint/anti-slop/UPSTREAM.txt +++ b/tools/oxlint/anti-slop/UPSTREAM.txt @@ -14,3 +14,4 @@ These files are ours now. Change a rule when it stops matching how rat-stack wor - `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 a6388d3a..16f3bb6f 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -23,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"; @@ -57,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 91fdec62..0ce800be 100644 --- a/tools/oxlint/anti-slop/provenance.json +++ b/tools/oxlint/anti-slop/provenance.json @@ -16,6 +16,7 @@ "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-rpc-path-comparison.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-unsettled-axe.test.ts b/tools/oxlint/anti-slop/rules/no-unsettled-axe.test.ts new file mode 100644 index 00000000..da9e0a0e --- /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 00000000..0d219e42 --- /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" }); + } + }, + }; + }, +}); From c77efcda187467993621733bc329e6b32b6d77d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:32 +0000 Subject: [PATCH 139/148] refactor(verification): make the receipt import rehearsal and its observations Effect programs The rehearsal owns its cluster, pool, and backend processes in its scope, runs commands through ChildProcess, reads configuration through Config, and serves the 0097 delivery sink on the Bun HttpServer that its entry provides. The delivery and reopening observations return Effects that require platform services; Playwright, pg, and the script client stay behind journey steps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../receipt-delivery-observation.ts | 739 ++++---- .../verification/receipt-import-rehearsal.ts | 1539 +++++++++-------- .../receipt-reopen-observation.ts | 1119 ++++++------ tools/verification/tsconfig.bun.json | 2 +- tools/verification/tsconfig.json | 4 +- 5 files changed, 1813 insertions(+), 1590 deletions(-) diff --git a/tools/verification/receipt-delivery-observation.ts b/tools/verification/receipt-delivery-observation.ts index 64bc37b3..64070e3b 100644 --- a/tools/verification/receipt-delivery-observation.ts +++ b/tools/verification/receipt-delivery-observation.ts @@ -1,309 +1,368 @@ /** 0097 extends the owned 0095 PostgreSQL/API rehearsal after its zero-effect import window. */ import assert from "node:assert/strict"; -import { Predicate } from "effect"; +import { Buffer } from "node:buffer"; +import { randomBytes, randomUUID } from "node:crypto"; +import { Config, Data, 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 { createServer } from "node:http"; -import { spawn } from "node:child_process"; -import { randomBytes, randomUUID } from "node:crypto"; +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", - }); +/** 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"; + +/** 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 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 backend gives up. + if (mode === "ambiguous") return yield* Effect.never; + + return HttpServerResponse.empty({ status: 202 }); + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + sinkFailures.push(String(cause)); + + return HttpServerResponse.empty({ status: 500 }); + }), + ), + ); + + yield* HttpServer.serveEffect(receive); + + 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", + }), + }; - const timer = setTimeout(() => { - child.kill("SIGTERM"); - reject(new Error("bounded drain timeout")); - }, 30_000); + /** 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)!; + }; - child.on("error", reject); - child.on("exit", (code) => { - clearTimeout(timer); - resolve(code ?? 1); + 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], - ) - ).rows; - - const keys = new Map(); - - const identity = (key: string) => { - if (!keys.has(key)) keys.set(key, randomUUID()); - - return keys.get(key)!; - }; - - const native = nativeScriptClient(origin); - 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 }, - ) => - native.call(as(session), (client) => { - const payload = { - receiptId: ReceiptId.make(receipt.id), - idempotencyKey: IdempotencyKey.make(identity(key)), - ifMatch: StrongETag.make(receipt.etag), + 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", + }), }; - return action === "approve" - ? client["receipts.approveReceipt"](payload) - : client["receipts.rejectReceipt"](payload); - }); - - const submit = async (key: string) => { - const result = await 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 }; - }; - - 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 transition("approve", approverCookie, "receipt0097-approve", failed); - - assert.equal(approval.status, 200, JSON.stringify(approval)); - 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 transition("reject", approverCookie, "receipt0097-reject", concurrent); - - assert.equal(rejected.status, 200, JSON.stringify(rejected)); - 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({ + 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", + ) + ? yield* observeReceiptReopening({ pool, origin, dashboardOrigin, @@ -319,52 +378,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 transition("reject", approverCookie, "receipt0097-denied", missing); - - 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 { - await native.dispose(); - 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 48ca2573..749b2819 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/RPC/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, @@ -58,530 +60,584 @@ 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", + }); -let pool: InstanceType | undefined; + 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 backend: ChildProcess | undefined; + if (logs.length > 30) logs.shift(); + }), + ), + Effect.ignore, + Effect.forkScoped, + ); -let evidence: Schema.Json | undefined; + return child; + }).pipe(Effect.provideService(Scope.Scope, rehearsalScope)); -let cleanupOkay = false; + /** 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; -try { - const [backendPort, dashboardPort] = await reserveLoopbackPorts(2); + yield* child.kill({ forceKillAfter: "5 seconds" }).pipe( + Effect.timeoutOrElse({ + duration: "15 seconds", + orElse: () => + Effect.fail(new ReceiptRehearsalTimeout({ message: "owned child did not exit" })), + }), + ); + }); - const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + let backend: OwnedChild | undefined; - postgres = await startDisposablePostgres({ database: "receipt_0095" }); - const pgUrl = postgres.url; - pool = new Pool({ connectionString: pgUrl }); + const [backendPort, dashboardPort] = yield* step(() => reserveLoopbackPorts(2)); - const databaseLayer = DatabaseLive({ url: Redacted.make(pgUrl), maxConnections: 2 }).pipe( - Layer.provide(BunServices.layer), - ); + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; - 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 postgres = yield* Effect.acquireRelease( + step(() => startDisposablePostgres({ database: "receipt_0095" })), + (owned) => Effect.promise(() => owned.stop()), + ); + + 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({ claimId: "0095-guard", claimedAt: 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 = firstSetCookie(response); + assert.ok(cookie !== undefined && cookie.length > 0); + + return cookie; }); - assert.equal(response.status, 200); - const cookie = response.headers.get("set-cookie")?.split(";")[0]; - assert.ok(cookie); + yield* Console.log("0095 backend ready"); - return cookie; - }; + const cookie = yield* signIn(persons[0]!.email), + foreign = yield* signIn(persons[1]!.email); - await Effect.runPromise(Console.log("0095 backend ready")); + secretValues.push( + cookie, + foreign, + ...[cookie, foreign].map((value) => value.slice(value.indexOf("=") + 1)), + ); + const native = nativeScriptClient(backendOrigin); - const cookie = await signIn(persons[0]!.email), - foreign = await signIn(persons[1]!.email); + const ownerHeaders = { cookie, origin: dashboardOrigin }; - secretValues.push( - cookie, - foreign, - ...[cookie, foreign].map((value) => value.slice(value.indexOf("=") + 1)), - ); - const native = nativeScriptClient(backendOrigin); - const ownerHeaders = { cookie, origin: dashboardOrigin }; - - const reconciliationDiagnostics: Array<{ - sourcePrimaryKey: string; - phase: string; - reason: string; - }> = []; - - const reconcile = async (result: (typeof accepted)[number]) => { - let phase = "persisted fact comparison"; - - const observed = await run( - reconcileReceiptImport(result, () => - Effect.tryPromise({ - try: async () => { + const reconciliationDiagnostics: Array<{ + sourcePrimaryKey: string; + phase: string; + reason: string; + }> = []; + + 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 native.call(ownerHeaders, (client) => - client["receipts.listReceipts"]({}), + + const list = yield* step(() => + native.call(ownerHeaders, (client) => client["receipts.listReceipts"]({})), ); assert.ok( @@ -593,286 +649,308 @@ try { 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 native.call(ownerHeaders, (client) => - client["receipts.readReceiptFile"]({ - receiptId: ReceiptId.make(result.receipt.receiptId), - }), + 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; - }, - 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)), - ), - ); + }).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; + }), + ), + ), + ), + ); + + 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`, + ); + } - if (!observed && phase === "persisted fact comparison") { - reconciliationDiagnostics.push({ + return observed; + }); + + const reconciliations = []; + + for (const result of accepted) { + assert.equal(yield* reconcile(result), true); + reconciliations.push({ sourcePrimaryKey: result.sourcePrimaryKey, - phase, - reason: "persisted canonical fact differs from source", + sourceOccurrence: result.sourceOccurrence, + sourceDigest: result.provenance.sourceDigest, + reconciled: true, }); - logs.push( - `reconciliation ${result.sourcePrimaryKey}: persisted canonical fact differs from source`, - ); + + 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); + } } - return observed; - }; + yield* Console.log("0095 owner reads and denials passed"); - const reconciliations = []; + 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"), + }, + }; - 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, - }); + 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; + + assert.equal(collisionLedger[0]?.result, "Quarantined"); + assert.ok(collisionLedger[0]?.reasons_json.reasons.includes("DestinationIdentityCollision")); - for (const deniedCookie of [foreign, undefined]) { - const denied = await native.call( - deniedCookie === undefined - ? { origin: dashboardOrigin } - : { cookie: deniedCookie, origin: dashboardOrigin }, + const invalidBearer = yield* step(() => + native.call( + { cookie, authorization: "Bearer invalid-synthetic-0095", origin: dashboardOrigin }, (client) => client["receipts.readReceiptFile"]({ - receiptId: ReceiptId.make(result.receipt.receiptId), + receiptId: ReceiptId.make(accepted[0]!.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); - } - } + assert.equal(invalidBearer.status, 401); + const stable = yield* snapshot(); - 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( - "SELECT result,reasons_json FROM economy_receipt_import_ledger WHERE source_primary_key='destination-collision'", - ) - ).rows; + const fileDigest = Effect.fnUntraced(function* () { + const values = []; - assert.equal(collisionLedger[0]?.result, "Quarantined"); - assert.ok(collisionLedger[0]?.reasons_json.reasons.includes("DestinationIdentityCollision")); + for (const result of accepted) + values.push( + digest( + yield* fileSystem.readFile( + join(env.RECEIPT_COMMITTED_ROOT, result.receipt.file.objectKey), + ), + ), + ); - const invalidBearer = await native.call( - { cookie, authorization: "Bearer invalid-synthetic-0095", origin: dashboardOrigin }, - (client) => - client["receipts.readReceiptFile"]({ - receiptId: ReceiptId.make(accepted[0]!.receipt.receiptId), - }), - ); + return values; + }); - assert.equal(invalidBearer.status, 401); - const stable = await snapshot(); + 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 fileDigest = async () => { - const values = []; + 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()), + ); - for (const result of accepted) - values.push( - digest(await readFile(join(env.RECEIPT_COMMITTED_ROOT, result.receipt.file.objectKey))), - ); + 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 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); - // 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. - await native.dispose(); - 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); + 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", @@ -882,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 6baae9ce..9561d096 100644 --- a/tools/verification/receipt-reopen-observation.ts +++ b/tools/verification/receipt-reopen-observation.ts @@ -1,18 +1,24 @@ /** 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 { Match } from "effect"; +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 { 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}`); @@ -20,538 +26,613 @@ const resourceOf = (result: ScriptCallResult, label: string): A => { return result.value; }; -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 native = nativeScriptClient(origin); - const as = (session: string) => ({ cookie: session, origin: dashboardOrigin }); - - /** 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(), - ) => - 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 = 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 resource = resourceOf( - await 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 }; - }; - - const rejected = async () => { - const receipt = await submit(); - const response = resourceOf(await request(receipt.id, "reject", receipt.etag), "rejection"); - - return { ...receipt, initialEtag: receipt.etag, etag: response.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. 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", await request(target.id, "reopen", target.etag, cookie), 403], - ["stale revision", await request(target.id, "reopen", target.initialEtag), 412], - ] 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()"); +/** 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; +} - const reopened = resourceOf( - await request(target.id, "reopen", target.etag, approverCookie, retryKey), - "reopening", - ); +/** 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; - assert.equal(reopened.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 = resourceOf( - await request(target.id, "reopen", target.etag, approverCookie, retryKey), - "reopening replay", - ); + const native = yield* Effect.acquireRelease( + Effect.sync(() => nativeScriptClient(origin)), + (client) => Effect.promise(() => client.dispose()), + ); - assert.deepEqual(replay, reopened); - assert.deepEqual(await snapshot(target.id), after); - assert.equal( - (await request(target.id, "reopen", reopened.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.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"] as const) { - const receipt = await submit(); - - const closed = await request( - receipt.id, - action, - receipt.etag, - action === "withdraw" ? cookie : approverCookie, - ); - - const closedEtag = resourceOf(closed, `${action} of a pending receipt`).etag; - const closedBefore = await snapshot(receipt.id); - assert.equal((await request(receipt.id, "reopen", closedEtag)).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, + 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 [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 { default: AxeBuilder } = requireDashboard("@axe-core/playwright"); + + const checkAxe = Effect.fnUntraced(function* (page: any, gate: string) { + yield* step(() => + page.evaluate( + "Promise.all(document.getAnimations().map(animation => animation.finished.catch(() => {})))", + ), + ); + + const violations = (yield* step(() => 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) { + yield* step(() => + page.screenshot({ + path: path.join(options.artifactDirectory, "0102-browser-failure.png"), + fullPage: true, }), ); + + return yield* new ReceiptReopenObservationFailed({ + message: yield* jsonText({ gate, violations }), + }); } }); - 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, + // 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; }); - 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, + + 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, }), ); - } - - 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(); - await native.dispose(); - - 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); - } - } - } -} + 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, + }); + 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, + }), + ); + 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/tsconfig.bun.json b/tools/verification/tsconfig.bun.json index fc2278cd..da5f0b01 100644 --- a/tools/verification/tsconfig.bun.json +++ b/tools/verification/tsconfig.bun.json @@ -3,7 +3,7 @@ "compilerOptions": { "types": ["node", "bun"] }, - "files": ["completion-receipt-postgres-proof-main.ts"], + "files": ["completion-receipt-postgres-proof-main.ts", "receipt-import-rehearsal.ts"], "include": [], "exclude": [] } diff --git a/tools/verification/tsconfig.json b/tools/verification/tsconfig.json index 9c9d17e7..b9d124e0 100644 --- a/tools/verification/tsconfig.json +++ b/tools/verification/tsconfig.json @@ -5,6 +5,6 @@ "types": ["node"] }, "include": ["*.ts"], - // The loopback receiver of this proof is a Bun HTTP server, whose types tsconfig.bun.json adds. - "exclude": ["completion-receipt-postgres-proof-main.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"] } From d00e28493fc87d520677e29bed8eb1a39346fd2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:56:33 +0000 Subject: [PATCH 140/148] refactor(e2e): make the native recruitment assignment journey an Effect program The runner becomes TypeScript: children run through ChildProcess in owned scopes, the recording proxy serves through Bun.serve and forwards with HttpClient from a FiberSet runtime, ports and executables come from Config, and the psql and browser evidence decode through Schema. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- apps/dashboard/package.json | 2 +- docs/web-system-functional-testing.md | 2 +- oxlint.config.ts | 1 + ...run-real-native-recruitment-assignment.mjs | 1093 -------------- .../run-real-native-recruitment-assignment.ts | 1312 +++++++++++++++++ tools/e2e/tsconfig.bun.json | 2 +- 6 files changed, 1316 insertions(+), 1096 deletions(-) delete mode 100644 tools/e2e/run-real-native-recruitment-assignment.mjs create mode 100644 tools/e2e/run-real-native-recruitment-assignment.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 39925067..f9594507 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -34,7 +34,7 @@ "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", diff --git a/docs/web-system-functional-testing.md b/docs/web-system-functional-testing.md index 767d9392..e8cea558 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. diff --git a/oxlint.config.ts b/oxlint.config.ts index e0f211bb..569a6873 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -176,6 +176,7 @@ const effectConfig = { "tools/e2e/golden-harness.ts", "tools/e2e/golden-harness-self-test.ts", "tools/e2e/golden-reimbursement.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/public-application-outbox-driver.ts", 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 83969d23..00000000 --- a/tools/e2e/run-real-native-recruitment-assignment.mjs +++ /dev/null @@ -1,1093 +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"; -import { replacedAnswer, replacedRequest } from "../../apps/dashboard/e2e/native-rpc-ledger.ts"; -import { isNativeRpcPath } from "../../apps/dashboard/e2e/native-operations.ts"; - -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", -}; - -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, - }; - - 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.pathAndQuery = - rpc.tag === "recruitment.readAssignmentBoard" && Predicate.isString(rpc.payload.status) - ? `${rpc.path}?status=${rpc.payload.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); - - 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"); - - 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; - } - - 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, - }; - - - // 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], - // 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) { - 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 00000000..9e817e48 --- /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/tsconfig.bun.json b/tools/e2e/tsconfig.bun.json index 7ea7361b..3b801204 100644 --- a/tools/e2e/tsconfig.bun.json +++ b/tools/e2e/tsconfig.bun.json @@ -3,6 +3,6 @@ "compilerOptions": { "types": ["node", "bun"] }, - "files": ["safe-file-io.ts", "golden-reimbursement.ts"], + "files": ["safe-file-io.ts", "golden-reimbursement.ts", "run-real-native-recruitment-assignment.ts"], "include": [] } From cb9ec066c1617e16ddfbb6817c7d6666ae0551f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:08:05 +0000 Subject: [PATCH 141/148] fix(verification): release withheld delivery sink requests before the sink stops Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .../verification/receipt-delivery-observation.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/verification/receipt-delivery-observation.ts b/tools/verification/receipt-delivery-observation.ts index 64070e3b..42ce4d1f 100644 --- a/tools/verification/receipt-delivery-observation.ts +++ b/tools/verification/receipt-delivery-observation.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; import { randomBytes, randomUUID } from "node:crypto"; -import { Config, Data, type Duration, Effect, Option, Predicate, Schema } from "effect"; +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"; @@ -72,6 +72,8 @@ export const observeReceiptDelivery = (options: ReceiptDeliveryOptions(); + const receive = Effect.gen(function* () { const req = yield* HttpServerRequest.HttpServerRequest; @@ -101,8 +103,13 @@ export const observeReceiptDelivery = (options: ReceiptDeliveryOptions(options: ReceiptDeliveryOptions Deferred.succeed(withheld, undefined)); + const { address } = yield* HttpServer.HttpServer; if (Predicate.isTagged(address, "UnixPathAddress")) From a08f35221d9317a347afc83d02345b0a94afd4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:08:14 +0000 Subject: [PATCH 142/148] refactor(scripts): run the dev launcher, the model checks, and the preview deployment as Effect programs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 3 + tools/scripts/deploy-preview.ts | 349 +++++++++++++++++--------------- tools/scripts/dev.ts | 310 ++++++++++++++-------------- tools/scripts/job-process.ts | 32 ++- tools/scripts/model.ts | 302 ++++++++++++++++----------- 5 files changed, 548 insertions(+), 448 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 7b6e432b..b0978c2e 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -184,9 +184,12 @@ const effectConfig = { "tools/e2e/run-legacy-*.ts", "tools/conventions/src/cli.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", diff --git a/tools/scripts/deploy-preview.ts b/tools/scripts/deploy-preview.ts index c341a93e..c09ebcb2 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 a1ed39e8..ef80d150 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/job-process.ts b/tools/scripts/job-process.ts index 83ed408c..1cc2f154 100644 --- a/tools/scripts/job-process.ts +++ b/tools/scripts/job-process.ts @@ -16,16 +16,24 @@ export interface JobExit { 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, the variables that it adds to the environment, and the signals forwarded to it. */ +/** 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; } @@ -38,17 +46,29 @@ export const runJob = ({ command, arguments: jobArguments, variables, + isolated = false, + cwd, forwardedSignals, -}: Job): Effect.Effect => +}: Job): Effect.Effect => Effect.uninterruptible( - Effect.callback((resume) => { + Effect.callback((resume) => { const child = spawn(command, jobArguments, { stdio: "inherit", - env: { ...process.env, ...variables }, + env: isolated ? { ...variables } : { ...process.env, ...variables }, + cwd, }); + let forwarded: NodeJS.Signals | null = null; + const handlers = forwardedSignals.map( - (signal) => [signal, () => child.kill(signal)] as const, + (signal) => + [ + signal, + () => { + forwarded ??= signal; + child.kill(signal); + }, + ] as const, ); for (const [signal, handler] of handlers) process.on(signal, handler); @@ -64,7 +84,7 @@ export const runJob = ({ child.once("exit", (exitCode, signal) => { stop(); - resume(Effect.succeed({ exitCode, signal })); + resume(Effect.succeed({ exitCode, signal, forwarded })); }); }), ); diff --git a/tools/scripts/model.ts b/tools/scripts/model.ts index 335ee658..b626b830 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, +); From 7589d4650dc67be9ed4977786cd7714888645ffa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:14:14 +0000 Subject: [PATCH 143/148] refactor(placements-docs): generate, check, and accept the reference as Effect programs The artifact checks and the subprocess runner become TypeScript modules that return Effects; the CI job runs the renamed test file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .github/workflows/tests.yml | 2 +- oxlint.config.ts | 2 + tools/placements-docs/placements-artifact.mjs | 99 ----- .../placements-artifact.test.mjs | 248 ----------- .../placements-artifact.test.ts | 301 +++++++++++++ tools/placements-docs/placements-artifact.ts | 326 ++++++++++++++ tools/placements-docs/placements-process.mjs | 73 ---- tools/placements-docs/placements-process.ts | 114 +++++ tools/placements-docs/placements.ts | 412 +++++++++++------- 9 files changed, 990 insertions(+), 587 deletions(-) delete mode 100644 tools/placements-docs/placements-artifact.mjs delete mode 100644 tools/placements-docs/placements-artifact.test.mjs create mode 100644 tools/placements-docs/placements-artifact.test.ts create mode 100644 tools/placements-docs/placements-artifact.ts delete mode 100644 tools/placements-docs/placements-process.mjs create mode 100644 tools/placements-docs/placements-process.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index db6d18a5..351e5cfb 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 diff --git a/oxlint.config.ts b/oxlint.config.ts index b0978c2e..1d100d1c 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -183,6 +183,7 @@ const effectConfig = { "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", @@ -212,6 +213,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", ], diff --git a/tools/placements-docs/placements-artifact.mjs b/tools/placements-docs/placements-artifact.mjs deleted file mode 100644 index faa14178..00000000 --- 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 d0fd19d8..00000000 --- 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 00000000..32c352a8 --- /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 00000000..194fe1b8 --- /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 e4dc2306..00000000 --- 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 00000000..ee7e4752 --- /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 511c4a3c..d1ef56d6 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 }); From 0fd94400ffbca6c64754b144e2d520990c3504b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:14:40 +0000 Subject: [PATCH 144/148] refactor(acceptance): run the recommendation probe as one Effect program The recommendation probe and its helpers (returning assistant, report observer, co-interviewer correction, correction boundaries) become Effect programs on the Bun platform: ChildProcess through startOwnedProcess and commandOutput, HttpClient for fetch, FileSystem and Path, Config, DateTime, Effect.sleep and timeouts, Console through one ordered output queue, and typed failures. owned-process.ts and its test go; EX-0009 retires, and EX-0013 registers the page fetch of the returning options probe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/effect-exceptions.json | 56 +- .../co-interviewer-correction-0106.ts | 770 +-- .../interview-correction-boundaries.ts | 976 ++-- .../interview-report-observation.ts | 1047 ++-- tools/acceptance/journey-step.ts | 14 +- tools/acceptance/owned-process.test.ts | 25 - tools/acceptance/owned-process.ts | 14 - tools/acceptance/recommendation-check.ts | 5126 +++++++++-------- .../acceptance/returning-assistant-journey.ts | 2344 ++++---- 9 files changed, 5517 insertions(+), 4855 deletions(-) delete mode 100644 tools/acceptance/owned-process.test.ts delete mode 100644 tools/acceptance/owned-process.ts diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index d61b942b..e234cc8c 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -163,39 +163,6 @@ }, "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"], - "scope": { - "files": ["tools/acceptance/recommendation-check.ts"], - "symbols": [ - "recordGate", - "stage", - "ReturningLoginProbeComplete", - "ReturningTargetedComplete", - "ApplicantProgressTargetedComplete", - "CoInterviewerTargetedComplete", - "CorrectionTargetedComplete", - "failureEvidence" - ] - }, - "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.", - "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." - ], - "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." - ], - "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." - }, { "id": "EX-0010", "rules": ["FX002", "effecttsgo/missing-pipeable-signature"], @@ -269,6 +236,29 @@ "@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/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." } ] } diff --git a/tools/acceptance/co-interviewer-correction-0106.ts b/tools/acceptance/co-interviewer-correction-0106.ts index db82a584..12dde2dd 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,8 +10,10 @@ import { } from "./interview-correction-boundaries.ts"; import { assertInterviewCorrectionIntegrity } from "./interview-correction-integrity.ts"; import type { CoInterviewerCorrection0106Fixture } from "./recommendation-preupgrade-fixture.ts"; -import { Effect, Predicate, Schema } from "effect"; -import { surfaceStepFailure } from "./journey-step.ts"; +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"; @@ -33,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; @@ -77,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; }>; @@ -108,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, @@ -128,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 = {}; @@ -165,28 +171,34 @@ export async function runCoInterviewerCorrectionJourney( cookie, ); - const getDetail = async (cookie: string): Promise => { - const response = await readConduct(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 recruitment("recruitment.readSchedulingBoard", null, 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( @@ -211,101 +223,104 @@ export async function runCoInterviewerCorrectionJourney( 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, @@ -324,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) => { @@ -371,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)); @@ -403,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, { @@ -456,120 +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 readConduct(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 readConduct(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( @@ -577,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), @@ -609,115 +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 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, - }), - }, - co.cookie, + 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, + }), + }, + 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 recruitment( - "recruitment.cancelInterview", - { - interviewId: fixture.targetInterviewId, - idempotencyKey: freshId("co-interviewer-cancel"), - ifMatch: afterBrowserCorrection.etag, - }, - co.cookie, + const cancellation = yield* step(() => + recruitment( + "recruitment.cancelInterview", + { + interviewId: fixture.targetInterviewId, + idempotencyKey: freshId("co-interviewer-cancel"), + ifMatch: afterBrowserCorrection.etag, + }, + 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 recruitment( - "recruitment.scheduleInterview", - { - interviewId: fixture.targetInterviewId, - idempotencyKey: freshId("co-interviewer-schedule"), - ifMatch: boardItem(await getBoard(co.cookie)).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.", + 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.", + }, }, - }, - co.cookie, + 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, @@ -736,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()) { @@ -790,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, @@ -801,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, @@ -816,40 +871,41 @@ export async function runCoInterviewerCorrectionJourney( recordGate, }); - await Effect.runPromise( - surfaceStepFailure( - 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); + yield* assertInterviewCorrectionIntegrity({ + pool, + interviewId: fixture.targetInterviewId, + expectedCorrectedByPersonId: fixture.coInterviewer.personId, + expectedCoInterviewerPersonId: fixture.coInterviewer.personId, + }); + 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"); @@ -861,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 8fc10e9e..cabff4c6 100644 --- a/tools/acceptance/interview-correction-boundaries.ts +++ b/tools/acceptance/interview-correction-boundaries.ts @@ -1,14 +1,13 @@ import { decodePostgresObservations, type PostgresObservation } from "./postgres-observation.js"; import { NativeProblem } from "../../packages/rpc/src/problem.js"; -import { - replacedFetch, - replacedHttpResponse, -} from "../../apps/dashboard/e2e/native-rpc-ledger.js"; +import { replacedFetch, replacedHttpResponse } from "../../apps/dashboard/e2e/native-rpc-ledger.js"; import { RecruitmentInterviewConductObservationSchema } from "../../packages/domain/src/recruitment/schema.js"; -import { Option, 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; @@ -54,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, @@ -93,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", @@ -104,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( @@ -113,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, @@ -152,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, ); @@ -210,106 +237,128 @@ export async function assertInterviewCorrectionBoundaries( _key: string, requestCookie: string | null = cookie, ) => - fetch(`${api}/api/rpc`, { - method: "POST", - headers: { ...headers(requestCookie), "content-type": "application/json" }, - body, - }).then(replacedHttpResponse); + 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, @@ -331,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, @@ -389,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 @@ -519,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 @@ -624,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"); @@ -637,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, @@ -653,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], + ), + ); - 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 exactReplayWaiting = yield* Effect.forkChild( + step(() => + post( + selfLinkRaceInterviewId, + acceptedRaceReplay.payload, + acceptedRaceReplay.etag, + acceptedRaceReplay.key, + ), + ), + ); + + 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( @@ -832,21 +888,23 @@ export async function assertInterviewCorrectionBoundaries( ); 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); @@ -855,7 +913,7 @@ export async function assertInterviewCorrectionBoundaries( // 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 = await response.text(); + const refusal = yield* step(() => response.text()); assert.equal(response.status, 400, refusal); @@ -864,33 +922,30 @@ export async function assertInterviewCorrectionBoundaries( assert.equal( response.status, semanticInvalidCases.has(name) ? 422 : 500, - await response.text(), + yield* step(() => response.text()), ); } - await assertSnapshot(before, `invalid ${name}`); + yield* assertSnapshot(before, `invalid ${name}`); } record( "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", ); @@ -907,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 @@ -927,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(); - - const response = await post( - interviewId, - currentPayload, - current.etag, - freshId(`correction-boundary-rollback-${name}`), - ); + yield* step(() => rollbackClient.query("COMMIT")); + }), + ); - status(`rollback:${name}`, response.status); + 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")); + }), + ); - const failureBody = Schema.decodeUnknownSync(NativeProblem)(await response.json()); + const before = yield* snapshot(); - assert.equal(response.status, 503, JSON.stringify(failureBody)); - assert.equal(failureBody.code, "dependency.unavailable", JSON.stringify(failureBody)); + const response = yield* step(() => + post( + interviewId, + currentPayload, + current.etag, + freshId(`correction-boundary-rollback-${name}`), + ), + ); - const marker = await pool.query( - `SELECT last_value, is_called FROM public.${quote(rollbackSequence)}`, - ); + status(`rollback:${name}`, response.status); - 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}`); - } + const failureBody = yield* Schema.decodeUnknownEffect(NativeProblem)( + yield* step(() => response.json()), + ); - 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(); + assert.equal(response.status, 503, yield* jsonText(failureBody)); + assert.equal(failureBody.code, "dependency.unavailable", yield* jsonText(failureBody)); - 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-report-observation.ts b/tools/acceptance/interview-report-observation.ts index aca9e829..aee63c72 100644 --- a/tools/acceptance/interview-report-observation.ts +++ b/tools/acceptance/interview-report-observation.ts @@ -3,15 +3,31 @@ 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, @@ -84,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", @@ -107,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, @@ -130,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'", { @@ -148,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[] = []; @@ -161,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 }, @@ -195,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, @@ -209,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", }); @@ -230,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", @@ -239,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'", { @@ -255,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'", { @@ -273,45 +322,52 @@ 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( - Schema.decodeUnknownSync(Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }))( - await signIn.json(), - ).user.id, + (yield* Schema.decodeUnknownEffect( + Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }), + )(yield* signIn.json)).user.id, ids.person, ); @@ -319,28 +375,27 @@ export async function observeInterviewReport(o: Options) { * 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) => { - const headers = new Headers({ origin: ui, "content-type": "application/json" }); - - if (session !== "") headers.set("cookie", session); - - return fetch(`${api}${nativeRpcPath}`, { - method: "POST", - headers, - body: nativeRpcRequestBody(tag, payload), - }).then((response) => - response.text().then((text) => { - const outcome = nativeRpcOutcome(text); - - return { - status: nativeRpcStatus(text) ?? response.status, - value: nativeRpcValue(text), - problem: Predicate.isTagged(outcome, "Problem") ? outcome.problem : undefined, - text, - }; - }), - ); - }; + 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 get = (query: Record = {}, session = cookie) => rpc("recruitment.readInterviewReport", query, session); @@ -355,19 +410,19 @@ export async function observeInterviewReport(o: Options) { return response.problem.code; }; - const read = async (query: Record = {}): Promise => { - const response = await get(query); + const read = Effect.fnUntraced(function* (query: Record = {}) { + const response = yield* get(query); if (response.status !== 200) { - const authority = ( - await pool.query( + 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, @@ -377,52 +432,52 @@ export async function observeInterviewReport(o: Options) { ); } - return Schema.decodeUnknownSync(InterviewReport)(response.value, { + 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 report: InterviewReport = await read( - Schema.encodeSync(InterviewReportQuery)( - Schema.decodeSync(InterviewReportQuery)({ admissionPeriodId: ids.period }), + 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(), @@ -455,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, @@ -472,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], @@ -487,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), @@ -501,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), @@ -516,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( @@ -535,11 +590,11 @@ 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( @@ -548,17 +603,17 @@ export async function observeInterviewReport(o: Options) { session, ); - const assignedDetailResponse = await conduct(o.ordinaryCookie); + const assignedDetailResponse = yield* conduct(o.ordinaryCookie); assert.equal(assignedDetailResponse.status, 200, assignedDetailResponse.text); - const assignedResource = Schema.decodeUnknownSync( + const assignedResource = yield* Schema.decodeUnknownEffect( Schema.Struct({ detail: Schema.Json, etag: Schema.String }), )(assignedDetailResponse.value); - const assignedDetail = Schema.decodeUnknownSync(RecruitmentInterviewConductObservationSchema)( - assignedResource.detail, - ); + const assignedDetail = yield* Schema.decodeUnknownEffect( + RecruitmentInterviewConductObservationSchema, + )(assignedResource.detail); const assignedETag = assignedResource.etag; assert.ok(Predicate.isString(assignedETag)); @@ -570,17 +625,17 @@ export async function observeInterviewReport(o: Options) { ); assert.notEqual(assignedDetail.recommendation, null); - const coordinatorDetailResponse = await conduct(cookie); + const coordinatorDetailResponse = yield* conduct(cookie); assert.equal(coordinatorDetailResponse.status, 403, coordinatorDetailResponse.text); - const coordinatorCorrectionResponse = await rpc( + const coordinatorCorrectionResponse = yield* rpc( "recruitment.correctInterviewAssessment", { interviewId: "interview-native-conduct-a-0063", idempotencyKey: "report-coordinator-correction-denied-0103", ifMatch: assignedETag, - request: Schema.encodeSync(Schema.Json)({ + request: yield* Schema.encodeEffect(Schema.Json)({ expectedRevision: assignedDetail.revision, answers: assignedDetail.answers, score: assignedDetail.score, @@ -591,57 +646,61 @@ export async function observeInterviewReport(o: Options) { ); assert.equal(coordinatorCorrectionResponse.status, 403, coordinatorCorrectionResponse.text); - assert.deepEqual(await snapshot(), before); + 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 conduct(cookie)).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((await get({ departmentId: ids.department })).status, 200); + assert.equal((yield* get({ departmentId: ids.department })).status, 200); for (const [key, value] of [ ["sort", "bogus"], ["recommendation", ""], ] as const) - assert.equal((await get({ [key]: value })).status, 500); - 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"); // 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); + const denial = yield* get({ admissionPeriodId: ids.period }, cookie); const status = denial.status; assert.ok([401, 403].includes(status)); 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"], @@ -649,23 +708,23 @@ 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({ @@ -673,100 +732,110 @@ export async function observeInterviewReport(o: Options) { status: ambiguousStatus, 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( - !Schema.decodeUnknownSync(InterviewReport)(response.value).rows.some( - (r) => 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") { @@ -775,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 [ @@ -790,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}`, @@ -821,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 }); @@ -951,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: | { @@ -975,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"); @@ -1043,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", @@ -1084,34 +1209,40 @@ export async function observeInterviewReport(o: Options) { 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 index 016ade86..ed9de5f3 100644 --- a/tools/acceptance/journey-step.ts +++ b/tools/acceptance/journey-step.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, type PlatformError, Schema } from "effect"; +import { Cause, Effect, FileSystem, type PlatformError, Schema } from "effect"; import { dual } from "effect/Function"; import type { HttpClientResponse } from "effect/unstable/http"; @@ -32,6 +32,18 @@ export const surfaceStepFailure = ( 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; diff --git a/tools/acceptance/owned-process.test.ts b/tools/acceptance/owned-process.test.ts deleted file mode 100644 index 0a8e56c4..00000000 --- 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 638197d9..00000000 --- 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/recommendation-check.ts b/tools/acceptance/recommendation-check.ts index 608a791d..c4586228 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,29 +64,30 @@ 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, Effect, Layer, Option, Predicate, Redacted, Schema } from "effect"; -import * as BunServices from "@effect/platform-bun/BunServices"; -import process from "node:process"; -import { type JourneyStepFailed, surfaceStepFailure } from "./journey-step.ts"; 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 { FetchHttpClient, type HttpClient } from "effect/unstable/http"; const root = new URL("../../", import.meta.url).pathname; @@ -80,23 +97,6 @@ const uiRequire = createRequire(new URL("../../apps/dashboard/package.json", imp const { Pool } = dbRequire("pg"); -/** - * Runs one Effect program of the journey helpers with the Bun platform and a fetch HTTP client. - * A failed driver step rejects with the error that the driver raised. - */ -const runJourneyEffect = ( - effect: Effect.Effect< - A, - E | JourneyStepFailed, - BunServices.BunServices | HttpClient.HttpClient - >, -): Promise => - Effect.runPromise( - surfaceStepFailure(effect).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), - ), - ); - const fixtureKeys = { invalid0: "invalid-recommendation-0101-0", invalid1: "invalid-recommendation-0101-1", @@ -111,155 +111,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 ( @@ -283,14 +151,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 ""; @@ -299,84 +159,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(); +const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json)); - 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); - } +/** The value of a JSON text, decoded through Schema. */ +const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); - 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 JSON value of a JSON text, decoded through Schema. */ +const decodeJsonValue = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json)); - const listening = Promise.withResolvers(); - server.once("error", listening.reject); - server.listen(portNumber, "127.0.0.1", listening.resolve); - await listening.promise; - - 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", @@ -403,2493 +207,2843 @@ 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(Layer.merge(gateway, 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"; - } +const program = Effect.gen(function* () { + const coInterviewerMode = process.argv.includes("--co-interviewer-mode"); + const applicantProgressMode = process.argv.includes("--applicant-progress-mode"); - 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", - }); + if (process.argv.includes("--report")) validateInterviewReportFixture(); - 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 runJourneyEffect(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"), - ); + if (process.argv.includes("--validate-fixture")) { + yield* Console.log("All recommendation fixture idempotency keys satisfy the canonical schema"); - if (coInterviewerMode) { - coInterviewerFixture = await runJourneyEffect(seedCoInterviewerCorrection0106Fixture({ pool })); - recordGate( - "seeded synthetic 0106 co-interviewer designation after the canonical migration chain", - ); + return; } - 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 }); + const { chromium } = uiRequire("@playwright/test"); + const AxeBuilder = uiRequire("@axe-core/playwright").default; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; - if (effectMode === "http") { - effectServer = await startEffectReceiver(effectToken, effectPort, (interviewId, capability) => { - invitationCapabilities.set(interviewId, capability); - secrets.push(capability); - }); - secrets.push(effectToken); - } + const run: RunCommand = (command, args, env = {}, cwd = root) => + commandOutput({ command, args, cwd, env, deadline: "180 seconds", stderr: "pipe" }); - 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, - })), - ); - }; + assert.equal( + (yield* run("git", ["status", "--porcelain", "--", ".", ":(exclude).serena"])).trim(), + "", + ); - const effectsBefore = await effectSnapshot(); + 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 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], - ); - }; + return yield* new ProbeFailure({ message: "Readiness failed" }); + }); - await pool.query( - `INSERT INTO public.person_profiles(person_id,first_name,last_name,revision) VALUES('recommendation-other-0101','Other','Interviewer',0)`, + 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, ); - await link("self", "journey-conduct-leader-0063"); - await link("maybe", "recommendation-other-0101"); - if (applicantProgressMode) { - await runJourneyEffect(seedApplicantProgress0107(pool)); - recordGate("seeded synthetic current-semester applicant progress states"); - secrets.push( - applicantProgressUnlinkedIdentity.email, - applicantProgressUnlinkedIdentity.password, - ); - } + const say = (line: string) => { + Queue.offerUnsafe(lines, line); + }; - 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")], - ); + /** Ends standard output and waits until every reported line is written. */ + const flushOutput = Queue.end(lines).pipe(Effect.andThen(Fiber.join(printer))); - backend = start("bun", ["apps/backend/src/main.ts"], env); - await ready(async () => (await fetch(`${api}/health`)).ok); + const recordGate = (...observations: string[]) => { + gates.push(...observations); + say(JSON.stringify({ observed: observations })); + }; - 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]; + const auditPage = (auditedPage: any, state: string) => + Effect.gen(function* () { + const analysis = yield* step(() => new AxeBuilder({ page: auditedPage }).analyze()); - assert.deepEqual(historicalAfter.value, historicalBefore); - assert.equal(historicalAfter.recommendation, null); - recordGate( - "immutable historical row survived actual0037 upgrade without invented recommendation", - ); - assert.ok(correctionPre0039Fixture); - await runJourneyEffect( - assertInterviewCorrectionPre0039Preserved({ connection: pool, fixture: correctionPre0039Fixture }), - ); - recordGate( - "0039/0040 upgrades preserved original interview/schedule/invitation/conduct/lifecycle rows", - ); - 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 violations: ReadonlyArray = analysis.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 })), + })), + })); - const password = "journey-conduct-secret-0123456789", - email = "lina.conduct@example.invalid"; + accessibility.push({ state, violations }); + let evidence = yield* indentedJsonText(accessibility); - secrets.push(password, email); + for (const secret of secrets) evidence = evidence.replaceAll(secret, "[redacted]"); + yield* fs.writeFileString(path.join(artifacts, "accessibility.json"), evidence); - const credentialCheck = await fetch(`${api}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: ui, "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + return violations; + }); - if (!credentialCheck.ok) { - const failure = await credentialCheck.json(); - throw new Error(`Native sign-in failed: ${credentialCheck.status} ${JSON.stringify(failure)}`); - } + /** 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); - 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); - } + if ((yield* fs.stat(file)).type === "File" && !name.endsWith(".png")) { + const text = yield* fs.readFileString(file); - 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) + assert.ok(!text.includes(secret), `retained artifact ${name} contains a credential`); + } + } + }); - 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'`, + /** 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)), ); - const journey = await runJourneyEffect(runApplicantProgress0107({ - pool, - page, - cookie, - api, - ui, - artifacts, - errors, - audit: (journeyPage) => auditPage(journeyPage, "applicant-progress"), - })); - - recordGate("observed applicant-owned progress through the real browser/API/PostgreSQL path"); + // 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 ApplicantProgressTargetedComplete(journey); - } + const postgres = yield* Effect.acquireRelease( + step(() => startDisposablePostgres()), + (cluster) => step(() => cluster.stop()).pipe(Effect.orDie), + ); - 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, - }); + const pg = postgres.url, + api = `http://127.0.0.1:${apiPort}`, + ui = `http://127.0.0.1:${uiPort}`; - 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 effectMode = process.argv.includes("--returning-mode") ? "http" : "disabled"; + const effectToken = randomBytes(32).toString("hex"); - for (const secret of secrets) - assert.ok( - !text.includes(secret), - `retained artifact ${entry.name} contains a credential`, - ); - } - } + const journeyPool = yield* Effect.acquireRelease( + Effect.sync(() => new Pool({ connectionString: pg })), + (owned) => step(() => owned.end()).pipe(Effect.orDie), + ); - throw new CoInterviewerTargetedComplete(journey); - } + pool = journeyPool; - if (process.argv.includes("--returning-mode")) { - let currentStage = "returning:startup"; - const returningStages: string[] = []; + // 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 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 })); + // 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", }; - 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 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", }; - 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(), - }); - - assert.equal(result._tag, "Delivered"); - - const outbox = await pool.query( - "SELECT status,attempts FROM public.recruitment_invitation_outbox WHERE effect_id=$1", - [result.claim.effectId], - ); + const env = effectMode === "http" ? { ...baseEnv, ...effectEnv } : baseEnv; - assert.deepEqual(outbox.rows, [{ status: "Delivered", attempts: 1 }]); + 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", + }); - return result; - }, - }), + 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 (effectMode === "http") { - const recruitmentInvitationCalls = effectCalls.filter( - (call) => call.kind === "SendInterviewInvitation", - ); - - assert.equal(recruitmentInvitationCalls.length, 1); - assert.deepEqual( - recruitmentInvitationCalls.map((call) => call.status), - [204], - ); + 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", + 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'`, ), ); - 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"), - ), - 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 returningEffectCalls = effectCalls.filter( - (call) => call.origin === "ReturningAssistant", - ); - - 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", - ); + const journey = yield* runApplicantProgress0107({ + pool, + page, + cookie, + api, + ui, + artifacts, + errors, + audit: (journeyPage) => runCallback(auditPage(journeyPage, "applicant-progress")), + }); - 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); + recordGate("observed applicant-owned progress through the real browser/API/PostgreSQL path"); - 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; - // 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 }, - }); + return { result: "CoInterviewerTargeted", report: { journey } } satisfies Outcome; + } - 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 }, - }); + if (process.argv.includes("--returning-mode")) { + let currentStage = "returning:startup"; + const returningStages: string[] = []; - 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 }, - }); + 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 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], + ), + ); + + assert.deepEqual(outbox.rows, [{ status: "Delivered", attempts: 1 }]); - const waitForRecruitmentOperation = (p: any, operation: string) => - p.waitForResponse((response: any) => { - if ( - response.request().method() !== "POST" || - new URL(response.url()).pathname !== "/recruitment" - ) - return false; + return result; + }), + }), + ); - try { - const payload: unknown = response.request().postDataJSON(); + if (effectMode === "http") { + const recruitmentInvitationCalls = effectCalls.filter( + (call) => call.kind === "SendInterviewInvitation", + ); - return ( - (payload === null || Predicate.isObjectOrArray(payload)) && - payload !== null && - "operation" in payload && - payload.operation === operation + 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 before = yield* step(() => get(correctionId)); + assert.equal(before.status, 200); + const beforeBody = yield* step(() => before.json()); - const correctionPayload = (detail: any, recommendation: "Ja" | "Kanskje" | "Nei") => ({ - expectedRevision: detail.revision, - answers: [ - { questionId: "interview-schema-native-conduct-0063-q0", answer: "API correction answer" }, + 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(); + } + + 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 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, + { + 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(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(); + 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 race = await Promise.all( - ["correction-race-0105-a", "correction-race-0105-b"].map((key, index) => + const rollbackResponse = yield* step(() => correctPost( correctionId, - correctionPayload(raceDetail, index === 0 ? "Ja" : "Kanskje"), - key, - raceEtag, + 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", + ); + recordGate( + "missing/null/unknown/numeric rejected without effects; all choices roundtrip; exact replay and conflicting/concurrent writes fenced", ); - 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'`, + 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 runJourneyEffect(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* 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`); - } - - 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`) }); - } - - 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", - ); - - 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 } }; + 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(); - const id = "interview-recommendation-maybe", - initial = await get(id); + 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, + }); - assert.equal(initial.status, 200); - const etag = initial.headers.get("etag")!; + yield* fs.writeFileString( + path.join(artifacts, "correction-boundaries.json"), + yield* indentedJsonText({ revision, ...boundaryResult }), + ); + } - 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], + // 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 before = await lifecycleSnapshot(); - - const decodeDieCause = Schema.decodeOption( - Schema.fromJsonString(Schema.Tuple([Schema.TaggedStruct("Die", {})])), - ); + assert.equal(applicantResponse.status, 200); + const applicantObservation = yield* step(() => applicantResponse.text()); + assertNoRecommendation(yield* decodeJsonValue(applicantObservation)); + assert.ok(!applicantObservation.includes("Kanskje")); - // 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 }; + // The public application confirmation is an anonymous RPC. + const confirmations = nativeScriptClient(api); - const refused = await post( - id, - body, - [fixtureKeys.invalid0, fixtureKeys.invalid1, fixtureKeys.invalid2, fixtureKeys.invalid3][i]!, - etag, + const applicationProjection = yield* step(() => + confirmations.call({}, (client) => + client["admissions.readApplicationConfirmation"]({ + applicationId: PublicApplicationIdSchema.make("application-recommendation-maybe"), + }), + ), ); - const refusal = await refused.text(); - - assert.equal(refused.status, 500, refusal); + 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", + ); - assert.ok(Option.isSome(decodeDieCause(refusal)), refusal); - } + recordGate( + "removed assignment and ended membership deny read/write/replay without lifecycle writes", + ); - 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")!, - ), - ]); + const lifecycleBeforeSelf = yield* lifecycleSnapshot(); + assert.equal((yield* step(() => get("interview-recommendation-self"))).status, 403); + assert.equal( + (yield* step(() => + post( + "interview-recommendation-self", + { ...payload, recommendation: "Ja" }, + fixtureKeys.selfFinalize, + etag, + ), + )).status, + 403, + ); - 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", ); - } - - // The capability travels in the payload, with no session beside it. - const applicantResponse = await replacedFetch({ - origin: api, - tag: "recruitment.readInvitationResponse", - payload: { capability: invitationCapability }, - headers: { origin: ui }, - }); - - assert.equal(applicantResponse.status, 200); - const applicantObservation = await applicantResponse.text(); - assertNoRecommendation(JSON.parse(applicantObservation)); - assert.ok(!applicantObservation.includes("Kanskje")); - // The public application confirmation is an anonymous RPC. - const confirmations = nativeScriptClient(api); - - const applicationProjection = await confirmations.call({}, (client) => - client["admissions.readApplicationConfirmation"]({ - applicationId: PublicApplicationIdSchema.make("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, + ); - await confirmations.dispose(); - assert.equal(applicationProjection.status, 200); - const applicationBody = JSON.stringify(applicationProjection); - 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 replacedFetch({ - origin: api, - tag: "recruitment.cancelInterview", - payload: { - interviewId: "interview-recommendation-self", - idempotencyKey: fixtureKeys.selfCancel, - ifMatch: etag, - }, - headers: { cookie, origin: ui }, - }); - - 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 replacedFetch({ - origin: api, - tag: "onboarding.claim", - payload: { mode: "ExistingAccount", token: onboardingToken }, - headers: { cookie, origin: ui }, - }); + 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/returning-assistant-journey.ts b/tools/acceptance/returning-assistant-journey.ts index 1e7fdb64..341bc8f3 100644 --- a/tools/acceptance/returning-assistant-journey.ts +++ b/tools/acceptance/returning-assistant-journey.ts @@ -1,4 +1,4 @@ -import { type RecruitmentInvitationDeliveryResult } from "../../packages/database/src/recruitment/index.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"; @@ -13,10 +13,8 @@ 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 { 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 { ReturningAssistantRegistrationInputSchema } from "../../packages/domain/src/application/returning.js"; @@ -30,10 +28,59 @@ 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, @@ -182,7 +229,6 @@ 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. @@ -229,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, @@ -361,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], - ); - - 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; + [applicantId, person.personId, accountLinkedAt, invitationId], + ); - 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) + 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()) { + 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], { @@ -617,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, @@ -640,22 +700,34 @@ 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()); @@ -671,7 +743,7 @@ 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 ( @@ -680,49 +752,65 @@ export const runReturningAssistantBrowserJourney = async ({ !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()); @@ -750,51 +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 native = nativeScriptClient(api); /** The browser context's current session, sent from the dashboard origin. */ - const sessionHeaders = async () => ({ - cookie: (await context.cookies()).map((cookie) => `${cookie.name}=${cookie.value}`).join("; "), - origin: ui, + const sessionHeaders = Effect.fnUntraced(function* () { + return { + cookie: (yield* step(() => context.cookies())) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "), + origin: ui, + }; }); 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 = async ( + const register = Effect.fnUntraced(function* ( idempotencyKey: string, data: typeof ReturningAssistantRegistrationInputSchema.Encoded, - ) => { - const request = Schema.decodeSync(ReturningAssistantRegistrationInputSchema)(data); - - return native.call(await sessionHeaders(), (client) => - client["admissions.registerReturningAssistant"]({ - idempotencyKey: IdempotencyKey.make(idempotencyKey), - request, - }), + ) { + 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 = await readOptions({ origin: ui, cookie: cookieHeader }); + const optionsAnswer = yield* step(() => readOptions({ origin: ui, cookie: cookieHeader })); - const waitForDashboardAction = async (periodId: string, trigger: () => Promise) => { + const waitForDashboardAction = Effect.fnUntraced(function* ( + periodId: string, + trigger: () => Effect.Effect, + ) { const responsePromise = returning.waitForResponse( (response) => { const url = new URL(response.url()); @@ -813,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() ?? ""); @@ -826,104 +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 ${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 answer = await readOptions({ origin: ui, cookie }); - const body = JSON.stringify(answer); + const answer = yield* step(() => readOptions({ origin: ui, cookie })); + const body = yield* jsonText(answer); assert.equal(answer.status, expectedStatus, `${gate} status body=${body}`); - const after = await negativeMutationSnapshot(probePerson.personId); + const after = yield* 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: 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", @@ -932,35 +1038,40 @@ export const runReturningAssistantBrowserJourney = async ({ }); // The browser calls the RPC with its own session cookie, across origins, as CORS admits it. - const browserOptions = await returning.evaluate( - async ({ endpoint, message }) => { - const response = await fetch(endpoint, { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(message), - }); - - return { status: response.status, answer: await response.json() }; - }, - { - endpoint: `${api}${nativeRpcPath}`, - message: WireRequest.make({ - id: "1", - tag: "admissions.readReturningAssistantOptions", - payload: null, - headers: [], - }), - }, + 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-0013: 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. - Schema.decodeUnknownSync(WireSuccess)(browserOptions.answer); + 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, @@ -983,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), @@ -1003,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; @@ -1044,23 +1162,25 @@ export const runReturningAssistantBrowserJourney = async ({ teamIds: [teamId], } as const; - const anonymousBefore = await negativeMutationSnapshot(person.personId); + const anonymousBefore = yield* negativeMutationSnapshot(person.personId); - const anonymous = await readOptions({ origin: ui }); + 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 register("returning-wrong-team-0104", { + const wrongTeam = yield* register("returning-wrong-team-0104", { ...firstPayload, commandId: "returning-wrong-team-0104", teamInterest: true, @@ -1070,43 +1190,28 @@ export const runReturningAssistantBrowserJourney = async ({ assert.ok(!wrongTeam.ok); assert.equal(wrongTeam.status, 403); assert.equal(wrongTeam.code, "returning.team-scope-denied"); - assert.deepEqual(await negativeMutationSnapshot(person.personId), wrongTeamBefore); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), wrongTeamBefore); trace.push({ phase: "negative-gate", gate: "cross-department-team", status: wrongTeam.status, code: wrongTeam.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; - }); - - const secondActionSettled = new Promise((resolve, reject) => { - resolveSecondAction = resolve; - rejectSecondAction = reject; - }); + // The route handler below settles these; the journey awaits them as steps. + const firstActionSettled = Promise.withResolvers(); + const secondActionSettled = Promise.withResolvers(); - await returning.route("**/dashboard/tidligere-assistenter*", async (route) => { - try { - if (route.request().method() !== "POST") { - await route.continue(); + const intercept = (route: Route): Promise => { + if (route.request().method() !== "POST") return route.continue(); - 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`); - 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({ @@ -1128,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); @@ -1171,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( + yield* step(() => reloaded.getByLabel("Torsdag", { exact: true }).isChecked()), + true, + ); assert.equal( - await reloaded.getByLabel("Ønsket skole (valgfritt)", { exact: true }).inputValue(), + 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) { @@ -1373,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) => { @@ -1470,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 }]); @@ -1510,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"); @@ -1521,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( + yield* step(() => periodForm.locator('input[name="expectedRevision"]').inputValue()), + "1", + ); assert.equal( - await periodForm.locator('input[name="commandId"]').inputValue(), + yield* step(() => periodForm.getByRole("combobox", { name: "Studieår" }).inputValue()), + "5", + ); + assert.equal( + yield* step(() => periodForm.locator('input[name="commandId"]').inputValue()), staleDraftCommandId, ); trace.push({ @@ -1534,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, @@ -1602,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({ @@ -1622,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, [ @@ -1728,18 +1940,19 @@ export const runReturningAssistantBrowserJourney = async ({ }, ] as const; - const concurrentDetails = await Promise.all( - concurrentRevisions.map(async ({ idempotencyKey, payload }) => { - const answer = await register(idempotencyKey, payload); - - return { idempotencyKey, payload, status: answer.status, answer }; - }), + 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), [200, 412], - JSON.stringify(concurrentDetails), + yield* jsonText(concurrentDetails), ); const concurrentWinner = concurrentDetails.find(({ status }) => status === 200); @@ -1758,9 +1971,11 @@ export const runReturningAssistantBrowserJourney = async ({ 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 }]); @@ -1786,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, @@ -1814,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, @@ -1831,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); @@ -1849,9 +2070,11 @@ export const runReturningAssistantBrowserJourney = async ({ payload: { applicationId: nextApplicationId, idempotencyKey, request: assignmentPayload }, }); - const ambiguousAssignment = await assign(page, "returning-next-assignment-ambiguous-0104"); + 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(), @@ -1861,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, @@ -1878,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({ @@ -1896,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, @@ -1914,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( @@ -1933,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, @@ -1957,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, [ @@ -1983,31 +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 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 = await assign(coordinatorPage, assignmentCommandId); + const assignmentResponse = yield* step(() => assign(coordinatorPage, assignmentCommandId)); assignmentStatus = assignmentResponse.status(); - assignmentBodyText = await assignmentResponse.text(); + assignmentBodyText = yield* step(() => assignmentResponse.text()); assignmentETag = assignmentResponse.headers()["etag"] ?? ""; // An RPC command answers 200; the 201 was an HTTP transport fact. if (assignmentStatus !== 200) { - const assignmentActorContext = await pool.query( - `SELECT + const assignmentActorContext = yield* step(() => + pool.query( + `SELECT membership.person_id, membership.team_id, membership.start_at, @@ -2022,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({ @@ -2036,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, [ @@ -2063,25 +2306,27 @@ export const runReturningAssistantBrowserJourney = async ({ assert.ok(assignmentETag); const assignedETag = assignmentETag; - const scheduleResponse = await 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 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( @@ -2089,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); @@ -2107,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", ); @@ -2124,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 }]); @@ -2144,8 +2389,7 @@ 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); @@ -2161,9 +2405,9 @@ export const runReturningAssistantBrowserJourney = async ({ headers: { origin: ui }, }); - const invitationPendingResponse = await readInvitation(); + const invitationPendingResponse = yield* step(() => readInvitation()); - const invitationPendingText = await invitationPendingResponse.text(); + const invitationPendingText = yield* step(() => invitationPendingResponse.text()); if (invitationPendingResponse.status !== 200) { throw new Error( @@ -2171,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); @@ -2185,14 +2429,16 @@ export const runReturningAssistantBrowserJourney = async ({ responseMessage: null, }); - const invitationConfirmResponse = await replacedFetch({ - origin: api, - tag: "recruitment.confirmInvitation", - payload: { capability: invitationCapability, ifMatch: invitationETag }, - headers: { origin: ui }, - }); + 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()); // The confirmation answers the invitation's new tag with 200, where HTTP answered 204. if (invitationConfirmResponse.status !== 200) { @@ -2201,13 +2447,13 @@ export const runReturningAssistantBrowserJourney = async ({ ); } - const invitationAcceptedResponse = await readInvitation(); + 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, @@ -2232,15 +2478,15 @@ export const runReturningAssistantBrowserJourney = async ({ payload: { interviewId: nextInterviewId }, }); - const conductResponse = await readConduct(); + 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); @@ -2264,23 +2510,25 @@ export const runReturningAssistantBrowserJourney = async ({ const finalizeKey = "returning-native-finalize-0104"; - const finalizeResponse = await 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 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( @@ -2288,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); @@ -2297,13 +2545,13 @@ export const runReturningAssistantBrowserJourney = async ({ assert.equal(finalizeBody.completionState, "Completed"); assert.equal(finalizeBody.cancellationState, "NotCancelled"); - const conductAfterResponse = await readConduct(); + 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); @@ -2323,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, [ @@ -2433,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, @@ -2445,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, [ @@ -2480,90 +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 register(firstCommandKey, firstReplayPayload); + const replayKey = firstCommandKey; + const exactReplay = yield* register(replayKey, firstReplayPayload); 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, - ]); + 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, + ]), + ); - try { - const closedReplay = await register(firstCommandKey, firstReplayPayload); + yield* Effect.gen(function* () { + const closedReplay = yield* register(replayKey, firstReplayPayload); assert.equal(closedReplay.status, 409); - assert.deepEqual(await negativeMutationSnapshot(person.personId), closedBefore); + assert.deepEqual(yield* 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], - ); - } + }).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 register(firstCommandKey, firstReplayPayload); + const revokedReplay = yield* register(replayKey, firstReplayPayload); assert.equal(revokedReplay.status, 401); - await native.dispose(); - assert.deepEqual(await negativeMutationSnapshot(person.personId), revokedBefore); + yield* step(() => native.dispose()); + assert.deepEqual(yield* negativeMutationSnapshot(person.personId), revokedBefore); trace.push({ phase: "negative-gate", gate: "stale-revoked-auth", 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); @@ -2580,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); @@ -2604,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, @@ -2643,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) => { @@ -2681,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]"), ); @@ -2728,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)}`, ); } -}; +}); From 88c5477351df9fb2747408387207e3a5c92785bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:21:52 +0000 Subject: [PATCH 145/148] refactor(verification): make the unattended delivery recovery proof an Effect program The proof owns its native processes through ChildProcess and signals them as before, serves its loopback provider on the Bun HttpServer of its entry, waits through Clock and Effect.sleep, and writes its evidence through FileSystem. It joins the Bun composition-root group and tsconfig.bun.json. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- oxlint.config.ts | 1 + tools/verification/tsconfig.bun.json | 6 +- tools/verification/tsconfig.json | 6 +- .../unattended-delivery-recovery.ts | 1477 +++++++++-------- 4 files changed, 835 insertions(+), 655 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 22e20d06..a3a32b6c 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -194,6 +194,7 @@ const effectConfig = { "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", diff --git a/tools/verification/tsconfig.bun.json b/tools/verification/tsconfig.bun.json index da5f0b01..fcbf2440 100644 --- a/tools/verification/tsconfig.bun.json +++ b/tools/verification/tsconfig.bun.json @@ -3,7 +3,11 @@ "compilerOptions": { "types": ["node", "bun"] }, - "files": ["completion-receipt-postgres-proof-main.ts", "receipt-import-rehearsal.ts"], + "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 b9d124e0..94698322 100644 --- a/tools/verification/tsconfig.json +++ b/tools/verification/tsconfig.json @@ -6,5 +6,9 @@ }, "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"] + "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 4fe6751b..6933eab3 100644 --- a/tools/verification/unattended-delivery-recovery.ts +++ b/tools/verification/unattended-delivery-recovery.ts @@ -1,12 +1,36 @@ -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"; @@ -16,743 +40,890 @@ import { 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 run = (command: string, args: string[], env = safeEnvironment) => - execFileSync(command, args, { - cwd: root, - env, - encoding: "utf8", - timeout: 60_000, - stdio: ["ignore", "pipe", "pipe"], +const DeliveryBody = Schema.fromJsonString(Schema.Struct({ deliveryId: Schema.String })); + +type Environment = Readonly>; + +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 revision = run("git", ["rev-parse", "HEAD"]).trim(); +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 sourceTree = run("git", ["rev-parse", "HEAD^{tree}"]).trim(); + const safeEnvironment: Record = {}; -const artifacts = await mkdtemp(join(tmpdir(), "vektor-delivery-recovery-")); + for (const key of ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "LD_LIBRARY_PATH"]) { + const value = yield* Config.option(Config.String(key)); -const privateRoot = join(artifacts, "private"); + if (Option.isSome(value)) safeEnvironment[key] = value.value; + } -await mkdir(privateRoot, { mode: 0o700 }); + /** 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}`, + }), + ), + ), + ); -process.stdout.write(`evidence: ${artifacts}\n`); + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed source", + ); -const eventually = async (label: string, inspect: () => Promise, timeout = 20_000) => { - const deadline = Date.now() + timeout; + const revision = (yield* run("git", ["rev-parse", "HEAD"])).trim(); - while (Date.now() < deadline) { - if (await inspect()) return; - await pause(50); - } + const sourceTree = (yield* run("git", ["rev-parse", "HEAD^{tree}"])).trim(); - throw new Error(`Observation timeout: ${label}`); -}; + const artifacts = yield* fileSystem.makeTempDirectory({ prefix: "vektor-delivery-recovery-" }); -interface NativeProcess { - child: ChildProcess; - logs: string[]; - exited: Promise; -} + const privateRoot = join(artifacts, "private"); -const processes: NativeProcess[] = []; + yield* fileSystem.makeDirectory(privateRoot, { mode: 0o700 }); -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"], - }); + yield* Console.log(`evidence: ${artifacts}`); - 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"); - }), - ]); + const eventually = ( + label: string, + inspect: Effect.Effect, + timeout = 20_000, + ) => + Effect.gen(function* () { + const deadline = (yield* Clock.currentTimeMillis) + timeout; - if (signal === "SIGTERM") assert.equal(code, 0, "graceful shutdown"); -}; + while ((yield* Clock.currentTimeMillis) < deadline) { + if (yield* inspect) return; + yield* Effect.sleep("50 millis"); + } -// The replacement main of the recovery binds the fourth port. -const ownedPorts = [...(await reserveLoopbackPorts(4))]; + return yield* new RecoveryTimeout({ message: `Observation timeout: ${label}` }); + }); -const [pgPort, apiPort, dashboardPort, replacementPort] = ownedPorts; + const processes: NativeProcess[] = []; -const pgDirectory = join(privateRoot, "postgres"); + const client = yield* HttpClient.HttpClient; -const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/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 origin = `http://127.0.0.1:${apiPort}`; + // The replacement main of the recovery binds the fourth port. + const ownedPorts = [...(yield* step(() => reserveLoopbackPorts(4)))]; -const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; + const [pgPort, apiPort, dashboardPort, replacementPort] = ownedPorts; -const providerToken = randomBytes(24).toString("hex"); + const pgDirectory = join(privateRoot, "postgres"); -const secret = randomBytes(32).toString("hex"); + const postgresUrl = `postgres://postgres@127.0.0.1:${pgPort}/postgres`; -const password = randomBytes(24).toString("hex"); + const origin = `http://127.0.0.1:${apiPort}`; -const person = "delivery-recovery-person"; + const dashboardOrigin = `http://127.0.0.1:${dashboardPort}`; -const email = "delivery-recovery@example.invalid"; + const providerToken = randomBytes(24).toString("hex"); -const department = "delivery-recovery-department"; + const secret = randomBytes(32).toString("hex"); -type Mode = "failure" | "accept" | "hold"; + const password = randomBytes(24).toString("hex"); -let resetMode: Mode = "failure"; + const person = "delivery-recovery-person"; -let receiptMode: Mode = "failure"; + const email = "delivery-recovery@example.invalid"; -const attempts: Array<{ kind: "reset" | "receipt"; id: string; hash: string; accepted: boolean }> = - []; + const department = "delivery-recovery-department"; -const held: Array<() => void> = []; + type Mode = "failure" | "accept" | "hold"; -const poison = new Set(); + let resetMode: Mode = "failure"; -const provider = createHttpServer(async (request, response) => { - assert.equal(request.headers.authorization, `Bearer ${providerToken}`); - let payload = ""; + let receiptMode: Mode = "failure"; - for await (const chunk of request) payload += String(chunk); + const attempts: Array<{ + kind: "reset" | "receipt"; + id: string; + hash: string; + accepted: boolean; + }> = []; - const body = Schema.decodeSync( - Schema.fromJsonString(Schema.Struct({ deliveryId: Schema.String })), - )(payload); + const held: Array> = []; - 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 releaseHeld = Effect.suspend(() => + Effect.forEach(held.splice(0), (release) => Deferred.succeed(release, undefined)), + ); - if (mode === "hold") { - const pending = Promise.withResolvers(); - held.push(pending.resolve); - await pending.promise; - } + const poison = new Set(); - response.writeHead(mode === "failure" ? 503 : 204); - response.end(); -}); + const pool = new Pool({ + connectionString: postgresUrl, + application_name: "delivery-recovery-observer", + }); -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 query = (text: string, values?: ReadonlyArray) => + step(() => pool.query(text, values === undefined ? undefined : [...values])); -const checks: string[] = []; + const checks: string[] = []; -let postgres: DisposablePostgres | undefined; + let postgres: DisposablePostgres | undefined; -let completed = false; + let completed = false; -let sourceUnchanged = false; + let providerPort = 0; -ownedPorts.push(providerPort); + // A provider request that breaks an assertion answers 500; the proof fails on it at the end. + const providerFailures: string[] = []; -let current: NativeProcess | undefined; + 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 cookie = ""; + const mode = poison.has(body.deliveryId) + ? "failure" + : kind === "reset" + ? resetMode + : receiptMode; -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; + assert.equal(request.headers["idempotency-key"], body.deliveryId); + attempts.push({ + kind, + id: body.deliveryId, + hash: createHash("sha256").update(payload).digest("hex"), + accepted: mode === "accept", + }); + + 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 (await resetRows()).at(-1)!; -}; - -const receiptRequest = async () => { - const native = nativeScriptClient(origin); - - try { - const result = await native.call({ cookie, origin: dashboardOrigin }, (client) => - client["receipts.submitReceipt"]({ - idempotencyKey: IdempotencyKey.make(randomUUID()), - departmentId: department, - request: { - description: "Disposable delivery recovery proof", - amountOre: 500, - receiptDate: new Date().toISOString().slice(0, 10), - file: { - contentType: "application/pdf", - bytes: new TextEncoder().encode("%PDF-1.4\nDisposable receipt\n%%EOF"), - }, - }, + return HttpServerResponse.empty({ status: 500 }); }), - ); + ), + ); - assert.ok(result.ok, "real receipt submission accepted"); + 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 result.value.receiptId; - } finally { - await native.dispose(); - } -}; + const stop = Effect.fnUntraced(function* ( + owned: NativeProcess, + name: NodeJS.Signals = "SIGTERM", + ) { + if (!(yield* owned.child.isRunning)) return; + yield* signal(owned, name); -const business = async () => - ( - await pool.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(); + const code = yield* exitWithin(owned, "Native shutdown timeout"); - const login = await fetch(`${origin}/api/auth/sign-in/email`, { - method: "POST", - headers: { origin: dashboardOrigin, "content-type": "application/json" }, - body: JSON.stringify({ email, password }), - }); + if (name === "SIGTERM") assert.equal(code, 0, "graceful shutdown"); + }); - 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); + 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; + }); - 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); - } + 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)); - 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", - ); + 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)); - // 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", - ); + const resetRequest = Effect.fnUntraced(function* () { + const response = yield* postJson(`${origin}/api/auth/request-password-reset`, { + email, + redirectTo: `${dashboardOrigin}/tilbakestill-passord`, + }); - // 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", - ); + assert.equal(response.status, 200, "real reset request accepted"); - 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", - ); + return (yield* resetRows()).at(-1)!; + }); - // 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 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`).pipe( + Effect.map((result) => result.rows[0]), + ); + + const started = yield* step(() => + startDisposablePostgres({ + port: pgPort, + directory: pgDirectory, + environment: safeEnvironment, + }), + ); - const faultEffect = (await receiptRows()).find( - (row) => row.receipt_id === faultReceipt && row.status === "Failed", - )!; + 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(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", - }); + 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); + } - const interpreterCode = await Promise.race([ - current.exited, - pause(10_000).then(() => { - throw new Error("Receipt interpreter SQL failure timeout"); - }), - ]); + 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(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", - ); + // 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", + ); - 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"); + // 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(); - const shutdownCode = await Promise.race([ - current!.exited, - pause(10_000).then(() => { - throw new Error("Finalization failure shutdown timeout"); - }), - ]); + const staleReceiptRequest = yield* receiptRequest().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkScoped, + ); - 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", + 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", }); - const table = kind === "reset" ? "auth.password_reset_email_outbox" : "economy_receipt_outbox"; + 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" + ); + }), + ); - const renamed = - kind === "reset" ? "password_reset_email_outbox_fault" : "economy_receipt_outbox_fault"; + 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", + ); - await pool.query(`ALTER TABLE ${table} RENAME TO ${renamed}`); + // 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!); - 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`); - } + const faultEffect = (yield* receiptRows()).find( + (row) => row.receipt_id === faultReceipt && row.status === "Failed", + )!; - const sessions = await pool.query( - "SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid()", - ); + 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()", + ); + + const interpreter = yield* start({ + ...env, + PASSWORD_RESET_DELIVERY_MODE: "disabled", + RECEIPT_DELIVERY_POLL_MS: "100", + }); + + current = interpreter; + + const interpreterCode = yield* exitWithin( + interpreter, + "Receipt interpreter SQL failure timeout", + ); + + 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", + ); + + 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"); + + const shutdownCode = yield* exitWithin(current!, "Finalization failure shutdown timeout"); - 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; + 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`); } - } - await pool.end(); - provider.closeAllConnections(); - const providerClosed = Promise.withResolvers(); - provider.close(() => providerClosed.resolve()); - await providerClosed.promise; + 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; + }); + + // 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; + } + } + + 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); + + assert.ok(completed && sourceUnchanged, "proof and source identity must pass"); + + yield* Console.log(`PASS ${checks.length} recovery observations; sanitized evidence retained`); +}); -process.stdout.write(`PASS ${checks.length} recovery observations; sanitized evidence retained\n`); +BunRuntime.runMain( + proof.pipe( + surfaceStepFailure, + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + ), +); From 89f7e567605046910be4a7ff622bfe2b8434138b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:33:15 +0000 Subject: [PATCH 146/148] refactor(tools/postgres): run the disposable cluster construct as Effect programs The Promise API of tools/postgres now bridges Effect programs over FileSystem, Path, and ChildProcessSpawner in one module runtime of the shared Node implementations, which Bun programs and Node suites both run. initdb, createdb, pg_isready, PgBouncer, and the sentinels run through ChildProcess; the postmaster and the synchronous installation resolution stay on Node under EX-0017. The exported functions of two parameters gain dual forms. The package tests become @effect/vitest programs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- bun.lock | 1 + docs/effect-exceptions.json | 25 + tools/acceptance/onboarding-check.ts | 2 +- tools/postgres/index.ts | 1082 ++++++++++++++++---------- tools/postgres/package.json | 1 + tools/postgres/readiness.test.ts | 151 ++-- tools/postgres/teardown.test.ts | 331 ++++---- 7 files changed, 963 insertions(+), 630 deletions(-) diff --git a/bun.lock b/bun.lock index 6b5d8be2..499d1753 100644 --- a/bun.lock +++ b/bun.lock @@ -308,6 +308,7 @@ "effect": "catalog:effect-v4", }, "devDependencies": { + "@effect/vitest": "catalog:effect-v4", "@types/node": "^22", "typescript": "7.0.2", "vitest": "catalog:effect-v4", diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index 14d6ffc3..c9e0385b 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -330,6 +330,31 @@ "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": ["FX002", "effecttsgo/node-builtin-import"], + "scope": { + "files": ["tools/postgres/index.ts"], + "symbols": ["spawnPostmaster", "executable", "versionOf", "supportedPostgresMajors"] + }, + "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": [ + "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 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": "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/tools/acceptance/onboarding-check.ts b/tools/acceptance/onboarding-check.ts index d5f6c6c9..eee11693 100644 --- a/tools/acceptance/onboarding-check.ts +++ b/tools/acceptance/onboarding-check.ts @@ -202,7 +202,7 @@ const journey = Effect.gen(function* () { return new Response(null, { status: 503 }); } - const json = Schema.decodeUnknownSync(JsonBody)(body); + const json = Schema.decodeSync(JsonBody)(body); const text = Schema.encodeSync(JsonBody)(json); const message = Schema.decodeUnknownSync(MailMessage)(json); const old = mail.get(message.deliveryId); diff --git a/tools/postgres/index.ts b/tools/postgres/index.ts index 20c07458..c5fb3c8c 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-0017). */ -import { type ChildProcess, execFile, spawn, spawnSync } from "node:child_process"; -import { randomInt } from "node:crypto"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- EX-0017: 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-0017: 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-0017). */ -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 3876367a..38fea9d7 100644 --- a/tools/postgres/package.json +++ b/tools/postgres/package.json @@ -15,6 +15,7 @@ "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 d74d6290..bc245c9b 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 00eab28e..c1d515bb 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, ); }); From 5a2a920e5ada852e03166a4d56489989623a8ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:33:53 +0000 Subject: [PATCH 147/148] docs(constructs): render the test-harness contracts of tools/postgres Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- docs/constructs/test-harness.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/constructs/test-harness.md b/docs/constructs/test-harness.md index 974b62c9..59b3055a 100644 --- a/docs/constructs/test-harness.md +++ b/docs/constructs/test-harness.md @@ -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** From af38c278737a43f23c7ccf785af347fc1826cc87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:47:42 +0000 Subject: [PATCH 148/148] refactor(e2e): run the placement journeys and their golden observers as Effect programs placement-check.ts becomes one Effect program at a Bun composition root: owned children through ChildProcess, the loopback receiver through Bun.serve with Effect handlers, readiness and sign-in through HttpClient, the artifacts through FileSystem, and the variables through Config. The golden school-service and recruitment observers and the evidence reader become typed TypeScript modules whose checkpoints are Effects; the evidence reader keeps its .mjs path and Promise forms for the CI wrapper and the browser drivers (EX-0017). The journey clock reads instants through DateTime, and the projection file lock through Semaphore (EX-0018, EX-0019). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmXVLTGJH6D28NK4jkdy3e --- .github/workflows/tests.yml | 2 +- docs/constructs/test-harness.md | 8 +- docs/effect-exceptions.json | 80 + docs/specs/command-grammar.md | 2 +- docs/web-system-functional-testing.md | 6 +- oxlint.config.ts | 5 + ...-recruitment.mjs => golden-recruitment.ts} | 150 +- tools/e2e/golden-school-service-evidence.mjs | 313 +-- .../golden-school-service-evidence.test.mjs | 143 -- .../golden-school-service-evidence.test.ts | 161 ++ tools/e2e/golden-school-service-evidence.ts | 407 ++++ ...l-service.mjs => golden-school-service.ts} | 384 ++-- tools/e2e/journey-clock.ts | 22 +- tools/e2e/placement-check.ts | 2030 +++++++++-------- tools/e2e/safe-file-io.ts | 161 +- tools/e2e/tsconfig.bun.json | 8 +- 16 files changed, 2269 insertions(+), 1613 deletions(-) rename tools/e2e/{golden-recruitment.mjs => golden-recruitment.ts} (76%) delete mode 100644 tools/e2e/golden-school-service-evidence.test.mjs create mode 100644 tools/e2e/golden-school-service-evidence.test.ts create mode 100644 tools/e2e/golden-school-service-evidence.ts rename tools/e2e/{golden-school-service.mjs => golden-school-service.ts} (72%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 351e5cfb..4e250cab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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/docs/constructs/test-harness.md b/docs/constructs/test-harness.md index 974b62c9..059c9862 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** diff --git a/docs/effect-exceptions.json b/docs/effect-exceptions.json index 14d6ffc3..6bfa61a6 100644 --- a/docs/effect-exceptions.json +++ b/docs/effect-exceptions.json @@ -330,6 +330,86 @@ "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": [ + "runForPromiseCaller", + "dashboardBuildInventory", + "inspectGoldenEvidence", + "stageGoldenEvidence", + "withProjectionFileLock" + ] + }, + "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": [ + "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": [ + "`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/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-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." } ] } diff --git a/docs/specs/command-grammar.md b/docs/specs/command-grammar.md index 660473d7..894dc11e 100644 --- a/docs/specs/command-grammar.md +++ b/docs/specs/command-grammar.md @@ -65,7 +65,7 @@ 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/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` | diff --git a/docs/web-system-functional-testing.md b/docs/web-system-functional-testing.md index e8cea558..9c5e2149 100644 --- a/docs/web-system-functional-testing.md +++ b/docs/web-system-functional-testing.md @@ -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/oxlint.config.ts b/oxlint.config.ts index f56d3d98..37757892 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -175,10 +175,15 @@ const effectConfig = { "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", 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 81eae122..1e4845e4 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-school-service-evidence.mjs b/tools/e2e/golden-school-service-evidence.mjs index d30b6cb3..bf981cda 100644 --- a/tools/e2e/golden-school-service-evidence.mjs +++ b/tools/e2e/golden-school-service-evidence.mjs @@ -1,309 +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() !== true, "build must not contain symlinks"); - - if (info.isDirectory() === true) { - 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") !== 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 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() !== true, - "evidence directory symlink rejected", - ); - - const read = async (name) => { - const info = await lstat(join(directory, name)); - assert.ok( - info.isFile() === true && info.isSymbolicLink() !== true, - "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") === true) 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") === true) 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 (Predicate.isTruthy(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 fbca1108..00000000 --- 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 00000000..6cb5fea1 --- /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 00000000..5c4d83f6 --- /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 72% rename from tools/e2e/golden-school-service.mjs rename to tools/e2e/golden-school-service.ts index aa707fa7..40276804 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) !== true); + 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); @@ -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,12 +232,24 @@ 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 !== undefined) - assertRecord(recorded, candidateId, volunteerId, reached("coverage-replaced") ? leaderId : null); + assertRecord( + recorded, + candidateId, + volunteerId, + reached("coverage-replaced") ? leaderId : null, + ); if (replaced !== undefined) { assertRecord( @@ -230,11 +266,11 @@ const assertSubstituteFacts = (step, facts, previous, baseline, fixture) => { } if (rerecorded !== undefined) assertRecord(rerecorded, candidateId, volunteerId, null); - const current = facts.coverage.filter((row) => row.withdrawn_at === 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) !== true, + (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) !== true, + (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, @@ -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/journey-clock.ts b/tools/e2e/journey-clock.ts index 7e4c08d2..ca2583bd 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/placement-check.ts b/tools/e2e/placement-check.ts index 94350342..b931a4c1 100644 --- a/tools/e2e/placement-check.ts +++ b/tools/e2e/placement-check.ts @@ -1,3 +1,28 @@ +/** + * 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, @@ -10,232 +35,358 @@ import { } from "@vektorprogrammet/domain/placements"; import { IdempotencyKey, StrongETag } from "@vektorprogrammet/rpc/problem"; import { nativeScriptClient, type ScriptCallResult } from "@vektorprogrammet/rpc/script"; -/** 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 { - loopbackPortFree, - postgresVersion, - reserveLoopbackPorts, - startDisposablePostgres, - type DisposablePostgres, -} from "@monoweb/postgres"; -import { Option, 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}`, + }); -const fault = process.env.GOLDEN_SCHOOL_SERVICE_FAULT; + return stdout; + }), + ).pipe( + Effect.timeoutOrElse({ + duration: timeout, + orElse: () => Effect.fail(new PlacementCheckFailure({ message: `${command} timed out` })), + }), + ); -assert.ok(fault === undefined || goldenFaults.includes(fault), "unknown test-driver 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, + ); -const secrets = [ - "synthetic-recruitment-provider", - "journey-secret-0123456789abcdef", - "synthetic-school-service-token", -]; + yield* handle.exitCode.pipe( + Effect.exit, + Effect.flatMap((result) => + Deferred.succeed(exit, Exit.isSuccess(result) ? result.value : null), + ), + Effect.forkDetach, + ); -const sanitize = (value: string) => redactDiagnostic(secrets, value); + return child; + }); -const children: ChildProcess[] = []; + /** 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" })), + ), + ), + }), + ); -let postgres: DisposablePostgres | undefined; + if (code !== 0) + return yield* new PlacementCheckFailure({ message: command + " exited " + String(code) }); + }); -const outputs: string[] = []; + const revision = (yield* run("git", ["rev-parse", "HEAD"])).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))); + const sourceTree = (yield* run("git", ["rev-parse", "HEAD^{tree}"])).trim(); - return child; -}; + assert.equal( + (yield* run("git", ["status", "--porcelain"])).trim(), + "", + "requires committed clean artifact", + ); -/** 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"); - }); -}; + const artifacts = yield* fs.makeTempDirectory({ prefix: "vektor-placements-0096-" }); -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + yield* Effect.sync(() => + process.stdout.write("artifacts: " + artifacts + "\nrunner-pid: " + process.pid + "\n"), + ); -let pool: InstanceType; + 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 evidence: Schema.JsonObject | undefined; + const fault = Option.getOrUndefined( + yield* Config.option(Config.String("GOLDEN_SCHOOL_SERVICE_FAULT")), + ); -let notificationServer: HttpServer | undefined; + assert.ok(fault === undefined || goldenFaults.includes(fault), "unknown test-driver fault"); -let checkpoint: ((step: string) => Promise) | undefined; + const secrets = [ + "synthetic-recruitment-provider", + "journey-secret-0123456789abcdef", + "synthetic-school-service-token", + ]; -let observations: Array<{ step: string }> = []; + const sanitize = (value: string) => redactDiagnostic(secrets, value); -let ownedPorts: number[] = []; + let postgres: DisposablePostgres | undefined; -let failure: string | undefined; + let pool: Pool | undefined; -let interruptedSignal: "SIGINT" | "SIGTERM" | undefined; + /** What the journey recorded; each journey sets it only when it passed. */ + let evidence: object | undefined; -let cleanupPromise: Promise | undefined; + let journeyPassed = false; -const cleanup = () => - (cleanupPromise ??= (async () => { - const errors: string[] = []; + let notificationServer: Bun.Server | undefined; - for (const child of [...children].reverse()) { - try { - await stopChild(child); - } catch (error) { - errors.push(sanitize(String(error))); - } - } + let receiverOpen = false; - try { - if (pool !== undefined) await pool.end(); - } catch (error) { - errors.push(sanitize(String(error))); - } + let checkpoint: + | ((step: string) => Effect.Effect) + | undefined; - try { - await postgres?.stop(); - } catch (error) { - errors.push(sanitize(String(error))); - } + let observations: Array<{ step: string }> = []; - try { - if (notificationServer?.listening === true) - await new Promise((resolve, reject) => - notificationServer!.close((error) => (error !== undefined ? reject(error) : resolve())), - ); - } catch (error) { - errors.push(sanitize(String(error))); - } + let ownedPorts: Array = []; - const removed: string[] = []; + let failure: string | 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))); - } - } + let interruptedSignal: "SIGINT" | "SIGTERM" | undefined; + + 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"; + 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, @@ -249,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 !== true, + 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()) { + for (const name of (yield* fs.readDirectory(artifacts)).sort()) { if (goldenArtifactName.test(name) !== true) continue; - const bytes = await readFile(join(artifacts, name)); - retained.push({ - path: name, - sha256: createHash("sha256").update(bytes).digest("hex"), - bytes: bytes.length, - }); + 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 = { @@ -331,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, @@ -340,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 > 0) 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/") === true) { - 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[] = []; - - 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; + if (recruitment) return yield* recruitmentMailbox.handle(request); - 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}`; @@ -441,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", @@ -515,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, @@ -555,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 @@ -594,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'); @@ -627,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", @@ -661,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 = { @@ -694,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") { @@ -748,71 +890,76 @@ 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 leader = yield* login(persons.leader), + volunteer = yield* login(persons.volunteer), + wrong = yield* login(persons.wrongDepartment), + candidate = yield* login(persons.candidate); // Placements are served as RPCs. Each call names the person whose session it forwards. const native = nativeScriptClient(backendOrigin); @@ -821,7 +968,7 @@ try { 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}`; @@ -843,69 +990,70 @@ try { body?: Schema.Json, etag?: string, key = randomBytes(18).toString("base64url"), - ): 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), - }), - ); - } + ): 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); - const scope = Schema.decodeUnknownSync(PlacementScope)(parameters); + if (url.pathname === "/api/placements/affiliation") { + const scope = Schema.decodeUnknownSync(AffiliationScope)(parameters); - switch (url.pathname) { - case "/api/placements": return body === undefined - ? native.call(headers, (client) => client["placements.readBoard"](scope)) + ? native.call(headers, (client) => client["placements.readOwnAffiliation"](scope)) : native.call(headers, (client) => - client["placements.commandBoard"]({ + client["placements.commandOwnAffiliation"]({ ...scope, ...commandKeys(etag, key), - request: Schema.decodeUnknownSync(PlacementCommand)(body), + request: Schema.decodeUnknownSync(OwnAffiliationCommand)(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 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}`); + } + }); // The journey reads answers as the untyped JSON bodies it read over HTTP. - const expectStatus = async ( + const expectStatus = ( response: ScriptCallResult, status: number, code?: string, - ): Promise => { + ): any => { assert.equal(response.status, status, JSON.stringify(response)); if (code !== undefined) assert.equal(response.ok ? undefined : response.code, code); @@ -913,146 +1061,167 @@ try { return response.ok ? response.value : "problem" in response ? response.problem : response; }; - const readBoard = async () => - valueOf( - await native.call(session(leader), (client) => client["placements.readBoard"](query)), + const readBoard = Effect.fnUntraced(function* () { + return valueOf( + yield* step(() => + native.call(session(leader), (client) => client["placements.readBoard"](query)), + ), ); + }); - const command = async (payload: Schema.Json) => { - const board = await readBoard(); + const command = Effect.fnUntraced(function* (payload: Schema.Json) { + const board = yield* readBoard(); return valueOf( - await native.call(session(leader), (client) => - client["placements.commandBoard"]({ - ...query, - ...commandKeys(board.etag, randomBytes(18).toString("base64url")), - request: Schema.decodeUnknownSync(PlacementCommand)(payload), - }), + 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 (cookie: string) => - valueOf( - await native.call(session(cookie), (client) => client["placements.readOwnCoverage"](query)), + const readOwnCoverage = Effect.fnUntraced(function* (cookie: string) { + return valueOf( + yield* step(() => + native.call(session(cookie), (client) => client["placements.readOwnCoverage"](query)), + ), ); + }); - const readCoverageBoard = async () => - valueOf( - await native.call(session(leader), (client) => - client["placements.readCoverageBoard"](query), + const readCoverageBoard = Effect.fnUntraced(function* () { + return valueOf( + yield* step(() => + native.call(session(leader), (client) => client["placements.readCoverageBoard"](query)), ), ); + }); - const commandOwnCoverage = async ( + const commandOwnCoverage = Effect.fnUntraced(function* ( cookie: string, payload: Schema.Json, etag?: string, key = randomBytes(18).toString("base64url"), - ) => { - const current = etag ?? (await readOwnCoverage(cookie)).etag; + ) { + const current = etag ?? (yield* readOwnCoverage(cookie)).etag; return valueOf( - await native.call(session(cookie), (client) => - client["placements.commandOwnCoverage"]({ - ...query, - ...commandKeys(current, key), - request: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), - }), + yield* step(() => + native.call(session(cookie), (client) => + client["placements.commandOwnCoverage"]({ + ...query, + ...commandKeys(current, key), + request: Schema.decodeUnknownSync(OwnCoverageCommand)(payload), + }), + ), ), ); - }; + }); - const commandCoverage = async ( + const commandCoverage = Effect.fnUntraced(function* ( payload: Schema.Json, etag?: string, key = randomBytes(18).toString("base64url"), - ) => { - const current = etag ?? (await readCoverageBoard()).etag; + ) { + const current = etag ?? (yield* readCoverageBoard()).etag; return valueOf( - await native.call(session(leader), (client) => - client["placements.commandCoverageBoard"]({ - ...query, - ...commandKeys(current, key), - request: Schema.decodeUnknownSync(CoverageCommand)(payload), - }), + yield* step(() => + native.call(session(leader), (client) => + client["placements.commandCoverageBoard"]({ + ...query, + ...commandKeys(current, key), + request: Schema.decodeUnknownSync(CoverageCommand)(payload), + }), + ), ), ); - }; + }); // An RPC answer carries no Cache-Control; the backend answers every RPC uncached. - await expectStatus(await request(boardPath, leader), 200); + 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( valueOf( - await native.call(session(leader), (client) => client["placements.listScopes"]()), + yield* step(() => + native.call(session(leader), (client) => client["placements.listScopes"]()), + ), ).semesters.some((semester) => semester.semesterId === semesterId), ); - 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), 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( - !JSON.stringify(await expectStatus(await request(ownPath, volunteer), 200)).includes( - leaderId, - ), + !(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. - 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], + 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, 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], + expectStatus(yield* request(boardPath, volunteer), 403, "authority.denied"); + yield* step(() => + journeyPool.query("DELETE FROM organization_memberships WHERE membership_id='member-0096'"), + ); + yield* step(() => + journeyPool.query( + "UPDATE organization_memberships SET is_suspended=true WHERE person_id=$1", + [leaderId], + ), + ); + 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, wrong), 200); - await pool.query( - "DELETE FROM organization_global_administrator_grants WHERE grant_id='admin-0096'", + 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], + ), + ); + 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", @@ -1063,7 +1232,7 @@ try { block: "1", }; - let board = await readBoard(); + 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. @@ -1076,21 +1245,21 @@ try { 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, ); @@ -1099,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, @@ -1139,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", @@ -1394,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", @@ -1407,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, { @@ -1492,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, @@ -1511,8 +1693,8 @@ try { 403, "authority.denied", ); - await expectStatus(await request(ownCoveragePath, volunteer), 200); - const initialOwnCoverage = await readOwnCoverage(volunteer); + expectStatus(yield* request(ownCoveragePath, volunteer), 200); + const initialOwnCoverage = yield* readOwnCoverage(volunteer); assert.deepEqual( initialOwnCoverage.commitments .filter((item) => item.proposalId === apiCoverageProposalId) @@ -1521,19 +1703,19 @@ try { [commitments.Monday, commitments.Wednesday].sort(), ); assert.deepEqual( - (await readOwnCoverage(wrong)).commitments.filter( + (yield* readOwnCoverage(wrong)).commitments.filter( (item) => item.proposalId === apiCoverageProposalId, ), [], ); assert.deepEqual( - (await readOwnCoverage(candidate)).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 }, @@ -1544,7 +1726,7 @@ try { const coveredAbsenceCommand = { action: "ReportAbsence", commitmentId: commitments.Monday }; const reportAbsenceKey = randomBytes(18).toString("base64url"); - const reportedOwnCoverage = await commandOwnCoverage( + const reportedOwnCoverage = yield* commandOwnCoverage( volunteer, coveredAbsenceCommand, initialOwnCoverage.etag, @@ -1560,8 +1742,8 @@ try { assert.ok(coveredAbsence); assert.deepEqual( - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, coveredAbsenceCommand, @@ -1572,8 +1754,8 @@ try { ), reportedOwnCoverage, ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, { ...coveredAbsenceCommand, commitmentId: commitments.Wednesday }, @@ -1585,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, @@ -1597,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", @@ -1625,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(volunteer)).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), @@ -1660,26 +1844,30 @@ try { const leaderSession = { cookie: leader, origin: dashboardOrigin }; const outcomeApplicationId = PublicApplicationIdSchema.make(coverageApplicationId); - const outcomeAnswer = await outcomeClient.call(leaderSession, (client) => - client["admissionOutcomes.readOutcome"]({ applicationId: outcomeApplicationId }), + const outcomeAnswer = yield* step(() => + outcomeClient.call(leaderSession, (client) => + client["admissionOutcomes.readOutcome"]({ applicationId: outcomeApplicationId }), + ), ); - assert.ok(outcomeAnswer.ok, JSON.stringify(outcomeAnswer)); + assert.ok(outcomeAnswer.ok, yield* jsonText(outcomeAnswer)); const outcomeEntry = outcomeAnswer.value; assert.deepEqual([outcomeEntry.outcome, outcomeEntry.revision], [null, 0]); - const onCallAnswer = await outcomeClient.call(leaderSession, (client) => - client["admissionOutcomes.recordOutcome"]({ - applicationId: outcomeApplicationId, - idempotencyKey: IdempotencyKey.make(randomBytes(18).toString("base64url")), - ifMatch: outcomeEntry.etag, - request: { outcome: "Substitute" }, - }), + 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" }, + }), + ), ); - await outcomeClient.dispose(); - assert.ok(onCallAnswer.ok, JSON.stringify(onCallAnswer)); + yield* step(() => outcomeClient.dispose()); + assert.ok(onCallAnswer.ok, yield* jsonText(onCallAnswer)); const onCall = onCallAnswer.value; assert.deepEqual([onCall.outcome, onCall.revision], ["Substitute", 1]); @@ -1691,11 +1879,11 @@ try { kind: "Substitute", }; - coverageBoard = await readCoverageBoard(); + coverageBoard = yield* readCoverageBoard(); assert.deepEqual(coverageBoard.coverers, [onCallCoverer]); - assert.deepEqual((await readOwnCoverage(volunteer)).coverers, [onCallCoverer]); + assert.deepEqual((yield* readOwnCoverage(volunteer)).coverers, [onCallCoverer]); assert.deepEqual( - (await readOwnCoverage(candidate)).coverers, + (yield* readOwnCoverage(candidate)).coverers, [], "only a person with an open absence sees the names of possible coverers", ); @@ -1706,20 +1894,20 @@ try { recordCoverage(coveredAbsence.absenceId, substitute.personId), withdrawCoverage(coveredAbsence.absenceId), ]) - await rejectedCoverage( + yield* rejectedCoverage( ownCoveragePath, cookie, payload, - (await readOwnCoverage(cookie)).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(volunteer); + const ownBeforeRecord = yield* readOwnCoverage(volunteer); const recordKey = randomBytes(18).toString("base64url"); - const recordedOwn = await commandOwnCoverage( + const recordedOwn = yield* commandOwnCoverage( volunteer, recordCoverage(coveredAbsence.absenceId, substitute.personId), ownBeforeRecord.etag, @@ -1741,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), @@ -1753,8 +1941,8 @@ try { ), recordedOwn, ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, withdrawCoverage(coveredAbsence.absenceId), @@ -1764,8 +1952,8 @@ try { 409, "idempotency.digest-conflict", ); - await expectStatus( - await request( + expectStatus( + yield* request( ownCoveragePath, volunteer, withdrawCoverage(coveredAbsence.absenceId), @@ -1774,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, @@ -1800,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, @@ -1823,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), @@ -1865,7 +2055,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-04", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, completeCovered, @@ -1874,7 +2064,7 @@ try { "commitment.outcome-invalid", ); - let closeCoverageBoard = await commandCoverage( + let closeCoverageBoard = yield* commandCoverage( recordCoverage(coveredAbsence.absenceId, substitute.personId), withdrawnBoard.etag, ); @@ -1889,13 +2079,13 @@ try { [substitute.personId, leaderId], ); assert.deepEqual( - (await readOwnCoverage(candidate)).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, { @@ -1910,7 +2100,7 @@ try { ); const completeKey = randomBytes(18).toString("base64url"); - const coveredClosure = await commandCoverage( + const coveredClosure = yield* commandCoverage( completeCovered, closeCoverageBoard.etag, completeKey, @@ -1943,8 +2133,8 @@ try { ], ); assert.deepEqual( - await expectStatus( - await request( + expectStatus( + yield* request( coverageBoardPath, leader, completeCovered, @@ -1955,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 [ @@ -1983,17 +2173,17 @@ try { recordCoverage(coveredAbsence.absenceId, leaderId), closeCoverageBoard.etag, ], - [ownCoveragePath, volunteer, coveredAbsenceCommand, (await readOwnCoverage(volunteer)).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, @@ -2018,7 +2208,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-05", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, { @@ -2030,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, @@ -2046,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), @@ -2059,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(volunteer, { + const wednesdayOwn = yield* commandOwnCoverage(volunteer, { action: "ReportAbsence", commitmentId: commitments.Wednesday, }); @@ -2069,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), ); @@ -2088,7 +2278,7 @@ try { assert.ok(wednesdayRecord); - const bothAbsent = await commandCoverage( + const bothAbsent = yield* commandCoverage( { action: "ReportAbsenceForVolunteer", personId: leaderId, @@ -2102,7 +2292,7 @@ try { ); assert.ok(leaderWednesday); - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, recordCoverage(leaderWednesday.absenceId, substitute.personId), @@ -2118,7 +2308,7 @@ try { evidenceSource: "Skole Alfa kontakt, telefon 2024-03-06", }; - await rejectedCoverage( + yield* rejectedCoverage( coverageBoardPath, leader, { @@ -2131,8 +2321,9 @@ 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); @@ -2140,7 +2331,7 @@ try { for (const response of competingPartial.filter((result) => result.status !== 200)) assert.ok([409, 412].includes(response.status)); - const partialDecision = (await readCoverageBoard()).commitments.find( + const partialDecision = (yield* readCoverageBoard()).commitments.find( (item) => item.commitmentId === commitments.Wednesday, )?.decision; @@ -2149,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, @@ -2161,7 +2352,7 @@ try { assert.ok(cancelledAbsenceId); - const thursdayCovered = await commandCoverage( + const thursdayCovered = yield* commandCoverage( recordCoverage(cancelledAbsenceId, substitute.personId), thursdayAbsenceBoard.etag, ); @@ -2171,7 +2362,7 @@ try { ); assert.ok(thursdayRecord); - assert.deepEqual(await reservationsOf(commitments.Thursday), [ + assert.deepEqual(yield* reservationsOf(commitments.Thursday), [ { sourceKind: "Coverage", personId: substitute.personId, @@ -2187,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, @@ -2206,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) @@ -2246,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", @@ -2257,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) => [ @@ -2319,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] }, @@ -2335,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" @@ -2344,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, @@ -2382,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" @@ -2391,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], @@ -2435,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), @@ -2473,7 +2664,7 @@ try { auditActions: apiCoverageAudit.map((entry) => entry.action), }; - const browserLeaderBoard = await command({ + const browserLeaderBoard = yield* command({ action: "Create", personId: leaderId, schoolId: 962, @@ -2493,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, @@ -2524,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, @@ -2560,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, @@ -2573,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 }, @@ -2595,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" @@ -2604,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, @@ -2616,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 }, @@ -2651,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, @@ -2660,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) => [ @@ -2711,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, [ { @@ -2739,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", @@ -2749,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, @@ -2793,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( @@ -2833,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", @@ -2844,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, @@ -2866,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), @@ -2894,20 +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", ); - await native.dispose(); + 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 } = @@ -2962,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/safe-file-io.ts b/tools/e2e/safe-file-io.ts index 04479c1a..f85818eb 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 3b801204..91e60222 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", "golden-reimbursement.ts", "run-real-native-recruitment-assignment.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": [] }