Skip to content

Commit db64b1d

Browse files
Merge pull request #51 from Juliusolsson05/feat/admin-strict-validation
fix: harden admin write validation with shared zod schemas
2 parents efad0da + 9201264 commit db64b1d

37 files changed

Lines changed: 1039 additions & 826 deletions

File tree

agent/AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ You are the Pharos fulfillment agent for a high-stakes conflict-intelligence das
2626
17. **Day snapshot must be kept complete.** The brief, keyFacts, casualties, economicImpact (chips + narrative), and scenarios/outlook must be filled and updated whenever material changes occur. Empty fields on a live conflict day are a product failure.
2727
18. **X signals must be captured continuously.** Every cycle should search for real tweets and official statements. If good signals exist and are not in the system, add them. Never fabricate tweet IDs.
2828
19. **The workspace todos list is a real work queue.** P1 items must be addressed in the current cycle. P2 items should be addressed before declaring NOOP.
29+
20. **Complete coverage before moving to the next day.** Finish all entity types for the current day (events, sources, responses, snapshots, actions, x-posts, map features, day snapshot, stories) before starting the next day. Breadth without depth is a product failure.
30+
21. **Enforce before creating.** Always run the payload with `?enforcement=true` before creating events, day snapshots, x-posts, and stories. Fix flagged issues, then create. Do not skip this step.
2931

3032
## Mission standard
3133

agent/HEARTBEAT.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@ Bare-skeleton events are not acceptable output.
5252
- **Story**: If a cluster of events forms a coherent spatial narrative, create the story in the same cycle.
5353

5454
8. For every cycle (even NOOP on new events):
55-
- **Actor responses**: Check all today's events for missing responses. Fill gaps for HIGH and CRITICAL events.
56-
- **Day snapshot brief**: Check if keyFacts, casualties, economicImpact, or scenarios need updating based on what happened. Update if material changes exist.
57-
- **Actor snapshots**: If any actor snapshots are missing for today, create them.
58-
- **Todos**: Work through the workspace todos list. These are real gaps, not suggestions.
55+
- **Actor responses**: Check all today's events for missing responses. Fill gaps for HIGH and CRITICAL events.
56+
- **Day snapshot brief**: Check if keyFacts, casualties, economicImpact, or scenarios need updating based on what happened. Update if material changes exist.
57+
- **Actor snapshots**: If any actor snapshots are missing for today, create them.
58+
- **Todos**: Work through the workspace todos list. These are real gaps, not suggestions.
59+
- **Enforcement**: Before every create call, run the payload with `?enforcement=true` first. Fix flagged issues, then create.
60+
- **Coordinates**: Verify all viewState and geometry coordinates against named locations before writing. Do not use approximate or memorized coordinates.
5961

6062
### Phase 5: Verify
6163

agent/TOOLS.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,17 @@ There is NO generic `POST /map/features` endpoint. Use these concrete routes:
169169
| THREAT_ZONE | `POST /map/threat-zones` | Area polygon (closure, NFZ, etc.) |
170170
| HEAT_POINT | `POST /map/heat-points` | Intensity/concentration marker |
171171

172-
`POST /map/strike-arcs`: `{id, actor (MAP_ACTOR_KEY), priority (P1|P2|P3), category, type (AIRSTRIKE|NAVAL_STRIKE|BALLISTIC|CRUISE|DRONE), geometry: {from: {lat, lng}, to: {lat, lng}}, status?, timestamp?, sourceEventId?, properties?}`
172+
`POST /map/strike-arcs`: `{id, actor (MAP_ACTOR_KEY), priority (P1|P2|P3), category, type (AIRSTRIKE|NAVAL_STRIKE|BALLISTIC|CRUISE|DRONE), geometry: {from: {lat, lng}, to: {lat, lng}}, status?, timestamp?, sourceEventId?, properties: {label, ...}}`
173173
`POST /map/missile-tracks`: same schema as strike-arcs
174174
`POST /map/targets`: `{id, actor, priority, category, type (CARRIER|AIR_BASE|NAVAL_BASE|ARMY_BASE|NUCLEAR_SITE|COMMAND|INFRASTRUCTURE), geometry: {position: {lat, lng}}, status?, timestamp?, sourceEventId?, properties: {name, description?}}`
175175
`POST /map/assets`: same schema as targets
176-
`POST /map/threat-zones`: `{id, actor, priority, category, type (CLOSURE|PATROL|NFZ|THREAT_CORRIDOR), geometry: {coordinates: [[lat, lng], ...]}, timestamp?, sourceEventId?, properties: {name, color?}}`
176+
`POST /map/threat-zones`: `{id, actor, priority, category, type (CLOSURE|PATROL|NFZ|THREAT_CORRIDOR), geometry: {coordinates: [[lat, lng], ...]}, timestamp?, sourceEventId?, properties: {name, color, ...}}`
177177
`POST /map/heat-points`: `{id, actor, priority, category, type, geometry: {position: {lat, lng}}, properties: {weight}}`
178178
`PUT /map/features/{featureId}`: update any existing feature (partial)
179179

