Skip to content
Open
3 changes: 3 additions & 0 deletions STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +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 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.
Expand Down
11 changes: 8 additions & 3 deletions apps/backend/src/admission/outcome-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
);

Expand All @@ -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),
Expand Down
6 changes: 0 additions & 6 deletions apps/backend/src/authority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,13 @@ import {
} from "@vektorprogrammet/domain/identity";
import type {
DepartmentId,
OrganizationActor,
OrganizationAuthorityInstant,
OrganizationPersonAuthority,
PersonId,
ProfileRole,
} from "@vektorprogrammet/domain/organization";
import {
mapOrganizationAuthorityToDepartmentActor,
mapOrganizationAuthorityToOrganizationActor,
mapOrganizationAuthorityToProfileRole,
Organization,
OrganizationAuthorityInstantSchema,
Expand Down Expand Up @@ -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. */
Expand Down
1 change: 0 additions & 1 deletion apps/backend/src/http-api/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ describe("native request schema error transport", () => {
const response = yield* makeOrganizationTestHttp(
{
config: backendTestConfig.organization,
resolveActor: unreachable,
resolveAuthority: unreachable,
},
securityServices,
Expand Down
10 changes: 7 additions & 3 deletions apps/backend/src/onboarding/claim.http.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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],
Expand Down
13 changes: 6 additions & 7 deletions apps/backend/src/onboarding/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
credentialMatchesAccessSpec,
reaches,
ReachTarget,
requireDepartmentReach,
Scope,
} from "@vektorprogrammet/domain/authz";
import { Database } from "@vektorprogrammet/database";
Expand Down Expand Up @@ -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(
{
Expand All @@ -208,7 +208,7 @@ const authorize = (
presentation,
);

return auth;
return { ...auth, coordinator };
});

/**
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 2 additions & 27 deletions apps/backend/src/organization/http.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -148,19 +145,10 @@ const organization = {
}),
createDepartment: (
command: Parameters<OrganizationOperations["createDepartment"]>[0],
actor: Parameters<OrganizationOperations["createDepartment"]>[1],
_administrator: Parameters<OrganizationOperations["createDepartment"]>[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 }));
}
Expand Down Expand Up @@ -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"),
Expand Down
Loading
Loading