diff --git a/.dockerignore b/.dockerignore index ee361f2..3bf2ed9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,3 +17,4 @@ **/.DS_Store **/Thumbs.db **/assets/wireframes +**/*.log diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 92bba6f..af7747c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,7 +16,7 @@ updates: - /p11-saas-workspace - /p12-hr-access-governance - /p13-student-records - - /p14-ai-scheduling-assistant + - /p14-capital-district - /p15-marketplace - /shared/identity-provider schedule: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index c59ebd7..7753740 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -100,11 +100,11 @@ jobs: pnpm install --frozen-lockfile pnpm build - name: Install browser and Linux dependencies - if: (matrix.project == 'p1-task-manager' || matrix.project == 'p4-editorial-publishing' || matrix.project == 'p5-dataguard' || matrix.project == 'p6-field-inspection' || matrix.project == 'p7-collaborative-docs' || matrix.project == 'p10-warehouse-workloads' || matrix.project == 'p11-saas-workspace' || matrix.project == 'p14-ai-scheduling-assistant') && runner.os == 'Linux' + if: (matrix.project == 'p1-task-manager' || matrix.project == 'p4-editorial-publishing' || matrix.project == 'p5-dataguard' || matrix.project == 'p6-field-inspection' || matrix.project == 'p7-collaborative-docs' || matrix.project == 'p10-warehouse-workloads' || matrix.project == 'p11-saas-workspace' || matrix.project == 'p14-capital-district' || matrix.project == 'p15-marketplace') && runner.os == 'Linux' working-directory: ${{ matrix.project }} run: pnpm exec playwright install --with-deps chromium - name: Install browser - if: (matrix.project == 'p1-task-manager' || matrix.project == 'p4-editorial-publishing' || matrix.project == 'p5-dataguard' || matrix.project == 'p6-field-inspection' || matrix.project == 'p7-collaborative-docs' || matrix.project == 'p10-warehouse-workloads' || matrix.project == 'p14-ai-scheduling-assistant') && runner.os != 'Linux' + if: (matrix.project == 'p1-task-manager' || matrix.project == 'p4-editorial-publishing' || matrix.project == 'p5-dataguard' || matrix.project == 'p6-field-inspection' || matrix.project == 'p7-collaborative-docs' || matrix.project == 'p10-warehouse-workloads' || matrix.project == 'p14-capital-district' || matrix.project == 'p15-marketplace') && runner.os != 'Linux' working-directory: ${{ matrix.project }} run: pnpm exec playwright install chromium - name: Check diff --git a/.gitignore b/.gitignore index bccc432..4257795 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ *.log .DS_Store Thumbs.db +!/p14-capital-district/.dockerignore diff --git a/README.md b/README.md index ca2f4d4..dae228b 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Project links open the setup guides; article links open the published tutorials. | [P11 - Securing Active-Tenant Switching in a SaaS Workspace with Cedarling](./p11-saas-workspace/) | Node.js, React Router Framework Mode, Express, PostgreSQL | Reevaluate access as users switch tenants, accept invitations, or receive support access. | - | | [P12 - Governing Employee Record Access with Cedarling](./p12-hr-access-governance/) | Node.js, Express, React, SQLite | Grant and revoke employee-record access while separating requesters from approvers. | - | | [P13 - Protecting Grade Publication and Guardian Access with Cedarling](./p13-student-records/) | Node.js, Express, React, SQLite | Separate grade editing, publication, student access, and guardian access. | - | -| [P14 - Governing an AI Scheduling Assistant with Cedarling](./p14-ai-scheduling-assistant/) | Node.js, Fastify, React, SQLite | Authorize each scheduling action an assistant proposes before it changes anything. | - | +| [P14 - Authorizing Turns and Property Trades with Cedarling](./p14-capital-district/) | Node.js, Fastify, React, SQLite | Authorize turns, property improvements, and expiring trades using current match facts. | - | | [P15 - Authorizing a Multi-Party Marketplace Refund with Cedarling](./p15-marketplace/) | Node.js, Express, React, SQLite | Give buyers, sellers, support, and fraud reviewers the right views and refund actions. | - | ## Run the projects your way diff --git a/p1-task-manager/package.json b/p1-task-manager/package.json index 99431a0..cffec55 100644 --- a/p1-task-manager/package.json +++ b/p1-task-manager/package.json @@ -50,7 +50,7 @@ "tsx": "4.23.12", "typescript": "6.0.3", "typescript-eslint": "8.68.0", - "vite": "8.2.2", + "vite": "8.2.4", "vitest": "4.1.11" }, "pnpm": { diff --git a/p1-task-manager/pnpm-lock.yaml b/p1-task-manager/pnpm-lock.yaml index c5b61ce..b624cc8 100644 --- a/p1-task-manager/pnpm-lock.yaml +++ b/p1-task-manager/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 6.1.1(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) eslint: specifier: 10.11.0 version: 10.11.0 @@ -88,11 +88,11 @@ importers: specifier: 8.68.0 version: 8.68.0(eslint@10.11.0)(typescript@6.0.3) vite: - specifier: 8.2.2 - version: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + specifier: 8.2.4 + version: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.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)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + version: 4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) packages: @@ -1421,8 +1421,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - 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: @@ -2001,10 +2001,10 @@ snapshots: '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) '@vitest/expect@4.1.11': dependencies: @@ -2015,13 +2015,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.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)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) '@vitest/pretty-format@4.1.11': dependencies: @@ -2778,7 +2778,7 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0): + vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -2792,10 +2792,10 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): + vitest@4.1.11(@types/node@24.19.0)(jsdom@30.1.1)(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(vite@8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -2812,7 +2812,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) + vite: 8.2.4(@types/node@24.19.0)(esbuild@0.28.2)(tsx@4.23.12)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.19.0 diff --git a/p1-task-manager/src/web/App.tsx b/p1-task-manager/src/web/App.tsx index 2c39232..9e23279 100644 --- a/p1-task-manager/src/web/App.tsx +++ b/p1-task-manager/src/web/App.tsx @@ -107,13 +107,37 @@ function ProgramFooter() { return ( ); diff --git a/p10-warehouse-workloads/.dockerignore b/p10-warehouse-workloads/.dockerignore deleted file mode 100644 index 213b7e5..0000000 --- a/p10-warehouse-workloads/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.env -.git -.local -dist -node_modules -*.log -*.tsbuildinfo diff --git a/p10-warehouse-workloads/README.md b/p10-warehouse-workloads/README.md index 03e605e..c4a2382 100644 --- a/p10-warehouse-workloads/README.md +++ b/p10-warehouse-workloads/README.md @@ -1,108 +1,111 @@ # P10 - Authorizing Warehouse Workloads with Cedarling -P10 is a warehouse stock-transfer console for learning how Cedarling authorizes -OAuth Client Credentials workloads. A signed machine token proves which -workload is calling; authorization decides whether that workload may plan, -release, receive, or inspect a transfer in its current state. +P10 moves stock between two warehouses. Its console sends commands through +four workload agents, each authenticated with OAuth Client Credentials. -The marked capabilities currently use compact fake permissive decisions. The -Cedarling tutorial replaces that adapter while preserving every business and -authentication boundary. +The learning baseline validates workload tokens and requests, while business +permissions return `FAKE ALLOW`. Cedarling will restrict each workload to its +warehouse and operation. ## Architecture -```text -React workload console - │ fixed workload action - ▼ -Console BFF ── private credential ──► Workload agent ── Client Credentials ──► IdP - │ signed access token - ▼ - Warehouse API (PEP) - │ principal + current SQLite facts - ▼ - Cedarling - │ │ - DENY ALLOW ──► conditional SQLite effect +```mermaid +flowchart TD + Browser["React workload console"] --> BFF["Console BFF"] + BFF -->|Private control request| Agents["Four isolated workload agents"] + Agents -->|Client Credentials| IdP["Tutorial IdP"] + Agents -->|Signed access token| API["Warehouse API"] + API --> Fake["FAKE ALLOW"] + Fake --> DB["Conditional inventory and transfer transaction"] ``` -The browser never receives a client secret or workload token. Four isolated -agent processes each own one credential. The Warehouse API verifies the token, -loads current transfer and inventory facts, marks the authorization boundary, -and only then attempts a versioned SQLite transaction. - ## 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. - -The commands work from PowerShell, macOS terminals, and Ubuntu shells. +- 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. +`pnpm dev` prepares configuration and starts the IdP, Warehouse API, four +agents, and console. Refresh after a browser-source rebuild. +The console uses workload identities and opens directly. + +## Exercise -`pnpm build` followed by `pnpm start` runs the compiled application stack and its project IdP. +The workloads have these intended responsibilities: -Use `pnpm dev -- --reset` only when you want to restore the tutorial fixtures. +- Transfer Planner creates transfers. +- North Warehouse releases North stock and receives North deliveries. +- South Warehouse releases South stock and receives South deliveries. +- Inventory Auditor reads inventory. -## Exercise +Choose **New transfer**, select North to South, choose an item and quantity, +then create it as Planner. Release it as North, then receive it as South. +Planning leaves stock unchanged; release deducts from the source and receipt +adds to the destination. + +Repeat with a new transfer. Use **Try another workload** at each step: -The workflow moves three allowlisted products between North and South -warehouses: +1. Create as Auditor despite its read-only responsibility. +2. Release North's stock as South. +3. Receive the South-bound delivery as North. -- **Transfer Planner** — creates and inspects planned transfers. -- **North Warehouse** — releases North stock and receives North deliveries. -- **South Warehouse** — releases South stock and receives South deliveries. -- **Inventory Auditor** — reads inventory without changing transfers. +All three operations succeed in the baseline. Stock, transfer-state, and version +checks still apply. -Create a North-to-South transfer as Planner. Release it as North, receive it as -South, and confirm that stock moves exactly once at each transition. Then use -the secondary actions to reproduce the three authorization problems: +## Data and reset -- Auditor creates a transfer despite being read-only. -- South releases stock owned by North. -- North receives stock destined for South. +Restarts preserve your changes. For native data, stop the app, run +`pnpm reset`, then `pnpm dev`. Reset restores stock and removes transfers and +retry records. -Cedarling will deny those three operations from the same authenticated -workloads while retaining the valid Planner, source-warehouse, -destination-warehouse, and Auditor reads. +For the default Docker setup: + +```bash +docker compose down +docker volume rm p10-warehouse-workloads_p10-data +docker compose up --build +``` + +This removes application data and preserves IdP credentials. Custom Compose +project names or data mounts require their corresponding data target. ## Commands | Command | Purpose | | --- | --- | -| `pnpm run setup` | Synchronize workload credentials and initialize SQLite | -| `pnpm dev` | Build and run the watched native stack | -| `pnpm start` | Build and run the production-mode native stack | -| `pnpm reset` | Restore deterministic inventory and transfer fixtures | +| `pnpm run setup` | Prepare workload credentials and inventory | +| `pnpm start` | Build and run the complete native stack | | `pnpm test:e2e` | Exercise the workload console in Chromium | -| `pnpm check` | Run formatting, lint, types, tests, build, and browser checks | ## Verify -Install Chromium once before the complete local check: +Stop this project's running stack before checking local changes. `pnpm check` +runs formatting, lint, type checks, tests, build, and browser checks using +disposable data. Install Chromium once: ```bash pnpm exec playwright install chromium pnpm check -pnpm audit --audit-level low ``` + +On Linux, add `--with-deps` to the Chromium installation command if system +libraries are missing. diff --git a/p10-warehouse-workloads/compose.yaml b/p10-warehouse-workloads/compose.yaml index b725645..5f44616 100644 --- a/p10-warehouse-workloads/compose.yaml +++ b/p10-warehouse-workloads/compose.yaml @@ -51,8 +51,9 @@ services: test: - CMD - node + - --env-file=/run/config/.env - -e - - fetch('http://127.0.0.1:18010/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1)) + - fetch('http://127.0.0.1:18010/.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/p10-warehouse-workloads/e2e/workload-gaps.e2e.ts b/p10-warehouse-workloads/e2e/workload-gaps.e2e.ts index 45af154..b97ec5d 100644 --- a/p10-warehouse-workloads/e2e/workload-gaps.e2e.ts +++ b/p10-warehouse-workloads/e2e/workload-gaps.e2e.ts @@ -1,4 +1,183 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Locator, test } from "@playwright/test"; +import type { WorkloadCommand, Workspace } from "../src/shared/types.ts"; + +test("retries an uncertain creation and release without repeating either effect", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.locator(".transfer-item").first()).toBeVisible(); + const before = (await ( + await page.request.get("/api/workspace") + ).json()) as Workspace; + const commands: WorkloadCommand[] = []; + const interrupted = new Set(); + await page.route("**/api/workloads/*/commands", async (route) => { + const command = route.request().postDataJSON() as WorkloadCommand; + commands.push(command); + const response = await route.fetch(); + expect(response.ok()).toBe(true); + if (!interrupted.has(command.type)) { + interrupted.add(command.type); + await route.abort("failed"); + } else { + await route.fulfill({ response }); + } + }); + await page.getByRole("button", { name: "New transfer" }).click(); + await page.getByRole("spinbutton", { name: "Quantity" }).fill("1"); + const create = page.getByRole("button", { name: "Create as Planner" }); + await create.click(); + await expect(page.getByRole("alert")).toContainText("could not be completed"); + await create.click(); + await expect(page.getByRole("status")).toContainText("created the transfer"); + expect(commands[1]).toEqual(commands[0]); + const release = page.getByRole("button", { name: "Release as North" }); + await release.click(); + await expect(page.getByRole("alert")).toContainText("could not be completed"); + await release.click(); + await expect(page.locator(".detail-card .status")).toHaveText("in transit"); + expect(commands[3]).toEqual(commands[2]); + const after = (await ( + await page.request.get("/api/workspace") + ).json()) as Workspace; + expect(after.transfers).toHaveLength(before.transfers.length + 1); + const stock = (workspace: Workspace) => + workspace.inventory.find( + (row) => row.warehouseId === "north" && row.skuId === "bearing-kit", + )?.quantity; + expect(stock(after)).toBe((stock(before) ?? 0) - 1); +}); + +test("recovers a failed refresh after a committed command without resending it", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.locator(".transfer-item").first()).toBeVisible(); + let unavailable = true; + let commandCount = 0; + await page.route("**/api/workspace", async (route) => { + if (unavailable) + await route.fulfill({ + status: 503, + json: { error: "service_unavailable" }, + }); + else await route.continue(); + }); + page.on("request", (request) => { + if (request.method() === "POST" && request.url().endsWith("/commands")) + commandCount += 1; + }); + await page.getByRole("button", { name: "New transfer" }).click(); + await page.getByRole("spinbutton", { name: "Quantity" }).fill("1"); + await page.getByRole("button", { name: "Create as Planner" }).click(); + await expect(page.getByRole("alert")).toContainText( + "created the transfer. Refresh", + ); + await expect( + page.getByRole("button", { name: "Release as North" }), + ).toBeDisabled(); + unavailable = false; + await page.getByRole("button", { name: "Refresh workspace" }).click(); + await expect(page.getByRole("status")).toHaveText("Workspace refreshed."); + await expect( + page.getByRole("button", { name: "Release as North" }), + ).toBeEnabled(); + expect(commandCount).toBe(1); +}); + +test("scrolls the transfer list independently and opens one task at a time", async ({ + page, +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.route("**/api/workspace", async (route) => { + const response = await route.fetch(); + const workspace = await response.json(); + const original = workspace.transfers[0]; + workspace.transfers = Array.from({ length: 40 }, (_, index) => ({ + ...original, + id: `scroll-test-${index}`, + })); + await route.fulfill({ response, json: workspace }); + }); + await page.goto("/"); + await expect(page.locator(".transfer-item")).toHaveCount(40); + await expect( + page.getByRole("heading", { name: "Plan stock transfer" }), + ).toBeHidden(); + const panel = page.locator(".main-panel"); + const initialTop = await panel.evaluate( + (element) => element.getBoundingClientRect().top, + ); + const rail = page.locator(".transfer-rail"); + await rail.hover(); + await page.mouse.wheel(0, 1200); + await expect + .poll(() => rail.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + expect(await panel.evaluate((element) => element.scrollTop)).toBe(0); + expect( + await panel.evaluate((element) => element.getBoundingClientRect().top), + ).toBe(initialTop); + expect( + await page.locator(".workspace").evaluate((element) => element.scrollTop), + ).toBe(0); + await page.getByRole("button", { name: "New transfer" }).click(); + await expect( + page.getByRole("heading", { name: "Plan stock transfer" }), + ).toBeVisible(); + await expect(page.locator(".detail-card")).toBeHidden(); + await page.locator(".transfer-item").first().click(); + await expect(page.locator(".detail-card")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Plan stock transfer" }), + ).toBeHidden(); +}); + +test("follows a South-to-North transfer and keeps comparison actions available", async ({ + page, +}) => { + await page.goto("/"); + await page.getByRole("button", { name: "New transfer" }).click(); + await page + .getByRole("combobox", { name: "From", exact: true }) + .selectOption("south"); + await page + .getByRole("combobox", { name: "To", exact: true }) + .selectOption("north"); + await page.getByRole("spinbutton", { name: "Quantity" }).fill("1"); + await page.getByRole("button", { name: "Create as Planner" }).click(); + const release = page.getByRole("button", { name: "Release as South" }); + await expect(release).toHaveClass(/primary/); + await page + .locator(".detail-card") + .getByText("Try another workload", { exact: true }) + .click(); + await expect( + page.getByRole("button", { name: "Release as North" }), + ).toBeEnabled(); + await release.click(); + const receive = page.getByRole("button", { name: "Receive as North" }); + await expect(receive).toHaveClass(/primary/); + await receive.click(); + await expect(page.getByText("Stock movement is complete.")).toBeVisible(); +}); + +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); +} test("reproduces the three workload authorization gaps", async ({ page }) => { const missing = await page.request.get("/missing"); @@ -9,25 +188,110 @@ test("reproduces the three workload authorization gaps", async ({ page }) => { }); await page.goto("/"); + await page.getByRole("button", { name: "New transfer" }).click(); await expect( page.getByRole("heading", { name: "Plan stock transfer" }), ).toBeVisible(); + await page + .locator(".create-card") + .getByText("Try another workload", { exact: true }) + .click(); await page.getByRole("button", { name: "Try as Auditor" }).click(); await expect(page.getByRole("status")).toContainText( "Auditor created the transfer", ); - await expect(page.getByText("planned", { exact: true })).toBeVisible(); + await expect(page.locator(".detail-card .status")).toHaveText("planned"); + await page + .locator(".detail-card") + .getByText("Try another workload", { exact: true }) + .click(); await page.getByRole("button", { name: "Release as South" }).click(); await expect(page.getByRole("status")).toContainText( "South released the transfer", ); - await expect(page.getByText("in transit", { exact: true })).toBeVisible(); + await expect(page.locator(".detail-card .status")).toHaveText("in transit"); + await page + .locator(".detail-card") + .getByText("Try another workload", { exact: true }) + .click(); await page.getByRole("button", { name: "Receive as North" }).click(); await expect(page.getByRole("status")).toContainText( "North received the transfer", ); - await expect(page.getByText("received", { exact: true })).toBeVisible(); + await expect(page.locator(".detail-card .status")).toHaveText("received"); }); + +test("keeps mobile transfers, creation and inventory in a bounded workspace", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await page.getByRole("button", { name: "New transfer" }).click(); + await expect( + page.getByRole("heading", { name: "Plan stock transfer" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Create as Planner" }).click(); + await expect( + page.getByRole("button", { name: "Release as North" }), + ).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBe( + 844, + ); + await expect(page.locator("footer")).toBeInViewport(); + await page.getByRole("button", { name: "Back to transfers" }).click(); + await expect( + page.getByRole("button", { name: "New transfer" }), + ).toBeFocused(); + await page.getByRole("button", { name: "Inventory", exact: true }).click(); + await expect( + page.getByRole("heading", { name: "Current inventory" }), + ).toBeVisible(); +}); + +for (const [width, height] of [ + [320, 568], + [390, 844], + [768, 1024], + [844, 390], + [1440, 1000], +] as const) { + test(`keeps transfer controls reachable at ${width} × ${height}`, async ({ + page, + }) => { + await page.setViewportSize({ width, height }); + await page.goto("/"); + await page.getByRole("button", { name: "New transfer" }).click(); + await expect(page.locator(".brand-rail")).toHaveJSProperty( + "clientWidth", + width, + ); + for (const name of ["From", "To", "Item"]) + await touchTarget(page.getByRole("combobox", { name, exact: true })); + await touchTarget(page.getByRole("spinbutton", { name: "Quantity" })); + await page.getByRole("spinbutton", { name: "Quantity" }).fill("1"); + await touchTarget(page.getByRole("button", { name: "Create as Planner" })); + await page.getByRole("button", { name: "Create as Planner" }).click(); + await touchTarget(page.getByRole("button", { name: "Release as North" })); + await page.getByRole("button", { name: "Release as North" }).click(); + await touchTarget(page.getByRole("button", { name: "Receive as South" })); + await page.getByRole("button", { name: "Receive as South" }).click(); + await expect(page.getByText("Stock movement is complete.")).toBeVisible(); + if (width <= 980) + await page.getByRole("button", { name: "Back to transfers" }).click(); + await page.getByRole("button", { name: "Inventory", exact: true }).click(); + const table = page.getByRole("region", { name: "Inventory stock levels" }); + await expect(table).toBeVisible(); + await table.focus(); + await expect(table).toBeFocused(); + await expect(page.locator("footer")).toBeInViewport(); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBe(width); + expect( + await page.evaluate(() => document.documentElement.scrollHeight), + ).toBe(height); + }); +} diff --git a/p10-warehouse-workloads/package.json b/p10-warehouse-workloads/package.json index f1f9da3..01eb5f7 100644 --- a/p10-warehouse-workloads/package.json +++ b/p10-warehouse-workloads/package.json @@ -12,7 +12,7 @@ "dev": "node scripts/dev.mjs", "start": "node scripts/dev.mjs --production", "reset": "node --env-file=.env scripts/reset.ts", - "build": "vite build && tsc -p tsconfig.server.json", + "build": "vite build && tsc -b tsconfig.server.json --force", "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.scripts.json && tsc --noEmit -p tsconfig.web.json", "format": "biome check --write .", "format:check": "biome format .", @@ -40,7 +40,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", "typescript": "6.0.3", - "vite": "8.2.2", + "vite": "8.2.4", "vitest": "4.1.11" }, "pnpm": { diff --git a/p10-warehouse-workloads/playwright.config.ts b/p10-warehouse-workloads/playwright.config.ts index 4e477e6..40e9f3f 100644 --- a/p10-warehouse-workloads/playwright.config.ts +++ b/p10-warehouse-workloads/playwright.config.ts @@ -6,6 +6,8 @@ export default defineConfig({ timeout: 45_000, expect: { timeout: 8_000 }, fullyParallel: false, + workers: 1, + globalSetup: "./scripts/browser-test-stack.mjs", retries: process.env.CI ? 1 : 0, reporter: process.env.CI ? "github" : "line", use: { @@ -13,11 +15,4 @@ export default defineConfig({ browserName: "chromium", trace: "retain-on-failure", }, - webServer: { - command: "pnpm dev -- --reset", - gracefulShutdown: { signal: "SIGINT", timeout: 10_000 }, - url: "http://127.0.0.1:17010/healthz", - reuseExistingServer: false, - timeout: 120_000, - }, }); diff --git a/p10-warehouse-workloads/pnpm-lock.yaml b/p10-warehouse-workloads/pnpm-lock.yaml index 3c935c8..0f01d64 100644 --- a/p10-warehouse-workloads/pnpm-lock.yaml +++ b/p10-warehouse-workloads/pnpm-lock.yaml @@ -53,16 +53,16 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: 6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@24.19.0)) + version: 6.1.1(vite@8.2.4(@types/node@24.19.0)) typescript: 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: @@ -897,8 +897,8 @@ packages: resolution: {integrity: sha512-AdzuGcAkzhbha3GgCSoUBx2P0quwym6qdFNCQz/fDGZc8w9XJ741wHUbt2kTmfPz+ZIOMkySFkiY7RXmiDDC8w==} engines: {node: '>=22.19.0'} - 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: @@ -1239,10 +1239,10 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.19.0))': + '@vitejs/plugin-react@6.1.1(vite@8.2.4(@types/node@24.19.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@24.19.0) + vite: 8.2.4(@types/node@24.19.0) '@vitest/expect@4.1.11': dependencies: @@ -1253,13 +1253,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: @@ -1761,7 +1761,7 @@ snapshots: undici@8.11.0: optional: true - 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 @@ -1772,10 +1772,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 @@ -1792,7 +1792,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/p10-warehouse-workloads/scripts/browser-test-stack.d.mts b/p10-warehouse-workloads/scripts/browser-test-stack.d.mts new file mode 100644 index 0000000..bbac97f --- /dev/null +++ b/p10-warehouse-workloads/scripts/browser-test-stack.d.mts @@ -0,0 +1 @@ +export default function startBrowserTestStack(): Promise<() => Promise>; diff --git a/p10-warehouse-workloads/scripts/browser-test-stack.mjs b/p10-warehouse-workloads/scripts/browser-test-stack.mjs new file mode 100644 index 0000000..2c1a83a --- /dev/null +++ b/p10-warehouse-workloads/scripts/browser-test-stack.mjs @@ -0,0 +1,71 @@ +import { randomBytes } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { + canConnect, + DevSupervisor, + runCommand, +} from "../../shared/dev-supervisor.mjs"; +import { ensureProjectIdentity } from "../../shared/identity-provider/scripts/setup.mjs"; +import { WORKLOADS, workloadAgentPort } from "../src/shared/catalog.ts"; +import { stackServices } from "./stack-services.mjs"; + +/** Run the same services as native development, with disposable test state. */ +export default async function startBrowserTestStack() { + const root = process.cwd(); + const ports = [ + 17010, + 18010, + 3110, + ...WORKLOADS.map(({ id }) => workloadAgentPort(id)), + ]; + for (const port of ports) { + if (await canConnect("127.0.0.1", port)) + throw new Error( + `P10 browser tests require free port ${port}; stop your own P10 instance first.`, + ); + } + await mkdir(join(root, ".local"), { recursive: true }); + const directory = await mkdtemp(join(root, ".local/e2e-")); + const supervisor = new DevSupervisor(); + const cleanup = async () => { + await supervisor.stop(); + await rm(directory, { recursive: true, force: true }); + }; + try { + const identityFile = join(directory, "idp.env"); + const identity = ensureProjectIdentity("P10", identityFile); + const configured = { + ...identity, + P10_HOST: "127.0.0.1", + P10_PORT: "17010", + P10_BASE_URL: "http://localhost:17010", + P10_ISSUER: identity.IDP_ISSUER, + P10_API_HOST: "127.0.0.1", + P10_API_PORT: "3110", + P10_DATA_DIR: join(directory, "data"), + P10_CONTROL_SECRET: randomBytes(32).toString("base64url"), + P10_WAREHOUSE_API_URL: "http://127.0.0.1:3110", + P10_TRANSFER_PLANNER_URL: "http://127.0.0.1:3111", + P10_WAREHOUSE_NORTH_URL: "http://127.0.0.1:3112", + P10_WAREHOUSE_SOUTH_URL: "http://127.0.0.1:3113", + P10_INVENTORY_AUDITOR_URL: "http://127.0.0.1:3114", + }; + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + await runCommand(pnpm, ["build"], { + cwd: resolve(root, "../shared/identity-provider"), + }); + await runCommand(pnpm, ["build"], { cwd: root }); + const services = stackServices(root, configured, { + production: true, + identityFile, + }); + services[0].env = { ...process.env, ...identity }; + for (const service of services) + await supervisor.ensure({ ...service, reuseExisting: false }); + return cleanup; + } catch (error) { + await cleanup(); + throw error; + } +} diff --git a/p10-warehouse-workloads/scripts/dev.mjs b/p10-warehouse-workloads/scripts/dev.mjs index d503701..3264912 100644 --- a/p10-warehouse-workloads/scripts/dev.mjs +++ b/p10-warehouse-workloads/scripts/dev.mjs @@ -2,21 +2,12 @@ import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseEnv } from "node:util"; -import { - issuerHealth, - runDevStack, - serviceHealth, -} from "../../shared/dev-supervisor.mjs"; -import { - WORKLOADS, - workloadAgentPort, - workloadEnvironmentPrefix, -} from "../src/shared/catalog.ts"; +import { runDevStack } from "../../shared/dev-supervisor.mjs"; +import { stackServices } from "./stack-services.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const identityRoot = resolve(root, "../shared/identity-provider"); const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -const node = process.execPath; const resetFixtures = process.argv.includes("--reset"); const production = process.argv.includes("--production"); @@ -53,95 +44,5 @@ try { async function services() { const configured = parseEnv(await readFile(resolve(root, ".env"), "utf8")); - const base = Object.fromEntries( - Object.entries(process.env).filter(([name]) => !name.startsWith("P10_")), - ); - const common = { - P10_ISSUER: configured.P10_ISSUER, - P10_API_RESOURCE: configured.P10_API_RESOURCE, - }; - const source = (name) => - production - ? [`dist/server/${name}-main.js`] - : ["--watch", `src/server/${name}-main.ts`]; - const workloadIds = WORKLOADS.map(({ id }) => id); - return [ - { - name: "P10 identity provider", - command: node, - args: [`--env-file=${resolve(root, ".local/idp/.env")}`, "dist/main.js"], - cwd: identityRoot, - health: { - url: "http://127.0.0.1:18010/.well-known/openid-configuration", - validate: issuerHealth(configured.P10_ISSUER), - }, - }, - { - name: "P10 Warehouse API", - command: node, - args: source("api"), - cwd: root, - env: { - ...base, - ...common, - P10_API_HOST: configured.P10_API_HOST, - P10_API_PORT: configured.P10_API_PORT, - P10_DATA_DIR: configured.P10_DATA_DIR, - ...Object.fromEntries( - workloadIds.map((id) => { - const prefix = workloadEnvironmentPrefix(id); - return [`${prefix}_CLIENT_ID`, configured[`${prefix}_CLIENT_ID`]]; - }), - ), - }, - health: { - url: `http://127.0.0.1:${configured.P10_API_PORT}/healthz`, - validate: serviceHealth("p10-warehouse-api"), - }, - }, - ...workloadIds.map((id) => { - const prefix = workloadEnvironmentPrefix(id); - const port = String(workloadAgentPort(id)); - return { - name: `P10 ${id} agent`, - command: node, - args: [...source("agent"), id], - cwd: root, - env: { - ...base, - ...common, - P10_CONTROL_SECRET: configured.P10_CONTROL_SECRET, - P10_WAREHOUSE_API_URL: configured.P10_WAREHOUSE_API_URL, - P10_AGENT_PORT: port, - [`${prefix}_CLIENT_ID`]: configured[`${prefix}_CLIENT_ID`], - [`${prefix}_CLIENT_SECRET`]: configured[`${prefix}_CLIENT_SECRET`], - }, - health: { - url: `http://127.0.0.1:${port}/healthz`, - validate: serviceHealth(`p10-agent-${id}`), - }, - }; - }), - { - name: "P10 CedarStock console", - command: node, - args: source("console"), - cwd: root, - env: { - ...base, - P10_HOST: configured.P10_HOST, - P10_PORT: configured.P10_PORT, - P10_BASE_URL: configured.P10_BASE_URL, - P10_CONTROL_SECRET: configured.P10_CONTROL_SECRET, - P10_TRANSFER_PLANNER_URL: configured.P10_TRANSFER_PLANNER_URL, - P10_WAREHOUSE_NORTH_URL: configured.P10_WAREHOUSE_NORTH_URL, - P10_WAREHOUSE_SOUTH_URL: configured.P10_WAREHOUSE_SOUTH_URL, - P10_INVENTORY_AUDITOR_URL: configured.P10_INVENTORY_AUDITOR_URL, - }, - health: { - url: `http://127.0.0.1:${configured.P10_PORT}/healthz`, - validate: serviceHealth("p10-warehouse-workloads"), - }, - }, - ]; + return stackServices(root, configured, { production }); } diff --git a/p10-warehouse-workloads/scripts/setup.ts b/p10-warehouse-workloads/scripts/setup.ts index 0313450..acab3ea 100644 --- a/p10-warehouse-workloads/scripts/setup.ts +++ b/p10-warehouse-workloads/scripts/setup.ts @@ -17,6 +17,8 @@ const required = (name: string): string => { if (!value) throw new Error(`${name} is missing from .local/idp/.env`); return value; }; +const apiResource = required("P10_API_RESOURCE").replace(/\/$/u, ""); +const baseUrl = new URL(apiResource).origin; const current = readProjectEnvironment(target); const controlSecret = current.environment.P10_CONTROL_SECRET?.trim() || @@ -32,14 +34,15 @@ const managed = Object.fromEntries( ); const merged = mergeProjectEnvironment(current.text, { managed: { + P10_BASE_URL: baseUrl, + P10_PORT: + new URL(baseUrl).port || (baseUrl.startsWith("https:") ? "443" : "80"), P10_ISSUER: required("IDP_ISSUER").replace(/\/$/u, ""), - P10_API_RESOURCE: required("P10_API_RESOURCE").replace(/\/$/u, ""), + P10_API_RESOURCE: apiResource, ...managed, }, defaults: { P10_HOST: "127.0.0.1", - P10_PORT: "17010", - P10_BASE_URL: "http://localhost:17010", P10_API_HOST: "127.0.0.1", P10_API_PORT: "3110", P10_DATA_DIR: ".local/p10-data", diff --git a/p10-warehouse-workloads/scripts/stack-services.mjs b/p10-warehouse-workloads/scripts/stack-services.mjs new file mode 100644 index 0000000..b06c2c1 --- /dev/null +++ b/p10-warehouse-workloads/scripts/stack-services.mjs @@ -0,0 +1,121 @@ +import { resolve } from "node:path"; +import { issuerHealth, serviceHealth } from "../../shared/dev-supervisor.mjs"; +import { + WORKLOADS, + workloadAgentPort, + workloadEnvironmentPrefix, +} from "../src/shared/catalog.ts"; + +export function stackServices( + root, + configured, + { production = false, identityFile = resolve(root, ".local/idp/.env") } = {}, +) { + const identityRoot = resolve(root, "../shared/identity-provider"); + const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + const node = process.execPath; + const base = { + ...Object.fromEntries( + Object.entries(process.env).filter(([name]) => !name.startsWith("P10_")), + ), + P10_SUPERVISED: "1", + }; + const common = { + P10_ISSUER: configured.P10_ISSUER, + P10_API_RESOURCE: configured.P10_API_RESOURCE, + }; + const source = (name) => + production + ? [`dist/server/${name}-main.js`] + : ["--watch", `src/server/${name}-main.ts`]; + const workloadIds = WORKLOADS.map(({ id }) => id); + return [ + { + name: "P10 identity provider", + command: node, + args: [`--env-file=${identityFile}`, "dist/main.js"], + cwd: identityRoot, + health: { + url: "http://127.0.0.1:18010/.well-known/openid-configuration", + validate: issuerHealth(configured.P10_ISSUER), + }, + }, + { + name: "P10 Warehouse API", + command: node, + args: source("api"), + cwd: root, + env: { + ...base, + ...common, + P10_API_HOST: configured.P10_API_HOST, + P10_API_PORT: configured.P10_API_PORT, + P10_DATA_DIR: configured.P10_DATA_DIR, + ...Object.fromEntries( + workloadIds.map((id) => { + const prefix = workloadEnvironmentPrefix(id); + return [`${prefix}_CLIENT_ID`, configured[`${prefix}_CLIENT_ID`]]; + }), + ), + }, + health: { + url: `http://127.0.0.1:${configured.P10_API_PORT}/healthz`, + validate: serviceHealth("p10-warehouse-api"), + }, + }, + ...workloadIds.map((id) => { + const prefix = workloadEnvironmentPrefix(id); + const port = String(workloadAgentPort(id)); + return { + name: `P10 ${id} agent`, + command: node, + args: [...source("agent"), id], + cwd: root, + env: { + ...base, + ...common, + P10_CONTROL_SECRET: configured.P10_CONTROL_SECRET, + P10_WAREHOUSE_API_URL: configured.P10_WAREHOUSE_API_URL, + P10_AGENT_PORT: port, + [`${prefix}_CLIENT_ID`]: configured[`${prefix}_CLIENT_ID`], + [`${prefix}_CLIENT_SECRET`]: configured[`${prefix}_CLIENT_SECRET`], + }, + health: { + url: `http://127.0.0.1:${port}/healthz`, + validate: serviceHealth(`p10-agent-${id}`), + }, + }; + }), + ...(production + ? [] + : [ + { + name: "P10 web build watcher", + command: pnpm, + args: ["exec", "vite", "build", "--watch", "--emptyOutDir=false"], + cwd: root, + }, + ]), + { + name: "P10 CedarStock console", + command: node, + args: source("console"), + cwd: root, + env: { + ...base, + P10_HOST: configured.P10_HOST, + P10_PORT: configured.P10_PORT, + P10_BASE_URL: configured.P10_BASE_URL, + P10_CONTROL_SECRET: configured.P10_CONTROL_SECRET, + P10_TRANSFER_PLANNER_URL: configured.P10_TRANSFER_PLANNER_URL, + P10_WAREHOUSE_NORTH_URL: configured.P10_WAREHOUSE_NORTH_URL, + P10_WAREHOUSE_SOUTH_URL: configured.P10_WAREHOUSE_SOUTH_URL, + P10_INVENTORY_AUDITOR_URL: configured.P10_INVENTORY_AUDITOR_URL, + }, + health: { + url: `http://127.0.0.1:${configured.P10_PORT}/healthz`, + validate: serviceHealth("p10-warehouse-workloads"), + }, + }, + ]; +} diff --git a/p10-warehouse-workloads/src/server/agent-app.ts b/p10-warehouse-workloads/src/server/agent-app.ts index 7dec4a4..a0c6e50 100644 --- a/p10-warehouse-workloads/src/server/agent-app.ts +++ b/p10-warehouse-workloads/src/server/agent-app.ts @@ -93,11 +93,9 @@ export async function createAgentApp( app.setErrorHandler((error, request, reply) => { const domain = requestError(error, "dependency_unavailable"); if (domain.statusCode >= 500) { - console.error("P10 workload agent failed", { - requestId: request.id, - workload: config.workloadId, - error: error instanceof Error ? error.name : "unknown", - }); + console.error( + `P10 agent | ERROR | ${domain.code} | ${config.workloadId} | ${request.id}`, + ); } void reply .code(domain.statusCode) diff --git a/p10-warehouse-workloads/src/server/agent-main.ts b/p10-warehouse-workloads/src/server/agent-main.ts index 71f1afa..a712a26 100644 --- a/p10-warehouse-workloads/src/server/agent-main.ts +++ b/p10-warehouse-workloads/src/server/agent-main.ts @@ -1,13 +1,11 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { loadEnvFile } from "node:process"; import { type WorkloadId, workloadIds } from "../shared/catalog.ts"; import { createAgentApp } from "./agent-app.ts"; import { loadAgentConfig } from "./config.ts"; +import { loadLocalEnvironment } from "./environment.ts"; import { createWorkloadTokens } from "./oidc.ts"; import { listen, shutdown } from "./runtime.ts"; -if (existsSync(resolve(".env"))) loadEnvFile(resolve(".env")); +loadLocalEnvironment(); const selected = process.argv[2] ?? process.env.P10_WORKLOAD_ID; if (!selected || !workloadIds.has(selected as WorkloadId)) { throw new Error("P10 agent requires a registered workload ID"); diff --git a/p10-warehouse-workloads/src/server/api-app.ts b/p10-warehouse-workloads/src/server/api-app.ts index eff0509..342eac3 100644 --- a/p10-warehouse-workloads/src/server/api-app.ts +++ b/p10-warehouse-workloads/src/server/api-app.ts @@ -6,7 +6,7 @@ import type { Authorization } from "./authorization.ts"; import type { WarehouseDatabase } from "./database.ts"; import { DomainError, notFoundHandler, requestError } from "./errors.ts"; import type { TokenVerifier, WorkloadPrincipal } from "./jwt.ts"; -import { parseCommand } from "./validation.ts"; +import { parseCommand, parseRouteBody } from "./validation.ts"; type Dependencies = Readonly<{ database: WarehouseDatabase; @@ -164,8 +164,14 @@ export async function createApiApp( dependencies.verifier, ); const command = parseCommand({ + ...parseRouteBody(request.body, [ + "sourceId", + "destinationId", + "skuId", + "quantity", + "idempotencyKey", + ]), type: "transfer.create", - ...(request.body as object), }); await authorize(command, principal, request.id); if (command.type !== "transfer.create") @@ -191,9 +197,9 @@ export async function createApiApp( dependencies.verifier, ); const command = parseCommand({ + ...parseRouteBody(request.body, ["expectedVersion", "idempotencyKey"]), type: `transfer.${operation}`, transferId: request.params.id, - ...(request.body as object), }); if ( (command.type !== "transfer.release" && @@ -224,10 +230,7 @@ export async function createApiApp( app.setErrorHandler((error, request, reply) => { const domain = requestError(error, "service_unavailable"); if (domain.statusCode >= 500) { - console.error("P10 Warehouse API request failed", { - requestId: request.id, - error: error instanceof Error ? error.name : "unknown", - }); + console.error(`P10 API | ERROR | ${domain.code} | ${request.id}`); } void reply .code(domain.statusCode) diff --git a/p10-warehouse-workloads/src/server/api-main.ts b/p10-warehouse-workloads/src/server/api-main.ts index ce49d36..02c6bb5 100644 --- a/p10-warehouse-workloads/src/server/api-main.ts +++ b/p10-warehouse-workloads/src/server/api-main.ts @@ -1,14 +1,13 @@ -import { existsSync } from "node:fs"; import { resolve } from "node:path"; -import { loadEnvFile } from "node:process"; import { createApiApp } from "./api-app.ts"; import { fakeAuthorization } from "./authorization.ts"; import { loadApiConfig, prepareDataDirectory } from "./config.ts"; import { WarehouseDatabase } from "./database.ts"; +import { loadLocalEnvironment } from "./environment.ts"; import { createTokenVerifier } from "./jwt.ts"; import { listen, shutdown } from "./runtime.ts"; -if (existsSync(resolve(".env"))) loadEnvFile(resolve(".env")); +loadLocalEnvironment(); console.info( "P10 baseline: FAKE ALLOW marks authorization boundaries; Cedarling is not called.", ); diff --git a/p10-warehouse-workloads/src/server/console-app.ts b/p10-warehouse-workloads/src/server/console-app.ts index 1578134..254e594 100644 --- a/p10-warehouse-workloads/src/server/console-app.ts +++ b/p10-warehouse-workloads/src/server/console-app.ts @@ -100,10 +100,7 @@ export async function createConsoleApp( app.setErrorHandler((error, request, reply) => { const domain = requestError(error, "service_unavailable"); if (domain.statusCode >= 500) { - console.error("P10 console request failed", { - requestId: request.id, - error: error instanceof Error ? error.name : "unknown", - }); + console.error(`P10 console | ERROR | ${domain.code} | ${request.id}`); } void reply .code(domain.statusCode) diff --git a/p10-warehouse-workloads/src/server/console-main.ts b/p10-warehouse-workloads/src/server/console-main.ts index 8e4c944..d6ad350 100644 --- a/p10-warehouse-workloads/src/server/console-main.ts +++ b/p10-warehouse-workloads/src/server/console-main.ts @@ -1,11 +1,9 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { loadEnvFile } from "node:process"; import { loadConsoleConfig } from "./config.ts"; import { createConsoleApp } from "./console-app.ts"; +import { loadLocalEnvironment } from "./environment.ts"; import { listen, shutdown } from "./runtime.ts"; -if (existsSync(resolve(".env"))) loadEnvFile(resolve(".env")); +loadLocalEnvironment(); const config = loadConsoleConfig(); const app = await createConsoleApp(config); await listen(app, "P10 CedarStock", config.host, config.port, config.baseUrl); diff --git a/p10-warehouse-workloads/src/server/environment.ts b/p10-warehouse-workloads/src/server/environment.ts new file mode 100644 index 0000000..b77fdc7 --- /dev/null +++ b/p10-warehouse-workloads/src/server/environment.ts @@ -0,0 +1,10 @@ +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadEnvFile } from "node:process"; + +export function loadLocalEnvironment(): void { + // The supervisor already supplies only the credentials this service needs. + if (process.env.P10_SUPERVISED === "1") return; + const file = resolve(".env"); + if (existsSync(file)) loadEnvFile(file); +} diff --git a/p10-warehouse-workloads/src/server/validation.ts b/p10-warehouse-workloads/src/server/validation.ts index 12034af..68ec9d8 100644 --- a/p10-warehouse-workloads/src/server/validation.ts +++ b/p10-warehouse-workloads/src/server/validation.ts @@ -43,6 +43,15 @@ const id = (value: unknown) => const key = (value: unknown) => string(value, "idempotency_key_invalid", /^[0-9a-f]{8}-[0-9a-f-]{27}$/iu); +export function parseRouteBody( + value: unknown, + keys: readonly string[], +): Record { + const body = record(value); + exact(body, keys); + return body; +} + export function parseCommand(value: unknown): WorkloadCommand { const body = record(value); const type = body.type; diff --git a/p10-warehouse-workloads/src/web/App.tsx b/p10-warehouse-workloads/src/web/App.tsx index 63dbf27..b1da581 100644 --- a/p10-warehouse-workloads/src/web/App.tsx +++ b/p10-warehouse-workloads/src/web/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SKUS, type SkuId, @@ -9,10 +9,12 @@ import { type WorkloadId, warehouseLabel, } from "../shared/catalog.ts"; -import type { Transfer, WorkloadCommand, Workspace } from "../shared/types.ts"; +import type { Workspace } from "../shared/types.ts"; import { ApiError, api } from "./api.ts"; import cedarlingMark from "./assets/cedarling-mark.png"; import cedarlingWordmark from "./assets/cedarling-wordmark-dark.webp"; +import { CommandAttempt, type MutationInput } from "./command-attempt.ts"; +import { TransferDetails } from "./TransferDetails.tsx"; type Notice = Readonly<{ kind: "success" | "error"; text: string }> | undefined; type View = "transfers" | "inventory"; @@ -23,7 +25,8 @@ function message(error: unknown): string { version_conflict: "The transfer changed. Refresh and try again.", transition_invalid: "This transfer cannot make that transition.", insufficient_stock: "The source warehouse does not have enough stock.", - authorization_denied: "Cedarling denied this workload action.", + authorization_denied: + "This workload is not allowed to perform this action.", origin_invalid: "Reload the application before trying again.", dependency_unavailable: "A workload service is unavailable.", service_unavailable: "The application is unavailable.", @@ -37,13 +40,37 @@ function Footer() { return ( ); @@ -53,7 +80,7 @@ function Header() { return (
- + Cedarling
@@ -70,26 +97,27 @@ function Header() { ); } -function Status({ status }: Readonly<{ status: Transfer["status"] }>) { - return {status.replace("_", " ")}; -} - function Inventory({ rows }: Readonly<{ rows: Workspace["inventory"] }>) { return (
- Auditor view

Current inventory

+

Stock reported by the Inventory Auditor.

-
+
- + @@ -102,7 +130,7 @@ function Inventory({ rows }: Readonly<{ rows: Workspace["inventory"] }>) { ))}
Warehouse ItemAvailableOn hand
-
+
); } @@ -115,9 +143,23 @@ export function App() { const [selectedId, setSelectedId] = useState( parameters.get("transfer") ?? "", ); + const [mobileView, setMobileView] = useState< + "list" | "create" | "detail" | "inventory" + >(() => + parameters.get("view") === "inventory" + ? "inventory" + : parameters.has("transfer") + ? "detail" + : "list", + ); + const railRef = useRef(null); + const panelRef = useRef(null); const [workspace, setWorkspace] = useState(); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); + const pending = useRef(false); + const attempt = useRef(new CommandAttempt()); + const [needsRefresh, setNeedsRefresh] = useState(false); const [notice, setNotice] = useState(); const [draft, setDraft] = useState<{ sourceId: WarehouseId; @@ -152,36 +194,77 @@ export function App() { ? current : (next.transfers[0]?.id ?? ""), ); - } catch (error) { - setNotice({ kind: "error", text: message(error) }); + setNeedsRefresh(false); } finally { setLoading(false); } }, []); useEffect(() => { - void load(); + void load().catch((error: unknown) => { + setNotice({ kind: "error", text: message(error) }); + setNeedsRefresh(true); + }); }, [load]); useEffect(() => { updateLocation(view, selectedId); }, [selectedId, updateLocation, view]); + useEffect(() => { + if ( + mobileView !== "list" && + window.matchMedia("(max-width: 980px)").matches + ) + panelRef.current?.focus(); + }, [mobileView]); const run = async ( workloadId: WorkloadId, - command: WorkloadCommand, + command: MutationInput, success: string, ) => { + if (pending.current || needsRefresh) return; + pending.current = true; setBusy(true); setNotice(undefined); try { - const result = await api.command(workloadId, command); - if (result.transfer) setSelectedId(result.transfer.id); - await load(); - setNotice({ kind: "success", text: success }); + const result = await api.command( + workloadId, + attempt.current.prepare(workloadId, command), + ); + attempt.current.acknowledge(); + if (result.transfer) { + const transfer = result.transfer; + setSelectedId(transfer.id); + setWorkspace((current) => + current + ? { + ...current, + transfers: [ + transfer, + ...current.transfers.filter( + (item) => item.id !== transfer.id, + ), + ], + } + : current, + ); + setMobileView("detail"); + } + try { + await load(); + setNotice({ kind: "success", text: success }); + } catch { + setNeedsRefresh(true); + setNotice({ + kind: "error", + text: `${success} Refresh the workspace to load current stock before continuing.`, + }); + } } catch (error) { setNotice({ kind: "error", text: message(error) }); } finally { + pending.current = false; setBusy(false); } }; @@ -192,43 +275,75 @@ export function App() { { type: "transfer.create", ...draft, - idempotencyKey: crypto.randomUUID(), }, `${WORKLOADS.find(({ id }) => id === workloadId)?.shortLabel} created the transfer.`, ); }; - if (loading) return
Loading CedarStock…
; + if (loading) + return
Loading warehouse workspace…
; return (
-
-
+ -
+