180180
### Map story endpoints
181181

182-
`POST /map/stories`: `{id, title, tagline, iconName, category, narrative, viewState: {longitude, latitude, zoom}, timestamp, primaryEventId?, sourceEventIds?[], highlightStrikeIds?[], highlightMissileIds?[], highlightTargetIds?[], highlightAssetIds?[], keyFacts?[], events?: [{time, label, type}]}`
182+
`POST /map/stories`: `{id, title, tagline, iconName, category, narrative, viewState: {longitude, latitude, zoom}, timestamp, primaryEventId?, sourceEventIds?[], highlightStrikeIds?[], highlightMissileIds?[], highlightTargetIds?[], highlightAssetIds?[], keyFacts?[], events: [{time, label, type}]}`
183183
`PUT /map/stories/{storyId}`: update story (partial)
184184
`POST /map/stories/{storyId}/events`: append timeline events
185185
`PUT /map/stories/{storyId}/events`: replace all timeline events
@@ -228,3 +228,31 @@ All optional: `{status, threatLevel, escalation (0-100), name, summary, keyFacts
228228
- empty day snapshot fields are a product failure — fill them
229229
- NOOP is only valid when the dashboard is complete AND nothing new happened
230230
- ALWAYS read the FULL /instructions manual including the API endpoint reference — do not skip sections
231+
232+
## Coordinate verification
233+
234+
Before writing any viewState or geometry coordinates:
235+
- look up the named location's real coordinates from your search results or existing map features
236+
- do not use memorized or approximate coordinates
237+
- verify longitude and latitude are correct to within ~0.5 degrees of the named location
238+
- if uncertain, search "[location name] coordinates" before writing
239+
240+
## Highlight ID rules
241+
242+
Feature ID prefixes determine which highlight array they belong in:
243+
- `sa-*` -> highlightStrikeIds
244+
- `mt-*` -> highlightMissileIds
245+
- `t-*` -> highlightTargetIds
246+
- `a-*` -> highlightAssetIds
247+
- `hp-*` and `tz-*` are heat points and threat zones - these NEVER go in highlight arrays
248+
249+
Only reference features from the same day/incident as the story. Do not cross-reference features from unrelated days. If all highlight arrays would be empty, find or create the right features first.
250+
251+
## Enforcement rule
252+
253+
Before any POST that creates an event, day snapshot, x-post, or story:
254+
1. Run the same payload with `?enforcement=true` first
255+
2. Read the enforcement response and fix any flagged issues
256+
3. Then run the real create
257+
258+
Do not skip this step.

package-lock.json

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@
1010
"homepage": "https://conflicts.app",
1111
"bugs": "https://github.com/Juliusolsson05/pharos-ai/issues",
1212
"author": "Julius Olsson",
13-
"engines": {
14-
"node": "22.x"
15-
},
1613
"scripts": {
1714
"dev": "next dev",
1815
"build": "next build",
@@ -91,8 +88,8 @@
9188
"react-dom": "19.2.3",
9289
"react-grid-layout": "^2.2.2",
9390
"react-leaflet": "^5.0.0",
94-
"react-markdown": "^10.1.0",
9591
"react-map-gl": "^8.1.0",
92+
"react-markdown": "^10.1.0",
9693
"react-redux": "^9.2.0",
9794
"react-resizable-panels": "^4.7.0",
9895
"react-tweet": "^3.3.0",
@@ -101,7 +98,8 @@
10198
"sonner": "^2.0.7",
10299
"tailwind-merge": "^3.5.0",
103100
"tailwindcss-animate": "^1.0.7",
104-
"vaul": "^1.1.2"
101+
"vaul": "^1.1.2",
102+
"zod": "^4.3.6"
105103
},
106104
"devDependencies": {
107105
"@tailwindcss/postcss": "^4",

src/app/api/v1/admin/[conflictId]/actors/[actorId]/actions/route.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { requireAdmin } from '@/server/lib/admin-auth';
4-
import { assertEnum , assertRequired, safeJson } from '@/server/lib/admin-validate';
5-
import { err,ok } from '@/server/lib/api-utils';
4+
import { parseBodyWithSchema } from '@/server/lib/admin-schema-utils';
5+
import { adminActorActionCreateSchema } from '@/server/lib/admin-schemas';
6+
import { err, ok } from '@/server/lib/api-utils';
67
import { prisma } from '@/server/lib/db';
78
import { upsertActorDocument } from '@/server/lib/rag/indexer';
89

9-
import { ActionSignificance,ActionType } from '@/generated/prisma/client';
10-
11-
const ACTION_TYPES = Object.values(ActionType);
12-
const ACTION_SIGNIFICANCES = Object.values(ActionSignificance);
13-
1410
export async function POST(
1511
req: NextRequest,
1612
{ params }: { params: Promise<{ conflictId: string; actorId: string }> },
@@ -19,21 +15,12 @@ export async function POST(
1915
if (denied) return denied;
2016

2117
const { conflictId, actorId } = await params;
22-
const body = await safeJson(req);
18+
const body = await parseBodyWithSchema(req, adminActorActionCreateSchema);
2319
if (body instanceof NextResponse) return body;
2420

2521
const actor = await prisma.actor.findFirst({ where: { id: actorId, conflictId } });
2622
if (!actor) return err('NOT_FOUND', `Actor ${actorId} not found`, 404);
2723

28-
const missing = assertRequired(body, ['date', 'type', 'description', 'significance']);
29-
if (missing) return err('VALIDATION', missing);
30-
31-
const typeErr = assertEnum(body.type, ACTION_TYPES, 'type');
32-
if (typeErr) return err('VALIDATION', typeErr);
33-
34-
const sigErr = assertEnum(body.significance, ACTION_SIGNIFICANCES, 'significance');
35-
if (sigErr) return err('VALIDATION', sigErr);
36-
3724
const action = await prisma.actorAction.create({
3825
data: {
3926
actorId,

src/app/api/v1/admin/[conflictId]/actors/[actorId]/responses/route.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { requireAdmin } from '@/server/lib/admin-auth';
4-
import { assertEnum , assertRequired, safeJson } from '@/server/lib/admin-validate';
5-
import { err,ok } from '@/server/lib/api-utils';
4+
import { parseBodyWithSchema } from '@/server/lib/admin-schema-utils';
5+
import { adminActorResponseCreateSchema } from '@/server/lib/admin-schemas';
6+
import { err, ok } from '@/server/lib/api-utils';
67
import { prisma } from '@/server/lib/db';
78
import { upsertActorDocument, upsertEventDocument } from '@/server/lib/rag/indexer';
89

9-
import { ActorResponseStance } from '@/generated/prisma/client';
10-
11-
const STANCES = Object.values(ActorResponseStance);
12-
1310
export async function POST(
1411
req: NextRequest,
1512
{ params }: { params: Promise<{ conflictId: string; actorId: string }> },
@@ -18,19 +15,12 @@ export async function POST(
1815
if (denied) return denied;
1916

2017
const { conflictId, actorId } = await params;
21-
const body = await safeJson(req);
18+
const body = await parseBodyWithSchema(req, adminActorResponseCreateSchema);
2219
if (body instanceof NextResponse) return body;
2320

2421
const actor = await prisma.actor.findFirst({ where: { id: actorId, conflictId } });
2522
if (!actor) return err('NOT_FOUND', `Actor ${actorId} not found`, 404);
2623

27-
const missing = assertRequired(body, ['eventId', 'stance', 'type', 'statement']);
28-
if (missing) return err('VALIDATION', missing);
29-
30-
const stanceErr = assertEnum(body.stance, STANCES, 'stance');
31-
if (stanceErr) return err('VALIDATION', stanceErr);
32-
33-
// Validate event exists
3424
const event = await prisma.intelEvent.findFirst({
3525
where: { id: body.eventId, conflictId },
3626
});

src/app/api/v1/admin/[conflictId]/actors/[actorId]/route.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { requireAdmin } from '@/server/lib/admin-auth';
4-
import { assertEnum , safeJson } from '@/server/lib/admin-validate';
5-
import { err,ok } from '@/server/lib/api-utils';
4+
import { parseBodyWithSchema } from '@/server/lib/admin-schema-utils';
5+
import { adminActorUpdateSchema } from '@/server/lib/admin-schemas';
6+
import { err, ok } from '@/server/lib/api-utils';
67
import { prisma } from '@/server/lib/db';
78
import { upsertActorDocument } from '@/server/lib/rag/indexer';
89

9-
import { ActivityLevel, Stance } from '@/generated/prisma/client';
10-
11-
const ACTIVITY_LEVELS = Object.values(ActivityLevel);
12-
const STANCES = Object.values(Stance);
13-
1410
export async function PUT(
1511
req: NextRequest,
1612
{ params }: { params: Promise<{ conflictId: string; actorId: string }> },
@@ -19,24 +15,15 @@ export async function PUT(
1915
if (denied) return denied;
2016

2117
const { conflictId, actorId } = await params;
22-
const body = await safeJson(req);
18+
const body = await parseBodyWithSchema(req, adminActorUpdateSchema);
2319
if (body instanceof NextResponse) return body;
2420

2521
const actor = await prisma.actor.findFirst({ where: { id: actorId, conflictId } });
2622
if (!actor) return err('NOT_FOUND', `Actor ${actorId} not found`, 404);
2723

2824
const data: Record<string, unknown> = {};
29-
30-
if (body.activityLevel !== undefined) {
31-
const e = assertEnum(body.activityLevel, ACTIVITY_LEVELS, 'activityLevel');
32-
if (e) return err('VALIDATION', e);
33-
data.activityLevel = body.activityLevel;
34-
}
35-
if (body.stance !== undefined) {
36-
const e = assertEnum(body.stance, STANCES, 'stance');
37-
if (e) return err('VALIDATION', e);
38-
data.stance = body.stance;
39-
}
25+
if (body.activityLevel !== undefined) data.activityLevel = body.activityLevel;
26+
if (body.stance !== undefined) data.stance = body.stance;
4027
if (body.activityScore !== undefined) data.activityScore = body.activityScore;
4128
if (body.saying !== undefined) data.saying = body.saying;
4229
if (body.doing !== undefined) data.doing = body.doing;

src/app/api/v1/admin/[conflictId]/actors/[actorId]/snapshots/[day]/route.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { requireAdmin } from '@/server/lib/admin-auth';
4-
import { assertEnum, assertIntRange , safeJson } from '@/server/lib/admin-validate';
5-
import { err,ok } from '@/server/lib/api-utils';
4+
import { parseBodyWithSchema, parseDayParam } from '@/server/lib/admin-schema-utils';
5+
import { adminActorSnapshotUpdateSchema } from '@/server/lib/admin-schemas';
6+
import { err, ok } from '@/server/lib/api-utils';
67
import { prisma } from '@/server/lib/db';
78
import { upsertActorDocument } from '@/server/lib/rag/indexer';
89

9-
import { ActivityLevel, Stance } from '@/generated/prisma/client';
10-
11-
const ACTIVITY_LEVELS = Object.values(ActivityLevel);
12-
const STANCES = Object.values(Stance);
13-
1410
export async function PUT(
1511
req: NextRequest,
1612
{ params }: { params: Promise<{ conflictId: string; actorId: string; day: string }> },
@@ -19,37 +15,24 @@ export async function PUT(
1915
if (denied) return denied;
2016

2117
const { conflictId, actorId, day: dayStr } = await params;
22-
const body = await safeJson(req);
18+
const body = await parseBodyWithSchema(req, adminActorSnapshotUpdateSchema);
2319
if (body instanceof NextResponse) return body;
2420

2521
const actor = await prisma.actor.findFirst({ where: { id: actorId, conflictId } });
2622
if (!actor) return err('NOT_FOUND', `Actor ${actorId} not found`, 404);
2723

28-
const day = new Date(dayStr + 'T00:00:00Z');
29-
if (isNaN(day.getTime())) return err('VALIDATION', 'Invalid day format');
24+
const day = parseDayParam(dayStr);
25+
if (!day) return err('VALIDATION', 'Invalid day format', 422);
3026

3127
const snapshot = await prisma.actorDaySnapshot.findUnique({
3228
where: { actorId_day: { actorId, day } },
3329
});
3430
if (!snapshot) return err('NOT_FOUND', `Snapshot for ${actorId} on ${dayStr} not found`, 404);
3531

3632
const data: Record<string, unknown> = {};
37-
38-
if (body.activityLevel !== undefined) {
39-
const e = assertEnum(body.activityLevel, ACTIVITY_LEVELS, 'activityLevel');
40-
if (e) return err('VALIDATION', e);
41-
data.activityLevel = body.activityLevel;
42-
}
43-
if (body.stance !== undefined) {
44-
const e = assertEnum(body.stance, STANCES, 'stance');
45-
if (e) return err('VALIDATION', e);
46-
data.stance = body.stance;
47-
}
48-
if (body.activityScore !== undefined) {
49-
const e = assertIntRange(body.activityScore, 0, 100, 'activityScore');
50-
if (e) return err('VALIDATION', e);
51-
data.activityScore = body.activityScore;
52-
}
33+
if (body.activityLevel !== undefined) data.activityLevel = body.activityLevel;
34+
if (body.stance !== undefined) data.stance = body.stance;
35+
if (body.activityScore !== undefined) data.activityScore = body.activityScore;
5336
if (body.saying !== undefined) data.saying = body.saying;
5437
if (body.doing !== undefined) data.doing = body.doing;
5538
if (body.assessment !== undefined) data.assessment = body.assessment;

src/app/api/v1/admin/[conflictId]/actors/[actorId]/snapshots/route.ts

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
import { requireAdmin } from '@/server/lib/admin-auth';
4-
import { assertEnum, assertIntRange , assertRequired, safeJson } from '@/server/lib/admin-validate';
5-
import { err,ok } from '@/server/lib/api-utils';
4+
import { parseBodyWithSchema } from '@/server/lib/admin-schema-utils';
5+
import { adminActorSnapshotCreateSchema } from '@/server/lib/admin-schemas';
6+
import { err, ok } from '@/server/lib/api-utils';
67
import { prisma } from '@/server/lib/db';
78
import { upsertActorDocument } from '@/server/lib/rag/indexer';
89

9-
import { ActivityLevel, Stance } from '@/generated/prisma/client';
10-
11-
const ACTIVITY_LEVELS = Object.values(ActivityLevel);
12-
const STANCES = Object.values(Stance);
13-
1410
export async function POST(
1511
req: NextRequest,
1612
{ params }: { params: Promise<{ conflictId: string; actorId: string }> },
@@ -19,30 +15,14 @@ export async function POST(
1915
if (denied) return denied;
2016

2117
const { conflictId, actorId } = await params;
22-
const body = await safeJson(req);
18+
const body = await parseBodyWithSchema(req, adminActorSnapshotCreateSchema);
2319
if (body instanceof NextResponse) return body;
2420

2521
const actor = await prisma.actor.findFirst({ where: { id: actorId, conflictId } });
2622
if (!actor) return err('NOT_FOUND', `Actor ${actorId} not found`, 404);
2723

28-
const missing = assertRequired(body, [
29-
'day', 'activityLevel', 'activityScore', 'stance', 'saying', 'doing', 'assessment',
30-
]);
31-
if (missing) return err('VALIDATION', missing);
32-
33-
const alErr = assertEnum(body.activityLevel, ACTIVITY_LEVELS, 'activityLevel');
34-
if (alErr) return err('VALIDATION', alErr);
35-
36-
const stErr = assertEnum(body.stance, STANCES, 'stance');
37-
if (stErr) return err('VALIDATION', stErr);
38-
39-
const scoreErr = assertIntRange(body.activityScore, 0, 100, 'activityScore');
40-
if (scoreErr) return err('VALIDATION', scoreErr);
41-
4224
const day = new Date(body.day + 'T00:00:00Z');
43-
if (isNaN(day.getTime())) return err('VALIDATION', 'Invalid day format');
4425

45-
// Check for duplicate
4626
const existing = await prisma.actorDaySnapshot.findUnique({
4727
where: { actorId_day: { actorId, day } },
4828
});

0 commit comments

Comments
 (0)