From df990d0becb161cecd26448e07f58f285c41a402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Wall?= Date: Wed, 24 Jun 2026 09:50:00 +0200 Subject: [PATCH 1/5] Guard endpoint access with auth --- .agents/skills/faim-test/SKILL.md | 197 +++++++ .claude/skills/faim-test/SKILL.md | 197 +++++++ .faim/axioms.sha256 | 1 + .faim/axioms/decisions.pl | 12 + .faim/axioms/invariants.pl | 6 + .faim/axioms/requirements.pl | 24 + .faim/axioms/schema.pl | 42 ++ .faim/derived/provenance.pl | 494 ++++++++++++++++++ .faim/derived/structure.pl | 103 ++++ .faim/faim.conf | 34 ++ .faim/rules/deduction.pl | 28 + .faim/rules/reasoning.lp | 24 + .gitignore | 1 + scripts/prisma-generate-safe.mjs | 10 + src/server/routes/foodSelections.ts | 3 + src/server/routes/menus.ts | 10 + src/server/routes/polls.ts | 2 + src/server/routes/shoppingList.ts | 1 + tests/server/food-selection-routes.test.ts | 15 +- .../server/meal-recommendation-routes.test.ts | 7 +- tests/server/menu-routes.test.ts | 99 ++-- tests/server/poll-routes.test.ts | 21 +- 22 files changed, 1290 insertions(+), 41 deletions(-) create mode 100644 .agents/skills/faim-test/SKILL.md create mode 100644 .claude/skills/faim-test/SKILL.md create mode 100644 .faim/axioms.sha256 create mode 100644 .faim/axioms/decisions.pl create mode 100644 .faim/axioms/invariants.pl create mode 100644 .faim/axioms/requirements.pl create mode 100644 .faim/axioms/schema.pl create mode 100644 .faim/derived/provenance.pl create mode 100644 .faim/derived/structure.pl create mode 100644 .faim/faim.conf create mode 100644 .faim/rules/deduction.pl create mode 100644 .faim/rules/reasoning.lp diff --git a/.agents/skills/faim-test/SKILL.md b/.agents/skills/faim-test/SKILL.md new file mode 100644 index 0000000..ff80dfd --- /dev/null +++ b/.agents/skills/faim-test/SKILL.md @@ -0,0 +1,197 @@ +--- +name: faim +description: >- + Query and maintain a project's formal, logic-based memory in `.faim/`. Use + whenever a `.faim/` directory is present: orient via stored facts before + digging through code, check invariants/requirements before and after changes, + record what you derive with provenance, and keep facts fresh. Also triggers on + "faim", "formal memory", "project memory", or the `faim` CLI (init/bootstrap/ + check/status/query/reason/assert/add/retract/attest/stale/update/refresh/seal). +--- + +# FAIM — Formal AI Memory + +FAIM is a queryable, logic-based memory of a codebase. It replaces repeated code +digging with stored facts you can query, and catches drift/contradictions +formally. Facts live in `.faim/` and are versioned with the project. + +**Golden rule:** before reading code to answer a structural question, ask FAIM. +Trust facts by default; verify by exception when the stakes are high or the fact +is stale/low-confidence. + +## Mental model (minimum needed) + +- **Two tiers.** *Axioms* (`.faim/axioms/`) are co-authored ground truth — + descriptive (`prop(project, language, rust)`) or prescriptive + (`requirement(...)`). Changing them is high-ceremony. *Derived* + (`.faim/derived/`) are facts you scanned from code, with provenance. +- **EAV vocabulary.** Everything is `entity(Id, Kind)`, `prop(Id, Attr, Value)`, + `rel(Src, RelType, Dst)`. The legal kinds/attrs/reltypes are declared in + `axioms/schema.pl` — using an undeclared one is a hard error (anti-drift). +- **Trust = freshness, cheaply checked.** A fact whose source files are unchanged + (hash match) is trustworthy for free. Only changed-source facts need attention. + +## Commands + +```bash +# Orient — understand the project's state and structure +faim bootstrap [--project PATH] # whole-project overview: schema vocabulary, requirements, state, checklist +faim status [--project PATH] # fact counts, stale count, axiom-hash, violations, req enforcement +faim query "GOAL" [--project PATH] [--json] [--limit N] # ad-hoc Prolog goal — one answer to deduce +faim query "reaches(A, Rel, B)" # built-in transitive closure over any reltype (impact/blast-radius) +faim reason [--max N] # ASP/clingo decision — many valid answers searched (e.g. min-cut to break cycles) + +# Validate — around changes and in CI +faim check [--strict] [--verify] [--on TARGET] # 4-pass validation; --verify runs allowlisted tools +faim seal # re-bless axiom digest after a deliberate Tier 1 (axiom) change + +# Record — write Tier 2 facts (with provenance) +faim add entity|prop|rel ARG… # quote-free EAV entry (no raw Prolog terms) — the common case +faim batch facts.txt --files A B --query "the question" # many EAV lines: entity|prop|rel ... +faim assert "FACT" --source agent_scan --files A B --query "the question" [--replace] # raw term; --why aliases --query; --files @list reads a path list +faim retract "FACT" | faim retract entity|prop|rel ARG… # remove a fact + provenance (quote-free mirrors add) +faim attest KIND TIMESTAMP # record a user-attested occurrence + +# Keep fresh — after sources change +faim stale # facts whose source files changed (cheap hash sweep) +faim update # re-derivation worklist for stale facts +faim refresh --all # re-bless hashes of stale facts whose meaning is unchanged (no re-assert) +``` + +## On session pickup (do this proactively) + +If the codebase is a nested repo/worktree, pass `--project PATH` instead of +changing directories. Provenance paths are stored relative to that FAIM root. + +1. `faim status` — learn fact counts, stale count, whether axioms changed. (First + time on an unfamiliar project? `faim bootstrap` gives the fuller one-shot + overview — schema vocabulary + requirements + state.) +2. Read `axioms/` — the project's intent, invariants, requirements. This is your + fast orientation; do not re-derive it from code. +3. `faim check --on schedule` (when temporal requirements exist) — surface any + overdue/unconfirmed time obligations and **prompt the user** about them + (e.g. "secret rotation looks overdue — done it?"). On confirmation, record it + with `faim attest`. FAIM never detects real-world events; you are the bridge. +4. If `faim stale` is non-empty, refresh those facts (see "Keeping facts fresh"). + +## Orienting on a task + +- Query FAIM instead of grepping: "what depends on X?", "which endpoints are + unauthenticated?", "what does module Y assume?". +- Each answer carries freshness/confidence. Fresh + high-confidence → trust and + proceed. Stale or low-confidence + high stakes → verify against code. +- `query` vs `reason`: reach for `query` when the question has **one** answer to + deduce (what depends on X, is Y authenticated, full reachability). Reach for + `reason` only when there are **many** valid answers to search/optimize over — + e.g. "fewest `depends_on` edges to cut to break all cycles". A plain impact set + is `query`, not `reason`. + +## Making a change + +Use the smallest memory that answers the task. Model only durable, decision-relevant +facts; do not map the whole codebase before touching code. + +1. Before: query dependents/relationships to scope the blast radius. Direct edges + are `rel(X, depends_on, target)`; for the *full* radius use the built-in + `reaches(X, depends_on, target)` — transitive closure over any reltype, so you + catch indirect dependents a one-hop query misses. Examples: + - event flow: `reaches(X, depends_on, client_sse_hub)` or `rel(service, emits, Event)` + - shared type flow: `reaches(X, depends_on, food_selection_type)` + - endpoint audit: `prop(E, mutates_state, true), prop(E, requires_auth, false)` +2. After: refresh affected derived facts, then `faim check`. Report any new + `violation` (broken invariant, now-unauthenticated endpoint, requirement + breached). **You surface; the user resolves** — never silently amend an axiom. +3. Changing an axiom is high-ceremony: make it explicit, get a good reason, and + expect a wave of re-derivation + a fresh check. After a deliberate, agreed + axiom change, run `faim seal` to re-bless the digest. + +## Deriving facts (Tier 2) — with discipline + +When you scan code and assert a fact: + +- **Record every file you consulted** in `--files`. Provenance completeness is the + linchpin of trust — a missing file means silent staleness later. `--files` accepts + globs and directories, so an aggregate fact (e.g. "package P depends_on Q", + derived from many files) records *all* of them, not one representative. + But don't over-couple: passing a whole directory ties the fact to *every* file + under it, so unrelated edits flag it stale. List the files that actually + determine the fact (for package containment/imports, usually the `__init__`/ + module-index file), not the entire tree. +- **One id, one kind.** A canonical key (path/module/FQN) maps to a single + entity of a single kind — `check` flags the same id under two kinds as an + identity violation. In ecosystems where a name is both a package and a module + (Python `flask.json` is a dir *and* an importable name), pick one kind for the + bare id and disambiguate the other (e.g. `flask.json` the package vs + `flask.json:__init__` the module). +- **Record the question** in `--query` (alias `--why`) so the fact can be + re-derived. Long file lists: `--files @sources.txt` (one path/glob per line). +- **Skip raw Prolog for the common case.** `faim add entity ID KIND`, + `faim add prop ID ATTR VALUE`, `faim add rel SRC RELTYPE DST` build the term and + quote it for you (same `--files/--query/--replace`). Reach for raw + `faim assert "TERM"` only when `add` can't express the shape. +- **Only materialize what earns it.** Store a fact iff it is (a) expensive or + impossible to cheaply re-derive, AND (b) decision-relevant, AND (c) cheaply + kept trustworthy (stable source or tool-verifiable). If you'd have to check the + code anyway, do not store it. +- **Prefer tool-verifiable facts** (`verifiable_by` + a `faim.conf` tool) over + your own judgement — they re-check cheaply and never go stale silently. + +## Keeping facts fresh + +- `faim stale` lists facts whose sources changed; `faim update` gives the + worklist (each with its derivation question + changed files). +- Re-read only the changed files, re-answer each question, then re-`faim assert` + with `--replace` (drops the stale copy + its provenance in place, then writes + fresh — no retract-first). Never re-scan everything — the unchanged majority is + already trustworthy. +- Use `faim refresh --all` only for facts whose **meaning is unchanged** after you + inspected the changed source bytes. If the answer changed, re-derive with + `--replace`; if you did not check, leave it stale. + +## Adopting FAIM on a new project + +1. `faim init`. +2. Co-author the minimum `axioms/schema.pl` vocabulary needed for the task. Common + TS/web vocabulary: `module`, `endpoint`, `event`, `type`, `interface`; + `depends_on`, `emits`, `consumes`, `exposes`; endpoint attrs such as `method`, + `path`, `mutates_state`, `requires_auth`. +3. Help the user state axioms — descriptive (language, architecture) and + prescriptive (coverage, auth, deploy gates). Then `faim seal` to bless the + axiom digest (authoring axioms changes it). +4. `faim bootstrap` to see the declared vocabulary + an authoring checklist, then + scan the codebase and record the high-value structural facts with `faim add` + or `faim batch` (quote-free, with provenance) — raw `faim assert` only for + shapes `add` can't express. Propose missing vocabulary as a schema addition + for the user to approve (that is a Tier 1 change, so re-`seal` after). +5. `faim check` — surface the first contradictions between stated intent and + observed reality. + +## Recipes + +### Invariant audit + +1. Add `requirement(ID, ...)` in axioms. +2. Record observed facts, e.g. endpoint `mutates_state` and `requires_auth`. +3. Add a `violation(ID, Subject, Reason)` rule. +4. Run `faim check`; fix code or record intentional exceptions with rationale. +5. Update facts, re-run `faim check`, and report exact validation commands. + +### Blast-radius change + +1. Record only the dependency edges needed for the target (`depends_on`, `emits`, + `consumes`, `exposes`). +2. Query `reaches(X, depends_on, Target)` before editing. +3. Edit the scoped files; then run `faim stale`, re-derive changed facts or + `refresh` only unchanged meanings, and `faim check`. + +### Validation reporting + +Report the exact cwd/project root, environment variables, and command. If a test +needs special env and you cannot reproduce it, say so instead of claiming pass. + +## Hard rules + +- Never put a derived fact in `axioms/`, never edit `axioms/` without explicit + user agreement. +- Never claim a fact is true beyond what its freshness/confidence supports. +- Never run a verification tool that is not allowlisted in `faim.conf`. diff --git a/.claude/skills/faim-test/SKILL.md b/.claude/skills/faim-test/SKILL.md new file mode 100644 index 0000000..ff80dfd --- /dev/null +++ b/.claude/skills/faim-test/SKILL.md @@ -0,0 +1,197 @@ +--- +name: faim +description: >- + Query and maintain a project's formal, logic-based memory in `.faim/`. Use + whenever a `.faim/` directory is present: orient via stored facts before + digging through code, check invariants/requirements before and after changes, + record what you derive with provenance, and keep facts fresh. Also triggers on + "faim", "formal memory", "project memory", or the `faim` CLI (init/bootstrap/ + check/status/query/reason/assert/add/retract/attest/stale/update/refresh/seal). +--- + +# FAIM — Formal AI Memory + +FAIM is a queryable, logic-based memory of a codebase. It replaces repeated code +digging with stored facts you can query, and catches drift/contradictions +formally. Facts live in `.faim/` and are versioned with the project. + +**Golden rule:** before reading code to answer a structural question, ask FAIM. +Trust facts by default; verify by exception when the stakes are high or the fact +is stale/low-confidence. + +## Mental model (minimum needed) + +- **Two tiers.** *Axioms* (`.faim/axioms/`) are co-authored ground truth — + descriptive (`prop(project, language, rust)`) or prescriptive + (`requirement(...)`). Changing them is high-ceremony. *Derived* + (`.faim/derived/`) are facts you scanned from code, with provenance. +- **EAV vocabulary.** Everything is `entity(Id, Kind)`, `prop(Id, Attr, Value)`, + `rel(Src, RelType, Dst)`. The legal kinds/attrs/reltypes are declared in + `axioms/schema.pl` — using an undeclared one is a hard error (anti-drift). +- **Trust = freshness, cheaply checked.** A fact whose source files are unchanged + (hash match) is trustworthy for free. Only changed-source facts need attention. + +## Commands + +```bash +# Orient — understand the project's state and structure +faim bootstrap [--project PATH] # whole-project overview: schema vocabulary, requirements, state, checklist +faim status [--project PATH] # fact counts, stale count, axiom-hash, violations, req enforcement +faim query "GOAL" [--project PATH] [--json] [--limit N] # ad-hoc Prolog goal — one answer to deduce +faim query "reaches(A, Rel, B)" # built-in transitive closure over any reltype (impact/blast-radius) +faim reason [--max N] # ASP/clingo decision — many valid answers searched (e.g. min-cut to break cycles) + +# Validate — around changes and in CI +faim check [--strict] [--verify] [--on TARGET] # 4-pass validation; --verify runs allowlisted tools +faim seal # re-bless axiom digest after a deliberate Tier 1 (axiom) change + +# Record — write Tier 2 facts (with provenance) +faim add entity|prop|rel ARG… # quote-free EAV entry (no raw Prolog terms) — the common case +faim batch facts.txt --files A B --query "the question" # many EAV lines: entity|prop|rel ... +faim assert "FACT" --source agent_scan --files A B --query "the question" [--replace] # raw term; --why aliases --query; --files @list reads a path list +faim retract "FACT" | faim retract entity|prop|rel ARG… # remove a fact + provenance (quote-free mirrors add) +faim attest KIND TIMESTAMP # record a user-attested occurrence + +# Keep fresh — after sources change +faim stale # facts whose source files changed (cheap hash sweep) +faim update # re-derivation worklist for stale facts +faim refresh --all # re-bless hashes of stale facts whose meaning is unchanged (no re-assert) +``` + +## On session pickup (do this proactively) + +If the codebase is a nested repo/worktree, pass `--project PATH` instead of +changing directories. Provenance paths are stored relative to that FAIM root. + +1. `faim status` — learn fact counts, stale count, whether axioms changed. (First + time on an unfamiliar project? `faim bootstrap` gives the fuller one-shot + overview — schema vocabulary + requirements + state.) +2. Read `axioms/` — the project's intent, invariants, requirements. This is your + fast orientation; do not re-derive it from code. +3. `faim check --on schedule` (when temporal requirements exist) — surface any + overdue/unconfirmed time obligations and **prompt the user** about them + (e.g. "secret rotation looks overdue — done it?"). On confirmation, record it + with `faim attest`. FAIM never detects real-world events; you are the bridge. +4. If `faim stale` is non-empty, refresh those facts (see "Keeping facts fresh"). + +## Orienting on a task + +- Query FAIM instead of grepping: "what depends on X?", "which endpoints are + unauthenticated?", "what does module Y assume?". +- Each answer carries freshness/confidence. Fresh + high-confidence → trust and + proceed. Stale or low-confidence + high stakes → verify against code. +- `query` vs `reason`: reach for `query` when the question has **one** answer to + deduce (what depends on X, is Y authenticated, full reachability). Reach for + `reason` only when there are **many** valid answers to search/optimize over — + e.g. "fewest `depends_on` edges to cut to break all cycles". A plain impact set + is `query`, not `reason`. + +## Making a change + +Use the smallest memory that answers the task. Model only durable, decision-relevant +facts; do not map the whole codebase before touching code. + +1. Before: query dependents/relationships to scope the blast radius. Direct edges + are `rel(X, depends_on, target)`; for the *full* radius use the built-in + `reaches(X, depends_on, target)` — transitive closure over any reltype, so you + catch indirect dependents a one-hop query misses. Examples: + - event flow: `reaches(X, depends_on, client_sse_hub)` or `rel(service, emits, Event)` + - shared type flow: `reaches(X, depends_on, food_selection_type)` + - endpoint audit: `prop(E, mutates_state, true), prop(E, requires_auth, false)` +2. After: refresh affected derived facts, then `faim check`. Report any new + `violation` (broken invariant, now-unauthenticated endpoint, requirement + breached). **You surface; the user resolves** — never silently amend an axiom. +3. Changing an axiom is high-ceremony: make it explicit, get a good reason, and + expect a wave of re-derivation + a fresh check. After a deliberate, agreed + axiom change, run `faim seal` to re-bless the digest. + +## Deriving facts (Tier 2) — with discipline + +When you scan code and assert a fact: + +- **Record every file you consulted** in `--files`. Provenance completeness is the + linchpin of trust — a missing file means silent staleness later. `--files` accepts + globs and directories, so an aggregate fact (e.g. "package P depends_on Q", + derived from many files) records *all* of them, not one representative. + But don't over-couple: passing a whole directory ties the fact to *every* file + under it, so unrelated edits flag it stale. List the files that actually + determine the fact (for package containment/imports, usually the `__init__`/ + module-index file), not the entire tree. +- **One id, one kind.** A canonical key (path/module/FQN) maps to a single + entity of a single kind — `check` flags the same id under two kinds as an + identity violation. In ecosystems where a name is both a package and a module + (Python `flask.json` is a dir *and* an importable name), pick one kind for the + bare id and disambiguate the other (e.g. `flask.json` the package vs + `flask.json:__init__` the module). +- **Record the question** in `--query` (alias `--why`) so the fact can be + re-derived. Long file lists: `--files @sources.txt` (one path/glob per line). +- **Skip raw Prolog for the common case.** `faim add entity ID KIND`, + `faim add prop ID ATTR VALUE`, `faim add rel SRC RELTYPE DST` build the term and + quote it for you (same `--files/--query/--replace`). Reach for raw + `faim assert "TERM"` only when `add` can't express the shape. +- **Only materialize what earns it.** Store a fact iff it is (a) expensive or + impossible to cheaply re-derive, AND (b) decision-relevant, AND (c) cheaply + kept trustworthy (stable source or tool-verifiable). If you'd have to check the + code anyway, do not store it. +- **Prefer tool-verifiable facts** (`verifiable_by` + a `faim.conf` tool) over + your own judgement — they re-check cheaply and never go stale silently. + +## Keeping facts fresh + +- `faim stale` lists facts whose sources changed; `faim update` gives the + worklist (each with its derivation question + changed files). +- Re-read only the changed files, re-answer each question, then re-`faim assert` + with `--replace` (drops the stale copy + its provenance in place, then writes + fresh — no retract-first). Never re-scan everything — the unchanged majority is + already trustworthy. +- Use `faim refresh --all` only for facts whose **meaning is unchanged** after you + inspected the changed source bytes. If the answer changed, re-derive with + `--replace`; if you did not check, leave it stale. + +## Adopting FAIM on a new project + +1. `faim init`. +2. Co-author the minimum `axioms/schema.pl` vocabulary needed for the task. Common + TS/web vocabulary: `module`, `endpoint`, `event`, `type`, `interface`; + `depends_on`, `emits`, `consumes`, `exposes`; endpoint attrs such as `method`, + `path`, `mutates_state`, `requires_auth`. +3. Help the user state axioms — descriptive (language, architecture) and + prescriptive (coverage, auth, deploy gates). Then `faim seal` to bless the + axiom digest (authoring axioms changes it). +4. `faim bootstrap` to see the declared vocabulary + an authoring checklist, then + scan the codebase and record the high-value structural facts with `faim add` + or `faim batch` (quote-free, with provenance) — raw `faim assert` only for + shapes `add` can't express. Propose missing vocabulary as a schema addition + for the user to approve (that is a Tier 1 change, so re-`seal` after). +5. `faim check` — surface the first contradictions between stated intent and + observed reality. + +## Recipes + +### Invariant audit + +1. Add `requirement(ID, ...)` in axioms. +2. Record observed facts, e.g. endpoint `mutates_state` and `requires_auth`. +3. Add a `violation(ID, Subject, Reason)` rule. +4. Run `faim check`; fix code or record intentional exceptions with rationale. +5. Update facts, re-run `faim check`, and report exact validation commands. + +### Blast-radius change + +1. Record only the dependency edges needed for the target (`depends_on`, `emits`, + `consumes`, `exposes`). +2. Query `reaches(X, depends_on, Target)` before editing. +3. Edit the scoped files; then run `faim stale`, re-derive changed facts or + `refresh` only unchanged meanings, and `faim check`. + +### Validation reporting + +Report the exact cwd/project root, environment variables, and command. If a test +needs special env and you cannot reproduce it, say so instead of claiming pass. + +## Hard rules + +- Never put a derived fact in `axioms/`, never edit `axioms/` without explicit + user agreement. +- Never claim a fact is true beyond what its freshness/confidence supports. +- Never run a verification tool that is not allowlisted in `faim.conf`. diff --git a/.faim/axioms.sha256 b/.faim/axioms.sha256 new file mode 100644 index 0000000..41be884 --- /dev/null +++ b/.faim/axioms.sha256 @@ -0,0 +1 @@ +a5d5e3e256831c219bba74ee9b97b2a3562429330c52b8e2a654a9846ec8c706 diff --git a/.faim/axioms/decisions.pl b/.faim/axioms/decisions.pl new file mode 100644 index 0000000..bfa0807 --- /dev/null +++ b/.faim/axioms/decisions.pl @@ -0,0 +1,12 @@ +% decisions.pl — descriptive axioms (Tier 1). What the project IS. +% Asserted ground truth from .specify/memory/constitution.md (v1.1.0). +% Compared against observed reality by `faim check` (consistency pass). + +entity(project, project). +prop(project, language, typescript). +prop(project, architecture, single_package_fullstack). +prop(project, storage, postgres). +prop(project, storage, sqlite). +prop(project, orm, prisma). +prop(project, runtime, node24). +prop(project, realtime, sse). diff --git a/.faim/axioms/invariants.pl b/.faim/axioms/invariants.pl new file mode 100644 index 0000000..c6da409 --- /dev/null +++ b/.faim/axioms/invariants.pl @@ -0,0 +1,6 @@ +% invariants.pl — structural invariants (Tier 1). What must remain true. +% Named requirements; the breach rules live in rules/deduction.pl and emit +% violation(ReqId, Subject, Reason). + +requirement(no_circular_deps, 'the service/module/route dependency graph is acyclic'). +requirement(auth_on_mutations, 'any endpoint that mutates state requires authentication'). diff --git a/.faim/axioms/requirements.pl b/.faim/axioms/requirements.pl new file mode 100644 index 0000000..d46358b --- /dev/null +++ b/.faim/axioms/requirements.pl @@ -0,0 +1,24 @@ +% requirements.pl — prescriptive axioms (Tier 1). What the project MUST satisfy. +% The five Core Principles + Quality Gates from constitution.md (v1.1.0). +% Structural invariants (acyclic deps, auth-on-mutations) live in invariants.pl. + +% --- Core Principles (I–V) --- +requirement(thin_routes, 'routes only validate -> service -> return; no business logic in src/server/routes'). +requirement(single_prisma_client, 'all DB access goes through the Prisma singleton in src/server/db.ts'). +requirement(shared_types, 'request/response and domain types defined once in src/lib, imported by both sides'). +requirement(realtime_via_sse, 'state-changing services broadcast through src/server/sse.ts; no silent shared-state mutations'). +requirement(tests_mandatory, 'every feature ships its Vitest/Supertest + Testing Library tests in the same change'). + +% --- Quality Gates (tool-verifiable; run only under `faim check --verify`) --- +requirement(typecheck_clean, 'tsc --noEmit passes across server + client + lib'). +verifiable_by(typecheck_clean, typecheck). + +requirement(lint_clean, 'ESLint (incl. sonarjs) passes'). +verifiable_by(lint_clean, lint). + +requirement(architecture_guard, 'dependency-cruiser architecture guard passes (enforces thin routes / single client)'). +verifiable_by(architecture_guard, architecture). + +requirement(deps_clean, 'no production dependency vulnerabilities'). +verifiable_by(deps_clean, audit). +trigger(deps_clean, event(deployment, before)). diff --git a/.faim/axioms/schema.pl b/.faim/axioms/schema.pl new file mode 100644 index 0000000..a82883e --- /dev/null +++ b/.faim/axioms/schema.pl @@ -0,0 +1,42 @@ +% schema.pl — the project's vocabulary (Tier 1, hash-tracked). +% Declares the legal kinds, attributes, and relationship types. `faim check` +% rejects any fact that uses undeclared vocabulary — this is the anti-drift guard. +% +% Source of truth: .specify/memory/constitution.md (v1.1.0) and +% specs/003-learned-meal-recommender/plan.md. + +% --- kinds --- +kind(project). +kind(service). % src/server/services/*.ts — owns business logic +kind(route). % src/server/routes/*.ts — thin: validate->service->return +kind(endpoint). % an HTTP endpoint a route exposes +kind(module). % other source module (db singleton, shared lib, util) +kind(event). % an SSE event name broadcast through src/server/sse.ts +kind(type). % a shared request/response/domain type in src/lib + +% --- attributes --- +attr(project, language, atom). +attr(project, architecture, atom). +attr(project, storage, atom). +attr(project, orm, atom). +attr(project, runtime, atom). +attr(project, realtime, atom). + +attr(endpoint, method, atom). +attr(endpoint, path, string). +attr(endpoint, mutates_state, boolean). +attr(endpoint, requires_auth, boolean). + +attr(service, mutates_shared_state, boolean). % changes shared lunch state others must observe + +attr(event, scoped_by, atom). % e.g. office_location (multi-office isolation) + +% --- relationship types --- +reltype(depends_on, any, any). +reltype(emits, one_of([service, route]), event). +reltype(consumes, any, event). +reltype(exposes, route, endpoint). +reltype(declares, one_of([service, module, route]), type). + +% --- temporal event types (for requirement triggers) --- +event_type(deployment). diff --git a/.faim/derived/provenance.pl b/.faim/derived/provenance.pl new file mode 100644 index 0000000..4f2be99 --- /dev/null +++ b/.faim/derived/provenance.pl @@ -0,0 +1,494 @@ +% provenance.pl — Tier 2 trust layer. One group per derived fact. +% Kept separate so structure.pl stays readable. +% +% derived_from(Fact, Source). Source: tool_scan | agent_scan | user_attested +% source_files(Fact, [Path, ...]). EVERY file consulted — completeness = trust. +% source_hash(Fact, Sha256). content hash for cheap staleness detection +% derivation_query(Fact, Question). the question answered (re-derivation key) +% derived_at(Fact, Timestamp, Agent). +% +% Example: +% derived_from(prop('/abc', requires_auth, true), agent_scan). +% source_files(prop('/abc', requires_auth, true), ['src/routes/abc.rs']). +% source_hash(prop('/abc', requires_auth, true), 'sha256:...'). +% derivation_query(prop('/abc', requires_auth, true), 'does /abc require auth'). +derived_from(entity(mealRecommendation,service), agent_scan). +source_files(entity(mealRecommendation,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealRecommendation,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealRecommendation,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealRecommendation,service), '2026-06-24T05:07:35Z', agent). +derived_from(entity(mealRecommendationModel,service), agent_scan). +source_files(entity(mealRecommendationModel,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealRecommendationModel,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealRecommendationModel,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealRecommendationModel,service), '2026-06-24T05:07:35Z', agent). +derived_from(entity(mealRecommendationAi,service), agent_scan). +source_files(entity(mealRecommendationAi,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealRecommendationAi,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealRecommendationAi,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealRecommendationAi,service), '2026-06-24T05:07:35Z', agent). +derived_from(entity(mealRecommendationEval,service), agent_scan). +source_files(entity(mealRecommendationEval,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealRecommendationEval,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealRecommendationEval,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealRecommendationEval,service), '2026-06-24T05:07:36Z', agent). +derived_from(entity(mealFeatures,service), agent_scan). +source_files(entity(mealFeatures,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealFeatures,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealFeatures,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealFeatures,service), '2026-06-24T05:07:36Z', agent). +derived_from(entity(mealItemIdentity,service), agent_scan). +source_files(entity(mealItemIdentity,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(mealItemIdentity,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(mealItemIdentity,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(mealItemIdentity,service), '2026-06-24T05:07:36Z', agent). +derived_from(entity(officeRecommenderSettings,service), agent_scan). +source_files(entity(officeRecommenderSettings,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(officeRecommenderSettings,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(officeRecommenderSettings,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(officeRecommenderSettings,service), '2026-06-24T05:07:36Z', agent). +derived_from(entity(userPreferences,service), agent_scan). +source_files(entity(userPreferences,service), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(userPreferences,service), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(userPreferences,service), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(userPreferences,service), '2026-06-24T05:07:37Z', agent). +derived_from(entity(db,module), agent_scan). +source_files(entity(db,module), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(db,module), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(db,module), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(db,module), '2026-06-24T05:07:37Z', agent). +derived_from(entity(lib_types,module), agent_scan). +source_files(entity(lib_types,module), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(lib_types,module), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(lib_types,module), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(lib_types,module), '2026-06-24T05:07:37Z', agent). +derived_from(entity(routeUtils,module), agent_scan). +source_files(entity(routeUtils,module), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(routeUtils,module), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(routeUtils,module), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(routeUtils,module), '2026-06-24T05:07:37Z', agent). +derived_from(entity(authIdentity,module), agent_scan). +source_files(entity(authIdentity,module), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(authIdentity,module), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(authIdentity,module), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(authIdentity,module), '2026-06-24T05:07:38Z', agent). +derived_from(entity(recommenderAdmin,route), agent_scan). +source_files(entity(recommenderAdmin,route), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(entity(recommenderAdmin,route), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(entity(recommenderAdmin,route), "recommender feature dependency spine for blast-radius queries"). +derived_at(entity(recommenderAdmin,route), '2026-06-24T05:07:38Z', agent). +derived_from(rel(mealRecommendation,depends_on,db), agent_scan). +source_files(rel(mealRecommendation,depends_on,db), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,db), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,db), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,db), '2026-06-24T05:07:38Z', agent). +derived_from(rel(mealRecommendation,depends_on,mealRecommendationAi), agent_scan). +source_files(rel(mealRecommendation,depends_on,mealRecommendationAi), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,mealRecommendationAi), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,mealRecommendationAi), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,mealRecommendationAi), '2026-06-24T05:07:38Z', agent). +derived_from(rel(mealRecommendation,depends_on,mealFeatures), agent_scan). +source_files(rel(mealRecommendation,depends_on,mealFeatures), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,mealFeatures), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,mealFeatures), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,mealFeatures), '2026-06-24T05:07:39Z', agent). +derived_from(rel(mealRecommendation,depends_on,mealRecommendationModel), agent_scan). +source_files(rel(mealRecommendation,depends_on,mealRecommendationModel), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,mealRecommendationModel), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,mealRecommendationModel), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,mealRecommendationModel), '2026-06-24T05:07:39Z', agent). +derived_from(rel(mealRecommendation,depends_on,mealItemIdentity), agent_scan). +source_files(rel(mealRecommendation,depends_on,mealItemIdentity), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,mealItemIdentity), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,mealItemIdentity), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,mealItemIdentity), '2026-06-24T05:07:39Z', agent). +derived_from(rel(mealRecommendation,depends_on,userPreferences), agent_scan). +source_files(rel(mealRecommendation,depends_on,userPreferences), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,userPreferences), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,userPreferences), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,userPreferences), '2026-06-24T05:07:39Z', agent). +derived_from(rel(mealRecommendation,depends_on,lib_types), agent_scan). +source_files(rel(mealRecommendation,depends_on,lib_types), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(mealRecommendation,depends_on,lib_types), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(mealRecommendation,depends_on,lib_types), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(mealRecommendation,depends_on,lib_types), '2026-06-24T05:07:40Z', agent). +derived_from(rel(recommenderAdmin,depends_on,routeUtils), agent_scan). +source_files(rel(recommenderAdmin,depends_on,routeUtils), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,routeUtils), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,routeUtils), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,routeUtils), '2026-06-24T05:07:40Z', agent). +derived_from(rel(recommenderAdmin,depends_on,authIdentity), agent_scan). +source_files(rel(recommenderAdmin,depends_on,authIdentity), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,authIdentity), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,authIdentity), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,authIdentity), '2026-06-24T05:07:40Z', agent). +derived_from(rel(recommenderAdmin,depends_on,mealRecommendationEval), agent_scan). +source_files(rel(recommenderAdmin,depends_on,mealRecommendationEval), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,mealRecommendationEval), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,mealRecommendationEval), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,mealRecommendationEval), '2026-06-24T05:07:40Z', agent). +derived_from(rel(recommenderAdmin,depends_on,officeRecommenderSettings), agent_scan). +source_files(rel(recommenderAdmin,depends_on,officeRecommenderSettings), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,officeRecommenderSettings), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,officeRecommenderSettings), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,officeRecommenderSettings), '2026-06-24T05:07:40Z', agent). +derived_from(rel(recommenderAdmin,depends_on,mealRecommendationModel), agent_scan). +source_files(rel(recommenderAdmin,depends_on,mealRecommendationModel), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,mealRecommendationModel), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,mealRecommendationModel), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,mealRecommendationModel), '2026-06-24T05:07:41Z', agent). +derived_from(rel(recommenderAdmin,depends_on,lib_types), agent_scan). +source_files(rel(recommenderAdmin,depends_on,lib_types), ['src/server/services/mealRecommendation.ts', 'src/server/routes/recommenderAdmin.ts']). +source_hash(rel(recommenderAdmin,depends_on,lib_types), 'sha256:571e9c41161a653f75763d59e5cc5db19d358e256a0e148978259ca9ae5256e9'). +derivation_query(rel(recommenderAdmin,depends_on,lib_types), "recommender feature dependency spine for blast-radius queries"). +derived_at(rel(recommenderAdmin,depends_on,lib_types), '2026-06-24T05:07:41Z', agent). +derived_from(entity('POST:/api/menus',endpoint), agent_scan). +source_files(entity('POST:/api/menus',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('POST:/api/menus',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('POST:/api/menus',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('POST:/api/menus',endpoint), '2026-06-24T05:26:54Z', agent). +derived_from(prop('POST:/api/menus',method,post), agent_scan). +source_files(prop('POST:/api/menus',method,post), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus',method,post), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus',method,post), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus',method,post), '2026-06-24T05:26:55Z', agent). +derived_from(prop('POST:/api/menus',path,'/api/menus'), agent_scan). +source_files(prop('POST:/api/menus',path,'/api/menus'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus',path,'/api/menus'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus',path,'/api/menus'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus',path,'/api/menus'), '2026-06-24T05:26:55Z', agent). +derived_from(prop('POST:/api/menus',mutates_state,true), agent_scan). +source_files(prop('POST:/api/menus',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus',mutates_state,true), '2026-06-24T05:26:55Z', agent). +derived_from(entity('POST:/api/menus/import',endpoint), agent_scan). +source_files(entity('POST:/api/menus/import',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('POST:/api/menus/import',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('POST:/api/menus/import',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('POST:/api/menus/import',endpoint), '2026-06-24T05:26:56Z', agent). +derived_from(prop('POST:/api/menus/import',method,post), agent_scan). +source_files(prop('POST:/api/menus/import',method,post), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/import',method,post), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/import',method,post), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/import',method,post), '2026-06-24T05:26:56Z', agent). +derived_from(prop('POST:/api/menus/import',path,'/api/menus/import'), agent_scan). +source_files(prop('POST:/api/menus/import',path,'/api/menus/import'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/import',path,'/api/menus/import'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/import',path,'/api/menus/import'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/import',path,'/api/menus/import'), '2026-06-24T05:26:56Z', agent). +derived_from(prop('POST:/api/menus/import',mutates_state,true), agent_scan). +source_files(prop('POST:/api/menus/import',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/import',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/import',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/import',mutates_state,true), '2026-06-24T05:26:57Z', agent). +derived_from(entity('PUT:/api/menus/:id',endpoint), agent_scan). +source_files(entity('PUT:/api/menus/:id',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('PUT:/api/menus/:id',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('PUT:/api/menus/:id',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('PUT:/api/menus/:id',endpoint), '2026-06-24T05:26:57Z', agent). +derived_from(prop('PUT:/api/menus/:id',method,put), agent_scan). +source_files(prop('PUT:/api/menus/:id',method,put), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:id',method,put), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:id',method,put), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:id',method,put), '2026-06-24T05:26:57Z', agent). +derived_from(prop('PUT:/api/menus/:id',path,'/api/menus/:id'), agent_scan). +source_files(prop('PUT:/api/menus/:id',path,'/api/menus/:id'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:id',path,'/api/menus/:id'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:id',path,'/api/menus/:id'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:id',path,'/api/menus/:id'), '2026-06-24T05:26:58Z', agent). +derived_from(prop('PUT:/api/menus/:id',mutates_state,true), agent_scan). +source_files(prop('PUT:/api/menus/:id',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:id',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:id',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:id',mutates_state,true), '2026-06-24T05:26:58Z', agent). +derived_from(entity('DELETE:/api/menus/:id',endpoint), agent_scan). +source_files(entity('DELETE:/api/menus/:id',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('DELETE:/api/menus/:id',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('DELETE:/api/menus/:id',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('DELETE:/api/menus/:id',endpoint), '2026-06-24T05:26:59Z', agent). +derived_from(prop('DELETE:/api/menus/:id',method,delete), agent_scan). +source_files(prop('DELETE:/api/menus/:id',method,delete), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:id',method,delete), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:id',method,delete), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:id',method,delete), '2026-06-24T05:26:59Z', agent). +derived_from(prop('DELETE:/api/menus/:id',path,'/api/menus/:id'), agent_scan). +source_files(prop('DELETE:/api/menus/:id',path,'/api/menus/:id'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:id',path,'/api/menus/:id'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:id',path,'/api/menus/:id'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:id',path,'/api/menus/:id'), '2026-06-24T05:26:59Z', agent). +derived_from(prop('DELETE:/api/menus/:id',mutates_state,true), agent_scan). +source_files(prop('DELETE:/api/menus/:id',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:id',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:id',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:id',mutates_state,true), '2026-06-24T05:26:59Z', agent). +derived_from(entity('POST:/api/menus/:menuId/items',endpoint), agent_scan). +source_files(entity('POST:/api/menus/:menuId/items',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('POST:/api/menus/:menuId/items',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('POST:/api/menus/:menuId/items',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('POST:/api/menus/:menuId/items',endpoint), '2026-06-24T05:27:00Z', agent). +derived_from(prop('POST:/api/menus/:menuId/items',method,post), agent_scan). +source_files(prop('POST:/api/menus/:menuId/items',method,post), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/:menuId/items',method,post), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/:menuId/items',method,post), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/:menuId/items',method,post), '2026-06-24T05:27:00Z', agent). +derived_from(prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'), agent_scan). +source_files(prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'), '2026-06-24T05:27:01Z', agent). +derived_from(prop('POST:/api/menus/:menuId/items',mutates_state,true), agent_scan). +source_files(prop('POST:/api/menus/:menuId/items',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/:menuId/items',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('POST:/api/menus/:menuId/items',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('POST:/api/menus/:menuId/items',mutates_state,true), '2026-06-24T05:27:01Z', agent). +derived_from(entity('PUT:/api/menus/:menuId/items/:id',endpoint), agent_scan). +source_files(entity('PUT:/api/menus/:menuId/items/:id',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('PUT:/api/menus/:menuId/items/:id',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('PUT:/api/menus/:menuId/items/:id',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('PUT:/api/menus/:menuId/items/:id',endpoint), '2026-06-24T05:27:01Z', agent). +derived_from(prop('PUT:/api/menus/:menuId/items/:id',method,put), agent_scan). +source_files(prop('PUT:/api/menus/:menuId/items/:id',method,put), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:menuId/items/:id',method,put), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:menuId/items/:id',method,put), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:menuId/items/:id',method,put), '2026-06-24T05:27:02Z', agent). +derived_from(prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), agent_scan). +source_files(prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), '2026-06-24T05:27:02Z', agent). +derived_from(prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true), agent_scan). +source_files(prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true), '2026-06-24T05:27:02Z', agent). +derived_from(entity('DELETE:/api/menus/:menuId/items/:id',endpoint), agent_scan). +source_files(entity('DELETE:/api/menus/:menuId/items/:id',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('DELETE:/api/menus/:menuId/items/:id',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('DELETE:/api/menus/:menuId/items/:id',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('DELETE:/api/menus/:menuId/items/:id',endpoint), '2026-06-24T05:27:03Z', agent). +derived_from(prop('DELETE:/api/menus/:menuId/items/:id',method,delete), agent_scan). +source_files(prop('DELETE:/api/menus/:menuId/items/:id',method,delete), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:menuId/items/:id',method,delete), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:menuId/items/:id',method,delete), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:menuId/items/:id',method,delete), '2026-06-24T05:27:03Z', agent). +derived_from(prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), agent_scan). +source_files(prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'), '2026-06-24T05:27:03Z', agent). +derived_from(prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true), agent_scan). +source_files(prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true), '2026-06-24T05:27:04Z', agent). +derived_from(entity('GET:/api/menus',endpoint), agent_scan). +source_files(entity('GET:/api/menus',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/menus',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/menus',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/menus',endpoint), '2026-06-24T05:27:04Z', agent). +derived_from(prop('GET:/api/menus',method,get), agent_scan). +source_files(prop('GET:/api/menus',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/menus',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/menus',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/menus',method,get), '2026-06-24T05:27:05Z', agent). +derived_from(prop('GET:/api/menus',path,'/api/menus'), agent_scan). +source_files(prop('GET:/api/menus',path,'/api/menus'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/menus',path,'/api/menus'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/menus',path,'/api/menus'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/menus',path,'/api/menus'), '2026-06-24T05:27:05Z', agent). +derived_from(prop('GET:/api/menus',mutates_state,false), agent_scan). +source_files(prop('GET:/api/menus',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/menus',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/menus',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/menus',mutates_state,false), '2026-06-24T05:27:05Z', agent). +derived_from(entity('GET:/api/food-selections/active',endpoint), agent_scan). +source_files(entity('GET:/api/food-selections/active',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/food-selections/active',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/food-selections/active',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/food-selections/active',endpoint), '2026-06-24T05:27:06Z', agent). +derived_from(prop('GET:/api/food-selections/active',method,get), agent_scan). +source_files(prop('GET:/api/food-selections/active',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/active',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/active',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/active',method,get), '2026-06-24T05:27:06Z', agent). +derived_from(prop('GET:/api/food-selections/active',path,'/api/food-selections/active'), agent_scan). +source_files(prop('GET:/api/food-selections/active',path,'/api/food-selections/active'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/active',path,'/api/food-selections/active'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/active',path,'/api/food-selections/active'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/active',path,'/api/food-selections/active'), '2026-06-24T05:27:06Z', agent). +derived_from(prop('GET:/api/food-selections/active',mutates_state,false), agent_scan). +source_files(prop('GET:/api/food-selections/active',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/active',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/active',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/active',mutates_state,false), '2026-06-24T05:27:07Z', agent). +derived_from(entity('GET:/api/food-selections/history',endpoint), agent_scan). +source_files(entity('GET:/api/food-selections/history',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/food-selections/history',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/food-selections/history',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/food-selections/history',endpoint), '2026-06-24T05:27:07Z', agent). +derived_from(prop('GET:/api/food-selections/history',method,get), agent_scan). +source_files(prop('GET:/api/food-selections/history',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/history',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/history',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/history',method,get), '2026-06-24T05:27:07Z', agent). +derived_from(prop('GET:/api/food-selections/history',path,'/api/food-selections/history'), agent_scan). +source_files(prop('GET:/api/food-selections/history',path,'/api/food-selections/history'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/history',path,'/api/food-selections/history'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/history',path,'/api/food-selections/history'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/history',path,'/api/food-selections/history'), '2026-06-24T05:27:08Z', agent). +derived_from(prop('GET:/api/food-selections/history',mutates_state,false), agent_scan). +source_files(prop('GET:/api/food-selections/history',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/history',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/history',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/history',mutates_state,false), '2026-06-24T05:27:08Z', agent). +derived_from(entity('GET:/api/food-selections/:id/fallback-candidates',endpoint), agent_scan). +source_files(entity('GET:/api/food-selections/:id/fallback-candidates',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/food-selections/:id/fallback-candidates',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/food-selections/:id/fallback-candidates',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/food-selections/:id/fallback-candidates',endpoint), '2026-06-24T05:27:09Z', agent). +derived_from(prop('GET:/api/food-selections/:id/fallback-candidates',method,get), agent_scan). +source_files(prop('GET:/api/food-selections/:id/fallback-candidates',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/:id/fallback-candidates',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/:id/fallback-candidates',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/:id/fallback-candidates',method,get), '2026-06-24T05:27:09Z', agent). +derived_from(prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'), agent_scan). +source_files(prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'), '2026-06-24T05:27:09Z', agent). +derived_from(prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false), agent_scan). +source_files(prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false), '2026-06-24T05:27:09Z', agent). +derived_from(entity('GET:/api/polls/active',endpoint), agent_scan). +source_files(entity('GET:/api/polls/active',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/polls/active',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/polls/active',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/polls/active',endpoint), '2026-06-24T05:27:10Z', agent). +derived_from(prop('GET:/api/polls/active',method,get), agent_scan). +source_files(prop('GET:/api/polls/active',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/active',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/active',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/active',method,get), '2026-06-24T05:27:10Z', agent). +derived_from(prop('GET:/api/polls/active',path,'/api/polls/active'), agent_scan). +source_files(prop('GET:/api/polls/active',path,'/api/polls/active'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/active',path,'/api/polls/active'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/active',path,'/api/polls/active'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/active',path,'/api/polls/active'), '2026-06-24T05:27:11Z', agent). +derived_from(prop('GET:/api/polls/active',mutates_state,false), agent_scan). +source_files(prop('GET:/api/polls/active',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/active',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/active',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/active',mutates_state,false), '2026-06-24T05:27:11Z', agent). +derived_from(entity('GET:/api/polls/:id',endpoint), agent_scan). +source_files(entity('GET:/api/polls/:id',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/polls/:id',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/polls/:id',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/polls/:id',endpoint), '2026-06-24T05:27:11Z', agent). +derived_from(prop('GET:/api/polls/:id',method,get), agent_scan). +source_files(prop('GET:/api/polls/:id',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/:id',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/:id',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/:id',method,get), '2026-06-24T05:27:12Z', agent). +derived_from(prop('GET:/api/polls/:id',path,'/api/polls/:id'), agent_scan). +source_files(prop('GET:/api/polls/:id',path,'/api/polls/:id'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/:id',path,'/api/polls/:id'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/:id',path,'/api/polls/:id'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/:id',path,'/api/polls/:id'), '2026-06-24T05:27:12Z', agent). +derived_from(prop('GET:/api/polls/:id',mutates_state,false), agent_scan). +source_files(prop('GET:/api/polls/:id',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/:id',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/polls/:id',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/polls/:id',mutates_state,false), '2026-06-24T05:27:12Z', agent). +derived_from(entity('GET:/api/shopping-list',endpoint), agent_scan). +source_files(entity('GET:/api/shopping-list',endpoint), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(entity('GET:/api/shopping-list',endpoint), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(entity('GET:/api/shopping-list',endpoint), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(entity('GET:/api/shopping-list',endpoint), '2026-06-24T05:27:13Z', agent). +derived_from(prop('GET:/api/shopping-list',method,get), agent_scan). +source_files(prop('GET:/api/shopping-list',method,get), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/shopping-list',method,get), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/shopping-list',method,get), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/shopping-list',method,get), '2026-06-24T05:27:13Z', agent). +derived_from(prop('GET:/api/shopping-list',path,'/api/shopping-list'), agent_scan). +source_files(prop('GET:/api/shopping-list',path,'/api/shopping-list'), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/shopping-list',path,'/api/shopping-list'), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/shopping-list',path,'/api/shopping-list'), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/shopping-list',path,'/api/shopping-list'), '2026-06-24T05:27:13Z', agent). +derived_from(prop('GET:/api/shopping-list',mutates_state,false), agent_scan). +source_files(prop('GET:/api/shopping-list',mutates_state,false), ['src/server/routes/menus.ts', 'src/server/routes/foodSelections.ts', 'src/server/routes/polls.ts', 'src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/shopping-list',mutates_state,false), 'sha256:52294d7d48a9d726a2c53a47ea17c8f94595b81b3bdbe3d2eaa04d5c9080ae6e'). +derivation_query(prop('GET:/api/shopping-list',mutates_state,false), "security audit: which HTTP endpoints are reachable without authentication (requires_auth=false)"). +derived_at(prop('GET:/api/shopping-list',mutates_state,false), '2026-06-24T05:27:14Z', agent). +derived_from(prop('POST:/api/menus',requires_auth,true), agent_scan). +source_files(prop('POST:/api/menus',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('POST:/api/menus',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('POST:/api/menus',requires_auth,true), '2026-06-24T05:40:05Z', agent). +derived_from(prop('POST:/api/menus/import',requires_auth,true), agent_scan). +source_files(prop('POST:/api/menus/import',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/import',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('POST:/api/menus/import',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('POST:/api/menus/import',requires_auth,true), '2026-06-24T05:40:05Z', agent). +derived_from(prop('PUT:/api/menus/:id',requires_auth,true), agent_scan). +source_files(prop('PUT:/api/menus/:id',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:id',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('PUT:/api/menus/:id',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('PUT:/api/menus/:id',requires_auth,true), '2026-06-24T05:40:06Z', agent). +derived_from(prop('DELETE:/api/menus/:id',requires_auth,true), agent_scan). +source_files(prop('DELETE:/api/menus/:id',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:id',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('DELETE:/api/menus/:id',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('DELETE:/api/menus/:id',requires_auth,true), '2026-06-24T05:40:07Z', agent). +derived_from(prop('POST:/api/menus/:menuId/items',requires_auth,true), agent_scan). +source_files(prop('POST:/api/menus/:menuId/items',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('POST:/api/menus/:menuId/items',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('POST:/api/menus/:menuId/items',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('POST:/api/menus/:menuId/items',requires_auth,true), '2026-06-24T05:40:07Z', agent). +derived_from(prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true), agent_scan). +source_files(prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true), '2026-06-24T05:40:08Z', agent). +derived_from(prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true), agent_scan). +source_files(prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true), "security audit: menu mutations now require auth (requireAuthenticatedActor)"). +derived_at(prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true), '2026-06-24T05:40:09Z', agent). +derived_from(prop('GET:/api/menus',requires_auth,true), agent_scan). +source_files(prop('GET:/api/menus',requires_auth,true), ['src/server/routes/menus.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/menus',requires_auth,true), 'sha256:178b12b51d02704607cda79e2b42873fcc76e19aa00e2fb1dcb29bab0f56c28c'). +derivation_query(prop('GET:/api/menus',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/menus',requires_auth,true), '2026-06-24T06:04:15Z', agent). +derived_from(prop('GET:/api/food-selections/active',requires_auth,true), agent_scan). +source_files(prop('GET:/api/food-selections/active',requires_auth,true), ['src/server/routes/foodSelections.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/active',requires_auth,true), 'sha256:cbc740e862706262916b2b804545681498cd1f0afe7a519b71636b620e1087c8'). +derivation_query(prop('GET:/api/food-selections/active',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/food-selections/active',requires_auth,true), '2026-06-24T06:04:17Z', agent). +derived_from(prop('GET:/api/food-selections/history',requires_auth,true), agent_scan). +source_files(prop('GET:/api/food-selections/history',requires_auth,true), ['src/server/routes/foodSelections.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/history',requires_auth,true), 'sha256:cbc740e862706262916b2b804545681498cd1f0afe7a519b71636b620e1087c8'). +derivation_query(prop('GET:/api/food-selections/history',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/food-selections/history',requires_auth,true), '2026-06-24T06:04:18Z', agent). +derived_from(prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true), agent_scan). +source_files(prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true), ['src/server/routes/foodSelections.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true), 'sha256:cbc740e862706262916b2b804545681498cd1f0afe7a519b71636b620e1087c8'). +derivation_query(prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true), '2026-06-24T06:04:20Z', agent). +derived_from(prop('GET:/api/polls/active',requires_auth,true), agent_scan). +source_files(prop('GET:/api/polls/active',requires_auth,true), ['src/server/routes/polls.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/active',requires_auth,true), 'sha256:8cf6cea77fac7f765ddd819071a4184811ef45f35efb013305295ccd0ed724d3'). +derivation_query(prop('GET:/api/polls/active',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/polls/active',requires_auth,true), '2026-06-24T06:04:21Z', agent). +derived_from(prop('GET:/api/polls/:id',requires_auth,true), agent_scan). +source_files(prop('GET:/api/polls/:id',requires_auth,true), ['src/server/routes/polls.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/polls/:id',requires_auth,true), 'sha256:8cf6cea77fac7f765ddd819071a4184811ef45f35efb013305295ccd0ed724d3'). +derivation_query(prop('GET:/api/polls/:id',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/polls/:id',requires_auth,true), '2026-06-24T06:04:23Z', agent). +derived_from(prop('GET:/api/shopping-list',requires_auth,true), agent_scan). +source_files(prop('GET:/api/shopping-list',requires_auth,true), ['src/server/routes/shoppingList.ts', 'src/server/routes/authIdentity.ts']). +source_hash(prop('GET:/api/shopping-list',requires_auth,true), 'sha256:8e0759fe8617f71de66d92554c380d1358bcbad1797e430322e6beb54e77230e'). +derivation_query(prop('GET:/api/shopping-list',requires_auth,true), "security audit: all data-read endpoints now require auth"). +derived_at(prop('GET:/api/shopping-list',requires_auth,true), '2026-06-24T06:04:24Z', agent). diff --git a/.faim/derived/structure.pl b/.faim/derived/structure.pl new file mode 100644 index 0000000..4f3e705 --- /dev/null +++ b/.faim/derived/structure.pl @@ -0,0 +1,103 @@ +% structure.pl — Tier 2 empirical facts, scanned from the codebase by the agent. +% Same EAV shape as axioms; the loader tags these as `observed`. Each fact should +% have a matching entry in provenance.pl. +% +% entity('/abc', endpoint). +% prop('/abc', requires_auth, true). +% rel(c42, depends_on, lib_theme). +entity(mealRecommendation,service). +entity(mealRecommendationModel,service). +entity(mealRecommendationAi,service). +entity(mealRecommendationEval,service). +entity(mealFeatures,service). +entity(mealItemIdentity,service). +entity(officeRecommenderSettings,service). +entity(userPreferences,service). +entity(db,module). +entity(lib_types,module). +entity(routeUtils,module). +entity(authIdentity,module). +entity(recommenderAdmin,route). +rel(mealRecommendation,depends_on,db). +rel(mealRecommendation,depends_on,mealRecommendationAi). +rel(mealRecommendation,depends_on,mealFeatures). +rel(mealRecommendation,depends_on,mealRecommendationModel). +rel(mealRecommendation,depends_on,mealItemIdentity). +rel(mealRecommendation,depends_on,userPreferences). +rel(mealRecommendation,depends_on,lib_types). +rel(recommenderAdmin,depends_on,routeUtils). +rel(recommenderAdmin,depends_on,authIdentity). +rel(recommenderAdmin,depends_on,mealRecommendationEval). +rel(recommenderAdmin,depends_on,officeRecommenderSettings). +rel(recommenderAdmin,depends_on,mealRecommendationModel). +rel(recommenderAdmin,depends_on,lib_types). +entity('POST:/api/menus',endpoint). +prop('POST:/api/menus',method,post). +prop('POST:/api/menus',path,'/api/menus'). +prop('POST:/api/menus',mutates_state,true). +entity('POST:/api/menus/import',endpoint). +prop('POST:/api/menus/import',method,post). +prop('POST:/api/menus/import',path,'/api/menus/import'). +prop('POST:/api/menus/import',mutates_state,true). +entity('PUT:/api/menus/:id',endpoint). +prop('PUT:/api/menus/:id',method,put). +prop('PUT:/api/menus/:id',path,'/api/menus/:id'). +prop('PUT:/api/menus/:id',mutates_state,true). +entity('DELETE:/api/menus/:id',endpoint). +prop('DELETE:/api/menus/:id',method,delete). +prop('DELETE:/api/menus/:id',path,'/api/menus/:id'). +prop('DELETE:/api/menus/:id',mutates_state,true). +entity('POST:/api/menus/:menuId/items',endpoint). +prop('POST:/api/menus/:menuId/items',method,post). +prop('POST:/api/menus/:menuId/items',path,'/api/menus/:menuId/items'). +prop('POST:/api/menus/:menuId/items',mutates_state,true). +entity('PUT:/api/menus/:menuId/items/:id',endpoint). +prop('PUT:/api/menus/:menuId/items/:id',method,put). +prop('PUT:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'). +prop('PUT:/api/menus/:menuId/items/:id',mutates_state,true). +entity('DELETE:/api/menus/:menuId/items/:id',endpoint). +prop('DELETE:/api/menus/:menuId/items/:id',method,delete). +prop('DELETE:/api/menus/:menuId/items/:id',path,'/api/menus/:menuId/items/:id'). +prop('DELETE:/api/menus/:menuId/items/:id',mutates_state,true). +entity('GET:/api/menus',endpoint). +prop('GET:/api/menus',method,get). +prop('GET:/api/menus',path,'/api/menus'). +prop('GET:/api/menus',mutates_state,false). +entity('GET:/api/food-selections/active',endpoint). +prop('GET:/api/food-selections/active',method,get). +prop('GET:/api/food-selections/active',path,'/api/food-selections/active'). +prop('GET:/api/food-selections/active',mutates_state,false). +entity('GET:/api/food-selections/history',endpoint). +prop('GET:/api/food-selections/history',method,get). +prop('GET:/api/food-selections/history',path,'/api/food-selections/history'). +prop('GET:/api/food-selections/history',mutates_state,false). +entity('GET:/api/food-selections/:id/fallback-candidates',endpoint). +prop('GET:/api/food-selections/:id/fallback-candidates',method,get). +prop('GET:/api/food-selections/:id/fallback-candidates',path,'/api/food-selections/:id/fallback-candidates'). +prop('GET:/api/food-selections/:id/fallback-candidates',mutates_state,false). +entity('GET:/api/polls/active',endpoint). +prop('GET:/api/polls/active',method,get). +prop('GET:/api/polls/active',path,'/api/polls/active'). +prop('GET:/api/polls/active',mutates_state,false). +entity('GET:/api/polls/:id',endpoint). +prop('GET:/api/polls/:id',method,get). +prop('GET:/api/polls/:id',path,'/api/polls/:id'). +prop('GET:/api/polls/:id',mutates_state,false). +entity('GET:/api/shopping-list',endpoint). +prop('GET:/api/shopping-list',method,get). +prop('GET:/api/shopping-list',path,'/api/shopping-list'). +prop('GET:/api/shopping-list',mutates_state,false). +prop('POST:/api/menus',requires_auth,true). +prop('POST:/api/menus/import',requires_auth,true). +prop('PUT:/api/menus/:id',requires_auth,true). +prop('DELETE:/api/menus/:id',requires_auth,true). +prop('POST:/api/menus/:menuId/items',requires_auth,true). +prop('PUT:/api/menus/:menuId/items/:id',requires_auth,true). +prop('DELETE:/api/menus/:menuId/items/:id',requires_auth,true). +prop('GET:/api/menus',requires_auth,true). +prop('GET:/api/food-selections/active',requires_auth,true). +prop('GET:/api/food-selections/history',requires_auth,true). +prop('GET:/api/food-selections/:id/fallback-candidates',requires_auth,true). +prop('GET:/api/polls/active',requires_auth,true). +prop('GET:/api/polls/:id',requires_auth,true). +prop('GET:/api/shopping-list',requires_auth,true). diff --git a/.faim/faim.conf b/.faim/faim.conf new file mode 100644 index 0000000..3de5ad7 --- /dev/null +++ b/.faim/faim.conf @@ -0,0 +1,34 @@ +# faim.conf - engine config + trust boundaries for this project. + +[engine] +# Path to SWI-Prolog. Empty = found on PATH. +swipl = "" + +[tools] +# Allowlist for `verifiable_by`, run only under `faim check --verify`. A +# requirement names a tool; FAIM runs only the command mapped here (cwd = project +# root). Unknown tool names are reported `unverifiable`, never run. +# Exit-code contract: 0 = requirement satisfied, non-zero = tool_failed violation. +typecheck = "pnpm typecheck" +lint = "pnpm lint" +architecture = "pnpm architecture" +audit = "pnpm audit --prod" + +[identity] +# How entity IDs are formed per kind (deterministic, stable across re-derivation). +# Once any convention is set here, `faim check --verbose` warns about entity kinds +# with no convention (drift risk). With this section empty, the check stays silent. +service = "path" +route = "path" +module = "path" +type = "name" +event = "name" +endpoint = "route" + +[log] +# Activity log: records each `faim` invocation (the command) and its output +# (the response), so FAIM usage can be reviewed afterwards. Plain text, +# append-only; gitignore it if you don't want it tracked. +# `file` is relative to .faim/ unless absolute. +enabled = true +file = "faim.log" diff --git a/.faim/rules/deduction.pl b/.faim/rules/deduction.pl new file mode 100644 index 0000000..88dd385 --- /dev/null +++ b/.faim/rules/deduction.pl @@ -0,0 +1,28 @@ +% deduction.pl — logic programs (not facts). Consulted as-is by the engine. +:- dynamic violation/3. + +% --- readable sugar over EAV --- +authenticated(X) :- prop(X, requires_auth, true). +mutating(X) :- prop(X, mutates_state, true). + +% transitive dependency closure (local helper; distinct from the engine's +% built-in reaches/3 used at query time). +dep_reaches(A, B) :- rel(A, depends_on, B). +dep_reaches(A, B) :- rel(A, depends_on, M), dep_reaches(M, B). + +% --- invariant breaches --- + +% no_circular_deps: a node that transitively depends on itself. +violation(no_circular_deps, X, cycle_through(X)) :- + rel(X, depends_on, _), + dep_reaches(X, X). + +% auth_on_mutations: a state-mutating endpoint with auth disabled. +violation(auth_on_mutations, E, mutating_endpoint_without_auth) :- + prop(E, mutates_state, true), + prop(E, requires_auth, false). + +% realtime_via_sse: a service that mutates shared state but emits no SSE event. +violation(realtime_via_sse, S, mutates_shared_state_without_emit) :- + prop(S, mutates_shared_state, true), + \+ rel(S, emits, _). diff --git a/.faim/rules/reasoning.lp b/.faim/rules/reasoning.lp new file mode 100644 index 0000000..82ef7ff --- /dev/null +++ b/.faim/rules/reasoning.lp @@ -0,0 +1,24 @@ +% reasoning.lp — ASP decision programs (clingo). The Datalog engine +% (rules/deduction.pl) answers "what is true" with one model; clingo searches +% *many* valid configurations under constraints — "what should we do". +% +% `faim reason` adds the fact base (entity/2, rel/3) as clingo atoms, then runs +% this program. Edit / add programs here per project. +% +% --- min feedback arc set ------------------------------------------------- +% Smallest set of depends_on edges to remove so the graph becomes acyclic — a +% refactor aid ("which dependency cuts break this cycle, minimally?"). On an +% already-acyclic graph the optimum is the empty set. `faim reason --max N` +% adds a hard bound: UNSAT proves no acyclic configuration cuts <= N edges. + +#const maxcut = -1. % -1 = no bound (pure optimization) + +edge(X,Y) :- rel(X, depends_on, Y). +{ cut(X,Y) } :- edge(X,Y). % choose edges to remove +keep(X,Y) :- edge(X,Y), not cut(X,Y). +path(X,Y) :- keep(X,Y). +path(X,Z) :- keep(X,Y), path(Y,Z). +:- path(X,X). % kept graph must be acyclic +:- maxcut >= 0, #count{ X,Y : cut(X,Y) } > maxcut. +#minimize { 1,X,Y : cut(X,Y) }. % fewest cuts wins +#show cut/2. diff --git a/.gitignore b/.gitignore index dfbf020..fc67fb5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ graphify-out/* backups/ package-lock.json .zed/settings.json +.faim/faim.log diff --git a/scripts/prisma-generate-safe.mjs b/scripts/prisma-generate-safe.mjs index f0f8e7b..b74466d 100644 --- a/scripts/prisma-generate-safe.mjs +++ b/scripts/prisma-generate-safe.mjs @@ -9,9 +9,19 @@ // locking `query_engine-windows.dll.node`. Prisma 7 is engine-free (driver // adapters), so there is no native DLL to lock and the workaround is gone. +import { rmSync } from "node:fs"; import { spawnSync } from "node:child_process"; import path from "node:path"; +const generatedClientDirs = [ + path.join(process.cwd(), "src", "server", "generated", "client"), + path.join(process.cwd(), "src", "server", "generated", "sqlite-client"), +]; + +for (const generatedClientDir of generatedClientDirs) { + rmSync(generatedClientDir, { recursive: true, force: true }); +} + const prismaCli = path.join( process.cwd(), "node_modules", diff --git a/src/server/routes/foodSelections.ts b/src/server/routes/foodSelections.ts index 5fd0cd4..367d3b7 100644 --- a/src/server/routes/foodSelections.ts +++ b/src/server/routes/foodSelections.ts @@ -107,6 +107,7 @@ function registerSelectionOverviewRoutes(app: FastifyInstance) { // GET /api/food-selections/active — get active/overtime food selection with orders app.get('/api/food-selections/active', async (req, reply) => { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -121,6 +122,7 @@ function registerSelectionOverviewRoutes(app: FastifyInstance) { // GET /api/food-selections/history — latest completed selections (most recent first) app.get('/api/food-selections/history', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -486,6 +488,7 @@ function registerFallbackRoutes(app: FastifyInstance) { '/api/food-selections/:id/fallback-candidates', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), diff --git a/src/server/routes/menus.ts b/src/server/routes/menus.ts index 851a6e8..824ebc5 100644 --- a/src/server/routes/menus.ts +++ b/src/server/routes/menus.ts @@ -5,6 +5,7 @@ import { readRequestedOfficeLocationId, resolveOfficeLocationIdFromCookie, } from '../services/officeContext.js'; +import { requireAuthenticatedActor } from './authIdentity.js'; import type { CreateMenuRequest, UpdateMenuRequest, @@ -16,6 +17,7 @@ import type { export default async function menuRoutes(app: FastifyInstance) { // GET /api/menus — list all menus with items app.get('/api/menus', async (req, reply) => { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -27,6 +29,7 @@ export default async function menuRoutes(app: FastifyInstance) { // POST /api/menus — create menu app.post<{ Body: CreateMenuRequest }>('/api/menus', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -41,6 +44,7 @@ export default async function menuRoutes(app: FastifyInstance) { // POST /api/menus/import — import menu JSON payload (all-or-nothing) app.post<{ Body: ImportMenuRequest }>('/api/menus/import', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -57,6 +61,7 @@ export default async function menuRoutes(app: FastifyInstance) { '/api/menus/import/preview', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -74,6 +79,7 @@ export default async function menuRoutes(app: FastifyInstance) { '/api/menus/:id', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -89,6 +95,7 @@ export default async function menuRoutes(app: FastifyInstance) { // DELETE /api/menus/:id — delete menu app.delete<{ Params: { id: string } }>('/api/menus/:id', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -105,6 +112,7 @@ export default async function menuRoutes(app: FastifyInstance) { '/api/menus/:menuId/items', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -129,6 +137,7 @@ export default async function menuRoutes(app: FastifyInstance) { '/api/menus/:menuId/items/:id', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -153,6 +162,7 @@ export default async function menuRoutes(app: FastifyInstance) { '/api/menus/:menuId/items/:id', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), diff --git a/src/server/routes/polls.ts b/src/server/routes/polls.ts index 2831fdd..7b20455 100644 --- a/src/server/routes/polls.ts +++ b/src/server/routes/polls.ts @@ -88,6 +88,7 @@ export default async function pollRoutes(app: FastifyInstance) { // GET /api/polls/active — get current active/tied poll app.get('/api/polls/active', async (req, reply) => { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), @@ -100,6 +101,7 @@ export default async function pollRoutes(app: FastifyInstance) { // GET /api/polls/:id — get a specific poll for direct/historical URLs app.get<{ Params: { id: string } }>('/api/polls/:id', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), diff --git a/src/server/routes/shoppingList.ts b/src/server/routes/shoppingList.ts index bfd7779..8335e01 100644 --- a/src/server/routes/shoppingList.ts +++ b/src/server/routes/shoppingList.ts @@ -14,6 +14,7 @@ import type { export default async function shoppingListRoutes(app: FastifyInstance) { app.get('/api/shopping-list', async (req, reply) => { try { + await requireAuthenticatedActor(req.headers.cookie); const officeLocationId = await resolveOfficeLocationIdFromCookie( req.headers.cookie, readRequestedOfficeLocationId(req.query), diff --git a/tests/server/food-selection-routes.test.ts b/tests/server/food-selection-routes.test.ts index 14d86cf..8279e17 100644 --- a/tests/server/food-selection-routes.test.ts +++ b/tests/server/food-selection-routes.test.ts @@ -98,13 +98,18 @@ describe('Food selection routes (integration)', () => { // ─── Helpers ───────────────────────────────────────────── async function createMenu(name: string) { - const res = await supertest(app.server).post('/api/menus').send({ name }).expect(201); + const res = await supertest(app.server) + .post('/api/menus') + .set(await approvedAuthHeaders()) + .send({ name }) + .expect(201); return res.body; } async function createMenuItem(menuId: string, name: string, description?: string) { const res = await supertest(app.server) .post(`/api/menus/${menuId}/items`) + .set(await approvedAuthHeaders()) .send({ name, description }) .expect(201); return res.body; @@ -499,6 +504,7 @@ describe('Food selection routes (integration)', () => { const res = await supertest(app.server) .get('/api/food-selections/active') + .set(await approvedAuthHeaders()) .expect(200); expect(res.body.id).toBe(selection.id); @@ -565,6 +571,7 @@ describe('Food selection routes (integration)', () => { it('returns 404 when no active food selection', async () => { await supertest(app.server) .get('/api/food-selections/active') + .set(await approvedAuthHeaders()) .expect(404); }); @@ -645,7 +652,10 @@ describe('Food selection routes (integration)', () => { await supertest(app.server).post(`/api/food-selections/${second.id}/place-order`).set(organizerHeaders).send({ etaMinutes: 20, nickname: 'admin@example.com' }).expect(200); await supertest(app.server).post(`/api/food-selections/${second.id}/confirm-arrival`).set(organizerHeaders).expect(200); - const res = await supertest(app.server).get('/api/food-selections/history').expect(200); + const res = await supertest(app.server) + .get('/api/food-selections/history') + .set(await approvedAuthHeaders()) + .expect(200); expect(res.body).toHaveLength(2); expect(res.body[0].id).toBe(second.id); expect(res.body[1].id).toBe(first.id); @@ -820,6 +830,7 @@ describe('Food selection routes (integration)', () => { const res = await supertest(app.server) .get(`/api/food-selections/${selection.id}/fallback-candidates`) + .set(await approvedAuthHeaders()) .expect(200); expect(res.body).toEqual([ diff --git a/tests/server/meal-recommendation-routes.test.ts b/tests/server/meal-recommendation-routes.test.ts index b92fee3..9893e66 100644 --- a/tests/server/meal-recommendation-routes.test.ts +++ b/tests/server/meal-recommendation-routes.test.ts @@ -153,13 +153,18 @@ describe('Meal recommendation routes (integration)', () => { } async function createMenu(name: string) { - const res = await supertest(app.server).post('/api/menus').send({ name }).expect(201); + const res = await supertest(app.server) + .post('/api/menus') + .set(await approvedAuthHeaders()) + .send({ name }) + .expect(201); return res.body; } async function createMenuItem(menuId: string, name: string, description?: string) { const res = await supertest(app.server) .post(`/api/menus/${menuId}/items`) + .set(await approvedAuthHeaders()) .send({ name, description }) .expect(201); return res.body; diff --git a/tests/server/menu-routes.test.ts b/tests/server/menu-routes.test.ts index 0b88773..aff87bf 100644 --- a/tests/server/menu-routes.test.ts +++ b/tests/server/menu-routes.test.ts @@ -3,7 +3,7 @@ import supertest from 'supertest'; import { buildApp } from '../../src/server/index.js'; import { cleanDatabase, disconnectDatabase } from './helpers/db.js'; import type { FastifyInstance } from 'fastify'; -import { createOfficeLocation } from '../../src/server/services/officeLocation.js'; +import { createOfficeLocation, ensureDefaultOfficeLocation } from '../../src/server/services/officeLocation.js'; import { createSessionCookieValue } from '../../src/server/services/authSession.js'; import prisma from '../../src/server/db.js'; @@ -16,6 +16,25 @@ vi.mock('../../src/server/sse.js', () => ({ let app: FastifyInstance; +// Menu mutations now require an authenticated session. The basic CRUD tests run +// as a default approved user (approval workflow is off in tests, so any valid +// entra session auto-approves); office-context tests below use their own cookies. +const defaultCookie = `team_lunch_auth_session=${createSessionCookieValue({ + username: 'tester@company.com', + method: 'entra', + iat: Math.floor(Date.now() / 1000), +})}`; + +function agent(server: FastifyInstance['server']) { + const st = supertest(server); + return { + get: (url: string) => st.get(url).set('Cookie', defaultCookie), + post: (url: string) => st.post(url).set('Cookie', defaultCookie), + put: (url: string) => st.put(url).set('Cookie', defaultCookie), + delete: (url: string) => st.delete(url).set('Cookie', defaultCookie), + }; +} + describe('Menu routes (integration)', () => { beforeAll(async () => { app = await buildApp(); @@ -24,6 +43,18 @@ describe('Menu routes (integration)', () => { beforeEach(async () => { await cleanDatabase(); + // Seed the default-office user that `agent()`/`defaultCookie` authenticate as, + // so menu mutations resolve an office instead of 403-ing on "no assignment". + const office = await ensureDefaultOfficeLocation(); + await prisma.authAccessUser.create({ + data: { + email: 'tester@company.com', + approved: true, + blocked: false, + isAdmin: false, + officeLocationId: office.id, + }, + }); }); afterAll(async () => { @@ -38,7 +69,7 @@ describe('Menu routes (integration)', () => { const server = app.server; // Create - const createRes = await supertest(server) + const createRes = await agent(server) .post('/api/menus') .send({ name: 'Italian' }) .expect(201); @@ -46,33 +77,33 @@ describe('Menu routes (integration)', () => { const menuId = createRes.body.id; // List - const listRes = await supertest(server).get('/api/menus').expect(200); + const listRes = await agent(server).get('/api/menus').expect(200); expect(listRes.body).toHaveLength(1); expect(listRes.body[0].name).toBe('Italian'); // Update - const updateRes = await supertest(server) + const updateRes = await agent(server) .put(`/api/menus/${menuId}`) .send({ name: 'Mediterranean' }) .expect(200); expect(updateRes.body.name).toBe('Mediterranean'); // Delete - await supertest(server).delete(`/api/menus/${menuId}`).expect(204); + await agent(server).delete(`/api/menus/${menuId}`).expect(204); // Verify deleted - const listRes2 = await supertest(server).get('/api/menus').expect(200); + const listRes2 = await agent(server).get('/api/menus').expect(200); expect(listRes2.body).toHaveLength(0); }); it('updates menu contact fields', async () => { const server = app.server; - const createRes = await supertest(server) + const createRes = await agent(server) .post('/api/menus') .send({ name: 'Italian' }) .expect(201); - const updatedRes = await supertest(server) + const updatedRes = await agent(server) .put(`/api/menus/${createRes.body.id}`) .send({ name: 'Italian', @@ -93,14 +124,14 @@ describe('Menu routes (integration)', () => { const server = app.server; // Create menu first - const menuRes = await supertest(server) + const menuRes = await agent(server) .post('/api/menus') .send({ name: 'Italian' }) .expect(201); const menuId = menuRes.body.id; // Create item - const createRes = await supertest(server) + const createRes = await agent(server) .post(`/api/menus/${menuId}/items`) .send({ name: 'Margherita Pizza', description: 'Classic', itemNumber: '12', price: 9.5 }) .expect(201); @@ -111,7 +142,7 @@ describe('Menu routes (integration)', () => { const itemId = createRes.body.id; // Update item - const updateRes = await supertest(server) + const updateRes = await agent(server) .put(`/api/menus/${menuId}/items/${itemId}`) .send({ name: 'Neapolitan Pizza', description: 'From Naples', itemNumber: '21', price: 10.5 }) .expect(200); @@ -120,10 +151,10 @@ describe('Menu routes (integration)', () => { expect(updateRes.body.price).toBe(10.5); // Delete item - await supertest(server).delete(`/api/menus/${menuId}/items/${itemId}`).expect(204); + await agent(server).delete(`/api/menus/${menuId}/items/${itemId}`).expect(204); // Verify menu still exists with 0 items - const listRes = await supertest(server).get('/api/menus').expect(200); + const listRes = await agent(server).get('/api/menus').expect(200); expect(listRes.body[0].itemCount).toBe(0); }); @@ -131,9 +162,9 @@ describe('Menu routes (integration)', () => { it('duplicate menu name returns 409', async () => { const server = app.server; - await supertest(server).post('/api/menus').send({ name: 'Italian' }).expect(201); + await agent(server).post('/api/menus').send({ name: 'Italian' }).expect(201); - const res = await supertest(server) + const res = await agent(server) .post('/api/menus') .send({ name: 'italian' }) .expect(409); @@ -297,15 +328,15 @@ describe('Menu routes (integration)', () => { it('duplicate item name within same menu returns 409', async () => { const server = app.server; - const menuRes = await supertest(server).post('/api/menus').send({ name: 'Italian' }).expect(201); + const menuRes = await agent(server).post('/api/menus').send({ name: 'Italian' }).expect(201); const menuId = menuRes.body.id; - await supertest(server) + await agent(server) .post(`/api/menus/${menuId}/items`) .send({ name: 'Pizza' }) .expect(201); - const res = await supertest(server) + const res = await agent(server) .post(`/api/menus/${menuId}/items`) .send({ name: 'pizza' }) .expect(409); @@ -316,7 +347,7 @@ describe('Menu routes (integration)', () => { it('empty menu name returns 400', async () => { const server = app.server; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus') .send({ name: '' }) .expect(400); @@ -325,8 +356,8 @@ describe('Menu routes (integration)', () => { it('empty item name returns 400', async () => { const server = app.server; - const menuRes = await supertest(server).post('/api/menus').send({ name: 'Italian' }).expect(201); - const res = await supertest(server) + const menuRes = await agent(server).post('/api/menus').send({ name: 'Italian' }).expect(201); + const res = await agent(server) .post(`/api/menus/${menuRes.body.id}/items`) .send({ name: '' }) .expect(400); @@ -335,8 +366,8 @@ describe('Menu routes (integration)', () => { it('rejects invalid item price on create', async () => { const server = app.server; - const menuRes = await supertest(server).post('/api/menus').send({ name: 'Italian' }).expect(201); - const res = await supertest(server) + const menuRes = await agent(server).post('/api/menus').send({ name: 'Italian' }).expect(201); + const res = await agent(server) .post(`/api/menus/${menuRes.body.id}/items`) .send({ name: 'Pizza', price: -1 }) .expect(400); @@ -347,7 +378,7 @@ describe('Menu routes (integration)', () => { it('updating non-existent menu returns 404', async () => { const server = app.server; - await supertest(server) + await agent(server) .put('/api/menus/00000000-0000-0000-0000-000000000000') .send({ name: 'Test' }) .expect(404); @@ -355,7 +386,7 @@ describe('Menu routes (integration)', () => { it('deleting non-existent menu returns 404', async () => { const server = app.server; - await supertest(server) + await agent(server) .delete('/api/menus/00000000-0000-0000-0000-000000000000') .expect(404); }); @@ -380,7 +411,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import') .send({ payload }) .expect(200); @@ -409,7 +440,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import') .send({ payload }) .expect(200); @@ -439,7 +470,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import') .send({ payload }) .expect(400); @@ -454,19 +485,19 @@ describe('Menu routes (integration)', () => { ]), ); - const listRes = await supertest(server).get('/api/menus').expect(200); + const listRes = await agent(server).get('/api/menus').expect(200); expect(listRes.body).toHaveLength(0); }); it('previews import with item summary counts', async () => { const server = app.server; - const menuRes = await supertest(server) + const menuRes = await agent(server) .post('/api/menus') .send({ name: 'Pizza Pronto' }) .expect(201); - await supertest(server) + await agent(server) .post(`/api/menus/${menuRes.body.id}/items`) .send({ name: 'Will Delete', description: 'gone' }) .expect(201); @@ -487,7 +518,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import/preview') .send({ payload }) .expect(200); @@ -496,7 +527,7 @@ describe('Menu routes (integration)', () => { expect(res.body.menuExists).toBe(true); expect(res.body.itemSummary).toEqual({ created: 1, updated: 0, deleted: 1 }); - const listRes = await supertest(server).get('/api/menus').expect(200); + const listRes = await agent(server).get('/api/menus').expect(200); expect(listRes.body[0].items).toHaveLength(1); expect(listRes.body[0].items[0].name).toBe('Will Delete'); }); @@ -515,7 +546,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import/preview') .send({ payload }) .expect(400); diff --git a/tests/server/poll-routes.test.ts b/tests/server/poll-routes.test.ts index 20b694c..c1b19c8 100644 --- a/tests/server/poll-routes.test.ts +++ b/tests/server/poll-routes.test.ts @@ -65,7 +65,11 @@ describe('Poll routes (integration)', () => { // Helper: create a menu for voting async function createMenu(name: string) { - const res = await supertest(app.server).post('/api/menus').send({ name }).expect(201); + const res = await supertest(app.server) + .post('/api/menus') + .set(await approvedAuthHeaders()) + .send({ name }) + .expect(201); return res.body; } @@ -223,12 +227,18 @@ describe('Poll routes (integration)', () => { it('returns active poll', async () => { const poll = await startPoll(); - const res = await supertest(app.server).get('/api/polls/active').expect(200); + const res = await supertest(app.server) + .get('/api/polls/active') + .set(await approvedAuthHeaders()) + .expect(200); expect(res.body.id).toBe(poll.id); }); it('returns 404 when no active poll', async () => { - await supertest(app.server).get('/api/polls/active').expect(404); + await supertest(app.server) + .get('/api/polls/active') + .set(await approvedAuthHeaders()) + .expect(404); }); // ─── GET /api/polls/:id ───────────────────────────────── @@ -236,7 +246,10 @@ describe('Poll routes (integration)', () => { it('returns a poll by id for direct links', async () => { const poll = await startPoll(); - const res = await supertest(app.server).get(`/api/polls/${poll.id}`).expect(200); + const res = await supertest(app.server) + .get(`/api/polls/${poll.id}`) + .set(await approvedAuthHeaders()) + .expect(200); expect(res.body.id).toBe(poll.id); expect(res.body.description).toBe(poll.description); From b56471ba69065e521b179025b15ccb83cfe87adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Wall?= Date: Thu, 25 Jun 2026 10:16:01 +0200 Subject: [PATCH 2/5] update faim skill to 0.2 --- .agents/skills/faim-test/SKILL.md | 197 ------------------------------ .claude/skills/faim-test/SKILL.md | 197 ------------------------------ .faim/derived/provenance.pl | 2 +- .faim/faim.conf | 8 +- 4 files changed, 5 insertions(+), 399 deletions(-) delete mode 100644 .agents/skills/faim-test/SKILL.md delete mode 100644 .claude/skills/faim-test/SKILL.md diff --git a/.agents/skills/faim-test/SKILL.md b/.agents/skills/faim-test/SKILL.md deleted file mode 100644 index ff80dfd..0000000 --- a/.agents/skills/faim-test/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -name: faim -description: >- - Query and maintain a project's formal, logic-based memory in `.faim/`. Use - whenever a `.faim/` directory is present: orient via stored facts before - digging through code, check invariants/requirements before and after changes, - record what you derive with provenance, and keep facts fresh. Also triggers on - "faim", "formal memory", "project memory", or the `faim` CLI (init/bootstrap/ - check/status/query/reason/assert/add/retract/attest/stale/update/refresh/seal). ---- - -# FAIM — Formal AI Memory - -FAIM is a queryable, logic-based memory of a codebase. It replaces repeated code -digging with stored facts you can query, and catches drift/contradictions -formally. Facts live in `.faim/` and are versioned with the project. - -**Golden rule:** before reading code to answer a structural question, ask FAIM. -Trust facts by default; verify by exception when the stakes are high or the fact -is stale/low-confidence. - -## Mental model (minimum needed) - -- **Two tiers.** *Axioms* (`.faim/axioms/`) are co-authored ground truth — - descriptive (`prop(project, language, rust)`) or prescriptive - (`requirement(...)`). Changing them is high-ceremony. *Derived* - (`.faim/derived/`) are facts you scanned from code, with provenance. -- **EAV vocabulary.** Everything is `entity(Id, Kind)`, `prop(Id, Attr, Value)`, - `rel(Src, RelType, Dst)`. The legal kinds/attrs/reltypes are declared in - `axioms/schema.pl` — using an undeclared one is a hard error (anti-drift). -- **Trust = freshness, cheaply checked.** A fact whose source files are unchanged - (hash match) is trustworthy for free. Only changed-source facts need attention. - -## Commands - -```bash -# Orient — understand the project's state and structure -faim bootstrap [--project PATH] # whole-project overview: schema vocabulary, requirements, state, checklist -faim status [--project PATH] # fact counts, stale count, axiom-hash, violations, req enforcement -faim query "GOAL" [--project PATH] [--json] [--limit N] # ad-hoc Prolog goal — one answer to deduce -faim query "reaches(A, Rel, B)" # built-in transitive closure over any reltype (impact/blast-radius) -faim reason [--max N] # ASP/clingo decision — many valid answers searched (e.g. min-cut to break cycles) - -# Validate — around changes and in CI -faim check [--strict] [--verify] [--on TARGET] # 4-pass validation; --verify runs allowlisted tools -faim seal # re-bless axiom digest after a deliberate Tier 1 (axiom) change - -# Record — write Tier 2 facts (with provenance) -faim add entity|prop|rel ARG… # quote-free EAV entry (no raw Prolog terms) — the common case -faim batch facts.txt --files A B --query "the question" # many EAV lines: entity|prop|rel ... -faim assert "FACT" --source agent_scan --files A B --query "the question" [--replace] # raw term; --why aliases --query; --files @list reads a path list -faim retract "FACT" | faim retract entity|prop|rel ARG… # remove a fact + provenance (quote-free mirrors add) -faim attest KIND TIMESTAMP # record a user-attested occurrence - -# Keep fresh — after sources change -faim stale # facts whose source files changed (cheap hash sweep) -faim update # re-derivation worklist for stale facts -faim refresh --all # re-bless hashes of stale facts whose meaning is unchanged (no re-assert) -``` - -## On session pickup (do this proactively) - -If the codebase is a nested repo/worktree, pass `--project PATH` instead of -changing directories. Provenance paths are stored relative to that FAIM root. - -1. `faim status` — learn fact counts, stale count, whether axioms changed. (First - time on an unfamiliar project? `faim bootstrap` gives the fuller one-shot - overview — schema vocabulary + requirements + state.) -2. Read `axioms/` — the project's intent, invariants, requirements. This is your - fast orientation; do not re-derive it from code. -3. `faim check --on schedule` (when temporal requirements exist) — surface any - overdue/unconfirmed time obligations and **prompt the user** about them - (e.g. "secret rotation looks overdue — done it?"). On confirmation, record it - with `faim attest`. FAIM never detects real-world events; you are the bridge. -4. If `faim stale` is non-empty, refresh those facts (see "Keeping facts fresh"). - -## Orienting on a task - -- Query FAIM instead of grepping: "what depends on X?", "which endpoints are - unauthenticated?", "what does module Y assume?". -- Each answer carries freshness/confidence. Fresh + high-confidence → trust and - proceed. Stale or low-confidence + high stakes → verify against code. -- `query` vs `reason`: reach for `query` when the question has **one** answer to - deduce (what depends on X, is Y authenticated, full reachability). Reach for - `reason` only when there are **many** valid answers to search/optimize over — - e.g. "fewest `depends_on` edges to cut to break all cycles". A plain impact set - is `query`, not `reason`. - -## Making a change - -Use the smallest memory that answers the task. Model only durable, decision-relevant -facts; do not map the whole codebase before touching code. - -1. Before: query dependents/relationships to scope the blast radius. Direct edges - are `rel(X, depends_on, target)`; for the *full* radius use the built-in - `reaches(X, depends_on, target)` — transitive closure over any reltype, so you - catch indirect dependents a one-hop query misses. Examples: - - event flow: `reaches(X, depends_on, client_sse_hub)` or `rel(service, emits, Event)` - - shared type flow: `reaches(X, depends_on, food_selection_type)` - - endpoint audit: `prop(E, mutates_state, true), prop(E, requires_auth, false)` -2. After: refresh affected derived facts, then `faim check`. Report any new - `violation` (broken invariant, now-unauthenticated endpoint, requirement - breached). **You surface; the user resolves** — never silently amend an axiom. -3. Changing an axiom is high-ceremony: make it explicit, get a good reason, and - expect a wave of re-derivation + a fresh check. After a deliberate, agreed - axiom change, run `faim seal` to re-bless the digest. - -## Deriving facts (Tier 2) — with discipline - -When you scan code and assert a fact: - -- **Record every file you consulted** in `--files`. Provenance completeness is the - linchpin of trust — a missing file means silent staleness later. `--files` accepts - globs and directories, so an aggregate fact (e.g. "package P depends_on Q", - derived from many files) records *all* of them, not one representative. - But don't over-couple: passing a whole directory ties the fact to *every* file - under it, so unrelated edits flag it stale. List the files that actually - determine the fact (for package containment/imports, usually the `__init__`/ - module-index file), not the entire tree. -- **One id, one kind.** A canonical key (path/module/FQN) maps to a single - entity of a single kind — `check` flags the same id under two kinds as an - identity violation. In ecosystems where a name is both a package and a module - (Python `flask.json` is a dir *and* an importable name), pick one kind for the - bare id and disambiguate the other (e.g. `flask.json` the package vs - `flask.json:__init__` the module). -- **Record the question** in `--query` (alias `--why`) so the fact can be - re-derived. Long file lists: `--files @sources.txt` (one path/glob per line). -- **Skip raw Prolog for the common case.** `faim add entity ID KIND`, - `faim add prop ID ATTR VALUE`, `faim add rel SRC RELTYPE DST` build the term and - quote it for you (same `--files/--query/--replace`). Reach for raw - `faim assert "TERM"` only when `add` can't express the shape. -- **Only materialize what earns it.** Store a fact iff it is (a) expensive or - impossible to cheaply re-derive, AND (b) decision-relevant, AND (c) cheaply - kept trustworthy (stable source or tool-verifiable). If you'd have to check the - code anyway, do not store it. -- **Prefer tool-verifiable facts** (`verifiable_by` + a `faim.conf` tool) over - your own judgement — they re-check cheaply and never go stale silently. - -## Keeping facts fresh - -- `faim stale` lists facts whose sources changed; `faim update` gives the - worklist (each with its derivation question + changed files). -- Re-read only the changed files, re-answer each question, then re-`faim assert` - with `--replace` (drops the stale copy + its provenance in place, then writes - fresh — no retract-first). Never re-scan everything — the unchanged majority is - already trustworthy. -- Use `faim refresh --all` only for facts whose **meaning is unchanged** after you - inspected the changed source bytes. If the answer changed, re-derive with - `--replace`; if you did not check, leave it stale. - -## Adopting FAIM on a new project - -1. `faim init`. -2. Co-author the minimum `axioms/schema.pl` vocabulary needed for the task. Common - TS/web vocabulary: `module`, `endpoint`, `event`, `type`, `interface`; - `depends_on`, `emits`, `consumes`, `exposes`; endpoint attrs such as `method`, - `path`, `mutates_state`, `requires_auth`. -3. Help the user state axioms — descriptive (language, architecture) and - prescriptive (coverage, auth, deploy gates). Then `faim seal` to bless the - axiom digest (authoring axioms changes it). -4. `faim bootstrap` to see the declared vocabulary + an authoring checklist, then - scan the codebase and record the high-value structural facts with `faim add` - or `faim batch` (quote-free, with provenance) — raw `faim assert` only for - shapes `add` can't express. Propose missing vocabulary as a schema addition - for the user to approve (that is a Tier 1 change, so re-`seal` after). -5. `faim check` — surface the first contradictions between stated intent and - observed reality. - -## Recipes - -### Invariant audit - -1. Add `requirement(ID, ...)` in axioms. -2. Record observed facts, e.g. endpoint `mutates_state` and `requires_auth`. -3. Add a `violation(ID, Subject, Reason)` rule. -4. Run `faim check`; fix code or record intentional exceptions with rationale. -5. Update facts, re-run `faim check`, and report exact validation commands. - -### Blast-radius change - -1. Record only the dependency edges needed for the target (`depends_on`, `emits`, - `consumes`, `exposes`). -2. Query `reaches(X, depends_on, Target)` before editing. -3. Edit the scoped files; then run `faim stale`, re-derive changed facts or - `refresh` only unchanged meanings, and `faim check`. - -### Validation reporting - -Report the exact cwd/project root, environment variables, and command. If a test -needs special env and you cannot reproduce it, say so instead of claiming pass. - -## Hard rules - -- Never put a derived fact in `axioms/`, never edit `axioms/` without explicit - user agreement. -- Never claim a fact is true beyond what its freshness/confidence supports. -- Never run a verification tool that is not allowlisted in `faim.conf`. diff --git a/.claude/skills/faim-test/SKILL.md b/.claude/skills/faim-test/SKILL.md deleted file mode 100644 index ff80dfd..0000000 --- a/.claude/skills/faim-test/SKILL.md +++ /dev/null @@ -1,197 +0,0 @@ ---- -name: faim -description: >- - Query and maintain a project's formal, logic-based memory in `.faim/`. Use - whenever a `.faim/` directory is present: orient via stored facts before - digging through code, check invariants/requirements before and after changes, - record what you derive with provenance, and keep facts fresh. Also triggers on - "faim", "formal memory", "project memory", or the `faim` CLI (init/bootstrap/ - check/status/query/reason/assert/add/retract/attest/stale/update/refresh/seal). ---- - -# FAIM — Formal AI Memory - -FAIM is a queryable, logic-based memory of a codebase. It replaces repeated code -digging with stored facts you can query, and catches drift/contradictions -formally. Facts live in `.faim/` and are versioned with the project. - -**Golden rule:** before reading code to answer a structural question, ask FAIM. -Trust facts by default; verify by exception when the stakes are high or the fact -is stale/low-confidence. - -## Mental model (minimum needed) - -- **Two tiers.** *Axioms* (`.faim/axioms/`) are co-authored ground truth — - descriptive (`prop(project, language, rust)`) or prescriptive - (`requirement(...)`). Changing them is high-ceremony. *Derived* - (`.faim/derived/`) are facts you scanned from code, with provenance. -- **EAV vocabulary.** Everything is `entity(Id, Kind)`, `prop(Id, Attr, Value)`, - `rel(Src, RelType, Dst)`. The legal kinds/attrs/reltypes are declared in - `axioms/schema.pl` — using an undeclared one is a hard error (anti-drift). -- **Trust = freshness, cheaply checked.** A fact whose source files are unchanged - (hash match) is trustworthy for free. Only changed-source facts need attention. - -## Commands - -```bash -# Orient — understand the project's state and structure -faim bootstrap [--project PATH] # whole-project overview: schema vocabulary, requirements, state, checklist -faim status [--project PATH] # fact counts, stale count, axiom-hash, violations, req enforcement -faim query "GOAL" [--project PATH] [--json] [--limit N] # ad-hoc Prolog goal — one answer to deduce -faim query "reaches(A, Rel, B)" # built-in transitive closure over any reltype (impact/blast-radius) -faim reason [--max N] # ASP/clingo decision — many valid answers searched (e.g. min-cut to break cycles) - -# Validate — around changes and in CI -faim check [--strict] [--verify] [--on TARGET] # 4-pass validation; --verify runs allowlisted tools -faim seal # re-bless axiom digest after a deliberate Tier 1 (axiom) change - -# Record — write Tier 2 facts (with provenance) -faim add entity|prop|rel ARG… # quote-free EAV entry (no raw Prolog terms) — the common case -faim batch facts.txt --files A B --query "the question" # many EAV lines: entity|prop|rel ... -faim assert "FACT" --source agent_scan --files A B --query "the question" [--replace] # raw term; --why aliases --query; --files @list reads a path list -faim retract "FACT" | faim retract entity|prop|rel ARG… # remove a fact + provenance (quote-free mirrors add) -faim attest KIND TIMESTAMP # record a user-attested occurrence - -# Keep fresh — after sources change -faim stale # facts whose source files changed (cheap hash sweep) -faim update # re-derivation worklist for stale facts -faim refresh --all # re-bless hashes of stale facts whose meaning is unchanged (no re-assert) -``` - -## On session pickup (do this proactively) - -If the codebase is a nested repo/worktree, pass `--project PATH` instead of -changing directories. Provenance paths are stored relative to that FAIM root. - -1. `faim status` — learn fact counts, stale count, whether axioms changed. (First - time on an unfamiliar project? `faim bootstrap` gives the fuller one-shot - overview — schema vocabulary + requirements + state.) -2. Read `axioms/` — the project's intent, invariants, requirements. This is your - fast orientation; do not re-derive it from code. -3. `faim check --on schedule` (when temporal requirements exist) — surface any - overdue/unconfirmed time obligations and **prompt the user** about them - (e.g. "secret rotation looks overdue — done it?"). On confirmation, record it - with `faim attest`. FAIM never detects real-world events; you are the bridge. -4. If `faim stale` is non-empty, refresh those facts (see "Keeping facts fresh"). - -## Orienting on a task - -- Query FAIM instead of grepping: "what depends on X?", "which endpoints are - unauthenticated?", "what does module Y assume?". -- Each answer carries freshness/confidence. Fresh + high-confidence → trust and - proceed. Stale or low-confidence + high stakes → verify against code. -- `query` vs `reason`: reach for `query` when the question has **one** answer to - deduce (what depends on X, is Y authenticated, full reachability). Reach for - `reason` only when there are **many** valid answers to search/optimize over — - e.g. "fewest `depends_on` edges to cut to break all cycles". A plain impact set - is `query`, not `reason`. - -## Making a change - -Use the smallest memory that answers the task. Model only durable, decision-relevant -facts; do not map the whole codebase before touching code. - -1. Before: query dependents/relationships to scope the blast radius. Direct edges - are `rel(X, depends_on, target)`; for the *full* radius use the built-in - `reaches(X, depends_on, target)` — transitive closure over any reltype, so you - catch indirect dependents a one-hop query misses. Examples: - - event flow: `reaches(X, depends_on, client_sse_hub)` or `rel(service, emits, Event)` - - shared type flow: `reaches(X, depends_on, food_selection_type)` - - endpoint audit: `prop(E, mutates_state, true), prop(E, requires_auth, false)` -2. After: refresh affected derived facts, then `faim check`. Report any new - `violation` (broken invariant, now-unauthenticated endpoint, requirement - breached). **You surface; the user resolves** — never silently amend an axiom. -3. Changing an axiom is high-ceremony: make it explicit, get a good reason, and - expect a wave of re-derivation + a fresh check. After a deliberate, agreed - axiom change, run `faim seal` to re-bless the digest. - -## Deriving facts (Tier 2) — with discipline - -When you scan code and assert a fact: - -- **Record every file you consulted** in `--files`. Provenance completeness is the - linchpin of trust — a missing file means silent staleness later. `--files` accepts - globs and directories, so an aggregate fact (e.g. "package P depends_on Q", - derived from many files) records *all* of them, not one representative. - But don't over-couple: passing a whole directory ties the fact to *every* file - under it, so unrelated edits flag it stale. List the files that actually - determine the fact (for package containment/imports, usually the `__init__`/ - module-index file), not the entire tree. -- **One id, one kind.** A canonical key (path/module/FQN) maps to a single - entity of a single kind — `check` flags the same id under two kinds as an - identity violation. In ecosystems where a name is both a package and a module - (Python `flask.json` is a dir *and* an importable name), pick one kind for the - bare id and disambiguate the other (e.g. `flask.json` the package vs - `flask.json:__init__` the module). -- **Record the question** in `--query` (alias `--why`) so the fact can be - re-derived. Long file lists: `--files @sources.txt` (one path/glob per line). -- **Skip raw Prolog for the common case.** `faim add entity ID KIND`, - `faim add prop ID ATTR VALUE`, `faim add rel SRC RELTYPE DST` build the term and - quote it for you (same `--files/--query/--replace`). Reach for raw - `faim assert "TERM"` only when `add` can't express the shape. -- **Only materialize what earns it.** Store a fact iff it is (a) expensive or - impossible to cheaply re-derive, AND (b) decision-relevant, AND (c) cheaply - kept trustworthy (stable source or tool-verifiable). If you'd have to check the - code anyway, do not store it. -- **Prefer tool-verifiable facts** (`verifiable_by` + a `faim.conf` tool) over - your own judgement — they re-check cheaply and never go stale silently. - -## Keeping facts fresh - -- `faim stale` lists facts whose sources changed; `faim update` gives the - worklist (each with its derivation question + changed files). -- Re-read only the changed files, re-answer each question, then re-`faim assert` - with `--replace` (drops the stale copy + its provenance in place, then writes - fresh — no retract-first). Never re-scan everything — the unchanged majority is - already trustworthy. -- Use `faim refresh --all` only for facts whose **meaning is unchanged** after you - inspected the changed source bytes. If the answer changed, re-derive with - `--replace`; if you did not check, leave it stale. - -## Adopting FAIM on a new project - -1. `faim init`. -2. Co-author the minimum `axioms/schema.pl` vocabulary needed for the task. Common - TS/web vocabulary: `module`, `endpoint`, `event`, `type`, `interface`; - `depends_on`, `emits`, `consumes`, `exposes`; endpoint attrs such as `method`, - `path`, `mutates_state`, `requires_auth`. -3. Help the user state axioms — descriptive (language, architecture) and - prescriptive (coverage, auth, deploy gates). Then `faim seal` to bless the - axiom digest (authoring axioms changes it). -4. `faim bootstrap` to see the declared vocabulary + an authoring checklist, then - scan the codebase and record the high-value structural facts with `faim add` - or `faim batch` (quote-free, with provenance) — raw `faim assert` only for - shapes `add` can't express. Propose missing vocabulary as a schema addition - for the user to approve (that is a Tier 1 change, so re-`seal` after). -5. `faim check` — surface the first contradictions between stated intent and - observed reality. - -## Recipes - -### Invariant audit - -1. Add `requirement(ID, ...)` in axioms. -2. Record observed facts, e.g. endpoint `mutates_state` and `requires_auth`. -3. Add a `violation(ID, Subject, Reason)` rule. -4. Run `faim check`; fix code or record intentional exceptions with rationale. -5. Update facts, re-run `faim check`, and report exact validation commands. - -### Blast-radius change - -1. Record only the dependency edges needed for the target (`depends_on`, `emits`, - `consumes`, `exposes`). -2. Query `reaches(X, depends_on, Target)` before editing. -3. Edit the scoped files; then run `faim stale`, re-derive changed facts or - `refresh` only unchanged meanings, and `faim check`. - -### Validation reporting - -Report the exact cwd/project root, environment variables, and command. If a test -needs special env and you cannot reproduce it, say so instead of claiming pass. - -## Hard rules - -- Never put a derived fact in `axioms/`, never edit `axioms/` without explicit - user agreement. -- Never claim a fact is true beyond what its freshness/confidence supports. -- Never run a verification tool that is not allowlisted in `faim.conf`. diff --git a/.faim/derived/provenance.pl b/.faim/derived/provenance.pl index 4f2be99..890f1bb 100644 --- a/.faim/derived/provenance.pl +++ b/.faim/derived/provenance.pl @@ -1,7 +1,7 @@ % provenance.pl — Tier 2 trust layer. One group per derived fact. % Kept separate so structure.pl stays readable. % -% derived_from(Fact, Source). Source: tool_scan | agent_scan | user_attested +% derived_from(Fact, Source). Source: scanner_scan | agent_scan | user_attested % source_files(Fact, [Path, ...]). EVERY file consulted — completeness = trust. % source_hash(Fact, Sha256). content hash for cheap staleness detection % derivation_query(Fact, Question). the question answered (re-derivation key) diff --git a/.faim/faim.conf b/.faim/faim.conf index 3de5ad7..9011595 100644 --- a/.faim/faim.conf +++ b/.faim/faim.conf @@ -4,11 +4,11 @@ # Path to SWI-Prolog. Empty = found on PATH. swipl = "" -[tools] +[verifiers] # Allowlist for `verifiable_by`, run only under `faim check --verify`. A -# requirement names a tool; FAIM runs only the command mapped here (cwd = project -# root). Unknown tool names are reported `unverifiable`, never run. -# Exit-code contract: 0 = requirement satisfied, non-zero = tool_failed violation. +# requirement names a verifier; FAIM runs only the command mapped here (cwd = project +# root). Unknown verifier names are reported `unverifiable`, never run. +# Exit-code contract: 0 = requirement satisfied, non-zero = verifier_failed violation. typecheck = "pnpm typecheck" lint = "pnpm lint" architecture = "pnpm architecture" From 71a17aad43e6f57b04bbf6b0a0a5ca78fab8305c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Wall?= Date: Fri, 26 Jun 2026 13:52:58 +0200 Subject: [PATCH 3/5] update fame to 0.3.0 --- .faim/derived/provenance.pl | 4 ++++ .faim/derived/structure.pl | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.faim/derived/provenance.pl b/.faim/derived/provenance.pl index 890f1bb..6448851 100644 --- a/.faim/derived/provenance.pl +++ b/.faim/derived/provenance.pl @@ -492,3 +492,7 @@ source_hash(prop('GET:/api/shopping-list',requires_auth,true), 'sha256:8e0759fe8617f71de66d92554c380d1358bcbad1797e430322e6beb54e77230e'). derivation_query(prop('GET:/api/shopping-list',requires_auth,true), "security audit: all data-read endpoints now require auth"). derived_at(prop('GET:/api/shopping-list',requires_auth,true), '2026-06-24T06:04:24Z', agent). +derived_from(entity(faim,tool), scanner_scan). +derived_at(entity(faim,tool), '2026-06-26T11:46:43Z', agent). +derived_from(prop(faim,version,'0.3.0'), scanner_scan). +derived_at(prop(faim,version,'0.3.0'), '2026-06-26T11:46:43Z', agent). diff --git a/.faim/derived/structure.pl b/.faim/derived/structure.pl index 4f3e705..5c16bd0 100644 --- a/.faim/derived/structure.pl +++ b/.faim/derived/structure.pl @@ -101,3 +101,5 @@ prop('GET:/api/polls/active',requires_auth,true). prop('GET:/api/polls/:id',requires_auth,true). prop('GET:/api/shopping-list',requires_auth,true). +entity(faim,tool). +prop(faim,version,'0.3.0'). From cbb21da0c1103ba225079a50f8695471eed7bb84 Mon Sep 17 00:00:00 2001 From: Timo Klerx Date: Mon, 29 Jun 2026 08:48:57 +0200 Subject: [PATCH 4/5] test: cover authenticated get guards --- src/server/routes/foodSelections.ts | 22 +++++----- src/server/routes/menus.ts | 18 +++++---- src/server/routes/polls.ts | 20 ++++++---- tests/server/authenticated-get-routes.test.ts | 40 +++++++++++++++++++ tests/server/food-selection-routes.test.ts | 22 ++-------- tests/server/menu-routes.test.ts | 7 +--- 6 files changed, 82 insertions(+), 47 deletions(-) create mode 100644 tests/server/authenticated-get-routes.test.ts diff --git a/src/server/routes/foodSelections.ts b/src/server/routes/foodSelections.ts index 367d3b7..d1e0f46 100644 --- a/src/server/routes/foodSelections.ts +++ b/src/server/routes/foodSelections.ts @@ -107,16 +107,20 @@ function registerSelectionOverviewRoutes(app: FastifyInstance) { // GET /api/food-selections/active — get active/overtime food selection with orders app.get('/api/food-selections/active', async (req, reply) => { - await requireAuthenticatedActor(req.headers.cookie); - const officeLocationId = await resolveOfficeLocationIdFromCookie( - req.headers.cookie, - readRequestedOfficeLocationId(req.query), - ); - const selection = await foodSelectionService.getActiveFoodSelection(officeLocationId); - if (!selection) { - return reply.status(404).send({ error: 'No active food selection' }); + try { + await requireAuthenticatedActor(req.headers.cookie); + const officeLocationId = await resolveOfficeLocationIdFromCookie( + req.headers.cookie, + readRequestedOfficeLocationId(req.query), + ); + const selection = await foodSelectionService.getActiveFoodSelection(officeLocationId); + if (!selection) { + return reply.status(404).send({ error: 'No active food selection' }); + } + return reply.send(selection); + } catch (err) { + return sendServiceError(reply, err); } - return reply.send(selection); }); // GET /api/food-selections/history — latest completed selections (most recent first) diff --git a/src/server/routes/menus.ts b/src/server/routes/menus.ts index 824ebc5..75d6593 100644 --- a/src/server/routes/menus.ts +++ b/src/server/routes/menus.ts @@ -17,13 +17,17 @@ import type { export default async function menuRoutes(app: FastifyInstance) { // GET /api/menus — list all menus with items app.get('/api/menus', async (req, reply) => { - await requireAuthenticatedActor(req.headers.cookie); - const officeLocationId = await resolveOfficeLocationIdFromCookie( - req.headers.cookie, - readRequestedOfficeLocationId(req.query), - ); - const menus = await menuService.listMenus(officeLocationId); - return reply.send(menus); + try { + await requireAuthenticatedActor(req.headers.cookie); + const officeLocationId = await resolveOfficeLocationIdFromCookie( + req.headers.cookie, + readRequestedOfficeLocationId(req.query), + ); + const menus = await menuService.listMenus(officeLocationId); + return reply.send(menus); + } catch (err) { + return sendServiceError(reply, err); + } }); // POST /api/menus — create menu diff --git a/src/server/routes/polls.ts b/src/server/routes/polls.ts index 7b20455..b7af8fa 100644 --- a/src/server/routes/polls.ts +++ b/src/server/routes/polls.ts @@ -88,14 +88,18 @@ export default async function pollRoutes(app: FastifyInstance) { // GET /api/polls/active — get current active/tied poll app.get('/api/polls/active', async (req, reply) => { - await requireAuthenticatedActor(req.headers.cookie); - const officeLocationId = await resolveOfficeLocationIdFromCookie( - req.headers.cookie, - readRequestedOfficeLocationId(req.query), - ); - const poll = await pollService.getActivePoll(officeLocationId); - if (!poll) return reply.status(404).send({ error: 'No active poll' }); - return reply.send(poll); + try { + await requireAuthenticatedActor(req.headers.cookie); + const officeLocationId = await resolveOfficeLocationIdFromCookie( + req.headers.cookie, + readRequestedOfficeLocationId(req.query), + ); + const poll = await pollService.getActivePoll(officeLocationId); + if (!poll) return reply.status(404).send({ error: 'No active poll' }); + return reply.send(poll); + } catch (err) { + return sendServiceError(reply, err); + } }); // GET /api/polls/:id — get a specific poll for direct/historical URLs diff --git a/tests/server/authenticated-get-routes.test.ts b/tests/server/authenticated-get-routes.test.ts new file mode 100644 index 0000000..9de9505 --- /dev/null +++ b/tests/server/authenticated-get-routes.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; +import supertest from 'supertest'; +import type { FastifyInstance } from 'fastify'; +import { buildApp } from '../../src/server/index.js'; +import { cleanDatabase, disconnectDatabase } from './helpers/db.js'; + +let app: FastifyInstance; + +describe('Authenticated GET route guards', () => { + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + beforeEach(async () => { + await cleanDatabase(); + }); + + afterAll(async () => { + await cleanDatabase(); + await app.close(); + await disconnectDatabase(); + }); + + const guardedGets = [ + '/api/menus', + '/api/polls/active', + '/api/polls/00000000-0000-0000-0000-000000000000', + '/api/food-selections/active', + '/api/food-selections/history', + '/api/food-selections/00000000-0000-0000-0000-000000000000/fallback-candidates', + '/api/shopping-list', + ]; + + it.each(guardedGets)('rejects unauthenticated GET %s', async (path) => { + const res = await supertest(app.server).get(path).expect(401); + + expect(res.body).toEqual({ error: 'Authentication required' }); + }); +}); diff --git a/tests/server/food-selection-routes.test.ts b/tests/server/food-selection-routes.test.ts index 8279e17..d1c6774 100644 --- a/tests/server/food-selection-routes.test.ts +++ b/tests/server/food-selection-routes.test.ts @@ -98,11 +98,7 @@ describe('Food selection routes (integration)', () => { // ─── Helpers ───────────────────────────────────────────── async function createMenu(name: string) { - const res = await supertest(app.server) - .post('/api/menus') - .set(await approvedAuthHeaders()) - .send({ name }) - .expect(201); + const res = await supertest(app.server).post('/api/menus').set(await approvedAuthHeaders()).send({ name }).expect(201); return res.body; } @@ -502,10 +498,7 @@ describe('Food selection routes (integration)', () => { const { poll } = await createFinishedPoll(); const selection = await startFoodSelection(poll.id); - const res = await supertest(app.server) - .get('/api/food-selections/active') - .set(await approvedAuthHeaders()) - .expect(200); + const res = await supertest(app.server).get('/api/food-selections/active').set(await approvedAuthHeaders()).expect(200); expect(res.body.id).toBe(selection.id); expect(res.body.status).toBe('active'); @@ -569,10 +562,7 @@ describe('Food selection routes (integration)', () => { }); it('returns 404 when no active food selection', async () => { - await supertest(app.server) - .get('/api/food-selections/active') - .set(await approvedAuthHeaders()) - .expect(404); + await supertest(app.server).get('/api/food-selections/active').set(await approvedAuthHeaders()).expect(404); }); // ─── POST /api/food-selections/:id/timer ─────────────── @@ -652,10 +642,7 @@ describe('Food selection routes (integration)', () => { await supertest(app.server).post(`/api/food-selections/${second.id}/place-order`).set(organizerHeaders).send({ etaMinutes: 20, nickname: 'admin@example.com' }).expect(200); await supertest(app.server).post(`/api/food-selections/${second.id}/confirm-arrival`).set(organizerHeaders).expect(200); - const res = await supertest(app.server) - .get('/api/food-selections/history') - .set(await approvedAuthHeaders()) - .expect(200); + const res = await supertest(app.server).get('/api/food-selections/history').set(await approvedAuthHeaders()).expect(200); expect(res.body).toHaveLength(2); expect(res.body[0].id).toBe(second.id); expect(res.body[1].id).toBe(first.id); @@ -1315,4 +1302,3 @@ describe('Food selection routes (integration)', () => { - diff --git a/tests/server/menu-routes.test.ts b/tests/server/menu-routes.test.ts index aff87bf..6b60571 100644 --- a/tests/server/menu-routes.test.ts +++ b/tests/server/menu-routes.test.ts @@ -278,10 +278,8 @@ describe('Menu routes (integration)', () => { .set('Cookie', berlinCookie) .expect(403); - expect(attemptedOverride.body).toMatchObject({ - error: 'Forbidden', - message: 'Requested office is not assigned to the user', - statusCode: 403, + expect(attemptedOverride.body).toEqual({ + error: 'Requested office is not assigned to the user', }); }); @@ -555,4 +553,3 @@ describe('Menu routes (integration)', () => { expect(Array.isArray(res.body.violations)).toBe(true); }); }); - From c13ba7689542cdf40f6cf9bb1d61f0d00cd00dd9 Mon Sep 17 00:00:00 2001 From: Timo Klerx Date: Mon, 29 Jun 2026 09:12:09 +0200 Subject: [PATCH 5/5] fix: require auth for sse stream --- src/server/index.ts | 29 ++++++++++++------- tests/server/authenticated-get-routes.test.ts | 1 + tests/server/sse-integration.test.ts | 4 +-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index a1187b3..ff0f746 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -34,6 +34,8 @@ import { resolveOfficeLocationIdFromCookie, } from './services/officeContext.js'; import { getAppVersion } from './buildInfo.js'; +import { requireAuthenticatedActor } from './routes/authIdentity.js'; +import { sendServiceError } from './routes/routeUtils.js'; if (typeof process.loadEnvFile === 'function') { try { @@ -140,17 +142,22 @@ export async function buildApp() { // SSE endpoint app.get('/api/events', async (request, reply) => { - const raw = reply.raw; - const officeLocationId = await resolveOfficeLocationIdFromCookie( - request.headers.cookie, - readRequestedOfficeLocationId(request.query), - ); - - // Prevent Fastify from automatically sending a response - reply.hijack(); - - register(raw, officeLocationId); - await sendInitialState(raw, officeLocationId); + try { + await requireAuthenticatedActor(request.headers.cookie); + const raw = reply.raw; + const officeLocationId = await resolveOfficeLocationIdFromCookie( + request.headers.cookie, + readRequestedOfficeLocationId(request.query), + ); + + // Prevent Fastify from automatically sending a response + reply.hijack(); + + register(raw, officeLocationId); + await sendInitialState(raw, officeLocationId); + } catch (err) { + return sendServiceError(reply, err); + } }); // Health check diff --git a/tests/server/authenticated-get-routes.test.ts b/tests/server/authenticated-get-routes.test.ts index 9de9505..684a2b5 100644 --- a/tests/server/authenticated-get-routes.test.ts +++ b/tests/server/authenticated-get-routes.test.ts @@ -23,6 +23,7 @@ describe('Authenticated GET route guards', () => { }); const guardedGets = [ + '/api/events', '/api/menus', '/api/polls/active', '/api/polls/00000000-0000-0000-0000-000000000000', diff --git a/tests/server/sse-integration.test.ts b/tests/server/sse-integration.test.ts index 8e7b4bc..e3cc07e 100644 --- a/tests/server/sse-integration.test.ts +++ b/tests/server/sse-integration.test.ts @@ -203,7 +203,7 @@ describe('SSE end-to-end integration', () => { } it('sends initial_state on connection with empty DB', async () => { - const sse = await connectSSE(port); + const sse = await connectSSE(port, defaultHeaders); try { await waitForEvents(sse.events, 1); @@ -235,7 +235,7 @@ describe('SSE end-to-end integration', () => { headers, ); - const sse = await connectSSE(port); + const sse = await connectSSE(port, defaultHeaders); try { await waitForEvents(sse.events, 1);