Skip to content

Commit 18a9529

Browse files
feat(foundation): sign in, tenant scoping and audited mutations
Phase 1. The application runs: four seeded roles sign in through Better Auth, land on a shell that reflects their permissions, and every mutation writes its audit row inside the same transaction as the change. Tenant isolation is the point of this phase, so it is structural rather than remembered: - forTenant(ctx) injects the organization on reads and stamps it on writes, overruling an organizationId the caller supplied. It refuses findUnique/update/delete outright, because those address a row by unique key alone and Prisma will not accept a non-unique filter beside it — a loud error beats a filter that looks applied and is not. - A registry test parses schema.prisma and fails if a model with an organizationId column is not scoped. It immediately found Membership missing, which would have let one organization list another's members. - authorize() answers 404, not 403, for a record in another tenant. A 403 confirms the record exists. Two defects were caught by exercising the running server rather than reading the code. The Command Center listed modules without filtering by permission, so an Operator could see that an Administration area existed while the sidebar beside it hid that module. And a page calling getTenantContext() directly threw an unhandled 401 on anonymous requests, because a layout's redirect does not stop its pages — Next renders them in parallel. Pages now use requireTenantContext(), which redirects; queries and actions keep the throwing variant, where throwing is correct. Three plan decisions were revised against the environment, each recorded in DECISIONS.md with its evidence: npm replaces pnpm (corepack cannot write its shims without administrator rights here), TypeScript is pinned to 5.9 rather than 7.0 (the ESLint toolchain is not built against 7), and the PostgreSQL image is pinned exactly — a floating tag restarted the container against a data directory the newer server refused to open. Verified: lint, typecheck, 26 tests and build all green; both migrations apply to an empty database; all 10 CHECK constraints and the partial unique index exist; the seed is idempotent; a wrong password and an unknown e-mail return the same 401, and the sixth attempt returns 429. The Command Center reports "not computable yet" instead of a status, because three of the four inputs to the readiness score arrive in phases 4 to 6. A green light nobody computed is the one lie this product cannot afford.
1 parent 8321cd4 commit 18a9529

60 files changed

Lines changed: 16021 additions & 794 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copy to .env and fill in. Every variable is parsed by src/config/env.ts at
2+
# startup: a missing or malformed value fails the boot instead of surfacing as
3+
# undefined in production three days later.
4+
5+
# ─── Database (required) ──────────────────────────────────────────────────
6+
# Pooled connection used by the application at runtime.
7+
DATABASE_URL="postgresql://ocean:ocean_dev_only@localhost:5433/ocean_command"
8+
# Unpooled connection used by Prisma Migrate. Same value locally; differs on
9+
# hosted PostgreSQL where the pooler cannot run DDL.
10+
DIRECT_DATABASE_URL="postgresql://ocean:ocean_dev_only@localhost:5433/ocean_command"
11+
12+
# ─── Authentication (required) ────────────────────────────────────────────
13+
# Minimum 32 characters. Generate with: openssl rand -base64 32
14+
BETTER_AUTH_SECRET=""
15+
BETTER_AUTH_URL="http://localhost:3000"
16+
17+
# ─── Providers (optional — defaults are the free/offline implementations) ──
18+
# mock (default). A real AIS feed is not part of the MVP.
19+
AIS_PROVIDER="mock"
20+
# open-meteo (default) | mock
21+
WEATHER_PROVIDER="open-meteo"
22+
# null (default) | openai | anthropic — Phase 9. Ships disabled on purpose.
23+
AI_PROVIDER="null"
24+
# OPENAI_API_KEY=""
25+
# ANTHROPIC_API_KEY=""
26+
27+
# ─── Observability (optional) ─────────────────────────────────────────────
28+
# Protects /api/metrics. In production, if this is unset the route is NOT
29+
# registered at all — a metrics endpoint that fails open is an information leak.
30+
# METRICS_TOKEN=""
31+
LOG_LEVEL="info"

