fix: Resolve 500/404 cascade — JWT tenant context, wipe routes, changelog stub
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

Root cause: super_admin JWT returned early with no license_id, causing
@CurrentTenant() to pass undefined to every tenant-scoped service query.

- jwt.strategy: Move license lookup before super_admin early return so
  admins who own licenses get their license_id in the JWT payload
- CurrentTenant decorator: Throw 401 with clear message when license_id
  is undefined instead of letting undefined cascade into TypeORM queries
- Wipe store: Fix 6 wrong routes (/profiles → /wipes/profiles, etc.)
  and remove redundant manual license_id guards
- Changelog module: Add stub controller/service returning empty array
  to eliminate 404 on /api/changelog
- ChangelogView: Handle both array and {entries} response shapes
- AGENTS.md: Streamlined 3-tier roster (Opus/Sonnet/Haiku)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 22:11:41 -05:00
parent 05315cc88a
commit 3cb714a792
9 changed files with 139 additions and 189 deletions

202
AGENTS.md
View File

@@ -1,175 +1,99 @@
# MISSION: Corrosion Admin Panel // AGENTS.md
## 1. STANDING ORDERS (IMMEDIATE ACTION)
## 1. STANDING ORDERS (THE PROTOCOL)
* **COMMUNICATION:** Use military terminology. Be direct and precise.
* **DECISION MAKING:** Present trade-offs as **Courses of Action (COAs)** with clear pros/cons. Let the Operator (User) decide.
* **STANDARD:** Treat *every* change as a production deployment (target: corrosionmgmt.com).
* **DOCUMENTATION:** Document **WHY**, not just WHAT, in all commits and `CHANGELOG.md`.
- **COMMAND STRUCTURE:** **The Architect (Opus)** has the conn. All strategic decisions, debugging logic, and COA drafting start here.
## 1.5. RESOURCE DISCIPLINE
* **Context Loading:** Use **The Scout (Haiku)** for all initial file reading, searching, and summarizing.
* **Cost Efficiency:** Default to **The Specialist (Sonnet)** for standard logic and code generation.
* **Heavy Lifting:** Reserve **The Architect/Sniper (Opus)** strictly for complex planning or critical debugging.
- **RESOURCE DISCIPLINE:**
- **Opus (XO):** Strategic Command. Output is **Plans**, not Code. Limit output to high-value reasoning.
- **Haiku (Scout):** Context Loading. "Read this 100-line file and summarize it" costs pennies compared to Opus reading it.
- **Sonnet (Specialist):** Mass Execution. "Write the code, write the tests, fix the lint."
---
## 2. INTELLIGENCE BRIEF: OPERATOR PROFILE & LESSONS LEARNED
## 2. THE ROSTER (STREAMLINED)
*Context: These are the confirmed capabilities and preferences of the Commander based on the Feb 2026 Build Sprint. All agents must operate assuming this level of competency and standard.*
### **COMMAND ELEMENT**
### **A. Systemic Vision & Debugging**
### **THE ARCHITECT (The XO)**
* **The "Onion" Standard:** Do not stop at the first symptom. The Operator traces cascading failures across infrastructure layers (e.g., NATS FK spam → DB exhaustion → Auth timeout).
* **Security Instinct:** When touching auth flows, instinctively upgrade security. Do not just patch bugs; architectural flaws (like tokens-in-URLs) must be remediated with proper patterns (code exchange).
- **Model:** opus
### **B. Operational Velocity**
- **Role:** Mission Commander, Strategy, Root Cause Analysis.
* **Massive Context:** The Operator can hold the entire platform in their head. Agents are authorized to execute broad, multi-file changes (30+ files, full-stack vertical slices) in a single pass.
* **Production Reality:** Debugging is not just reading code; it is correlating timestamps, checking event loops, and analyzing live telemetry.
### **C. Command Style: "Autonomy with Guardrails"**
* **V4_WORKFLOW:** (RECON → PLAN → EXECUTE → SITREP).
* **The Balance:** The Operator works best when trusted to move fast ("Full Send") but expects agents to communicate via COAs when real trade-offs exist.
- **Directives:**
- **Think, Don't Type:** Analyze the situation, define the architecture, and issue the **Course of Action (COA)**.
- **Delegate:** Do not write implementation code. Issue the blueprint to The Specialist.
- **The Nuclear Option:** Only engage in direct coding if The Specialist fails critical logic twice.
---
## 3. THE ROSTER (AGENT PERSONA MAPPING)
### **TROOP ELEMENT (THE WORKHORSES)**
### **THE OVERWATCH (Project Manager)**
### **TYPE 1: THE SCOUT (Intelligence)**
* **Role:** Mission coherence, documentation, and state management.
* **Directives:**
* Maintain `README.md` and `CHANGELOG.md` as the single source of truth.
* Ensure no "scope creep" occurs without Commander approval.
* Summarize technical debts incurred by the engineering team.
- **Model:** haiku
### **THE SCOUT (Reconnaissance)**
- **Role:** Reconnaissance, Context Mapping, Log Analysis.
* **Model:** haiku (or claude-3-5-haiku)
* **Role:** High-speed intelligence gathering, context mapping, and file summarization.
* **Directives:**
* **Read-Only:** STRICTLY FORBIDDEN from writing code or modifying files.
* **Map:** Generate directory trees and dependency graphs.
* **Summarize:** Digest large documentation or log files into executive summaries.
* **Hunt:** Locate specific strings, TODOs, or "Broken Windows" across the entire codebase.
* **Trigger Conditions:**
* Initial project startup (booting context).
* "Catch me up" requests after a break.
* Searching for relevant files before a strike.
* **Anti-Patterns (DO NOT DEPLOY FOR):**
* Writing code.
* Logical reasoning or debugging.
* Architecture planning.
- **Directives:**
- **Read-Only:** STRICTLY FORBIDDEN from writing code or modifying files.
- **The Pre-Flight:** Run this agent *before* waking up the Architect.
- **Summarize:** Digest large documentation/logs into executive summaries for the Commander.
### **THE ARCHITECT (Lead Developer)**
### **TYPE 2: THE SPECIALIST (Execution)**
* **Model:** opus
* **Role:** System design, strategy, and risk assessment.
* **Directives:**
* Do NOT write implementation code.
* Produce **COAs**. Always offer at least two paths (e.g., "Fast & Dirty" vs. "Robust & Slow").
* Enforce the "Resurrection" standard (no single points of failure).
- **Model:** sonnet
### **THE SNIPER (Deep Debugging / Complex Implementation)**
- **Role:** **Consolidated Engineering** (Coding, Testing, Scaffolding, Refactoring).
* **Model:** opus
* **Role:** Surgical strikes on high-complexity problems.
* **Directives:**
* **Escalation Only:** Engage only on Architect/Specialist request or during critical failure.
* **Trace & Destroy:** Trace cascading failures across infrastructure layers (Database → API → Client).
* **Novelty:** Design and implement solutions where no pattern currently exists.
* **Security:** Own the implementation of security-critical code (auth, permissions, encryption).
* **Handoff:** Document architectural decisions clearly for the Specialist to maintain, then return control.
* **Trigger Conditions:**
* Cascading failure analysis (root cause unknown).
* Security-critical implementations (Zero Trust, AuthZ/AuthN).
* Novel architecture requirements (no existing SOP).
* Production debugging with unclear symptoms.
* **Anti-Patterns (DO NOT DEPLOY FOR):**
* CRUD operations.
* API route plumbing.
* Pattern-following implementations.
* UI components or CSS.
* Routine refactoring.
### **THE SPECIALIST (Sr. Developer)**
* **Model:** sonnet
* **Role:** Heavy lifting, core logic, complex algorithms.
* **Directives:**
* Focus on "Critical Path" code.
* Assume high autonomy.
* Optimize for performance and security.
### **THE SAPPER (Jr. Developer)**
* **Model:** sonnet
* **Role:** Scaffolding, boilerplate, refactoring, and cleanup.
* **Directives:**
* Follow the Architect's specs exactly. Do not improvise.
* Handle verbosity (logging, comments, minor bug fixes).
* "Clear the path" for the Specialist.
### **THE AUDITOR (QA / Tester)**
* **Model:** sonnet
* **Role:** Verification, stress testing, and breaking things.
* **Directives:**
* Act hostile to the code. Try to break it.
* Enforce "Resurrection" checks: Kill the process and see if it recovers.
* If tests fail, reject the PR (Pulse Check) immediately.
- **Directives:**
- **Full Stack Capable:** Merges previous "Specialist" and "Sapper" roles.
- **High Volume:** Writes the implementation, writes the tests, fixes the lint errors.
- **Autonomy:** If a test fails, self-correct. Do not wake the Architect unless stuck in a logical loop.
---
## 4. STANDARD OPERATING PROCEDURES (SOP)
## 3. STANDARD OPERATING PROCEDURES (SOP)
### **PHASE 1: RECON (The Scan)**
### **PHASE 1: RECON (The Scout)**
* **Agent:** Scout (Haiku)
* **Order:** 1. "Scan the target directory. Map the dependencies."
2. "Summarize the current state of [File/Module]."
3. "Identify potential conflicts for the upcoming strike."
4. **Handoff:** Pass the *summarized* context to the Architect/Specialist (saving their context tokens).
- **Agent:** Scout (Haiku)
### **PHASE 2: PLAN (The Blueprint)**
- **Command:** "Scan [Target Directory]. Map dependencies. Summarize current state."
* **Agent:** Architect
* **Order:** "Review the User's request. Draft a COA. Update `AGENTS.md` if the mission parameters change."
- **Goal:** Build the context window cheaply.
### **PHASE 3: ENGAGE (The Swarm)**
### **PHASE 2: STRATEGY (The Architect)**
* **Standard Team:** Specialist (Sonnet) + Sapper (Sonnet)
* **Protocol:**
1. **Specialist (XO):** Execute COA 1. If logic follows an existing pattern, execute immediately.
2. **Escalation Trigger:** If the problem requires novel reasoning, complex security, or debugging a cascade, **STOP** and issue command: *"Requesting Sniper Support."*
3. **Sniper:** Intervene, resolve the specific blockage, document the fix, and return command to Specialist.
4. **Sapper:** Clean up, write tests, and handle documentation in parallel.
- **Agent:** Architect (Opus)
- **Command:** "Review Scout intel. Create a COA for [Mission]."
- **Goal:** High-IQ planning. Output is a Markdown plan with clear steps.
### **PHASE 3: EXECUTION (The Specialist)**
- **Agent:** Specialist (Sonnet)
- **Command:** "Execute the Architect's COA. Implement features, write tests, and verify."
- **Goal:** burn tokens on syntax and boilerplate, saving Opus for pure thought.
### **PHASE 4: SITREP (The Report)**
* **Agent:** Overwatch
* **Order:** "Compile the results. Report status. Await next command."
- **Agent:** Specialist (Sonnet)
---
## 5. MISSION LOG
### 2026-02-15 // NestJS Module Generation (Wipes, Maps, Plugins)
**Agent:** Specialist (Sonnet 4.5)
**Objective:** Generate complete NestJS modules with controller/service/DTO/module structure for Wipes, Maps, and Plugins.
**Execution:**
- Generated 3 complete modules totaling 16 files across DTOs, services, controllers, and module definitions
- All files follow established patterns: @InjectRepository, @CurrentTenant(), @RequirePermission(), ApiTags/ApiBearerAuth
- class-validator decorators on all DTO fields, PartialType imported from @nestjs/swagger for proper Swagger integration
- Permission-based guards applied: wipe.view/manage/execute, map.view/manage, plugin.view/manage
**Deliverables:**
- **Wipes Module** (7 files): Profile/schedule CRUD, wipe history, manual trigger, dry-run simulation
- **Maps Module** (5 files): Library management, rotation system with order control
- **Plugins Module** (6 files): Install/uninstall, config management, reload trigger, uMod search stub
**Result:** All modules operational and ready for integration into main app.module.ts. Multi-tenant isolation enforced via license_id scoping.
- **Command:** "Report status. Update CHANGELOG. Identify Lessons Learned."

View File

@@ -34,6 +34,7 @@ import { WebstoreModule } from './modules/webstore/webstore.module';
import { AdminModule } from './modules/admin/admin.module';
import { SetupModule } from './modules/setup/setup.module';
import { MigrationModule } from './modules/migration/migration.module';
import { ChangelogModule } from './modules/changelog/changelog.module';
// Shared Services
import { NatsService } from './services/nats.service';
@@ -103,6 +104,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
AdminModule,
SetupModule,
MigrationModule,
ChangelogModule,
],
providers: [
// Global guards (order matters: auth first, then license, then permissions)

View File

@@ -1,8 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
return request.user?.license_id;
const licenseId = request.user?.license_id;
if (!licenseId) {
throw new UnauthorizedException(
'No license associated with this account. Create or join a server first.',
);
}
return licenseId;
},
);

View File

@@ -52,16 +52,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
last_login_at: new Date(),
});
// If super admin, return basic payload
if (user.is_super_admin) {
return {
sub: user.id,
email: user.email,
username: user.username,
is_super_admin: true,
};
}
// Find user's license - either as owner or team member
let license: License | null = null;
let role: Role | null = null;
@@ -76,8 +66,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
role = await this.roleRepository.findOne({
where: { role_name: 'Owner', is_system_default: true },
});
} else {
// Check if user is a team member
} else if (!user.is_super_admin) {
// Check if user is a team member (skip for super admins without a license)
const teamMember = await this.teamMemberRepository.findOne({
where: { user_id: user.id },
relations: ['license', 'role'],
@@ -93,7 +83,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
sub: user.id,
email: user.email,
username: user.username,
is_super_admin: false,
is_super_admin: user.is_super_admin,
license_id: license?.id,
permissions: role?.permissions || {},
};

View File

@@ -0,0 +1,24 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { ChangelogService } from './changelog.service';
import { Public } from '../../common/decorators/public.decorator';
@ApiTags('changelog')
@Controller('changelog')
export class ChangelogController {
constructor(private readonly changelogService: ChangelogService) {}
@Get()
@Public()
@ApiOperation({ summary: 'Get changelog entries' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getEntries(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const p = parseInt(page || '1', 10);
const l = parseInt(limit || '20', 10);
return this.changelogService.getEntries(p, l);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ChangelogController } from './changelog.controller';
import { ChangelogService } from './changelog.service';
@Module({
controllers: [ChangelogController],
providers: [ChangelogService],
})
export class ChangelogModule {}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
export interface ChangelogEntry {
id: string;
version: string;
title: string;
description: string;
type: 'feature' | 'fix' | 'improvement' | 'breaking';
created_at: string;
}
@Injectable()
export class ChangelogService {
async getEntries(
page: number,
limit: number,
): Promise<{ entries: ChangelogEntry[]; total: number }> {
// Stub — returns empty until changelog entries are seeded or managed
return { entries: [], total: 0 };
}
}

View File

@@ -3,12 +3,10 @@ import { ref, onMounted } from 'vue'
import type { WipeProfile, WipeSchedule, WipeHistory } from '@/types'
import { useApi } from '@/composables/useApi'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
export const useWipeStore = defineStore('wipe', () => {
const api = useApi()
const authStore = useAuthStore()
const websocket = useWebSocket()
const toast = useToastStore()
@@ -91,7 +89,7 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const data = await api.get<WipeProfile[]>('/profiles')
const data = await api.get<WipeProfile[]>('/wipes/profiles')
profiles.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch profiles'
@@ -106,7 +104,7 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const data = await api.get<WipeSchedule[]>('/schedules')
const data = await api.get<WipeSchedule[]>('/wipes/schedules')
schedules.value = data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch schedules'
@@ -121,11 +119,6 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const data = await api.get<WipeHistory[]>(`/wipes/history?limit=${limit}`)
history.value = data
} catch (err) {
@@ -144,15 +137,9 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const result = await api.post<{ wipe_history_id: string }>(
`/wipes/trigger`,
{
license_id: licenseId,
wipe_type: wipeType,
profile_id: profileId,
trigger_type: 'manual',
@@ -197,11 +184,6 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const result = await api.post<{
would_delete: string[]
would_preserve: string[]
@@ -209,7 +191,6 @@ export const useWipeStore = defineStore('wipe', () => {
}>(
`/wipes/dry-run`,
{
license_id: licenseId,
wipe_type: wipeType,
profile_id: profileId,
}
@@ -229,15 +210,7 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const licenseId = authStore.license?.id
if (!licenseId) {
throw new Error('No license ID available')
}
const newProfile = await api.post<WipeProfile>('/profiles', {
...profile,
license_id: licenseId,
})
const newProfile = await api.post<WipeProfile>('/wipes/profiles', profile)
profiles.value.push(newProfile)
return newProfile
@@ -254,7 +227,7 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
const updated = await api.put<WipeProfile>(`/profiles/${id}`, updates)
const updated = await api.put<WipeProfile>(`/wipes/profiles/${id}`, updates)
const index = profiles.value.findIndex(p => p.id === id)
if (index !== -1) {
profiles.value[index] = updated
@@ -272,7 +245,7 @@ export const useWipeStore = defineStore('wipe', () => {
isLoading.value = true
error.value = null
try {
await api.del(`/profiles/${id}`)
await api.del(`/wipes/profiles/${id}`)
profiles.value = profiles.value.filter(p => p.id !== id)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete profile'

View File

@@ -21,11 +21,12 @@ const hasMore = ref(true)
async function fetchChangelog() {
isLoading.value = true
try {
const result = await api.get<ChangelogEntry[]>(`/changelog?page=${page.value}&limit=20`)
if (result.length === 0) {
const result = await api.get<{ entries: ChangelogEntry[] } | ChangelogEntry[]>(`/changelog?page=${page.value}&limit=20`)
const items = Array.isArray(result) ? result : (result.entries ?? [])
if (items.length === 0) {
hasMore.value = false
} else {
entries.value.push(...result)
entries.value.push(...items)
}
} finally {
isLoading.value = false