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..6448851 --- /dev/null +++ b/.faim/derived/provenance.pl @@ -0,0 +1,498 @@ +% provenance.pl — Tier 2 trust layer. One group per derived fact. +% Kept separate so structure.pl stays readable. +% +% 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) +% 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). +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 new file mode 100644 index 0000000..5c16bd0 --- /dev/null +++ b/.faim/derived/structure.pl @@ -0,0 +1,105 @@ +% 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). +entity(faim,tool). +prop(faim,version,'0.3.0'). diff --git a/.faim/faim.conf b/.faim/faim.conf new file mode 100644 index 0000000..9011595 --- /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 = "" + +[verifiers] +# Allowlist for `verifiable_by`, run only under `faim check --verify`. A +# 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" +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/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/src/server/routes/foodSelections.ts b/src/server/routes/foodSelections.ts index 5fd0cd4..d1e0f46 100644 --- a/src/server/routes/foodSelections.ts +++ b/src/server/routes/foodSelections.ts @@ -107,20 +107,26 @@ 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) => { - 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) 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 +492,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..75d6593 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,17 +17,23 @@ import type { export default async function menuRoutes(app: FastifyInstance) { // GET /api/menus — list all menus with items app.get('/api/menus', async (req, reply) => { - 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 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 +48,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 +65,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 +83,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 +99,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 +116,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 +141,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 +166,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..b7af8fa 100644 --- a/src/server/routes/polls.ts +++ b/src/server/routes/polls.ts @@ -88,18 +88,24 @@ export default async function pollRoutes(app: FastifyInstance) { // GET /api/polls/active — get current active/tied poll app.get('/api/polls/active', async (req, reply) => { - 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 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/authenticated-get-routes.test.ts b/tests/server/authenticated-get-routes.test.ts new file mode 100644 index 0000000..684a2b5 --- /dev/null +++ b/tests/server/authenticated-get-routes.test.ts @@ -0,0 +1,41 @@ +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/events', + '/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 14d86cf..d1c6774 100644 --- a/tests/server/food-selection-routes.test.ts +++ b/tests/server/food-selection-routes.test.ts @@ -98,13 +98,14 @@ 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; @@ -497,9 +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') - .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'); @@ -563,9 +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') - .expect(404); + await supertest(app.server).get('/api/food-selections/active').set(await approvedAuthHeaders()).expect(404); }); // ─── POST /api/food-selections/:id/timer ─────────────── @@ -645,7 +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').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 +817,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([ @@ -1304,4 +1302,3 @@ describe('Food selection routes (integration)', () => { - 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..6b60571 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); @@ -247,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', }); }); @@ -297,15 +326,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 +345,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 +354,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 +364,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 +376,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 +384,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 +409,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 +438,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 +468,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 +483,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 +516,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 +525,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 +544,7 @@ describe('Menu routes (integration)', () => { ], }; - const res = await supertest(server) + const res = await agent(server) .post('/api/menus/import/preview') .send({ payload }) .expect(400); @@ -524,4 +553,3 @@ describe('Menu routes (integration)', () => { expect(Array.isArray(res.body.violations)).toBe(true); }); }); - 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); 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);