.github/workflows/ci.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
jobs:
13+
quality:
14+
name: lint · typecheck · test · build
15+
runs-on: ubuntu-latest
16+
17+
services:
18+
postgres:
19+
image: postgres:17-alpine
20+
env:
21+
POSTGRES_USER: ocean
22+
POSTGRES_PASSWORD: ocean_ci
23+
POSTGRES_DB: ocean_command
24+
ports:
25+
- 5432:5432
26+
options: >-
27+
--health-cmd "pg_isready -U ocean -d ocean_command"
28+
--health-interval 5s
29+
--health-timeout 5s
30+
--health-retries 10
31+
32+
env:
33+
DATABASE_URL: postgresql://ocean:ocean_ci@localhost:5432/ocean_command
34+
DIRECT_DATABASE_URL: postgresql://ocean:ocean_ci@localhost:5432/ocean_command
35+
# CI-only value. Real secrets never live in a workflow file.
36+
BETTER_AUTH_SECRET: ci-only-secret-not-used-anywhere-else-000
37+
BETTER_AUTH_URL: http://localhost:3000
38+
39+
steps:
40+
- uses: actions/checkout@v4
41+
42+
- uses: actions/setup-node@v4
43+
with:
44+
node-version: 24
45+
cache: npm
46+
47+
- run: npm ci
48+
49+
- run: npx prisma generate
50+
51+
# Migrations run against an empty database on every build, which is what
52+
# proves they still apply from scratch — not just on top of a dev database
53+
# that has been drifting for weeks.
54+
- name: Apply migrations
55+
run: npx prisma migrate deploy
56+
57+
- name: Check for schema drift
58+
run: |
59+
npx prisma migrate diff \
60+
--from-migrations prisma/migrations \
61+
--to-schema-datamodel prisma/schema.prisma \
62+
--shadow-database-url "$DATABASE_URL" \
63+
--exit-code && echo "schema and migrations agree"
64+
65+
- run: npm run db:seed
66+
67+
- run: npm run lint
68+
69+
- run: npm run typecheck
70+
71+
# Integration tests run for real here: the service container means the
72+
# tenant-isolation suite cannot silently skip.
73+
- run: npm run test
74+
75+
- run: npm run build
76+
77+
secret-scan:
78+
name: secret scan
79+
runs-on: ubuntu-latest
80+
steps:
81+
- uses: actions/checkout@v4
82+
with:
83+
# TruffleHog diffs the range a push introduced, which needs the parent
84+
# of the first commit. A shallow checkout makes multi-commit pushes
85+
# fail for a reason that has nothing to do with secrets.
86+
fetch-depth: 0
87+
88+
- uses: trufflesecurity/trufflehog@main
89+
with:
90+
extra_args: --results=verified,unknown

.prettierrc.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"semi": false,
3+
"singleQuote": true,
4+
"printWidth": 100,
5+
"trailingComma": "all",
6+
"arrowParens": "always"
7+
}

README.md

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,42 @@ down and testable.
1010

1111
## Current state — read this first
1212

13-
**Phase 0 (Architecture) is complete. There is no application code in this repository yet.**
13+
**Phases 0 (Architecture) and 1 (Foundation) are complete.** You can run the application, sign in
14+
as any of four roles, and every mutation is audited. The operational modules — fleet, operations,
15+
weather, risk, alerts, assets, incidents — are **not built yet**; they are phases 2 to 8.
1416

15-
This is a deliberate first step, not an unfinished one. Phase 0 produced the domain model, the
16-
schema, the decision rules, the security model and the roadmap, so that Phase 1 starts from a
17-
design instead of discovering it mid-implementation.
18-
19-
| Deliverable | Status |
17+
| Capability | Status |
2018
| --- | --- |
21-
| Product analysis and module scope |[ARCHITECTURE.md](docs/ARCHITECTURE.md) |
22-
| Architecture, layers, module graph, provider strategy |[ARCHITECTURE.md](docs/ARCHITECTURE.md) |
23-
| Domain model, reference schema, indexes, constraints |[DATABASE.md](docs/DATABASE.md) |
24-
| Risk / weather-window / readiness formulas |[ARCHITECTURE.md §5](docs/ARCHITECTURE.md) |
25-
| Auth, RBAC matrix, multi-tenancy, threat model |[SECURITY.md](docs/SECURITY.md) |
26-
| Internal API contracts |[API.md](docs/API.md) |
27-
| Technology decisions, alternatives rejected |[DECISIONS.md](docs/DECISIONS.md) + [ADRs](docs/adr/) |
28-
| Roadmap with per-phase acceptance criteria |[ROADMAP.md](docs/ROADMAP.md) |
29-
| Application, database, UI | 🔜 Phase 1 |
30-
31-
Nothing below is described as working unless this table says it is. The status column is updated
32-
when a phase actually passes its gate — never in advance.
19+
| Architecture, domain model, decision rules, threat model |[docs/](docs/) |
20+
| PostgreSQL schema — 24 tables, 2 migrations, CHECK constraints, partial unique index ||
21+
| Authentication — e-mail/password, Argon2id, database sessions, rate limiting ||
22+
| RBAC — 4 roles, 36 permissions, one matrix, checked server-side ||
23+
| Multi-tenancy — `TenantContext` required by every data access ||
24+
| Audit trail — written inside the mutation's transaction ||
25+
| Application shell — navigation filtered by role, `DEMO DATA` marker ||
26+
| Deterministic idempotent seed — 2 organizations, one user per role ||
27+
| Fleet, operations, weather, risk, alerts, assets, incidents, analytics | 🔜 Phases 2–8 |
28+
| Ocean AI | 🔜 Phase 9 |
29+
| E2E tests, metrics, deployment | 🔜 Phase 10 |
30+
31+
Nothing is described as working unless this table says it is, and the Command Center says the same
32+
thing on screen: its Operational Status panel reports **"not computable yet"** rather than a green
33+
light, because three of the four inputs to that score do not exist before phase 6.
34+
35+
### Verified on 2026-07-26
36+
37+
```text
38+
lint ✓ no errors
39+
typecheck ✓ no errors
40+
test ✓ 26 passed (5 files)
41+
build ✓ 6 routes
42+
```
43+
44+
Plus, against a real PostgreSQL 17: both migrations apply to an empty database, all 10 CHECK
45+
constraints and the partial unique index exist, the seed is idempotent (run twice → same counts),
46+
and all four seeded roles sign in and receive a shell that matches their permissions — a Viewer
47+
sees 10 permissions and no Administration module, an Operator 17 with `alert:acknowledge` but not
48+
`alert:resolve`, a Manager 30, an Administrator 36.
3349

3450
---
3551

@@ -108,20 +124,49 @@ accept, making a forgotten tenant filter a type error rather than a data leak.
108124

109125
## Stack
110126

111-
Next.js 16 · React 19 · TypeScript 7 (strict) · Tailwind 4 · shadcn/ui · PostgreSQL 17 ·
112-
Prisma 7 · Better Auth · Zod 4 · Leaflet · Recharts · Vitest · pnpm.
127+
Next.js 16 · React 19 · TypeScript 5.9 (strict, `noUncheckedIndexedAccess`) · Tailwind 4 ·
128+
PostgreSQL 17 · Prisma 7 · Better Auth · Argon2id · Zod 4 · Vitest · npm.
129+
Leaflet and Recharts arrive with the modules that need them (phases 2 and 8).
113130

114-
Every choice, and the alternative it beat, is in [DECISIONS.md](docs/DECISIONS.md).
115-
Running cost of the MVP is zero: free tiers and open source only.
131+
Every choice, and the alternative it beat, is in [DECISIONS.md](docs/DECISIONS.md) — including
132+
three decisions revised during phase 1 because the environment disagreed with the plan.
133+
Running cost is zero: free tiers and open source only.
116134

117135
## Getting started
118136

119-
Nothing to run yet — Phase 1 creates the application. When it exists, this section will carry the
120-
real commands, verified, and not before.
137+
Requires Node 22+ and Docker.
138+
139+
```bash
140+
cp .env.example .env # then set BETTER_AUTH_SECRET (openssl rand -base64 32)
141+
npm install
142+
docker compose up -d # PostgreSQL 17 on port 5433
143+
npx prisma migrate deploy # or `npm run db:migrate` while developing
144+
npm run db:seed # idempotent — safe to re-run
145+
npm run dev # http://localhost:3000
146+
```
147+
148+
### Demo access
149+
150+
Seeded accounts, **development only**, all with the password `OceanCommand2026!`:
151+
152+
| E-mail | Role |
153+
| --- | --- |
154+
| `admin@oceancommand.demo` | Administrator |
155+
| `manager@oceancommand.demo` | Operations Manager |
156+
| `operator@oceancommand.demo` | Operator |
157+
| `viewer@oceancommand.demo` | Viewer |
158+
159+
Sign in as more than one to see the same page grant different access. These accounts exist only in
160+
a database you seeded yourself, in an organization flagged `isDemo`, which is what puts the
161+
`DEMO DATA` marker in the header.
162+
163+
A second organization, `northern-marine`, is seeded with one user and no data. It is not a demo of
164+
anything: it exists so the tenant-isolation tests can *prove* that one organization cannot read
165+
another's records instead of asserting it.
121166

122167
## Quality gate
123168

124-
From Phase 1 onward, no phase is considered done until all of these pass in CI:
169+
No phase is considered done until all of these pass in CI:
125170

126171
```text
127172
lint · typecheck · unit + integration tests · build · secret scan
@@ -144,7 +189,7 @@ deletable.
144189

145190
## Roadmap
146191

147-
Phase 0 Architecture ✅ · 1 Foundation · 2 Fleet Command · 3 Operations · 4 Environmental
192+
Phase 0 Architecture ✅ · 1 Foundation · 2 Fleet Command · 3 Operations · 4 Environmental
148193
Intelligence · 5 Risk & Alerts · 6 Asset Monitoring · 7 Incidents · 8 Analytics & Command Center ·
149194
9 Ocean AI · 10 Production Readiness.
150195

docker-compose.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Local development database only. Deployed environments use managed PostgreSQL
2+
# (see docs/adr/002-postgresql-and-hosting.md). Port 5433 on the host to avoid
3+
# colliding with a PostgreSQL already installed locally.
4+
services:
5+
postgres:
6+
# Pinned exactly. A floating tag means the day a new major is published, the
7+
# container restarts against a data directory the new server refuses to open.
8+
image: postgres:17-alpine
9+
container_name: ocean-command-db
10+
restart: unless-stopped
11+
environment:
12+
POSTGRES_USER: ocean
13+
POSTGRES_PASSWORD: ocean_dev_only
14+
POSTGRES_DB: ocean_command
15+
ports:
16+
- '5433:5432'
17+
volumes:
18+
- postgres-data:/var/lib/postgresql/data
19+
healthcheck:
20+
test: ['CMD-SHELL', 'pg_isready -U ocean -d ocean_command']
21+
interval: 5s
22+
timeout: 5s
23+
retries: 10
24+
25+
volumes:
26+
postgres-data:

0 commit comments

Comments
 (0)