diff --git a/agents/morgana/work/queue-193a/BUILD.md b/agents/morgana/work/queue-193a/BUILD.md new file mode 100644 index 000000000..ee42c448a --- /dev/null +++ b/agents/morgana/work/queue-193a/BUILD.md @@ -0,0 +1,64 @@ +# Row 193a build: the viewer level on R1's access file + +Morgana (Opus 5.5), 2026-10-20. Gitea #1691, parent row 193, design `agents/morgana/work/queue-193/DESIGN.md` +r5 (e2d161e2e on morgana/row193, approved at r4 by Duckworth, duckworth-0014). Go: sage-1373 item 3. Base: +`refactor` at aa1bd209e. Lands after FED R1 and is rebased onto R1's landing. + +## What it is + +Design 4.2: R1 refuses any grant level but `collaborator`. Row 193a widens that check to `collaborator` or +`viewer` and refuses anything else. `covers` needs no change: FED 3.5 already compares the grant's level +with the one asked for, so a viewer grant matches `{}` and `{level: 'viewer'}`, and fails +`{level: 'collaborator'}`. + +## Why the candidate holds only a test now + +R1 (`packages/business/src/access.mjs`) has no candidate yet. Sage ruled no stub on refactor (sage-1373 +item 3). So this branch carries: + +- `packages/business/tests/access-levels.test.mjs`: the row 193a tests, written against R1's interface as + FED r4.5 (acd5f1f42) sections 3.5 and 7 give it. +- The source change, below, to apply on R1's candidate at rebase. + +Until R1 lands, the test file fails with `ERR_MODULE_NOT_FOUND` for `../src/access.mjs`. That is expected. + +## The source change for the rebase + +In R1's level check, the allowed set becomes `["collaborator", "viewer"]`. It is one line wherever R1 keeps +the level rule. Its refusal message must name the key path `grants[].level`, as R1's own rule does. + +## Interface assumptions (to confirm against R1) + +FED r4.5 names `covers(grant, {business, project, instance}, now, {level})` exactly. It doesn't name the +loader or validator export, or the shape of the context argument. The test assumes: + +- a pure validator `checkAccess(doc, {providers, peers, businesses})` that throws a `BusinessError` + (exit 2) naming the key path, as `checkVars` does; +- `businesses` maps a business id to `{human, instances}`, because the rules need each business's + `human` and its declared instances. + +Every call into the validator goes through one `check()` helper at the top of the test file, so a different +name or shape changes one place at rebase. The `covers` calls follow FED exactly. + +One test case R1 may not cover itself: a grant with no `level` refuses (`undefined` in the refused list). +Design 4.2 says anything but the two levels refuses, and a missing level is not one of them. + +## Evidence + +Run on 2026-10-20 in the row193a worktree with `node --test --test-reporter=tap +tests/access-levels.test.mjs` from `packages/business`. + +- Base (no access.mjs): fails to load, `ERR_MODULE_NOT_FOUND`. +- Throwaway reference: an `access.mjs` with only the level rule, a declared-business rule and FED's + `covers`, placed in `src/`, run, then deleted, never committed. Result: pass 4, fail 0. + +Mutants on that reference, each failing exactly one test: + +| Mutant | Failing test | +|---|---| +| levels back to collaborator only | 1, "a grant's level is collaborator or viewer" | +| `owner` added to the levels | 2, "any other level refuses with the key path named" | +| `covers` ignores the asked level | 3, "covers tells a viewer grant from a collaborator grant" | +| `covers` passes any grant when collaborator is asked | 3 | + +At rebase, the same four mutants run again on R1's real code before the row goes to review. diff --git a/packages/business/tests/access-levels.test.mjs b/packages/business/tests/access-levels.test.mjs new file mode 100644 index 000000000..24cde07b7 --- /dev/null +++ b/packages/business/tests/access-levels.test.mjs @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { BusinessError } from "../src/errors.mjs"; +import * as access from "../src/access.mjs"; + +// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its +// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers() +// tells the two apart. Every call into R1 goes through check() below, so a change to R1's loader +// interface edits one place. +const CONTEXT = { + providers: ["authentik"], + peers: ["factory"], + businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } }, +}; +const check = (doc) => access.checkAccess(doc, CONTEXT); + +const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69"; +const NOW = new Date("2026-10-20T19:30:00Z"); + +function file(level) { + const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] }; + if (level !== undefined) grant.level = level; + return { + accessVersion: 1, + revision: 1, + users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }], + grants: [grant], + }; +} + +function refuses(fn, pattern) { + assert.throws(fn, (error) => { + assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); + assert.equal(error.exitCode, 2); + assert.match(error.message, pattern); + return true; + }); +} + +test("a grant's level is collaborator or viewer", () => { + for (const level of ["collaborator", "viewer"]) { + assert.doesNotThrow(() => check(file(level)), level); + } +}); + +test("any other level refuses with the key path named", () => { + for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) { + refuses(() => check(file(level)), /grants\[0\]\.level/); + } +}); + +test("covers tells a viewer grant from a collaborator grant", () => { + const target = { business: "creative-hub", project: PROJECT, instance: "researcher" }; + const viewer = file("viewer").grants[0]; + const collaborator = file("collaborator").grants[0]; + // Share reads ask for any level (design 9); create, open, send and share ask for collaborator. + assert.equal(access.covers(viewer, target, NOW, {}), true); + assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true); + assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false); + assert.equal(access.covers(collaborator, target, NOW, {}), true); + assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true); + assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), false); +}); + +test("a viewer grant still needs its business, project, instance and expiry to cover", () => { + const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" }; + const target = { business: "creative-hub", project: PROJECT, instance: "researcher" }; + assert.equal(access.covers(viewer, target, NOW, {}), true); + assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, {}), false); + assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, {}), false); + assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, {}), false); + assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), {}), false); +});