+
diff --git a/p11-saas-workspace/app/routes.ts b/p11-saas-workspace/app/routes.ts
index c66c967..1eb441e 100644
--- a/p11-saas-workspace/app/routes.ts
+++ b/p11-saas-workspace/app/routes.ts
@@ -1,31 +1,33 @@
import {
index,
layout,
- route,
+ prefix,
type RouteConfig,
+ route,
} from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
- layout("routes/organization.tsx", [
- route("organizations/:organizationId/projects", "routes/projects.tsx"),
- route(
- "organizations/:organizationId/projects/:projectId",
- "routes/project.tsx",
- ),
- route("organizations/:organizationId/billing", "routes/billing.tsx"),
+ layout("routes/workspace/layout.tsx", [
+ ...prefix("organizations/:organizationId", [
+ route("projects", "routes/organizations/projects.tsx"),
+ route("projects/:projectId", "routes/organizations/project.tsx"),
+ route("members", "routes/organizations/members.tsx"),
+ route("billing", "routes/organizations/billing.tsx"),
+ ]),
+ route("invitations", "routes/invitations/index.tsx"),
+ route("support", "routes/support/index.tsx"),
+ ]),
+ ...prefix("organizations/:organizationId", [
+ route("switch", "routes/organizations/switch.ts"),
+ route("invitations", "routes/organizations/issue-invitation.ts"),
+ ]),
+ route("invitations/:invitationId/accept", "routes/invitations/accept.ts"),
+ route("support/:approvalId/open", "routes/support/open.ts"),
+ ...prefix("auth", [
+ route("login", "routes/auth/login.ts"),
+ route("callback", "routes/auth/callback.ts"),
+ route("logout", "routes/auth/logout.ts"),
]),
- route("organizations/:organizationId/switch", "routes/switch.ts"),
- route(
- "organizations/:organizationId/invitations",
- "routes/issue-invitation.ts",
- ),
- route("invitations", "routes/invitations.tsx"),
- route("invitations/:invitationId/accept", "routes/accept-invitation.ts"),
- route("support", "routes/support.tsx"),
- route("support/:approvalId/open", "routes/open-support.ts"),
- route("auth/login", "routes/auth-login.ts"),
- route("auth/callback", "routes/auth-callback.ts"),
- route("auth/logout", "routes/auth-logout.ts"),
- route("health", "routes/health.ts"),
+ route("health", "routes/system/health.ts"),
] satisfies RouteConfig;
diff --git a/p11-saas-workspace/app/routes/accept-invitation.ts b/p11-saas-workspace/app/routes/accept-invitation.ts
deleted file mode 100644
index 65db961..0000000
--- a/p11-saas-workspace/app/routes/accept-invitation.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import type { Route } from "./+types/accept-invitation";
-import { services } from "../context.ts";
-import { actionFailure, field, mutation, routeId, version } from "../server.ts";
-import { idempotencyKey } from "../../src/server/validation.ts";
-import { badRequest } from "../../src/server/errors.ts";
-
-export async function action({ request, context, params }: Route.ActionArgs) {
- const runtime = services(context);
- try {
- const { session, form } = await mutation(request, context);
- const token = field(form, "token");
- if (token.length < 16 || token.length > 200) {
- throw badRequest("invalid_invitation_token");
- }
- const invitation = await runtime.workspace.acceptInvitation(
- session.principal,
- runtime.requestId,
- {
- invitationId: routeId(params.invitationId, "invitation_id"),
- token,
- expectedVersion: version(form),
- idempotencyKey: idempotencyKey(field(form, "idempotencyKey")),
- },
- );
- return { ok: true as const, invitation, requestId: runtime.requestId };
- } catch (error) {
- return actionFailure(error, runtime.requestId);
- }
-}
diff --git a/p11-saas-workspace/app/routes/auth-callback.ts b/p11-saas-workspace/app/routes/auth-callback.ts
deleted file mode 100644
index 14165f1..0000000
--- a/p11-saas-workspace/app/routes/auth-callback.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { Route } from "./+types/auth-callback";
-import { services } from "../context.ts";
-
-export function loader({ request, context }: Route.LoaderArgs) {
- return services(context).sessions.callback(request);
-}
diff --git a/p11-saas-workspace/app/routes/auth-login.ts b/p11-saas-workspace/app/routes/auth-login.ts
deleted file mode 100644
index dde240b..0000000
--- a/p11-saas-workspace/app/routes/auth-login.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { Route } from "./+types/auth-login";
-import { services } from "../context.ts";
-
-export function loader({ request, context }: Route.LoaderArgs) {
- return services(context).sessions.login(request);
-}
diff --git a/p11-saas-workspace/app/routes/auth-logout.ts b/p11-saas-workspace/app/routes/auth-logout.ts
deleted file mode 100644
index 5f173dd..0000000
--- a/p11-saas-workspace/app/routes/auth-logout.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { Route } from "./+types/auth-logout";
-import { services } from "../context.ts";
-
-export async function action({ request, context }: Route.ActionArgs) {
- return services(context).sessions.logout(request, await request.formData());
-}
diff --git a/p11-saas-workspace/app/routes/auth/callback.ts b/p11-saas-workspace/app/routes/auth/callback.ts
new file mode 100644
index 0000000..96495a2
--- /dev/null
+++ b/p11-saas-workspace/app/routes/auth/callback.ts
@@ -0,0 +1,11 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/callback";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ return await runtime.sessions.callback(request);
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/auth/login.ts b/p11-saas-workspace/app/routes/auth/login.ts
new file mode 100644
index 0000000..0beb568
--- /dev/null
+++ b/p11-saas-workspace/app/routes/auth/login.ts
@@ -0,0 +1,11 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/login";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ return await runtime.sessions.login(request);
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/auth/logout.ts b/p11-saas-workspace/app/routes/auth/logout.ts
new file mode 100644
index 0000000..7919a0d
--- /dev/null
+++ b/p11-saas-workspace/app/routes/auth/logout.ts
@@ -0,0 +1,11 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/logout";
+
+export async function action({ request, context }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ return await runtime.sessions.logout(request, await request.formData());
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/billing.tsx b/p11-saas-workspace/app/routes/billing.tsx
deleted file mode 100644
index 56d5284..0000000
--- a/p11-saas-workspace/app/routes/billing.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Link, useLoaderData } from "react-router";
-import type { Route } from "./+types/billing";
-import { services } from "../context.ts";
-import { authenticated, loaderFailure, routeId } from "../server.ts";
-
-export async function loader({ request, context, params }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const organizationId = routeId(params.organizationId, "organization_id");
- return {
- organizationId,
- billing: await runtime.workspace.billing(
- session.principal,
- runtime.requestId,
- organizationId,
- ),
- };
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export default function Billing() {
- const { billing, organizationId } = useLoaderData
();
- return (
-
- Billing projection
-
- {billing.plan} · {billing.seats} seats · $
- {(billing.monthlyCents / 100).toFixed(2)}
-
-
- Return to projects
-
-
- );
-}
diff --git a/p11-saas-workspace/app/routes/health.ts b/p11-saas-workspace/app/routes/health.ts
deleted file mode 100644
index 217b411..0000000
--- a/p11-saas-workspace/app/routes/health.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { Route } from "./+types/health";
-import { services } from "../context.ts";
-
-export async function loader({ context }: Route.LoaderArgs) {
- const runtime = services(context);
- await runtime.database.health();
- return Response.json({
- status: "ok",
- service: "p11-saas-workspace",
- authorizationMode: "permissive",
- });
-}
diff --git a/p11-saas-workspace/app/routes/home.tsx b/p11-saas-workspace/app/routes/home.tsx
index 08210be..01db4b4 100644
--- a/p11-saas-workspace/app/routes/home.tsx
+++ b/p11-saas-workspace/app/routes/home.tsx
@@ -1,20 +1,27 @@
import { redirect } from "react-router";
-import type { Route } from "./+types/home";
import { Login } from "../components/shell.tsx";
-import { sessionView } from "../server.ts";
+import { requestServicesContext } from "../context.ts";
+import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
- const session = await sessionView(request, context);
- if (!session) return null;
- if (session.user.id === "user-imani") throw redirect("/support");
- if (session.user.id === "user-lena" && session.memberships.length === 0) {
- throw redirect("/invitations");
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.sessions.optional(request);
+ if (!session) return null;
+ const workspace = await runtime.workspace.workspace(session.principal);
+ if (workspace.selection.organizationId) {
+ throw redirect(
+ `/organizations/${workspace.selection.organizationId}/projects`,
+ );
+ }
+ if (workspace.memberships.length) throw redirect("/invitations");
+ const approvals = await runtime.workspace.supportApprovals(
+ session.principal,
+ );
+ throw redirect(approvals.length ? "/support" : "/invitations");
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
}
- throw redirect(
- session.activeOrganizationId
- ? `/organizations/${session.activeOrganizationId}/projects`
- : "/invitations",
- );
}
export default function Home() {
diff --git a/p11-saas-workspace/app/routes/invitations.tsx b/p11-saas-workspace/app/routes/invitations.tsx
deleted file mode 100644
index 839a866..0000000
--- a/p11-saas-workspace/app/routes/invitations.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import { useFetcher, useLoaderData } from "react-router";
-import type { Route } from "./+types/invitations";
-import { services } from "../context.ts";
-import { authenticated, commandKey, loaderFailure } from "../server.ts";
-import { Outcome } from "../components/shell.tsx";
-
-export async function loader({ request, context }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const invitations = await runtime.workspace.invitationsFor(
- session.principal,
- );
- return {
- csrfToken: session.csrfToken,
- invitations,
- commandKeys: Object.fromEntries(
- invitations.map((item) => [item.id, commandKey()]),
- ),
- };
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export default function Invitations() {
- const data = useLoaderData();
- const accept = useFetcher();
- return (
-
-
-
- );
-}
diff --git a/p11-saas-workspace/app/routes/invitations/accept.ts b/p11-saas-workspace/app/routes/invitations/accept.ts
new file mode 100644
index 0000000..0ded86a
--- /dev/null
+++ b/p11-saas-workspace/app/routes/invitations/accept.ts
@@ -0,0 +1,30 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/accept";
+
+export async function action({ request, context, params }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const { session, form } = await runtime.requests.mutation(request);
+ const token = runtime.requests.input.invitationToken(
+ runtime.requests.input.field(form, "token"),
+ );
+ const invitation = await runtime.workspace.acceptInvitation(
+ session.principal,
+ runtime.requestId,
+ {
+ invitationId: runtime.requests.input.identifier(
+ params.invitationId,
+ "invitation_id",
+ ),
+ token,
+ expectedVersion: runtime.requests.input.version(form),
+ idempotencyKey: runtime.requests.input.idempotencyKey(
+ runtime.requests.input.field(form, "idempotencyKey"),
+ ),
+ },
+ );
+ return { ok: true as const, invitation, requestId: runtime.requestId };
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/invitations/index.tsx b/p11-saas-workspace/app/routes/invitations/index.tsx
new file mode 100644
index 0000000..037cac0
--- /dev/null
+++ b/p11-saas-workspace/app/routes/invitations/index.tsx
@@ -0,0 +1,187 @@
+import { Link, useFetcher, useOutletContext } from "react-router";
+import { Inbox } from "../../components/icons.tsx";
+import { EmptyState, Outcome } from "../../components/shell.tsx";
+import { OpenWorkspace, PageHeader } from "../../components/workspace.tsx";
+import { requestServicesContext } from "../../context.ts";
+import { useExpiries } from "../../hooks/use-expiries.ts";
+import { useHydrated } from "../../hooks/use-hydrated.ts";
+import { errorNotice } from "../../utils/messages.ts";
+import { displayTime } from "../../utils/time.ts";
+import type { WorkspaceContext } from "../workspace/layout.tsx";
+import type { Route } from "./+types/index";
+import type { action as acceptAction } from "./accept.ts";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const invitations = await runtime.workspace.invitationsFor(
+ session.principal,
+ );
+ return {
+ renderedAt: Date.now(),
+ invitations,
+ commandKeys: Object.fromEntries(
+ invitations.map((item) => [item.id, runtime.requests.commandKey()]),
+ ),
+ };
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export default function Invitations({
+ loaderData: data,
+}: Route.ComponentProps) {
+ const workspace = useOutletContext();
+ const accept = useFetcher();
+ const hydrated = useHydrated();
+ const now = useExpiries(
+ data.invitations
+ .filter((item) => item.state === "pending")
+ .map((item) => item.expiresAt),
+ data.renderedAt,
+ );
+ return (
+
+
+ {!workspace.selection.organizationId && workspace.memberships.length ? (
+
+ Choose a workspace from the menu, or open an accepted invitation
+ below.
+
+ ) : null}
+
+ Enable JavaScript to accept an invitation.
+ {data.invitations.length ? (
+ data.invitations.map((item) => {
+ const state =
+ item.state === "pending" && Date.parse(item.expiresAt) <= now
+ ? "expired"
+ : item.state;
+ const membership = workspace.memberships.find(
+ (member) => member.organizationId === item.organizationId,
+ );
+ return (
+
+
+
+
+ {item.organizationName ?? item.organizationId}
+
+ Invited as {item.role}
+
+
+ {state}
+
+
+ {state === "pending" ? (
+
+
+ Expires {displayTime(item.expiresAt)}
+
+
+
+
+
+ One-time invitation token
+
+
+
+ {accept.state === "submitting"
+ ? "Accepting…"
+ : "Accept invitation"}
+
+
+ ) : state === "accepted" ? (
+ <>
+
+ You accepted this invitation.
+ {membership
+ ? ""
+ : " Ask an admin for a new invitation if you need access again."}
+
+ {membership ? (
+ workspace.selection.organizationId ===
+ item.organizationId ? (
+
+ Go to workspace
+
+ ) : (
+
+ )
+ ) : null}
+ >
+ ) : (
+
+ {state === "expired"
+ ? `This invitation expired ${displayTime(item.expiresAt)}.`
+ : "This invitation was revoked."}{" "}
+ Ask a workspace admin for a new invitation.
+
+ )}
+
+ );
+ })
+ ) : (
+ } title="No invitations">
+ Ask a workspace admin to invite you, then return here with the
+ one-time token.
+
+ )}
+ {accept.data ? (
+
+ {!accept.data.ok
+ ? errorNotice(accept.data.error, accept.data.requestId)
+ : "Invitation accepted. Choose a workspace to continue."}
+
+ ) : null}
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/routes/issue-invitation.ts b/p11-saas-workspace/app/routes/issue-invitation.ts
deleted file mode 100644
index 29f9fb7..0000000
--- a/p11-saas-workspace/app/routes/issue-invitation.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { Route } from "./+types/issue-invitation";
-import { services } from "../context.ts";
-import { actionFailure, field, mutation, routeId, version } from "../server.ts";
-import {
- idempotencyKey,
- identifier,
- inviteRole,
-} from "../../src/server/validation.ts";
-
-export async function action({ request, context, params }: Route.ActionArgs) {
- const runtime = services(context);
- try {
- const { session, form } = await mutation(request, context);
- const result = await runtime.workspace.issueInvitation(
- session.principal,
- runtime.requestId,
- {
- organizationId: routeId(params.organizationId, "organization_id"),
- targetPrincipalId: identifier(
- field(form, "targetPrincipalId"),
- "target_principal_id",
- ),
- role: inviteRole(field(form, "role")),
- expectedSelectionVersion: version(form),
- idempotencyKey: idempotencyKey(field(form, "idempotencyKey")),
- },
- );
- return { ok: true as const, ...result, requestId: runtime.requestId };
- } catch (error) {
- return actionFailure(error, runtime.requestId);
- }
-}
diff --git a/p11-saas-workspace/app/routes/open-support.ts b/p11-saas-workspace/app/routes/open-support.ts
deleted file mode 100644
index 4c9efba..0000000
--- a/p11-saas-workspace/app/routes/open-support.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Route } from "./+types/open-support";
-import { services } from "../context.ts";
-import { actionFailure, field, mutation, routeId, version } from "../server.ts";
-import { idempotencyKey } from "../../src/server/validation.ts";
-
-export async function action({ request, context, params }: Route.ActionArgs) {
- const runtime = services(context);
- try {
- const { session, form } = await mutation(request, context);
- const approval = await runtime.workspace.openSupport(
- session.principal,
- runtime.requestId,
- routeId(params.approvalId, "approval_id"),
- version(form),
- idempotencyKey(field(form, "idempotencyKey")),
- );
- return { ok: true as const, approval, requestId: runtime.requestId };
- } catch (error) {
- return actionFailure(error, runtime.requestId);
- }
-}
diff --git a/p11-saas-workspace/app/routes/organization.tsx b/p11-saas-workspace/app/routes/organization.tsx
deleted file mode 100644
index d2f3767..0000000
--- a/p11-saas-workspace/app/routes/organization.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import { Link, Outlet, useFetcher, useLoaderData } from "react-router";
-import type { Route } from "./+types/organization";
-import { services } from "../context.ts";
-import {
- authenticated,
- commandKey,
- loaderFailure,
- routeId,
-} from "../server.ts";
-import { Outcome } from "../components/shell.tsx";
-
-export async function loader({ request, context, params }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const organizationId = routeId(params.organizationId, "organization_id");
- const [workspace, projects, members, invitations] = await Promise.all([
- runtime.workspace.workspace(session.principal),
- runtime.workspace.listProjects(
- session.principal,
- runtime.requestId,
- organizationId,
- ),
- runtime.workspace.members(
- session.principal,
- runtime.requestId,
- organizationId,
- ),
- runtime.workspace.invitations(
- session.principal,
- runtime.requestId,
- organizationId,
- ),
- ]);
- return {
- organizationId,
- projects,
- members,
- invitations,
- memberships: workspace.memberships,
- selection: workspace.selection,
- csrfToken: session.csrfToken,
- switchKeys: Object.fromEntries(
- workspace.memberships.map((membership) => [
- membership.organizationId,
- commandKey(),
- ]),
- ),
- invitationKey: commandKey(),
- };
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export default function Organization() {
- const data = useLoaderData();
- const invitation = useFetcher();
- const switcher = useFetcher();
- const activeMembership = data.memberships.find(
- (item) => item.organizationId === data.organizationId,
- );
- return (
-
-
- Organizations
- {data.memberships.map((membership) => (
-
-
-
-
-
- {membership.organizationName}
- {membership.role}
-
-
- ))}
- Invitations
- {switcher.data && typeof switcher.data === "object" ? (
-
- {"error" in switcher.data
- ? `${String(switcher.data.error)} · ${String(switcher.data.requestId)}`
- : "Organization switched."}
-
- ) : null}
-
-
-
-
-
-
- {data.projects.map((project) => (
-
- {project.name} v{project.version}
-
- ))}
-
-
-
-
-
-
-
- );
-}
diff --git a/p11-saas-workspace/app/routes/organizations/billing.tsx b/p11-saas-workspace/app/routes/organizations/billing.tsx
new file mode 100644
index 0000000..cbe0535
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/billing.tsx
@@ -0,0 +1,95 @@
+import { Link, useOutletContext } from "react-router";
+import { PageHeader } from "../../components/workspace.tsx";
+import { requestServicesContext } from "../../context.ts";
+import type { WorkspaceContext } from "../workspace/layout.tsx";
+import type { Route } from "./+types/billing";
+
+export async function loader({ request, context, params }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const organizationId = runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ );
+ const billing = await runtime.workspace.billing(
+ session.principal,
+ runtime.requestId,
+ organizationId,
+ );
+ return {
+ organizationId,
+ billing,
+ };
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export default function Billing({
+ loaderData: { billing, organizationId },
+}: Route.ComponentProps) {
+ const { activeSeats } = billing;
+ const workspace = useOutletContext();
+ const organizationName =
+ workspace?.memberships.find(
+ (membership) => membership.organizationId === organizationId,
+ )?.organizationName ?? organizationId;
+ const seatUsage =
+ billing.seats > 0
+ ? Math.min(100, Math.round((activeSeats / billing.seats) * 100))
+ : 0;
+ return (
+
+
+
+ Return to projects
+
+
+
+
+ Plan
+ {billing.plan}
+
+
+ Seats
+
+ {activeSeats}
+ / {billing.seats}
+
+
+
+ Monthly
+ ${(billing.monthlyCents / 100).toFixed(2)}
+
+
+
+
+
+ {seatUsage}%
+
+
+ {activeSeats} active members out of {billing.seats} seats.
+
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/routes/organizations/issue-invitation.ts b/p11-saas-workspace/app/routes/organizations/issue-invitation.ts
new file mode 100644
index 0000000..7847180
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/issue-invitation.ts
@@ -0,0 +1,33 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/issue-invitation";
+
+export async function action({ request, context, params }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const { session, form } = await runtime.requests.mutation(request);
+ const result = await runtime.workspace.issueInvitation(
+ session.principal,
+ runtime.requestId,
+ {
+ organizationId: runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ ),
+ targetPrincipalId: runtime.requests.input.identifier(
+ runtime.requests.input.field(form, "targetPrincipalId"),
+ "target_principal_id",
+ ),
+ role: runtime.requests.input.inviteRole(
+ runtime.requests.input.field(form, "role"),
+ ),
+ expectedSelectionVersion: runtime.requests.input.version(form),
+ idempotencyKey: runtime.requests.input.idempotencyKey(
+ runtime.requests.input.field(form, "idempotencyKey"),
+ ),
+ },
+ );
+ return { ok: true as const, ...result, requestId: runtime.requestId };
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/organizations/members.tsx b/p11-saas-workspace/app/routes/organizations/members.tsx
new file mode 100644
index 0000000..d84372d
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/members.tsx
@@ -0,0 +1,77 @@
+import { useOutletContext } from "react-router";
+import { PageHeader, TeamPanels } from "../../components/workspace.tsx";
+import { requestServicesContext } from "../../context.ts";
+import type { Invitation, InvitationCandidate } from "../../types/contracts.ts";
+import type { WorkspaceContext } from "../workspace/layout.tsx";
+import type { Route } from "./+types/members";
+
+export async function loader({ request, context, params }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const organizationId = runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ );
+ const members = await runtime.workspace.members(
+ session.principal,
+ runtime.requestId,
+ organizationId,
+ );
+ let invitations: readonly Invitation[] = [];
+ let candidates: readonly InvitationCandidate[] = [];
+ let canInvite = true;
+ try {
+ const setup = await runtime.workspace.invitationSetup(
+ session.principal,
+ runtime.requestId,
+ organizationId,
+ );
+ invitations = setup.invitations;
+ candidates = setup.candidates;
+ } catch (error) {
+ if (!runtime.requests.isDenied(error)) throw error;
+ canInvite = false;
+ }
+ return {
+ organizationId,
+ members,
+ invitations,
+ candidates,
+ canInvite,
+ invitationKey: runtime.requests.commandKey(),
+ };
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export default function Members({ loaderData: data }: Route.ComponentProps) {
+ const workspace = useOutletContext();
+ const activeOrganization = workspace.memberships.find(
+ (membership) => membership.organizationId === data.organizationId,
+ );
+ const organizationName =
+ activeOrganization?.organizationName ?? data.organizationId;
+ return (
+
+
+ {data.members.length} active
+
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/routes/organizations/project.tsx b/p11-saas-workspace/app/routes/organizations/project.tsx
new file mode 100644
index 0000000..d0b91f8
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/project.tsx
@@ -0,0 +1,238 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ Form,
+ useBeforeUnload,
+ useBlocker,
+ useNavigation,
+ useOutletContext,
+} from "react-router";
+import { Outcome } from "../../components/shell.tsx";
+import { ProjectList } from "../../components/workspace.tsx";
+import { requestServicesContext } from "../../context.ts";
+import { useHydrated } from "../../hooks/use-hydrated.ts";
+import { errorNotice } from "../../utils/messages.ts";
+import type { WorkspaceContext } from "../workspace/layout.tsx";
+import type { Route } from "./+types/project";
+
+export async function loader({ request, context, params }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const project = await runtime.workspace.readProject(
+ session.principal,
+ runtime.requestId,
+ runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ ),
+ runtime.requests.input.identifier(params.projectId, "project_id"),
+ );
+ const projects = await runtime.workspace.listProjects(
+ session.principal,
+ runtime.requestId,
+ project.organizationId,
+ );
+ return {
+ project,
+ projects,
+ commandKey: runtime.requests.commandKey(),
+ };
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export async function action({ request, context, params }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const { session, form } = await runtime.requests.mutation(request);
+ const project = await runtime.workspace.writeProject(
+ session.principal,
+ runtime.requestId,
+ {
+ organizationId: runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ ),
+ projectId: runtime.requests.input.identifier(
+ params.projectId,
+ "project_id",
+ ),
+ name: runtime.requests.input.projectName(
+ runtime.requests.input.field(form, "name"),
+ ),
+ body: runtime.requests.input.projectBody(
+ runtime.requests.input.field(form, "body"),
+ ),
+ expectedVersion: runtime.requests.input.version(form),
+ idempotencyKey: runtime.requests.input.idempotencyKey(
+ runtime.requests.input.field(form, "idempotencyKey"),
+ ),
+ },
+ );
+ return { ok: true as const, project, requestId: runtime.requestId };
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
+
+export default function Project({
+ loaderData: data,
+ actionData: result,
+}: Route.ComponentProps) {
+ const workspace = useOutletContext();
+ const activeOrganization = workspace.memberships.find(
+ (membership) => membership.organizationId === data.project.organizationId,
+ );
+ return (
+
+ );
+}
+
+function ProjectEditor({
+ data,
+ result,
+ csrfToken,
+}: Readonly<{
+ data: Route.ComponentProps["loaderData"];
+ result: Route.ComponentProps["actionData"];
+ csrfToken: string;
+}>) {
+ const [draft, setDraft] = useState(data.project);
+ const [fields, setFields] = useState({
+ name: data.project.name,
+ body: data.project.body,
+ });
+ const hydrated = useHydrated();
+ const formRef = useRef(null);
+ const appliedSave = useRef(0);
+ const dirty = fields.name !== draft.name || fields.body !== draft.body;
+ const navigation = useNavigation();
+ const saving = navigation.state === "submitting";
+ useBlocker(
+ ({ currentLocation, nextLocation }) =>
+ dirty &&
+ currentLocation.pathname !== nextLocation.pathname &&
+ !window.confirm("Discard your unsaved project changes?"),
+ );
+ useBeforeUnload((event) => {
+ if (dirty) {
+ event.preventDefault();
+ event.returnValue = "";
+ }
+ });
+ useEffect(() => {
+ if (
+ result?.ok &&
+ result.project.id === data.project.id &&
+ result.project.version > appliedSave.current
+ ) {
+ appliedSave.current = result.project.version;
+ setDraft(result.project);
+ setFields({ name: result.project.name, body: result.project.body });
+ }
+ }, [result, data.project.id]);
+ useEffect(() => {
+ // Organization switching and logout use forms outside this editor.
+ const confirmLeaving = (event: SubmitEvent) => {
+ if (!dirty || event.target === formRef.current) return;
+ const target = event.target;
+ if (!(target instanceof HTMLFormElement)) return;
+ const path = new URL(target.action, location.href).pathname;
+ if (!path.endsWith("/switch") && path !== "/auth/logout") return;
+ if (!window.confirm("Discard your unsaved project changes?")) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ };
+ document.addEventListener("submit", confirmLeaving, true);
+ return () => document.removeEventListener("submit", confirmLeaving, true);
+ }, [dirty]);
+ return (
+ <>
+
+ {result ? (
+
+ {result.ok
+ ? `Changes saved. Version ${result.project.version}.`
+ : errorNotice(result.error, result.requestId)}
+
+ ) : null}
+ >
+ );
+}
diff --git a/p11-saas-workspace/app/routes/organizations/projects.tsx b/p11-saas-workspace/app/routes/organizations/projects.tsx
new file mode 100644
index 0000000..6dd1557
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/projects.tsx
@@ -0,0 +1,39 @@
+import { redirect } from "react-router";
+import { Folder } from "../../components/icons.tsx";
+import { EmptyState } from "../../components/shell.tsx";
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/projects";
+
+export async function loader({ request, context, params }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const organizationId = runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ );
+ const projects = await runtime.workspace.listProjects(
+ session.principal,
+ runtime.requestId,
+ organizationId,
+ );
+ if (projects[0]) {
+ throw redirect(
+ `/organizations/${organizationId}/projects/${projects[0].id}`,
+ );
+ }
+ return null;
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export default function Projects() {
+ return (
+
+ } title="No projects available">
+ Choose another workspace to continue.
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/routes/organizations/switch.ts b/p11-saas-workspace/app/routes/organizations/switch.ts
new file mode 100644
index 0000000..ab6d4f0
--- /dev/null
+++ b/p11-saas-workspace/app/routes/organizations/switch.ts
@@ -0,0 +1,26 @@
+import { redirect } from "react-router";
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/switch";
+
+export async function action({ request, context, params }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const { session, form } = await runtime.requests.mutation(request);
+ const organizationId = runtime.requests.input.identifier(
+ params.organizationId,
+ "organization_id",
+ );
+ await runtime.workspace.switchOrganization(
+ session.principal,
+ runtime.requestId,
+ organizationId,
+ runtime.requests.input.version(form),
+ runtime.requests.input.idempotencyKey(
+ runtime.requests.input.field(form, "idempotencyKey"),
+ ),
+ );
+ return redirect(`/organizations/${organizationId}/projects`);
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/project.tsx b/p11-saas-workspace/app/routes/project.tsx
deleted file mode 100644
index 981377b..0000000
--- a/p11-saas-workspace/app/routes/project.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import {
- Form,
- useActionData,
- useLoaderData,
- useNavigation,
-} from "react-router";
-import type { Route } from "./+types/project";
-import { services } from "../context.ts";
-import {
- actionFailure,
- authenticated,
- commandKey,
- field,
- loaderFailure,
- mutation,
- routeId,
- version,
-} from "../server.ts";
-import {
- idempotencyKey,
- projectBody,
- projectName,
-} from "../../src/server/validation.ts";
-import { Outcome } from "../components/shell.tsx";
-
-export async function loader({ request, context, params }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const project = await runtime.workspace.readProject(
- session.principal,
- runtime.requestId,
- routeId(params.organizationId, "organization_id"),
- routeId(params.projectId, "project_id"),
- );
- return { project, csrfToken: session.csrfToken, commandKey: commandKey() };
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export async function action({ request, context, params }: Route.ActionArgs) {
- const runtime = services(context);
- try {
- const { session, form } = await mutation(request, context);
- const project = await runtime.workspace.writeProject(
- session.principal,
- runtime.requestId,
- {
- organizationId: routeId(params.organizationId, "organization_id"),
- projectId: routeId(params.projectId, "project_id"),
- name: projectName(field(form, "name")),
- body: projectBody(field(form, "body")),
- expectedVersion: version(form),
- idempotencyKey: idempotencyKey(field(form, "idempotencyKey")),
- },
- );
- return { ok: true as const, project, requestId: runtime.requestId };
- } catch (error) {
- return actionFailure(error, runtime.requestId);
- }
-}
-
-export default function Project() {
- const data = useLoaderData();
- const result = useActionData();
- const navigation = useNavigation();
- const saving = navigation.state === "submitting";
- return (
- <>
-
-
-
-
-
- Project name
-
-
-
- Project body
-
-
-
- {saving ? "Saving…" : "Save current version"}
-
-
- {result ? (
-
- {result.ok
- ? `Saved version ${result.project.version} · ${result.requestId}`
- : `${result.error} · ${result.requestId}`}
-
- ) : null}
- >
- );
-}
diff --git a/p11-saas-workspace/app/routes/projects.tsx b/p11-saas-workspace/app/routes/projects.tsx
deleted file mode 100644
index bf335f7..0000000
--- a/p11-saas-workspace/app/routes/projects.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { redirect } from "react-router";
-import type { Route } from "./+types/projects";
-import { services } from "../context.ts";
-import { authenticated, loaderFailure, routeId } from "../server.ts";
-
-export async function loader({ request, context, params }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const organizationId = routeId(params.organizationId, "organization_id");
- const projects = await runtime.workspace.listProjects(
- session.principal,
- runtime.requestId,
- organizationId,
- );
- if (projects[0]) {
- throw redirect(
- `/organizations/${organizationId}/projects/${projects[0].id}`,
- );
- }
- return null;
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export default function Projects() {
- return No project returned.
;
-}
diff --git a/p11-saas-workspace/app/routes/support.tsx b/p11-saas-workspace/app/routes/support.tsx
deleted file mode 100644
index f636489..0000000
--- a/p11-saas-workspace/app/routes/support.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Link, useFetcher, useLoaderData } from "react-router";
-import type { Route } from "./+types/support";
-import { services } from "../context.ts";
-import { authenticated, commandKey, loaderFailure } from "../server.ts";
-import { Outcome } from "../components/shell.tsx";
-
-export async function loader({ request, context }: Route.LoaderArgs) {
- const runtime = services(context);
- try {
- const session = await authenticated(request, context);
- const approvals = await runtime.workspace.supportApprovals(
- session.principal,
- );
- const selectedId = new URL(request.url).searchParams.get("project");
- const selected = approvals.find((item) => item.projectId === selectedId);
- const project = selected
- ? await runtime.workspace.readProject(
- session.principal,
- runtime.requestId,
- selected.organizationId,
- selected.projectId,
- )
- : undefined;
- return {
- approvals,
- project,
- csrfToken: session.csrfToken,
- commandKeys: Object.fromEntries(
- approvals.map((item) => [item.id, commandKey()]),
- ),
- };
- } catch (error) {
- loaderFailure(error, runtime.requestId);
- }
-}
-
-export default function Support() {
- const data = useLoaderData();
- const open = useFetcher();
- return (
-
-
- Approved support scope
- {data.approvals.map((approval) => (
-
- {approval.projectId} · read only
-
- Expires {new Date(approval.expiresAt).toLocaleTimeString()}
-
-
-
-
-
-
-
- {open.state === "submitting"
- ? "Opening…"
- : "Open exact scope"}
-
-
- {approval.activeUntil ? (
-
- Read approved project
-
- ) : null}
-
-
- ))}
- {data.project ? (
-
- {data.project.name}
- {data.project.body}
-
- ) : null}
- {open.data && typeof open.data === "object" ? (
-
- {"error" in open.data
- ? `${String(open.data.error)} · ${String(open.data.requestId)}`
- : "Exact support scope opened."}
-
- ) : null}
-
-
- );
-}
diff --git a/p11-saas-workspace/app/routes/support/index.tsx b/p11-saas-workspace/app/routes/support/index.tsx
new file mode 100644
index 0000000..cc3e7bd
--- /dev/null
+++ b/p11-saas-workspace/app/routes/support/index.tsx
@@ -0,0 +1,183 @@
+import { Link, useFetcher, useOutletContext } from "react-router";
+import { Shield } from "../../components/icons.tsx";
+import { EmptyState, Outcome } from "../../components/shell.tsx";
+import { PageHeader } from "../../components/workspace.tsx";
+import { requestServicesContext } from "../../context.ts";
+import { useExpiries } from "../../hooks/use-expiries.ts";
+import { useHydrated } from "../../hooks/use-hydrated.ts";
+import { errorNotice } from "../../utils/messages.ts";
+import { displayTime } from "../../utils/time.ts";
+import type { WorkspaceContext } from "../workspace/layout.tsx";
+import type { Route } from "./+types/index";
+import type { action as openAction } from "./open.ts";
+
+export async function loader({ request, context }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const session = await runtime.requests.authenticated(request);
+ const approvals = await runtime.workspace.supportApprovals(
+ session.principal,
+ );
+ const selectedId = new URL(request.url).searchParams.get("project");
+ const selected = approvals.find((item) => item.projectId === selectedId);
+ const project = selected
+ ? await runtime.workspace.readProject(
+ session.principal,
+ runtime.requestId,
+ selected.organizationId,
+ selected.projectId,
+ )
+ : undefined;
+ return {
+ renderedAt: Date.now(),
+ approvals,
+ project,
+ commandKeys: Object.fromEntries(
+ approvals.map((item) => [item.id, runtime.requests.commandKey()]),
+ ),
+ };
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+}
+
+export default function Support({ loaderData: data }: Route.ComponentProps) {
+ const workspace = useOutletContext();
+ const open = useFetcher();
+ const hydrated = useHydrated();
+ const now = useExpiries(
+ data.approvals.flatMap((approval) =>
+ approval.activeUntil
+ ? [approval.expiresAt, approval.activeUntil]
+ : [approval.expiresAt],
+ ),
+ data.renderedAt,
+ );
+ return (
+
+
+
+ Enable JavaScript to open a support session.
+ {data.approvals.length ? (
+ data.approvals.map((approval) => {
+ const expired = Date.parse(approval.expiresAt) <= now;
+ const active =
+ !expired &&
+ Boolean(
+ approval.activeUntil && Date.parse(approval.activeUntil) > now,
+ );
+ return (
+
+
+
+
+
+
+
+ {approval.projectName ?? `Project ${approval.projectId}`}
+
+
+ {approval.organizationName ?? approval.organizationId} ·
+ read only
+
+
+ Approval {expired ? "expired" : "expires"}{" "}
+ {displayTime(approval.expiresAt)}
+
+
+
+ {expired
+ ? "Approval expired"
+ : active
+ ? "Access open"
+ : "Access closed"}
+
+
+
+
+
+
+
+
+ {open.state === "submitting"
+ ? "Opening…"
+ : active
+ ? "Extend access"
+ : "Open support access"}
+
+
+ {active ? (
+
+ Read approved project
+
+ ) : null}
+
+ {active ? (
+
+ Access ends at {displayTime(approval.activeUntil ?? "")}.
+
+ ) : (
+
+ {expired
+ ? "Ask for a renewed approval to continue."
+ : "Open access to start a session of up to ten minutes."}
+
+ )}
+
+ );
+ })
+ ) : (
+ } title="No support approvals">
+ An approver must grant a time-boxed project scope before you can
+ open a support session.
+
+ )}
+ {data.project ? (
+
+ {data.project.name}
+ {data.project.body}
+
+ ) : null}
+ {open.data ? (
+
+ {!open.data.ok
+ ? errorNotice(open.data.error, open.data.requestId)
+ : "Support access opened."}
+
+ ) : null}
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/routes/support/open.ts b/p11-saas-workspace/app/routes/support/open.ts
new file mode 100644
index 0000000..58f0b63
--- /dev/null
+++ b/p11-saas-workspace/app/routes/support/open.ts
@@ -0,0 +1,21 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/open";
+
+export async function action({ request, context, params }: Route.ActionArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ const { session, form } = await runtime.requests.mutation(request);
+ const approval = await runtime.workspace.openSupport(
+ session.principal,
+ runtime.requestId,
+ runtime.requests.input.identifier(params.approvalId, "approval_id"),
+ runtime.requests.input.version(form),
+ runtime.requests.input.idempotencyKey(
+ runtime.requests.input.field(form, "idempotencyKey"),
+ ),
+ );
+ return { ok: true as const, approval, requestId: runtime.requestId };
+ } catch (error) {
+ return runtime.requests.actionFailure(error);
+ }
+}
diff --git a/p11-saas-workspace/app/routes/switch.ts b/p11-saas-workspace/app/routes/switch.ts
deleted file mode 100644
index a84bc92..0000000
--- a/p11-saas-workspace/app/routes/switch.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { redirect } from "react-router";
-import type { Route } from "./+types/switch";
-import { services } from "../context.ts";
-import { actionFailure, field, mutation, routeId, version } from "../server.ts";
-import { idempotencyKey } from "../../src/server/validation.ts";
-
-export async function action({ request, context, params }: Route.ActionArgs) {
- const runtime = services(context);
- try {
- const { session, form } = await mutation(request, context);
- const organizationId = routeId(params.organizationId, "organization_id");
- await runtime.workspace.switchOrganization(
- session.principal,
- runtime.requestId,
- organizationId,
- version(form),
- idempotencyKey(field(form, "idempotencyKey")),
- );
- return redirect(`/organizations/${organizationId}/projects`);
- } catch (error) {
- return actionFailure(error, runtime.requestId);
- }
-}
diff --git a/p11-saas-workspace/app/routes/system/health.ts b/p11-saas-workspace/app/routes/system/health.ts
new file mode 100644
index 0000000..4d77914
--- /dev/null
+++ b/p11-saas-workspace/app/routes/system/health.ts
@@ -0,0 +1,16 @@
+import { requestServicesContext } from "../../context.ts";
+import type { Route } from "./+types/health";
+
+export async function loader({ context }: Route.LoaderArgs) {
+ const runtime = context.get(requestServicesContext);
+ try {
+ await runtime.database.health();
+ } catch (error) {
+ return runtime.requests.loaderFailure(error);
+ }
+ return Response.json({
+ status: "ok",
+ service: "p11-saas-workspace",
+ authorizationMode: "permissive",
+ });
+}
diff --git a/p11-saas-workspace/app/routes/workspace/layout.tsx b/p11-saas-workspace/app/routes/workspace/layout.tsx
new file mode 100644
index 0000000..7b6ebce
--- /dev/null
+++ b/p11-saas-workspace/app/routes/workspace/layout.tsx
@@ -0,0 +1,35 @@
+import { Outlet, useRouteLoaderData } from "react-router";
+import { WorkspaceSidebar } from "../../components/workspace.tsx";
+import type { loader as rootLoader } from "../../root.tsx";
+import type { Membership } from "../../types/contracts.ts";
+
+export type WorkspaceContext = Readonly<{
+ organizationId: string | null;
+ selection: Readonly<{ organizationId: string | null; version: number }>;
+ memberships: readonly Membership[];
+ csrfToken: string;
+ switchKeys: Readonly>;
+}>;
+
+export default function WorkspaceLayout() {
+ const session = useRouteLoaderData("root")?.session;
+ if (!session) return null;
+ const workspace: WorkspaceContext = {
+ organizationId: session.navigationOrganizationId,
+ selection: {
+ organizationId: session.activeOrganizationId,
+ version: session.selectionVersion,
+ },
+ memberships: session.memberships,
+ csrfToken: session.csrfToken,
+ switchKeys: session.switchKeys,
+ };
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/p11-saas-workspace/app/server.ts b/p11-saas-workspace/app/server.ts
deleted file mode 100644
index 2c901a2..0000000
--- a/p11-saas-workspace/app/server.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { data, redirect, type RouterContextProvider } from "react-router";
-import { services } from "./context.ts";
-import { AppError } from "../src/server/errors.ts";
-import { randomToken } from "../src/server/crypto.ts";
-import { identifier } from "../src/server/validation.ts";
-import type { Session } from "../src/server/models.ts";
-import type { SessionView } from "../src/shared/contracts.ts";
-
-export async function authenticated(
- request: Request,
- context: Readonly,
-): Promise {
- const current = await services(context).sessions.optional(request);
- if (!current) throw redirect("/");
- return current;
-}
-
-export async function sessionView(
- request: Request,
- context: Readonly,
-): Promise {
- const runtime = services(context);
- const session = await runtime.sessions.optional(request);
- if (!session) return undefined;
- const workspace = await runtime.workspace.workspace(session.principal);
- return {
- user: { id: session.principal.id, name: session.principal.name },
- csrfToken: session.csrfToken,
- expiresAt: new Date(session.expiresAt).toISOString(),
- activeOrganizationId: workspace.selection.organizationId,
- selectionVersion: workspace.selection.version,
- memberships: workspace.memberships,
- };
-}
-
-export async function mutation(
- request: Request,
- context: Readonly,
-): Promise<{ session: Session; form: FormData }> {
- const runtime = services(context);
- const session = await authenticated(request, context);
- const form = await request.formData();
- runtime.sessions.requireMutation(request, session, form);
- return { session, form };
-}
-
-export function field(form: FormData, name: string): string {
- const value = form.get(name);
- if (typeof value !== "string") throw new AppError(`invalid_${name}`, 400);
- return value;
-}
-
-export function version(form: FormData): number {
- const value = field(form, "expectedVersion");
- if (!/^[1-9][0-9]*$/u.test(value) || !Number.isSafeInteger(Number(value))) {
- throw new AppError("invalid_expected_version", 400);
- }
- return Number(value);
-}
-
-export function commandKey(): string {
- return randomToken(12);
-}
-
-export function actionFailure(error: unknown, requestId: string) {
- if (error instanceof Response) throw error;
- if (error instanceof AppError) {
- return data(
- { ok: false as const, error: error.code, requestId },
- { status: error.status },
- );
- }
- throw error;
-}
-
-export function loaderFailure(error: unknown, requestId: string): never {
- if (error instanceof Response) throw error;
- if (error instanceof AppError) {
- throw data({ error: error.code, requestId }, { status: error.status });
- }
- throw error;
-}
-
-export function routeId(value: string | undefined, name: string): string {
- return identifier(value, name);
-}
diff --git a/p11-saas-workspace/src/web/shell.css b/p11-saas-workspace/app/styles/shell.css
similarity index 58%
rename from p11-saas-workspace/src/web/shell.css
rename to p11-saas-workspace/app/styles/shell.css
index 66ba4ac..0a3255d 100644
--- a/p11-saas-workspace/src/web/shell.css
+++ b/p11-saas-workspace/app/styles/shell.css
@@ -1,25 +1,41 @@
:root {
+ color-scheme: light;
font-family:
Inter, "Mona Sans", ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
- color: #262a29;
+ color: #1c2422;
background: #ffffff;
font-synthesis: none;
--navy: #0a384e;
--deep-navy: #061d33;
+ --ink: #1c2422;
+ --charcoal: #262a29;
+ --slate: #5b6662;
+ --muted: #5b6662;
--green: #03a96d;
--green-dark: #007a4d;
--signal-blue: #00aeef;
- --blue-strong: #2563eb;
+ --blue-strong: #1d63d1;
--amber: #ffb94a;
- --coral-dark: #c2412d;
- --charcoal: #262a29;
- --slate: #59605d;
- --border: #d8dfdc;
+ --coral-dark: #b93a25;
+ --border: #dfe5e2;
+ --border-strong: #c3cdc8;
--surface: #ffffff;
- --surface-soft: #f4f7f6;
- --surface-green: #edf5f2;
- --surface-blue: #eef7fb;
+ --surface-soft: #f5f8f7;
+ --surface-green: #eef6f2;
+ --surface-blue: #eef6fb;
+ --success-bg: #eaf7f0;
+ --success-border: #bfe4d1;
+ --success-ink: #0b6b45;
+ --error-bg: #fdf0ed;
+ --error-border: #f2c7be;
+ --error-ink: #a12f1c;
+ --radius-xs: 6px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 16px;
+ --shadow-sm: 0 1px 2px rgb(15 23 42 / 6%);
+ --shadow-md: 0 16px 40px rgb(15 23 42 / 16%);
}
* {
@@ -60,7 +76,8 @@ button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
-select:focus-visible {
+select:focus-visible,
+summary:focus-visible {
outline: 3px solid var(--signal-blue);
outline-offset: 2px;
}
@@ -84,6 +101,15 @@ p {
margin-top: 0;
}
+.eyebrow {
+ display: block;
+ color: var(--green-dark);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
.app-shell,
.login-shell {
width: 100%;
@@ -134,8 +160,8 @@ p {
.topbar-copy h1 {
margin: 0;
color: #ffffff;
- font-size: clamp(1rem, 1.65vw, 1.38rem);
- font-weight: 750;
+ font-size: clamp(1rem, 1.65vw, 1.34rem);
+ font-weight: 700;
letter-spacing: -0.018em;
line-height: 1.2;
}
@@ -152,7 +178,7 @@ p {
min-width: 44px;
min-height: 44px;
display: grid;
- grid-template-columns: auto auto auto;
+ grid-template-columns: auto auto;
align-items: center;
gap: 10px;
padding: 0;
@@ -177,27 +203,39 @@ p {
.account-menu-items {
position: absolute;
- z-index: 20;
+ z-index: 30;
top: calc(100% + 0.5rem);
right: 0;
- min-width: 10rem;
+ width: min(17rem, calc(100vw - 20px));
padding: 0.35rem;
border: 1px solid var(--border);
- border-radius: 0.75rem;
+ border-radius: var(--radius-md);
background: var(--surface);
- color: var(--charcoal);
- box-shadow: 0 0.75rem 2rem rgb(15 23 42 / 12%);
+ color: var(--ink);
+ box-shadow: var(--shadow-md);
+}
+
+.account-menu-header {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 10px;
+ padding: 0.55rem 0.6rem 0.65rem;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 0.3rem;
}
.account-menu-items button {
display: block;
width: 100%;
+ min-height: 44px;
padding: 0.55rem 0.65rem;
border: 0;
- border-radius: 0.5rem;
+ border-radius: var(--radius-sm);
background: transparent;
color: inherit;
text-align: left;
+ cursor: pointer;
}
.account-menu-items button:hover,
@@ -216,7 +254,7 @@ p {
border: 1px solid #31c38b;
border-radius: 50%;
font-size: 0.78rem;
- font-weight: 800;
+ font-weight: 700;
}
.identity-copy,
@@ -224,7 +262,7 @@ p {
min-width: 0;
display: grid;
gap: 2px;
- line-height: 1.1;
+ line-height: 1.15;
}
.identity-copy strong {
@@ -234,38 +272,49 @@ p {
.identity-copy small {
color: #dce7e2;
font-size: 0.7rem;
- text-transform: capitalize;
+}
+
+.account-menu-header .identity-copy small {
+ color: var(--slate);
+ font-size: 0.7rem;
}
.secondary,
-button:not(.rail-identity, .organization-option, .account-menu-action) {
- min-height: 42px;
+button:not(.rail-identity, .switcher-option, .account-menu-action) {
+ min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 16px;
- border-radius: 5px;
+ border-radius: var(--radius-sm);
cursor: pointer;
- font-weight: 720;
+ font-weight: 600;
line-height: 1;
}
+button:not(.secondary, .rail-identity, .switcher-option, .account-menu-action) {
+ color: #ffffff;
+ background: var(--green-dark);
+ border: 1px solid var(--green-dark);
+ box-shadow: var(--shadow-sm);
+}
+
button:not(
.secondary,
.rail-identity,
- .organization-option,
+ .switcher-option,
.account-menu-action
-) {
- color: #ffffff;
- background: var(--green-dark);
- border: 1px solid var(--green-dark);
+):hover:not(:disabled) {
+ background: var(--green);
+ border-color: var(--green);
}
.secondary {
color: var(--navy);
- background: #ffffff;
- border: 1px solid #8fa09a;
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ text-decoration: none;
}
.secondary:hover {
@@ -274,24 +323,34 @@ button:not(
}
label {
+ min-width: 0;
display: grid;
gap: 6px;
color: var(--navy);
font-size: 0.76rem;
- font-weight: 750;
+ font-weight: 600;
}
input,
textarea,
select {
+ min-width: 0;
+ min-height: 44px;
+ font-size: 1rem;
width: 100%;
- color: var(--charcoal);
- background: #ffffff;
- border: 1px solid #9aa9a3;
- border-radius: 5px;
+ color: var(--ink);
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-xs);
padding: 9px 11px;
}
+input:hover,
+textarea:hover,
+select:hover {
+ border-color: var(--slate);
+}
+
textarea {
resize: none;
}
@@ -299,25 +358,43 @@ textarea {
.landing {
min-height: 0;
display: grid;
- place-items: center;
+ place-items: safe center;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
padding: 38px 24px;
- background: var(--surface-soft);
+ background:
+ radial-gradient(circle at 18% 8%, #eaf5f1 0, transparent 42%),
+ radial-gradient(circle at 82% 92%, #e9f4fb 0, transparent 40%),
+ var(--surface-soft);
+}
+
+@media (max-height: 500px) {
+ .brand-rail {
+ min-height: 56px;
+ padding-block: 6px;
+ }
+ .topbar-copy p {
+ display: none;
+ }
+ .landing {
+ padding-block: 16px;
+ }
}
.login-panel,
.service-state {
- background: #ffffff;
+ background: var(--surface);
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
}
.login-panel {
width: min(100%, 660px);
- padding: 34px;
+ padding: 26px 30px;
+ box-shadow: var(--shadow-sm);
}
.login-heading {
@@ -325,24 +402,16 @@ textarea {
}
.login-heading h2 {
- margin: 7px 0 8px;
+ margin: 0 0 6px;
color: var(--deep-navy);
font-size: clamp(1.5rem, 4vw, 2rem);
letter-spacing: -0.02em;
}
-.login-heading > p:last-child {
- max-width: 480px;
- margin: 0 auto;
- color: var(--slate);
- font-size: 0.88rem;
- line-height: 1.55;
-}
-
.account-choices {
display: grid;
gap: 9px;
- margin-top: 24px;
+ margin-top: 18px;
}
.account-choice {
@@ -353,15 +422,20 @@ textarea {
gap: 13px;
padding: 11px 14px;
color: var(--deep-navy);
- background: #ffffff;
- border: 1px solid #afbbb7;
- border-radius: 5px;
+ background: var(--surface);
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius-sm);
text-decoration: none;
+ transition:
+ background 120ms ease,
+ border-color 120ms ease,
+ transform 120ms ease;
}
.account-choice:hover {
background: var(--surface-green);
border-color: var(--green-dark);
+ transform: translateY(-1px);
}
.account-avatar {
@@ -373,7 +447,7 @@ textarea {
background: var(--green-dark);
border-radius: 50%;
font-size: 0.75rem;
- font-weight: 800;
+ font-weight: 700;
}
.account-choice:nth-child(2) .account-avatar {
@@ -385,6 +459,10 @@ textarea {
background: var(--amber);
}
+.account-choice:nth-child(4) .account-avatar {
+ background: var(--navy);
+}
+
.account-copy strong {
font-size: 0.88rem;
}
@@ -403,10 +481,39 @@ textarea {
}
.outcome {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
color: var(--navy);
background: var(--surface-blue);
- border: 1px solid #b8dcea;
- border-radius: 5px;
+ border: 1px solid #c8e2ef;
+ border-radius: var(--radius-xs);
+}
+
+.outcome.success {
+ color: var(--success-ink);
+ background: var(--success-bg);
+ border-color: var(--success-border);
+}
+
+.outcome.error {
+ color: var(--error-ink);
+ background: var(--error-bg);
+ border-color: var(--error-border);
+}
+
+.outcome-icon {
+ display: inline-flex;
+ margin-top: 1px;
+}
+
+.outcome-icon:empty {
+ display: none;
+}
+
+.outcome-copy {
+ min-width: 0;
+ overflow-wrap: anywhere;
}
.service-state {
@@ -431,7 +538,7 @@ textarea {
gap: 20px;
padding: 1px clamp(20px, 3vw, 34px);
color: var(--slate);
- background: #ffffff;
+ background: var(--surface);
border-top: 1px solid var(--border);
font-size: 0.72rem;
}
@@ -441,7 +548,7 @@ textarea {
display: flex;
justify-content: center;
flex-wrap: wrap;
- gap: 18px;
+ gap: 0 18px;
}
.program-footer a {
@@ -456,6 +563,42 @@ textarea {
color: var(--slate);
}
+.route-progress {
+ position: fixed;
+ z-index: 60;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 3px;
+ pointer-events: none;
+}
+
+.route-progress::before {
+ content: "";
+ display: block;
+ height: 100%;
+ width: 100%;
+ background: linear-gradient(90deg, var(--signal-blue), var(--green));
+ transform: scaleX(0);
+ transform-origin: left;
+}
+
+.route-progress[data-active="true"]::before {
+ animation: route-progress 1.2s ease-in-out infinite;
+}
+
+@keyframes route-progress {
+ 0% {
+ transform: scaleX(0);
+ }
+ 55% {
+ transform: scaleX(0.72);
+ }
+ 100% {
+ transform: scaleX(1);
+ }
+}
+
@media (max-width: 760px) {
.brand-rail {
min-height: 72px;
@@ -476,7 +619,7 @@ textarea {
.rail-identity {
position: relative;
width: 54px;
- grid-template-columns: auto auto auto;
+ grid-template-columns: auto;
justify-content: flex-start;
gap: 0;
}
@@ -528,11 +671,67 @@ textarea {
}
}
+@media (max-height: 700px) {
+ .login-shell .brand-rail {
+ min-height: 60px;
+ }
+ .landing {
+ padding-block: 14px;
+ }
+ .login-panel {
+ padding: 18px 22px;
+ }
+ .login-heading h2 {
+ font-size: clamp(1.25rem, 3.4vw, 1.6rem);
+ }
+ .account-choices {
+ margin-top: 12px;
+ gap: 7px;
+ }
+ .account-choice {
+ min-height: 54px;
+ padding: 8px 12px;
+ }
+ .account-avatar {
+ width: 36px;
+ height: 36px;
+ }
+}
+
+@media (max-height: 500px) {
+ .login-shell .brand-rail {
+ min-height: 54px;
+ }
+ .landing {
+ padding: 8px 12px;
+ }
+ .login-panel {
+ padding: 12px 16px;
+ }
+ .login-heading h2 {
+ margin-bottom: 4px;
+ font-size: 1.1rem;
+ }
+ .account-choices {
+ margin-top: 8px;
+ gap: 5px;
+ }
+ .account-choice {
+ min-height: 44px;
+ padding: 4px 10px;
+ }
+ .account-copy small {
+ font-size: 0.68rem;
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto;
transition-duration: 0.01ms;
+ animation-duration: 0.01ms;
+ animation-iteration-count: 1;
}
}
diff --git a/p11-saas-workspace/app/styles/styles.css b/p11-saas-workspace/app/styles/styles.css
new file mode 100644
index 0000000..6097075
--- /dev/null
+++ b/p11-saas-workspace/app/styles/styles.css
@@ -0,0 +1,693 @@
+@import "./shell.css";
+
+.content-shell {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 252px minmax(0, 1fr);
+ overflow: hidden;
+ background: var(--surface-soft);
+}
+
+.workspace-sidebar {
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ overflow: auto;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+}
+
+.workspace-switcher {
+ position: relative;
+ display: grid;
+ gap: 7px;
+}
+
+.switcher {
+ position: relative;
+}
+
+.switcher-trigger {
+ width: 100%;
+ min-height: 48px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ color: var(--ink);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ list-style: none;
+ text-align: left;
+ transition:
+ background 120ms ease,
+ border-color 120ms ease;
+}
+
+.switcher-trigger::-webkit-details-marker {
+ display: none;
+}
+
+.switcher-trigger:hover,
+.switcher[open] > .switcher-trigger {
+ background: var(--surface-soft);
+ border-color: var(--border-strong);
+}
+
+.switcher-chevron {
+ flex: 0 0 auto;
+ color: var(--muted);
+ transition: transform 120ms ease;
+}
+
+.switcher[open] .switcher-chevron {
+ transform: rotate(180deg);
+}
+
+.switcher-menu {
+ position: absolute;
+ inset-inline: 0;
+ top: calc(100% + 4px);
+ z-index: 6;
+ display: grid;
+ gap: 2px;
+ padding: 4px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: var(--shadow-md);
+}
+
+.switcher-option {
+ width: 100%;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ color: var(--navy);
+ background: transparent;
+ border: 0;
+ border-radius: var(--radius-xs);
+ cursor: pointer;
+ text-align: left;
+}
+
+.switcher-option:hover {
+ background: var(--surface-green);
+}
+
+.switcher-option[aria-current="true"] {
+ color: var(--slate);
+ opacity: 1;
+ cursor: default;
+}
+
+.switcher-option[aria-current="true"]:hover {
+ background: var(--surface-soft);
+}
+
+.switcher-check {
+ flex: 0 0 auto;
+ color: var(--green-dark);
+}
+
+.org-copy {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.switcher-trigger .org-copy {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+}
+
+.org-copy strong {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.82rem;
+}
+
+.role-chip.stale {
+ color: var(--muted);
+ font-style: italic;
+ text-transform: none;
+}
+
+.role-chip,
+.state-badge {
+ flex: 0 0 auto;
+ color: var(--slate);
+ font-size: 0.66rem;
+ font-weight: 600;
+ text-transform: capitalize;
+}
+
+.role-chip.admin,
+.state-badge[data-state="active"],
+.state-badge[data-state="accepted"] {
+ color: var(--success-ink);
+}
+
+.role-chip.editor {
+ color: var(--blue-strong);
+}
+
+.state-badge[data-state="expired"],
+.state-badge[data-state="revoked"] {
+ color: var(--error-ink);
+}
+
+.section-nav {
+ display: grid;
+ gap: 4px;
+}
+
+.section-nav a {
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: var(--radius-sm);
+ color: var(--navy);
+ font-size: 0.8rem;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.section-nav a:hover {
+ background: var(--surface-soft);
+}
+
+.section-nav a.active {
+ color: var(--green-dark);
+ background: var(--surface-green);
+ box-shadow: inset 3px 0 var(--green-dark);
+}
+
+.section-nav svg {
+ flex: 0 0 auto;
+ color: var(--muted);
+}
+
+.section-nav a.active svg {
+ color: var(--green-dark);
+}
+
+.main-region {
+ min-width: 0;
+ min-height: 0;
+ padding: 14px;
+ overflow: auto;
+}
+
+.page {
+ min-height: 100%;
+ display: grid;
+ align-content: start;
+ gap: 12px;
+}
+
+.page-narrow {
+ max-width: 760px;
+ margin-inline: auto;
+}
+
+.page-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 4px 2px;
+}
+
+.page-header h1 {
+ margin: 0;
+ color: var(--navy);
+ font-size: 1.28rem;
+ letter-spacing: -0.01em;
+}
+
+.page-header p {
+ margin: 4px 0 0;
+ color: var(--slate);
+ font-size: 0.82rem;
+}
+
+.workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 10px;
+}
+
+.panel {
+ min-width: 0;
+ padding: 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.panel h2 {
+ margin-bottom: 10px;
+ color: var(--navy);
+ font-size: 0.94rem;
+}
+
+.panel h3 {
+ color: var(--navy);
+ font-size: 0.84rem;
+}
+
+.panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.panel-heading h2 {
+ margin: 0;
+}
+
+.panel-heading small {
+ color: var(--slate);
+ font-size: 0.68rem;
+}
+
+.count-badge {
+ flex: 0 0 auto;
+ color: var(--slate);
+ font-size: 0.68rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.project-panel {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.project-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin: 12px 0;
+}
+
+.project-list a {
+ display: inline-flex;
+ align-items: center;
+ min-height: 44px;
+ gap: 7px;
+ padding: 7px 9px;
+ color: var(--navy);
+ background: var(--surface-soft);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.project-list a:hover {
+ background: var(--surface-green);
+ border-color: #7ec9ad;
+}
+
+.project-list a.selected {
+ color: #ffffff;
+ background: var(--green-dark);
+ border-color: var(--green-dark);
+}
+
+.project-editor {
+ min-height: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.project-editor label {
+ min-height: 0;
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.project-editor textarea {
+ min-height: 180px;
+ resize: vertical;
+}
+
+.project-editor button {
+ justify-self: start;
+}
+
+.editor-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+}
+
+.editor-actions [role="status"] {
+ font-size: 0.75rem;
+}
+
+.invitation-token {
+ display: grid;
+ gap: 8px;
+}
+
+.invitation-token p {
+ margin: 0;
+}
+
+.invite-card > form {
+ display: grid;
+ gap: 8px;
+}
+
+.invite-card > .secondary {
+ justify-self: start;
+}
+
+.team-grid {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.member-list,
+.invitation-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.member-list li,
+.invitation-list li {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 0;
+ border-top: 1px solid var(--border);
+ font-size: 0.78rem;
+}
+
+.member-list li:first-child,
+.invitation-list li:first-child {
+ border-top: 0;
+}
+
+.member-copy {
+ min-width: 0;
+ display: grid;
+ gap: 1px;
+ flex: 1;
+}
+
+.member-copy strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.78rem;
+}
+
+.member-copy small {
+ color: var(--muted);
+ font-size: 0.66rem;
+ overflow-wrap: anywhere;
+}
+
+.compact-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ margin-top: 10px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+
+.compact-form button {
+ grid-column: 1 / -1;
+}
+
+.panel > p,
+.invite-card,
+.support-card,
+.project-preview {
+ font-size: 0.75rem;
+ line-height: 1.4;
+}
+
+.outcome {
+ margin: 10px 0 0;
+ padding: 8px 10px;
+ font-size: 0.72rem;
+}
+
+.token-chip {
+ display: inline-block;
+ padding: 2px 8px;
+ border: 1px dashed var(--border-strong);
+ border-radius: var(--radius-xs);
+ background: var(--surface);
+ color: var(--ink);
+ overflow-wrap: anywhere;
+ user-select: all;
+}
+
+.invite-card,
+.support-card {
+ display: grid;
+ gap: 7px;
+ padding: 10px 0;
+ border-top: 1px solid var(--border);
+}
+
+.invite-heading,
+.support-heading {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.support-mark {
+ width: 32px;
+ height: 32px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: var(--navy);
+ background: var(--surface-blue);
+ border: 1px solid #c8e2ef;
+ border-radius: var(--radius-xs);
+}
+
+.button-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.invitation-token .button-row {
+ align-items: center;
+}
+
+.project-preview {
+ margin: 10px 0;
+ padding: 12px;
+ background: var(--surface-soft);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+.project-preview h3,
+.project-preview p {
+ margin-bottom: 6px;
+}
+
+.stat-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 10px;
+}
+
+.stat-card {
+ display: grid;
+ gap: 6px;
+ padding: 14px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.stat-card strong {
+ color: var(--navy);
+ font-size: 1.1rem;
+}
+
+.stat-card small {
+ color: var(--muted);
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.meter {
+ width: 100%;
+ height: 16px;
+ overflow: hidden;
+ background: var(--surface-soft);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xs);
+}
+
+.meter::-webkit-meter-bar {
+ background: var(--surface-soft);
+ border: 0;
+}
+.meter::-webkit-meter-optimum-value {
+ background: var(--green-dark);
+}
+.meter::-moz-meter-bar {
+ background: var(--green-dark);
+}
+
+.stat-card strong small {
+ color: var(--slate);
+}
+.result-reference {
+ display: block;
+ margin-top: 4px;
+ color: inherit;
+ font-size: 0.8rem;
+ overflow-wrap: anywhere;
+}
+
+.billing-note {
+ margin: 12px 0 0;
+}
+
+.empty-state {
+ display: grid;
+ justify-items: center;
+ gap: 6px;
+ padding: 26px 18px;
+ color: var(--slate);
+ text-align: center;
+}
+
+.empty-state strong {
+ color: var(--navy);
+ font-size: 0.9rem;
+}
+
+.empty-state p {
+ margin: 0;
+ max-width: 42ch;
+ font-size: 0.78rem;
+ line-height: 1.5;
+}
+
+.empty-state-icon {
+ width: 44px;
+ height: 44px;
+ display: grid;
+ place-items: center;
+ color: var(--navy);
+ background: var(--surface-soft);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+
+@media (max-width: 1080px) {
+ .content-shell {
+ grid-template-columns: 220px minmax(0, 1fr);
+ }
+ .team-grid {
+ grid-template-columns: 1fr;
+ }
+ .workspace-grid {
+ grid-template-columns: minmax(0, 1fr);
+ min-height: auto;
+ }
+ .project-editor textarea {
+ min-height: 160px;
+ }
+ .project-editor {
+ grid-template-rows: auto minmax(190px, 1fr) auto;
+ }
+}
+
+@media (max-width: 860px) {
+ .content-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .workspace-sidebar {
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px 10px;
+ border-right: 0;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .workspace-switcher {
+ max-width: 320px;
+ }
+
+ .workspace-switcher .eyebrow {
+ display: none;
+ }
+
+ .switcher-menu {
+ width: min(320px, calc(100vw - 20px));
+ inset-inline: 0 auto;
+ }
+
+ .section-nav {
+ grid-auto-flow: column;
+ grid-auto-columns: max-content;
+ justify-content: start;
+ overflow-x: auto;
+ }
+
+ .section-nav a {
+ white-space: nowrap;
+ }
+
+ .main-region {
+ padding: 8px;
+ }
+
+ .page-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .compact-form {
+ grid-template-columns: 1fr;
+ }
+
+ .compact-form button {
+ grid-column: auto;
+ }
+}
diff --git a/p11-saas-workspace/src/shared/contracts.ts b/p11-saas-workspace/app/types/contracts.ts
similarity index 73%
rename from p11-saas-workspace/src/shared/contracts.ts
rename to p11-saas-workspace/app/types/contracts.ts
index 1d48b49..c0172bf 100644
--- a/p11-saas-workspace/src/shared/contracts.ts
+++ b/p11-saas-workspace/app/types/contracts.ts
@@ -1,7 +1,4 @@
-const roles = ["admin", "editor", "viewer"] as const;
-export type Role = (typeof roles)[number];
-
-export type Organization = Readonly<{ id: string; name: string }>;
+export type Role = "admin" | "editor" | "viewer";
export type Membership = Readonly<{
principalId?: string;
principalName?: string;
@@ -21,6 +18,7 @@ export type Project = Readonly<{
export type Invitation = Readonly<{
id: string;
organizationId: string;
+ organizationName?: string;
targetPrincipalId: string;
targetName: string;
role: Role;
@@ -32,13 +30,21 @@ export type Billing = Readonly<{
organizationId: string;
plan: string;
seats: number;
+ activeSeats: number;
monthlyCents: number;
version: number;
}>;
+export type InvitationCandidate = Readonly<{
+ id: string;
+ name: string;
+ state: "available" | "member" | "pending";
+}>;
export type SupportApproval = Readonly<{
id: string;
organizationId: string;
+ organizationName?: string;
projectId: string;
+ projectName?: string;
operation: "project.read";
expiresAt: string;
version: number;
@@ -46,10 +52,11 @@ export type SupportApproval = Readonly<{
}>;
export type SessionView = Readonly<{
- user: { id: string; name: string };
+ user: { name: string };
csrfToken: string;
- expiresAt: string;
activeOrganizationId: string | null;
selectionVersion: number;
memberships: readonly Membership[];
+ navigationOrganizationId: string | null;
+ switchKeys: Readonly>;
}>;
diff --git a/p11-saas-workspace/app/utils/messages.ts b/p11-saas-workspace/app/utils/messages.ts
new file mode 100644
index 0000000..4291693
--- /dev/null
+++ b/p11-saas-workspace/app/utils/messages.ts
@@ -0,0 +1,52 @@
+const messages: Readonly> = {
+ authority_changed:
+ "Your workspace access changed. Reload and try again. No changes were saved.",
+ authorization_denied: "This action is not allowed.",
+ database_unavailable: "The database is unavailable. Try again shortly.",
+ authentication_required: "Your session has expired. Sign in again.",
+ authorization_unavailable:
+ "Authorization is unavailable right now. No changes were made.",
+ billing_not_found: "Billing details are not available for this workspace.",
+ idempotency_key_reused:
+ "This form was already used. Reload before making another change.",
+ invitation_expired:
+ "That invitation has expired. Ask an admin for a new one.",
+ invitation_limit_reached:
+ "This workspace has reached its pending invitation limit.",
+ invitation_already_pending:
+ "That person already has a pending invitation. Use it or wait until it expires.",
+ invitation_not_created: "The invitation could not be created. Try again.",
+ invitation_not_found: "That invitation no longer exists.",
+ invitation_not_pending:
+ "That invitation is no longer pending. Ask an admin for a new one.",
+ invitation_target_mismatch: "This invitation belongs to someone else.",
+ invitation_target_not_found: "The selected teammate does not exist.",
+ invitation_token_mismatch:
+ "That one-time token does not match this invitation.",
+ invalid_expected_version: "The workspace changed. Reload and try again.",
+ invalid_idempotency_key: "The request was malformed. Reload and try again.",
+ membership_already_exists: "That person is already a member.",
+ membership_limit_reached: "This workspace has reached its member limit.",
+ membership_not_found: "That membership no longer exists.",
+ organization_not_found: "That workspace no longer exists.",
+ project_not_found: "That project no longer exists.",
+ request_verification_failed:
+ "The request could not be verified. Reload and try again.",
+ selection_not_found: "Choose a workspace to continue.",
+ support_approval_invalid:
+ "That support approval expired or changed. Ask for a fresh approval.",
+ support_approval_not_found: "That support approval no longer exists.",
+ version_conflict: "This record changed. Copy any unsaved edits, then reload.",
+};
+
+export function humanizeError(code: string): string {
+ if (messages[code]) return messages[code];
+ if (code.startsWith("invalid_")) {
+ return "Some information was not valid. Check the form and try again.";
+ }
+ return "The request could not be completed. Try again.";
+}
+
+export function errorNotice(code: string, requestId: string): string {
+ return `${humanizeError(code)} (ref ${requestId})`;
+}
diff --git a/p11-saas-workspace/app/utils/time.ts b/p11-saas-workspace/app/utils/time.ts
new file mode 100644
index 0000000..2d07943
--- /dev/null
+++ b/p11-saas-workspace/app/utils/time.ts
@@ -0,0 +1,13 @@
+const clock = new Intl.DateTimeFormat("en-GB", {
+ timeZone: "UTC",
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+/** Keep server-rendered deadlines identical in browsers with another timezone. */
+export function displayTime(value: string): string {
+ return `${clock.format(new Date(value))} UTC`;
+}
diff --git a/p11-saas-workspace/compose.yaml b/p11-saas-workspace/compose.yaml
index a0efa96..f540c68 100644
--- a/p11-saas-workspace/compose.yaml
+++ b/p11-saas-workspace/compose.yaml
@@ -25,8 +25,6 @@ services:
- 127.0.0.1:5435:5432
volumes:
- p11-postgres:/var/lib/postgresql
- - ./migrations/001_initial.sql:/docker-entrypoint-initdb.d/001_initial.sql:ro
- - ./fixtures/seed.sql:/docker-entrypoint-initdb.d/002_seed.sql:ro
healthcheck:
test:
- CMD-SHELL
@@ -75,7 +73,7 @@ services:
command:
- node
- --env-file=/run/config/app.env
- - server.js
+ - server/index.js
build: .
depends_on:
configure:
diff --git a/p11-saas-workspace/e2e/workspace.e2e.ts b/p11-saas-workspace/e2e/workspace.e2e.ts
index 77dae89..901d003 100644
--- a/p11-saas-workspace/e2e/workspace.e2e.ts
+++ b/p11-saas-workspace/e2e/workspace.e2e.ts
@@ -1,5 +1,22 @@
import { spawnSync } from "node:child_process";
-import { expect, type Page, test } from "@playwright/test";
+import { expect, type Locator, type Page, test } from "@playwright/test";
+
+async function touchTarget(control: Locator): Promise {
+ await control.scrollIntoViewIfNeeded();
+ await expect(control).toBeInViewport();
+ const box = await control.boundingBox();
+ expect(box?.height).toBeGreaterThanOrEqual(44);
+ expect(
+ await control.evaluate((element) => {
+ const box = element.getBoundingClientRect();
+ const target = document.elementFromPoint(
+ box.x + box.width / 2,
+ box.y + box.height / 2,
+ );
+ return target === element || element.contains(target);
+ }),
+ ).toBe(true);
+}
async function signIn(page: Page, persona: string): Promise {
await page.goto("/");
@@ -13,27 +30,270 @@ async function signIn(page: Page, persona: string): Promise {
await expect(page).toHaveURL(/localhost:17011/u);
}
-function revokeMayaFromAster(): void {
- const result = spawnSync(
- "docker",
- [
- "compose",
- "exec",
- "-T",
- "postgres",
- "psql",
- "-U",
- "p11",
- "-d",
- "p11",
- "-c",
- "UPDATE memberships SET active = false, version = version + 1 WHERE principal_id = 'user-maya' AND organization_id = 'aster';",
- ],
- { cwd: process.cwd(), env: process.env, encoding: "utf8" },
+async function switchWorkspace(
+ page: Page,
+ organization: string,
+): Promise {
+ const trigger = page.getByLabel("Switch workspace");
+ await touchTarget(trigger);
+ await trigger.click();
+ const option = page.getByRole("button", {
+ name: new RegExp(organization, "u"),
+ });
+ await touchTarget(option);
+ const switchUrl = await option.evaluate(
+ (button) => button.closest("form")?.action ?? "",
+ );
+ const projectPath = new URL(switchUrl).pathname.replace(
+ /\/switch$/u,
+ "/projects/",
);
+ await option.click();
+ await expect(page).toHaveURL((url) => url.pathname.startsWith(projectPath));
+}
+
+test("@visual keeps user controls compact across roles and workspace states", async ({
+ browser,
+}) => {
+ for (const persona of ["Maya", "Noah", "Lena", "Imani"]) {
+ const context = await browser.newContext({
+ viewport: { width: 1440, height: 1000 },
+ });
+ try {
+ const page = await context.newPage();
+ const pageErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error" && /hydrat/iu.test(message.text())) {
+ pageErrors.push(message.text());
+ }
+ });
+ await signIn(page, persona);
+ if (persona === "Maya" || persona === "Noah") {
+ const trigger = page.getByLabel("Switch workspace");
+ const box = await trigger.boundingBox();
+ expect(box?.height).toBeGreaterThanOrEqual(44);
+ expect(box?.height).toBeLessThanOrEqual(60);
+ await trigger.click();
+ await expect(page.locator(".switcher")).toHaveAttribute("open", "");
+ await page.keyboard.press("Escape");
+ await expect(page.locator(".switcher")).not.toHaveAttribute("open", "");
+ await expect(trigger).toBeFocused();
+ }
+ await expect(
+ page.getByRole("link", { name: "My invitations", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Support access", exact: true }),
+ ).toBeVisible();
+ const account = page.getByLabel("Open account menu");
+ await account.click();
+ await expect(
+ page.getByRole("button", { name: "Change account", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Sign out", exact: true }),
+ ).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(account).toBeFocused();
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth),
+ ).toBe(1440);
+ await expect(page.locator(".program-footer")).toBeInViewport();
+ expect(pageErrors).toEqual([]);
+ } finally {
+ await context.close();
+ }
+ }
+});
+
+for (const [width, height] of [
+ [320, 568],
+ [390, 844],
+ [768, 1024],
+ [844, 390],
+ [1440, 1000],
+] as const) {
+ test(`@responsive keeps editing and organization controls reachable at ${width} × ${height}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height });
+ await signIn(page, "Maya");
+ await touchTarget(page.getByRole("textbox", { name: "Project name" }));
+ const projectName = page.getByRole("textbox", { name: "Project name" });
+ const originalName = await projectName.inputValue();
+ await projectName.fill(`Small-screen project ${width}`);
+ await touchTarget(page.getByRole("button", { name: "Save changes" }));
+ await expect(projectName).toHaveValue(`Small-screen project ${width}`);
+ await projectName.fill(originalName);
+ await page.getByRole("link", { name: "Members", exact: true }).click();
+ await touchTarget(page.getByRole("combobox", { name: "Teammate" }));
+ await touchTarget(
+ page.getByRole("combobox", { name: "Role", exact: true }),
+ );
+ await touchTarget(page.getByRole("button", { name: "Create invitation" }));
+ await touchTarget(page.getByRole("link", { name: "Billing", exact: true }));
+ await switchWorkspace(page, "Tenant B · Boreal");
+ await expect(page).toHaveURL(
+ /organizations\/boreal\/projects\/project-b1/u,
+ );
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth),
+ ).toBe(width);
+ expect(
+ await page.evaluate(() => document.documentElement.scrollHeight),
+ ).toBe(height);
+ await expect(page.locator(".program-footer")).toBeInViewport();
+ await switchWorkspace(page, "Tenant A · Aster");
+ await expect(page).toHaveURL(/organizations\/aster\/projects\/project-a1/u);
+ await page.locator('summary[aria-label="Open account menu"]').click();
+ const logout = page.getByRole("button", {
+ name: "Change account",
+ });
+ await touchTarget(logout);
+ await logout.click();
+ await expect(page.getByRole("link", { name: /Maya Aster/u })).toBeVisible();
+ await page
+ .getByRole("link", { name: /Imani Support agent/u })
+ .scrollIntoViewIfNeeded();
+ await expect(
+ page.getByRole("link", { name: /Imani Support agent/u }),
+ ).toBeInViewport();
+ });
+}
+
+for (const [width, height] of [
+ [320, 568],
+ [844, 390],
+] as const) {
+ test(`@responsive keeps invitation and support controls reachable at ${width} × ${height}`, async ({
+ browser,
+ }) => {
+ for (const persona of ["Lena", "Imani"]) {
+ const context = await browser.newContext({ viewport: { width, height } });
+ try {
+ const page = await context.newPage();
+ await signIn(page, persona);
+ if (persona === "Lena") {
+ await touchTarget(
+ page.getByRole("textbox", { name: "One-time invitation token" }),
+ );
+ await touchTarget(
+ page.getByRole("button", { name: "Accept invitation" }),
+ );
+ } else {
+ const open = page.getByRole("button", {
+ name: /Open support access|Extend access/u,
+ });
+ await expect(open).toBeEnabled();
+ await touchTarget(open);
+ await open.click();
+ const read = page.getByRole("link", {
+ name: "Read approved project",
+ });
+ await touchTarget(read);
+ await read.click();
+ await expect(page.locator(".project-preview")).toBeVisible();
+ }
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth),
+ ).toBe(width);
+ await expect(page.locator(".program-footer")).toBeInViewport();
+ } finally {
+ await context.close();
+ }
+ }
+ });
+}
+
+function compose(...args: string[]): void {
+ const result = spawnSync("docker", ["compose", ...args], {
+ cwd: process.cwd(),
+ env: process.env,
+ encoding: "utf8",
+ });
expect(result.status, result.stderr || result.stdout).toBe(0);
}
+test("@forms keeps unsaved content with its original project", async ({
+ page,
+}) => {
+ await signIn(page, "Maya");
+ await page
+ .getByRole("textbox", { name: "Project name" })
+ .fill("Unsaved first project");
+ await page
+ .getByRole("textbox", { name: "Description" })
+ .fill("Belongs to project-a1 only");
+ page.once("dialog", (dialog) => dialog.dismiss());
+ await page.getByRole("link", { name: /Aster financial controls/u }).click();
+ await expect(page).toHaveURL(/projects\/project-a1$/u);
+ await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(
+ "Unsaved first project",
+ );
+ page.once("dialog", (dialog) => dialog.accept());
+ await page.getByRole("link", { name: /Aster financial controls/u }).click();
+ await expect(page).toHaveURL(/projects\/project-a2$/u);
+ await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(
+ "Aster financial controls",
+ );
+ await expect(page.getByRole("textbox", { name: "Description" })).toHaveValue(
+ "Private Tenant A finance control notes.",
+ );
+});
+
+test("@forms preserves request integrity before JavaScript loads", async ({
+ browser,
+}) => {
+ const context = await browser.newContext({ javaScriptEnabled: false });
+ try {
+ const page = await context.newPage();
+ await signIn(page, "Maya");
+ await switchWorkspace(page, "Tenant B · Boreal");
+ await expect(page).toHaveURL(
+ /organizations\/boreal\/projects\/project-b1/u,
+ );
+ await expect(page.locator(".content-shell")).toHaveCSS("display", "grid");
+ await page.getByRole("link", { name: "Members", exact: true }).click();
+ await expect(
+ page.getByRole("button", { name: "Create invitation" }),
+ ).toBeDisabled();
+
+ const form = page.locator('form[action="/organizations/aster/switch"]');
+ const fields = Object.fromEntries(
+ await form
+ .locator("input")
+ .evaluateAll((inputs) =>
+ inputs.map((input) => [
+ input.getAttribute("name") ?? "",
+ input.getAttribute("value") ?? "",
+ ]),
+ ),
+ );
+ for (const origin of ["null", "https://untrusted.example"]) {
+ const response = await page.request.post("/organizations/aster/switch", {
+ headers: { Origin: origin },
+ form: fields,
+ });
+ expect(response.status()).toBe(403);
+ }
+ const tampered = await page.request.post("/organizations/aster/switch", {
+ headers: { Origin: "http://localhost:17011" },
+ form: { ...fields, _csrf: "invalid" },
+ });
+ expect(tampered.status()).toBe(403);
+ await switchWorkspace(page, "Tenant A · Aster");
+ await expect(page).toHaveURL(/organizations\/aster\/projects\/project-a1/u);
+ await page.getByLabel("Open account menu").click();
+ await page.getByRole("button", { name: "Change account" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Choose a tutorial identity" }),
+ ).toBeVisible();
+ } finally {
+ await context.close();
+ }
+});
+
test("@exercise reproduces the three tenant authorization gaps and controls", async ({
browser,
}) => {
@@ -44,57 +304,78 @@ test("@exercise reproduces the three tenant authorization gaps and controls", as
try {
const maya = await mayaContext.newPage();
await signIn(maya, "Maya");
- await expect(maya.getByRole("heading", { name: "Projects" })).toBeVisible();
+ await expect(
+ maya.getByRole("heading", { name: "Aster checkout hardening" }),
+ ).toBeVisible();
- await maya.getByRole("button", { name: /Tenant B · Boreal/u }).click();
+ await switchWorkspace(maya, "Tenant B · Boreal");
await expect(maya).toHaveURL(
/organizations\/boreal\/projects\/project-b1/u,
);
await maya
.getByRole("textbox", { name: "Project name" })
.fill("Boreal viewer changed this project");
- await maya.getByRole("button", { name: "Save current version" }).click();
- await expect(maya.getByRole("status")).toContainText("Saved version 2");
+ await maya.getByRole("button", { name: "Save changes" }).click();
+ await expect(maya.locator(".outcome")).toContainText(
+ "Changes saved. Version 2",
+ );
await maya.getByRole("link", { name: "Billing" }).click();
- await expect(
- maya.getByRole("heading", { name: "Billing projection" }),
- ).toBeVisible();
+ await expect(maya.getByRole("heading", { name: "Billing" })).toBeVisible();
- await maya.getByRole("button", { name: /Tenant A · Aster/u }).click();
+ await switchWorkspace(maya, "Tenant A · Aster");
await expect(maya).toHaveURL(/organizations\/aster\/projects\/project-a1/u);
- revokeMayaFromAster();
+ compose(
+ "exec",
+ "-T",
+ "cedarworkspace",
+ "node",
+ "--env-file=/run/config/app.env",
+ "scripts/admin.ts",
+ "revoke",
+ "user-maya",
+ "aster",
+ );
await maya.reload();
+ await expect(maya.locator(".switcher-trigger")).toContainText(
+ "Membership unavailable",
+ );
+ await expect(maya.locator(".switcher-trigger")).not.toContainText("Boreal");
await expect(
maya.getByRole("textbox", { name: "Project name" }),
).toBeVisible();
await maya
.getByRole("textbox", { name: "Project name" })
.fill("Stale Aster route remained effective");
- await maya.getByRole("button", { name: "Save current version" }).click();
- await expect(maya.getByRole("status")).toContainText("Saved version 2");
+ await maya.getByRole("button", { name: "Save changes" }).click();
+ await expect(maya.locator(".outcome")).toContainText(
+ "Changes saved. Version 2",
+ );
const noah = await noahContext.newPage();
await signIn(noah, "Noah");
await noah
- .getByRole("textbox", { name: "Project body" })
+ .getByRole("textbox", { name: "Description" })
.fill("P11 restart persistence proof");
- await noah.getByRole("button", { name: "Save current version" }).click();
- await expect(noah.getByRole("status")).toContainText("Saved version 3");
+ await noah.getByRole("button", { name: "Save changes" }).click();
+ await expect(noah.locator(".outcome")).toContainText(
+ "Changes saved. Version 3",
+ );
const imani = await imaniContext.newPage();
await signIn(imani, "Imani");
- await imani.getByRole("button", { name: "Open exact scope" }).click();
+ await imani
+ .getByRole("button", { name: /Open support access|Extend access/u })
+ .click();
await expect(imani.getByRole("status")).toContainText(
- "Exact support scope opened.",
+ "Support access opened.",
);
+ await imani.setViewportSize({ width: 390, height: 844 });
await imani.goto("/organizations/aster/projects/project-a2");
await expect(
imani.getByRole("textbox", { name: "Project name" }),
).toHaveValue("Aster financial controls");
await imani.getByRole("link", { name: "Billing" }).click();
- await expect(
- imani.getByRole("heading", { name: "Billing projection" }),
- ).toBeVisible();
+ await expect(imani.getByRole("heading", { name: "Billing" })).toBeVisible();
const lena = await lenaContext.newPage();
await signIn(lena, "Lena");
@@ -103,7 +384,7 @@ test("@exercise reproduces the three tenant authorization gaps and controls", as
.fill("p11-lena-invite-token");
await lena.getByRole("button", { name: "Accept invitation" }).click();
await expect(lena.getByRole("status")).toContainText(
- "Invitation accepted once.",
+ "Invitation accepted.",
);
await expect(lena).toHaveURL(/\/invitations$/u);
} finally {
@@ -116,11 +397,268 @@ test("@exercise reproduces the three tenant authorization gaps and controls", as
}
});
+test("@workflow keeps editing explicit and preserves drafts after conflicts", async ({
+ page,
+}) => {
+ await signIn(page, "Maya");
+ await page.getByRole("link", { name: /Aster financial controls/u }).click();
+ await expect(page).toHaveURL(/projects\/project-a2$/u);
+ const projectUrl = page.url();
+ const save = page.getByRole("button", { name: "Save changes" });
+ const description = page.getByRole("textbox", { name: "Description" });
+ const original = await description.inputValue();
+ await expect(save).toBeDisabled();
+ await page.getByLabel("Switch workspace").click();
+ await expect(
+ page.getByRole("button", { name: /Tenant A · Aster/u }),
+ ).toBeDisabled();
+ await page.keyboard.press("Escape");
+ expect(page.url()).toBe(projectUrl);
+ await description.fill("Draft to cancel");
+ await expect(
+ page.getByText("Unsaved changes", { exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(description).toHaveValue(original);
+ await expect(save).toBeDisabled();
+ await description.fill("First saved draft");
+ await save.click();
+ await expect(save).toBeDisabled();
+ await expect(page.getByText(/Changes saved\. Version/u)).toBeVisible();
+
+ const other = await page.context().newPage();
+ await other.goto(projectUrl);
+ await other
+ .getByRole("textbox", { name: "Description" })
+ .fill("Saved in another tab");
+ await other.getByRole("button", { name: "Save changes" }).click();
+ await expect(other.getByText(/Changes saved\. Version/u)).toBeVisible();
+ await description.fill("Keep this unsaved draft after the conflict");
+ await save.click();
+ await expect(
+ page.getByText(/This record changed\. Copy any unsaved edits/u),
+ ).toBeVisible();
+ await expect(description).toHaveValue(
+ "Keep this unsaved draft after the conflict",
+ );
+ await page.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(description).toHaveValue("First saved draft");
+ await page.reload();
+ await expect(description).toHaveValue("Saved in another tab");
+ await other.close();
+});
+
+test("@workflow completes invitation handoff and workspace entry without persona redirects", async ({
+ browser,
+}) => {
+ test.setTimeout(120_000);
+ const mayaContext = await browser.newContext({
+ permissions: ["clipboard-read", "clipboard-write"],
+ });
+ const lenaContext = await browser.newContext();
+ const imaniContext = await browser.newContext();
+ try {
+ const maya = await mayaContext.newPage();
+ const lena = await lenaContext.newPage();
+ const imani = await imaniContext.newPage();
+ await signIn(imani, "Imani");
+ await expect(imani).toHaveURL(/\/support$/u);
+ await expect(imani.locator(".support-card")).toContainText(
+ "Tenant A · Aster",
+ );
+ await signIn(maya, "Maya");
+ await maya.getByRole("link", { name: "Members", exact: true }).click();
+ const teammate = maya.getByRole("combobox", { name: "Teammate" });
+ await expect(teammate).toHaveValue("user-imani");
+ await expect(teammate.locator('option[value="user-lena"]')).toHaveAttribute(
+ "disabled",
+ "",
+ );
+ await expect(teammate.locator('option[value="user-maya"]')).toHaveAttribute(
+ "disabled",
+ "",
+ );
+ await maya.getByRole("button", { name: "Create invitation" }).click();
+ await expect(maya.locator(".invitation-token")).toBeVisible();
+ await maya.getByRole("button", { name: "Copy token" }).click();
+ await expect(maya.locator(".invitation-token")).toContainText(
+ "Token copied.",
+ );
+ expect(
+ await maya.evaluate(
+ async () =>
+ (await navigator.clipboard.readText()) ===
+ document.querySelector(".token-chip")?.textContent,
+ ),
+ ).toBe(true);
+ await maya.evaluate(() =>
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: {
+ writeText: async () => {
+ throw new Error("Clipboard denied");
+ },
+ },
+ }),
+ );
+ await maya.getByRole("button", { name: "Copy token" }).click();
+ await expect(maya.locator(".invitation-token")).toContainText(
+ "Select the token and copy it manually.",
+ );
+ await switchWorkspace(maya, "Tenant B · Boreal");
+ await maya.getByRole("link", { name: "Members", exact: true }).click();
+ await expect(maya.locator(".token-chip")).toHaveCount(0);
+ await teammate.selectOption("user-lena");
+ await maya.getByRole("button", { name: "Create invitation" }).click();
+ const lenaToken = await maya.locator(".token-chip").innerText();
+ compose(
+ "exec",
+ "-T",
+ "postgres",
+ "psql",
+ "-U",
+ "p11",
+ "-d",
+ "p11",
+ "-c",
+ "UPDATE invitations SET expires_at = now() - interval '1 second' WHERE id = 'invite-lena-aster'",
+ );
+ await signIn(lena, "Lena");
+ const expired = lena
+ .locator(".invite-card")
+ .filter({ hasText: "Tenant A · Aster" });
+ await expect(expired).toContainText("expired");
+ await expect(expired.getByRole("textbox")).toHaveCount(0);
+ await lena
+ .getByRole("textbox", { name: "One-time invitation token" })
+ .fill(lenaToken);
+ await lena.getByRole("button", { name: "Accept invitation" }).click();
+ await expect(
+ lena.getByRole("textbox", { name: "One-time invitation token" }),
+ ).toHaveCount(0);
+ await expect(lena.getByLabel("Open account menu")).toContainText(
+ "No workspace selected",
+ );
+ await expect(
+ lena.getByRole("link", { name: "Projects", exact: true }),
+ ).toHaveCount(0);
+ await lena.getByRole("button", { name: "Open Tenant B · Boreal" }).click();
+ await expect(lena).toHaveURL(/organizations\/boreal\/projects/u);
+ await lena.goto("/invitations");
+ await expect(
+ lena.getByRole("link", { name: "Go to workspace" }),
+ ).toBeVisible();
+
+ await maya.reload();
+ await teammate.selectOption("user-imani");
+ await maya.getByRole("button", { name: "Create invitation" }).click();
+ const imaniToken = await maya.locator(".token-chip").innerText();
+ compose(
+ "exec",
+ "-T",
+ "postgres",
+ "psql",
+ "-U",
+ "p11",
+ "-d",
+ "p11",
+ "-c",
+ "UPDATE invitations SET state = 'revoked' WHERE target_principal_id = 'user-imani' AND organization_id = 'aster'",
+ );
+ await imani.goto("/invitations");
+ const revoked = imani
+ .locator(".invite-card")
+ .filter({ hasText: "Tenant A · Aster" });
+ await expect(revoked).toContainText("revoked");
+ await expect(revoked.getByRole("textbox")).toHaveCount(0);
+ await imani
+ .getByRole("textbox", { name: "One-time invitation token" })
+ .fill(imaniToken);
+ await imani.getByRole("button", { name: "Accept invitation" }).click();
+ await expect(
+ imani.getByRole("button", { name: "Open Tenant B · Boreal" }),
+ ).toBeVisible();
+ await imani.goto("/");
+ await expect(imani).toHaveURL(/\/invitations$/u);
+ await imani.getByRole("button", { name: "Open Tenant B · Boreal" }).click();
+ await expect(imani).toHaveURL(/organizations\/boreal\/projects/u);
+ await imani.goto("/");
+ await expect(imani).toHaveURL(/organizations\/boreal\/projects/u);
+
+ await maya.reload();
+ await teammate.selectOption("user-noah");
+ await maya.getByRole("button", { name: "Create invitation" }).click();
+ await expect(
+ maya.getByRole("button", { name: "Create invitation" }),
+ ).toBeDisabled();
+ await expect(
+ maya.getByText(
+ "Everyone listed is already a member or has a pending invitation.",
+ ),
+ ).toBeVisible();
+ } finally {
+ await Promise.all([
+ mayaContext.close(),
+ lenaContext.close(),
+ imaniContext.close(),
+ ]);
+ }
+});
+
+test("@limits keeps public assets outside the application request budget", async ({
+ request,
+}) => {
+ const page = await request.get("/");
+ const stylesheet = (await page.text()).match(
+ /href="([^"]+\.css(?:\?[^"]*)?)"/u,
+ )?.[1];
+ expect(stylesheet).toBeTruthy();
+ for (let index = 0; index < 605; index += 1) {
+ expect((await request.get(stylesheet ?? "")).status()).toBe(200);
+ }
+ expect((await request.get("/health")).status()).toBe(200);
+ let status = 200;
+ for (let index = 0; index <= 600 && status === 200; index += 1) {
+ status = (await request.get("/health")).status();
+ }
+ expect(status).toBe(429);
+});
+
test("@persistence retains an authorized project effect after restart", async ({
page,
}) => {
await signIn(page, "Noah");
- await expect(page.getByRole("textbox", { name: "Project body" })).toHaveValue(
+ await expect(page.getByRole("textbox", { name: "Description" })).toHaveValue(
"P11 restart persistence proof",
);
});
+
+test("@recovery retains the session and data after a database interruption", async ({
+ page,
+}) => {
+ await signIn(page, "Noah");
+ const projectPath = new URL(page.url()).pathname;
+ const body = await page
+ .getByRole("textbox", { name: "Description" })
+ .inputValue();
+ const document = await page.request.get(projectPath);
+ expect(document.headers()["cache-control"]).toContain("no-store");
+ const loader = await page.request.get(`${projectPath}.data`);
+ expect(loader.headers()["cache-control"]).toContain("no-store");
+ try {
+ compose("stop", "postgres");
+ const unavailable = await page.request.get(projectPath);
+ expect(unavailable.status()).toBe(503);
+ expect(unavailable.headers()["x-request-id"]).toBeTruthy();
+ expect(await unavailable.text()).toContain("database_unavailable");
+ } finally {
+ compose("start", "postgres");
+ }
+ await expect
+ .poll(async () => (await page.request.get("/health")).status())
+ .toBe(200);
+ await page.reload();
+ await expect(page.getByRole("textbox", { name: "Description" })).toHaveValue(
+ body,
+ );
+});
diff --git a/p11-saas-workspace/package.json b/p11-saas-workspace/package.json
index 6022442..b3005a6 100644
--- a/p11-saas-workspace/package.json
+++ b/p11-saas-workspace/package.json
@@ -8,7 +8,7 @@
},
"packageManager": "pnpm@10.17.1",
"scripts": {
- "admin": "node scripts/admin.ts",
+ "admin": "node --env-file=.env scripts/admin.ts",
"build": "react-router build",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build",
"dev": "node scripts/dev.mjs",
@@ -19,7 +19,7 @@
"test:e2e": "node scripts/verify-docker.ts",
"preflight": "node scripts/preflight.mjs",
"reset": "node --env-file=.env scripts/reset.ts",
- "start": "pnpm preflight && node --env-file=.env server.js",
+ "start": "pnpm preflight && node --env-file=.env server/index.js",
"test": "vitest run",
"typecheck": "react-router typegen && tsc -b --pretty false"
},
@@ -43,7 +43,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"typescript": "6.0.3",
- "vite": "8.2.2",
+ "vite": "8.2.4",
"vitest": "4.1.11"
}
}
diff --git a/p11-saas-workspace/pnpm-lock.yaml b/p11-saas-workspace/pnpm-lock.yaml
index 7457ec2..cf4cb99 100644
--- a/p11-saas-workspace/pnpm-lock.yaml
+++ b/p11-saas-workspace/pnpm-lock.yaml
@@ -41,7 +41,7 @@ importers:
version: 1.63.0
'@react-router/dev':
specifier: 8.4.0
- version: 8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.2.2(@types/node@24.19.0))
+ version: 8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.2.4(@types/node@24.19.0))
'@types/express':
specifier: 5.0.6
version: 5.0.6
@@ -61,11 +61,11 @@ importers:
specifier: 6.0.3
version: 6.0.3
vite:
- specifier: 8.2.2
- version: 8.2.2(@types/node@24.19.0)
+ specifier: 8.2.4
+ version: 8.2.4(@types/node@24.19.0)
vitest:
specifier: 4.1.11
- version: 4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.19.0))
+ version: 4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.4(@types/node@24.19.0))
packages:
@@ -1220,8 +1220,8 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
- vite@8.2.2:
- resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
+ vite@8.2.4:
+ resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1563,7 +1563,7 @@ snapshots:
dependencies:
playwright: 1.63.0
- '@react-router/dev@8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.2.2(@types/node@24.19.0))':
+ '@react-router/dev@8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.2.4(@types/node@24.19.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
@@ -1589,7 +1589,7 @@ snapshots:
semver: 7.8.5
tinyglobby: 0.2.17
valibot: 1.5.0(typescript@6.0.3)
- vite: 8.2.2(@types/node@24.19.0)
+ vite: 8.2.4(@types/node@24.19.0)
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
@@ -1737,13 +1737,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0))':
+ '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.2.2(@types/node@24.19.0)
+ vite: 8.2.4(@types/node@24.19.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -2472,7 +2472,7 @@ snapshots:
vary@1.1.2: {}
- vite@8.2.2(@types/node@24.19.0):
+ vite@8.2.4(@types/node@24.19.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -2483,10 +2483,10 @@ snapshots:
'@types/node': 24.19.0
fsevents: 2.3.3
- vitest@4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.19.0)):
+ vitest@4.1.11(@types/node@24.19.0)(jsdom@30.0.1)(vite@8.2.4(@types/node@24.19.0)):
dependencies:
'@vitest/expect': 4.1.11
- '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0))
+ '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -2503,7 +2503,7 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.2.2(@types/node@24.19.0)
+ vite: 8.2.4(@types/node@24.19.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.19.0
diff --git a/p11-saas-workspace/scripts/admin.ts b/p11-saas-workspace/scripts/admin.ts
index 2b501aa..2b38524 100644
--- a/p11-saas-workspace/scripts/admin.ts
+++ b/p11-saas-workspace/scripts/admin.ts
@@ -1,29 +1,29 @@
-import { loadConfig } from "../src/server/config.ts";
-import { AppDatabase } from "../src/server/database.ts";
+import { loadConfig } from "../server/config/environment.ts";
+import { AppDatabase } from "../server/data/database.ts";
const [command, principalId, organizationId] = process.argv.slice(2);
if (
- (command !== "revoke" && command !== "restore") ||
!principalId ||
- !organizationId
+ (command === "renew-support"
+ ? Boolean(organizationId)
+ : (command !== "revoke" && command !== "restore") || !organizationId)
) {
throw new Error(
- "Usage: pnpm admin ",
+ "Usage: pnpm admin | pnpm admin renew-support ",
);
}
const database = new AppDatabase(loadConfig().databaseUrl);
try {
- if (command === "revoke") {
+ if (command === "renew-support") {
+ await database.renewSupportApproval(principalId);
+ } else if (command === "revoke" && organizationId) {
await database.revokeMembership(principalId, organizationId);
- } else {
- const result = await database.pool.query(
- `UPDATE memberships SET active = true, version = version + 1
- WHERE principal_id = $1 AND organization_id = $2 AND NOT active`,
- [principalId, organizationId],
- );
- if (!result.rowCount) throw new Error("Inactive membership not found");
+ } else if (organizationId) {
+ await database.restoreMembership(principalId, organizationId);
}
- console.log(`${command} applied to ${principalId} in ${organizationId}`);
+ console.log(
+ `${command} applied to ${principalId}${organizationId ? ` in ${organizationId}` : ""}`,
+ );
} finally {
await database.close();
}
diff --git a/p11-saas-workspace/scripts/dev.mjs b/p11-saas-workspace/scripts/dev.mjs
index 03b1883..41eb67c 100644
--- a/p11-saas-workspace/scripts/dev.mjs
+++ b/p11-saas-workspace/scripts/dev.mjs
@@ -63,7 +63,12 @@ async function services() {
{
name: "P11 application",
command: node,
- args: ["--env-file=.env", "--conditions", "development", "server.js"],
+ args: [
+ "--env-file=.env",
+ "--conditions",
+ "development",
+ "server/index.js",
+ ],
cwd: root,
env: { ...process.env, NODE_ENV: "development" },
health: {
diff --git a/p11-saas-workspace/scripts/reset.ts b/p11-saas-workspace/scripts/reset.ts
index 3f45ff1..0fbc9d0 100644
--- a/p11-saas-workspace/scripts/reset.ts
+++ b/p11-saas-workspace/scripts/reset.ts
@@ -1,10 +1,11 @@
-import { loadConfig } from "../src/server/config.ts";
-import { AppDatabase } from "../src/server/database.ts";
+import { loadConfig } from "../server/config/environment.ts";
+import { AppDatabase } from "../server/data/database.ts";
-const database = new AppDatabase(loadConfig().databaseUrl);
+const config = loadConfig();
+const database = new AppDatabase(config.databaseUrl);
try {
await database.migrate();
- await database.seed();
+ await database.seed(config.issuer);
console.info("P11 SaaS workspace fixtures restored");
} finally {
await database.close();
diff --git a/p11-saas-workspace/scripts/setup.ts b/p11-saas-workspace/scripts/setup.ts
index 71e142f..6ea1156 100644
--- a/p11-saas-workspace/scripts/setup.ts
+++ b/p11-saas-workspace/scripts/setup.ts
@@ -1,14 +1,14 @@
-import { ensureProjectIdentity } from "../../shared/identity-provider/scripts/setup.mjs";
-import { randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import {
mergeProjectEnvironment,
readProjectEnvironment,
writePrivateEnvironment,
} from "../../shared/identity-provider/scripts/project-environment.mjs";
-import { loadConfig } from "../src/server/config.ts";
-import { AppDatabase } from "../src/server/database.ts";
+import { ensureProjectIdentity } from "../../shared/identity-provider/scripts/setup.mjs";
+import { loadConfig } from "../server/config/environment.ts";
+import { AppDatabase } from "../server/data/database.ts";
const target = resolve(".env");
const identity = ensureProjectIdentity("P11");
@@ -35,6 +35,8 @@ const merged = mergeProjectEnvironment(current.text, {
current.environment.P11_DATABASE_URL?.trim() ||
defaultDatabaseUrl,
P11_BASE_URL: baseUrl,
+ P11_PORT:
+ new URL(baseUrl).port || (baseUrl.startsWith("https:") ? "443" : "80"),
P11_ISSUER: url("IDP_ISSUER"),
P11_API_RESOURCE: url("P11_API_RESOURCE"),
P11_CLIENT_ID: required("P11_CLIENT_ID"),
@@ -42,7 +44,6 @@ const merged = mergeProjectEnvironment(current.text, {
},
defaults: {
P11_HOST: "127.0.0.1",
- P11_PORT: "17011",
P11_SESSION_SECRET: randomBytes(32).toString("base64url"),
},
});
@@ -62,11 +63,7 @@ if (config.databaseUrl === defaultDatabaseUrl) {
const database = new AppDatabase(config.databaseUrl);
try {
- await database.migrate();
- const count = await database.pool.query<{ count: string }>(
- "SELECT count(*) count FROM principals",
- );
- if (Number(count.rows[0]?.count) === 0) await database.seed();
+ await database.initialize(config.issuer);
} finally {
await database.close();
}
diff --git a/p11-saas-workspace/scripts/verify-docker.ts b/p11-saas-workspace/scripts/verify-docker.ts
index 22bd2c2..c283b2f 100644
--- a/p11-saas-workspace/scripts/verify-docker.ts
+++ b/p11-saas-workspace/scripts/verify-docker.ts
@@ -58,10 +58,45 @@ async function waitForApplication(): Promise {
async function verify(): Promise {
ensureIdle();
compose("up", "--build", "--wait");
- playwright("@exercise");
+ const databaseTests = spawnSync(
+ process.platform === "win32" ? "pnpm.cmd" : "pnpm",
+ ["exec", "vitest", "run", "test/database.integration.test.ts"],
+ {
+ cwd: process.cwd(),
+ env: {
+ ...environment,
+ P11_TEST_DATABASE_URL: "postgresql://p11:p11@127.0.0.1:5435/p11",
+ },
+ stdio: "inherit",
+ },
+ );
+ if (databaseTests.status !== 0)
+ throw new Error("P11 PostgreSQL lifecycle checks failed");
+ // Each replay batch has its own normal per-process HTTP request budget.
+ playwright("@visual|@responsive.*editing");
+ compose("restart", "cedarworkspace");
+ await waitForApplication();
+ playwright("@responsive.*invitation");
+ compose("restart", "cedarworkspace");
+ await waitForApplication();
+ playwright("@forms|@exercise|@recovery");
+ compose("restart", "cedarworkspace");
+ await waitForApplication();
+ playwright("@limits");
compose("restart", "cedarworkspace");
await waitForApplication();
playwright("@persistence");
+ compose(
+ "exec",
+ "-T",
+ "cedarworkspace",
+ "node",
+ "--env-file=/run/config/app.env",
+ "scripts/reset.ts",
+ );
+ compose("restart", "cedarworkspace");
+ await waitForApplication();
+ playwright("@workflow");
console.log(
"P11 Docker browser proof passed: OIDC, three gaps, controls, and restart persistence",
);
diff --git a/p11-saas-workspace/server/app.ts b/p11-saas-workspace/server/app.ts
index 27c4cf6..7b46b7c 100644
--- a/p11-saas-workspace/server/app.ts
+++ b/p11-saas-workspace/server/app.ts
@@ -1,35 +1,30 @@
import { createRequestHandler } from "@react-router/express";
-import express from "express";
import type { Express } from "express";
+import express from "express";
import { RouterContextProvider, type ServerBuild } from "react-router";
import { requestServicesContext } from "../app/context.ts";
-import { PermissiveAuthorizationGateway } from "../src/server/capabilities.ts";
-import { loadConfig } from "../src/server/config.ts";
-import { AppDatabase } from "../src/server/database.ts";
-import { createOidcRuntime } from "../src/server/oidc.ts";
-import { SessionManager } from "../src/server/session.ts";
-import { WorkspaceService } from "../src/server/service.ts";
+import { createOidcRuntime } from "./auth/oidc.ts";
+import { SessionManager } from "./auth/session.ts";
+import { loadConfig } from "./config/environment.ts";
+import { AppDatabase } from "./data/database.ts";
+import { RequestService } from "./http/requests.ts";
+import { PermissiveAuthorizationGateway } from "./workspace/capabilities.ts";
+import { WorkspaceService } from "./workspace/service.ts";
const runtimeKey = Symbol.for("p11.runtime");
-type Runtime = Awaited>;
-type RuntimeGlobal = typeof globalThis & { [runtimeKey]?: Promise };
+type Resources = Awaited>;
+type RuntimeGlobal = typeof globalThis & { [runtimeKey]?: Promise };
-async function createRuntime() {
+async function createResources() {
const config = loadConfig();
const database = new AppDatabase(config.databaseUrl);
try {
- await database.migrate();
+ await database.initialize(config.issuer);
const oidc = await createOidcRuntime(config);
- const authorization = new PermissiveAuthorizationGateway();
return {
config,
- database,
- sessions: new SessionManager(config, database, oidc),
- workspace: new WorkspaceService(
- database,
- authorization,
- config.sessionSecret,
- ),
+ pool: database.pool,
+ oidc,
};
} catch (error) {
await database.close();
@@ -39,9 +34,17 @@ async function createRuntime() {
const globalRuntime = globalThis as RuntimeGlobal;
if (!globalRuntime[runtimeKey]) {
- globalRuntime[runtimeKey] = createRuntime();
+ const pending = createResources();
+ globalRuntime[runtimeKey] = pending;
+ void pending.catch(() => {
+ if (globalRuntime[runtimeKey] === pending) delete globalRuntime[runtimeKey];
+ });
}
-const runtime = await globalRuntime[runtimeKey];
+const resources = await globalRuntime[runtimeKey];
+// Preserve connections, not old class instances, when Vite reloads server code.
+const database = new AppDatabase(resources.pool);
+const authorization = new PermissiveAuthorizationGateway();
+const sessions = new SessionManager(resources.config, database, resources.oidc);
export const app: Express = express();
app.use(
@@ -52,9 +55,18 @@ app.use(
) as unknown as Promise,
getLoadContext(_request, response) {
const context = new RouterContextProvider();
+ const workspace = new WorkspaceService(
+ database,
+ authorization,
+ resources.config.sessionSecret,
+ );
+ const requestId = String(response.locals.requestId);
context.set(requestServicesContext, {
- ...runtime,
- requestId: String(response.locals.requestId),
+ database,
+ sessions,
+ workspace,
+ requests: new RequestService(sessions, workspace, requestId),
+ requestId,
nonce: String(response.locals.nonce),
});
return context;
@@ -65,5 +77,5 @@ app.use(
export async function closeRuntime(): Promise {
const active = globalRuntime[runtimeKey];
delete globalRuntime[runtimeKey];
- if (active) await (await active).database.close();
+ if (active) await (await active).pool.end();
}
diff --git a/p11-saas-workspace/src/server/crypto.ts b/p11-saas-workspace/server/auth/crypto.ts
similarity index 100%
rename from p11-saas-workspace/src/server/crypto.ts
rename to p11-saas-workspace/server/auth/crypto.ts
diff --git a/p11-saas-workspace/src/server/oidc.ts b/p11-saas-workspace/server/auth/oidc.ts
similarity index 95%
rename from p11-saas-workspace/src/server/oidc.ts
rename to p11-saas-workspace/server/auth/oidc.ts
index 097dfae..dd8f3f0 100644
--- a/p11-saas-workspace/src/server/oidc.ts
+++ b/p11-saas-workspace/server/auth/oidc.ts
@@ -1,6 +1,6 @@
import * as oidc from "openid-client";
-import type { AppConfig } from "./config.ts";
-import type { OidcTokens, OidcTransaction } from "./models.ts";
+import type { AppConfig } from "../config/environment.ts";
+import type { OidcTokens, OidcTransaction } from "../shared/models.ts";
type OidcIdentity = Readonly<{
issuer: string;
diff --git a/p11-saas-workspace/src/server/session.ts b/p11-saas-workspace/server/auth/session.ts
similarity index 93%
rename from p11-saas-workspace/src/server/session.ts
rename to p11-saas-workspace/server/auth/session.ts
index 625a993..fa35662 100644
--- a/p11-saas-workspace/src/server/session.ts
+++ b/p11-saas-workspace/server/auth/session.ts
@@ -1,12 +1,12 @@
import { createCookie, redirect } from "react-router";
-import type { AppConfig } from "./config.ts";
+import type { AppConfig } from "../config/environment.ts";
+import { limits } from "../config/limits.ts";
+import type { AppDatabase } from "../data/database.ts";
+import { forbidden } from "../shared/errors.ts";
+import type { OidcTokens, Session } from "../shared/models.ts";
+import { safeEqual } from "../shared/validation.ts";
import { decryptJson, encryptJson, randomToken, tokenHash } from "./crypto.ts";
-import type { AppDatabase } from "./database.ts";
-import { forbidden, unauthorized } from "./errors.ts";
-import { limits } from "./limits.ts";
import type { OidcRuntime } from "./oidc.ts";
-import type { OidcTokens, Session } from "./models.ts";
-import { safeEqual } from "./validation.ts";
const allowedLoginHints = new Set(["maya", "noah", "lena", "imani"]);
@@ -48,12 +48,6 @@ export class SessionManager {
return pending;
}
- async require(request: Request): Promise {
- const session = await this.optional(request);
- if (!session) throw unauthorized();
- return session;
- }
-
requireMutation(request: Request, session: Session, form: FormData): void {
const origin = request.headers.get("Origin");
const fetchSite = request.headers.get("Sec-Fetch-Site");
diff --git a/p11-saas-workspace/src/server/config.ts b/p11-saas-workspace/server/config/environment.ts
similarity index 96%
rename from p11-saas-workspace/src/server/config.ts
rename to p11-saas-workspace/server/config/environment.ts
index 63444ff..179ddbf 100644
--- a/p11-saas-workspace/src/server/config.ts
+++ b/p11-saas-workspace/server/config/environment.ts
@@ -8,7 +8,6 @@ export type AppConfig = Readonly<{
clientId: string;
clientSecret: string;
sessionSecret: string;
- production: boolean;
}>;
function required(env: NodeJS.ProcessEnv, name: string): string {
@@ -56,6 +55,5 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
clientId: env.P11_CLIENT_ID?.trim() || "p11-saas-workspace",
clientSecret: secret(env, "P11_CLIENT_SECRET"),
sessionSecret: secret(env, "P11_SESSION_SECRET"),
- production: env.NODE_ENV === "production",
};
}
diff --git a/p11-saas-workspace/src/server/limits.ts b/p11-saas-workspace/server/config/limits.ts
similarity index 81%
rename from p11-saas-workspace/src/server/limits.ts
rename to p11-saas-workspace/server/config/limits.ts
index 0461309..b14ba31 100644
--- a/p11-saas-workspace/src/server/limits.ts
+++ b/p11-saas-workspace/server/config/limits.ts
@@ -9,7 +9,4 @@ export const limits = {
projectPageSize: 50,
projectNameCharacters: 120,
projectBodyBytes: 16_384,
- diagnostics: 100,
- diagnosticTtlMs: 15 * 60 * 1_000,
- diagnosticBytes: 16_384,
} as const;
diff --git a/p11-saas-workspace/src/server/database.ts b/p11-saas-workspace/server/data/database.ts
similarity index 63%
rename from p11-saas-workspace/src/server/database.ts
rename to p11-saas-workspace/server/data/database.ts
index ea6c9ca..1934826 100644
--- a/p11-saas-workspace/src/server/database.ts
+++ b/p11-saas-workspace/server/data/database.ts
@@ -1,25 +1,32 @@
import { createHash, randomUUID } from "node:crypto";
-import { readFile, readdir } from "node:fs/promises";
+import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import type {
Billing,
Invitation,
+ InvitationCandidate,
Membership,
Project,
Role,
SupportApproval,
-} from "../shared/contracts.ts";
-import { conflict, notFound } from "./errors.ts";
-import { limits } from "./limits.ts";
-import { safeEqual } from "./validation.ts";
+} from "../../app/types/contracts.ts";
+import { limits } from "../config/limits.ts";
+import { conflict, notFound } from "../shared/errors.ts";
import type {
+ AuthoritySnapshot,
+ InvitationAcceptInput,
+ InvitationEvidence,
+ InvitationIssueInput,
OidcTransaction,
Principal,
+ ProjectWriteInput,
Selection,
Session,
+ SupportOpenInput,
WorkspaceRepository,
-} from "./models.ts";
+} from "../shared/models.ts";
+import { safeEqual } from "../shared/validation.ts";
type Row = QueryResultRow & Record;
@@ -56,6 +63,9 @@ function mapInvitation(row: Row): Invitation {
return {
id: String(row.id),
organizationId: String(row.organization_id),
+ ...(row.organization_name
+ ? { organizationName: String(row.organization_name) }
+ : {}),
targetPrincipalId: String(row.target_principal_id),
targetName: String(row.target_name),
role: String(row.role) as Role,
@@ -69,7 +79,11 @@ function mapSupport(row: Row): SupportApproval {
const result: SupportApproval = {
id: String(row.id),
organizationId: String(row.organization_id),
+ ...(row.organization_name
+ ? { organizationName: String(row.organization_name) }
+ : {}),
projectId: String(row.project_id),
+ ...(row.project_name ? { projectName: String(row.project_name) } : {}),
operation: "project.read",
expiresAt: date(row.expires_at),
version: number(row.version),
@@ -81,13 +95,22 @@ function mapSupport(row: Row): SupportApproval {
export class AppDatabase implements WorkspaceRepository {
readonly pool: Pool;
- constructor(connectionString: string) {
- this.pool = new Pool({
- connectionString,
- max: 8,
- connectionTimeoutMillis: 3_000,
- idleTimeoutMillis: 10_000,
- });
+ constructor(connection: string | Pool) {
+ this.pool =
+ typeof connection === "string"
+ ? new Pool({
+ connectionString: connection,
+ max: 8,
+ connectionTimeoutMillis: 3_000,
+ idleTimeoutMillis: 10_000,
+ })
+ : connection;
+ if (typeof connection === "string") {
+ // pg removes the broken idle client; the next query can establish a connection.
+ this.pool.on("error", () =>
+ console.error("P11 PostgreSQL connection unavailable"),
+ );
+ }
}
async migrate(directory = path.resolve("migrations")): Promise {
@@ -120,8 +143,31 @@ export class AppDatabase implements WorkspaceRepository {
}
}
- async seed(file = path.resolve("fixtures/seed.sql")): Promise {
- await this.pool.query(await readFile(file, "utf8"));
+ async seed(
+ issuer: string,
+ file = path.resolve("fixtures/seed.sql"),
+ ): Promise {
+ const sql = await readFile(file, "utf8");
+ const client = await this.pool.connect();
+ try {
+ await client.query("BEGIN");
+ await client.query(sql);
+ await client.query("UPDATE principals SET issuer = $1", [issuer]);
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ } finally {
+ client.release();
+ }
+ }
+
+ async initialize(issuer: string): Promise {
+ await this.migrate();
+ const { rows } = await this.pool.query<{ count: string }>(
+ "SELECT count(*) count FROM principals",
+ );
+ if (Number(rows[0]?.count) === 0) await this.seed(issuer);
}
async health(): Promise {
@@ -151,35 +197,108 @@ export class AppDatabase implements WorkspaceRepository {
return rows[0] ? this.mapPrincipal(rows[0]) : undefined;
}
- async memberships(principalId: string): Promise {
- const { rows } = await this.pool.query(
- `SELECT m.organization_id, o.name organization_name, m.role, m.version
- FROM memberships m JOIN organizations o ON o.id = m.organization_id
- WHERE m.principal_id = $1 AND m.active
- ORDER BY m.organization_id LIMIT $2`,
- [principalId, limits.membershipsPerPrincipal],
- );
- return rows.map(mapMembership);
+ authority(principalId: string): Promise {
+ return this.readAuthority(this.pool, principalId);
}
- async selection(principalId: string): Promise {
- const { rows } = await this.pool.query(
+ private async readAuthority(
+ client: Pool | PoolClient,
+ principalId: string,
+ ): Promise {
+ const selection = await client.query(
"SELECT organization_id, version FROM active_selections WHERE principal_id = $1",
[principalId],
);
- const row = rows[0];
- if (!row) throw notFound("selection_not_found");
+ if (!selection.rows[0]) throw notFound("selection_not_found");
+ const memberships = await client.query(
+ `SELECT m.organization_id, o.name organization_name, m.role, m.version
+ FROM memberships m JOIN organizations o ON o.id = m.organization_id
+ WHERE m.principal_id = $1 AND m.active ORDER BY m.organization_id`,
+ [principalId],
+ );
+ const support = await client.query(
+ `SELECT a.*, s.active_until, s.version session_version FROM support_approvals a
+ JOIN support_sessions s ON s.approval_id = a.id
+ WHERE a.principal_id = $1 AND s.active_until > clock_timestamp()
+ AND a.expires_at > clock_timestamp() AND s.approval_version = a.version
+ ORDER BY a.id`,
+ [principalId],
+ );
return {
- organizationId: row.organization_id ? String(row.organization_id) : null,
- version: number(row.version),
+ principalId,
+ selection: {
+ organizationId: selection.rows[0].organization_id
+ ? String(selection.rows[0].organization_id)
+ : null,
+ version: number(selection.rows[0].version),
+ },
+ memberships: memberships.rows.map(mapMembership),
+ support: support.rows.map((row) => ({
+ ...mapSupport(row),
+ sessionVersion: number(row.session_version),
+ })),
};
}
+ private async guardAuthority(
+ client: PoolClient,
+ expected: AuthoritySnapshot,
+ principalId: string,
+ organizationId: string,
+ ): Promise {
+ if (expected.principalId !== principalId)
+ throw conflict("authority_changed");
+ // Advisory locks also protect missing relationships against application inserts.
+ // Row locks fence updates made by database administration tools through commit.
+ await client.query(
+ "SELECT 1 FROM active_selections WHERE principal_id = $1 FOR SHARE",
+ [principalId],
+ );
+ await client.query(
+ "SELECT 1 FROM memberships WHERE principal_id = $1 AND organization_id = $2 FOR SHARE",
+ [principalId, organizationId],
+ );
+ await client.query(
+ "SELECT 1 FROM support_approvals WHERE principal_id = $1 AND organization_id = $2 ORDER BY id FOR SHARE",
+ [principalId, organizationId],
+ );
+ await client.query(
+ "SELECT 1 FROM support_sessions WHERE principal_id = $1 AND organization_id = $2 ORDER BY approval_id FOR SHARE",
+ [principalId, organizationId],
+ );
+ const fingerprint = (snapshot: AuthoritySnapshot) =>
+ JSON.stringify({
+ selection: snapshot.selection,
+ memberships: snapshot.memberships
+ .filter((m) => m.organizationId === organizationId)
+ .map((m) => [m.organizationId, m.role, m.version]),
+ support: snapshot.support
+ .filter((s) => s.organizationId === organizationId)
+ .map((s) => [
+ s.id,
+ s.organizationId,
+ s.projectId,
+ s.operation,
+ s.version,
+ s.sessionVersion,
+ s.expiresAt,
+ s.activeUntil,
+ ]),
+ });
+ if (
+ fingerprint(expected) !==
+ fingerprint(await this.readAuthority(client, principalId))
+ ) {
+ throw conflict("authority_changed");
+ }
+ }
+
async switchOrganization(
principalId: string,
organizationId: string,
expectedVersion: number,
key: string,
+ authority: AuthoritySnapshot,
): Promise {
return this.idempotent(
principalId,
@@ -192,6 +311,12 @@ export class AppDatabase implements WorkspaceRepository {
[organizationId],
);
if (!organization.rowCount) throw notFound("organization_not_found");
+ await this.guardAuthority(
+ client,
+ authority,
+ principalId,
+ organizationId,
+ );
const { rows } = await client.query(
`UPDATE active_selections SET organization_id = $1, version = version + 1
WHERE principal_id = $2 AND version = $3
@@ -226,17 +351,7 @@ export class AppDatabase implements WorkspaceRepository {
return rows[0] ? mapProject(rows[0]) : undefined;
}
- async writeProject(
- input: Readonly<{
- principalId: string;
- organizationId: string;
- projectId: string;
- name: string;
- body: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise {
+ async writeProject(input: ProjectWriteInput): Promise {
return this.idempotent(
input.principalId,
`project.write:${input.projectId}`,
@@ -249,6 +364,16 @@ export class AppDatabase implements WorkspaceRepository {
input.expectedVersion,
),
async (client) => {
+ await client.query(
+ "SELECT 1 FROM projects WHERE id = $1 AND organization_id = $2 FOR UPDATE",
+ [input.projectId, input.organizationId],
+ );
+ await this.guardAuthority(
+ client,
+ input.authority,
+ input.principalId,
+ input.organizationId,
+ );
const { rows } = await client.query(
`UPDATE projects SET name = $1, body = $2, author_id = $3, version = version + 1
WHERE id = $4 AND organization_id = $5 AND version = $6 RETURNING *`,
@@ -282,8 +407,14 @@ export class AppDatabase implements WorkspaceRepository {
async invitations(organizationId: string): Promise {
await this.expireInvitations("organization_id", organizationId);
const { rows } = await this.pool.query(
- `SELECT i.*, p.name target_name FROM invitations i JOIN principals p ON p.id = i.target_principal_id
- WHERE i.organization_id = $1 ORDER BY i.id LIMIT $2`,
+ `SELECT i.*, o.name organization_name, p.name target_name
+ FROM invitations i
+ JOIN principals p ON p.id = i.target_principal_id
+ JOIN organizations o ON o.id = i.organization_id
+ WHERE i.organization_id = $1
+ ORDER BY (i.state = 'pending') DESC,
+ CASE WHEN i.state = 'pending' THEN i.expires_at END ASC,
+ i.expires_at DESC, i.id LIMIT $2`,
[organizationId, limits.pendingInvitationsPerOrganization],
);
return rows.map(mapInvitation);
@@ -292,25 +423,37 @@ export class AppDatabase implements WorkspaceRepository {
async invitationsFor(principalId: string): Promise {
await this.expireInvitations("target_principal_id", principalId);
const { rows } = await this.pool.query(
- `SELECT i.*, p.name target_name FROM invitations i JOIN principals p ON p.id = i.target_principal_id
- WHERE i.target_principal_id = $1 ORDER BY i.id LIMIT $2`,
+ `SELECT i.*, o.name organization_name, p.name target_name
+ FROM invitations i
+ JOIN principals p ON p.id = i.target_principal_id
+ JOIN organizations o ON o.id = i.organization_id
+ WHERE i.target_principal_id = $1
+ ORDER BY (i.state = 'pending') DESC,
+ CASE WHEN i.state = 'pending' THEN i.expires_at END ASC,
+ i.expires_at DESC, i.id LIMIT $2`,
[principalId, limits.pendingInvitationsPerOrganization],
);
return rows.map(mapInvitation);
}
- async issueInvitation(
- input: Readonly<{
- principalId: string;
- organizationId: string;
- targetPrincipalId: string;
- role: "editor" | "viewer";
- expectedSelectionVersion: number;
- idempotencyKey: string;
- tokenHash: string;
- expiresAt: Date;
- }>,
- ): Promise {
+ async invitation(
+ id: string,
+ hash: string,
+ ): Promise {
+ const { rows } = await this.pool.query(
+ `SELECT i.*, p.name target_name FROM invitations i
+ JOIN principals p ON p.id = i.target_principal_id WHERE i.id = $1`,
+ [id],
+ );
+ return rows[0]
+ ? {
+ invitation: mapInvitation(rows[0]),
+ tokenMatches: safeEqual(String(rows[0].token_hash), hash),
+ }
+ : undefined;
+ }
+
+ async issueInvitation(input: InvitationIssueInput): Promise {
await this.expireInvitations("organization_id", input.organizationId);
return this.idempotent(
input.principalId,
@@ -330,6 +473,17 @@ export class AppDatabase implements WorkspaceRepository {
);
if (!selection.rowCount) throw conflict();
await this.lock(client, `invitations:${input.organizationId}`);
+ const existingMember = await client.query(
+ "SELECT 1 FROM memberships WHERE principal_id = $1 AND organization_id = $2 AND active",
+ [input.targetPrincipalId, input.organizationId],
+ );
+ if (existingMember.rowCount)
+ throw conflict("membership_already_exists");
+ const pending = await client.query(
+ "SELECT 1 FROM invitations WHERE organization_id = $1 AND target_principal_id = $2 AND state = 'pending'",
+ [input.organizationId, input.targetPrincipalId],
+ );
+ if (pending.rowCount) throw conflict("invitation_already_pending");
const count = await client.query(
"SELECT count(*) count FROM invitations WHERE organization_id = $1 AND state = 'pending'",
[input.organizationId],
@@ -340,11 +494,17 @@ export class AppDatabase implements WorkspaceRepository {
) {
throw conflict("invitation_limit_reached");
}
+ await this.guardAuthority(
+ client,
+ input.authority,
+ input.principalId,
+ input.organizationId,
+ );
const id = `invite-${randomUUID()}`;
const { rows } = await client.query(
`INSERT INTO invitations
(id, organization_id, target_principal_id, role, issuer_id, token_hash, expires_at, state)
- VALUES ($1,$2,$3,$4,$5,$6,$7,'pending')
+ VALUES ($1,$2,$3,$4,$5,$6,clock_timestamp() + $7 * interval '1 millisecond','pending')
RETURNING *, (SELECT name FROM principals WHERE id = $3) target_name`,
[
id,
@@ -353,24 +513,17 @@ export class AppDatabase implements WorkspaceRepository {
input.role,
input.principalId,
input.tokenHash,
- input.expiresAt,
+ limits.invitationMs,
],
);
if (!rows[0]) throw conflict("invitation_not_created");
return mapInvitation(rows[0]);
},
+ [input.targetPrincipalId],
);
}
- async acceptInvitation(
- input: Readonly<{
- principalId: string;
- invitationId: string;
- tokenHash: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise {
+ async acceptInvitation(input: InvitationAcceptInput): Promise {
await this.expireInvitations("id", input.invitationId);
return this.idempotent(
input.principalId,
@@ -440,10 +593,13 @@ export class AppDatabase implements WorkspaceRepository {
const updated = await client.query(
`UPDATE invitations SET state = 'accepted', version = version + 1
WHERE id = $1 AND state = 'pending' AND version = $2
+ AND expires_at > clock_timestamp()
RETURNING *, (SELECT name FROM principals WHERE id = target_principal_id) target_name`,
[input.invitationId, input.expectedVersion],
);
- if (!updated.rows[0]) throw conflict();
+ // Any membership insert above rolls back with an invitation that expired
+ // while this transaction waited for the membership locks.
+ if (!updated.rows[0]) throw conflict("invitation_expired");
return mapInvitation(updated.rows[0]);
},
);
@@ -451,7 +607,9 @@ export class AppDatabase implements WorkspaceRepository {
async billing(organizationId: string): Promise {
const { rows } = await this.pool.query(
- "SELECT * FROM billing_projections WHERE organization_id = $1",
+ `SELECT b.*, (SELECT count(*) FROM memberships m
+ WHERE m.organization_id = b.organization_id AND m.active) active_seats
+ FROM billing_projections b WHERE b.organization_id = $1`,
[organizationId],
);
const row = rows[0];
@@ -460,6 +618,7 @@ export class AppDatabase implements WorkspaceRepository {
organizationId: String(row.organization_id),
plan: String(row.plan),
seats: number(row.seats),
+ activeSeats: number(row.active_seats),
monthlyCents: number(row.monthly_cents),
version: number(row.version),
}
@@ -477,26 +636,78 @@ export class AppDatabase implements WorkspaceRepository {
);
}
+ async invitationCandidates(
+ organizationId: string,
+ ): Promise {
+ const { rows } = await this.pool.query(
+ `SELECT p.id, p.name,
+ CASE WHEN EXISTS (SELECT 1 FROM memberships m
+ WHERE m.principal_id = p.id AND m.organization_id = $1 AND m.active)
+ THEN 'member'
+ WHEN EXISTS (SELECT 1 FROM invitations i
+ WHERE i.target_principal_id = p.id AND i.organization_id = $1
+ AND i.state = 'pending' AND i.expires_at > clock_timestamp())
+ THEN 'pending' ELSE 'available' END state
+ FROM principals p ORDER BY p.name, p.id LIMIT $2`,
+ [organizationId, limits.membersPerOrganization],
+ );
+ return rows.map((row) => ({
+ id: String(row.id),
+ name: String(row.name),
+ state: String(row.state) as InvitationCandidate["state"],
+ }));
+ }
+
async supportApprovals(
principalId: string,
): Promise {
const { rows } = await this.pool.query(
- `SELECT a.*, s.active_until FROM support_approvals a
+ `SELECT a.*, o.name organization_name, p.name project_name, s.active_until FROM support_approvals a
+ JOIN organizations o ON o.id = a.organization_id
LEFT JOIN support_sessions s ON s.approval_id = a.id
+ AND s.approval_version = a.version AND s.active_until > clock_timestamp()
+ AND a.expires_at > clock_timestamp()
+ LEFT JOIN projects p ON p.id = a.project_id
WHERE a.principal_id = $1 ORDER BY a.id LIMIT 20`,
[principalId],
);
return rows.map(mapSupport);
}
- async openSupport(
- input: Readonly<{
- principalId: string;
- approvalId: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise {
+ async renewSupportApproval(approvalId: string): Promise {
+ const owner = await this.pool.query<{ principal_id: string }>(
+ "SELECT principal_id FROM support_approvals WHERE id = $1",
+ [approvalId],
+ );
+ if (!owner.rows[0]) throw notFound("support_approval_not_found");
+ const client = await this.pool.connect();
+ try {
+ await client.query("BEGIN");
+ await this.lock(client, `authority:${owner.rows[0].principal_id}`);
+ const current = await client.query(
+ "SELECT id FROM support_approvals WHERE id = $1 FOR UPDATE",
+ [approvalId],
+ );
+ if (!current.rowCount) throw notFound("support_approval_not_found");
+ await client.query(
+ "DELETE FROM support_sessions WHERE approval_id = $1",
+ [approvalId],
+ );
+ await client.query(
+ `UPDATE support_approvals SET expires_at = clock_timestamp() + interval '2 hours',
+ version = version + 1 WHERE id = $1`,
+ [approvalId],
+ );
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ } finally {
+ client.release();
+ }
+ }
+
+ async openSupport(input: SupportOpenInput): Promise {
return this.idempotent(
input.principalId,
`support.open:${input.approvalId}`,
@@ -549,20 +760,6 @@ export class AppDatabase implements WorkspaceRepository {
);
}
- async activeSupport(
- principalId: string,
- ): Promise {
- const { rows } = await this.pool.query(
- `SELECT a.*, s.active_until FROM support_approvals a
- JOIN support_sessions s ON s.approval_id = a.id
- WHERE s.principal_id = $1 AND s.active_until > now()
- AND a.expires_at > now() AND s.approval_version = a.version
- ORDER BY a.id LIMIT 20`,
- [principalId],
- );
- return rows.map(mapSupport);
- }
-
async createTransaction(
rawIdHash: string,
transaction: OidcTransaction,
@@ -666,12 +863,38 @@ export class AppDatabase implements WorkspaceRepository {
principalId: string,
organizationId: string,
): Promise {
- const result = await this.pool.query(
- `UPDATE memberships SET active = false, version = version + 1
- WHERE principal_id = $1 AND organization_id = $2 AND active`,
- [principalId, organizationId],
- );
- if (!result.rowCount) throw notFound("membership_not_found");
+ return this.setMembershipActive(principalId, organizationId, false);
+ }
+
+ async restoreMembership(
+ principalId: string,
+ organizationId: string,
+ ): Promise {
+ return this.setMembershipActive(principalId, organizationId, true);
+ }
+
+ private async setMembershipActive(
+ principalId: string,
+ organizationId: string,
+ active: boolean,
+ ): Promise {
+ const client = await this.pool.connect();
+ try {
+ await client.query("BEGIN");
+ await this.lock(client, `authority:${principalId}`);
+ const result = await client.query(
+ `UPDATE memberships SET active = $3, version = version + 1
+ WHERE principal_id = $1 AND organization_id = $2 AND active <> $3`,
+ [principalId, organizationId, active],
+ );
+ if (!result.rowCount) throw notFound("membership_not_found");
+ await client.query("COMMIT");
+ } catch (error) {
+ await client.query("ROLLBACK");
+ throw error;
+ } finally {
+ client.release();
+ }
}
private mapPrincipal(row: Row): Principal {
@@ -689,10 +912,15 @@ export class AppDatabase implements WorkspaceRepository {
key: string,
hash: string,
effect: (client: PoolClient) => Promise,
+ relatedPrincipals: readonly string[] = [],
): Promise {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
+ await this.lock(
+ client,
+ ...[principalId, ...relatedPrincipals].map((id) => `authority:${id}`),
+ );
await this.lock(client, `idempotency:${principalId}:${operation}:${key}`);
const existing = await client.query(
`SELECT result, request_hash FROM idempotency_records
diff --git a/p11-saas-workspace/server/http/render.tsx b/p11-saas-workspace/server/http/render.tsx
new file mode 100644
index 0000000..583bd3d
--- /dev/null
+++ b/p11-saas-workspace/server/http/render.tsx
@@ -0,0 +1,62 @@
+import { PassThrough } from "node:stream";
+import { createReadableStreamFromReadable } from "@react-router/node";
+import { renderToPipeableStream } from "react-dom/server";
+import type { EntryContext, RouterContextProvider } from "react-router";
+import { ServerRouter } from "react-router";
+import {
+ DocumentNonceContext,
+ requestServicesContext,
+} from "../../app/context.ts";
+
+export const streamTimeout = 5_000;
+
+export default function handleRequest(
+ request: Request,
+ responseStatusCode: number,
+ responseHeaders: Headers,
+ routerContext: EntryContext,
+ loadContext: RouterContextProvider,
+): Promise | Response {
+ if (request.method.toUpperCase() === "HEAD") {
+ return new Response(null, {
+ status: responseStatusCode,
+ headers: responseHeaders,
+ });
+ }
+ const nonce = loadContext.get(requestServicesContext).nonce;
+ return new Promise((resolve, reject) => {
+ let shellRendered = false;
+ let timeout: ReturnType | undefined;
+ const { pipe, abort } = renderToPipeableStream(
+
+
+ ,
+ {
+ nonce,
+ onShellReady() {
+ shellRendered = true;
+ const body = new PassThrough({
+ final(callback) {
+ if (timeout) clearTimeout(timeout);
+ callback();
+ },
+ });
+ responseHeaders.set("Content-Type", "text/html; charset=utf-8");
+ pipe(body);
+ resolve(
+ new Response(createReadableStreamFromReadable(body), {
+ status: responseStatusCode,
+ headers: responseHeaders,
+ }),
+ );
+ },
+ onShellError: reject,
+ onError(error) {
+ responseStatusCode = 500;
+ if (shellRendered) console.error("P11 render failed", error);
+ },
+ },
+ );
+ timeout = setTimeout(abort, streamTimeout);
+ });
+}
diff --git a/p11-saas-workspace/server/http/requests.ts b/p11-saas-workspace/server/http/requests.ts
new file mode 100644
index 0000000..179834c
--- /dev/null
+++ b/p11-saas-workspace/server/http/requests.ts
@@ -0,0 +1,122 @@
+import { data, redirect } from "react-router";
+import type { SessionView } from "../../app/types/contracts.ts";
+import { randomToken } from "../auth/crypto.ts";
+import type { SessionManager } from "../auth/session.ts";
+import { AppError, databaseFailure, isDenied } from "../shared/errors.ts";
+import type { Session } from "../shared/models.ts";
+import {
+ expectedVersion,
+ idempotencyKey,
+ identifier,
+ invitationToken,
+ inviteRole,
+ projectBody,
+ projectName,
+} from "../shared/validation.ts";
+import type { WorkspaceService } from "../workspace/service.ts";
+
+function field(form: FormData, name: string): string {
+ const value = form.get(name);
+ if (typeof value !== "string") throw new AppError(`invalid_${name}`, 400);
+ return value;
+}
+
+export class RequestService {
+ private readonly sessions: SessionManager;
+ private readonly workspace: WorkspaceService;
+ private readonly requestId: string;
+ constructor(
+ sessions: SessionManager,
+ workspace: WorkspaceService,
+ requestId: string,
+ ) {
+ this.sessions = sessions;
+ this.workspace = workspace;
+ this.requestId = requestId;
+ }
+
+ readonly isDenied = isDenied;
+
+ readonly input = {
+ field,
+ identifier,
+ idempotencyKey,
+ invitationToken,
+ inviteRole,
+ projectBody,
+ projectName,
+ version: (form: FormData) =>
+ expectedVersion(field(form, "expectedVersion")),
+ };
+
+ async authenticated(request: Request): Promise {
+ const current = await this.sessions.optional(request);
+ if (!current) throw redirect("/");
+ return current;
+ }
+
+ async sessionView(
+ request: Request,
+ organizationId?: string,
+ ): Promise {
+ const session = await this.sessions.optional(request);
+ if (!session) return undefined;
+ const workspace = await this.workspace.workspace(session.principal);
+ return {
+ user: { name: session.principal.name },
+ csrfToken: session.csrfToken,
+ activeOrganizationId: workspace.selection.organizationId,
+ selectionVersion: workspace.selection.version,
+ memberships: workspace.memberships,
+ navigationOrganizationId:
+ organizationId === undefined
+ ? workspace.selection.organizationId
+ : identifier(organizationId, "organization_id"),
+ switchKeys: Object.fromEntries(
+ workspace.memberships.map((membership) => [
+ membership.organizationId,
+ this.commandKey(),
+ ]),
+ ),
+ };
+ }
+
+ async mutation(
+ request: Request,
+ ): Promise<{ session: Session; form: FormData }> {
+ const session = await this.authenticated(request);
+ const form = await request.formData();
+ this.sessions.requireMutation(request, session, form);
+ return { session, form };
+ }
+
+ commandKey(): string {
+ return randomToken(12);
+ }
+
+ actionFailure(error: unknown) {
+ if (error instanceof Response) throw error;
+ const failure = this.requestFailure(error);
+ return data(
+ { ok: false as const, error: failure.code, requestId: this.requestId },
+ { status: failure.status },
+ );
+ }
+
+ loaderFailure(error: unknown): never {
+ if (error instanceof Response) throw error;
+ const failure = this.requestFailure(error);
+ throw data(
+ { error: failure.code, requestId: this.requestId },
+ { status: failure.status },
+ );
+ }
+
+ private requestFailure(error: unknown): AppError {
+ if (error instanceof AppError) return error;
+ if (databaseFailure(error))
+ return new AppError("database_unavailable", 503);
+ console.error(`P11 request failed | ${this.requestId}`);
+ return new AppError("request_failed", 500);
+ }
+}
diff --git a/p11-saas-workspace/server.js b/p11-saas-workspace/server/index.js
similarity index 70%
rename from p11-saas-workspace/server.js
rename to p11-saas-workspace/server/index.js
index 1bda980..b5e0b4c 100644
--- a/p11-saas-workspace/server.js
+++ b/p11-saas-workspace/server/index.js
@@ -1,19 +1,20 @@
import { randomBytes } from "node:crypto";
+import { createServer } from "node:http";
import express from "express";
+import { loadConfig } from "./config/environment.ts";
-const buildPath = "./build/server/index.js";
+const buildPath = "../build/server/index.js";
const development = process.env.NODE_ENV === "development";
-const host = process.env.P11_HOST || "127.0.0.1";
-const port = Number.parseInt(process.env.P11_PORT || "17011", 10);
-const baseUrl = process.env.P11_BASE_URL || `http://${host}:${port}`;
+const { host, port, baseUrl } = loadConfig();
const requestBytes = 24_576;
-const requestsPerMinute = 120;
+const requestsPerMinute = 600;
const rates = new Map();
const app = express();
+const server = createServer(app);
app.disable("x-powered-by");
app.set("trust proxy", false);
-app.use((request, response, next) => {
+app.use((_request, response, next) => {
const nonce = randomBytes(18).toString("base64url");
response.locals.nonce = nonce;
response.locals.requestId = randomBytes(12).toString("base64url");
@@ -22,14 +23,19 @@ app.use((request, response, next) => {
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
);
- response.setHeader("Referrer-Policy", "no-referrer");
+ response.setHeader("Referrer-Policy", "same-origin");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "DENY");
response.setHeader(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()",
);
+ next();
+});
+/** @type {import("express").RequestHandler} */
+const limitRequests = (request, response, next) => {
+ response.setHeader("Cache-Control", "private, no-store");
const now = Date.now();
const key = request.socket.remoteAddress || "unknown";
const current = rates.get(key);
@@ -52,27 +58,33 @@ app.use((request, response, next) => {
}
}
next();
-});
+};
/** @typedef {{ app: import("express").RequestHandler, closeRuntime?: () => Promise }} ApplicationModule */
/** @type {ApplicationModule} */
let applicationModule;
+/** @type {import("vite").ViteDevServer | undefined} */
+let vite;
if (development) {
- const vite = await import("vite").then((module) =>
- module.createServer({ server: { middlewareMode: true } }),
+ const developmentServer = await import("vite").then((module) =>
+ module.createServer({
+ server: { middlewareMode: true, ws: { server, clientPort: port } },
+ }),
);
+ vite = developmentServer;
applicationModule = /** @type {ApplicationModule} */ (
- await vite.ssrLoadModule("./server/app.ts")
+ await developmentServer.ssrLoadModule("./server/app.ts")
);
- app.use(vite.middlewares);
+ app.use(developmentServer.middlewares);
+ app.use(limitRequests);
app.use(async (request, response, next) => {
try {
applicationModule = /** @type {ApplicationModule} */ (
- await vite.ssrLoadModule("./server/app.ts")
+ await developmentServer.ssrLoadModule("./server/app.ts")
);
return await applicationModule.app(request, response, next);
} catch (error) {
- if (error instanceof Error) vite.ssrFixStacktrace(error);
+ if (error instanceof Error) developmentServer.ssrFixStacktrace(error);
next(error);
}
});
@@ -82,15 +94,16 @@ if (development) {
express.static("build/client/assets", { immutable: true, maxAge: "1y" }),
);
app.use(express.static("build/client", { index: false, maxAge: "1h" }));
+ app.use(limitRequests);
applicationModule = /** @type {ApplicationModule} */ (
await import(buildPath)
);
app.use(applicationModule.app);
}
-const server = app.listen(port, host, () => {
+server.listen(port, host, () => {
console.info("P11 authorization: FAKE ALLOW; Cedarling is not called.");
- console.info(`P11 CedarWorkspace listening at ${baseUrl}`);
+ console.info(`P11 workspace listening at ${baseUrl}`);
});
let stopping = false;
@@ -98,7 +111,8 @@ let stopping = false;
async function stop(signal) {
if (stopping) return;
stopping = true;
- console.info(`Received ${signal}; stopping P11 CedarWorkspace`);
+ console.info(`Received ${signal}; stopping P11 workspace`);
+ await vite?.close();
server.close(async (error) => {
try {
await applicationModule?.closeRuntime?.();
diff --git a/p11-saas-workspace/server/shared/errors.ts b/p11-saas-workspace/server/shared/errors.ts
new file mode 100644
index 0000000..c74dbe6
--- /dev/null
+++ b/p11-saas-workspace/server/shared/errors.ts
@@ -0,0 +1,41 @@
+export class AppError extends Error {
+ readonly code: string;
+ readonly status: number;
+
+ constructor(code: string, status: number) {
+ super(code);
+ this.code = code;
+ this.status = status;
+ }
+}
+
+export const badRequest = (code = "invalid_request") => new AppError(code, 400);
+export const forbidden = (code = "request_verification_failed") =>
+ new AppError(code, 403);
+export const notFound = (code = "not_found") => new AppError(code, 404);
+export const conflict = (code = "version_conflict") => new AppError(code, 409);
+export const isDenied = (error: unknown): error is AppError =>
+ error instanceof AppError && error.code === "authorization_denied";
+
+/** Only known connection/transaction failures are safe to translate into retries. */
+export function databaseFailure(error: unknown): boolean {
+ if (!(error instanceof Error)) return false;
+ const code = "code" in error ? String(error.code) : "";
+ return (
+ /^(08|53|57P0)/u.test(code) ||
+ [
+ "ECONNREFUSED",
+ "ECONNRESET",
+ "EPIPE",
+ "ETIMEDOUT",
+ "ENETUNREACH",
+ "EAI_AGAIN",
+ "ENOTFOUND",
+ "40001",
+ "40P01",
+ ].includes(code) ||
+ /^(Connection terminated|Connection closed|timeout exceeded when trying to connect)/u.test(
+ error.message,
+ )
+ );
+}
diff --git a/p11-saas-workspace/server/shared/models.ts b/p11-saas-workspace/server/shared/models.ts
new file mode 100644
index 0000000..3ad8a47
--- /dev/null
+++ b/p11-saas-workspace/server/shared/models.ts
@@ -0,0 +1,141 @@
+import type {
+ Billing,
+ Invitation,
+ InvitationCandidate,
+ Membership,
+ Project,
+ SupportApproval,
+} from "../../app/types/contracts.ts";
+
+export type Principal = Readonly<{
+ id: string;
+ issuer: string;
+ subject: string;
+ name: string;
+}>;
+
+export type Selection = Readonly<{
+ organizationId: string | null;
+ version: number;
+}>;
+
+/** Server-only evidence captured before a decision, checked again before a write. */
+export type AuthoritySnapshot = Readonly<{
+ principalId: string;
+ selection: Selection;
+ memberships: readonly Membership[];
+ support: readonly (SupportApproval & { sessionVersion: number })[];
+}>;
+
+export type InvitationEvidence = Readonly<{
+ invitation: Invitation;
+ tokenMatches: boolean;
+}>;
+
+export type WriteProjectCommand = Readonly<{
+ organizationId: string;
+ projectId: string;
+ name: string;
+ body: string;
+ expectedVersion: number;
+ idempotencyKey: string;
+}>;
+
+export type IssueInvitationCommand = Readonly<{
+ organizationId: string;
+ targetPrincipalId: string;
+ role: "editor" | "viewer";
+ expectedSelectionVersion: number;
+ idempotencyKey: string;
+}>;
+
+export type AcceptInvitationCommand = Readonly<{
+ invitationId: string;
+ token: string;
+ expectedVersion: number;
+ idempotencyKey: string;
+}>;
+
+export type ProjectWriteInput = WriteProjectCommand &
+ Readonly<{
+ principalId: string;
+ authority: AuthoritySnapshot;
+ }>;
+
+export type InvitationIssueInput = IssueInvitationCommand &
+ Readonly<{
+ principalId: string;
+ tokenHash: string;
+ authority: AuthoritySnapshot;
+ }>;
+
+export type InvitationAcceptInput = Omit &
+ Readonly<{
+ principalId: string;
+ tokenHash: string;
+ }>;
+
+export type SupportOpenInput = Readonly<{
+ principalId: string;
+ approvalId: string;
+ expectedVersion: number;
+ idempotencyKey: string;
+}>;
+
+export type OidcTransaction = Readonly<{
+ state: string;
+ nonce: string;
+ verifier: string;
+}>;
+
+export type OidcTokens = Readonly<{
+ issuer: string;
+ subject: string;
+ accessToken: string;
+ accessTokenExpiresAt: number;
+ refreshToken?: string;
+ idToken: string;
+ idTokenExpiresAt: number;
+ tokenType: string;
+ scope: string;
+}>;
+
+export type Session = Readonly<{
+ principal: Principal;
+ csrfToken: string;
+ encryptedTokens: string;
+ expiresAt: number;
+}>;
+
+export interface WorkspaceRepository {
+ findPrincipalById(id: string): Promise;
+ authority(principalId: string): Promise;
+ switchOrganization(
+ principalId: string,
+ organizationId: string,
+ expectedVersion: number,
+ idempotencyKey: string,
+ authority: AuthoritySnapshot,
+ ): Promise;
+ listProjects(organizationId: string): Promise;
+ project(
+ organizationId: string,
+ projectId: string,
+ ): Promise;
+ writeProject(input: ProjectWriteInput): Promise;
+ members(organizationId: string): Promise;
+ invitations(organizationId: string): Promise;
+ invitationCandidates(
+ organizationId: string,
+ ): Promise;
+ invitationsFor(principalId: string): Promise;
+ invitation(
+ id: string,
+ tokenHash: string,
+ ): Promise;
+ issueInvitation(input: InvitationIssueInput): Promise;
+ acceptInvitation(input: InvitationAcceptInput): Promise;
+ billing(organizationId: string): Promise;
+ supportApprovals(principalId: string): Promise;
+ openSupport(input: SupportOpenInput): Promise;
+}
diff --git a/p11-saas-workspace/src/server/validation.ts b/p11-saas-workspace/server/shared/validation.ts
similarity index 79%
rename from p11-saas-workspace/src/server/validation.ts
rename to p11-saas-workspace/server/shared/validation.ts
index fb1d5ac..129e688 100644
--- a/p11-saas-workspace/src/server/validation.ts
+++ b/p11-saas-workspace/server/shared/validation.ts
@@ -1,17 +1,10 @@
import { timingSafeEqual } from "node:crypto";
+import { limits } from "../config/limits.ts";
import { badRequest } from "./errors.ts";
-import { limits } from "./limits.ts";
const identifierPattern = /^[a-z][a-z0-9-]{1,63}$/;
const idempotencyPattern = /^[A-Za-z0-9_-]{8,80}$/;
-export function record(value: unknown): Record {
- if (!value || typeof value !== "object" || Array.isArray(value)) {
- throw badRequest();
- }
- return value as Record;
-}
-
export function identifier(value: unknown, name: string): string {
if (typeof value !== "string" || !identifierPattern.test(value)) {
throw badRequest(`invalid_${name}`);
@@ -20,7 +13,11 @@ export function identifier(value: unknown, name: string): string {
}
export function expectedVersion(value: unknown): number {
- if (!Number.isSafeInteger(value) || Number(value) < 1) {
+ if (
+ typeof value !== "string" ||
+ !/^[1-9][0-9]*$/u.test(value) ||
+ !Number.isSafeInteger(Number(value))
+ ) {
throw badRequest("invalid_expected_version");
}
return Number(value);
@@ -33,13 +30,6 @@ export function idempotencyKey(value: unknown): string {
return value;
}
-export function requestIdentifier(value: unknown): string {
- if (typeof value !== "string" || !idempotencyPattern.test(value)) {
- throw badRequest("invalid_request_id");
- }
- return value;
-}
-
export function projectName(value: unknown): string {
if (typeof value !== "string") throw badRequest("invalid_project_name");
const result = value.trim();
@@ -75,6 +65,13 @@ export function inviteRole(value: unknown): "editor" | "viewer" {
return value;
}
+export function invitationToken(value: unknown): string {
+ if (typeof value !== "string" || value.length < 16 || value.length > 200) {
+ throw badRequest("invalid_invitation_token");
+ }
+ return value;
+}
+
export function safeEqual(left: string, right: string): boolean {
const a = Buffer.from(left);
const b = Buffer.from(right);
diff --git a/p11-saas-workspace/server/workspace/capabilities.ts b/p11-saas-workspace/server/workspace/capabilities.ts
new file mode 100644
index 0000000..1f1bb7a
--- /dev/null
+++ b/p11-saas-workspace/server/workspace/capabilities.ts
@@ -0,0 +1,35 @@
+export const capabilityCatalog = {
+ "organization.switch": "Workspace::SwitchOrganization",
+ "members.read": "Workspace::ReadMembers",
+ "project.read": "Workspace::ReadProject",
+ "project.write": "Workspace::WriteProject",
+ "invitation.issue": "Workspace::IssueInvitation",
+ "invitation.accept": "Workspace::AcceptInvitation",
+ "billing.view": "Workspace::ViewBilling",
+ "support.open": "Workspace::OpenSupportScope",
+} as const;
+
+export type Capability = keyof typeof capabilityCatalog;
+export type SafeFacts = Readonly<
+ Record
+>;
+export type AuthorizationIntent = Readonly<{
+ requestId: string;
+ principalId: string;
+ capability: Capability;
+ resource: string;
+ facts: SafeFacts;
+ effect: string;
+}>;
+
+export interface AuthorizationGateway {
+ authorize(intent: AuthorizationIntent): Promise;
+}
+
+export class PermissiveAuthorizationGateway implements AuthorizationGateway {
+ async authorize(intent: AuthorizationIntent): Promise {
+ console.info(
+ `P11 server | FAKE ALLOW | ${intent.capability} | ${intent.principalId} -> ${intent.resource} | ${intent.requestId}`,
+ );
+ }
+}
diff --git a/p11-saas-workspace/server/workspace/service.ts b/p11-saas-workspace/server/workspace/service.ts
new file mode 100644
index 0000000..cd80a5b
--- /dev/null
+++ b/p11-saas-workspace/server/workspace/service.ts
@@ -0,0 +1,473 @@
+import type { Project, SupportApproval } from "../../app/types/contracts.ts";
+import { boundToken, tokenHash } from "../auth/crypto.ts";
+import { isDenied, notFound } from "../shared/errors.ts";
+import type {
+ AcceptInvitationCommand,
+ AuthoritySnapshot,
+ IssueInvitationCommand,
+ Principal,
+ WorkspaceRepository,
+ WriteProjectCommand,
+} from "../shared/models.ts";
+import type {
+ AuthorizationGateway,
+ AuthorizationIntent,
+ Capability,
+ SafeFacts,
+} from "./capabilities.ts";
+
+type AccessContext = Readonly<{
+ snapshot: AuthoritySnapshot;
+ facts: SafeFacts;
+ support: readonly SupportApproval[];
+}>;
+
+export class WorkspaceService {
+ private readonly repository: WorkspaceRepository;
+ private readonly authorization: AuthorizationGateway;
+ private readonly invitationSecret: string;
+ private readonly snapshots = new Map>();
+
+ constructor(
+ repository: WorkspaceRepository,
+ authorization: AuthorizationGateway,
+ invitationSecret: string,
+ ) {
+ this.repository = repository;
+ this.authorization = authorization;
+ this.invitationSecret = invitationSecret;
+ }
+
+ async workspace(principal: Principal) {
+ const { memberships, selection } = await this.snapshot(principal.id);
+ return { memberships, selection };
+ }
+
+ async switchOrganization(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ expectedVersion: number,
+ idempotencyKey: string,
+ ) {
+ return this.mutation(principal.id, async () => {
+ const authority = await this.repository.authority(principal.id);
+ const memberships = authority.memberships;
+ const membership = memberships.find(
+ (item) => item.organizationId === organizationId,
+ );
+ await this.allow(
+ requestId,
+ principal.id,
+ "organization.switch",
+ `Organization::${organizationId}`,
+ {
+ currentMembership: Boolean(membership),
+ currentRole: membership?.role ?? null,
+ },
+ "persist versioned active-organization selection",
+ );
+ return this.repository.switchOrganization(
+ principal.id,
+ organizationId,
+ expectedVersion,
+ idempotencyKey,
+ authority,
+ );
+ });
+ }
+
+ async listProjects(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ ) {
+ const projects = await this.repository.listProjects(organizationId);
+ const access = await this.accessFacts(principal.id, organizationId);
+ const visible = [];
+ for (const project of projects) {
+ try {
+ await this.allow(
+ requestId,
+ principal.id,
+ "project.read",
+ `Project::${project.id}`,
+ {
+ ...this.projectFacts(access, project, "project.read"),
+ projectVersion: project.version,
+ },
+ "return bounded project summary",
+ );
+ } catch (error) {
+ if (isDenied(error)) continue;
+ throw error;
+ }
+ visible.push({
+ id: project.id,
+ organizationId: project.organizationId,
+ name: project.name,
+ version: project.version,
+ });
+ }
+ return visible;
+ }
+
+ async readProject(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ projectId: string,
+ ) {
+ const project = await this.repository.project(organizationId, projectId);
+ if (!project) throw notFound("project_not_found");
+ const access = await this.accessFacts(principal.id, organizationId);
+ try {
+ await this.allow(
+ requestId,
+ principal.id,
+ "project.read",
+ `Project::${project.id}`,
+ {
+ ...this.projectFacts(access, project, "project.read"),
+ projectVersion: project.version,
+ },
+ "return one project",
+ );
+ } catch (error) {
+ if (isDenied(error)) throw notFound("project_not_found");
+ throw error;
+ }
+ return project;
+ }
+
+ async writeProject(
+ principal: Principal,
+ requestId: string,
+ input: WriteProjectCommand,
+ ) {
+ return this.mutation(principal.id, async () => {
+ const current = await this.repository.project(
+ input.organizationId,
+ input.projectId,
+ );
+ if (!current) throw notFound("project_not_found");
+ const access = await this.accessFacts(
+ principal.id,
+ input.organizationId,
+ await this.repository.authority(principal.id),
+ );
+ await this.allow(
+ requestId,
+ principal.id,
+ "project.write",
+ `Project::${current.id}`,
+ {
+ ...this.projectFacts(access, current, "project.write"),
+ currentVersion: current.version,
+ expectedVersion: input.expectedVersion,
+ },
+ "conditionally update one project",
+ );
+ return this.repository.writeProject({
+ principalId: principal.id,
+ authority: access.snapshot,
+ ...input,
+ });
+ });
+ }
+
+ async members(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ ) {
+ const access = await this.accessFacts(principal.id, organizationId);
+ await this.allow(
+ requestId,
+ principal.id,
+ "members.read",
+ `Organization::${organizationId}`,
+ access.facts,
+ "return bounded current-organization member list",
+ );
+ return this.repository.members(organizationId);
+ }
+
+ async invitations(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ ) {
+ const access = await this.accessFacts(principal.id, organizationId);
+ await this.allow(
+ requestId,
+ principal.id,
+ "invitation.issue",
+ `Organization::${organizationId}`,
+ access.facts,
+ "return bounded current-organization invitation list",
+ );
+ return this.repository.invitations(organizationId);
+ }
+
+ invitationsFor(principal: Principal) {
+ return this.repository.invitationsFor(principal.id);
+ }
+
+ async invitationSetup(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ ) {
+ // The invitation-management decision also covers its bounded recipient list.
+ const invitations = await this.invitations(
+ principal,
+ requestId,
+ organizationId,
+ );
+ const candidates =
+ await this.repository.invitationCandidates(organizationId);
+ return { invitations, candidates };
+ }
+
+ async issueInvitation(
+ principal: Principal,
+ requestId: string,
+ input: IssueInvitationCommand,
+ ) {
+ return this.mutation(principal.id, async () => {
+ const target = await this.repository.findPrincipalById(
+ input.targetPrincipalId,
+ );
+ if (!target) throw notFound("invitation_target_not_found");
+ const access = await this.accessFacts(
+ principal.id,
+ input.organizationId,
+ await this.repository.authority(principal.id),
+ );
+ await this.allow(
+ requestId,
+ principal.id,
+ "invitation.issue",
+ `Organization::${input.organizationId}`,
+ {
+ ...access.facts,
+ role: input.role,
+ targetMapped: true,
+ expectedSelectionVersion: input.expectedSelectionVersion,
+ },
+ "create one bounded expiring invitation",
+ );
+ const token = boundToken(
+ this.invitationSecret,
+ JSON.stringify([
+ "invitation.issue",
+ principal.id,
+ input.organizationId,
+ input.targetPrincipalId,
+ input.role,
+ input.expectedSelectionVersion,
+ input.idempotencyKey,
+ ]),
+ );
+ const invitation = await this.repository.issueInvitation({
+ principalId: principal.id,
+ ...input,
+ tokenHash: tokenHash(token),
+ authority: access.snapshot,
+ });
+ return { invitation, token };
+ });
+ }
+
+ async acceptInvitation(
+ principal: Principal,
+ requestId: string,
+ input: AcceptInvitationCommand,
+ ) {
+ return this.mutation(principal.id, async () => {
+ const evidence = await this.repository.invitation(
+ input.invitationId,
+ tokenHash(input.token),
+ );
+ if (!evidence) throw notFound("invitation_not_found");
+ const { invitation, tokenMatches } = evidence;
+ await this.allow(
+ requestId,
+ principal.id,
+ "invitation.accept",
+ `Invitation::${input.invitationId}`,
+ {
+ organizationId: invitation.organizationId,
+ targetPrincipalId: invitation.targetPrincipalId,
+ targetMatches: invitation.targetPrincipalId === principal.id,
+ role: invitation.role,
+ state: invitation.state,
+ invitationVersion: invitation.version,
+ expiresAt: invitation.expiresAt,
+ expired: Date.parse(invitation.expiresAt) <= Date.now(),
+ tokenMatches,
+ expectedVersion: input.expectedVersion,
+ },
+ "atomically consume invitation and create membership",
+ );
+ return this.repository.acceptInvitation({
+ principalId: principal.id,
+ invitationId: input.invitationId,
+ tokenHash: tokenHash(input.token),
+ expectedVersion: input.expectedVersion,
+ idempotencyKey: input.idempotencyKey,
+ });
+ });
+ }
+
+ async billing(
+ principal: Principal,
+ requestId: string,
+ organizationId: string,
+ ) {
+ const billing = await this.repository.billing(organizationId);
+ if (!billing) throw notFound("billing_not_found");
+ const access = await this.accessFacts(principal.id, organizationId);
+ await this.allow(
+ requestId,
+ principal.id,
+ "billing.view",
+ `Billing::${organizationId}`,
+ { ...access.facts, billingVersion: billing.version },
+ "return bounded billing projection",
+ );
+ return billing;
+ }
+
+ supportApprovals(principal: Principal) {
+ return this.repository.supportApprovals(principal.id);
+ }
+
+ async openSupport(
+ principal: Principal,
+ requestId: string,
+ approvalId: string,
+ expectedVersion: number,
+ idempotencyKey: string,
+ ) {
+ return this.mutation(principal.id, async () => {
+ const approval = (
+ await this.repository.supportApprovals(principal.id)
+ ).find((item) => item.id === approvalId);
+ if (!approval) throw notFound("support_approval_not_found");
+ await this.allow(
+ requestId,
+ principal.id,
+ "support.open",
+ `SupportApproval::${approval.id}`,
+ {
+ organizationId: approval.organizationId,
+ projectId: approval.projectId,
+ operation: approval.operation,
+ approvalVersion: approval.version,
+ expectedVersion,
+ expired: new Date(approval.expiresAt).getTime() <= Date.now(),
+ },
+ "activate exact expiring project-read support scope",
+ );
+ return this.repository.openSupport({
+ principalId: principal.id,
+ approvalId,
+ expectedVersion,
+ idempotencyKey,
+ });
+ });
+ }
+
+ private async accessFacts(
+ principalId: string,
+ organizationId: string,
+ snapshot?: AuthoritySnapshot,
+ ): Promise {
+ const current = snapshot ?? (await this.snapshot(principalId));
+ const { memberships, selection, support } = current;
+ const membership = memberships.find(
+ (item) => item.organizationId === organizationId,
+ );
+ return {
+ snapshot: current,
+ facts: {
+ requestedOrganizationId: organizationId,
+ activeOrganizationId: selection.organizationId,
+ selectionVersion: selection.version,
+ selectionMatches: selection.organizationId === organizationId,
+ currentMembership: Boolean(membership),
+ currentRole: membership?.role ?? null,
+ organizationSupportActive: support.some(
+ (item) => item.organizationId === organizationId,
+ ),
+ },
+ support,
+ };
+ }
+
+ private snapshot(principalId: string): Promise {
+ const existing = this.snapshots.get(principalId);
+ if (existing) return existing;
+ const pending = this.repository.authority(principalId);
+ this.snapshots.set(principalId, pending);
+ void pending.catch(() => {
+ if (this.snapshots.get(principalId) === pending)
+ this.snapshots.delete(principalId);
+ });
+ return pending;
+ }
+
+ private async mutation(
+ principalId: string,
+ effect: () => Promise,
+ ): Promise {
+ this.snapshots.delete(principalId);
+ try {
+ return await effect();
+ } finally {
+ this.snapshots.delete(principalId);
+ }
+ }
+
+ private projectFacts(
+ access: AccessContext,
+ project: Project,
+ operation: "project.read" | "project.write",
+ ): SafeFacts {
+ const exactSupport = access.support.find(
+ (item) =>
+ item.organizationId === project.organizationId &&
+ item.projectId === project.id &&
+ item.operation === operation,
+ );
+ return {
+ ...access.facts,
+ projectOrganizationId: project.organizationId,
+ supportActive: Boolean(exactSupport),
+ supportProjectId: exactSupport?.projectId ?? null,
+ supportOperation: exactSupport?.operation ?? null,
+ supportExactMatch: Boolean(exactSupport),
+ };
+ }
+
+ private allow(
+ requestId: string,
+ principalId: string,
+ capability: Capability,
+ resource: string,
+ facts: SafeFacts,
+ effect: string,
+ ): Promise {
+ const intent: AuthorizationIntent = {
+ requestId,
+ principalId,
+ capability,
+ resource,
+ facts,
+ effect,
+ };
+ return this.authorization.authorize(intent);
+ }
+}
diff --git a/p11-saas-workspace/src/server/capabilities.ts b/p11-saas-workspace/src/server/capabilities.ts
deleted file mode 100644
index c4f1f8f..0000000
--- a/p11-saas-workspace/src/server/capabilities.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { limits } from "./limits.ts";
-
-export const capabilityCatalog = {
- "organization.switch": "Workspace::SwitchOrganization",
- "project.read": "Workspace::ReadProject",
- "project.write": "Workspace::WriteProject",
- "invitation.issue": "Workspace::IssueInvitation",
- "invitation.accept": "Workspace::AcceptInvitation",
- "billing.view": "Workspace::ViewBilling",
- "support.open": "Workspace::OpenSupportScope",
-} as const;
-
-export type Capability = keyof typeof capabilityCatalog;
-export type SafeFacts = Readonly<
- Record
->;
-export type AuthorizationIntent = Readonly<{
- requestId: string;
- principalId: string;
- capability: Capability;
- resource: string;
- facts: SafeFacts;
- effect: string;
-}>;
-
-type Diagnostic = Readonly<{
- mode: "permissive";
- requestId: string;
- principal: string;
- capability: Capability;
- action: string;
- resource: string;
- facts: SafeFacts;
- decision: "ALLOW (FAKE)";
- effect: string;
- createdAt: number;
-}>;
-
-export interface AuthorizationGateway {
- authorize(intent: AuthorizationIntent): Promise;
- diagnostic(requestId: string, principalId: string): Diagnostic | undefined;
-}
-
-export class PermissiveAuthorizationGateway implements AuthorizationGateway {
- private readonly entries: Diagnostic[] = [];
-
- async authorize(intent: AuthorizationIntent): Promise {
- const entry: Diagnostic = {
- mode: "permissive",
- requestId: intent.requestId,
- principal: intent.principalId,
- capability: intent.capability,
- action: capabilityCatalog[intent.capability],
- resource: intent.resource,
- facts: intent.facts,
- decision: "ALLOW (FAKE)",
- effect: intent.effect,
- createdAt: Date.now(),
- };
- const serialized = JSON.stringify(entry);
- if (Buffer.byteLength(serialized) > limits.diagnosticBytes) {
- throw new Error("sanitized diagnostic exceeded its bound");
- }
- this.entries.push(entry);
- const cutoff = Date.now() - limits.diagnosticTtlMs;
- while (
- this.entries.length > limits.diagnostics ||
- (this.entries[0]?.createdAt ?? Infinity) < cutoff
- ) {
- this.entries.shift();
- }
- console.info(
- `P11 server | FAKE ALLOW | ${entry.capability} | ${entry.principal} -> ${entry.resource}`,
- );
- }
-
- diagnostic(requestId: string, principalId: string): Diagnostic | undefined {
- return this.entries.findLast(
- (entry) =>
- entry.requestId === requestId && entry.principal === principalId,
- );
- }
-}
diff --git a/p11-saas-workspace/src/server/errors.ts b/p11-saas-workspace/src/server/errors.ts
deleted file mode 100644
index e854e8b..0000000
--- a/p11-saas-workspace/src/server/errors.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export class AppError extends Error {
- readonly code: string;
- readonly status: number;
-
- constructor(code: string, status: number) {
- super(code);
- this.code = code;
- this.status = status;
- }
-}
-
-export const badRequest = (code = "invalid_request") => new AppError(code, 400);
-export const unauthorized = () => new AppError("authentication_required", 401);
-export const forbidden = (code = "request_verification_failed") =>
- new AppError(code, 403);
-export const notFound = (code = "not_found") => new AppError(code, 404);
-export const conflict = (code = "version_conflict") => new AppError(code, 409);
-export const unavailable = (code = "authorization_unavailable") =>
- new AppError(code, 503);
diff --git a/p11-saas-workspace/src/server/models.ts b/p11-saas-workspace/src/server/models.ts
deleted file mode 100644
index 51f3636..0000000
--- a/p11-saas-workspace/src/server/models.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import type {
- Billing,
- Invitation,
- Membership,
- Project,
- SupportApproval,
-} from "../shared/contracts.ts";
-
-export type Principal = Readonly<{
- id: string;
- issuer: string;
- subject: string;
- name: string;
-}>;
-
-export type Selection = Readonly<{
- organizationId: string | null;
- version: number;
-}>;
-
-export type OidcTransaction = Readonly<{
- state: string;
- nonce: string;
- verifier: string;
-}>;
-
-export type OidcTokens = Readonly<{
- issuer: string;
- subject: string;
- accessToken: string;
- accessTokenExpiresAt: number;
- refreshToken?: string;
- idToken: string;
- idTokenExpiresAt: number;
- tokenType: string;
- scope: string;
-}>;
-
-export type Session = Readonly<{
- principal: Principal;
- csrfToken: string;
- encryptedTokens: string;
- expiresAt: number;
-}>;
-
-export interface WorkspaceRepository {
- findPrincipal(
- issuer: string,
- subject: string,
- ): Promise;
- findPrincipalById(id: string): Promise;
- memberships(principalId: string): Promise;
- selection(principalId: string): Promise;
- switchOrganization(
- principalId: string,
- organizationId: string,
- expectedVersion: number,
- idempotencyKey: string,
- ): Promise;
- listProjects(organizationId: string): Promise;
- project(
- organizationId: string,
- projectId: string,
- ): Promise;
- writeProject(
- input: Readonly<{
- principalId: string;
- organizationId: string;
- projectId: string;
- name: string;
- body: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise;
- members(organizationId: string): Promise;
- invitations(organizationId: string): Promise;
- invitationsFor(principalId: string): Promise;
- issueInvitation(
- input: Readonly<{
- principalId: string;
- organizationId: string;
- targetPrincipalId: string;
- role: "editor" | "viewer";
- expectedSelectionVersion: number;
- idempotencyKey: string;
- tokenHash: string;
- expiresAt: Date;
- }>,
- ): Promise;
- acceptInvitation(
- input: Readonly<{
- principalId: string;
- invitationId: string;
- tokenHash: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise;
- billing(organizationId: string): Promise;
- supportApprovals(principalId: string): Promise;
- openSupport(
- input: Readonly<{
- principalId: string;
- approvalId: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ): Promise;
- activeSupport(principalId: string): Promise;
-}
diff --git a/p11-saas-workspace/src/server/service.ts b/p11-saas-workspace/src/server/service.ts
deleted file mode 100644
index 33d0236..0000000
--- a/p11-saas-workspace/src/server/service.ts
+++ /dev/null
@@ -1,396 +0,0 @@
-import { boundToken, tokenHash } from "./crypto.ts";
-import { notFound } from "./errors.ts";
-import { limits } from "./limits.ts";
-import type { Project, SupportApproval } from "../shared/contracts.ts";
-import type { Principal, WorkspaceRepository } from "./models.ts";
-import type {
- AuthorizationGateway,
- AuthorizationIntent,
- Capability,
- SafeFacts,
-} from "./capabilities.ts";
-
-type AccessContext = Readonly<{
- facts: SafeFacts;
- support: readonly SupportApproval[];
-}>;
-
-export class WorkspaceService {
- private readonly repository: WorkspaceRepository;
- private readonly authorization: AuthorizationGateway;
- private readonly invitationSecret: string;
-
- constructor(
- repository: WorkspaceRepository,
- authorization: AuthorizationGateway,
- invitationSecret: string,
- ) {
- this.repository = repository;
- this.authorization = authorization;
- this.invitationSecret = invitationSecret;
- }
-
- async workspace(principal: Principal) {
- const [memberships, selection, support] = await Promise.all([
- this.repository.memberships(principal.id),
- this.repository.selection(principal.id),
- this.repository.supportApprovals(principal.id),
- ]);
- return { memberships, selection, support };
- }
-
- async switchOrganization(
- principal: Principal,
- requestId: string,
- organizationId: string,
- expectedVersion: number,
- idempotencyKey: string,
- ) {
- const memberships = await this.repository.memberships(principal.id);
- const membership = memberships.find(
- (item) => item.organizationId === organizationId,
- );
- await this.allow(
- requestId,
- principal.id,
- "organization.switch",
- `Organization::${organizationId}`,
- {
- currentMembership: Boolean(membership),
- currentRole: membership?.role ?? null,
- membershipCount: memberships.length,
- },
- "persist versioned active-organization selection",
- );
- return this.repository.switchOrganization(
- principal.id,
- organizationId,
- expectedVersion,
- idempotencyKey,
- );
- }
-
- async listProjects(
- principal: Principal,
- requestId: string,
- organizationId: string,
- ) {
- const projects = await this.repository.listProjects(organizationId);
- const access = await this.accessFacts(principal.id, organizationId);
- const visible = [];
- for (const project of projects) {
- await this.allow(
- requestId,
- principal.id,
- "project.read",
- `Project::${project.id}`,
- {
- ...this.projectFacts(access, project, "project.read"),
- projectVersion: project.version,
- },
- "return bounded project summary",
- );
- visible.push(project);
- }
- return visible;
- }
-
- async readProject(
- principal: Principal,
- requestId: string,
- organizationId: string,
- projectId: string,
- ) {
- const project = await this.repository.project(organizationId, projectId);
- if (!project) throw notFound("project_not_found");
- const access = await this.accessFacts(principal.id, organizationId);
- await this.allow(
- requestId,
- principal.id,
- "project.read",
- `Project::${project.id}`,
- {
- ...this.projectFacts(access, project, "project.read"),
- projectVersion: project.version,
- },
- "return one project",
- );
- return project;
- }
-
- async writeProject(
- principal: Principal,
- requestId: string,
- input: Readonly<{
- organizationId: string;
- projectId: string;
- name: string;
- body: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ) {
- const current = await this.repository.project(
- input.organizationId,
- input.projectId,
- );
- if (!current) throw notFound("project_not_found");
- const access = await this.accessFacts(principal.id, input.organizationId);
- await this.allow(
- requestId,
- principal.id,
- "project.write",
- `Project::${current.id}`,
- {
- ...this.projectFacts(access, current, "project.write"),
- currentVersion: current.version,
- expectedVersion: input.expectedVersion,
- },
- "conditionally update one project",
- );
- return this.repository.writeProject({
- principalId: principal.id,
- ...input,
- });
- }
-
- async members(
- principal: Principal,
- requestId: string,
- organizationId: string,
- ) {
- const access = await this.accessFacts(principal.id, organizationId);
- await this.allow(
- requestId,
- principal.id,
- "organization.switch",
- `Organization::${organizationId}`,
- access.facts,
- "return bounded current-organization member list",
- );
- return this.repository.members(organizationId);
- }
-
- async invitations(
- principal: Principal,
- requestId: string,
- organizationId: string,
- ) {
- const access = await this.accessFacts(principal.id, organizationId);
- await this.allow(
- requestId,
- principal.id,
- "invitation.issue",
- `Organization::${organizationId}`,
- access.facts,
- "return bounded current-organization invitation list",
- );
- return this.repository.invitations(organizationId);
- }
-
- invitationsFor(principal: Principal) {
- return this.repository.invitationsFor(principal.id);
- }
-
- async issueInvitation(
- principal: Principal,
- requestId: string,
- input: Readonly<{
- organizationId: string;
- targetPrincipalId: string;
- role: "editor" | "viewer";
- expectedSelectionVersion: number;
- idempotencyKey: string;
- }>,
- ) {
- const target = await this.repository.findPrincipalById(
- input.targetPrincipalId,
- );
- if (!target) throw notFound("invitation_target_not_found");
- const access = await this.accessFacts(principal.id, input.organizationId);
- await this.allow(
- requestId,
- principal.id,
- "invitation.issue",
- `Organization::${input.organizationId}`,
- {
- ...access.facts,
- role: input.role,
- targetMapped: true,
- expectedSelectionVersion: input.expectedSelectionVersion,
- },
- "create one bounded expiring invitation",
- );
- const token = boundToken(
- this.invitationSecret,
- JSON.stringify([
- "invitation.issue",
- principal.id,
- input.organizationId,
- input.targetPrincipalId,
- input.role,
- input.expectedSelectionVersion,
- input.idempotencyKey,
- ]),
- );
- const invitation = await this.repository.issueInvitation({
- principalId: principal.id,
- ...input,
- tokenHash: tokenHash(token),
- expiresAt: new Date(Date.now() + limits.invitationMs),
- });
- return { invitation, token };
- }
-
- async acceptInvitation(
- principal: Principal,
- requestId: string,
- input: Readonly<{
- invitationId: string;
- token: string;
- expectedVersion: number;
- idempotencyKey: string;
- }>,
- ) {
- await this.allow(
- requestId,
- principal.id,
- "invitation.accept",
- `Invitation::${input.invitationId}`,
- { expectedVersion: input.expectedVersion },
- "atomically consume invitation and create membership",
- );
- return this.repository.acceptInvitation({
- principalId: principal.id,
- invitationId: input.invitationId,
- tokenHash: tokenHash(input.token),
- expectedVersion: input.expectedVersion,
- idempotencyKey: input.idempotencyKey,
- });
- }
-
- async billing(
- principal: Principal,
- requestId: string,
- organizationId: string,
- ) {
- const billing = await this.repository.billing(organizationId);
- if (!billing) throw notFound("billing_not_found");
- const access = await this.accessFacts(principal.id, organizationId);
- await this.allow(
- requestId,
- principal.id,
- "billing.view",
- `Billing::${organizationId}`,
- { ...access.facts, billingVersion: billing.version },
- "return bounded billing projection",
- );
- return billing;
- }
-
- supportApprovals(principal: Principal) {
- return this.repository.supportApprovals(principal.id);
- }
-
- async openSupport(
- principal: Principal,
- requestId: string,
- approvalId: string,
- expectedVersion: number,
- idempotencyKey: string,
- ) {
- const approval = (
- await this.repository.supportApprovals(principal.id)
- ).find((item) => item.id === approvalId);
- if (!approval) throw notFound("support_approval_not_found");
- await this.allow(
- requestId,
- principal.id,
- "support.open",
- `SupportApproval::${approval.id}`,
- {
- organizationId: approval.organizationId,
- projectId: approval.projectId,
- operation: approval.operation,
- approvalVersion: approval.version,
- expectedVersion,
- expired: new Date(approval.expiresAt).getTime() <= Date.now(),
- },
- "activate exact expiring project-read support scope",
- );
- return this.repository.openSupport({
- principalId: principal.id,
- approvalId,
- expectedVersion,
- idempotencyKey,
- });
- }
-
- private async accessFacts(
- principalId: string,
- organizationId: string,
- ): Promise {
- const [memberships, selection, support] = await Promise.all([
- this.repository.memberships(principalId),
- this.repository.selection(principalId),
- this.repository.activeSupport(principalId),
- ]);
- const membership = memberships.find(
- (item) => item.organizationId === organizationId,
- );
- return {
- facts: {
- requestedOrganizationId: organizationId,
- activeOrganizationId: selection.organizationId,
- selectionVersion: selection.version,
- selectionMatches: selection.organizationId === organizationId,
- currentMembership: Boolean(membership),
- currentRole: membership?.role ?? null,
- organizationSupportActive: support.some(
- (item) => item.organizationId === organizationId,
- ),
- },
- support,
- };
- }
-
- private projectFacts(
- access: AccessContext,
- project: Project,
- operation: "project.read" | "project.write",
- ): SafeFacts {
- const exactSupport = access.support.find(
- (item) =>
- item.organizationId === project.organizationId &&
- item.projectId === project.id &&
- item.operation === operation,
- );
- return {
- ...access.facts,
- projectOrganizationId: project.organizationId,
- supportActive: Boolean(exactSupport),
- supportProjectId: exactSupport?.projectId ?? null,
- supportOperation: exactSupport?.operation ?? null,
- supportExactMatch: Boolean(exactSupport),
- };
- }
-
- private allow(
- requestId: string,
- principalId: string,
- capability: Capability,
- resource: string,
- facts: SafeFacts,
- effect: string,
- ): Promise {
- const intent: AuthorizationIntent = {
- requestId,
- principalId,
- capability,
- resource,
- facts,
- effect,
- };
- return this.authorization.authorize(intent);
- }
-}
diff --git a/p11-saas-workspace/src/web/icons.tsx b/p11-saas-workspace/src/web/icons.tsx
deleted file mode 100644
index a5849ea..0000000
--- a/p11-saas-workspace/src/web/icons.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { SVGProps } from "react";
-
-type IconProps = SVGProps & { size?: number; weight?: string };
-
-function Glyph({ children, size = 20, weight, ...props }: IconProps) {
- return (
-
-
- {children}
-
-
- );
-}
-
-export const ArrowLeft = (props: IconProps) => (
-
-
-
-);
-export const ArrowRight = (props: IconProps) => (
-
-
-
-);
-export const ArrowsClockwise = (props: IconProps) => (
-
-
-
-);
-export const WarningCircle = (props: IconProps) => (
-
-
-
-
-);
diff --git a/p11-saas-workspace/src/web/styles.css b/p11-saas-workspace/src/web/styles.css
deleted file mode 100644
index bcd03f3..0000000
--- a/p11-saas-workspace/src/web/styles.css
+++ /dev/null
@@ -1,350 +0,0 @@
-@import "./shell.css";
-
-.app-shell {
- grid-template-rows: auto minmax(0, 1fr) auto;
-}
-
-.content-shell {
- min-height: 0;
- display: grid;
- grid-template-columns: 210px minmax(0, 1fr);
- overflow: hidden;
- background: var(--surface-soft);
-}
-
-.organization-rail {
- min-width: 0;
- min-height: 0;
- display: flex;
- flex-direction: column;
- gap: 7px;
- padding: 16px;
- overflow: auto;
- background: #ffffff;
- border-right: 1px solid var(--border);
-}
-
-.organization-rail h2 {
- margin: 0 0 5px;
- color: var(--navy);
- font-size: 0.9rem;
-}
-
-.organization-option {
- width: 100%;
- min-height: 54px;
- display: grid;
- gap: 3px;
- padding: 9px 10px;
- color: var(--charcoal);
- background: #ffffff;
- border: 1px solid var(--border);
- border-radius: 5px;
- cursor: pointer;
- text-align: left;
-}
-
-.organization-option:hover,
-.organization-option.active {
- background: var(--surface-green);
- border-color: #7ec9ad;
-}
-
-.organization-option.active {
- box-shadow: inset 3px 0 var(--green-dark);
-}
-
-.organization-option strong {
- font-size: 0.8rem;
-}
-
-.organization-option span {
- color: var(--slate);
- font-size: 0.68rem;
- text-transform: capitalize;
-}
-
-.organization-rail > a {
- margin-top: auto;
- padding: 8px 2px;
- color: var(--navy);
- font-size: 0.76rem;
- font-weight: 720;
-}
-
-.main-region {
- min-width: 0;
- min-height: 0;
- padding: 14px;
- overflow: auto;
-}
-
-.workspace-grid {
- min-height: 100%;
- display: grid;
- grid-template-columns: minmax(0, 1.55fr) minmax(260px, 0.75fr);
- gap: 10px;
-}
-
-.panel {
- min-width: 0;
- padding: 14px;
- background: #ffffff;
- border: 1px solid var(--border);
- border-radius: 5px;
-}
-
-.panel h2 {
- margin-bottom: 10px;
- color: var(--navy);
- font-size: 0.94rem;
-}
-
-.panel h3 {
- color: var(--navy);
- font-size: 0.8rem;
-}
-
-.panel-heading {
- display: flex;
- align-items: start;
- justify-content: space-between;
- gap: 10px;
-}
-
-.panel-heading h2 {
- margin: 0;
-}
-
-.panel-heading small {
- color: var(--slate);
- font-size: 0.68rem;
-}
-
-.project-panel {
- min-height: 0;
- display: flex;
- flex-direction: column;
-}
-
-.project-list {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin: 12px 0;
-}
-
-.project-list a {
- display: inline-flex;
- gap: 7px;
- padding: 7px 9px;
- color: var(--navy);
- background: var(--surface-soft);
- border: 1px solid var(--border);
- border-radius: 5px;
- font-size: 0.75rem;
- font-weight: 700;
- text-decoration: none;
-}
-
-.project-list a.selected {
- color: #ffffff;
- background: var(--green-dark);
- border-color: var(--green-dark);
-}
-
-.editor {
- min-height: 0;
- display: grid;
- grid-template-rows: auto minmax(110px, 1fr) auto;
- gap: 10px;
- flex: 1;
-}
-
-.editor label {
- min-height: 0;
-}
-
-.editor textarea {
- min-height: 110px;
- height: 100%;
-}
-
-.editor button {
- justify-self: start;
-}
-
-.side-stack {
- min-width: 0;
- display: grid;
- align-content: start;
- gap: 10px;
-}
-
-.plain-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.plain-list li {
- display: flex;
- justify-content: space-between;
- gap: 8px;
- padding: 7px 0;
- border-top: 1px solid #edf1ef;
- font-size: 0.74rem;
-}
-
-.plain-list li strong {
- color: var(--green-dark);
- text-transform: capitalize;
-}
-
-.compact-form {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- margin-top: 10px;
-}
-
-.compact-form button {
- grid-column: 1 / -1;
-}
-
-.panel > p,
-.invite-card,
-.support-card,
-.project-preview {
- font-size: 0.75rem;
- line-height: 1.4;
-}
-
-.outcome {
- margin: 10px 0 0;
- padding: 8px 10px;
- font-size: 0.72rem;
- overflow-wrap: anywhere;
-}
-
-.centered-panel {
- width: min(700px, 100%);
- margin-inline: auto;
-}
-
-.centered-panel > label {
- margin-bottom: 10px;
-}
-
-.invite-card,
-.support-card {
- display: grid;
- gap: 7px;
- padding: 10px 0;
- border-top: 1px solid #edf1ef;
-}
-
-.button-row {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
-}
-
-.project-preview {
- margin: 10px 0;
- padding: 10px;
- background: var(--surface-soft);
- border: 1px solid var(--border);
- border-radius: 5px;
-}
-
-.project-preview h3,
-.project-preview p {
- margin-bottom: 6px;
-}
-
-.mobile-back {
- display: none;
-}
-
-@media (max-width: 900px) {
- .workspace-grid {
- grid-template-columns: minmax(0, 1fr) 250px;
- }
-}
-
-@media (max-width: 760px) {
- .content-shell {
- grid-template-columns: 1fr;
- grid-template-rows: auto minmax(0, 1fr);
- }
-
- .organization-rail {
- flex-direction: row;
- padding: 8px 10px;
- overflow-x: auto;
- border-right: 0;
- border-bottom: 1px solid var(--border);
- }
-
- .organization-rail h2 {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- }
-
- .organization-option {
- flex: 0 0 130px;
- min-height: 44px;
- padding: 6px 8px;
- }
-
- .organization-rail > a {
- flex: 0 0 auto;
- margin: auto 0;
- padding: 8px;
- }
-
- .main-region {
- padding: 8px;
- }
-
- .workspace-grid {
- grid-template-columns: 1fr;
- min-height: auto;
- }
-
- .project-panel .editor {
- display: none;
- }
-
- .workspace-grid.has-project .project-list {
- display: none;
- }
-
- .workspace-grid.has-project .editor {
- display: grid;
- }
-
- .workspace-grid.has-project .mobile-back {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- width: fit-content;
- margin-bottom: 10px;
- color: var(--navy);
- font-size: 0.75rem;
- font-weight: 720;
- text-decoration: none;
- }
-
- .compact-form {
- grid-template-columns: 1fr;
- }
-
- .compact-form button {
- grid-column: auto;
- }
-}
diff --git a/p11-saas-workspace/test/capabilities.test.ts b/p11-saas-workspace/test/capabilities.test.ts
index 2672ba4..4187d29 100644
--- a/p11-saas-workspace/test/capabilities.test.ts
+++ b/p11-saas-workspace/test/capabilities.test.ts
@@ -1,35 +1,39 @@
import { afterEach, expect, it, vi } from "vitest";
import {
- PermissiveAuthorizationGateway,
capabilityCatalog,
-} from "../src/server/capabilities.ts";
+ PermissiveAuthorizationGateway,
+} from "../server/workspace/capabilities.ts";
afterEach(() => vi.restoreAllMocks());
-it("defines exactly the seven locked P11 capability seams", async () => {
- expect(Object.keys(capabilityCatalog)).toEqual([
- "organization.switch",
- "project.read",
- "project.write",
- "invitation.issue",
- "invitation.accept",
- "billing.view",
- "support.open",
- ]);
- vi.spyOn(console, "info").mockImplementation(() => undefined);
- const gateway = new PermissiveAuthorizationGateway();
- await gateway.authorize({
- requestId: "request-1",
- principalId: "user-maya",
- capability: "project.read",
- resource: "Project::project-a1",
- facts: { version: 1 },
- effect: "return one project",
+it("keeps all eight capabilities permissive and logs only the request summary", async () => {
+ expect(capabilityCatalog).toEqual({
+ "organization.switch": "Workspace::SwitchOrganization",
+ "members.read": "Workspace::ReadMembers",
+ "project.read": "Workspace::ReadProject",
+ "project.write": "Workspace::WriteProject",
+ "invitation.issue": "Workspace::IssueInvitation",
+ "invitation.accept": "Workspace::AcceptInvitation",
+ "billing.view": "Workspace::ViewBilling",
+ "support.open": "Workspace::OpenSupportScope",
});
- expect(gateway.diagnostic("request-1", "user-maya")).toMatchObject({
- mode: "permissive",
- decision: "ALLOW (FAKE)",
- action: "Workspace::ReadProject",
- });
- expect(gateway.diagnostic("request-1", "user-imani")).toBeUndefined();
+ const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
+ const gateway = new PermissiveAuthorizationGateway();
+ for (const capability of Object.keys(
+ capabilityCatalog,
+ ) as (keyof typeof capabilityCatalog)[]) {
+ await gateway.authorize({
+ requestId: "request-1",
+ principalId: "user-maya",
+ capability,
+ resource: "Organization::aster",
+ facts: { currentMembership: false, privateValue: "unlogged-fact" },
+ effect: "exercise the baseline",
+ });
+ expect(log).toHaveBeenLastCalledWith(
+ `P11 server | FAKE ALLOW | ${capability} | user-maya -> Organization::aster | request-1`,
+ );
+ }
+ expect(log).toHaveBeenCalledTimes(8);
+ expect(JSON.stringify(log.mock.calls)).not.toContain("unlogged-fact");
});
diff --git a/p11-saas-workspace/test/database.integration.test.ts b/p11-saas-workspace/test/database.integration.test.ts
new file mode 100644
index 0000000..f7f8f48
--- /dev/null
+++ b/p11-saas-workspace/test/database.integration.test.ts
@@ -0,0 +1,562 @@
+import { randomUUID } from "node:crypto";
+import { setTimeout as delay } from "node:timers/promises";
+import { Pool } from "pg";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import { tokenHash } from "../server/auth/crypto.ts";
+import { AppDatabase } from "../server/data/database.ts";
+import { PermissiveAuthorizationGateway } from "../server/workspace/capabilities.ts";
+import { WorkspaceService } from "../server/workspace/service.ts";
+
+const connectionString = process.env.P11_TEST_DATABASE_URL;
+
+describe.skipIf(!connectionString)(
+ "PostgreSQL invitation and support lifecycle",
+ () => {
+ const schema = `p11_test_${randomUUID().replaceAll("-", "")}`;
+ const admin = new Pool({ connectionString });
+ let database: AppDatabase;
+
+ beforeAll(async () => {
+ await admin.query(`CREATE SCHEMA ${schema}`);
+ const url = new URL(connectionString ?? "postgresql://unused");
+ url.searchParams.set("options", `-csearch_path=${schema}`);
+ database = new AppDatabase(url.toString());
+ await database.migrate();
+ await database.seed("http://localhost:18011");
+ });
+
+ afterAll(async () => {
+ await database?.close();
+ await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
+ await admin.end();
+ });
+
+ beforeEach(async () => {
+ await database.seed("http://localhost:18011");
+ });
+
+ async function issue(targetPrincipalId = "user-imani") {
+ return {
+ principalId: "user-maya",
+ organizationId: "aster",
+ targetPrincipalId,
+ role: "viewer" as const,
+ expectedSelectionVersion: 1,
+ idempotencyKey: randomUUID(),
+ tokenHash: tokenHash(randomUUID()),
+ authority: await database.authority("user-maya"),
+ };
+ }
+
+ it("seeds identities for the configured issuer and restores the default on explicit reset", async () => {
+ const alternateIssuer = "http://localhost:19011";
+ await database.seed(alternateIssuer);
+ expect(
+ await database.findPrincipal(alternateIssuer, "maya"),
+ ).toMatchObject({ id: "user-maya" });
+ expect(
+ await database.findPrincipal("http://localhost:18011", "maya"),
+ ).toBeUndefined();
+ await database.seed("http://localhost:18011");
+ expect(
+ await database.findPrincipal("http://localhost:18011", "maya"),
+ ).toMatchObject({ id: "user-maya" });
+ expect(
+ await database.findPrincipal(alternateIssuer, "maya"),
+ ).toBeUndefined();
+ });
+
+ it("initializes an empty database with its configured issuer and preserves populated data", async () => {
+ await database.pool.query("TRUNCATE principals CASCADE");
+ await database.initialize("http://localhost:19011");
+ expect(
+ await database.findPrincipal("http://localhost:19011", "maya"),
+ ).toMatchObject({ id: "user-maya" });
+ await database.pool.query(
+ "UPDATE projects SET name = 'Preserved edit' WHERE id = 'project-a1'",
+ );
+ await database.initialize("http://localhost:18011");
+ expect(
+ await database.findPrincipal("http://localhost:19011", "maya"),
+ ).toMatchObject({ id: "user-maya" });
+ expect(await database.project("aster", "project-a1")).toMatchObject({
+ name: "Preserved edit",
+ });
+ });
+
+ it("rolls back the whole reseed when issuer assignment fails", async () => {
+ await database.pool.query(
+ "UPDATE projects SET name = 'Preserved edit' WHERE id = 'project-a1'",
+ );
+ await database.pool.query(
+ "ALTER TABLE principals ADD CONSTRAINT test_seed_issuer CHECK (issuer = 'http://localhost:18011')",
+ );
+ try {
+ await expect(
+ database.seed("http://localhost:19011"),
+ ).rejects.toMatchObject({ code: "23514" });
+ expect(await database.project("aster", "project-a1")).toMatchObject({
+ name: "Preserved edit",
+ });
+ expect(
+ await database.findPrincipal("http://localhost:18011", "maya"),
+ ).toMatchObject({ id: "user-maya" });
+ } finally {
+ await database.pool.query(
+ "ALTER TABLE principals DROP CONSTRAINT test_seed_issuer",
+ );
+ }
+ });
+
+ it("counts active billing seats without fetching the member directory", async () => {
+ expect(await database.billing("aster")).toMatchObject({
+ activeSeats: 2,
+ seats: 18,
+ });
+ await database.revokeMembership("user-noah", "aster");
+ expect(await database.billing("aster")).toMatchObject({
+ activeSeats: 1,
+ seats: 18,
+ });
+ });
+
+ it("projects candidate states from current memberships and unexpired invitations", async () => {
+ expect(await database.invitationCandidates("aster")).toEqual([
+ { id: "user-imani", name: "Imani", state: "available" },
+ { id: "user-lena", name: "Lena", state: "pending" },
+ { id: "user-maya", name: "Maya", state: "member" },
+ { id: "user-noah", name: "Noah", state: "member" },
+ ]);
+ await database.pool.query(
+ "UPDATE invitations SET expires_at = now() - interval '1 second'",
+ );
+ expect(await database.invitationCandidates("aster")).toContainEqual({
+ id: "user-lena",
+ name: "Lena",
+ state: "available",
+ });
+ await database.revokeMembership("user-noah", "aster");
+ expect(await database.invitationCandidates("aster")).toContainEqual({
+ id: "user-noah",
+ name: "Noah",
+ state: "available",
+ });
+ expect(await database.invitationCandidates("boreal")).toContainEqual({
+ id: "user-lena",
+ name: "Lena",
+ state: "available",
+ });
+ expect(await database.supportApprovals("user-imani")).toContainEqual(
+ expect.objectContaining({
+ organizationName: "Tenant A · Aster",
+ projectName: "Aster checkout hardening",
+ }),
+ );
+ });
+
+ it("lets Lena use a fresh invitation after the seed invitation expires", async () => {
+ await database.pool.query(
+ "UPDATE invitations SET expires_at = clock_timestamp() - interval '1 second' WHERE id = 'invite-lena-aster'",
+ );
+ const maya = await database.findPrincipalById("user-maya");
+ const lena = await database.findPrincipalById("user-lena");
+ if (!maya || !lena) throw new Error("Missing invitation fixtures");
+ const service = new WorkspaceService(
+ database,
+ new PermissiveAuthorizationGateway(),
+ "test-invitation-secret-with-32-characters",
+ );
+ const replacement = await service.issueInvitation(
+ maya,
+ "replace-invitation",
+ {
+ organizationId: "aster",
+ targetPrincipalId: lena.id,
+ role: "viewer",
+ expectedSelectionVersion: 1,
+ idempotencyKey: randomUUID(),
+ },
+ );
+ expect(
+ (await database.invitationsFor(lena.id)).find(
+ (item) => item.id === "invite-lena-aster",
+ ),
+ ).toHaveProperty("state", "expired");
+ expect(replacement.token).not.toBe("p11-lena-invite-token");
+ await expect(
+ service.acceptInvitation(lena, "accept-fresh-invitation", {
+ invitationId: replacement.invitation.id,
+ token: replacement.token,
+ expectedVersion: replacement.invitation.version,
+ idempotencyKey: randomUUID(),
+ }),
+ ).resolves.toMatchObject({ state: "accepted" });
+ expect((await database.authority(lena.id)).memberships).toHaveLength(1);
+ expect((await database.authority(lena.id)).selection).toEqual({
+ organizationId: null,
+ version: 1,
+ });
+ });
+
+ for (const change of ["membership", "support"] as const) {
+ it(`preserves an Aster write when an unrelated Boreal ${change} changes during authorization`, async () => {
+ if (change === "support") {
+ await database.pool.query(`INSERT INTO support_approvals
+ (id, principal_id, organization_id, project_id, operation, expires_at)
+ VALUES ('support-maya-b1', 'user-maya', 'boreal', 'project-b1', 'project.read', clock_timestamp() + interval '1 hour')`);
+ await database.openSupport({
+ principalId: "user-maya",
+ approvalId: "support-maya-b1",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ });
+ }
+ const maya = await database.findPrincipalById("user-maya");
+ if (!maya) throw new Error("Missing Maya fixture");
+ const service = new WorkspaceService(
+ database,
+ {
+ authorize: async () => {
+ if (change === "membership")
+ await database.revokeMembership(maya.id, "boreal");
+ else await database.renewSupportApproval("support-maya-b1");
+ },
+ },
+ "test-invitation-secret-with-32-characters",
+ );
+ await expect(
+ service.writeProject(maya, "unrelated-change", {
+ organizationId: "aster",
+ projectId: "project-a1",
+ name: "Aster edit",
+ body: "Still valid",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ }),
+ ).resolves.toMatchObject({ version: 2, name: "Aster edit" });
+ });
+ }
+
+ for (const operation of ["switch", "invite"] as const) {
+ it(`fences current membership changes before ${operation}`, async () => {
+ const maya = await database.findPrincipalById("user-maya");
+ if (!maya) throw new Error("Missing Maya fixture");
+ const service = new WorkspaceService(
+ database,
+ {
+ authorize: async () => {
+ await database.revokeMembership(maya.id, "aster");
+ },
+ },
+ "test-invitation-secret-with-32-characters",
+ );
+ const effect =
+ operation === "switch"
+ ? service.switchOrganization(
+ maya,
+ "relevant-change",
+ "aster",
+ 1,
+ randomUUID(),
+ )
+ : service.issueInvitation(maya, "relevant-change", {
+ organizationId: "aster",
+ targetPrincipalId: "user-imani",
+ role: "viewer",
+ expectedSelectionVersion: 1,
+ idempotencyKey: randomUUID(),
+ });
+ await expect(effect).rejects.toMatchObject({
+ code: "authority_changed",
+ status: 409,
+ });
+ expect((await database.authority(maya.id)).selection).toEqual({
+ organizationId: "aster",
+ version: 1,
+ });
+ expect(
+ (await database.invitations("aster")).some(
+ (item) => item.targetPrincipalId === "user-imani",
+ ),
+ ).toBe(false);
+ });
+ }
+
+ it("returns useful conflicts for an existing member and a pending invitation", async () => {
+ await expect(
+ database.issueInvitation(await issue("user-noah")),
+ ).rejects.toMatchObject({
+ code: "membership_already_exists",
+ status: 409,
+ });
+ await expect(
+ database.issueInvitation(await issue("user-lena")),
+ ).rejects.toMatchObject({
+ code: "invitation_already_pending",
+ status: 409,
+ });
+ });
+
+ it("serializes concurrent invitations, and replays only the exact request", async () => {
+ const input = await issue();
+ const results = await Promise.allSettled([
+ database.issueInvitation(input),
+ database.issueInvitation({ ...input, idempotencyKey: randomUUID() }),
+ ]);
+ expect(
+ results.filter((result) => result.status === "fulfilled"),
+ ).toHaveLength(1);
+ expect(
+ results.find((result) => result.status === "rejected"),
+ ).toMatchObject({ reason: { code: "invitation_already_pending" } });
+ const winner = results.find((result) => result.status === "fulfilled");
+ if (results[0]?.status === "fulfilled") {
+ expect(await database.issueInvitation(input)).toEqual(winner?.value);
+ }
+ const invitations = await database.invitations("aster");
+ expect(
+ invitations.filter((item) => item.targetPrincipalId === "user-imani"),
+ ).toHaveLength(1);
+ });
+
+ it("keeps pending invitations ahead of a bounded terminal history", async () => {
+ await database.pool.query(`INSERT INTO invitations
+ (id, organization_id, target_principal_id, role, issuer_id, token_hash, expires_at, state)
+ SELECT 'history-' || n, 'aster', 'user-lena', 'viewer', 'user-maya', 'unused-' || n,
+ clock_timestamp() - interval '1 day', 'expired' FROM generate_series(1,25) n`);
+ for (const invitations of [
+ await database.invitations("aster"),
+ await database.invitationsFor("user-lena"),
+ ]) {
+ expect(invitations).toHaveLength(20);
+ expect(invitations[0]?.id).toBe("invite-lena-aster");
+ }
+ });
+
+ it("preserves invitation target, token, and single-use checks", async () => {
+ const input = {
+ principalId: "user-lena",
+ invitationId: "invite-lena-aster",
+ tokenHash: tokenHash("p11-lena-invite-token"),
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ };
+ await expect(
+ database.acceptInvitation({ ...input, principalId: "user-imani" }),
+ ).rejects.toMatchObject({ code: "invitation_target_mismatch" });
+ await expect(
+ database.acceptInvitation({ ...input, tokenHash: "wrong" }),
+ ).rejects.toMatchObject({ code: "invitation_token_mismatch" });
+ const results = await Promise.allSettled([
+ database.acceptInvitation(input),
+ database.acceptInvitation({ ...input, idempotencyKey: randomUUID() }),
+ ]);
+ expect(
+ results.filter((result) => result.status === "fulfilled"),
+ ).toHaveLength(1);
+ expect(
+ results.find((result) => result.status === "rejected"),
+ ).toMatchObject({ reason: { code: "invitation_not_pending" } });
+ expect((await database.authority("user-lena")).memberships).toHaveLength(
+ 1,
+ );
+ });
+
+ for (const change of ["membership", "selection", "support"] as const) {
+ it(`rejects a write without an effect when ${change} changes during authorization`, async () => {
+ const principalId = change === "support" ? "user-imani" : "user-maya";
+ if (change === "support")
+ await database.openSupport({
+ principalId,
+ approvalId: "support-imani-a1",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ });
+ const principal = await database.findPrincipalById(principalId);
+ if (!principal) throw new Error("Missing principal fixture");
+ const original = await database.project("aster", "project-a1");
+ const service = new WorkspaceService(
+ database,
+ {
+ authorize: async () => {
+ if (change === "membership")
+ await database.revokeMembership(principalId, "aster");
+ else if (change === "selection")
+ await database.switchOrganization(
+ principalId,
+ "boreal",
+ 1,
+ randomUUID(),
+ await database.authority(principalId),
+ );
+ else await database.renewSupportApproval("support-imani-a1");
+ },
+ },
+ "test-invitation-secret-with-32-characters",
+ );
+ await expect(
+ service.writeProject(principal, "concurrent-write", {
+ organizationId: "aster",
+ projectId: "project-a1",
+ name: "Must not be saved",
+ body: "No effect",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ }),
+ ).rejects.toMatchObject({ code: "authority_changed", status: 409 });
+ expect(await database.project("aster", "project-a1")).toEqual(original);
+ });
+ }
+
+ it("rejects an expired support snapshot but preserves the fresh permissive gap", async () => {
+ const principalId = "user-imani";
+ await database.openSupport({
+ principalId,
+ approvalId: "support-imani-a1",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ });
+ const authority = await database.authority(principalId);
+ await database.pool.query(
+ "UPDATE support_sessions SET active_until = clock_timestamp() - interval '1 second' WHERE principal_id = $1",
+ [principalId],
+ );
+ const input = {
+ principalId,
+ organizationId: "aster",
+ projectId: "project-a2",
+ name: "Broad support gap",
+ body: "Permissive write",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ authority,
+ };
+ await expect(database.writeProject(input)).rejects.toMatchObject({
+ code: "authority_changed",
+ });
+ expect(
+ (await database.supportApprovals(principalId))[0]?.activeUntil,
+ ).toBeUndefined();
+ await expect(
+ database.writeProject({
+ ...input,
+ authority: await database.authority(principalId),
+ }),
+ ).resolves.toMatchObject({ version: 2 });
+ });
+
+ it("keeps all three deliberate gaps permissive with a fresh snapshot", async () => {
+ vi.spyOn(console, "info").mockImplementation(() => undefined);
+ try {
+ const service = new WorkspaceService(
+ database,
+ new PermissiveAuthorizationGateway(),
+ "test-invitation-secret-with-32-characters",
+ );
+ const maya = await database.findPrincipalById("user-maya");
+ const imani = await database.findPrincipalById("user-imani");
+ if (!maya || !imani) throw new Error("Missing fixtures");
+ await service.switchOrganization(
+ maya,
+ "switch",
+ "boreal",
+ 1,
+ randomUUID(),
+ );
+ await expect(
+ service.writeProject(maya, "viewer-write", {
+ organizationId: "boreal",
+ projectId: "project-b1",
+ name: "Viewer gap",
+ body: "Still permissive",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ }),
+ ).resolves.toMatchObject({ version: 2 });
+ await database.revokeMembership(maya.id, "aster");
+ await expect(
+ service.readProject(maya, "stale-read", "aster", "project-a1"),
+ ).resolves.toMatchObject({ id: "project-a1" });
+ await expect(
+ service.billing(imani, "broad-support", "aster"),
+ ).resolves.toMatchObject({ organizationId: "aster" });
+ } finally {
+ vi.restoreAllMocks();
+ }
+ });
+
+ it("rolls back acceptance when an invitation expires while waiting for a membership lock", async () => {
+ const blocker = await database.pool.connect();
+ await blocker.query("BEGIN");
+ await blocker.query(
+ "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
+ ["memberships:organization:aster"],
+ );
+ await database.pool.query(
+ "UPDATE invitations SET expires_at = clock_timestamp() + interval '1 second' WHERE id = 'invite-lena-aster'",
+ );
+ const accepting = database.acceptInvitation({
+ principalId: "user-lena",
+ invitationId: "invite-lena-aster",
+ tokenHash: "BRc1ep9tjr_5t2zoxYPpUjxKepB8OcZa3fjw3zZjwMc",
+ expectedVersion: 1,
+ idempotencyKey: randomUUID(),
+ });
+ const outcome = accepting.then(
+ () => "accepted",
+ (error: unknown) => error,
+ );
+ try {
+ await vi.waitFor(async () => {
+ const waiting = await database.pool.query(
+ "SELECT 1 FROM pg_stat_activity WHERE datname = current_database() AND wait_event = 'advisory'",
+ );
+ expect(waiting.rowCount).toBeGreaterThan(0);
+ });
+ await delay(1_050);
+ } finally {
+ await blocker.query("ROLLBACK");
+ blocker.release();
+ }
+ expect(await outcome).toMatchObject({ code: "invitation_expired" });
+ expect((await database.authority("user-lena")).memberships).toEqual([]);
+ const row = await database.pool.query(
+ "SELECT state FROM invitations WHERE id = 'invite-lena-aster'",
+ );
+ expect(row.rows[0]?.state).toBe("pending");
+ });
+
+ it("renews only the selected support approval and requires reopening its scope", async () => {
+ const [approval] = await database.supportApprovals("user-imani");
+ if (!approval) throw new Error("Missing support fixture");
+ await database.openSupport({
+ principalId: "user-imani",
+ approvalId: approval.id,
+ expectedVersion: approval.version,
+ idempotencyKey: randomUUID(),
+ });
+ const projects = await database.listProjects("aster");
+ await database.renewSupportApproval(approval.id);
+ expect((await database.authority("user-imani")).support).toEqual([]);
+ expect(await database.listProjects("aster")).toEqual(projects);
+ const [renewed] = await database.supportApprovals("user-imani");
+ expect(renewed).toMatchObject({
+ id: approval.id,
+ projectId: "project-a1",
+ operation: "project.read",
+ version: approval.version + 1,
+ });
+ expect(Date.parse(renewed?.expiresAt ?? "") - Date.now()).toBeGreaterThan(
+ 7_190_000,
+ );
+ });
+ },
+);
diff --git a/p11-saas-workspace/test/host.test.ts b/p11-saas-workspace/test/host.test.ts
new file mode 100644
index 0000000..8ae6845
--- /dev/null
+++ b/p11-saas-workspace/test/host.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => {
+ const app = {
+ use: vi.fn(),
+ set: vi.fn(),
+ disable: vi.fn(),
+ };
+ const server = { listen: vi.fn(), close: vi.fn() };
+ const vite = {
+ middlewares: vi.fn(),
+ ssrLoadModule: vi.fn(async () => ({ app: vi.fn() })),
+ close: vi.fn(async () => undefined),
+ };
+ return {
+ app,
+ server,
+ vite,
+ createServer: vi.fn(() => server),
+ createVite: vi.fn(async () => vite),
+ };
+});
+
+vi.mock("express", () => ({ default: () => state.app }));
+vi.mock("node:http", () => ({ createServer: state.createServer }));
+vi.mock("vite", () => ({ createServer: state.createVite }));
+vi.mock("../server/config/environment.ts", () => ({
+ loadConfig: () => ({
+ host: "127.0.0.1",
+ port: 19011,
+ baseUrl: "http://localhost:19011",
+ }),
+}));
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ vi.clearAllTimers();
+ vi.useRealTimers();
+});
+
+it("shares the HTTP listener with development WebSockets and closes Vite on shutdown", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.useFakeTimers();
+ vi.spyOn(console, "info").mockImplementation(() => undefined);
+ const signalHandlers: (() => Promise)[] = [];
+ const on = process.on;
+ vi.spyOn(process, "on").mockImplementation((event, handler) => {
+ if (event === "SIGINT" || event === "SIGTERM") {
+ signalHandlers.push(handler as () => Promise);
+ return process;
+ }
+ return on.call(process, event, handler);
+ });
+
+ await import("../server/index.js");
+ expect(state.createServer).toHaveBeenCalledWith(state.app);
+ expect(state.createVite).toHaveBeenCalledWith({
+ server: {
+ middlewareMode: true,
+ ws: { server: state.server, clientPort: 19011 },
+ },
+ });
+ expect(state.server.listen).toHaveBeenCalledWith(
+ 19011,
+ "127.0.0.1",
+ expect.any(Function),
+ );
+ expect(state.vite.ssrLoadModule).toHaveBeenCalledWith("./server/app.ts");
+ expect(signalHandlers).toHaveLength(2);
+ await signalHandlers[0]?.();
+ expect(state.vite.close).toHaveBeenCalledOnce();
+ expect(state.server.close).toHaveBeenCalledOnce();
+});
diff --git a/p11-saas-workspace/test/request.test.ts b/p11-saas-workspace/test/request.test.ts
new file mode 100644
index 0000000..d100383
--- /dev/null
+++ b/p11-saas-workspace/test/request.test.ts
@@ -0,0 +1,261 @@
+import { RouterContextProvider } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ type RequestServices,
+ requestServicesContext,
+} from "../app/context.ts";
+import { loader as rootLoader } from "../app/root.tsx";
+import { loader as callbackLoader } from "../app/routes/auth/callback.ts";
+import { loader as loginLoader } from "../app/routes/auth/login.ts";
+import { action as logoutAction } from "../app/routes/auth/logout.ts";
+import { loader as homeLoader } from "../app/routes/home.tsx";
+import { loader as billingLoader } from "../app/routes/organizations/billing.tsx";
+import { humanizeError } from "../app/utils/messages.ts";
+import { RequestService } from "../server/http/requests.ts";
+import { conflict, forbidden } from "../server/shared/errors.ts";
+
+function setServices(context: RouterContextProvider, runtime: RequestServices) {
+ context.set(requestServicesContext, {
+ ...runtime,
+ requests: new RequestService(
+ runtime.sessions,
+ runtime.workspace,
+ runtime.requestId,
+ ),
+ });
+}
+const requests = new RequestService(
+ {} as RequestServices["sessions"],
+ {} as RequestServices["workspace"],
+ "request-123",
+);
+
+afterEach(() => vi.restoreAllMocks());
+
+describe("request boundary", () => {
+ it.each([loginLoader, callbackLoader])(
+ "returns correlated, retryable authentication database failures",
+ async (loader) => {
+ const context = new RouterContextProvider();
+ const fail = async () => {
+ throw Object.assign(new Error("private database address"), {
+ code: "ECONNREFUSED",
+ });
+ };
+ setServices(context, {
+ requestId: "auth-request",
+ sessions: { login: fail, callback: fail },
+ } as unknown as RequestServices);
+ await expect(
+ loader({
+ request: new Request("http://localhost:17011/auth/login"),
+ context,
+ params: {},
+ url: new URL("http://localhost:17011/auth/login"),
+ pattern: "/auth/login",
+ }),
+ ).rejects.toMatchObject({
+ data: { error: "database_unavailable", requestId: "auth-request" },
+ init: { status: 503 },
+ });
+ },
+ );
+
+ it("returns a correlated forbidden response for an invalid logout request", async () => {
+ const context = new RouterContextProvider();
+ setServices(context, {
+ requestId: "logout-request",
+ sessions: {
+ logout: async () => {
+ throw forbidden();
+ },
+ },
+ } as unknown as RequestServices);
+ const url = new URL("http://localhost:17011/auth/logout");
+ const response = await logoutAction({
+ request: new Request(url, {
+ method: "POST",
+ body: new URLSearchParams(),
+ }),
+ context,
+ params: {},
+ url,
+ pattern: "/auth/logout",
+ });
+ expect(response).toMatchObject({
+ data: {
+ ok: false,
+ error: "request_verification_failed",
+ requestId: "logout-request",
+ },
+ init: { status: 403 },
+ });
+ });
+
+ it.each([
+ ["user-imani", "aster", [], [], "/organizations/aster/projects"],
+ ["user-imani", null, [{ organizationId: "aster" }], [{}], "/invitations"],
+ ["user-maya", "aster", [], [], "/organizations/aster/projects"],
+ ["user-imani", null, [], [{}], "/support"],
+ ["user-lena", null, [], [], "/invitations"],
+ ])(
+ "lands %s using current workspace facts (%s)",
+ async (id, organizationId, memberships, approvals, destination) => {
+ const context = new RouterContextProvider();
+ setServices(context, {
+ requestId: "home-request",
+ sessions: {
+ optional: async () => ({
+ principal: { id },
+ expiresAt: Date.now() + 60000,
+ }),
+ },
+ workspace: {
+ workspace: async () => ({
+ selection: { organizationId, version: 1 },
+ memberships,
+ }),
+ supportApprovals: async () => approvals,
+ },
+ } as unknown as RequestServices);
+ const response = await homeLoader({
+ request: new Request("http://localhost:17011/"),
+ context,
+ params: {},
+ url: new URL("http://localhost:17011/"),
+ pattern: "/",
+ }).catch((error: unknown) => error);
+ expect(response).toBeInstanceOf(Response);
+ expect((response as Response).status).toBe(302);
+ expect((response as Response).headers.get("Location")).toBe(destination);
+ },
+ );
+
+ it("keeps invitations workspace navigation unselected after joining", async () => {
+ const context = new RouterContextProvider();
+ setServices(context, {
+ requestId: "invitation-request",
+ sessions: {
+ optional: async () => ({
+ principal: { id: "user-lena" },
+ csrfToken: "csrf",
+ }),
+ },
+ workspace: {
+ invitationsFor: async () => [],
+ workspace: async () => ({
+ selection: { organizationId: null, version: 1 },
+ memberships: [{ organizationId: "aster" }],
+ }),
+ },
+ } as unknown as RequestServices);
+ const result = await rootLoader({
+ request: new Request("http://localhost:17011/invitations"),
+ context,
+ params: {},
+ url: new URL("http://localhost:17011/invitations"),
+ pattern: "/invitations",
+ });
+ expect(result.session?.navigationOrganizationId).toBeNull();
+ expect(result.session?.activeOrganizationId).toBeNull();
+ expect(result.session?.switchKeys).toHaveProperty("aster");
+ expect(result.session?.user).not.toHaveProperty("id");
+ expect(result.session).not.toHaveProperty("expiresAt");
+ });
+
+ it("loads billing without requesting the member directory", async () => {
+ const projection = {
+ organizationId: "aster",
+ plan: "Team",
+ seats: 18,
+ activeSeats: 2,
+ monthlyCents: 360_000,
+ version: 1,
+ };
+ const billing = vi.fn(async () => projection);
+ const members = vi.fn(async () => {
+ throw new Error("Member directory must not be fetched for billing");
+ });
+ const context = new RouterContextProvider();
+ setServices(context, {
+ requestId: "billing-request",
+ sessions: { optional: async () => ({ principal: { id: "user-maya" } }) },
+ workspace: { billing, members },
+ } as unknown as RequestServices);
+ const result = await billingLoader({
+ request: new Request(
+ "http://localhost:17011/organizations/aster/billing",
+ ),
+ context,
+ params: { organizationId: "aster" },
+ url: new URL("http://localhost:17011/organizations/aster/billing"),
+ pattern: "/organizations/:organizationId/billing",
+ });
+ expect(result).toEqual({ organizationId: "aster", billing: projection });
+ expect(billing).toHaveBeenCalledOnce();
+ expect(members).not.toHaveBeenCalled();
+ });
+
+ it("keeps specific form recovery messages ahead of the generic fallback", () => {
+ expect(humanizeError("invalid_expected_version")).toContain("Reload");
+ expect(humanizeError("invalid_idempotency_key")).toContain("Reload");
+ expect(humanizeError("invalid_project_name")).toContain("Check the form");
+ expect(humanizeError("unknown_error")).toContain("Try again");
+ });
+
+ it("parses the exact form version used by actions", () => {
+ const form = new FormData();
+ form.set("expectedVersion", "2");
+ expect(requests.input.version(form)).toBe(2);
+ form.set("expectedVersion", "2e0");
+ expect(() => requests.input.version(form)).toThrow(
+ "invalid_expected_version",
+ );
+ });
+
+ it("returns correlated conflicts, retryable database outages, and sanitized unknown errors", () => {
+ const log = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ for (const [error, code, status] of [
+ [conflict("authority_changed"), "authority_changed", 409],
+ [
+ Object.assign(new Error("private database address"), {
+ code: "ECONNREFUSED",
+ }),
+ "database_unavailable",
+ 503,
+ ],
+ ...["EAI_AGAIN", "ENOTFOUND"].map(
+ (code) =>
+ [
+ Object.assign(new Error("private database hostname"), { code }),
+ "database_unavailable",
+ 503,
+ ] as const,
+ ),
+ [new Error("private implementation details"), "request_failed", 500],
+ ] as const) {
+ const expected = {
+ data: { ok: false, error: code, requestId: "request-123" },
+ init: { status },
+ };
+ expect(requests.actionFailure(error)).toMatchObject(expected);
+ try {
+ requests.loaderFailure(error);
+ } catch (failure) {
+ expect(failure).toMatchObject({
+ data: { error: code, requestId: "request-123" },
+ init: { status },
+ });
+ }
+ }
+ expect(JSON.stringify(log.mock.calls)).not.toContain("private");
+ });
+
+ it("preserves redirects instead of turning them into errors", () => {
+ const redirect = new Response(null, {
+ status: 302,
+ headers: { Location: "/" },
+ });
+ expect(() => requests.loaderFailure(redirect)).toThrow(redirect);
+ });
+});
diff --git a/p11-saas-workspace/test/runtime-compatibility.test.ts b/p11-saas-workspace/test/runtime-compatibility.test.ts
index c2d8b01..4123c0e 100644
--- a/p11-saas-workspace/test/runtime-compatibility.test.ts
+++ b/p11-saas-workspace/test/runtime-compatibility.test.ts
@@ -10,9 +10,9 @@ describe("Node strip-only runtime compatibility", () => {
"--input-type=module",
"--eval",
[
- "import('./src/server/errors.ts')",
- "import('./src/server/capabilities.ts')",
- "import('./src/server/service.ts')",
+ "import('./server/shared/errors.ts')",
+ "import('./server/workspace/capabilities.ts')",
+ "import('./server/workspace/service.ts')",
].join(";"),
],
{ encoding: "utf8" },
diff --git a/p11-saas-workspace/test/runtime.test.ts b/p11-saas-workspace/test/runtime.test.ts
new file mode 100644
index 0000000..445a37b
--- /dev/null
+++ b/p11-saas-workspace/test/runtime.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ generation: 1,
+ pool: { end: vi.fn(async () => undefined) },
+ initialize: vi.fn(async () => undefined),
+ oidc: vi.fn(async () => ({})),
+ context: undefined as
+ | undefined
+ | ((request: unknown, response: unknown) => { get(key: unknown): unknown }),
+}));
+
+vi.mock("../server/config/environment.ts", () => ({
+ loadConfig: () => ({
+ databaseUrl: "test://database",
+ issuer: "http://localhost:18011",
+ baseUrl: "http://localhost:17011",
+ sessionSecret: "test-secret",
+ }),
+}));
+vi.mock("../server/data/database.ts", () => ({
+ AppDatabase: class {
+ pool: typeof state.pool;
+ constructor(connection: string | typeof state.pool) {
+ this.pool = typeof connection === "string" ? state.pool : connection;
+ }
+ initialize = state.initialize;
+ close() {
+ return this.pool.end();
+ }
+ },
+}));
+vi.mock("../server/auth/oidc.ts", () => ({ createOidcRuntime: state.oidc }));
+vi.mock("../server/workspace/service.ts", () => ({
+ WorkspaceService: class {
+ generation = state.generation;
+ },
+}));
+vi.mock("@react-router/express", () => ({
+ createRequestHandler: (options: { getLoadContext: typeof state.context }) => {
+ state.context = options.getLoadContext;
+ return (_request: unknown, _response: unknown, next: () => void) => next();
+ },
+}));
+
+afterEach(async () => {
+ const runtime = await import("../server/app.ts");
+ await runtime.closeRuntime();
+ vi.clearAllMocks();
+ vi.resetModules();
+});
+
+async function loadServices() {
+ await import("../server/app.ts");
+ const { requestServicesContext } = await import("../app/context.ts");
+ return state
+ .context?.(
+ {},
+ { locals: { requestId: "hmr-request", nonce: "test-nonce" } },
+ )
+ .get(requestServicesContext) as {
+ database: { pool: unknown };
+ workspace: { generation: number };
+ };
+}
+
+it("recreates service adapters after a module reload while sharing connections", async () => {
+ const first = await loadServices();
+ state.generation = 2;
+ vi.resetModules();
+ const second = await loadServices();
+ expect(second.workspace).not.toBe(first.workspace);
+ expect(second.workspace.generation).toBe(2);
+ expect(second.database).not.toBe(first.database);
+ expect(second.database.pool).toBe(first.database.pool);
+ expect(state.initialize).toHaveBeenCalledOnce();
+ expect(state.initialize).toHaveBeenCalledWith("http://localhost:18011");
+ expect(state.oidc).toHaveBeenCalledOnce();
+});
+
+it("gives each request its own workspace adapter while sharing connections", async () => {
+ const first = await loadServices();
+ const second = await loadServices();
+ expect(second.workspace).not.toBe(first.workspace);
+ expect(second.database.pool).toBe(first.database.pool);
+ expect(state.initialize).toHaveBeenCalledOnce();
+ expect(state.oidc).toHaveBeenCalledOnce();
+});
+
+it("discards failed initialization so a later module load can retry", async () => {
+ state.oidc.mockRejectedValueOnce(new Error("issuer temporarily unavailable"));
+ await expect(loadServices()).rejects.toThrow(
+ "issuer temporarily unavailable",
+ );
+ expect(state.pool.end).toHaveBeenCalledOnce();
+ vi.resetModules();
+ await expect(loadServices()).resolves.toHaveProperty("workspace");
+ expect(state.oidc).toHaveBeenCalledTimes(2);
+});
diff --git a/p11-saas-workspace/test/security.test.ts b/p11-saas-workspace/test/security.test.ts
index adad0bf..415ca0f 100644
--- a/p11-saas-workspace/test/security.test.ts
+++ b/p11-saas-workspace/test/security.test.ts
@@ -5,15 +5,15 @@ import {
decryptJson,
encryptJson,
tokenHash,
-} from "../src/server/crypto.ts";
-import { loadConfig } from "../src/server/config.ts";
+} from "../server/auth/crypto.ts";
import {
expectedVersion,
identifier,
+ invitationToken,
inviteRole,
projectBody,
projectName,
-} from "../src/server/validation.ts";
+} from "../server/shared/validation.ts";
const environment = {
P11_DATABASE_URL: "postgresql://p11:p11@127.0.0.1:5435/p11",
@@ -22,12 +22,6 @@ const environment = {
};
describe("P11 security controls", () => {
- it("ignores obsolete authorization settings", () => {
- expect(
- loadConfig({ ...environment, P11_AUTHZ_MODE: "unavailable" }),
- ).toEqual(loadConfig(environment));
- });
-
it("encrypts server-held token material and authenticates tampering", () => {
const encrypted = encryptJson(
{ accessToken: "private-token" },
@@ -58,10 +52,31 @@ describe("P11 security controls", () => {
it("bounds identifiers, versions, roles, names, and bodies", () => {
expect(identifier("project-a1", "project")).toBe("project-a1");
expect(() => identifier("../aster", "project")).toThrow();
- expect(expectedVersion(1)).toBe(1);
- expect(() => expectedVersion(0)).toThrow();
+ expect(expectedVersion("1")).toBe(1);
+ for (const value of [
+ 1,
+ 0,
+ "0",
+ "01",
+ "+1",
+ " 1",
+ "1 ",
+ "1e1",
+ "1.0",
+ "9007199254740992",
+ undefined,
+ null,
+ ]) {
+ expect(() => expectedVersion(value)).toThrow();
+ }
expect(inviteRole("viewer")).toBe("viewer");
expect(() => inviteRole("admin")).toThrow();
+ expect(invitationToken("p11-lena-invite-token")).toBe(
+ "p11-lena-invite-token",
+ );
+ for (const token of [undefined, 1234, "short", "x".repeat(201)]) {
+ expect(() => invitationToken(token)).toThrow("invalid_invitation_token");
+ }
expect(projectName(" Aster ")).toBe("Aster");
expect(() => projectName("\u0000bad")).toThrow();
expect(() => projectBody("x".repeat(16_385))).toThrow();
diff --git a/p11-saas-workspace/test/service.test.ts b/p11-saas-workspace/test/service.test.ts
index 2a3b132..a2c3c30 100644
--- a/p11-saas-workspace/test/service.test.ts
+++ b/p11-saas-workspace/test/service.test.ts
@@ -5,15 +5,21 @@ import type {
Membership,
Project,
SupportApproval,
-} from "../src/shared/contracts.ts";
-import { PermissiveAuthorizationGateway } from "../src/server/capabilities.ts";
+} from "../app/types/contracts.ts";
+import { AppError, conflict } from "../server/shared/errors.ts";
import type {
Principal,
Selection,
WorkspaceRepository,
-} from "../src/server/models.ts";
-import { unavailable } from "../src/server/errors.ts";
-import { WorkspaceService } from "../src/server/service.ts";
+} from "../server/shared/models.ts";
+import {
+ type AuthorizationGateway,
+ PermissiveAuthorizationGateway,
+} from "../server/workspace/capabilities.ts";
+import { WorkspaceService } from "../server/workspace/service.ts";
+
+const denied = () => new AppError("authorization_denied", 403);
+const unavailable = () => new AppError("authorization_unavailable", 503);
const maya: Principal = {
id: "user-maya",
@@ -66,21 +72,33 @@ const invitation: Invitation = {
};
function fake(
- overrides: Partial = {},
+ overrides: Partial & {
+ memberships?: (principalId: string) => Promise;
+ selection?: (principalId: string) => Promise;
+ } = {},
): WorkspaceRepository {
const selection: Selection = { organizationId: "aster", version: 1 };
+ const {
+ memberships = async () => [membership("aster", "admin")],
+ selection: currentSelection = async () => selection,
+ ...methods
+ } = overrides;
const billing: Billing = {
organizationId: "aster",
plan: "Enterprise",
seats: 18,
+ activeSeats: 2,
monthlyCents: 360_000,
version: 1,
};
- return {
- findPrincipal: async () => maya,
+ const repository: WorkspaceRepository = {
findPrincipalById: async () => maya,
- memberships: async () => [membership("aster", "admin")],
- selection: async () => selection,
+ authority: async (principalId) => ({
+ principalId,
+ selection: await currentSelection(principalId),
+ memberships: await memberships(principalId),
+ support: [],
+ }),
switchOrganization: async (_principalId, organizationId) => ({
organizationId,
version: 2,
@@ -94,7 +112,9 @@ function fake(
project(input.projectId, input.organizationId, input.expectedVersion + 1),
members: async () => [membership("aster", "admin")],
invitations: async () => [invitation],
+ invitationCandidates: async () => [],
invitationsFor: async () => [invitation],
+ invitation: async () => ({ invitation, tokenMatches: true }),
issueInvitation: async () => invitation,
acceptInvitation: async () => ({
...invitation,
@@ -104,15 +124,318 @@ function fake(
billing: async (organizationId) => ({ ...billing, organizationId }),
supportApprovals: async () => [support],
openSupport: async () => support,
- activeSupport: async () => [],
- ...overrides,
+ ...methods,
};
+ return repository;
}
beforeEach(() => vi.spyOn(console, "info").mockImplementation(() => undefined));
afterEach(() => vi.restoreAllMocks());
describe("P11 permissive service", () => {
+ it("authorizes invitation setup before disclosing candidates", async () => {
+ const candidates = [
+ { id: imani.id, name: imani.name, state: "available" as const },
+ ];
+ const invitationCandidates = vi.fn(async () => candidates);
+ const authorize = vi.fn(
+ async () => undefined,
+ );
+ const repository = fake({ invitationCandidates });
+ const service = new WorkspaceService(
+ repository,
+ { authorize },
+ invitationSecret,
+ );
+ await expect(
+ service.invitationSetup(maya, "invite-setup", "aster"),
+ ).resolves.toEqual({ invitations: [invitation], candidates });
+ expect(authorize).toHaveBeenCalledOnce();
+ expect(authorize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ capability: "invitation.issue",
+ resource: "Organization::aster",
+ }),
+ );
+ invitationCandidates.mockClear();
+ const deniedService = new WorkspaceService(
+ repository,
+ {
+ authorize: async () => {
+ throw denied();
+ },
+ },
+ invitationSecret,
+ );
+ await expect(
+ deniedService.invitationSetup(maya, "denied-setup", "aster"),
+ ).rejects.toMatchObject({ code: "authorization_denied" });
+ expect(invitationCandidates).not.toHaveBeenCalled();
+ });
+
+ it("authorizes member disclosure separately using the requested workspace's current facts", async () => {
+ const members = vi.fn(async () => []);
+ const authorize = vi.fn(
+ async () => undefined,
+ );
+ const service = new WorkspaceService(
+ fake({
+ members,
+ memberships: async () => [
+ membership("aster", "admin"),
+ membership("boreal", "viewer"),
+ ],
+ selection: async () => ({ organizationId: "boreal", version: 2 }),
+ }),
+ { authorize },
+ invitationSecret,
+ );
+ await service.members(maya, "members-request", "boreal");
+ expect(authorize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ capability: "members.read",
+ resource: "Organization::boreal",
+ facts: expect.objectContaining({
+ requestedOrganizationId: "boreal",
+ activeOrganizationId: "boreal",
+ selectionMatches: true,
+ currentMembership: true,
+ currentRole: "viewer",
+ }),
+ }),
+ );
+ expect(members).toHaveBeenCalledWith("boreal");
+ });
+
+ it("does not fetch the member directory after a denied read", async () => {
+ const members = vi.fn(async () => []);
+ const service = new WorkspaceService(
+ fake({ members }),
+ {
+ authorize: async () => {
+ throw denied();
+ },
+ },
+ invitationSecret,
+ );
+ await expect(
+ service.members(imani, "denied-members", "aster"),
+ ).rejects.toMatchObject({ code: "authorization_denied" });
+ expect(members).not.toHaveBeenCalled();
+ });
+
+ it("returns billing and seat usage under billing.view alone", async () => {
+ const members = vi.fn(async () => {
+ throw denied();
+ });
+ const authorize = vi.fn(
+ async (intent) => {
+ if (intent.capability !== "billing.view") throw denied();
+ },
+ );
+ const service = new WorkspaceService(
+ fake({ members }),
+ { authorize },
+ invitationSecret,
+ );
+ await expect(
+ service.billing(maya, "billing-request", "aster"),
+ ).resolves.toMatchObject({ activeSeats: 2, seats: 18 });
+ expect(authorize).toHaveBeenCalledOnce();
+ expect(authorize).toHaveBeenCalledWith(
+ expect.objectContaining({ capability: "billing.view" }),
+ );
+ expect(members).not.toHaveBeenCalled();
+ });
+
+ it("reuses request-local facts without reusing resource decisions or crossing principals and requests", async () => {
+ const repository = fake();
+ const authority = vi.spyOn(repository, "authority");
+ const authorize = vi.fn(
+ async () => undefined,
+ );
+ const service = new WorkspaceService(
+ repository,
+ { authorize },
+ invitationSecret,
+ );
+ await Promise.all([
+ service.workspace(maya),
+ service.readProject(maya, "read-request", "aster", "project-a1"),
+ service.listProjects(maya, "read-request", "aster"),
+ ]);
+ expect(authority).toHaveBeenCalledTimes(1);
+ expect(authorize).toHaveBeenCalledTimes(2);
+ await service.workspace(imani);
+ expect(authority).toHaveBeenCalledTimes(2);
+ await new WorkspaceService(
+ repository,
+ { authorize },
+ invitationSecret,
+ ).workspace(maya);
+ expect(authority).toHaveBeenCalledTimes(3);
+ });
+
+ it.each([false, true])(
+ "refreshes facts before a mutation and after its completion (failure: %s)",
+ async (failure) => {
+ let role: Membership["role"] = "admin";
+ let service: WorkspaceService;
+ const repository = fake({
+ memberships: async () => [membership("aster", role)],
+ writeProject: async () => {
+ // A read during the effect must not leave a stale snapshot for revalidation.
+ await service.workspace(maya);
+ role = "editor";
+ if (failure) throw conflict();
+ return project("project-a1", "aster", 2);
+ },
+ });
+ const authorize = vi.fn(
+ async () => undefined,
+ );
+ service = new WorkspaceService(
+ repository,
+ { authorize },
+ invitationSecret,
+ );
+ await service.workspace(maya);
+ role = "viewer";
+ const saved = service.writeProject(maya, "write-request", {
+ organizationId: "aster",
+ projectId: "project-a1",
+ name: "New name",
+ body: "New body",
+ expectedVersion: 1,
+ idempotencyKey: "write-key",
+ });
+ if (failure) await expect(saved).rejects.toMatchObject({ status: 409 });
+ else await expect(saved).resolves.toMatchObject({ version: 2 });
+ expect(authorize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ facts: expect.objectContaining({ currentRole: "viewer" }),
+ }),
+ );
+ expect((await service.workspace(maya)).memberships[0]?.role).toBe(
+ "editor",
+ );
+ },
+ );
+
+ it("can retry a failed fact load within the request", async () => {
+ const repository = fake();
+ const authority = vi
+ .spyOn(repository, "authority")
+ .mockRejectedValueOnce(unavailable());
+ const service = new WorkspaceService(
+ repository,
+ new PermissiveAuthorizationGateway(),
+ invitationSecret,
+ );
+ await expect(service.workspace(maya)).rejects.toMatchObject({
+ status: 503,
+ });
+ await expect(service.workspace(maya)).resolves.toHaveProperty(
+ "memberships",
+ );
+ expect(authority).toHaveBeenCalledTimes(2);
+ });
+
+ it("filters only explicit denials and returns summaries without project bodies", async () => {
+ const authorize = vi.fn(async (intent: { resource: string }) => {
+ if (intent.resource === "Project::project-a2") throw denied();
+ });
+ const service = new WorkspaceService(
+ fake({
+ listProjects: async () => [
+ project("project-a1", "aster"),
+ project("project-a2", "aster"),
+ ],
+ }),
+ { authorize },
+ invitationSecret,
+ );
+ expect(await service.listProjects(maya, "request-list", "aster")).toEqual([
+ {
+ id: "project-a1",
+ organizationId: "aster",
+ name: "project-a1",
+ version: 1,
+ },
+ ]);
+ expect(authorize).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not turn an authorization outage into an empty project list", async () => {
+ const service = new WorkspaceService(
+ fake(),
+ {
+ authorize: async () => {
+ throw unavailable();
+ },
+ },
+ invitationSecret,
+ );
+ await expect(
+ service.listProjects(maya, "request-outage", "aster"),
+ ).rejects.toMatchObject({ code: "authorization_unavailable", status: 503 });
+ });
+
+ it("uses the same not-found result for denied and absent project reads", async () => {
+ const gateway = {
+ authorize: async () => {
+ throw denied();
+ },
+ };
+ for (const repository of [
+ fake(),
+ fake({ project: async () => undefined }),
+ ]) {
+ const service = new WorkspaceService(
+ repository,
+ gateway,
+ invitationSecret,
+ );
+ await expect(
+ service.readProject(maya, "request-hidden", "aster", "project-a1"),
+ ).rejects.toMatchObject({ code: "project_not_found", status: 404 });
+ }
+ });
+
+ it("passes current invitation bindings to authorization without exposing the token", async () => {
+ const gateway = {
+ authorize: vi.fn(
+ async () => undefined,
+ ),
+ };
+ const accept = vi.fn(
+ async () => invitation,
+ );
+ const service = new WorkspaceService(
+ fake({ acceptInvitation: accept }),
+ gateway,
+ invitationSecret,
+ );
+ await service.acceptInvitation(maya, "request-accept", {
+ invitationId: invitation.id,
+ token: "private-test-token",
+ expectedVersion: 1,
+ idempotencyKey: "accept-key",
+ });
+ const intent = gateway.authorize.mock.calls[0]?.[0];
+ expect(intent?.facts).toMatchObject({
+ organizationId: "aster",
+ targetPrincipalId: "user-lena",
+ targetMatches: false,
+ role: "viewer",
+ state: "pending",
+ invitationVersion: 1,
+ tokenMatches: true,
+ });
+ expect(JSON.stringify(intent)).not.toContain("private-test-token");
+ expect(accept).toHaveBeenCalledOnce();
+ });
+
it("reproduces Maya's cross-tenant role-reuse write gap", async () => {
const repository = fake({
memberships: async () => [
@@ -121,7 +444,11 @@ describe("P11 permissive service", () => {
],
selection: async () => ({ organizationId: "boreal", version: 2 }),
});
- const gateway = new PermissiveAuthorizationGateway();
+ const gateway = {
+ authorize: vi.fn(
+ async () => undefined,
+ ),
+ };
const service = new WorkspaceService(repository, gateway, invitationSecret);
const result = await service.writeProject(maya, "request-role-reuse", {
organizationId: "boreal",
@@ -132,13 +459,17 @@ describe("P11 permissive service", () => {
idempotencyKey: "role-reuse-1",
});
expect(result.version).toBe(2);
- expect(
- gateway.diagnostic("request-role-reuse", maya.id)?.facts.currentRole,
- ).toBe("viewer");
+ expect(gateway.authorize.mock.calls[0]?.[0]?.facts.currentRole).toBe(
+ "viewer",
+ );
});
it("reproduces stale-route access after membership revocation", async () => {
- const gateway = new PermissiveAuthorizationGateway();
+ const gateway = {
+ authorize: vi.fn(
+ async () => undefined,
+ ),
+ };
const service = new WorkspaceService(
fake({
memberships: async () => [],
@@ -150,37 +481,43 @@ describe("P11 permissive service", () => {
await expect(
service.readProject(maya, "request-stale", "aster", "project-a1"),
).resolves.toMatchObject({ id: "project-a1" });
- expect(
- gateway.diagnostic("request-stale", maya.id)?.facts.currentMembership,
- ).toBe(false);
+ expect(gateway.authorize.mock.calls[0]?.[0]?.facts.currentMembership).toBe(
+ false,
+ );
});
it("keeps exact support evidence visible while reproducing broad support", async () => {
- const gateway = new PermissiveAuthorizationGateway();
- const activeSupport = vi
- .fn()
- .mockResolvedValueOnce([
- { ...support, id: "support-imani-a2", projectId: "project-a2" },
- support,
- ])
- .mockResolvedValue([support]);
+ const gateway = {
+ authorize: vi.fn(
+ async () => undefined,
+ ),
+ };
+ const snapshot = {
+ principalId: imani.id,
+ selection: { organizationId: null, version: 1 },
+ memberships: [],
+ support: [{ ...support, sessionVersion: 1 }],
+ };
+ const authority = vi
+ .fn()
+ .mockResolvedValue(snapshot);
const service = new WorkspaceService(
fake({
memberships: async () => [],
selection: async () => ({ organizationId: null, version: 1 }),
- activeSupport,
+ authority,
}),
gateway,
invitationSecret,
);
await service.readProject(imani, "request-approved", "aster", "project-a1");
- expect(
- gateway.diagnostic("request-approved", imani.id)?.facts.supportExactMatch,
- ).toBe(true);
+ expect(gateway.authorize.mock.calls[0]?.[0]?.facts.supportExactMatch).toBe(
+ true,
+ );
await service.readProject(imani, "request-expanded", "aster", "project-a2");
- expect(
- gateway.diagnostic("request-expanded", imani.id)?.facts.supportExactMatch,
- ).toBe(false);
+ expect(gateway.authorize.mock.calls[1]?.[0]?.facts.supportExactMatch).toBe(
+ false,
+ );
await expect(
service.billing(imani, "request-billing", "aster"),
).resolves.toMatchObject({ organizationId: "aster" });
@@ -228,7 +565,6 @@ describe("P11 permissive service", () => {
authorize: async () => {
throw unavailable();
},
- diagnostic: () => undefined,
},
invitationSecret,
);
diff --git a/p11-saas-workspace/test/setup.test.ts b/p11-saas-workspace/test/setup.test.ts
index 0b4d5c4..423cc83 100644
--- a/p11-saas-workspace/test/setup.test.ts
+++ b/p11-saas-workspace/test/setup.test.ts
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseEnv } from "node:util";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { ensureProjectIdentity } from "../../shared/identity-provider/scripts/setup.mjs";
const dependencies = vi.hoisted(() => ({
docker: vi.fn(),
@@ -14,17 +15,22 @@ const dependencies = vi.hoisted(() => ({
close: vi.fn(),
}));
vi.mock("node:child_process", () => ({ spawnSync: dependencies.docker }));
-vi.mock("../src/server/database.ts", () => ({
- AppDatabase: class {
- constructor(url: string) {
- dependencies.connect(url);
- }
- pool = { query: dependencies.query };
- migrate = dependencies.migrate;
- seed = dependencies.seed;
- close = dependencies.close;
- },
-}));
+vi.mock("../server/data/database.ts", async (importOriginal) => {
+ const { AppDatabase } =
+ await importOriginal();
+ return {
+ AppDatabase: class {
+ constructor(url: string) {
+ dependencies.connect(url);
+ }
+ pool = { query: dependencies.query };
+ migrate = dependencies.migrate;
+ seed = dependencies.seed;
+ initialize = AppDatabase.prototype.initialize;
+ close = dependencies.close;
+ },
+ };
+});
let directory: string;
const customUrl = "postgresql://learner:example@localhost:15435/workspace";
@@ -64,6 +70,37 @@ it("starts the default Compose database and initializes empty fixtures", async (
expect(dependencies.close).toHaveBeenCalledOnce();
});
+it.each([17011, 4211])(
+ "repairs a stale port from registration %s and preserves session credentials",
+ async (port) => {
+ const identityFile = join(directory, ".local/idp/.env");
+ ensureProjectIdentity("P11", identityFile);
+ const registration = readFileSync(identityFile, "utf8").replaceAll(
+ "localhost:17011",
+ `localhost:${port}`,
+ );
+ writeFileSync(identityFile, registration);
+ writeFileSync(
+ join(directory, ".env"),
+ `P11_PORT=3011\nP11_SESSION_SECRET=${"s".repeat(43)}\nP11_DATABASE_URL=${customUrl}\n`,
+ );
+ await setup();
+ expect(environment()).toMatchObject({
+ P11_PORT: String(port),
+ P11_BASE_URL: `http://localhost:${port}`,
+ P11_SESSION_SECRET: "s".repeat(43),
+ P11_CLIENT_SECRET: parseEnv(registration).P11_CLIENT_SECRET,
+ });
+ const first = readFileSync(join(directory, ".env"), "utf8");
+ dependencies.query.mockResolvedValue({ rows: [{ count: "4" }] });
+ await setup();
+ expect(readFileSync(join(directory, ".env"), "utf8")).toBe(first);
+ expect(readFileSync(identityFile, "utf8")).toBe(registration);
+ expect(dependencies.seed).toHaveBeenCalledOnce();
+ expect(dependencies.seed).toHaveBeenCalledWith("http://localhost:18011");
+ },
+);
+
it("persists a shell database override and preserves credentials and existing data on rerun", async () => {
writeFileSync(join(directory, ".env"), `P11_DATABASE_URL=${defaultUrl}\n`);
vi.stubEnv("P11_DATABASE_URL", customUrl);
@@ -80,6 +117,24 @@ it("persists a shell database override and preserves credentials and existing da
expect(dependencies.seed).toHaveBeenCalledOnce();
});
+it("passes the registered issuer to fresh seeding without rewriting existing identities on rerun", async () => {
+ const identityFile = join(directory, ".local/idp/.env");
+ ensureProjectIdentity("P11", identityFile);
+ writeFileSync(
+ identityFile,
+ readFileSync(identityFile, "utf8").replaceAll(
+ "localhost:18011",
+ "localhost:19011",
+ ),
+ );
+ await setup();
+ expect(environment().P11_ISSUER).toBe("http://localhost:19011");
+ expect(dependencies.seed).toHaveBeenCalledWith("http://localhost:19011");
+ dependencies.query.mockResolvedValue({ rows: [{ count: "4" }] });
+ await setup();
+ expect(dependencies.seed).toHaveBeenCalledOnce();
+});
+
it("uses an existing custom database without Docker", async () => {
writeFileSync(join(directory, ".env"), `P11_DATABASE_URL=${customUrl}\n`);
await setup();
diff --git a/p11-saas-workspace/test/shell.test.tsx b/p11-saas-workspace/test/shell.test.tsx
index f888284..dda7d86 100644
--- a/p11-saas-workspace/test/shell.test.tsx
+++ b/p11-saas-workspace/test/shell.test.tsx
@@ -1,7 +1,7 @@
import { renderToString } from "react-dom/server";
import { createMemoryRouter, RouterProvider } from "react-router";
import { describe, expect, it } from "vitest";
-import { BrandRail, Login } from "../app/components/shell.tsx";
+import { BrandRail, Login, ProgramFooter } from "../app/components/shell.tsx";
describe("P1 shell contract", () => {
it("uses the official brand, compact identity chooser, and program footer", () => {
@@ -14,11 +14,19 @@ describe("P1 shell contract", () => {
expect(html).toContain("https://cedarling.dev");
expect(html).toContain("Cedarling Docs");
expect(html).toContain("https://gluu.org");
- expect(html).not.toContain("synthetic local data");
- expect(html).not.toContain("Permissive teaching");
});
- it("provides one explicit account exit action", () => {
+ it("opens resource links in a separate tab without sharing the opener", () => {
+ const html = renderToString( );
+ const links = [...html.matchAll(/]*>/gu)].map(([link]) => link);
+ expect(links).toHaveLength(5);
+ for (const link of links) {
+ expect(link).toContain('target="_blank"');
+ expect(link).toContain('rel="noopener noreferrer"');
+ }
+ });
+
+ it("provides separate change-account and sign-out actions", () => {
const router = createMemoryRouter([
{
path: "*",
@@ -33,7 +41,9 @@ describe("P1 shell contract", () => {
const rail = renderToString( );
expect(rail).toContain('aria-label="Open account menu"');
- expect(rail).toContain("Sign out or change account");
+ expect(rail).toContain("Change account");
+ expect(rail).toContain("Sign out");
+ expect(rail.match(/action="\/auth\/logout"/gu)).toHaveLength(2);
expect(rail).toContain('name="_csrf"');
});
});
diff --git a/p11-saas-workspace/test/time.test.ts b/p11-saas-workspace/test/time.test.ts
new file mode 100644
index 0000000..9a7ec8a
--- /dev/null
+++ b/p11-saas-workspace/test/time.test.ts
@@ -0,0 +1,9 @@
+import { expect, it } from "vitest";
+import { displayTime } from "../app/utils/time.ts";
+
+it("renders explicit UTC deadlines regardless of the source offset", () => {
+ expect(displayTime("2026-10-20T07:45:00+01:00")).toBe(
+ "20 Oct 2026, 06:45 UTC",
+ );
+ expect(displayTime("2026-10-20T06:45:00Z")).toBe("20 Oct 2026, 06:45 UTC");
+});
diff --git a/p11-saas-workspace/tsconfig.node.json b/p11-saas-workspace/tsconfig.node.json
index 4746d13..5919f30 100644
--- a/p11-saas-workspace/tsconfig.node.json
+++ b/p11-saas-workspace/tsconfig.node.json
@@ -1,14 +1,15 @@
{
"extends": "./tsconfig.json",
"include": [
- "server.js",
+ "server/index.js",
"scripts/**/*.ts",
- "src/server/**/*",
- "src/shared/**/*",
+ "server/**/*.ts",
+ "app/types/**/*",
"vite.config.ts",
"react-router.config.ts",
"playwright.config.ts"
],
+ "exclude": ["server/app.ts"],
"compilerOptions": {
"allowImportingTsExtensions": true,
"composite": true,
diff --git a/p11-saas-workspace/tsconfig.vite.json b/p11-saas-workspace/tsconfig.vite.json
index d3e26ee..6c7a7f5 100644
--- a/p11-saas-workspace/tsconfig.vite.json
+++ b/p11-saas-workspace/tsconfig.vite.json
@@ -4,9 +4,6 @@
".react-router/types/**/*",
"app/**/*",
"server/**/*",
- "src/server/**/*",
- "src/shared/**/*",
- "src/web/icons.tsx",
"scripts/setup.ts",
"test/**/*"
],
diff --git a/p12-hr-access-governance/.dockerignore b/p12-hr-access-governance/.dockerignore
index 83decbb..9d1fbb3 100644
--- a/p12-hr-access-governance/.dockerignore
+++ b/p12-hr-access-governance/.dockerignore
@@ -5,3 +5,4 @@ dist
.env.*
.git
coverage
+**/*.log
diff --git a/p12-hr-access-governance/README.md b/p12-hr-access-governance/README.md
index 9b3c834..1e64114 100644
--- a/p12-hr-access-governance/README.md
+++ b/p12-hr-access-governance/README.md
@@ -1,81 +1,115 @@
# P12 - Governing Employee Record Access with Cedarling
-P12 is an HR workflow where administrators request employee-specific contact
-access, reviewers approve it, managers use it, and authorized staff revoke it.
-It shows how Cedarling centralizes separation of duties, grant state, and
-field-level authorization.
+P12 is an HR workspace with an employee directory and temporary access to
+contact or medical records. HR staff request access for a colleague; a reviewer
+approves or declines it. Approved access can expire or be revoked.
-The protected HR capabilities currently use a fake permissive decision; the
-Cedarling tutorial replaces that seam with policy-backed decisions.
+Business permissions return `FAKE ALLOW` in this learning baseline. Cedarling
+will check independent review, manager or clinician authority, and current
+grants for the requested employee. Authentication, tenant containment,
+validation, and workflow checks remain active.
## Architecture
-```text
-Lin / Nia / Ben ── sign in ──→ Tutorial IdP
- │
- └── employee / grant request ──→ React UI → Node.js API (PEP)
- │ actor + employee + grant facts
- ▼
- Cedarling PDP
- │ │
- DENY ALLOW → HR effect → SQLite
+```mermaid
+flowchart TD
+ accTitle: P12 employee access governance
+ accDescr: The HR workspace authenticates users and sends requests to an Express API with permissive authorization seams before SQLite operations.
+ Browser["React HR workspace"] -->|Sign in| IdP["Tutorial IdP"]
+ Browser --> API["Express API"]
+ API --> Facts["Employee, beneficiary and current grant facts"]
+ Facts --> Check["FAKE ALLOW"]
+ Check --> Data["SQLite grant change or field-group response"]
```
## Prerequisites
-- Docker Desktop or Docker Engine with Compose, or
-- Node.js 24.21 or newer within 24.x, pnpm 10, and the project-local tutorial identity provider.
+- Docker Desktop or Docker Engine with Compose.
+- Node.js 24.21 or newer within 24.x.
+- pnpm 10.17.1.
## Run
-Start the application and its own IdP:
+From this project directory, start with Docker:
```bash
docker compose up --build
```
-Open . The issuer is . Stop the stack with `Ctrl+C`, then `docker compose down`.
+Open . The tutorial IdP is .
+Stop with `Ctrl+C`, then `docker compose down`.
-For native development, run from this project directory:
+For native development:
```bash
pnpm --dir ../shared/identity-provider install --frozen-lockfile
-pnpm --dir ../shared/identity-provider build
pnpm install --frozen-lockfile
-pnpm run setup
pnpm dev
```
-`pnpm dev` starts this project’s IdP and application together.
-
-For `pnpm build` followed by `pnpm start`, first run `node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js` in another terminal in this project directory.
+`pnpm dev` prepares configuration and starts the IdP and application.
+After a browser-source rebuild, refresh the page.
## Exercise
-The business workflow grants temporary access to employee records:
+Choose an account. Use the prefilled username, or enter its lowercase first
+name, and any non-empty password such as `cedarling-is-awesome`. Dr. Aya's
+username is `aya`.
+
+- Lin requests access as an HR administrator and also has reviewer duty.
+- Nia is an independent reviewer.
+- Ben manages Cora, Amina, and Jonas.
+- Dr. Aya is the occupational-health clinician.
+
+1. Open **Employees**, find Cora Mensah, and inspect **Profile**, **Contact**,
+ and **Medical**.
+2. As Lin, request Cora's contact details for Ben. Give a reason and choose
+ one to seven days. As Nia, open **Access requests** and approve it.
+3. Read Cora's contact details as Ben, revoke the grant as Nia, and read again.
+ The baseline still returns the fields after revocation.
+4. As Lin, create another request and approve it yourself. The baseline permits
+ self-approval despite the intended independent-review rule.
+5. Request medical access for Dr. Aya. Contact and medical grants are separate;
+ changing one leaves the other intact.
+
+All accounts can open the task pages and attempt these actions. Access periods
+start at approval; unanswered requests expire after 24 hours. Declining requires
+a reason. Declined, revoked, or expired requests remain visible; submit a fresh
+request to try again.
+
+## Data and reset
+
+Employee and medical records are fictional and read-only.
-- **Lin** — HR administrator and reviewer who requests access for an employee.
-- **Nia** — Independent reviewer who approves or revokes requests.
-- **Ben** — Manager who reads his employees' permitted record fields.
+Restarts preserve your changes. To reset native data, stop the app, run
+`pnpm reset`, then `pnpm dev`.
+For Docker, keep the IdP running and reset the stopped application:
-Select Cora, request access, review it, then revoke it and read again. The
-current seam permits self-approval and contact reads without a current grant;
-Cedarling will enforce separation, employee scope, and grant effectiveness.
+```bash
+docker compose stop cedarhr
+docker compose run --rm --no-deps cedarhr node --env-file=/run/config/app.env dist/server/data/reset.js
+docker compose up --build
+```
+
+Reset restores employee fixtures and clears access requests, grants, and
+sessions. IdP credentials are preserved.
## Commands
| Command | Purpose |
| --- | --- |
-| `pnpm run setup` | Validate configuration and initialize SQLite |
-| `pnpm dev` | Build the browser and watch the app |
-| `pnpm start` | Run the built app |
-| `pnpm test:e2e` | Exercise the HR grant lifecycle |
-| `pnpm check` | Run formatting, lint, types, tests, and build |
+| `pnpm run setup` | Prepare configuration and SQLite |
+| `pnpm start` | Run the built app; requires a running IdP |
## Verify
+`pnpm check` runs formatting, lint, type checks, tests, and build.
+The native and Docker scenarios use disposable data and separate ports:
+
```bash
pnpm check
pnpm test:e2e
-pnpm audit --audit-level low
+pnpm test:docker
```
+
+The last command requires Docker.
diff --git a/p12-hr-access-governance/compose.yaml b/p12-hr-access-governance/compose.yaml
index 0b54fef..9b1eea9 100644
--- a/p12-hr-access-governance/compose.yaml
+++ b/p12-hr-access-governance/compose.yaml
@@ -71,7 +71,7 @@ services:
- CMD
- node
- -e
- - fetch(process.env.IDP_ISSUER+'/.well-known/openid-configuration').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))
+ - fetch(process.env.IDP_ISSUER+'/.well-known/openid-configuration').then(async r=>process.exit(r.ok&&(await r.json()).issuer===process.env.IDP_ISSUER?0:1)).catch(()=>process.exit(1))
interval: 2s
timeout: 3s
retries: 30
diff --git a/p12-hr-access-governance/package.json b/p12-hr-access-governance/package.json
index 0713ce4..2cd4a3c 100644
--- a/p12-hr-access-governance/package.json
+++ b/p12-hr-access-governance/package.json
@@ -8,6 +8,7 @@
},
"packageManager": "pnpm@10.17.1",
"scripts": {
+ "reset": "node --env-file=.env scripts/reset.ts",
"setup": "node scripts/setup.ts",
"dev": "node scripts/dev.mjs",
"start": "node --env-file=.env dist/server/main.js",
@@ -18,7 +19,8 @@
"lint": "biome check --formatter-enabled=false --error-on-warnings .",
"test": "vitest run",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build",
- "test:e2e": "node scripts/test-e2e.ts"
+ "test:e2e": "node scripts/test-e2e.ts",
+ "test:docker": "node scripts/test-docker.ts"
},
"dependencies": {
"better-sqlite3": "13.0.3",
@@ -26,7 +28,8 @@
"express": "5.2.1",
"openid-client": "6.8.8",
"react": "19.3.0",
- "react-dom": "19.3.0"
+ "react-dom": "19.3.0",
+ "react-router": "8.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.11",
@@ -37,7 +40,7 @@
"@types/react-dom": "19.3.0",
"jsdom": "30.1.1",
"typescript": "6.0.3",
- "vite": "8.2.2",
+ "vite": "8.2.4",
"vitest": "4.1.11"
},
"pnpm": {
diff --git a/p12-hr-access-governance/pnpm-lock.yaml b/p12-hr-access-governance/pnpm-lock.yaml
index 3d3c249..e8648fa 100644
--- a/p12-hr-access-governance/pnpm-lock.yaml
+++ b/p12-hr-access-governance/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
react-dom:
specifier: 19.3.0
version: 19.3.0(react@19.3.0)
+ react-router:
+ specifier: 8.4.0
+ version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
devDependencies:
'@biomejs/biome':
specifier: 2.5.11
@@ -55,11 +58,11 @@ importers:
specifier: 6.0.3
version: 6.0.3
vite:
- specifier: 8.2.2
- version: 8.2.2(@types/node@24.19.0)
+ specifier: 8.2.4
+ version: 8.2.4(@types/node@24.19.0)
vitest:
specifier: 4.1.11
- version: 4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.2(@types/node@24.19.0))
+ version: 4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0))
packages:
@@ -179,6 +182,9 @@ packages:
'@oxc-project/types@0.149.0':
resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==}
+ '@remix-run/route-pattern@0.22.1':
+ resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==}
+
'@rolldown/binding-android-arm-eabi@1.2.8':
resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -404,6 +410,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie-es@3.1.1:
+ resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
+
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
@@ -767,6 +776,16 @@ packages:
peerDependencies:
react: ^19.3.0
+ react-router@8.4.0:
+ resolution: {integrity: sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==}
+ engines: {node: '>=22.22.0'}
+ peerDependencies:
+ react: '>=19.2.7'
+ react-dom: '>=19.2.7'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react@19.3.0:
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
engines: {node: '>=0.10.0'}
@@ -896,8 +915,8 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
- vite@8.2.2:
- resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
+ vite@8.2.4:
+ resolution: {integrity: sha512-lfauXnrf2x0p+UoA+wXh0dgFWnUjCyG6Js95KBkgaCwUU9k0DAHI4W6Q0WCx+h+5g+Alce1y4Zi2QjjK0UvGBw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1101,6 +1120,8 @@ snapshots:
'@oxc-project/types@0.149.0': {}
+ '@remix-run/route-pattern@0.22.1': {}
+
'@rolldown/binding-android-arm-eabi@1.2.8':
optional: true
@@ -1221,13 +1242,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0))':
+ '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.2.2(@types/node@24.19.0)
+ vite: 8.2.4(@types/node@24.19.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -1304,6 +1325,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie-es@3.1.1: {}
+
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
@@ -1646,6 +1669,14 @@ snapshots:
react: 19.3.0
scheduler: 0.28.0
+ react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
+ dependencies:
+ '@remix-run/route-pattern': 0.22.1
+ cookie-es: 3.1.1
+ react: 19.3.0
+ optionalDependencies:
+ react-dom: 19.3.0(react@19.3.0)
+
react@19.3.0: {}
require-from-string@2.0.2: {}
@@ -1797,7 +1828,7 @@ snapshots:
vary@1.1.2: {}
- vite@8.2.2(@types/node@24.19.0):
+ vite@8.2.4(@types/node@24.19.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -1808,10 +1839,10 @@ snapshots:
'@types/node': 24.19.0
fsevents: 2.3.3
- vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.2(@types/node@24.19.0)):
+ vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0)):
dependencies:
'@vitest/expect': 4.1.11
- '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0))
+ '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -1828,7 +1859,7 @@ snapshots:
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.2.2(@types/node@24.19.0)
+ vite: 8.2.4(@types/node@24.19.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.19.0
diff --git a/p12-hr-access-governance/scripts/dev.mjs b/p12-hr-access-governance/scripts/dev.mjs
index 9d7e1e0..0639bb5 100644
--- a/p12-hr-access-governance/scripts/dev.mjs
+++ b/p12-hr-access-governance/scripts/dev.mjs
@@ -54,6 +54,12 @@ async function services() {
validate: issuerHealth(issuer),
},
},
+ {
+ name: "P12 web build watcher",
+ command: pnpm,
+ args: ["exec", "vite", "build", "--watch", "--emptyOutDir=false"],
+ cwd: root,
+ },
{
name: "P12 application",
command: node,
diff --git a/p12-hr-access-governance/scripts/reset.ts b/p12-hr-access-governance/scripts/reset.ts
new file mode 100644
index 0000000..c5610dd
--- /dev/null
+++ b/p12-hr-access-governance/scripts/reset.ts
@@ -0,0 +1 @@
+import "../src/server/data/reset.ts";
diff --git a/p12-hr-access-governance/scripts/scenario-session.ts b/p12-hr-access-governance/scripts/scenario-session.ts
index c8ff62d..63e9c4f 100644
--- a/p12-hr-access-governance/scripts/scenario-session.ts
+++ b/p12-hr-access-governance/scripts/scenario-session.ts
@@ -1,4 +1,4 @@
-import type { Config } from "../src/server/config.ts";
+import type { Config } from "../src/server/config/environment.ts";
import type { Result, SessionView } from "../src/shared/contracts.ts";
class CookieJar {
@@ -77,7 +77,7 @@ type ScenarioSession = Readonly<{
export async function openScenarioSession(
config: Config,
- login: "lin" | "nia" | "ben",
+ login: "lin" | "nia" | "ben" | "aya",
): Promise {
const app = new CookieJar();
const issuer = new CookieJar();
diff --git a/p12-hr-access-governance/scripts/scenario-support.ts b/p12-hr-access-governance/scripts/scenario-support.ts
index f9145f1..e1d2af9 100644
--- a/p12-hr-access-governance/scripts/scenario-support.ts
+++ b/p12-hr-access-governance/scripts/scenario-support.ts
@@ -3,8 +3,8 @@ import { randomBytes } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { resolve } from "node:path";
-import { type Config, loadConfig } from "../src/server/config.ts";
-import { assertDirectoryPath } from "../src/server/files.ts";
+import { type Config, loadConfig } from "../src/server/config/environment.ts";
+import { assertDirectoryPath } from "../src/server/data/files.ts";
import type { Grant, Result } from "../src/shared/contracts.ts";
import { openScenarioSession } from "./scenario-session.ts";
@@ -95,6 +95,8 @@ export async function proveAuthorizationFlow(config: Config): Promise {
console.info("✓ selected gap: contact disclosure without an effective grant");
const created = (await call(lin, "/api/grants", {
employeeId: "cora",
+ packageId: "team-support",
+ reason: "Coordinate employee support arrangements.",
days: 1,
})) as Result;
const approved = (await call(lin, `/api/grants/${created.data.id}/approve`, {
@@ -123,6 +125,8 @@ export async function proveAuthorizationFlow(config: Config): Promise {
assert.equal(terminal.data.status, "revoked");
const next = (await call(lin, "/api/grants", {
employeeId: "cora",
+ packageId: "team-support",
+ reason: "Coordinate employee support arrangements.",
days: 1,
})) as Result;
const separate = (await call(nia, `/api/grants/${next.data.id}/approve`, {
@@ -142,6 +146,53 @@ export async function proveAuthorizationFlow(config: Config): Promise {
console.info(
"✓ separate approval, terminal metadata, fresh requests, stale-version and native-boundary controls",
);
+
+ const aya = await openScenarioSession(config, "aya");
+ const medical = (await call(lin, "/api/grants", {
+ employeeId: "cora",
+ packageId: "occupational-health",
+ beneficiaryId: "aya",
+ days: 1,
+ reason: "Review the occupational-health case.",
+ })) as Result;
+ const declined = (await call(nia, `/api/grants/${medical.data.id}/reject`, {
+ version: 1,
+ note: "The review has already been completed.",
+ })) as Result;
+ assert.equal(declined.data.status, "rejected");
+ const clinical = (await call(lin, "/api/grants", {
+ employeeId: "cora",
+ packageId: "occupational-health",
+ beneficiaryId: "aya",
+ days: 2,
+ reason: "Review the next occupational-health case.",
+ })) as Result;
+ assert.equal(clinical.data.expiresAt, null);
+ const clinicalApproved = (await call(
+ nia,
+ `/api/grants/${clinical.data.id}/approve`,
+ { version: 1 },
+ )) as Result;
+ assert.equal(
+ clinicalApproved.data.expiresAt,
+ (clinicalApproved.data.approvedAt ?? 0) + 2 * 86400000,
+ );
+ assert.equal(
+ (await call(aya, "/api/employees/cora/medical")).data.bloodGroup,
+ "O+",
+ );
+ await call(nia, `/api/grants/${clinical.data.id}/revoke`, { version: 2 });
+ assert.equal(
+ (await call(ben, "/api/employees/cora/medical")).data.bloodGroup,
+ "O+",
+ );
+ assert.equal(
+ (await call(nia, `/api/grants/${next.data.id}`)).data.status,
+ "approved",
+ );
+ console.info(
+ "✓ clinician sign-in, independent medical grants, rejection, approval-based expiry and permissive disclosure",
+ );
return next.data.id;
}
export async function provePersistence(config: Config, id: string) {
diff --git a/p12-hr-access-governance/scripts/setup.ts b/p12-hr-access-governance/scripts/setup.ts
index 23b133c..299c3e8 100644
--- a/p12-hr-access-governance/scripts/setup.ts
+++ b/p12-hr-access-governance/scripts/setup.ts
@@ -5,8 +5,11 @@ import {
writePrivateEnvironment,
} from "../../shared/identity-provider/scripts/project-environment.mjs";
import { ensureProjectIdentity } from "../../shared/identity-provider/scripts/setup.mjs";
-import { loadConfig, prepareDataDirectory } from "../src/server/config.ts";
-import { Database } from "../src/server/database.ts";
+import {
+ loadConfig,
+ prepareDataDirectory,
+} from "../src/server/config/environment.ts";
+import { Database } from "../src/server/data/database.ts";
const target = resolve(".env");
const identity = ensureProjectIdentity("P12");
@@ -29,17 +32,17 @@ const current = readProjectEnvironment(target);
const merged = mergeProjectEnvironment(current.text, {
managed: {
P12_BASE_URL: baseUrl,
+ P12_PORT:
+ new URL(baseUrl).port || (baseUrl.startsWith("https:") ? "443" : "80"),
P12_ISSUER: issuer,
+ P12_IDP_PORT:
+ new URL(issuer).port || (issuer.startsWith("https:") ? "443" : "80"),
P12_API_RESOURCE: url("P12_API_RESOURCE"),
P12_CLIENT_ID: required("P12_CLIENT_ID"),
P12_CLIENT_SECRET: required("P12_CLIENT_SECRET"),
},
defaults: {
P12_HOST: "127.0.0.1",
- P12_PORT: new URL(baseUrl).port || "80",
- P12_IDP_PORT:
- new URL(issuer).port ||
- (new URL(issuer).protocol === "https:" ? "443" : "80"),
P12_DATA_DIR: ".local/p12-data",
},
});
diff --git a/p12-hr-access-governance/scripts/test-docker.ts b/p12-hr-access-governance/scripts/test-docker.ts
new file mode 100644
index 0000000..78b7194
--- /dev/null
+++ b/p12-hr-access-governance/scripts/test-docker.ts
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { openScenarioSession } from "./scenario-session.ts";
+import {
+ proveAuthorizationFlow,
+ provePersistence,
+ scenarioEnvironment,
+ waitFor,
+} from "./scenario-support.ts";
+
+const scenario = await scenarioEnvironment();
+const project = `p12-check-${process.pid}`;
+const env = { ...scenario.env, COMPOSE_PROJECT_NAME: project };
+async function compose(args: string[]) {
+ await new Promise((done, reject) => {
+ const child = spawn("docker", ["compose", "-p", project, ...args], {
+ env,
+ stdio: "inherit",
+ });
+ child.once("error", reject);
+ child.once("exit", (code) =>
+ code === 0
+ ? done()
+ : reject(
+ new Error(`P12 disposable Docker check failed (exit ${code})`),
+ ),
+ );
+ });
+}
+let success = false;
+try {
+ await compose(["up", "--build", "--wait", "--wait-timeout", "90"]);
+ await waitFor(`${scenario.config.baseUrl}/health`);
+ const id = await proveAuthorizationFlow(scenario.config);
+ await compose(["restart", "cedarhr"]);
+ await waitFor(`${scenario.config.baseUrl}/health`);
+ await provePersistence(scenario.config, id);
+ await compose(["stop", "cedarhr"]);
+ await compose([
+ "run",
+ "--rm",
+ "--no-deps",
+ "cedarhr",
+ "node",
+ "--env-file=/run/config/app.env",
+ "dist/server/data/reset.js",
+ ]);
+ await compose(["up", "-d", "--no-build", "--wait", "--wait-timeout", "90"]);
+ await waitFor(`${scenario.config.baseUrl}/health`);
+ const fresh = await openScenarioSession(scenario.config, "lin");
+ const grants = await fresh.request("/api/grants");
+ assert.equal(grants.status, 200);
+ assert.deepEqual((await grants.json()).data, []);
+ success = true;
+ console.info(
+ "P12 Docker proof passed: OIDC, seven seams, independent grants, restart persistence and documented reset.",
+ );
+} finally {
+ await compose(["down", "--volumes", "--remove-orphans"]);
+ if (success) scenario.cleanup();
+ else console.error(`P12 failed scenario retained at ${scenario.directory}`);
+}
diff --git a/p12-hr-access-governance/src/server/app.ts b/p12-hr-access-governance/src/server/app.ts
index e1e6d4b..7e680ed 100644
--- a/p12-hr-access-governance/src/server/app.ts
+++ b/p12-hr-access-governance/src/server/app.ts
@@ -1,14 +1,18 @@
import { randomUUID } from "node:crypto";
import { resolve } from "node:path";
import express, { type ErrorRequestHandler } from "express";
-import type { SessionView } from "../shared/contracts.ts";
-import { registerAuth } from "./auth.ts";
-import { type Authorize, fakeAuthorize } from "./authorization.ts";
-import type { Config } from "./config.ts";
-import type { Database } from "./database.ts";
-import { AppError, bodyFields, identifier, integer } from "./errors.ts";
-import type { OidcRuntime } from "./oidc.ts";
-import { HrService } from "./service.ts";
+import {
+ accessPackages,
+ type GrantRequest,
+ type SessionView,
+} from "../shared/contracts.ts";
+import type { OidcRuntime } from "./auth/oidc.ts";
+import { registerAuth } from "./auth/routes.ts";
+import type { Config } from "./config/environment.ts";
+import type { Database } from "./data/database.ts";
+import { type Authorize, fakeAuthorize } from "./hr/authorization.ts";
+import { HrService } from "./hr/service.ts";
+import { AppError, bodyFields, identifier, integer } from "./http/errors.ts";
export function createApp(
config: Config,
db: Database,
@@ -78,22 +82,46 @@ export function createApp(
db,
authorize,
response.locals.requestId,
- ).grants(response.locals.actor, view),
+ ).grants(
+ response.locals.actor,
+ view,
+ request.query.employeeId === undefined
+ ? undefined
+ : identifier(request.query.employeeId),
+ ),
requestId: response.locals.requestId,
});
});
app.post("/api/grants", async (request, response) => {
- const input = bodyFields(request.body, ["employeeId", "days"]);
+ const input = bodyFields(request.body, [
+ "employeeId",
+ "packageId",
+ "days",
+ "reason",
+ "beneficiaryId",
+ ]);
+ const pkg = accessPackages.find((value) => value.id === input.packageId);
+ if (!pkg)
+ throw new AppError(
+ 400,
+ "INVALID_INPUT",
+ "Choose an available access package.",
+ );
+ const grantRequest: GrantRequest = {
+ employeeId: identifier(input.employeeId),
+ packageId: pkg.id,
+ days: integer(input.days, 7),
+ reason: typeof input.reason === "string" ? input.reason : "",
+ ...(input.beneficiaryId === undefined
+ ? {}
+ : { beneficiaryId: identifier(input.beneficiaryId) }),
+ };
response.status(201).json({
data: await new HrService(
db,
authorize,
response.locals.requestId,
- ).request(
- response.locals.actor,
- identifier(input.employeeId),
- integer(input.days, 7),
- ),
+ ).request(response.locals.actor, grantRequest),
requestId: response.locals.requestId,
});
});
@@ -110,9 +138,12 @@ export function createApp(
requestId: response.locals.requestId,
});
});
- for (const operation of ["approve", "revoke"] as const)
+ for (const operation of ["approve", "reject", "revoke"] as const)
app.post(`/api/grants/:id/${operation}`, async (request, response) => {
- const input = bodyFields(request.body, ["version"]);
+ const input = bodyFields(
+ request.body,
+ operation === "reject" ? ["version", "note"] : ["version"],
+ );
response.json({
data: await new HrService(
db,
@@ -123,10 +154,21 @@ export function createApp(
identifier(request.params.id),
integer(input.version),
operation,
+ typeof input.note === "string" ? input.note : "",
),
requestId: response.locals.requestId,
});
});
+ app.get("/api/access-options", async (_request, response) =>
+ response.json({
+ data: await new HrService(
+ db,
+ authorize,
+ response.locals.requestId,
+ ).options(response.locals.actor),
+ requestId: response.locals.requestId,
+ }),
+ );
app.get("/api/employees", async (_request, response) =>
response.json({
data: await new HrService(
@@ -137,7 +179,7 @@ export function createApp(
requestId: response.locals.requestId,
}),
);
- for (const group of ["profile", "contact"] as const)
+ for (const group of ["profile", "contact", "medical"] as const)
app.get(`/api/employees/:id/${group}`, async (request, response) =>
response.json({
data: await new HrService(db, authorize, response.locals.requestId)[
@@ -152,6 +194,20 @@ export function createApp(
dotfiles: "deny",
}),
);
+ app.get(
+ [
+ "/employees",
+ "/employees/:id",
+ "/employees/:id/contact",
+ "/employees/:id/medical",
+ "/access-requests",
+ "/access-requests/new",
+ "/access-requests/:id",
+ ],
+ (_request, response) => {
+ response.sendFile(resolve("dist/web/index.html"));
+ },
+ );
app.use((_request, _response, next) =>
next(new AppError(404, "NOT_FOUND", "Resource is unavailable.")),
);
@@ -178,6 +234,10 @@ export function createApp(
error.type === "entity.too.large"
)
problem = new AppError(413, "BODY_TOO_LARGE", "Request exceeds 16 KiB.");
+ if (problem.status >= 500)
+ console.error(
+ `P12 server | ERROR | ${problem.code} | ${response.locals.requestId}`,
+ );
response.status(problem.status).json({
error: { code: problem.code, message: problem.message },
requestId: response.locals.requestId,
diff --git a/p12-hr-access-governance/src/server/oidc.ts b/p12-hr-access-governance/src/server/auth/oidc.ts
similarity index 95%
rename from p12-hr-access-governance/src/server/oidc.ts
rename to p12-hr-access-governance/src/server/auth/oidc.ts
index 1328af4..9c06194 100644
--- a/p12-hr-access-governance/src/server/oidc.ts
+++ b/p12-hr-access-governance/src/server/auth/oidc.ts
@@ -1,6 +1,6 @@
import * as oidc from "openid-client";
-import type { Config } from "./config.ts";
-import type { LoginTransaction } from "./database.ts";
+import type { Config } from "../config/environment.ts";
+import type { LoginTransaction } from "../data/database.ts";
type Identity = Readonly<{ subject: string; expiresAt: number }>;
export interface OidcRuntime {
diff --git a/p12-hr-access-governance/src/server/auth.ts b/p12-hr-access-governance/src/server/auth/routes.ts
similarity index 95%
rename from p12-hr-access-governance/src/server/auth.ts
rename to p12-hr-access-governance/src/server/auth/routes.ts
index 5021077..a8ba36c 100644
--- a/p12-hr-access-governance/src/server/auth.ts
+++ b/p12-hr-access-governance/src/server/auth/routes.ts
@@ -1,10 +1,10 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { parseCookie, stringifySetCookie } from "cookie";
import type { Express, Request } from "express";
-import { accounts } from "../shared/contracts.ts";
-import type { Config } from "./config.ts";
-import type { Database, LoginTransaction, Session } from "./database.ts";
-import { AppError, denied, unauthorized } from "./errors.ts";
+import { accounts } from "../../shared/contracts.ts";
+import type { Config } from "../config/environment.ts";
+import type { Database, LoginTransaction, Session } from "../data/database.ts";
+import { AppError, denied, unauthorized } from "../http/errors.ts";
import type { OidcRuntime } from "./oidc.ts";
export const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
diff --git a/p12-hr-access-governance/src/server/authorization.ts b/p12-hr-access-governance/src/server/authorization.ts
deleted file mode 100644
index a4849b3..0000000
--- a/p12-hr-access-governance/src/server/authorization.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Capability } from "../shared/contracts.ts";
-import type { Employee, GrantRow, Principal } from "./database.ts";
-import { AppError } from "./errors.ts";
-export type Facts = Readonly<{
- principal: Principal;
- grant?: GrantRow;
- employee?: Employee;
- manager?: Principal;
- effectiveGrant?: GrantRow;
- package: { id: string; scope: string; fieldGroups: string };
-}>;
-export type DecisionInput = Readonly<{
- capability: Capability;
- intent: "read" | "execute";
- resourceId: string;
- facts: Facts;
- parameters: Readonly<{
- durationDays?: number;
- expectedVersion?: number;
- }>;
- requestId: string;
- now: number;
-}>;
-export type Authorize = (input: DecisionInput) => Promise;
-export const fakeAuthorize: Authorize = async (input) => {
- console.info(
- `P12 server | FAKE ALLOW | ${input.capability} | ${input.facts.principal.id} -> ${input.resourceId}`,
- );
-};
-export const unavailable: Authorize = async () => {
- throw new AppError(
- 503,
- "AUTHORIZATION_UNAVAILABLE",
- "Authorization is unavailable. Try again later.",
- );
-};
diff --git a/p12-hr-access-governance/src/server/config.ts b/p12-hr-access-governance/src/server/config/environment.ts
similarity index 97%
rename from p12-hr-access-governance/src/server/config.ts
rename to p12-hr-access-governance/src/server/config/environment.ts
index 85a2fcd..09890e4 100644
--- a/p12-hr-access-governance/src/server/config.ts
+++ b/p12-hr-access-governance/src/server/config/environment.ts
@@ -1,6 +1,6 @@
import { mkdirSync } from "node:fs";
import { isAbsolute, relative, resolve, sep } from "node:path";
-import { assertDirectoryPath, assertSqlitePath } from "./files.ts";
+import { assertDirectoryPath, assertSqlitePath } from "../data/files.ts";
export type Config = Readonly<{
host: string;
port: number;
diff --git a/p12-hr-access-governance/src/server/data/database.ts b/p12-hr-access-governance/src/server/data/database.ts
new file mode 100644
index 0000000..be88690
--- /dev/null
+++ b/p12-hr-access-governance/src/server/data/database.ts
@@ -0,0 +1,185 @@
+import Sqlite from "better-sqlite3";
+import {
+ accessPackages,
+ type Grant,
+ type GrantStatus,
+} from "../../shared/contracts.ts";
+import { assertSqlitePath } from "./files.ts";
+import { seed } from "./fixtures.ts";
+import type {
+ Employee,
+ GrantRow,
+ MedicalRow,
+ Principal,
+ Session,
+} from "./models.ts";
+import { SCHEMA_VERSION, schema } from "./schema.ts";
+
+export type {
+ Employee,
+ GrantRow,
+ LoginTransaction,
+ Principal,
+ Session,
+} from "./models.ts";
+
+export class Database {
+ readonly sql: Sqlite.Database;
+ constructor(path: string) {
+ if (path !== ":memory:") assertSqlitePath(path);
+ this.sql = new Sqlite(path);
+ try {
+ this.sql.pragma("foreign_keys = ON");
+ this.sql.pragma("journal_mode = WAL");
+ this.sql.pragma("busy_timeout = 3000");
+ const version = this.sql.pragma("user_version", { simple: true });
+ const populated = this.sql
+ .prepare(
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1",
+ )
+ .get();
+ if (version !== SCHEMA_VERSION && populated)
+ throw new Error(
+ "P12 data uses a different baseline schema. Stop P12 and explicitly run pnpm reset, or choose a fresh data directory. Existing data was not replaced.",
+ );
+ if (!populated)
+ this.sql.transaction(() => {
+ this.sql.exec(schema);
+ seed(this.sql);
+ this.sql.pragma(`user_version = ${SCHEMA_VERSION}`);
+ })();
+ } catch (error) {
+ this.sql.close();
+ throw error;
+ }
+ }
+ principal(id: string): Principal | undefined {
+ return this.sql
+ .prepare<[string], Principal>("SELECT * FROM principals WHERE id = ?")
+ .get(id);
+ }
+ employee(id: string): Employee | undefined {
+ return this.sql
+ .prepare<[string], Employee>("SELECT * FROM employees WHERE id = ?")
+ .get(id);
+ }
+ medicalVersion(id: string): number | null {
+ return (
+ this.sql
+ .prepare<[string], { version: number }>(
+ "SELECT version FROM medical_records WHERE employeeId = ?",
+ )
+ .get(id)?.version ?? null
+ );
+ }
+ medical(id: string): MedicalRow | undefined {
+ return this.sql
+ .prepare<[string], MedicalRow>(
+ "SELECT * FROM medical_records WHERE employeeId = ?",
+ )
+ .get(id);
+ }
+ grant(id: string): GrantRow | undefined {
+ return this.sql
+ .prepare<[string], GrantRow>("SELECT * FROM grants WHERE id = ?")
+ .get(id);
+ }
+ currentGrant(
+ beneficiaryId: string,
+ employeeId: string,
+ packageId = "team-support",
+ ): GrantRow | undefined {
+ return this.sql
+ .prepare<[string, string, string], GrantRow>(
+ "SELECT * FROM grants WHERE beneficiaryId = ? AND employeeId = ? AND packageId = ? ORDER BY createdAt DESC, rowid DESC LIMIT 1",
+ )
+ .get(beneficiaryId, employeeId, packageId);
+ }
+ grantView(grant: GrantRow, now = Date.now()): Grant {
+ const {
+ id,
+ packageId,
+ employeeId,
+ requesterId,
+ beneficiaryId,
+ scope,
+ reason,
+ durationDays,
+ status,
+ createdAt,
+ reviewDeadline,
+ approvedAt,
+ expiresAt,
+ reviewedBy,
+ reviewedAt,
+ reviewNote,
+ revokedBy,
+ revokedAt,
+ version,
+ } = grant;
+ const fields = {
+ id,
+ packageId,
+ employeeId,
+ requesterId,
+ beneficiaryId,
+ scope,
+ reason,
+ durationDays,
+ status,
+ createdAt,
+ reviewDeadline,
+ approvedAt,
+ expiresAt,
+ reviewedBy,
+ reviewedAt,
+ reviewNote,
+ revokedBy,
+ revokedAt,
+ version,
+ };
+ return {
+ ...fields,
+ packageName:
+ accessPackages.find((pkg) => pkg.id === grant.packageId)?.name ??
+ "Unavailable",
+ employeeName: this.employee(grant.employeeId)?.name ?? "Unavailable",
+ requesterName: this.principal(grant.requesterId)?.name ?? "Unavailable",
+ beneficiaryName:
+ this.principal(grant.beneficiaryId)?.name ?? "Unavailable",
+ reviewerName: grant.reviewedBy
+ ? (this.principal(grant.reviewedBy)?.name ?? "Unavailable")
+ : null,
+ revokerName: grant.revokedBy
+ ? (this.principal(grant.revokedBy)?.name ?? "Unavailable")
+ : null,
+ status: effectiveStatus(grant, now),
+ };
+ }
+ session(id: string): Session | undefined {
+ this.prune();
+ return this.sql
+ .prepare<[string], Session>("SELECT * FROM sessions WHERE id = ?")
+ .get(id);
+ }
+ prune() {
+ this.sql
+ .prepare("DELETE FROM sessions WHERE expiresAt <= ?")
+ .run(Date.now());
+ this.sql
+ .prepare("DELETE FROM login_transactions WHERE expiresAt <= ?")
+ .run(Date.now());
+ }
+ close() {
+ this.sql.close();
+ }
+}
+export function effectiveStatus(grant: GrantRow, now: number): GrantStatus {
+ const deadline =
+ grant.status === "pending"
+ ? grant.reviewDeadline
+ : grant.status === "approved"
+ ? grant.expiresAt
+ : null;
+ return deadline !== null && deadline <= now ? "expired" : grant.status;
+}
diff --git a/p12-hr-access-governance/src/server/files.ts b/p12-hr-access-governance/src/server/data/files.ts
similarity index 100%
rename from p12-hr-access-governance/src/server/files.ts
rename to p12-hr-access-governance/src/server/data/files.ts
diff --git a/p12-hr-access-governance/src/server/data/fixtures.ts b/p12-hr-access-governance/src/server/data/fixtures.ts
new file mode 100644
index 0000000..9fb77c8
--- /dev/null
+++ b/p12-hr-access-governance/src/server/data/fixtures.ts
@@ -0,0 +1,229 @@
+import type Sqlite from "better-sqlite3";
+import { accessPackages, accounts } from "../../shared/contracts.ts";
+import type { MedicalData } from "./models.ts";
+
+const staff = [
+ [
+ "cora",
+ "Cora Mensah",
+ "Support",
+ "Support specialist",
+ "ben",
+ "Cotonou",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "amina",
+ "Amina Diallo",
+ "Support",
+ "Customer success lead",
+ "ben",
+ "Dakar",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "jonas",
+ "Jonas Weber",
+ "Support",
+ "Service coordinator",
+ "ben",
+ "Berlin",
+ "Fixed term",
+ "On leave",
+ ],
+ [
+ "other-team",
+ "Dee Okafor",
+ "Operations",
+ "Operations specialist",
+ "other-manager",
+ "Lagos",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "mei",
+ "Mei Chen",
+ "Operations",
+ "Workplace coordinator",
+ "other-manager",
+ "Singapore",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "rafael",
+ "Rafael Costa",
+ "Finance",
+ "Financial analyst",
+ "other-manager",
+ "Lisbon",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "ines",
+ "Ines Laurent",
+ "People",
+ "People operations partner",
+ "other-manager",
+ "Paris",
+ "Permanent",
+ "On leave",
+ ],
+ [
+ "samira",
+ "Samira Haddad",
+ "Engineering",
+ "Platform engineer",
+ "other-manager",
+ "Tunis",
+ "Permanent",
+ "Active",
+ ],
+ [
+ "foreign",
+ "Eli Rivera",
+ "Support",
+ "Support specialist",
+ "foreign-manager",
+ "Madrid",
+ "Permanent",
+ "Active",
+ ],
+] as const;
+
+function medicalFixture(index: number): MedicalData {
+ return {
+ dateOfBirth:
+ ["1992-04-16", "1988-07-23", "1995-11-04"][index % 3] ?? "1992-04-16",
+ bloodGroup: index % 2 ? "A+" : "O+",
+ allergies:
+ index % 2 ? ["No known allergies recorded"] : ["Seasonal pollen"],
+ conditions:
+ index % 2
+ ? []
+ : [
+ {
+ date: "2025-03-11",
+ title: "Seasonal allergic rhinitis",
+ details:
+ "Recorded history; reviewed at the annual health assessment.",
+ },
+ ],
+ medications: [
+ {
+ date: "2026-03-11",
+ title: "Current medication review",
+ details: "No current regular medication recorded.",
+ },
+ ],
+ consultations: [
+ {
+ date: "2026-09-09",
+ title: "Annual occupational-health consultation",
+ details:
+ "Routine review of work demands and reported symptoms. Follow-up recorded for the next annual assessment.",
+ },
+ ],
+ testResults: [
+ {
+ date: "2026-09-09",
+ title: "Vision screening",
+ details: "Right eye 6/6; left eye 6/6. Synthetic screening result.",
+ },
+ ],
+ vaccinations: [
+ {
+ date: "2025-10-14",
+ title: "Influenza vaccination",
+ details: "Annual vaccination recorded by the clinic.",
+ },
+ ],
+ assessments: [
+ {
+ date: "2026-09-09",
+ title: "Workplace assessment",
+ details:
+ "Fit for current duties. Adjustable workstation recommended. Review: 2027-09-09.",
+ },
+ ],
+ };
+}
+
+export function seed(sql: Sqlite.Database) {
+ const insertPrincipal = sql.prepare(
+ "INSERT INTO principals VALUES (@id,@name,@tenantId,@role,@administrator,@reviewer,@clinician,1)",
+ );
+ for (const account of accounts) {
+ insertPrincipal.run({
+ ...account,
+ tenantId: "tenant-a",
+ administrator: Number(account.id === "lin"),
+ reviewer: Number(account.id === "lin" || account.id === "nia"),
+ clinician: Number(account.id === "aya"),
+ });
+ }
+ for (const [id, name, tenantId] of [
+ ["other-manager", "Morgan", "tenant-a"],
+ ["foreign-manager", "Alex", "tenant-b"],
+ ])
+ insertPrincipal.run({
+ id,
+ name,
+ tenantId,
+ role: "Manager",
+ administrator: 0,
+ reviewer: 0,
+ clinician: 0,
+ });
+ const employee = sql.prepare(`INSERT INTO employees VALUES
+ (@id,@employeeNumber,@name,@preferredName,@tenantId,@managerId,@team,@jobTitle,@office,@employmentType,@employmentStatus,@startDate,
+ @workEmail,@workPhone,@personalEmail,@mobilePhone,@address,@emergencyName,@emergencyRelationship,@emergencyPhone,1)`);
+ const medical = sql.prepare("INSERT INTO medical_records VALUES (?,?,1)");
+ staff.forEach(
+ (
+ [
+ id,
+ name,
+ team,
+ jobTitle,
+ managerId,
+ office,
+ employmentType,
+ employmentStatus,
+ ],
+ index,
+ ) => {
+ employee.run({
+ id,
+ employeeNumber: `EMP-${String(1001 + index)}`,
+ name,
+ preferredName: name.split(" ")[0],
+ tenantId: id === "foreign" ? "tenant-b" : "tenant-a",
+ managerId,
+ team,
+ jobTitle,
+ office,
+ employmentType,
+ employmentStatus,
+ startDate: "2023-06-05",
+ workEmail: `${id}@example.test`,
+ workPhone: `+1 202 555 ${String(101 + index).padStart(4, "0")}`,
+ personalEmail: `${id}.personal@example.test`,
+ mobilePhone: `+1 202 555 ${String(201 + index).padStart(4, "0")}`,
+ address: `${12 + index} Sample Avenue, ${office}`,
+ emergencyName: `Taylor ${name.split(" ").at(-1)}`,
+ emergencyRelationship: "Family member",
+ emergencyPhone: `+1 202 555 ${String(301 + index).padStart(4, "0")}`,
+ });
+ medical.run(id, JSON.stringify(medicalFixture(index)));
+ },
+ );
+ const pkg = sql.prepare(
+ "INSERT INTO packages VALUES (@id,@scope,@fieldGroup)",
+ );
+ for (const value of accessPackages) pkg.run(value);
+}
diff --git a/p12-hr-access-governance/src/server/data/models.ts b/p12-hr-access-governance/src/server/data/models.ts
new file mode 100644
index 0000000..d54c204
--- /dev/null
+++ b/p12-hr-access-governance/src/server/data/models.ts
@@ -0,0 +1,46 @@
+import type {
+ Contact,
+ Grant,
+ Medical,
+ Profile,
+} from "../../shared/contracts.ts";
+export type Principal = Readonly<{
+ id: string;
+ name: string;
+ tenantId: string;
+ role: string;
+ administrator: number;
+ reviewer: number;
+ clinician: number;
+ version: number;
+}>;
+export type Employee = Omit &
+ Omit &
+ Readonly<{ tenantId: string }>;
+export type GrantRow = Omit<
+ Grant,
+ | "packageName"
+ | "employeeName"
+ | "requesterName"
+ | "beneficiaryName"
+ | "reviewerName"
+ | "revokerName"
+> &
+ Readonly<{ tenantId: string }>;
+export type MedicalRow = Readonly<{
+ employeeId: string;
+ value: string;
+ version: number;
+}>;
+export type MedicalData = Omit;
+export type Session = Readonly<{
+ id: string;
+ principalId: string;
+ csrfToken: string;
+ expiresAt: number;
+}>;
+export type LoginTransaction = Readonly<{
+ state: string;
+ nonce: string;
+ verifier: string;
+}>;
diff --git a/p12-hr-access-governance/src/server/data/reset.ts b/p12-hr-access-governance/src/server/data/reset.ts
new file mode 100644
index 0000000..3d5e394
--- /dev/null
+++ b/p12-hr-access-governance/src/server/data/reset.ts
@@ -0,0 +1,12 @@
+import { rmSync } from "node:fs";
+import { resolve } from "node:path";
+import { loadConfig, prepareDataDirectory } from "../config/environment.ts";
+import { Database } from "./database.ts";
+
+const config = loadConfig();
+prepareDataDirectory(config.dataDir);
+const path = resolve(config.dataDir, "hr.sqlite");
+for (const suffix of ["", "-wal", "-shm", "-journal"])
+ rmSync(path + suffix, { force: true });
+new Database(path).close();
+console.info("P12 application data reset; IdP credentials preserved.");
diff --git a/p12-hr-access-governance/src/server/data/schema.ts b/p12-hr-access-governance/src/server/data/schema.ts
new file mode 100644
index 0000000..da08666
--- /dev/null
+++ b/p12-hr-access-governance/src/server/data/schema.ts
@@ -0,0 +1,41 @@
+export const SCHEMA_VERSION = 2;
+export const schema = `
+CREATE TABLE principals (
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, tenantId TEXT NOT NULL, role TEXT NOT NULL,
+ administrator INTEGER NOT NULL, reviewer INTEGER NOT NULL, clinician INTEGER NOT NULL,
+ version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0)
+);
+CREATE TABLE employees (
+ id TEXT PRIMARY KEY, employeeNumber TEXT NOT NULL UNIQUE, name TEXT NOT NULL, preferredName TEXT NOT NULL,
+ tenantId TEXT NOT NULL, managerId TEXT NOT NULL REFERENCES principals(id),
+ team TEXT NOT NULL, jobTitle TEXT NOT NULL, office TEXT NOT NULL,
+ employmentType TEXT NOT NULL, employmentStatus TEXT NOT NULL CHECK(employmentStatus IN ('Active','On leave')),
+ startDate TEXT NOT NULL, workEmail TEXT NOT NULL, workPhone TEXT NOT NULL,
+ personalEmail TEXT NOT NULL, mobilePhone TEXT NOT NULL, address TEXT NOT NULL,
+ emergencyName TEXT NOT NULL, emergencyRelationship TEXT NOT NULL, emergencyPhone TEXT NOT NULL,
+ version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0)
+);
+CREATE TABLE medical_records (
+ employeeId TEXT PRIMARY KEY REFERENCES employees(id) ON DELETE CASCADE,
+ value TEXT NOT NULL CHECK(json_valid(value)), version INTEGER NOT NULL CHECK(version > 0)
+);
+CREATE TABLE packages (id TEXT PRIMARY KEY, scope TEXT NOT NULL, fieldGroup TEXT NOT NULL);
+CREATE TABLE grants (
+ id TEXT PRIMARY KEY, tenantId TEXT NOT NULL, packageId TEXT NOT NULL REFERENCES packages(id),
+ employeeId TEXT NOT NULL REFERENCES employees(id), requesterId TEXT NOT NULL REFERENCES principals(id),
+ beneficiaryId TEXT NOT NULL REFERENCES principals(id), scope TEXT NOT NULL, reason TEXT NOT NULL,
+ durationDays INTEGER NOT NULL CHECK(durationDays BETWEEN 1 AND 7),
+ status TEXT NOT NULL CHECK(status IN ('pending','approved','rejected','revoked','expired')),
+ createdAt INTEGER NOT NULL, reviewDeadline INTEGER NOT NULL,
+ approvedAt INTEGER, expiresAt INTEGER, reviewedBy TEXT REFERENCES principals(id), reviewedAt INTEGER,
+ reviewNote TEXT, revokedBy TEXT REFERENCES principals(id), revokedAt INTEGER,
+ version INTEGER NOT NULL CHECK(version > 0),
+ CHECK((approvedAt IS NULL AND expiresAt IS NULL) OR (approvedAt IS NOT NULL AND expiresAt > approvedAt)),
+ CHECK(reviewDeadline > createdAt)
+);
+CREATE UNIQUE INDEX active_grant ON grants(packageId,employeeId,beneficiaryId) WHERE status IN ('pending','approved');
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY, principalId TEXT NOT NULL REFERENCES principals(id), csrfToken TEXT NOT NULL, expiresAt INTEGER NOT NULL
+);
+CREATE TABLE login_transactions (id TEXT PRIMARY KEY,value TEXT NOT NULL,expiresAt INTEGER NOT NULL);
+`;
diff --git a/p12-hr-access-governance/src/server/database.ts b/p12-hr-access-governance/src/server/database.ts
deleted file mode 100644
index 264c75a..0000000
--- a/p12-hr-access-governance/src/server/database.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import Sqlite from "better-sqlite3";
-import type { Grant, GrantStatus } from "../shared/contracts.ts";
-import { accounts } from "../shared/contracts.ts";
-import { assertSqlitePath } from "./files.ts";
-export type Principal = Readonly<{
- id: string;
- name: string;
- tenantId: string;
- role: string;
- administrator: number;
- reviewer: number;
- version: number;
-}>;
-export type Employee = Readonly<{
- id: string;
- name: string;
- tenantId: string;
- managerId: string;
- team: string;
- jobTitle: string;
- workEmail: string;
- workPhone: string;
- version: number;
-}>;
-export type GrantRow = Readonly<{
- id: string;
- tenantId: string;
- packageId: string;
- employeeId: string;
- requesterId: string;
- managerId: string;
- scope: string;
- status: GrantStatus;
- expiresAt: number;
- version: number;
-}>;
-export type Session = Readonly<{
- id: string;
- principalId: string;
- csrfToken: string;
- expiresAt: number;
-}>;
-export type LoginTransaction = Readonly<{
- state: string;
- nonce: string;
- verifier: string;
-}>;
-export class Database {
- readonly sql: Sqlite.Database;
- constructor(path: string) {
- if (path !== ":memory:") assertSqlitePath(path);
- this.sql = new Sqlite(path);
- this.sql.pragma("foreign_keys = ON");
- this.sql.pragma("journal_mode = WAL");
- this.sql.pragma("busy_timeout = 3000");
- this.sql.exec(`
- CREATE TABLE IF NOT EXISTS principals (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- tenantId TEXT NOT NULL,
- role TEXT NOT NULL,
- administrator INTEGER NOT NULL,
- reviewer INTEGER NOT NULL,
- version INTEGER NOT NULL DEFAULT 1
- );
- CREATE TABLE IF NOT EXISTS employees (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- tenantId TEXT NOT NULL,
- managerId TEXT NOT NULL REFERENCES principals(id),
- team TEXT NOT NULL,
- jobTitle TEXT NOT NULL,
- workEmail TEXT NOT NULL,
- workPhone TEXT NOT NULL,
- version INTEGER NOT NULL DEFAULT 1
- );
- CREATE TABLE IF NOT EXISTS packages (
- id TEXT PRIMARY KEY,
- scope TEXT NOT NULL,
- fieldGroups TEXT NOT NULL
- );
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- principalId TEXT NOT NULL REFERENCES principals(id),
- csrfToken TEXT NOT NULL,
- expiresAt INTEGER NOT NULL
- );
- CREATE TABLE IF NOT EXISTS login_transactions (
- id TEXT PRIMARY KEY,
- value TEXT NOT NULL,
- expiresAt INTEGER NOT NULL
- );
- `);
- this.sql.transaction(() => {
- const duties = {
- lin: { administrator: 1, reviewer: 1 },
- nia: { administrator: 0, reviewer: 1 },
- ben: { administrator: 0, reviewer: 0 },
- } as const;
- const insertPrincipal = this.sql.prepare(`
- INSERT OR IGNORE INTO principals
- VALUES (@id, @name, 'tenant-a', @role, @administrator, @reviewer, 1)
- `);
- for (const account of accounts)
- insertPrincipal.run({ ...account, ...duties[account.id] });
- this.sql.exec(`
- INSERT OR IGNORE INTO principals VALUES
- ('other-manager', 'Other manager', 'tenant-a', 'Manager', 0, 0, 1),
- ('foreign-manager', 'Foreign manager', 'tenant-b', 'Manager', 0, 0, 1);
- INSERT OR IGNORE INTO employees VALUES
- ('cora', 'Cora', 'tenant-a', 'ben', 'Support', 'Support specialist',
- 'cora@example.test', '+1 202 555 0101', 1),
- ('other-team', 'Dee', 'tenant-a', 'other-manager',
- 'Operations', 'Operations specialist',
- 'dee@example.test', '+1 202 555 0102', 1),
- ('foreign', 'Eli', 'tenant-b', 'foreign-manager',
- 'Support', 'Support specialist',
- 'eli@example.test', '+1 202 555 0103', 1);
- INSERT OR IGNORE INTO packages
- VALUES ('team-support', 'current-direct-reports', 'basic,contact');
- `);
- this.migrateGrants();
- })();
- }
- private migrateGrants() {
- const columns = this.sql
- .prepare<[], { name: string }>("PRAGMA table_info(grants)")
- .all();
- if (columns.length && !columns.some(({ name }) => name === "employeeId")) {
- this.sql.exec(`
- DROP INDEX IF EXISTS active_grant;
- ALTER TABLE grants RENAME TO grants_legacy;
- `);
- }
- this.sql.exec(`
- CREATE TABLE IF NOT EXISTS grants (
- id TEXT PRIMARY KEY,
- tenantId TEXT NOT NULL,
- packageId TEXT NOT NULL REFERENCES packages(id),
- employeeId TEXT NOT NULL REFERENCES employees(id),
- requesterId TEXT NOT NULL REFERENCES principals(id),
- managerId TEXT NOT NULL REFERENCES principals(id),
- scope TEXT NOT NULL,
- status TEXT NOT NULL
- CHECK(status IN ('pending','approved','revoked','expired')),
- expiresAt INTEGER NOT NULL,
- version INTEGER NOT NULL CHECK(version > 0)
- );
- CREATE UNIQUE INDEX IF NOT EXISTS active_grant
- ON grants(packageId, employeeId, managerId)
- WHERE status IN ('pending','approved');
- `);
- const legacy = this.sql
- .prepare<[], { name: string }>(
- "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'grants_legacy'",
- )
- .get();
- if (legacy) {
- this.sql.exec(`
- INSERT INTO grants (
- id, tenantId, packageId, employeeId, requesterId, managerId,
- scope, status, expiresAt, version
- )
- SELECT id, tenantId, packageId, 'cora', requesterId, managerId,
- scope, status, expiresAt, version
- FROM grants_legacy;
- DROP TABLE grants_legacy;
- `);
- }
- }
- principal(id: string): Principal | undefined {
- return this.sql
- .prepare<[string], Principal>("SELECT * FROM principals WHERE id = ?")
- .get(id);
- }
- employee(id: string): Employee | undefined {
- return this.sql
- .prepare<[string], Employee>("SELECT * FROM employees WHERE id = ?")
- .get(id);
- }
- grant(id: string): GrantRow | undefined {
- return this.sql
- .prepare<[string], GrantRow>("SELECT * FROM grants WHERE id = ?")
- .get(id);
- }
- currentGrant(managerId: string, employeeId: string): GrantRow | undefined {
- return this.sql
- .prepare<[string, string], GrantRow>(
- `SELECT * FROM grants
- WHERE managerId = ? AND employeeId = ?
- ORDER BY rowid DESC
- LIMIT 1`,
- )
- .get(managerId, employeeId);
- }
- grantView(grant: GrantRow, now = Date.now()): Grant {
- return {
- id: grant.id,
- packageId: grant.packageId,
- employeeId: grant.employeeId,
- employeeName: this.employee(grant.employeeId)?.name ?? "Unavailable",
- requesterId: grant.requesterId,
- requesterName: this.principal(grant.requesterId)?.name ?? "Unavailable",
- managerId: grant.managerId,
- managerName: this.principal(grant.managerId)?.name ?? "Unavailable",
- scope: grant.scope,
- status: effectiveStatus(grant, now),
- expiresAt: grant.expiresAt,
- version: grant.version,
- };
- }
- session(id: string): Session | undefined {
- this.prune();
- return this.sql
- .prepare<[string], Session>("SELECT * FROM sessions WHERE id = ?")
- .get(id);
- }
- prune() {
- this.sql
- .prepare("DELETE FROM sessions WHERE expiresAt <= ?")
- .run(Date.now());
- this.sql
- .prepare("DELETE FROM login_transactions WHERE expiresAt <= ?")
- .run(Date.now());
- }
- close() {
- this.sql.close();
- }
-}
-export function effectiveStatus(grant: GrantRow, now: number): GrantStatus {
- return (grant.status === "pending" || grant.status === "approved") &&
- grant.expiresAt <= now
- ? "expired"
- : grant.status;
-}
diff --git a/p12-hr-access-governance/src/server/hr/authorization.ts b/p12-hr-access-governance/src/server/hr/authorization.ts
new file mode 100644
index 0000000..56cb4c3
--- /dev/null
+++ b/p12-hr-access-governance/src/server/hr/authorization.ts
@@ -0,0 +1,33 @@
+import type { Capability, PackageId, Profile } from "../../shared/contracts.ts";
+import type { GrantRow, Principal } from "../data/database.ts";
+export type GrantFacts = Omit;
+export type Facts = Readonly<{
+ principal: Principal;
+ employee?: Omit & { tenantId: string };
+ manager?: Principal;
+ beneficiary?: Principal;
+ grant?: GrantFacts;
+ effectiveGrant?: GrantFacts;
+ medicalVersion?: number | null;
+ package: { id: PackageId; scope: string; fieldGroup: string };
+}>;
+export type DecisionInput = Readonly<{
+ capability: Capability;
+ intent: "read" | "execute";
+ resourceId: string;
+ facts: Facts;
+ parameters: Readonly<{
+ durationDays?: number;
+ expectedVersion?: number;
+ packageId?: PackageId;
+ beneficiaryId?: string;
+ }>;
+ requestId: string;
+ now: number;
+}>;
+export type Authorize = (input: DecisionInput) => Promise;
+export const fakeAuthorize: Authorize = async (input) => {
+ console.info(
+ `P12 server | FAKE ALLOW | ${input.capability} | ${input.facts.principal.id} -> ${input.resourceId} | ${input.requestId}`,
+ );
+};
diff --git a/p12-hr-access-governance/src/server/hr/projections.ts b/p12-hr-access-governance/src/server/hr/projections.ts
new file mode 100644
index 0000000..0d7a9fe
--- /dev/null
+++ b/p12-hr-access-governance/src/server/hr/projections.ts
@@ -0,0 +1,105 @@
+import type {
+ Contact,
+ Medical,
+ MedicalEntry,
+ Profile,
+} from "../../shared/contracts.ts";
+import {
+ type Database,
+ type Employee,
+ effectiveStatus,
+ type GrantRow,
+} from "../data/database.ts";
+import type { MedicalData } from "../data/models.ts";
+import { missing } from "../http/errors.ts";
+import type { Facts, GrantFacts } from "./authorization.ts";
+
+export function employeeFacts(employee: Employee) {
+ const {
+ id,
+ employeeNumber,
+ name,
+ preferredName,
+ tenantId,
+ managerId,
+ team,
+ jobTitle,
+ office,
+ employmentType,
+ employmentStatus,
+ startDate,
+ version,
+ } = employee;
+ return {
+ id,
+ employeeNumber,
+ name,
+ preferredName,
+ tenantId,
+ managerId,
+ team,
+ jobTitle,
+ office,
+ employmentType,
+ employmentStatus,
+ startDate,
+ version,
+ };
+}
+export function grantFacts(grant: GrantRow): GrantFacts {
+ const { reason: _, reviewNote: __, ...facts } = grant;
+ return { ...facts, status: effectiveStatus(grant, Date.now()) };
+}
+export function profileProjection({ employee, manager }: Facts): Profile {
+ if (!employee || !manager) throw missing();
+ const { tenantId: _, ...fields } = employee;
+ return { ...fields, managerName: manager.name };
+}
+export function contactProjection(db: Database, id: string): Contact {
+ const employee = db.employee(id);
+ if (!employee) throw missing();
+ const {
+ workEmail,
+ workPhone,
+ personalEmail,
+ mobilePhone,
+ address,
+ emergencyName,
+ emergencyRelationship,
+ emergencyPhone,
+ version,
+ } = employee;
+ return {
+ id,
+ workEmail,
+ workPhone,
+ personalEmail,
+ mobilePhone,
+ address,
+ emergencyName,
+ emergencyRelationship,
+ emergencyPhone,
+ version,
+ };
+}
+function entries(rows: readonly MedicalEntry[]): readonly MedicalEntry[] {
+ return rows.map(({ date, title, details }) => ({ date, title, details }));
+}
+export function medicalProjection(db: Database, id: string): Medical {
+ const row = db.medical(id);
+ if (!row) throw missing();
+ const value = JSON.parse(row.value) as MedicalData;
+ return {
+ id,
+ dateOfBirth: value.dateOfBirth,
+ bloodGroup: value.bloodGroup,
+ allergies: value.allergies.map((entry) => String(entry)),
+ conditions: entries(value.conditions),
+ medications: entries(value.medications),
+ consultations: entries(value.consultations),
+ testResults: entries(value.testResults),
+ vaccinations: entries(value.vaccinations),
+ assessments: entries(value.assessments),
+ version: row.version,
+ };
+}
diff --git a/p12-hr-access-governance/src/server/hr/service.ts b/p12-hr-access-governance/src/server/hr/service.ts
new file mode 100644
index 0000000..02b23b8
--- /dev/null
+++ b/p12-hr-access-governance/src/server/hr/service.ts
@@ -0,0 +1,498 @@
+import { randomUUID } from "node:crypto";
+import {
+ type AccessOptions,
+ accessPackages,
+ accounts,
+ type Capability,
+ type Contact,
+ type Grant,
+ type GrantOperation,
+ type GrantRequest,
+ type Medical,
+ type Profile,
+} from "../../shared/contracts.ts";
+import type { Database, GrantRow } from "../data/database.ts";
+import {
+ AppError,
+ activeGrantExists,
+ conflict,
+ identifier,
+ integer,
+ missing,
+ text,
+ unauthorized,
+} from "../http/errors.ts";
+import type { Authorize, DecisionInput, Facts } from "./authorization.ts";
+import {
+ contactProjection,
+ employeeFacts,
+ grantFacts,
+ medicalProjection,
+ profileProjection,
+} from "./projections.ts";
+
+class ChangedFacts extends Error {}
+const day = 86_400_000;
+export class HrService {
+ readonly db: Database;
+ readonly authorize: Authorize;
+ readonly requestId: string;
+ constructor(db: Database, authorize: Authorize, requestId = randomUUID()) {
+ this.db = db;
+ this.authorize = authorize;
+ this.requestId = requestId;
+ }
+ private snapshot(
+ actorId: string,
+ capability: Capability,
+ intent: "read" | "execute",
+ target: string,
+ parameters: DecisionInput["parameters"] = {},
+ ): Facts {
+ const principal = this.db.principal(actorId);
+ if (!principal) throw unauthorized();
+ const collection = target === ":collection" && intent === "read";
+ const governance =
+ capability.startsWith("grant.") &&
+ !(capability === "grant.request" && intent === "execute") &&
+ !collection;
+ const grant = governance ? this.db.grant(target) : undefined;
+ if (governance && (!grant || grant.tenantId !== principal.tenantId))
+ throw missing();
+ const packageId =
+ grant?.packageId ??
+ parameters.packageId ??
+ (capability === "employee.medical.view"
+ ? "occupational-health"
+ : "team-support");
+ const pkg = accessPackages.find((value) => value.id === packageId);
+ if (!pkg)
+ throw new AppError(
+ 400,
+ "INVALID_INPUT",
+ "Choose an available access package.",
+ );
+ const base: Facts = { principal, package: pkg };
+ if (collection) return base;
+ const employee = this.db.employee(grant?.employeeId ?? target);
+ const manager = employee
+ ? this.db.principal(employee.managerId)
+ : undefined;
+ if (
+ !employee ||
+ employee.tenantId !== principal.tenantId ||
+ !manager ||
+ manager.tenantId !== principal.tenantId
+ )
+ throw missing();
+ const beneficiaryId =
+ grant?.beneficiaryId ??
+ (capability === "grant.request" && intent === "execute"
+ ? pkg.id === "team-support"
+ ? employee.managerId
+ : parameters.beneficiaryId
+ : actorId);
+ const beneficiary = beneficiaryId
+ ? this.db.principal(beneficiaryId)
+ : undefined;
+ if (!beneficiary || beneficiary.tenantId !== principal.tenantId)
+ throw missing();
+ if (
+ capability === "grant.request" &&
+ pkg.id === "team-support" &&
+ parameters.beneficiaryId &&
+ parameters.beneficiaryId !== employee.managerId
+ )
+ throw new AppError(
+ 400,
+ "INVALID_INPUT",
+ "Contact access is requested for the employee's current manager.",
+ );
+ const effectiveGrant = this.db.currentGrant(
+ beneficiary.id,
+ employee.id,
+ pkg.id,
+ );
+ return {
+ ...base,
+ employee: employeeFacts(employee),
+ manager,
+ beneficiary,
+ ...(grant ? { grant: grantFacts(grant) } : {}),
+ ...(effectiveGrant ? { effectiveGrant: grantFacts(effectiveGrant) } : {}),
+ ...(capability === "employee.medical.view"
+ ? { medicalVersion: this.db.medicalVersion(employee.id) }
+ : {}),
+ };
+ }
+ private async effect(
+ actor: string,
+ capability: Capability,
+ intent: "read" | "execute",
+ target: string,
+ effect: (facts: Facts) => T,
+ parameters: DecisionInput["parameters"] = {},
+ ): Promise {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const facts = this.snapshot(
+ actor,
+ capability,
+ intent,
+ target,
+ parameters,
+ );
+ await this.authorize({
+ capability,
+ intent,
+ resourceId: target,
+ facts,
+ parameters,
+ requestId: this.requestId,
+ now: Date.now(),
+ });
+ try {
+ return this.db.sql
+ .transaction(() => {
+ const current = this.snapshot(
+ actor,
+ capability,
+ intent,
+ target,
+ parameters,
+ );
+ if (JSON.stringify(current) !== JSON.stringify(facts))
+ throw new ChangedFacts();
+ return effect(current);
+ })
+ .immediate();
+ } catch (error) {
+ if (!(error instanceof ChangedFacts)) throw error;
+ }
+ }
+ throw conflict();
+ }
+ async request(actor: string, input: GrantRequest): Promise {
+ const employeeId = identifier(input.employeeId);
+ const days = integer(input.days, 7);
+ const reason = text(
+ input.reason,
+ 10,
+ 500,
+ "Explain why this access is needed (10–500 characters).",
+ );
+ const beneficiaryId =
+ input.beneficiaryId === undefined
+ ? undefined
+ : identifier(input.beneficiaryId);
+ return this.effect(
+ actor,
+ "grant.request",
+ "execute",
+ employeeId,
+ (facts) => {
+ if (!facts.employee || !facts.beneficiary) throw missing();
+ const now = Date.now();
+ this.db.sql
+ .prepare(`UPDATE grants SET status = 'expired', version = version + 1
+ WHERE packageId = ? AND beneficiaryId = ? AND employeeId = ?
+ AND ((status = 'pending' AND reviewDeadline <= ?) OR (status = 'approved' AND expiresAt <= ?))`)
+ .run(facts.package.id, facts.beneficiary.id, employeeId, now, now);
+ const active = this.db.sql
+ .prepare(
+ `SELECT id FROM grants WHERE packageId = ? AND beneficiaryId = ? AND employeeId = ? AND status IN ('pending','approved')`,
+ )
+ .get(facts.package.id, facts.beneficiary.id, employeeId);
+ if (active)
+ throw activeGrantExists(facts.beneficiary.name, facts.employee.name);
+ const grant: GrantRow = {
+ id: `grant_${randomUUID()}`,
+ tenantId: facts.principal.tenantId,
+ packageId: facts.package.id,
+ employeeId,
+ requesterId: actor,
+ beneficiaryId: facts.beneficiary.id,
+ scope: facts.package.scope,
+ reason,
+ durationDays: days,
+ status: "pending",
+ createdAt: now,
+ reviewDeadline: now + day,
+ approvedAt: null,
+ expiresAt: null,
+ reviewedBy: null,
+ reviewedAt: null,
+ reviewNote: null,
+ revokedBy: null,
+ revokedAt: null,
+ version: 1,
+ };
+ this.db.sql
+ .prepare(`INSERT INTO grants VALUES (
+ @id,@tenantId,@packageId,@employeeId,@requesterId,@beneficiaryId,@scope,@reason,@durationDays,@status,
+ @createdAt,@reviewDeadline,@approvedAt,@expiresAt,@reviewedBy,@reviewedAt,@reviewNote,@revokedBy,@revokedAt,@version)`)
+ .run(grant);
+ return this.db.grantView(grant);
+ },
+ {
+ durationDays: days,
+ packageId: input.packageId,
+ ...(beneficiaryId ? { beneficiaryId } : {}),
+ },
+ );
+ }
+ async transition(
+ actor: string,
+ id: string,
+ version: number,
+ operation: GrantOperation,
+ note = "",
+ ): Promise {
+ integer(version);
+ const reviewNote =
+ operation === "reject"
+ ? text(
+ note,
+ 3,
+ 500,
+ "Give a reason for declining this request (3–500 characters).",
+ )
+ : null;
+ const capability = {
+ approve: "grant.approve",
+ reject: "grant.reject",
+ revoke: "grant.revoke",
+ } as const;
+ return this.effect(
+ actor,
+ capability[operation],
+ "execute",
+ id,
+ ({ grant }) => {
+ const expectedStatus = operation === "revoke" ? "approved" : "pending";
+ if (
+ !grant ||
+ grant.version !== version ||
+ grant.status !== expectedStatus
+ )
+ throw conflict();
+ const current = this.db.grant(id);
+ if (!current) throw missing();
+ const now = Date.now();
+ const next: GrantRow = {
+ ...current,
+ version: version + 1,
+ status:
+ operation === "approve"
+ ? "approved"
+ : operation === "reject"
+ ? "rejected"
+ : "revoked",
+ ...(operation === "revoke"
+ ? { revokedBy: actor, revokedAt: now }
+ : {
+ reviewedBy: actor,
+ reviewedAt: now,
+ reviewNote,
+ ...(operation === "approve"
+ ? {
+ approvedAt: now,
+ expiresAt: now + current.durationDays * day,
+ }
+ : {}),
+ }),
+ };
+ const updated = this.db.sql
+ .prepare(`UPDATE grants SET status=@status,version=@version,
+ approvedAt=@approvedAt,expiresAt=@expiresAt,reviewedBy=@reviewedBy,reviewedAt=@reviewedAt,
+ reviewNote=@reviewNote,revokedBy=@revokedBy,revokedAt=@revokedAt
+ WHERE id=@id AND version=@expectedVersion AND status=@expectedStatus
+ AND CASE WHEN status='pending' THEN reviewDeadline ELSE expiresAt END > @now`)
+ .run({ ...next, expectedVersion: version, expectedStatus, now });
+ if (updated.changes !== 1) throw conflict();
+ return this.db.grantView(next);
+ },
+ { expectedVersion: version },
+ );
+ }
+ private async list(
+ actor: string,
+ capability: Capability,
+ table: "grants" | "employees",
+ project: (facts: Facts) => T,
+ activeEmployeeId?: string,
+ ): Promise {
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const collection = this.snapshot(
+ actor,
+ capability,
+ "read",
+ ":collection",
+ );
+ const now = Date.now();
+ const ids =
+ table === "grants"
+ ? this.db.sql
+ .prepare<
+ [
+ string,
+ string | null,
+ string | null,
+ number,
+ number,
+ number,
+ number,
+ ],
+ { id: string }
+ >(`SELECT id FROM grants
+ WHERE tenantId=? AND (? IS NULL OR (employeeId=? AND ((status='pending' AND reviewDeadline>?) OR (status='approved' AND expiresAt>?))))
+ ORDER BY CASE WHEN (status='pending' AND reviewDeadline>?) OR (status='approved' AND expiresAt>?) THEN 0 ELSE 1 END,
+ createdAt DESC,rowid DESC LIMIT 100`)
+ .all(
+ collection.principal.tenantId,
+ activeEmployeeId ?? null,
+ activeEmployeeId ?? null,
+ now,
+ now,
+ now,
+ now,
+ )
+ : this.db.sql
+ .prepare<[string], { id: string }>(
+ "SELECT id FROM employees WHERE tenantId=? ORDER BY name LIMIT 100",
+ )
+ .all(collection.principal.tenantId);
+ await this.authorize({
+ capability,
+ intent: "read",
+ resourceId: "collection",
+ facts: collection,
+ parameters: {},
+ requestId: this.requestId,
+ now,
+ });
+ const candidates = ids.map(({ id }) => ({
+ id,
+ facts: this.snapshot(actor, capability, "read", id),
+ }));
+ const allowed: typeof candidates = [];
+ for (const candidate of candidates) {
+ try {
+ await this.authorize({
+ capability,
+ intent: "read",
+ resourceId: candidate.id,
+ facts: candidate.facts,
+ parameters: {},
+ requestId: this.requestId,
+ now: Date.now(),
+ });
+ allowed.push(candidate);
+ } catch (error) {
+ if (!(error instanceof AppError && error.status === 403)) throw error;
+ }
+ }
+ try {
+ return this.db.sql
+ .transaction(() => {
+ if (
+ JSON.stringify(
+ this.snapshot(actor, capability, "read", ":collection"),
+ ) !== JSON.stringify(collection)
+ )
+ throw new ChangedFacts();
+ for (const candidate of candidates)
+ if (
+ JSON.stringify(
+ this.snapshot(actor, capability, "read", candidate.id),
+ ) !== JSON.stringify(candidate.facts)
+ )
+ throw new ChangedFacts();
+ return allowed.map(({ facts }) => project(facts));
+ })
+ .immediate();
+ } catch (error) {
+ if (!(error instanceof ChangedFacts)) throw error;
+ }
+ }
+ throw conflict();
+ }
+ grants(
+ actor: string,
+ view: "requests" | "review",
+ activeEmployeeId?: string,
+ ): Promise {
+ return this.list(
+ actor,
+ view === "requests" ? "grant.request" : "grant.approve",
+ "grants",
+ ({ grant }) => {
+ if (!grant) throw missing();
+ const row = this.db.grant(grant.id);
+ if (!row) throw missing();
+ return this.db.grantView(row);
+ },
+ activeEmployeeId,
+ );
+ }
+ grant(
+ actor: string,
+ id: string,
+ view: "requests" | "review",
+ ): Promise {
+ return this.effect(
+ actor,
+ view === "requests" ? "grant.request" : "grant.approve",
+ "read",
+ id,
+ () => {
+ const row = this.db.grant(id);
+ if (!row) throw missing();
+ return this.db.grantView(row);
+ },
+ );
+ }
+ options(actor: string): Promise {
+ return this.effect(
+ actor,
+ "grant.request",
+ "read",
+ ":collection",
+ ({ principal }) => {
+ const beneficiaries = this.db.sql
+ .prepare<[string], { id: string; name: string; role: string }>(
+ "SELECT id,name,role FROM principals WHERE tenantId=? ORDER BY name",
+ )
+ .all(principal.tenantId)
+ .filter(({ id }) => accounts.some((account) => account.id === id));
+ return { packages: accessPackages, beneficiaries };
+ },
+ );
+ }
+ employees(actor: string): Promise {
+ return this.list(
+ actor,
+ "employee.profile.view",
+ "employees",
+ profileProjection,
+ );
+ }
+ profile(actor: string, id: string): Promise {
+ return this.effect(
+ actor,
+ "employee.profile.view",
+ "read",
+ id,
+ profileProjection,
+ );
+ }
+ contact(actor: string, id: string): Promise {
+ return this.effect(actor, "employee.contact.view", "read", id, () =>
+ contactProjection(this.db, id),
+ );
+ }
+ medical(actor: string, id: string): Promise {
+ return this.effect(actor, "employee.medical.view", "read", id, () =>
+ medicalProjection(this.db, id),
+ );
+ }
+}
diff --git a/p12-hr-access-governance/src/server/errors.ts b/p12-hr-access-governance/src/server/http/errors.ts
similarity index 74%
rename from p12-hr-access-governance/src/server/errors.ts
rename to p12-hr-access-governance/src/server/http/errors.ts
index 946d539..95a5422 100644
--- a/p12-hr-access-governance/src/server/errors.ts
+++ b/p12-hr-access-governance/src/server/http/errors.ts
@@ -15,7 +15,7 @@ export const activeGrantExists = (managerName: string, employeeName: string) =>
new AppError(
409,
"ACTIVE_GRANT_EXISTS",
- `${managerName} already has active access to ${employeeName}.`,
+ `A pending request or approved grant already exists for ${managerName} and ${employeeName}.`,
);
export const denied = () =>
new AppError(403, "DENIED", "Access is not allowed.");
@@ -53,3 +53,21 @@ export function identifier(value: unknown): string {
throw new AppError(400, "INVALID_INPUT", "A valid identifier is required.");
return value;
}
+export function text(
+ value: unknown,
+ min: number,
+ max: number,
+ message: string,
+): string {
+ if (
+ typeof value !== "string" ||
+ value.trim().length < min ||
+ value.trim().length > max ||
+ Array.from(value).some((character) => {
+ const code = character.codePointAt(0) ?? 0;
+ return (code < 32 && ![9, 10, 13].includes(code)) || code === 127;
+ })
+ )
+ throw new AppError(400, "INVALID_INPUT", message);
+ return value.trim();
+}
diff --git a/p12-hr-access-governance/src/server/main.ts b/p12-hr-access-governance/src/server/main.ts
index 8ac9763..d154efc 100644
--- a/p12-hr-access-governance/src/server/main.ts
+++ b/p12-hr-access-governance/src/server/main.ts
@@ -1,8 +1,8 @@
import { resolve } from "node:path";
import { createApp } from "./app.ts";
-import { loadConfig, prepareDataDirectory } from "./config.ts";
-import { Database } from "./database.ts";
-import { createOidc } from "./oidc.ts";
+import { createOidc } from "./auth/oidc.ts";
+import { loadConfig, prepareDataDirectory } from "./config/environment.ts";
+import { Database } from "./data/database.ts";
console.info("P12 authorization: FAKE ALLOW; Cedarling is not called.");
diff --git a/p12-hr-access-governance/src/server/service.ts b/p12-hr-access-governance/src/server/service.ts
deleted file mode 100644
index c2eb4d0..0000000
--- a/p12-hr-access-governance/src/server/service.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import { randomUUID } from "node:crypto";
-import type {
- Capability,
- Contact,
- Grant,
- Profile,
-} from "../shared/contracts.ts";
-import type { Authorize, Facts } from "./authorization.ts";
-import { type Database, effectiveStatus } from "./database.ts";
-import {
- AppError,
- activeGrantExists,
- conflict,
- integer,
- missing,
- unauthorized,
-} from "./errors.ts";
-
-class ChangedFacts extends Error {}
-export class HrService {
- readonly db: Database;
- readonly authorize: Authorize;
- readonly requestId: string;
- constructor(db: Database, authorize: Authorize, requestId = randomUUID()) {
- this.db = db;
- this.authorize = authorize;
- this.requestId = requestId;
- }
- private snapshot(
- actorId: string,
- capability: Capability,
- intent: "read" | "execute",
- target: string,
- ): Facts {
- const principal = this.db.principal(actorId);
- if (!principal) throw unauthorized();
- const pkg = this.db.sql
- .prepare<[], Facts["package"]>(
- "SELECT * FROM packages WHERE id = 'team-support'",
- )
- .get();
- if (!pkg) throw missing();
- const base: Facts = { principal, package: pkg };
- if (target === ":collection" && intent === "read") return base;
- if (capability === "grant.request" && intent === "execute") {
- const employee = this.db.employee(target);
- const manager = employee
- ? this.db.principal(employee.managerId)
- : undefined;
- if (
- !employee ||
- employee.tenantId !== principal.tenantId ||
- !manager ||
- manager.tenantId !== principal.tenantId
- )
- throw missing();
- const grant = this.db.currentGrant(manager.id, employee.id);
- return {
- ...base,
- employee,
- manager,
- ...(grant
- ? {
- effectiveGrant: {
- ...grant,
- status: effectiveStatus(grant, Date.now()),
- },
- }
- : {}),
- };
- }
- if (capability.startsWith("grant.")) {
- const grant = this.db.grant(target);
- if (!grant || grant.tenantId !== principal.tenantId) throw missing();
- const employee = this.db.employee(grant.employeeId);
- const manager = this.db.principal(grant.managerId);
- if (!employee || !manager) throw missing();
- return {
- ...base,
- employee,
- manager,
- grant: { ...grant, status: effectiveStatus(grant, Date.now()) },
- };
- }
- const employee = this.db.employee(target);
- if (!employee || employee.tenantId !== principal.tenantId) throw missing();
- const manager = this.db.principal(employee.managerId);
- if (!manager) throw missing();
- const grant = this.db.currentGrant(actorId, employee.id);
- return {
- ...base,
- employee,
- manager,
- ...(grant
- ? {
- effectiveGrant: {
- ...grant,
- status: effectiveStatus(grant, Date.now()),
- },
- }
- : {}),
- };
- }
- private async effect(
- actor: string,
- capability: Capability,
- intent: "read" | "execute",
- target: string,
- effect: (facts: Facts) => T,
- parameters: Readonly<{
- durationDays?: number;
- expectedVersion?: number;
- }> = {},
- ): Promise {
- for (let attempt = 0; attempt < 2; attempt++) {
- const facts = this.snapshot(actor, capability, intent, target);
- await this.authorize({
- capability,
- intent,
- resourceId: target,
- facts,
- parameters,
- requestId: this.requestId,
- now: Date.now(),
- });
- try {
- return this.db.sql
- .transaction(() => {
- const current = this.snapshot(actor, capability, intent, target);
- if (JSON.stringify(current) !== JSON.stringify(facts))
- throw new ChangedFacts();
- return effect(current);
- })
- .immediate();
- } catch (error) {
- if (!(error instanceof ChangedFacts)) throw error;
- }
- }
- throw conflict();
- }
- async request(actor: string, employee: string, days: number): Promise {
- integer(days, 7);
- return this.effect(
- actor,
- "grant.request",
- "execute",
- employee,
- (facts) => {
- if (!facts.employee || !facts.manager) throw missing();
- const now = Date.now();
- this.db.sql
- .prepare(
- `UPDATE grants
- SET status = 'expired', version = version + 1
- WHERE packageId = ?
- AND managerId = ?
- AND employeeId = ?
- AND status IN ('pending','approved')
- AND expiresAt <= ?`,
- )
- .run(facts.package.id, facts.manager.id, employee, now);
- if (
- this.db.sql
- .prepare(
- `SELECT id FROM grants
- WHERE packageId = ?
- AND managerId = ?
- AND employeeId = ?
- AND status IN ('pending','approved')`,
- )
- .get(facts.package.id, facts.manager.id, employee)
- )
- throw activeGrantExists(facts.manager.name, facts.employee.name);
- const grant = {
- id: `grant_${randomUUID()}`,
- tenantId: facts.principal.tenantId,
- packageId: facts.package.id,
- employeeId: employee,
- requesterId: actor,
- managerId: facts.manager.id,
- scope: facts.package.scope,
- status: "pending" as const,
- expiresAt: now + days * 86400000,
- version: 1,
- };
- this.db.sql
- .prepare(
- `INSERT INTO grants VALUES (
- @id, @tenantId, @packageId, @employeeId, @requesterId, @managerId,
- @scope, @status, @expiresAt, @version
- )`,
- )
- .run(grant);
- return this.db.grantView(grant);
- },
- { durationDays: days },
- );
- }
- async transition(
- actor: string,
- id: string,
- version: number,
- operation: "approve" | "revoke",
- ): Promise {
- integer(version);
- return this.effect(
- actor,
- operation === "approve" ? "grant.approve" : "grant.revoke",
- "execute",
- id,
- ({ grant }) => {
- if (
- !grant ||
- grant.version !== version ||
- grant.status !== (operation === "approve" ? "pending" : "approved")
- )
- throw conflict();
- const status = operation === "approve" ? "approved" : "revoked";
- const changed = this.db.sql
- .prepare(
- `UPDATE grants
- SET status = ?, version = version + 1
- WHERE id = ?
- AND version = ?
- AND status = ?
- AND expiresAt > ?`,
- )
- .run(status, id, version, grant.status, Date.now());
- if (changed.changes !== 1) throw conflict();
- return this.db.grantView({ ...grant, status, version: version + 1 });
- },
- { expectedVersion: version },
- );
- }
- private async list(
- actor: string,
- capability: Capability,
- table: "grants" | "employees",
- project: (facts: Facts) => T,
- ): Promise {
- for (let attempt = 0; attempt < 2; attempt++) {
- const collection = this.snapshot(
- actor,
- capability,
- "read",
- ":collection",
- );
- const ids = this.db.sql
- .prepare<[string], { id: string }>(
- `SELECT id FROM ${table}
- WHERE tenantId = ?
- ORDER BY id
- LIMIT 100`,
- )
- .all(collection.principal.tenantId);
- await this.authorize({
- capability,
- intent: "read",
- resourceId: "collection",
- facts: collection,
- parameters: {},
- requestId: this.requestId,
- now: Date.now(),
- });
- const candidates = ids.map(({ id }) => ({
- id,
- facts: this.snapshot(actor, capability, "read", id),
- }));
- const allowed: typeof candidates = [];
- for (const candidate of candidates) {
- try {
- await this.authorize({
- capability,
- intent: "read",
- resourceId: candidate.id,
- facts: candidate.facts,
- parameters: {},
- requestId: this.requestId,
- now: Date.now(),
- });
- allowed.push(candidate);
- } catch (error) {
- if (!(error instanceof AppError && error.status === 403)) throw error;
- }
- }
- try {
- return this.db.sql
- .transaction(() => {
- if (
- JSON.stringify(
- this.snapshot(actor, capability, "read", ":collection"),
- ) !== JSON.stringify(collection)
- )
- throw new ChangedFacts();
- for (const candidate of candidates)
- if (
- JSON.stringify(
- this.snapshot(actor, capability, "read", candidate.id),
- ) !== JSON.stringify(candidate.facts)
- )
- throw new ChangedFacts();
- return allowed.map(({ facts }) => project(facts));
- })
- .immediate();
- } catch (error) {
- if (!(error instanceof ChangedFacts)) throw error;
- }
- }
- throw conflict();
- }
- grants(actor: string, view: "requests" | "review"): Promise {
- return this.list(
- actor,
- view === "requests" ? "grant.request" : "grant.approve",
- "grants",
- ({ grant }) => {
- if (!grant) throw missing();
- return this.db.grantView(grant);
- },
- );
- }
- grant(
- actor: string,
- id: string,
- view: "requests" | "review",
- ): Promise {
- return this.effect(
- actor,
- view === "requests" ? "grant.request" : "grant.approve",
- "read",
- id,
- ({ grant }) => {
- if (!grant) throw missing();
- return this.db.grantView(grant);
- },
- );
- }
- employees(actor: string): Promise {
- return this.list(
- actor,
- "employee.profile.view",
- "employees",
- profileProjection,
- );
- }
- profile(actor: string, id: string): Promise {
- return this.effect(
- actor,
- "employee.profile.view",
- "read",
- id,
- profileProjection,
- );
- }
- contact(actor: string, id: string): Promise {
- return this.effect(
- actor,
- "employee.contact.view",
- "read",
- id,
- ({ employee }) => {
- if (!employee) throw missing();
- const { id, workEmail, workPhone, version } = employee;
- return { id, workEmail, workPhone, version };
- },
- );
- }
-}
-
-function profileProjection({ employee, manager }: Facts): Profile {
- if (!employee || !manager) throw missing();
- const { id, name, managerId, team, jobTitle, version } = employee;
- return {
- id,
- name,
- managerId,
- managerName: manager.name,
- team,
- jobTitle,
- version,
- };
-}
diff --git a/p12-hr-access-governance/src/shared/contracts.ts b/p12-hr-access-governance/src/shared/contracts.ts
index f6ef631..08f4833 100644
--- a/p12-hr-access-governance/src/shared/contracts.ts
+++ b/p12-hr-access-governance/src/shared/contracts.ts
@@ -1,46 +1,124 @@
export const capabilities = {
"grant.request": "HR::RequestGrant",
"grant.approve": "HR::ApproveGrant",
+ "grant.reject": "HR::RejectGrant",
"grant.revoke": "HR::RevokeGrant",
"employee.profile.view": "HR::ViewProfile",
"employee.contact.view": "HR::ViewContact",
+ "employee.medical.view": "HR::ViewMedical",
} as const;
export type Capability = keyof typeof capabilities;
export const accounts = [
{ id: "lin", name: "Lin", role: "HR administrator · Reviewer" },
{ id: "nia", name: "Nia", role: "Reviewer" },
{ id: "ben", name: "Ben", role: "Manager" },
+ { id: "aya", name: "Dr. Aya", role: "Occupational-health clinician" },
] as const;
-export type GrantStatus = "pending" | "approved" | "revoked" | "expired";
+export const accessPackages = [
+ {
+ id: "team-support",
+ name: "Contact details",
+ scope: "current-direct-reports",
+ fieldGroup: "contact",
+ },
+ {
+ id: "occupational-health",
+ name: "Medical records",
+ scope: "occupational-health-case",
+ fieldGroup: "medical",
+ },
+] as const;
+export type PackageId = (typeof accessPackages)[number]["id"];
+export type GrantStatus =
+ | "pending"
+ | "approved"
+ | "rejected"
+ | "revoked"
+ | "expired";
+export type GrantOperation = "approve" | "reject" | "revoke";
export type Grant = Readonly<{
id: string;
- packageId: string;
+ packageId: PackageId;
+ packageName: string;
employeeId: string;
employeeName: string;
requesterId: string;
requesterName: string;
- managerId: string;
- managerName: string;
+ beneficiaryId: string;
+ beneficiaryName: string;
scope: string;
+ reason: string;
+ durationDays: number;
status: GrantStatus;
- expiresAt: number;
+ createdAt: number;
+ reviewDeadline: number;
+ approvedAt: number | null;
+ expiresAt: number | null;
+ reviewedBy: string | null;
+ reviewerName: string | null;
+ reviewedAt: number | null;
+ reviewNote: string | null;
+ revokedBy: string | null;
+ revokerName: string | null;
+ revokedAt: number | null;
version: number;
}>;
export type Profile = Readonly<{
id: string;
+ employeeNumber: string;
name: string;
+ preferredName: string;
managerId: string;
managerName: string;
team: string;
jobTitle: string;
+ office: string;
+ employmentType: string;
+ employmentStatus: "Active" | "On leave";
+ startDate: string;
version: number;
}>;
export type Contact = Readonly<{
id: string;
workEmail: string;
workPhone: string;
+ personalEmail: string;
+ mobilePhone: string;
+ address: string;
+ emergencyName: string;
+ emergencyRelationship: string;
+ emergencyPhone: string;
version: number;
}>;
+export type MedicalEntry = Readonly<{
+ date: string;
+ title: string;
+ details: string;
+}>;
+export type Medical = Readonly<{
+ id: string;
+ dateOfBirth: string;
+ bloodGroup: string;
+ allergies: readonly string[];
+ conditions: readonly MedicalEntry[];
+ medications: readonly MedicalEntry[];
+ consultations: readonly MedicalEntry[];
+ testResults: readonly MedicalEntry[];
+ vaccinations: readonly MedicalEntry[];
+ assessments: readonly MedicalEntry[];
+ version: number;
+}>;
+export type AccessOptions = Readonly<{
+ packages: typeof accessPackages;
+ beneficiaries: readonly { id: string; name: string; role: string }[];
+}>;
+export type GrantRequest = Readonly<{
+ employeeId: string;
+ packageId: PackageId;
+ days: number;
+ reason: string;
+ beneficiaryId?: string;
+}>;
export type SessionView = Readonly<{
user: { id: string; name: string; role: string };
csrfToken: string;
diff --git a/p12-hr-access-governance/src/web/App.tsx b/p12-hr-access-governance/src/web/App.tsx
index 3cba273..d1a3936 100644
--- a/p12-hr-access-governance/src/web/App.tsx
+++ b/p12-hr-access-governance/src/web/App.tsx
@@ -1,811 +1,9 @@
-import {
- type FormEvent,
- type ReactNode,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react";
-import type {
- Contact,
- Grant,
- Profile,
- SessionView,
-} from "../shared/contracts.ts";
-import { accounts } from "../shared/contracts.ts";
-import { ApiError, api, mutate, type Remote, useRemote } from "./api.ts";
-import mark from "./assets/cedarling-mark.png";
-import wordmark from "./assets/cedarling-wordmark-dark.webp";
-import { Icon } from "./Icons.tsx";
+import { type ComponentProps, useState } from "react";
+import { createBrowserRouter, RouterProvider } from "react-router";
+import { routes } from "./routes.tsx";
-function Shell({
- children,
- session,
- switchAccount,
-}: {
- children: ReactNode;
- session?: SessionView;
- switchAccount?: () => void;
-}) {
- return (
-
-