Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .faim/axioms.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a5d5e3e256831c219bba74ee9b97b2a3562429330c52b8e2a654a9846ec8c706
12 changes: 12 additions & 0 deletions .faim/axioms/decisions.pl
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions .faim/axioms/invariants.pl
Original file line number Diff line number Diff line change
@@ -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').
24 changes: 24 additions & 0 deletions .faim/axioms/requirements.pl
Original file line number Diff line number Diff line change
@@ -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)).
42 changes: 42 additions & 0 deletions .faim/axioms/schema.pl
Original file line number Diff line number Diff line change
@@ -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).
498 changes: 498 additions & 0 deletions .faim/derived/provenance.pl

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions .faim/derived/structure.pl
Original file line number Diff line number Diff line change
@@ -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').
34 changes: 34 additions & 0 deletions .faim/faim.conf
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions .faim/rules/deduction.pl
Original file line number Diff line number Diff line change
@@ -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, _).
24 changes: 24 additions & 0 deletions .faim/rules/reasoning.lp
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ graphify-out/*
backups/
package-lock.json
.zed/settings.json
.faim/faim.log
10 changes: 10 additions & 0 deletions scripts/prisma-generate-safe.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 18 additions & 11 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
23 changes: 15 additions & 8 deletions src/server/routes/foodSelections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading