From fc6ab10e1bd36e46d28302a12d6fe838eeba21f0 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:39:38 +0200 Subject: [PATCH 1/8] chore: add AGENTS.md files --- AGENTS.md | 101 +++++++++++++++++++++++++++++++++++++++ src/ai/AGENTS.md | 30 ++++++++++++ src/api/AGENTS.md | 47 ++++++++++++++++++ src/middleware/AGENTS.md | 29 +++++++++++ src/openapi/AGENTS.md | 43 +++++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 AGENTS.md create mode 100644 src/ai/AGENTS.md create mode 100644 src/api/AGENTS.md create mode 100644 src/middleware/AGENTS.md create mode 100644 src/openapi/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..49d4be114 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-04-24 + +## OVERVIEW + +Akamai App Platform API — Express/TypeScript REST API managing Kubernetes teams, workloads, and services. Uses **Git as database** (YAML files in a values repo). OpenAPI-first: specs define endpoints, authorization, and generate types. + +## STRUCTURE + +``` +apl-api/ +├── src/ +│ ├── app.ts # Express server setup, middleware chain, OpenAPI validator +│ ├── otomi-stack.ts # Core business logic engine (2600+ lines) — ALL CRUD goes through here +│ ├── otomi-models.ts # Domain types: AplObject, AplTeamObject, AplRequestObject +│ ├── authz.ts # CASL-based RBAC (platformAdmin, teamAdmin, teamMember) +│ ├── generated-schema.ts # AUTO-GENERATED from OpenAPI — DO NOT EDIT (24k lines) +│ ├── git.ts # Git operations for values repo persistence +│ ├── error.ts # Custom error classes (HttpError, ValidationError) +│ ├── validators.ts # Environment variable validation (envalid) +│ ├── constants.ts # Shared constants +│ ├── api/ # Route handlers (v1/, v2/, alpha/) — see AGENTS.md +│ ├── openapi/ # OpenAPI YAML specs — see AGENTS.md +│ ├── middleware/ # JWT, authz, session, rate-limit — see AGENTS.md +│ ├── ai/ # AI model/agent/knowledgebase CRD handlers — see AGENTS.md +│ ├── utils/ # Domain utilities (workload, codeRepo, user, YAML, sealed secrets) +│ ├── fileStore/ # In-memory cache over Git storage (see ARCHITECTURE.md) +│ ├── ttyManifests/ # CloudTTY manifest templates +│ ├── gitea/ # Gitea integration for commit monitoring +│ └── fixtures/ # Test fixtures +├── test/ # Helm chart for deployment testing (NOT unit tests) +├── bin/ # Shell scripts (client gen, releases) +├── docs/ # Additional documentation +└── ARCHITECTURE.md # FileStore architecture diagrams (Mermaid) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add new endpoint | `src/openapi/*.yaml` → `src/api/{version}/` | Define spec FIRST, then handler | +| Add authorization | OpenAPI spec `x-acl` + `x-aclSchema` | ACLs live in YAML, not code | +| Understand CRUD flow | `src/otomi-stack.ts` | All resource operations route here | +| Add middleware | `src/middleware/` → register in `src/app.ts` | Export from `middleware/index.ts` | +| Modify data models | `src/openapi/*.yaml` → `npm run build:models` | Generates `generated-schema.ts` | +| Secret handling | `src/fileStore/` + `ARCHITECTURE.md` | Secrets split on disk, merged in memory | +| K8s operations | `src/k8s_operations.ts` | Pod status, logs, builds | +| Auth flow | `src/middleware/jwt.ts` → `src/middleware/authz.ts` | JWT → group extraction → CASL check | +| AI features | `src/ai/` | Kubernetes CRD CRUD for AI resources | +| Environment config | `src/validators.ts` + `.env.sample` | All env vars validated via envalid | + +## CONVENTIONS + +- **No semicolons**, single quotes, trailing commas, 120 char width (Prettier) +- **OpenAPI-first**: Never add routes manually. Define in YAML spec, implement handler matching `operationId` +- **Handler signature**: `export async function operationId(req: OpenApiRequestExt): Promise` +- **Path params use curly braces in filesystem**: `src/api/v1/teams/{teamId}/services.ts` — Express resolves `:teamId` +- **Imports use `src/` prefix**: `import { ... } from 'src/middleware'` (tsconfig paths) +- **Debug logging**: `const debug = Debug('otomi:')` — namespaced debug +- **Conventional commits** enforced via commitlint + Husky +- **YAML tab indentation** in test files (width 4) + +## ANTI-PATTERNS (THIS PROJECT) + +- **DO NOT** edit `src/generated-schema.ts` — auto-generated from `npm run build:models` +- **DO NOT** add routes without OpenAPI spec — express-openapi-validator rejects unspecified routes +- **DO NOT** bypass `OtomiStack` for data operations — it manages FileStore + Git + deployment sync +- **DO NOT** store secrets in main YAML files — use `secrets.*` file pattern (see ARCHITECTURE.md) + +## KEY PATTERNS + +- **Git-as-Database**: CRUD → OtomiStack → FileStore (memory) + Git (disk) → commit → deploy +- **Secret splitting**: Main spec on disk without secrets; `secrets.*.yaml` holds sensitive fields; merged in memory +- **Multi-tenant isolation**: Team resources scoped by `teamId` in paths and CASL abilities +- **WebSocket updates**: Socket.io for real-time status (builds, workloads, services, sealed secrets) +- **OpenAPI validation**: express-openapi-validator validates all requests/responses against specs + +## COMMANDS + +```bash +npm run dev # Dev server with hot reload (tsx watch) +npm run build # Compile TypeScript to dist/ +npm run build:models # Generate TS types from OpenAPI specs +npm run build:spec # Build combined OpenAPI spec +npm test # Jest tests +npm run test:pattern -- X # Run specific test +npm run lint # ESLint + type check +npm run lint:fix # Auto-fix +npm run types # Type check only +``` + +## NOTES + +- `otomi-stack.ts` is 2600+ lines — the monolith. All resource CRUD funnels through it. +- `generated-schema.ts` is 24k+ lines — expect slow IDE. Never read fully, grep for specific types. +- Node 24+ required (see `.nvmrc` and `package.json` engines). +- Test directory contains Helm charts, not typical unit tests. Unit tests are colocated as `*.test.ts` in `src/`. +- `.history/` directory exists (VSCode local history) — ignore it. +- Mock auth available at `GET /api/mock/{idx}` for development. +- Every API mutation commits to the values Git repo with the author's email. diff --git a/src/ai/AGENTS.md b/src/ai/AGENTS.md new file mode 100644 index 000000000..3e98c0a91 --- /dev/null +++ b/src/ai/AGENTS.md @@ -0,0 +1,30 @@ +# AI Module + +## OVERVIEW + +Kubernetes CRD CRUD handlers for AI resources (models, agents, knowledge bases, databases). Bypasses OtomiStack — talks directly to K8s API. + +## STRUCTURE + +``` +ai/ +├── k8s.ts # Shared K8s client (CustomObjectsApi, AppsV1Api) +├── aiModelHandler.ts # AI model CRD operations +├── AkamaiAgentCR.ts # Agent custom resource CRUD +├── AkamaiKnowledgeBaseCR.ts # Knowledge base custom resource CRUD +├── DatabaseCR.ts # Database custom resource CRUD +└── *.test.ts # Colocated tests for each handler +``` + +## CONVENTIONS + +- **Direct K8s API**: Uses `@kubernetes/client-node` CustomObjectsApi — does NOT go through OtomiStack/FileStore +- **CRD pattern**: Each `*CR.ts` file exports create/get/list/update/delete for one custom resource +- **Shared client**: `k8s.ts` provides lazy-initialized, resettable API clients +- **Debug namespace**: `otomi:ai:*` +- **API version**: Alpha (`src/api/alpha/ai/`) + +## ANTI-PATTERNS + +- **DO NOT** use OtomiStack for AI resources — they live as K8s CRDs, not Git YAML +- **DO NOT** forget `resetApiClients()` in tests — clients are module-level singletons diff --git a/src/api/AGENTS.md b/src/api/AGENTS.md new file mode 100644 index 000000000..ddc077e83 --- /dev/null +++ b/src/api/AGENTS.md @@ -0,0 +1,47 @@ +# API Route Handlers + +## OVERVIEW + +Versioned REST endpoint handlers. Each file exports functions matching OpenAPI `operationId`s. + +## STRUCTURE + +``` +api/ +├── v1/ # Legacy handlers — (req, res) signature, call req.otomi.* +│ ├── teams/ # Team-scoped resources +│ │ └── {teamId}/ # Path param dirs with curly braces +│ ├── apps/ # Platform app configs +│ ├── settings/ # Cluster settings +│ └── *.ts # Top-level resource handlers +├── v2/ # Current handlers — return AplResponseObject, call req.otomi.*Apl* +│ └── teams/{teamId}/ # Team-scoped with sub-resource dirs +├── alpha/ # Experimental (AI features, team extensions) +│ ├── ai/ # AI model/agent/knowledgebase endpoints +│ └── teams/ # Alpha team features +└── apiDocs.ts # Swagger UI endpoint +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add v1 endpoint | `v1/` + matching OpenAPI spec | Legacy: `(req, res) => void` | +| Add v2 endpoint | `v2/` + matching OpenAPI spec | Current: `(req) => Promise` | +| Add AI endpoint | `alpha/ai/` | Uses `src/ai/` handlers, not OtomiStack | +| Team-scoped resource | `{version}/teams/{teamId}/` | Dir name literally `{teamId}` | + +## CONVENTIONS + +- **v1 handlers**: `export const opId = (req: OpenApiRequestExt, res: Response): void` — send response via `res.json()` +- **v2 handlers**: `export async function opId(req: OpenApiRequestExt): Promise` — return value +- **File naming**: Matches resource name (e.g., `services.ts`, `coderepos.ts`) +- **Sub-resource pattern**: Directory for collection, file for item (e.g., `services.ts` = list, `services/{name}.ts` = single) +- **All business logic** lives in `OtomiStack` via `req.otomi.*` — handlers are thin wrappers +- **Debug namespace**: `otomi:api:{version}:{resource}` + +## ANTI-PATTERNS + +- **DO NOT** put business logic in handlers — delegate to `req.otomi` (OtomiStack) +- **DO NOT** create handler files without corresponding OpenAPI spec entry +- **DO NOT** mix v1/v2 patterns — v1 uses `res.json()`, v2 returns objects diff --git a/src/middleware/AGENTS.md b/src/middleware/AGENTS.md new file mode 100644 index 000000000..cd12223c5 --- /dev/null +++ b/src/middleware/AGENTS.md @@ -0,0 +1,29 @@ +# Middleware + +## OVERVIEW + +Express middleware chain: JWT verification → group extraction → CASL authorization → session/stack injection → error handling. + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Auth flow | `jwt.ts` → `security-handlers.ts` → `authz.ts` | Sequential pipeline | +| Session/stack | `session.ts` | Attaches `OtomiStack` to `req.otomi` | +| Error handling | `error.ts` | Express error middleware, formats HttpError responses | +| Rate limiting | `rate-limit.ts` | Separate limiters for API and auth routes | +| Add middleware | New file → export from `index.ts` → register in `src/app.ts` | + +## KEY FILES + +- **`jwt.ts`**: Validates JWT tokens, extracts user identity. Uses JWKS for key rotation. +- **`security-handlers.ts`**: `groupAuthzSecurityHandler` — extracts groups from `Auth-Group` header, resolves role (platformAdmin/teamAdmin/teamMember). +- **`authz.ts`**: CASL ability check against `x-acl` from OpenAPI spec. Runs per-request. +- **`session.ts`**: Creates/retrieves `OtomiStack` instance, attaches to request as `req.otomi`. +- **`error.ts`**: Catches errors, maps to HTTP status codes, formats JSON response. +- **`rate-limit.ts`**: `apiRateLimiter` and `authRateLimiter` — separate limits, not exported via `index.ts`. + +## ANTI-PATTERNS + +- **DO NOT** import `rate-limit.ts` from `index.ts` — it's imported directly in `app.ts` +- **DO NOT** bypass the middleware chain — auth headers (`Authorization`, `Auth-Group`) are required diff --git a/src/openapi/AGENTS.md b/src/openapi/AGENTS.md new file mode 100644 index 000000000..f4a9d1580 --- /dev/null +++ b/src/openapi/AGENTS.md @@ -0,0 +1,43 @@ +# OpenAPI Specifications + +## OVERVIEW + +YAML specs defining all API endpoints, schemas, ACLs, and documentation links. Single source of truth for the entire API surface. + +## STRUCTURE + +``` +openapi/ +├── api.yaml # Main spec: ALL path definitions + component refs (3k lines) +├── definitions.yaml # Shared schema fragments (idName, etc.) +├── error.yaml # Error response schemas +├── otomi/ # Otomi-specific sub-specs +└── *.yaml # One file per resource schema (service, team, workload, etc.) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add endpoint path | `api.yaml` paths section | Must include `operationId` + `x-eov-operation-handler` | +| Define resource schema | New `{resource}.yaml` + ref from `api.yaml` | One schema file per resource | +| Set authorization | Schema file `x-acl` block | Per-role CRUD permissions | +| Field-level ACL | Schema property `x-acl` | Restricts field visibility by role | +| Shared types | `definitions.yaml` | Reusable schema fragments | + +## CONVENTIONS + +- **`operationId`**: Must match exported function name in handler file +- **`x-eov-operation-handler`**: Path to handler file relative to `src/api/` (e.g., `v1/teams`) +- **`x-aclSchema`**: References schema name for CASL authorization +- **`x-acl`**: Maps roles to CRUD abilities (`create-any`, `read`, `update`, `delete-any`) +- **`x-formtype`**: UI hint for console form generation (`SelectWidget`, etc.) +- **`x-externalDocsPath`**: Appended to base docs URL for per-resource documentation +- **Schema files** define the resource type at top level (e.g., `Service:` in `service.yaml`) + +## ANTI-PATTERNS + +- **DO NOT** add paths without `operationId` and `x-eov-operation-handler` +- **DO NOT** define schemas inline in `api.yaml` — create separate `{resource}.yaml` +- **DO NOT** forget `x-aclSchema` — endpoints without it bypass authorization +- After changes: run `npm run build:models` to regenerate `generated-schema.ts` From 3d26f9eaf963080980323ff44dff4c809b7af22c Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:42:00 +0200 Subject: [PATCH 2/8] chore: add AGENTS.md files --- src/.ignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/.ignore diff --git a/src/.ignore b/src/.ignore new file mode 100644 index 000000000..d74488f80 --- /dev/null +++ b/src/.ignore @@ -0,0 +1,21 @@ +# Used by AI AGENTS to skip analyzing these files +.history +.tmp +_.bak +node_modules/ +/coverage/ +/dist/ +/env/ +_.DS*Store +.vscode/values-schema.yaml +*.env +/.secrets +chart/apl/values.schema.json +chart/apl/README.md +workflow/ +\_.new +.envrc +otomi.cpuprofile +/.idea/ +tmp +\*\*values-repo.yaml From 064d25ef805f8837b41d55c4977ad8e6295b0f8b Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Mon, 4 May 2026 16:23:20 +0200 Subject: [PATCH 3/8] feat: ensure sufficient permissions to push to repo --- src/api-v2.authz.test.ts | 13 ++++++++++ src/api/v2/git.ts | 56 +++++++++++++++++++++++++++++++++------- src/git.test.ts | 36 ++++++++++++++++++++++++++ src/git.ts | 19 ++++++++++++++ 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/api-v2.authz.test.ts b/src/api-v2.authz.test.ts index 88eb3bc4b..0737f2413 100644 --- a/src/api-v2.authz.test.ts +++ b/src/api-v2.authz.test.ts @@ -1129,6 +1129,19 @@ describe('API V2 authz tests', () => { test('platform admin can migrate git', async () => { await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200) }) + + test('returns 403 when push access probe fails with permission denied', async () => { + jest.spyOn(otomiStack.git, 'testRemoteConnection').mockResolvedValue(true) + jest.spyOn(otomiStack.git, 'probePushAccess').mockRejectedValue(new Error('permission denied')) + + await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(403) + }) + + test('returns 404 when remote connectivity check indicates repository not found', async () => { + jest.spyOn(otomiStack.git, 'testRemoteConnection').mockRejectedValue(new Error('repository not found')) + + await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(404) + }) }) describe('Team Admin', () => { diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index 593c7a13b..c586751fe 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -2,9 +2,36 @@ import Debug from 'debug' import { Response } from 'express' import { lockApi } from 'src/middleware' import { OpenApiRequestExt } from 'src/otomi-models' +import { getSanitizedErrorMessage } from 'src/utils' const debug = Debug('otomi:api:v2:git') +type ErrorResponse = { + code: number + message: string +} + +function classifyGitRemoteError(error: unknown, defaults: ErrorResponse): ErrorResponse { + const message = getSanitizedErrorMessage(error).toLowerCase() + + if (message.includes('not found') || message.includes('repository does not exist')) { + return { code: 404, message: defaults.message } + } + + if ( + message.includes('permission denied') || + message.includes('access denied') || + message.includes('authentication failed') || + message.includes('auth failed') || + message.includes('write access to repository not granted') || + message.includes('403') + ) { + return { code: 403, message: 'Insufficient permissions to push to new git remote' } + } + + return defaults +} + /** * PUT /v2/git * Migrate the values repository to a new git remote. @@ -28,16 +55,25 @@ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise let remoteHasContent: boolean try { remoteHasContent = await req.otomi.git.testRemoteConnection(repoUrl, password, branch, username) - } catch (e: any) { - if (e.message.includes('not found')) { - const error = { message: `Cannot connect to new git remote`, statusCode: 404 } - res.json(error) - return - } else { - const error = { message: `Error connecting to new git remote`, statusCode: 400 } - res.json(error) - return - } + } catch (e: unknown) { + const error = classifyGitRemoteError(e, { + code: 400, + message: 'Error connecting to new git remote', + }) + res.status(error.code).json({ error: error.message }) + return + } + + // Validate push permission by creating and deleting a temporary branch on the new remote + try { + await req.otomi.git.probePushAccess(repoUrl, password, username) + } catch (e: unknown) { + const error = classifyGitRemoteError(e, { + code: 400, + message: 'Error validating push access to new git remote', + }) + res.status(error.code).json({ error: error.message }) + return } // Write config + commit locally → push to new remote (if empty) → push to current remote diff --git a/src/git.test.ts b/src/git.test.ts index f114bbce2..0ca5554c6 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -120,3 +120,39 @@ describe('Git.pushToNewRemote', () => { expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote']) }) }) + +describe('Git.probePushAccess', () => { + beforeEach(() => jest.clearAllMocks()) + + it('creates and deletes temporary probe branch on migration-remote', async () => { + mockRemote.mockResolvedValue('') + mockPush.mockResolvedValue({}) + const repo = makeRepo() + + await repo.probePushAccess('https://example.com/repo.git', 'p', 'u') + + expect(mockRemote).toHaveBeenCalledWith( + expect.arrayContaining(['add', 'migration-remote', expect.stringContaining('u')]), + ) + expect(mockPush).toHaveBeenNthCalledWith( + 1, + 'migration-remote', + expect.stringMatching(/^HEAD:refs\/heads\/apl-migration-probe-/), + ) + expect(mockPush).toHaveBeenNthCalledWith( + 2, + 'migration-remote', + expect.stringMatching(/^:refs\/heads\/apl-migration-probe-/), + ) + expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote']) + }) + + it('removes migration-remote in finally even when probe push fails', async () => { + mockRemote.mockResolvedValue('') + mockPush.mockRejectedValue(new Error('permission denied')) + const repo = makeRepo() + + await expect(repo.probePushAccess('https://example.com/repo.git', 'p', 'u')).rejects.toThrow('permission denied') + expect(mockRemote).toHaveBeenCalledWith(['remove', 'migration-remote']) + }) +}) diff --git a/src/git.ts b/src/git.ts index d708bcbc8..f64d6f4bb 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,4 +1,5 @@ import axios, { AxiosResponse } from 'axios' +import { randomUUID } from 'crypto' import Debug from 'debug' import diff from 'deep-diff' import { rmSync } from 'fs' @@ -352,6 +353,24 @@ export class Git { } } + async probePushAccess(url: string, password: string, user?: string): Promise { + const authUrl = password ? getUrlAuth(url, user, password) : url + const probeBranch = `apl-migration-probe-${randomUUID()}` + const probeRef = `refs/heads/${probeBranch}` + + try { + await this.git.remote(['add', 'migration-remote', authUrl!]) + await this.git.push('migration-remote', `HEAD:${probeRef}`) + await this.git.push('migration-remote', `:${probeRef}`) + } finally { + try { + await this.git.remote(['remove', 'migration-remote']) + } catch (e) { + debug(`Could not remove migration-remote: ${getSanitizedErrorMessage(e)}`) + } + } + } + async createWorktree(worktreePath: string, branch: string = this.branch): Promise { debug(`Creating worktree at: ${worktreePath} from branch: ${branch}`) await ensureDir(dirname(worktreePath), { mode: 0o744 }) From c9ea40a7b3ac8813db36d07987376458fca36984 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 6 May 2026 11:06:18 +0200 Subject: [PATCH 4/8] chore: remove agents.md --- AGENTS.md | 101 --------------------------------------- src/ai/AGENTS.md | 30 ------------ src/api/AGENTS.md | 47 ------------------ src/middleware/AGENTS.md | 29 ----------- src/openapi/AGENTS.md | 43 ----------------- 5 files changed, 250 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 src/ai/AGENTS.md delete mode 100644 src/api/AGENTS.md delete mode 100644 src/middleware/AGENTS.md delete mode 100644 src/openapi/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 49d4be114..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ -# PROJECT KNOWLEDGE BASE - -**Generated:** 2026-04-24 - -## OVERVIEW - -Akamai App Platform API — Express/TypeScript REST API managing Kubernetes teams, workloads, and services. Uses **Git as database** (YAML files in a values repo). OpenAPI-first: specs define endpoints, authorization, and generate types. - -## STRUCTURE - -``` -apl-api/ -├── src/ -│ ├── app.ts # Express server setup, middleware chain, OpenAPI validator -│ ├── otomi-stack.ts # Core business logic engine (2600+ lines) — ALL CRUD goes through here -│ ├── otomi-models.ts # Domain types: AplObject, AplTeamObject, AplRequestObject -│ ├── authz.ts # CASL-based RBAC (platformAdmin, teamAdmin, teamMember) -│ ├── generated-schema.ts # AUTO-GENERATED from OpenAPI — DO NOT EDIT (24k lines) -│ ├── git.ts # Git operations for values repo persistence -│ ├── error.ts # Custom error classes (HttpError, ValidationError) -│ ├── validators.ts # Environment variable validation (envalid) -│ ├── constants.ts # Shared constants -│ ├── api/ # Route handlers (v1/, v2/, alpha/) — see AGENTS.md -│ ├── openapi/ # OpenAPI YAML specs — see AGENTS.md -│ ├── middleware/ # JWT, authz, session, rate-limit — see AGENTS.md -│ ├── ai/ # AI model/agent/knowledgebase CRD handlers — see AGENTS.md -│ ├── utils/ # Domain utilities (workload, codeRepo, user, YAML, sealed secrets) -│ ├── fileStore/ # In-memory cache over Git storage (see ARCHITECTURE.md) -│ ├── ttyManifests/ # CloudTTY manifest templates -│ ├── gitea/ # Gitea integration for commit monitoring -│ └── fixtures/ # Test fixtures -├── test/ # Helm chart for deployment testing (NOT unit tests) -├── bin/ # Shell scripts (client gen, releases) -├── docs/ # Additional documentation -└── ARCHITECTURE.md # FileStore architecture diagrams (Mermaid) -``` - -## WHERE TO LOOK - -| Task | Location | Notes | -|------|----------|-------| -| Add new endpoint | `src/openapi/*.yaml` → `src/api/{version}/` | Define spec FIRST, then handler | -| Add authorization | OpenAPI spec `x-acl` + `x-aclSchema` | ACLs live in YAML, not code | -| Understand CRUD flow | `src/otomi-stack.ts` | All resource operations route here | -| Add middleware | `src/middleware/` → register in `src/app.ts` | Export from `middleware/index.ts` | -| Modify data models | `src/openapi/*.yaml` → `npm run build:models` | Generates `generated-schema.ts` | -| Secret handling | `src/fileStore/` + `ARCHITECTURE.md` | Secrets split on disk, merged in memory | -| K8s operations | `src/k8s_operations.ts` | Pod status, logs, builds | -| Auth flow | `src/middleware/jwt.ts` → `src/middleware/authz.ts` | JWT → group extraction → CASL check | -| AI features | `src/ai/` | Kubernetes CRD CRUD for AI resources | -| Environment config | `src/validators.ts` + `.env.sample` | All env vars validated via envalid | - -## CONVENTIONS - -- **No semicolons**, single quotes, trailing commas, 120 char width (Prettier) -- **OpenAPI-first**: Never add routes manually. Define in YAML spec, implement handler matching `operationId` -- **Handler signature**: `export async function operationId(req: OpenApiRequestExt): Promise` -- **Path params use curly braces in filesystem**: `src/api/v1/teams/{teamId}/services.ts` — Express resolves `:teamId` -- **Imports use `src/` prefix**: `import { ... } from 'src/middleware'` (tsconfig paths) -- **Debug logging**: `const debug = Debug('otomi:')` — namespaced debug -- **Conventional commits** enforced via commitlint + Husky -- **YAML tab indentation** in test files (width 4) - -## ANTI-PATTERNS (THIS PROJECT) - -- **DO NOT** edit `src/generated-schema.ts` — auto-generated from `npm run build:models` -- **DO NOT** add routes without OpenAPI spec — express-openapi-validator rejects unspecified routes -- **DO NOT** bypass `OtomiStack` for data operations — it manages FileStore + Git + deployment sync -- **DO NOT** store secrets in main YAML files — use `secrets.*` file pattern (see ARCHITECTURE.md) - -## KEY PATTERNS - -- **Git-as-Database**: CRUD → OtomiStack → FileStore (memory) + Git (disk) → commit → deploy -- **Secret splitting**: Main spec on disk without secrets; `secrets.*.yaml` holds sensitive fields; merged in memory -- **Multi-tenant isolation**: Team resources scoped by `teamId` in paths and CASL abilities -- **WebSocket updates**: Socket.io for real-time status (builds, workloads, services, sealed secrets) -- **OpenAPI validation**: express-openapi-validator validates all requests/responses against specs - -## COMMANDS - -```bash -npm run dev # Dev server with hot reload (tsx watch) -npm run build # Compile TypeScript to dist/ -npm run build:models # Generate TS types from OpenAPI specs -npm run build:spec # Build combined OpenAPI spec -npm test # Jest tests -npm run test:pattern -- X # Run specific test -npm run lint # ESLint + type check -npm run lint:fix # Auto-fix -npm run types # Type check only -``` - -## NOTES - -- `otomi-stack.ts` is 2600+ lines — the monolith. All resource CRUD funnels through it. -- `generated-schema.ts` is 24k+ lines — expect slow IDE. Never read fully, grep for specific types. -- Node 24+ required (see `.nvmrc` and `package.json` engines). -- Test directory contains Helm charts, not typical unit tests. Unit tests are colocated as `*.test.ts` in `src/`. -- `.history/` directory exists (VSCode local history) — ignore it. -- Mock auth available at `GET /api/mock/{idx}` for development. -- Every API mutation commits to the values Git repo with the author's email. diff --git a/src/ai/AGENTS.md b/src/ai/AGENTS.md deleted file mode 100644 index 3e98c0a91..000000000 --- a/src/ai/AGENTS.md +++ /dev/null @@ -1,30 +0,0 @@ -# AI Module - -## OVERVIEW - -Kubernetes CRD CRUD handlers for AI resources (models, agents, knowledge bases, databases). Bypasses OtomiStack — talks directly to K8s API. - -## STRUCTURE - -``` -ai/ -├── k8s.ts # Shared K8s client (CustomObjectsApi, AppsV1Api) -├── aiModelHandler.ts # AI model CRD operations -├── AkamaiAgentCR.ts # Agent custom resource CRUD -├── AkamaiKnowledgeBaseCR.ts # Knowledge base custom resource CRUD -├── DatabaseCR.ts # Database custom resource CRUD -└── *.test.ts # Colocated tests for each handler -``` - -## CONVENTIONS - -- **Direct K8s API**: Uses `@kubernetes/client-node` CustomObjectsApi — does NOT go through OtomiStack/FileStore -- **CRD pattern**: Each `*CR.ts` file exports create/get/list/update/delete for one custom resource -- **Shared client**: `k8s.ts` provides lazy-initialized, resettable API clients -- **Debug namespace**: `otomi:ai:*` -- **API version**: Alpha (`src/api/alpha/ai/`) - -## ANTI-PATTERNS - -- **DO NOT** use OtomiStack for AI resources — they live as K8s CRDs, not Git YAML -- **DO NOT** forget `resetApiClients()` in tests — clients are module-level singletons diff --git a/src/api/AGENTS.md b/src/api/AGENTS.md deleted file mode 100644 index ddc077e83..000000000 --- a/src/api/AGENTS.md +++ /dev/null @@ -1,47 +0,0 @@ -# API Route Handlers - -## OVERVIEW - -Versioned REST endpoint handlers. Each file exports functions matching OpenAPI `operationId`s. - -## STRUCTURE - -``` -api/ -├── v1/ # Legacy handlers — (req, res) signature, call req.otomi.* -│ ├── teams/ # Team-scoped resources -│ │ └── {teamId}/ # Path param dirs with curly braces -│ ├── apps/ # Platform app configs -│ ├── settings/ # Cluster settings -│ └── *.ts # Top-level resource handlers -├── v2/ # Current handlers — return AplResponseObject, call req.otomi.*Apl* -│ └── teams/{teamId}/ # Team-scoped with sub-resource dirs -├── alpha/ # Experimental (AI features, team extensions) -│ ├── ai/ # AI model/agent/knowledgebase endpoints -│ └── teams/ # Alpha team features -└── apiDocs.ts # Swagger UI endpoint -``` - -## WHERE TO LOOK - -| Task | Location | Notes | -|------|----------|-------| -| Add v1 endpoint | `v1/` + matching OpenAPI spec | Legacy: `(req, res) => void` | -| Add v2 endpoint | `v2/` + matching OpenAPI spec | Current: `(req) => Promise` | -| Add AI endpoint | `alpha/ai/` | Uses `src/ai/` handlers, not OtomiStack | -| Team-scoped resource | `{version}/teams/{teamId}/` | Dir name literally `{teamId}` | - -## CONVENTIONS - -- **v1 handlers**: `export const opId = (req: OpenApiRequestExt, res: Response): void` — send response via `res.json()` -- **v2 handlers**: `export async function opId(req: OpenApiRequestExt): Promise` — return value -- **File naming**: Matches resource name (e.g., `services.ts`, `coderepos.ts`) -- **Sub-resource pattern**: Directory for collection, file for item (e.g., `services.ts` = list, `services/{name}.ts` = single) -- **All business logic** lives in `OtomiStack` via `req.otomi.*` — handlers are thin wrappers -- **Debug namespace**: `otomi:api:{version}:{resource}` - -## ANTI-PATTERNS - -- **DO NOT** put business logic in handlers — delegate to `req.otomi` (OtomiStack) -- **DO NOT** create handler files without corresponding OpenAPI spec entry -- **DO NOT** mix v1/v2 patterns — v1 uses `res.json()`, v2 returns objects diff --git a/src/middleware/AGENTS.md b/src/middleware/AGENTS.md deleted file mode 100644 index cd12223c5..000000000 --- a/src/middleware/AGENTS.md +++ /dev/null @@ -1,29 +0,0 @@ -# Middleware - -## OVERVIEW - -Express middleware chain: JWT verification → group extraction → CASL authorization → session/stack injection → error handling. - -## WHERE TO LOOK - -| Task | Location | Notes | -|------|----------|-------| -| Auth flow | `jwt.ts` → `security-handlers.ts` → `authz.ts` | Sequential pipeline | -| Session/stack | `session.ts` | Attaches `OtomiStack` to `req.otomi` | -| Error handling | `error.ts` | Express error middleware, formats HttpError responses | -| Rate limiting | `rate-limit.ts` | Separate limiters for API and auth routes | -| Add middleware | New file → export from `index.ts` → register in `src/app.ts` | - -## KEY FILES - -- **`jwt.ts`**: Validates JWT tokens, extracts user identity. Uses JWKS for key rotation. -- **`security-handlers.ts`**: `groupAuthzSecurityHandler` — extracts groups from `Auth-Group` header, resolves role (platformAdmin/teamAdmin/teamMember). -- **`authz.ts`**: CASL ability check against `x-acl` from OpenAPI spec. Runs per-request. -- **`session.ts`**: Creates/retrieves `OtomiStack` instance, attaches to request as `req.otomi`. -- **`error.ts`**: Catches errors, maps to HTTP status codes, formats JSON response. -- **`rate-limit.ts`**: `apiRateLimiter` and `authRateLimiter` — separate limits, not exported via `index.ts`. - -## ANTI-PATTERNS - -- **DO NOT** import `rate-limit.ts` from `index.ts` — it's imported directly in `app.ts` -- **DO NOT** bypass the middleware chain — auth headers (`Authorization`, `Auth-Group`) are required diff --git a/src/openapi/AGENTS.md b/src/openapi/AGENTS.md deleted file mode 100644 index f4a9d1580..000000000 --- a/src/openapi/AGENTS.md +++ /dev/null @@ -1,43 +0,0 @@ -# OpenAPI Specifications - -## OVERVIEW - -YAML specs defining all API endpoints, schemas, ACLs, and documentation links. Single source of truth for the entire API surface. - -## STRUCTURE - -``` -openapi/ -├── api.yaml # Main spec: ALL path definitions + component refs (3k lines) -├── definitions.yaml # Shared schema fragments (idName, etc.) -├── error.yaml # Error response schemas -├── otomi/ # Otomi-specific sub-specs -└── *.yaml # One file per resource schema (service, team, workload, etc.) -``` - -## WHERE TO LOOK - -| Task | Location | Notes | -|------|----------|-------| -| Add endpoint path | `api.yaml` paths section | Must include `operationId` + `x-eov-operation-handler` | -| Define resource schema | New `{resource}.yaml` + ref from `api.yaml` | One schema file per resource | -| Set authorization | Schema file `x-acl` block | Per-role CRUD permissions | -| Field-level ACL | Schema property `x-acl` | Restricts field visibility by role | -| Shared types | `definitions.yaml` | Reusable schema fragments | - -## CONVENTIONS - -- **`operationId`**: Must match exported function name in handler file -- **`x-eov-operation-handler`**: Path to handler file relative to `src/api/` (e.g., `v1/teams`) -- **`x-aclSchema`**: References schema name for CASL authorization -- **`x-acl`**: Maps roles to CRUD abilities (`create-any`, `read`, `update`, `delete-any`) -- **`x-formtype`**: UI hint for console form generation (`SelectWidget`, etc.) -- **`x-externalDocsPath`**: Appended to base docs URL for per-resource documentation -- **Schema files** define the resource type at top level (e.g., `Service:` in `service.yaml`) - -## ANTI-PATTERNS - -- **DO NOT** add paths without `operationId` and `x-eov-operation-handler` -- **DO NOT** define schemas inline in `api.yaml` — create separate `{resource}.yaml` -- **DO NOT** forget `x-aclSchema` — endpoints without it bypass authorization -- After changes: run `npm run build:models` to regenerate `generated-schema.ts` From 82f5ee353d72068a94d72fedb3f58f2601837b6e Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 6 May 2026 11:06:53 +0200 Subject: [PATCH 5/8] chore: remove agents.md --- src/.ignore | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 src/.ignore diff --git a/src/.ignore b/src/.ignore deleted file mode 100644 index d74488f80..000000000 --- a/src/.ignore +++ /dev/null @@ -1,21 +0,0 @@ -# Used by AI AGENTS to skip analyzing these files -.history -.tmp -_.bak -node_modules/ -/coverage/ -/dist/ -/env/ -_.DS*Store -.vscode/values-schema.yaml -*.env -/.secrets -chart/apl/values.schema.json -chart/apl/README.md -workflow/ -\_.new -.envrc -otomi.cpuprofile -/.idea/ -tmp -\*\*values-repo.yaml From 2736d7dd0e728c0195e34741bfb631efe4d5ec62 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 6 May 2026 11:18:37 +0200 Subject: [PATCH 6/8] feat: simplify the code --- src/api-v2.authz.test.ts | 4 ++-- src/api/v2/git.ts | 46 +++++++++++----------------------------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/src/api-v2.authz.test.ts b/src/api-v2.authz.test.ts index 0737f2413..7a9211d39 100644 --- a/src/api-v2.authz.test.ts +++ b/src/api-v2.authz.test.ts @@ -1134,13 +1134,13 @@ describe('API V2 authz tests', () => { jest.spyOn(otomiStack.git, 'testRemoteConnection').mockResolvedValue(true) jest.spyOn(otomiStack.git, 'probePushAccess').mockRejectedValue(new Error('permission denied')) - await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(403) + await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200) }) test('returns 404 when remote connectivity check indicates repository not found', async () => { jest.spyOn(otomiStack.git, 'testRemoteConnection').mockRejectedValue(new Error('repository not found')) - await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(404) + await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200) }) }) diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index c586751fe..e15bbf755 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -2,7 +2,6 @@ import Debug from 'debug' import { Response } from 'express' import { lockApi } from 'src/middleware' import { OpenApiRequestExt } from 'src/otomi-models' -import { getSanitizedErrorMessage } from 'src/utils' const debug = Debug('otomi:api:v2:git') @@ -11,27 +10,6 @@ type ErrorResponse = { message: string } -function classifyGitRemoteError(error: unknown, defaults: ErrorResponse): ErrorResponse { - const message = getSanitizedErrorMessage(error).toLowerCase() - - if (message.includes('not found') || message.includes('repository does not exist')) { - return { code: 404, message: defaults.message } - } - - if ( - message.includes('permission denied') || - message.includes('access denied') || - message.includes('authentication failed') || - message.includes('auth failed') || - message.includes('write access to repository not granted') || - message.includes('403') - ) { - return { code: 403, message: 'Insufficient permissions to push to new git remote' } - } - - return defaults -} - /** * PUT /v2/git * Migrate the values repository to a new git remote. @@ -55,24 +33,24 @@ export const migrateGit = async (req: OpenApiRequestExt, res: Response): Promise let remoteHasContent: boolean try { remoteHasContent = await req.otomi.git.testRemoteConnection(repoUrl, password, branch, username) - } catch (e: unknown) { - const error = classifyGitRemoteError(e, { - code: 400, - message: 'Error connecting to new git remote', - }) - res.status(error.code).json({ error: error.message }) - return + } catch (e: any) { + if (e.message.includes('not found')) { + const error = { message: `Cannot connect to new git remote`, statusCode: 404 } + res.json(error) + return + } else { + const error = { message: `Error connecting to new git remote`, statusCode: 400 } + res.json(error) + return + } } // Validate push permission by creating and deleting a temporary branch on the new remote try { await req.otomi.git.probePushAccess(repoUrl, password, username) } catch (e: unknown) { - const error = classifyGitRemoteError(e, { - code: 400, - message: 'Error validating push access to new git remote', - }) - res.status(error.code).json({ error: error.message }) + const error = { message: `Error validating push access to new git remote`, statusCode: 400 } + res.json(error) return } From d29b2aaf404152b1e18ed072a85a7b5c685b43ba Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 6 May 2026 11:19:15 +0200 Subject: [PATCH 7/8] feat: simplify the code --- src/api/v2/git.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/api/v2/git.ts b/src/api/v2/git.ts index e15bbf755..37dc63751 100644 --- a/src/api/v2/git.ts +++ b/src/api/v2/git.ts @@ -5,11 +5,6 @@ import { OpenApiRequestExt } from 'src/otomi-models' const debug = Debug('otomi:api:v2:git') -type ErrorResponse = { - code: number - message: string -} - /** * PUT /v2/git * Migrate the values repository to a new git remote. From 5b6fa07f964cef1a1b1be846d572febd8e073ed3 Mon Sep 17 00:00:00 2001 From: Jehoszafat Zimnowoda <17126497+j-zimnowoda@users.noreply.github.com> Date: Wed, 6 May 2026 11:21:39 +0200 Subject: [PATCH 8/8] feat: simplify the code --- src/api-v2.authz.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api-v2.authz.test.ts b/src/api-v2.authz.test.ts index 7a9211d39..a79f17f95 100644 --- a/src/api-v2.authz.test.ts +++ b/src/api-v2.authz.test.ts @@ -4,12 +4,12 @@ import { initApp, loadSpec } from 'src/app' import getToken from 'src/fixtures/jwt' import OtomiStack from 'src/otomi-stack' import request from 'supertest' -import { Git } from './git' -import { getSessionStack } from './middleware' -import * as getValuesSchemaModule from './utils' import TestAgent from 'supertest/lib/agent' import { FileStore } from './fileStore/file-store' +import { Git } from './git' +import { getSessionStack } from './middleware' import { AplKind } from './otomi-models' +import * as getValuesSchemaModule from './utils' const platformAdminToken = getToken(['platform-admin']) const teamAdminToken = getToken(['team-admin', 'team-team1']) @@ -1130,14 +1130,14 @@ describe('API V2 authz tests', () => { await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200) }) - test('returns 403 when push access probe fails with permission denied', async () => { + test('returns 200 when push access probe fails with permission denied', async () => { jest.spyOn(otomiStack.git, 'testRemoteConnection').mockResolvedValue(true) jest.spyOn(otomiStack.git, 'probePushAccess').mockRejectedValue(new Error('permission denied')) await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200) }) - test('returns 404 when remote connectivity check indicates repository not found', async () => { + test('returns 200 when remote connectivity check indicates repository not found', async () => { jest.spyOn(otomiStack.git, 'testRemoteConnection').mockRejectedValue(new Error('repository not found')) await agent.put('/v2/git').send(gitBody).set('Authorization', `Bearer ${platformAdminToken}`).expect(200)