Skip to content

Promote staging to main: access-checked case images, archived Discover copies, canvas and docs updates - #1003

Merged
chrisdburr merged 309 commits into
mainfrom
staging
Sep 29, 2026
Merged

chrisdburr merged 309 commits into
mainfrom
staging

Conversation

@chrisdburr

Copy link
Copy Markdown
Collaborator

Promotion: staging → main

Carries every PR merged to staging since #947 (9 September): #910, #948–#954, #956–#1002. Each was gated on its own PR (tests, lint, coverage-mapped structural quality, and review). The Structural Quality check on this PR audits the whole delta as one change and is expected to fail; it is not a required check on main.

Headline changes

  • Case images are access-checked (fix: check access before serving case images, isolate published copies from live edits #1002): private screenshots and feature images are served only to users who can view the case; published images are copied at publish time and served from a version-scoped public route; the anonymous /uploads/* route is removed.
  • Trashing a published case (feat: ask whether to remove or archive a published case's Discover copy when trashing it #1001): the owner chooses to remove the Discover copy or keep it as an archived copy.
  • Canvas: away goals, modules and defeaters rendered and added from the canvas; selected-edge highlighting; GSN-style defeater identifiers.
  • Documentation moved from Nextra to Fumadocs; Getting Started, Building a Case and trainee modules 2–4 added.
  • Plugins management page with an off-switch confirmation.
  • Security: OAuth tokens encrypted at rest; per-user session version; password reset tokens stored hashed.
  • JSON editor: schema-aware editing; json-schema-v1.0.json generated from the export schema.

Database migrations (six, applied on deploy)

  • 20260914000000_add_defeats_dangling
  • 20260916000000_add_module_reference_dangling
  • 20260923000000_reconcile_schema_drift — also clears references to hard-deleted users before adding foreign keys
  • 20260924000000_add_session_version
  • 20260924010000_hash_password_reset_tokens — password reset links issued before the release stop working
  • 20260928000000_archived_published_cases

All six apply on every staging reseed. Staging data is freshly seeded each time, so the data-changing steps have not yet run against production's data.

Configuration

Production and staging App Service settings carry the same 21 names, including TOKEN_ENCRYPTION_KEY.

Release effect

No breaking-change markers since v0.7.0; 18 feat commits → minor bump (v0.8.0).

After merge

Once production is on this code, the media container on teastorageaccount (shared by staging and production) is set to private access, and direct blob addresses are checked to fail while the app still shows images on both environments.

…hema

ADR 0004 D1: the JSON Schema file becomes a generated artefact (Zod ->
JSON Schema, draft-07 for the JSON editor's json-schema-library resolver)
instead of a hand-maintained one that had drifted to 6 element types
against the Zod schema's 10. Adds pnpm schema:generate and a comment on
the export envelope naming version as the format-migration key.
…abularies

Fails if the committed json-schema-v1.0.json and a fresh generation
disagree, or if the Prisma ElementType/AssertionStatus enums and their
Zod counterparts (ElementTypeSchema, AssertionStatusSchema) drift apart
in either direction (ADR 0004 D1's build-time consistency check).
…portComment

ADR 0004 D1: types/curriculum.ts hand-copied these three from
lib/schemas/case-export.ts and had drifted (a `title` field on TreeNode
no real export ever set). Re-export the Zod-derived types instead;
drop the one dead consumer read of that field in case-data-transformer.ts.
…re-exports

fallow flagged these as unused type exports and duplicate exports (vs
lib/schemas/case-export.ts, which already exports all four) once the
hand-copied TreeNode that consumed them internally was removed.
The __schema<n> -> TreeNode rename assumed exactly one recursive
definition reachable from CaseExportNestedSchema; a blanket replaceAll
would have silently merged two distinct recursive shapes into one $ref
if that assumption ever broke. Extracted into renameRecursiveDef(),
which counts distinct placeholder ids and throws, naming the
assumption, if the count isn't exactly one. Covered directly with
synthetic two-lazy/zero-lazy/one-lazy schemas, since
CaseExportNestedSchema only ever exercises the passing case.

Also reworded a comment that called the drift test's comparison
"byte-for-byte" — it's a structural toEqual, not a byte comparison.
The JSON editor's hover test expects TreeNode.id's description ("Unique
identifier for the element"), which the hand-written schema carried but
TreeNodeSchema never did (case-export.ts's other schema for the same
shape, ElementV2Schema, already described every one of its fields —
only TreeNodeSchema was short a set of .describe() calls).

Compared every description in the pre-generation json-schema-v1.0.json
(git show origin/staging~1) against TreeNodeSchema and restored the
ones that describe a real, unconditional field: id, name, description,
inSandbox, children, comments, fromPattern, modifiedFromPattern,
isDefeater, defeatsElementId. Left alone: the top-level document
description (the "authoritative specification" framing ADR 0004 D1
demotes), ElementType's stale "Context/Justification/Assumption are now
attributes" note (wrong against the current 10-type vocabulary), and
every description that only existed inside the old file's per-type
allOf/if/then blocks (assumption, justification, context, url, level,
moduleReferenceId, moduleEmbedType, modulePublicSummary) — those
blocks encoded validation rules Zod never had, and their specific
per-type wording isn't separable from the rule ADR 0004 D1 accepts the
loss of.

Regenerated json-schema-v1.0.json.
A node with side attachments (data.attachedTo) now becomes an ELK group
laid out perpendicular to the tree direction, with the tree itself laid
out over groups via SEPARATE_CHILDREN hierarchy handling. Positions are
flattened to absolute coordinates before being handed to React Flow.

Verified against the nine-node probe from the ADR: every side attachment
lands in its target's row, to the right, and a side element's own subtree
sits beneath it rather than beneath the challenged node.
- case-fetch-service.ts admits AWAY_GOAL and MODULE children alongside
  PROPERTY_CLAIM, and resolves cited case/element names and viewer
  accessibility once per fetch for the new cards to show without a
  follow-up request.
- convert-case.ts routes a defeater to its target's cell (data.attachedTo)
  when the target is present and visible, otherwise it keeps its ordinary
  tree position; createEdgesFromNodes draws the challenges edge, replacing
  the support edge when the target is also the defeater's real parent.
- New away-goal-node.tsx and module-node.tsx cards, a challenges-edge.tsx
  (dashed, destructive token, open arrowhead at the target), and side
  handles on BaseNode so the edge is straight and horizontal.
- flow.tsx's hard-coded four-entry nodeTypes map is replaced by one
  resolution function (lib/case/node-type-resolver.ts, framework-agnostic;
  components/cases/node-type-resolver.ts pairs it with renderers) shared by
  convert-case.ts and the node-kind tests.
…ADR 0005 D7)

- "Add defeater" on the add-child menu of a goal, property claim or
  strategy creates a PROPERTY_CLAIM child flagged isDefeater with
  defeatsElementId set to the element the menu was opened on, through the
  existing element-creation path.
- "Add away goal" / "Add module" open a two-step picker (case, then goal
  for an away goal) backed by two new server actions
  (actions/cited-element-picker.ts): listCitableCases and listCitableGoals,
  both delegating to case-fetch-service.ts's existing permission checks.
  listCaseGoalElements is new there.
- The edit dialog shows a read-only "Challenges: <target name>" line for a
  defeater; changing the target stays out of scope.
- lib/case/types.ts's CreateNodePayload gains the fields the write path
  already accepted; lib/element-types.ts's collection-name map gains
  awaygoals/modules.
…solver

createEdgesFromNodes: replace-vs-both edge rules and the missing-target
fallback (ADR 0005 D4/D2). convertAssuranceCase: routes a defeater's
data.attachedTo when its target resolves, leaves it unset otherwise.
resolveReactFlowNodeType: every element-type spelling this codebase uses,
plus the unrecognised-type fallback and the D6 flags seam.
…elements

createElementSchema silently had no moduleEmbedType field, but
lib/prisma.ts's element-validation extension requires it for MODULE at
create time — creating a module via the new "Add Module" flow would have
thrown. Threaded through createElementSchema, element-service.ts's create,
CreateNodePayload, and the picker form (defaults to COPY, no UI choice of
embed type in 1.0). Test factory gains the same field for MODULE fixtures.

test(canvas): integration coverage for D3 admission and D7 server actions

case-fetch-service-side-attachments.test.ts: AWAY_GOAL/MODULE admitted
under goal/strategy/property claim, cited names and viewer accessibility
resolved, isDefeater/defeatsElementId pass through. cited-element-picker-
actions.test.ts: permission matrix (own case, shared VIEW/EDIT, no access,
non-existent case) for listCitableCases/listCitableGoals.
Away-goal and module cards shared an identical 20-line "view case" link
block — extracted to cited-case-link.tsx (fallow duplication finding).
components/cases/node-type-resolver.ts's resolveNodeRenderer and
lib/case/node-type-resolver.ts's REACT_FLOW_NODE_KINDS were unused exports
(fallow dead-code finding); the nodeTypes map and resolveReactFlowNodeType
cover every real caller.

test(e2e): canvas coverage for away goals and defeaters (ADR 0005)

Builds its fixtures through the canvas's own "Add away goal"/"Add
defeater" flows rather than JSON import — case import currently drops
isDefeater/defeatsElementId on both formats (tracked separately; the ADR
itself notes defeaters reach the canvas by API/UI until that's fixed).
validateCitedElementId only checked existence and self-reference — an
AWAY_GOAL's citedElementId could point at any element system-wide, and
buildCitationContext then resolved and displayed that element's name to
every viewer of the citing case. Now requires citedElement.caseId to match
the effective moduleReferenceId (the update path's own value if changing,
otherwise the element's existing one), returning the same "must reference
an existing element" message as not-found — anti-enumeration.

test(canvas): cross-case citedElementId rejected on create and update, and
confirm the D7 picker action (listCitableGoals) can't itself produce one
since it only ever lists goals scoped to the requested case.
BaseNode gains an isDefeater prop: a destructive-token border override
(buildNodeContainerClasses) and a "Defeater" badge in the header, always
visible (not just when expanded) — wired through all four existing card
kinds (goal, strategy, property, evidence) from data.isDefeater. Previously
nothing rendered this at all; the only isDefeater read in components/ was
the edit dialog's read-only "Challenges" line.

test(canvas): render tests for the chip/border default-off and on-when-
isDefeater states, across all four card kinds.

fix(e2e): scope the Defeater assertion to the actual defeater's own card
(by its description text, since the auto-generated identifier isn't known
ahead of time) rather than an unscoped getByText, which was passing
against unrelated page text since no chip existed to fail against it.
Scope the Case/Goal comboboxes to the dialog. Assert challenges-edge count
rather than visibility, which is unreliable on a zero-height horizontal path.
lib/case/__tests__/layout-helper.test.ts: the single large probe it() is
now several focused cases (row+right placement, row order, no overlaps,
each under TB and LR). Adds a second target with two side attachments
(stacked in one column, target's own tree row unaffected) and sort-order
coverage (attachments given in reverse identifier order still sort
correctly). Fixes an overclaim: the old comment said CSn1 sits "beneath
its own root (CG1)" specifically, distinguishing it from G2's column —
empirically (printed positions) it does not; only cell-membership row
placement is guaranteed, not which member's column a child aligns to. The
assertion now matches that.
AddCitedElementForm (CRAP 182, 0% covered) was one component owning
server-action fetching, two-step selection state, and the discriminated
create payload inline. Split out:
- hooks/use-cited-element-picker.ts — cases/goals fetching and selection
  state, unit-testable via renderHook without rendering the form.
- lib/case/build-cited-element-payload.ts — the away-goal-vs-module
  discriminated payload (citedElementId vs moduleEmbedType: "COPY"),
  a pure function.
The form is now a thin presentational shell wiring the two together.

fix(canvas): away goal / module cards now start expanded — the cited or
referenced case is the card's whole point, and it was previously hidden
behind BaseNode's expand toggle since it's passed as `children` (only
rendered in the expanded state). AwayGoalNode/ModuleNode set
initialExpanded so it shows without a click, matching what D3 asks the
card to show.

fix(canvas): handleDefeaterAdd left `loading` stuck true on a create
error (return before setLoading(false), mirroring an existing bug in the
handleClaimAdd pattern it was copied from) — fixed in the new function so
the "Add" button doesn't stay stuck spinning on failure.

test(canvas): render tests for AddCitedElementForm (away-goal vs module
payload, description prefill, error path), the picker hook (loading,
error toasts, case-change clearing the selected goal), the payload
builder (discriminated union, name trimming), and handleDefeaterAdd
(payload shape, all three opener kinds, error toast).
lib/element-compatibility.ts's REACTFLOW_TO_CANONICAL and VALID_CHILDREN
didn't know awayGoal/module (ADR 0005 D3) — getCompatibleChildTypes and
canBeChildOf treated them as unknown types, so the attach flow never
offered them as valid children of a goal/strategy/property claim they'd
been detached from. Added as leaves (same three parents as property
claim, no children of their own in 1.0).

refactor(canvas): extract the duplicated awayGoals/modules array building
(buildGoalStructure, buildStrategyStructure, buildPropertyClaimStructure
in case-fetch-service.ts each repeated the same filter-sort-map pair) into
buildCitedChildren — the one clone group fallow flagged in this file.

fix(canvas): humanise "away_goal" in the history feed ("Created away goal
\"AG1\"" instead of the raw snake_case type); "module" already matched the
existing lowercase convention unchanged.

test: unit coverage for both.
…gate

layout-helper.ts's flattenPositions exceeded the cognitive-complexity
threshold even with the split probe tests covering it (coverage lowers
CRAP but doesn't remove a raw complexity finding) — extracted the cell-
member flattening into flattenCellMembers.

case-fetch-service.ts's buildGoalStructure/buildPropertyClaimStructure
still shared one filter-sort-map clone (the STRATEGY-children block) after
the earlier buildCitedChildren extraction shifted surrounding lines enough
for fallow to flag it as newly introduced — extracted buildNestedStrategies.
…oves

enforceCitedElementIdRules short-circuits when citedElementId is absent
from the request, so PUT { moduleReferenceId: caseB } alone left the
existing citedElementId pointing into the old case — the away-goal card
then showed the new case's name beside the old case's element name.

validateUpdateElementFields now re-validates the EXISTING citedElementId
against the new moduleReferenceId whenever moduleReferenceId changes and
the request doesn't also touch citedElementId — same rule, same
not-found-shaped error as an explicit citedElementId. Requires
citedElementId in updateElement's own Prisma select (existing didn't carry
it — validateUpdateElementFields had no way to see it before).

test(canvas): moduleReferenceId-only change away from the citing case is
rejected; moduleReferenceId + a repointed citedElementId in the same
request is accepted.
validateCitedElementId used `moduleReferenceId && target.caseId !==
moduleReferenceId` — a falsy (null/undefined) moduleReferenceId short-
circuited the `&&` and skipped the case-membership check entirely.
updateElementSchema allows moduleReferenceId: null (clearing it), so
PUT { moduleReferenceId: null, citedElementId: <any element in any case> }
was accepted outright: the round-1 leak through a different route.

A citation now requires a case unconditionally: citedElementId with no
effective moduleReferenceId is rejected (same not-found-shaped message),
and the case-membership comparison always runs once moduleReferenceId is
present. This also closes the "clear the case" variant for free, via the
round-2 re-validation path: PUT { moduleReferenceId: null } alone, with an
existing citation, re-validates that existing citedElementId against the
now-null moduleReferenceId and is rejected; PUT { moduleReferenceId: null,
citedElementId: null } clears both together and is accepted.

The create path has no equivalent gap — moduleReferenceId is required
before citedElementId is ever checked (enforceModuleReferenceIdRules runs
first in validateElementReferences) — confirmed with a test rather than
assumed.

test(canvas): four cases in api-elements-cited-element-id.test.ts — cross-
case citation with moduleReferenceId: null rejected; moduleReferenceId:
null alone with an existing citation rejected; both cleared together
accepted; create-path AWAY_GOAL with citedElementId but no
moduleReferenceId at all rejected by the requiredness check.
fix(import): carry defeaters through import in both formats, flag unresolvable targets
…n-schema

feat(schemas): generate json-schema-v1.0.json from the Zod case schema (ADR 0004 D1)
…ttachments

feat(canvas): render and create away goals, modules and defeaters as side-attached elements (ADR 0005)
…e-queue

ci: queue staging deploys and reseeds so they never overlap
… of removing it

Deleting a published case now asks whether to remove its Discover copy or
keep it as an archived copy that no longer updates. Archived copies survive
permanent deletion of their case, carry an "Archived" label on Discover, and
can be removed by their author from the Trash page at any time. Restoring a
case makes its archived copy live again; account deletion archives rather
than removes; the API removes unless asked to archive; publishing refuses a
case that is in Trash.

This also fixes permanent deletion and the daily trash-purge cron for any
case with a published copy: the published-copy table's foreign key to its
case is relaxed to ON DELETE SET NULL (migration
20260928000000_archived_published_cases), so a permanent delete clears the
link instead of being refused.
Covers behaviour the implementer's own tests do not exercise: no orphan
published row survives a refused publish/republish of a trashed case, an
Admin collaborator who archives a copy cannot become its remover, the
daily batch purge (purgeExpiredCases) with a mix of archived-copy and
ordinary cases, a restore after the archived copy was individually
removed, a kept account-deletion case left completely untouched, and the
DELETE route's own archive query-param parsing (default, false, junk,
true).
… and close the publish/trash races

- Extract the published-case delete dialog into a shared
  components/cases/delete-case-dialog.tsx, used by both the case page
  toolbar and the dashboard case cards (which previously always removed
  the Discover copy with no choice). Cards now carry a `published` flag
  through the case-list service/response types.
- Word the "Keep as archived" choice for a non-owner Admin collaborator
  differently from the owner, using a new `isOwner` flag on the case
  response.
- Lock the case row before the published row in publishAssuranceCase and
  updatePublishedCase, matching softDeleteCase's own lock order, so the
  two paths can't deadlock against each other.
- removeArchivedCopy re-checks its ownership/archived guard at delete
  time (a single guarded deleteMany), closing a race where a concurrent
  restore could otherwise let a live copy be deleted.
- Validate the archived-copy route's id with uuidSchema so a malformed
  one 404s like a missing one, instead of 500ing.
- archivePublishedCopies no longer re-stamps a copy that's already
  archived.
- listArchivedCopies narrows archivedAt without a cast, matching its own
  where clause.
- Resolve a duplicated session+id validation block by combining the
  case id and delete options into one Zod schema.
- Regenerate docs/database/schema.dbml and public/openapi.json; drop the
  ruling/date citations from the added comments and doc strings
  throughout, and correct a stale FK comment in the e2e cleanup.
…hived-copy edge cases

- Add a row-lock test helper that holds a genuine Postgres row lock open
  inside a transaction until released, so a guard that only fires under
  real concurrency (an updateMany/deleteMany matching zero rows) can be
  reached deterministically, instead of racing on Promise.all timing or
  relying on a case already being fully trashed before the call under
  test starts.
- Cover CaseTrashedDuringTransactionError in both publishAssuranceCase
  and updatePublishedCase, and CaseAlreadyInTrashError in softDeleteCase,
  none of which any existing test reached (every one trashed the case
  first, so the earlier permission/pre-check always won).
- Cover the removeArchivedCopy race against a concurrent restore, the
  already-archived re-stamp guard, and removing an archived copy whose
  case has already been permanently deleted.
- Add route tests for the two new archived-copy endpoints (auth, missing
  id, malformed id, someone else's copy, success) and for the delete
  action's archive choice and malformed-options rejection.
- Add component tests for the shared delete dialog and the Trash page's
  archived-copies list.
…archiving accurately

- Trash/publishing/retention guide pages: describe the delete dialog's
  published-copy choice, restoring an archived copy, and the Trash
  page's "Archived on Discover" section.
- Correct two inaccuracies: the dialog for a published case replaces
  the usual one rather than adding an extra step, and account deletion
  only archives the copies of cases that move to Trash, not every case
  the deleted person published.
- One sentence per source line, for every sentence this change added or
  changed.
…ests

Replaces the fixed 50ms delays in the trash/publish row-lock races with a
poll against pg_stat_activity, so each test proves the racing call is
actually blocked on the guarded write before releasing the held lock,
rather than assuming its pre-transaction read finished in time. Also makes
holdRowLock's release() reject with the holder transaction's own error
instead of hanging when that transaction fails. Rewrites the adversarial
test file's header comment to describe its coverage without naming an
issue title or design note.
…s from live edits

Screenshot and feature-image bytes now flow through access-checked
private routes (/api/cases/[id]/media/screenshot, .../media/feature)
instead of raw storage addresses. A case's feature image is copied to
its own key at publish/republish time and served by a version-scoped
public route, so editing the live image can no longer affect or break
a published or archived Discover page. Local uploads move out of
public/ to UPLOADS_DIR, and /uploads/[...path] is removed.
Covers the four access outcomes on both private media routes (no
session, no access, VIEW, owner after re-upload), the case-information
form re-save keeping the displayed route address treated as unchanged,
the published-image copy surviving live edits and republish while a
superseded version's address stops resolving, an archived copy staying
reachable after its case is permanently deleted, every removal path
(unpublish, remove-from-discover, archive-then-purge, remove-archived-
copy) deleting the file it copied, path-traversal payloads in a stored
key never escaping the uploads root, and the same access and cleanup
behaviour against the Azure-backed storage path.
…ng a key it doesn't own

A case's feature-image write path now accepts only its own media route
address (with or without a cache-busting query), empty, or a genuine
external https address — a bare storage key, another case's own
address, or a legacy stored-value shape is refused. The upload route
persists a newly generated key through an internal setter the write
path itself cannot reach.

Reading, publish-time copying and deleting a case's feature image now
also check that the stored key actually starts with that case's own
key prefix, refusing (and, for a skipped copy, logging) anything else.
Every storage read, copy, delete and upload — on both the local and
Azure backends — validates the key first, rejecting an empty, `.` or
`..` segment, a backslash, a NUL byte, or a leading slash before it
reaches the filesystem or the Azure SDK; the local-storage fallback
routes go through the same path-safety check rather than building
paths directly. The publish and republish flows now share one helper
for composing a snapshot, copying its feature image, running the
caller's transaction and cleaning up the copy on failure.
…nses

A published item's summary, its detail response and its embedded
snapshot content now carry a version-scoped public route address for
an internal feature-image key (including a legacy stored shape from
before publish-time copies existed), never the raw storage key. A
genuine external address and an empty value still pass through
unchanged.
…d support conditional 304 requests

The private feature-image and screenshot route addresses now carry a
short cache-busting query derived from the current storage key, so a
browser sees a fresh address every time the image is replaced rather
than reusing bytes cached against the old one. The two private routes
and the public Discover image route ignore that query but now answer
a request whose If-None-Match header matches the current ETag with an
empty 304 response.
Integration tests that write real media files no longer touch this
worktree's own uploads directory: each vitest worker now gets a fresh
temporary directory for UPLOADS_DIR, removed again once the worker's
tests finish. The uploads directory is also excluded from the Docker
build context.
Covers the download, upload and delete branches directly against a
mocked @azure/storage-blob client — not configured, success, a
not-found blob, and any other SDK error — plus the key-validation
refusal on all three, none of which any existing test reached since
every other test mocks this module wholesale.
…cess and header details

Updates seed fixtures whose key shape the feature-image ownership
check now refuses, and updates two assertions to expect that refusal
rather than a silent overwrite. Adds coverage for a direct EDIT
collaborator and a team-granted viewer on both private media routes,
Cache-Control and Content-Length on those routes and the public one,
a legacy /uploads/ path and a legacy Azure blob URL served through
both the private feature route and the public route, and the
publish-time copy being deleted when its transaction fails. Also
updates assertions that previously expected a private media address
to stay fixed across a re-upload, and one address-source comment.
…media

fix: check access before serving case images, isolate published copies from live edits
@github-actions

Copy link
Copy Markdown

promotion PR — structural quality was gated per feature PR on staging

@chrisdburr
chrisdburr merged commit c3570eb into main Sep 29, 2026
21 of 23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant