diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..b93b3b3 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +reviews: + auto_review: + enabled: true + # Use ".*" to match all branches and review every PR regardless of target + base_branches: + - ".*" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9b8099 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Shared workspace env for all apps +# Copy to `.env` at the repo root. +# +# Nx loads env files when you run targets (`nx serve api`, etc.): +# 1) apps//.env (host-specific — wins on conflicts) +# 2) this root .env (shared defaults) +# Production injects env via the platform (no .env files in the image). +# Secrets are validated per provider via requireEnv (ADR-007) — not dotenv-safe. +# +# Fill after `pnpm supabase start`. Local CLI may still label keys as +# anon / service_role in `supabase status`; our env names follow Supabase’s +# publishable / secret terminology (legacy JWT keys still work as values). + +# --- Required (all apps) — admin / secret client -------------------------------- +SUPABASE_URL="http://127.0.0.1:54321" +SUPABASE_SECRET_KEY= + +# --- Logging (optional; used when nestjs-pino is restored) ------------------------- +# LOG_LEVEL=info +# LOG_PRETTY=true + +# --- Config artifact (optional; ADR-007) ------------------------------------------ +# NXT_CONFIG_PATH= +# NXT_CONFIG_JSON= diff --git a/.gitignore b/.gitignore index d212e8d..32986bb 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,7 @@ coverage/ # Environment & secrets .env .env.* -!.env.example -!supabase/.env.example +!**/.env.example # Supabase local state (root chain + legacy reference) supabase/generated-types.ts diff --git a/.nxignore b/.nxignore index d5391b2..68e379e 100644 --- a/.nxignore +++ b/.nxignore @@ -1,2 +1,9 @@ # Frozen reference tree — not part of the active Nx workspace (see legacy/README.md) legacy + +# Non-runtime files — @nx/js:node watches the whole project when build is +# nx:run-commands; ignore these so `nx serve` does not restart on docs / httpYac edits. +docs +**/*.http +**/*.md +apps/*/http diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 4220fba..eabe9f9 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "dbaeumer.vscode-eslint", "tombonnike.vscode-status-bar-format-toggle", - "firsttris.vscode-jest-runner" + "firsttris.vscode-jest-runner", + "anweber.vscode-httpyac" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index dfdca57..35c20d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "editor.tabSize": 2, "search.exclude": { "package-lock.json": true, + "pnpm-lock.yaml": true, "libs/*/migration": true, "supabase/migrations": true }, diff --git a/AGENTS.md b/AGENTS.md index e0ea8e1..bad6189 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ changes, or work clearly outside the domains below. | Meter command batches, load shedding, meter grouping | 011 | | Company cutover strategy | 012 | | Capability vs core boundaries, behavior/module placement | 013 | +| Machine credentials — API keys, scopes, Postgres roles, MCP | 014 | ### How to read (progressive) diff --git a/README.md b/README.md index 230a6e2..01e922c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,4 @@ -# NxtBackend - - - -✨ Your new, shiny [Nx workspace](https://nx.dev) is ready ✨. - -[Learn more about this workspace setup and its capabilities](https://nx.dev/nx-api/js?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed! +# NXT Backend ## Generate a library diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..3970982 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,21 @@ +# Host-specific env for `api`. +# Copy to `apps/api/.env`. +# +# Shared secrets (`SUPABASE_URL`, `SUPABASE_SECRET_KEY`, LOG_*, NXT_CONFIG_*) live in +# the repo-root `.env` — see root `.env.example`. Nx merges: this file first, then root +# (first definition wins). + +# --- Optional host port ----------------------------------------------------------- +# PORT=3000 + +# --- Auth (api only — AuthModule / Passport strategies) ----------------------------- +# Required when serving api. Validated via requireEnv in SupabaseStrategy (not worker). +# Publishable key (replaces legacy anon key) — user-scoped / RLS-exercising clients. +SUPABASE_PUBLISHABLE_KEY= +# +# Preferred JWT verification (asymmetric / JWKS). Local CLI: +SUPABASE_JWKS_URL="http://127.0.0.1:54321/auth/v1/.well-known/jwks.json" +# +# Legacy HS256 — used only when SUPABASE_JWKS_URL is unset. +# Both may be set; JWKS takes precedence (no runtime fallback if JWKS is unreachable). +# SUPABASE_JWT_SECRET= diff --git a/apps/api/http/.httpyac.js b/apps/api/http/.httpyac.js new file mode 100644 index 0000000..07be10a --- /dev/null +++ b/apps/api/http/.httpyac.js @@ -0,0 +1,18 @@ +/** + * httpYac environments — switch with the httpYac env picker in the status bar. + * Secrets come from parent `.env` files (`apps/api/.env` / repo root), same as `nx serve api`. + */ +module.exports = { + environments: { + $shared: { + baseUrl: 'http://localhost:3000', + supabaseUrl: 'http://127.0.0.1:54321', + platformEmail: 'superadmin@nxt-platform.com', + platformPassword: 'superadmin', + solarEmail: 'admin@nxt-solar.com', + solarPassword: 'admin', + devApiKey: 'dev-api-key-platform-superadmin', + }, + local: {}, + }, +}; diff --git a/apps/api/http/README.md b/apps/api/http/README.md new file mode 100644 index 0000000..83831b5 --- /dev/null +++ b/apps/api/http/README.md @@ -0,0 +1,53 @@ +# Local API smoke tests (httpYac) + +File-based requests for the [httpYac](https://marketplace.visualstudio.com/items?itemName=anweber.vscode-httpyac) extension — `.http` files live in the repo next to the API. + +## Setup + +1. Install **httpYac** VS Code extension (`anweber.vscode-httpyac`) and disable any other http extension you might have. +2. Use the same env as `nx serve api` — httpYac loads `.env` from parent folders + (`apps/api/.env` / repo root). Needs at least `SUPABASE_PUBLISHABLE_KEY` for login. +3. In the editor status bar, select httpYac environment **`local`** (URLs + seeded users from + `.httpyac.js`). +4. Local Supabase + seed, and `pnpm exec nx serve api`. + +## How to run + +Open an endpoint file (e.g. `me.http`) and **Send** a request. +`# @import ./login.http` + `# @ref loginPlatform` runs the login first (cached until you +force-refresh), then the API call uses `{{loginPlatform.access_token}}`. + +## Files + +| File | Role | +|------|------| +| `.httpyac.js` | Shared local URLs + seeded emails/passwords | +| `login.http` | Named Supabase password grants (`loginPlatform`, `loginSolar`) | +| `me.http` | Imports login, refs it, calls `GET /auth/me` | +| `user-admin.http` | Task 9: create/update/delete customer, agent, member (+ API-key create-customer) | + +## Seeded users + +| Persona | Email | Password | Login name | +|---------|-------|----------|------------| +| Platform | `superadmin@nxt-platform.com` | `superadmin` | `loginPlatform` | +| Solar | `admin@nxt-solar.com` | `admin` | `loginSolar` | + +New endpoint file pattern: + +```http +# @import ./login.http + +### Some endpoint +# @ref loginPlatform +GET {{baseUrl}}/… +Authorization: Bearer {{loginPlatform.access_token}} +``` + +## `user-admin.http` notes + +- Seed already covers this: solar org **2**, grid **1**, platform API key. No seed change needed. +- Run requests **top-down** when a step needs a prior `# @name` id (create → update → delete). +- Creates real Auth users; timestamps keep emails unique. If Auth/phone collides, reset: + `pnpm exec supabase db reset`. +- Machine smoke: **Create customer (X-API-KEY)** — privileged admin path (no user client yet). diff --git a/apps/api/http/login.http b/apps/api/http/login.http new file mode 100644 index 0000000..11957e1 --- /dev/null +++ b/apps/api/http/login.http @@ -0,0 +1,26 @@ +# Shared Supabase password-grant logins (seeded users). +# Imported by endpoint files via `# @import ./login.http` + `# @ref …` + +@publishableKey = {{$dotenv SUPABASE_PUBLISHABLE_KEY}} + +### Login — platform superadmin +# @name loginPlatform +POST {{supabaseUrl}}/auth/v1/token?grant_type=password +apikey: {{publishableKey}} +Content-Type: application/json + +{ + "email": "{{platformEmail}}", + "password": "{{platformPassword}}" +} + +### Login — solar developer admin +# @name loginSolar +POST {{supabaseUrl}}/auth/v1/token?grant_type=password +apikey: {{publishableKey}} +Content-Type: application/json + +{ + "email": "{{solarEmail}}", + "password": "{{solarPassword}}" +} diff --git a/apps/api/http/me.http b/apps/api/http/me.http new file mode 100644 index 0000000..3dd45d0 --- /dev/null +++ b/apps/api/http/me.http @@ -0,0 +1,15 @@ +# @import ./login.http + +### GET /auth/me (platform) +# @ref loginPlatform +GET {{baseUrl}}/auth/me +Authorization: Bearer {{loginPlatform.access_token}} + +### GET /auth/me (solar) +# @ref loginSolar +GET {{baseUrl}}/auth/me +Authorization: Bearer {{loginSolar.access_token}} + +### GET /auth/me (X-API-KEY — seeded platform key) +GET {{baseUrl}}/auth/me +X-API-KEY: {{devApiKey}} diff --git a/apps/api/http/user-admin.http b/apps/api/http/user-admin.http new file mode 100644 index 0000000..e5f96d8 --- /dev/null +++ b/apps/api/http/user-admin.http @@ -0,0 +1,118 @@ +# @import ./login.http +# +# Task 9 smoke — privileged user-admin (whole-method admin client). +# Needs: local Supabase + seed, `nx serve api`, httpYac env `local`. +# Creates Auth users + rows — use unique emails/phones (below) or `supabase db reset` +# between full runs if something collides. + +### Create customer (solar bearer → grid 1) +# @name createCustomerSolar +# @ref loginSolar +POST {{baseUrl}}/user-admin/create-customer +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "full_name": "Smoke Customer", + "email": "smoke-customer-{{$timestamp}}@example.com", + "grid_id": 1, + "is_hidden_from_reporting": false +} + +### Update customer (solar bearer) +# @ref loginSolar +POST {{baseUrl}}/user-admin/update-customer +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "id": {{createCustomerSolar.id}}, + "full_name": "Smoke Customer Updated", + "email": "{{createCustomerSolar.account.email}}", + "is_hidden_from_reporting": true +} + +### Create customer (X-API-KEY — machine path, platform key) +# @name createCustomerApiKey +POST {{baseUrl}}/user-admin/create-customer +X-API-KEY: {{devApiKey}} +Content-Type: application/json + +{ + "full_name": "Smoke Machine Customer", + "email": "smoke-machine-customer-{{$timestamp}}@example.com", + "grid_id": 1, + "is_hidden_from_reporting": false +} + +### Create agent (solar bearer → grid 1) +# @name createAgentSolar +# @ref loginSolar +POST {{baseUrl}}/user-admin/create-agent +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "full_name": "Smoke Agent", + "phone": "+316{{$randomInt 10000000 99999999}}", + "email": "smoke-agent-{{$timestamp}}@example.com", + "grid_id": 1 +} + +### Update agent (solar bearer) +# @ref loginSolar +POST {{baseUrl}}/user-admin/update-agent +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "id": {{createAgentSolar.id}}, + "full_name": "Smoke Agent Updated", + "phone": "+316{{$randomInt 10000000 99999999}}", + "email": "smoke-agent-updated-{{$timestamp}}@example.com" +} + +### Invite member (solar bearer → org 2) +# @name inviteMemberSolar +# @ref loginSolar +POST {{baseUrl}}/user-admin/invite-member +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "email": "smoke-member-{{$timestamp}}@example.com", + "full_name": "Smoke Member", + "organization_id": 2, + "member_type": "TECH", + "redirectTo": "http://localhost:5173/" +} + +### Update member (solar bearer — invited member) +# @ref loginSolar +POST {{baseUrl}}/user-admin/update-member +Authorization: Bearer {{loginSolar.access_token}} +Content-Type: application/json + +{ + "id": {{inviteMemberSolar.id}}, + "full_name": "Smoke Member Updated", + "member_type": "TECH", + "training_level": 1, + "subscribed_to_telegram_revenue_notifications": false, + "hidden": false +} + +### Delete customer created by solar bearer (cleanup) +# @ref loginSolar +DELETE {{baseUrl}}/user-admin/customer/{{createCustomerSolar.id}} +Authorization: Bearer {{loginSolar.access_token}} + +### Delete agent (cleanup) +# @ref loginSolar +DELETE {{baseUrl}}/user-admin/agent/{{createAgentSolar.id}} +Authorization: Bearer {{loginSolar.access_token}} + +### Delete invited member (cleanup — member id from invite response) +# @ref loginSolar +DELETE {{baseUrl}}/user-admin/member/{{inviteMemberSolar.id}} +Authorization: Bearer {{loginSolar.access_token}} diff --git a/apps/api/jest.config.cts b/apps/api/jest.config.cts index 8e75bbb..4591a17 100644 --- a/apps/api/jest.config.cts +++ b/apps/api/jest.config.cts @@ -1,21 +1,9 @@ -/* eslint-disable */ -const { readFileSync } = require('fs') - -// Reading the SWC compilation config for the spec files -const swcJestConfig = JSON.parse( - readFileSync(`${__dirname}/.spec.swcrc`, 'utf-8') -); - -// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves -swcJestConfig.swcrc = false; +const { shared } = require('./jest.shared.cjs'); +/** Default `nx test api` — unit only (no local Supabase / stack). */ module.exports = { + ...shared, displayName: 'api', - preset: '../../jest.preset.js', - testEnvironment: 'node', - transform: { - '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig] - }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: 'test-output/jest/coverage' + testMatch: [ '/test/unit/**/*.(spec|test).ts' ], + passWithNoTests: true, }; diff --git a/apps/api/jest.e2e.config.cts b/apps/api/jest.e2e.config.cts new file mode 100644 index 0000000..2c84471 --- /dev/null +++ b/apps/api/jest.e2e.config.cts @@ -0,0 +1,9 @@ +const { shared } = require('./jest.shared.cjs'); + +/** `nx run api:test-e2e` — needs local Supabase + seed. */ +module.exports = { + ...shared, + displayName: 'api-e2e', + testMatch: [ '/test/e2e/**/*.(spec|test).ts' ], + passWithNoTests: true, +}; diff --git a/apps/api/jest.integration.config.cts b/apps/api/jest.integration.config.cts new file mode 100644 index 0000000..1bed660 --- /dev/null +++ b/apps/api/jest.integration.config.cts @@ -0,0 +1,9 @@ +const { shared } = require('./jest.shared.cjs'); + +/** `nx run api:test-integration` — needs local Supabase + seed. */ +module.exports = { + ...shared, + displayName: 'api-integration', + testMatch: [ '/test/integration/**/*.(spec|test).ts' ], + passWithNoTests: true, +}; diff --git a/apps/api/jest.shared.cjs b/apps/api/jest.shared.cjs new file mode 100644 index 0000000..f4649cd --- /dev/null +++ b/apps/api/jest.shared.cjs @@ -0,0 +1,24 @@ +/* eslint-disable */ +const { readFileSync } = require('fs') + +const swcJestConfig = JSON.parse( + readFileSync(`${__dirname}/.spec.swcrc`, 'utf-8') +); +swcJestConfig.swcrc = false; + +/** Shared Jest options for api unit / integration / e2e configs. */ +const shared = { + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + // Source uses explicit `.js` extensions on relative imports (NodeNext); strip for Jest. + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + coverageDirectory: 'test-output/jest/coverage' +}; + +module.exports = { shared }; diff --git a/apps/api/package.json b/apps/api/package.json index 6c48fc4..f75b577 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -87,21 +87,52 @@ } } }, - "test": { - "executor": "nx:noop" + "test-integration": { + "executor": "nx:run-commands", + "dependsOn": [ + "^build" + ], + "options": { + "cwd": "apps/api", + "command": "jest --config jest.integration.config.cts" + } + }, + "test-e2e": { + "executor": "nx:run-commands", + "dependsOn": [ + "^build" + ], + "options": { + "cwd": "apps/api", + "command": "jest --config jest.e2e.config.cts" + } } } }, "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.0", "@nxt/core": "workspace:*", + "@supabase/supabase-js": "^2.110.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "jose": "^6.2.3", + "passport": "^0.7.0", + "passport-headerapikey": "^1.2.2", + "passport-http-bearer": "^1.0.1", + "ramda": "^0.31.3", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.0", "tslib": "^2.3.0" }, "devDependencies": { - "@nestjs/testing": "^11.0.0" + "@nestjs/testing": "^11.0.0", + "@types/passport": "^1.0.17", + "@types/passport-http-bearer": "^1.0.42", + "@types/ramda": "^0.31.1", + "@types/supertest": "^7.2.1", + "supertest": "^7.2.2" } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a456bb9..989fa0a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,18 +1,28 @@ -import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { loadConfig } from '@nxt/core'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import { loadConfig } from '@nxt/core/config'; async function bootstrap() { - // Config must be loaded before AppModule is imported: its capability contribution - // functions run at module-decoration time, which a static import would otherwise - // evaluate before this line runs (ADR-007 decision 3). + // Config subpath has no Nest module side effects. loadConfig before NestFactory.create + // so forRootAsync factories / providers can call getConfig() (ADR-007 decision 3). loadConfig(); const { AppModule } = await import('./modules/app.module.js'); const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + + app.enableCors(); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + transformOptions: { enableImplicitConversion: false }, + }), + ); + const port = process.env.PORT || 3000; await app.listen(port); - Logger.log(`🚀 Application is running on: http://localhost:${ port }`); + logger.log(`Application is running on: http://localhost:${ port }`); } bootstrap(); diff --git a/apps/api/src/modules/app.module.ts b/apps/api/src/modules/app.module.ts index b899a07..58010a0 100644 --- a/apps/api/src/modules/app.module.ts +++ b/apps/api/src/modules/app.module.ts @@ -1,10 +1,22 @@ import { Module } from '@nestjs/common'; -import { demoModules, getConfig } from '@nxt/core'; -import { HealthModule } from './health/health.module'; +import { + GlobalHttpModule, + GlobalLoggerModule, + GlobalSupabaseModule, +} from '@nxt/core'; +import { AuthModule } from './auth/auth.module.js'; +import { HealthModule } from './health/health.module.js'; +import { UserAdminModule } from './user-admin/user-admin.module.js'; -const alwaysOn = [ HealthModule ]; +/** Cross-cutting infra — Logger, Supabase, HTTP. */ +const infrastructure = [ GlobalLoggerModule, GlobalSupabaseModule, GlobalHttpModule ]; + +/** Always-on Foundation domain for this host. */ +const foundation = [ AuthModule, HealthModule, UserAdminModule ]; + +// Tier-1 capability conditionals (empty until capabilities are imported). @Module({ - imports: [ ...alwaysOn, ...demoModules(getConfig()) ], + imports: [ ...infrastructure, ...foundation ], }) export class AppModule {} diff --git a/apps/api/src/modules/auth/api-key.strategy.ts b/apps/api/src/modules/auth/api-key.strategy.ts new file mode 100644 index 0000000..04cc495 --- /dev/null +++ b/apps/api/src/modules/auth/api-key.strategy.ts @@ -0,0 +1,66 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; +import { SupabaseService } from '@nxt/core'; + +import type { AuthenticatedUser } from './authenticated-user.js'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) { + constructor(private readonly supabase: SupabaseService) { + super({ header: 'X-API-KEY', prefix: '' }, false); + } + + async validate(apiKey: string): Promise { + const apiKeyWithDetails = await this.supabase.adminClient + .from('api_keys') + .select(` + id, + account:accounts ( + id, + email, + full_name, + deleted_at, + supabase_id, + member:members ( + member_type + ), + organization:organizations ( + id + ) + ) + `) + .eq('key', apiKey) + .maybeSingle() + .then(this.supabase.handleResponse) + ; + + const account = apiKeyWithDetails?.account; + if (!account || account.deleted_at) { + throw new UnauthorizedException( + 'The API key used does not have a corresponding account', + ); + } + + const { member, organization } = account; + if (!member?.member_type || organization?.id == null) { + throw new UnauthorizedException( + 'The API key account is missing member or organization claims', + ); + } + + // Near-future (ADR-014 §5.3): attach an `authenticated` user client with this + // account's JWT claims so machine callers can exercise RLS. Until then, handlers + // that need a DB client after API-key auth fall through to service_role (high privilege). + return { + email: account.email ?? '', + full_name: account.full_name ?? '', + account_type: 'MEMBER', + member_type: member.member_type, + account_id: account.id, + organization_id: organization.id, + supabase_id: account.supabase_id, + async validate() { return {}; }, + }; + } +} diff --git a/apps/api/src/modules/auth/auth.controller.ts b/apps/api/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..bcd922f --- /dev/null +++ b/apps/api/src/modules/auth/auth.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; + +import { + CurrentUser, + type AuthenticatedUser, +} from './authenticated-user.js'; +import { AuthenticationGuard } from './authentication.guard.js'; + +/** Early probe for JWKS / API-key auth — returns the authenticated principal. */ +@UseGuards(AuthenticationGuard) +@Controller('auth') +export class AuthController { + @Get('me') + me(@CurrentUser() user: AuthenticatedUser) { + return { + email: user.email, + full_name: user.full_name, + account_type: user.account_type, + member_type: user.member_type, + account_id: user.account_id, + organization_id: user.organization_id, + supabase_id: user.supabase_id, + }; + } +} diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..906c2ad --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; + +import { ApiKeyStrategy } from './api-key.strategy.js'; +import { AuthController } from './auth.controller.js'; +import { AuthenticationGuard } from './authentication.guard.js'; +import { SupabaseStrategy } from './supabase.strategy.js'; + +/** Host auth — Passport strategies + guard (admin client used for API-key lookup). */ +@Module({ + imports: [ PassportModule ], + controllers: [ AuthController ], + providers: [ AuthenticationGuard, SupabaseStrategy, ApiKeyStrategy ], + exports: [ AuthenticationGuard, PassportModule ], +}) +export class AuthModule {} diff --git a/apps/api/src/modules/auth/authenticated-user.ts b/apps/api/src/modules/auth/authenticated-user.ts new file mode 100644 index 0000000..8963779 --- /dev/null +++ b/apps/api/src/modules/auth/authenticated-user.ts @@ -0,0 +1,45 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { PostgrestError, SupabaseClient, User } from '@supabase/supabase-js'; +import type { + AccountTypeEnum, + MemberTypeEnum, +} from '@nxt/core/types/supabase-types'; +import type { Database } from '@nxt/core/types/supabase-types-adjusted'; + +/** + * Principal attached by Passport after bearer or API-key validation. + * No embedded account row (legacy TEMPORARY attach dropped); use `account_id` / claims. + */ +export interface AuthenticatedUser { + email: string; + full_name: string; + account_type: AccountTypeEnum; + member_type: MemberTypeEnum; + account_id: number; + organization_id: number; + supabase_id: string; + /** Per-request user client (RLS). Absent on API-key auth until a user client is wired. */ + supabase?: { + client: SupabaseClient; + handleResponse({ + data, + error, + status, + }: { + data: T; + error: PostgrestError | null; + status: number; + }): T; + }; + /** + * Re-checks the session with Supabase Auth when a user client is present. + * API-key path may stub this until a privileged equivalent exists. + */ + validate(args?: { organization_id?: number }): Promise>; +} + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthenticatedUser => { + return context.switchToHttp().getRequest<{ user: AuthenticatedUser }>().user; + }, +); diff --git a/apps/api/src/modules/auth/authentication.guard.ts b/apps/api/src/modules/auth/authentication.guard.ts new file mode 100644 index 0000000..57fbec8 --- /dev/null +++ b/apps/api/src/modules/auth/authentication.guard.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Accepts either a Supabase bearer JWT (`supabase`) or an `X-API-KEY` header + * (`headerapikey`). + */ +@Injectable() +export class AuthenticationGuard extends AuthGuard([ 'supabase', 'headerapikey' ]) {} diff --git a/apps/api/src/modules/auth/supabase.strategy.ts b/apps/api/src/modules/auth/supabase.strategy.ts new file mode 100644 index 0000000..0c8bb6f --- /dev/null +++ b/apps/api/src/modules/auth/supabase.strategy.ts @@ -0,0 +1,152 @@ +import { + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { createClient, type PostgrestError } from '@supabase/supabase-js'; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; +import { Strategy } from 'passport-http-bearer'; +import { requireEnv } from '@nxt/core/config'; +import { throwSupabaseError } from '@nxt/core'; +import type { + AccountTypeEnum, + MemberTypeEnum, +} from '@nxt/core/types/supabase-types'; +import type { Database } from '@nxt/core/types/supabase-types-adjusted'; + +import type { AuthenticatedUser } from './authenticated-user.js'; + +interface AppMetadataClaims { + account_type?: AccountTypeEnum; + member_type?: MemberTypeEnum; + account_id?: number | string; + organization_id?: number | string; +} + +interface UserMetadataClaims { + full_name?: string; +} + +@Injectable() +export class SupabaseStrategy extends PassportStrategy(Strategy, 'supabase') { + private readonly logger = new Logger(SupabaseStrategy.name); + private readonly supabaseUrl: string; + private readonly publishableKey: string; + private readonly jwks: ReturnType | null; + private readonly jwtSecret: Uint8Array | null; + + constructor() { + super(); + this.supabaseUrl = requireEnv('SUPABASE_URL'); + this.publishableKey = requireEnv('SUPABASE_PUBLISHABLE_KEY'); + + // @TODO :: This supports the legacy JWT SECRET :: Remove when all clients upgraded + const jwksUrl = process.env.SUPABASE_JWKS_URL; + const jwtSecret = process.env.SUPABASE_JWT_SECRET; + if (jwksUrl) { + this.jwks = createRemoteJWKSet(new URL(jwksUrl)); + this.jwtSecret = null; + } + else if (jwtSecret) { + this.jwks = null; + this.jwtSecret = new TextEncoder().encode(jwtSecret); + } + else { + throw new Error('MISSING SUPABASE_JWKS_URL or SUPABASE_JWT_SECRET'); + } + } + + async validate(token: string): Promise { + let payload: JWTPayload; + try { + if (this.jwks) { + payload = (await jwtVerify(token, this.jwks)).payload; + } + else if (this.jwtSecret) { + payload = (await jwtVerify(token, this.jwtSecret, { algorithms: [ 'HS256' ] })).payload; + } + else { + throw new Error('JWT verification is not configured'); + } + } + catch (err) { + if (err instanceof UnauthorizedException) { + throw err; + } + // Expected for bad/expired client tokens — keep the log quiet (no stack dump). + const reason = err instanceof Error ? err.message : String(err); + this.logger.warn(`Rejecting bearer token: ${ reason }`); + throw new UnauthorizedException('Invalid or expired token'); + } + + if (!payload.sub) { + throw new UnauthorizedException('No UID in token'); + } + + const email = typeof payload.email === 'string' ? payload.email : ''; + const userMetadata = (payload.user_metadata ?? {}) as UserMetadataClaims; + const appMetadata = (payload.app_metadata ?? {}) as AppMetadataClaims; + + const account_id = Number(appMetadata.account_id); + if (!Number.isFinite(account_id) || account_id <= 0) { + throw new UnauthorizedException('This auth.user does not have a corresponding account'); + } + + const organization_id = Number(appMetadata.organization_id); + if (!Number.isFinite(organization_id) || organization_id <= 0) { + throw new UnauthorizedException('Token is missing organization_id'); + } + + if (!appMetadata.account_type || !appMetadata.member_type) { + throw new UnauthorizedException('Token is missing account_type or member_type'); + } + + const supabaseClient = createClient( + this.supabaseUrl, + this.publishableKey, + { global: { headers: { Authorization: `Bearer ${ token }` } } }, + ); + + const logger = this.logger; + return { + email, + full_name: userMetadata.full_name ?? '', + account_type: appMetadata.account_type, + member_type: appMetadata.member_type, + account_id, + organization_id, + supabase_id: payload.sub, + supabase: { + client: supabaseClient, + handleResponse({ + data, + error, + status, + }: { + data: T; + error: PostgrestError | null; + status: number; + }): T { + if (error) { + throwSupabaseError(error, status, logger); + } + return data; + }, + }, + validate({ organization_id: _organizationId }: { organization_id?: number } = {}) { + return supabaseClient.auth + .getUser() + .then(({ data, error }) => { + if (error) { + throwSupabaseError(error, 401, logger); + } + if (!data?.user) { + throw new UnauthorizedException('No valid user data found'); + } + return data.user; + }); + }, + }; + } +} diff --git a/apps/api/src/modules/health/health.service.ts b/apps/api/src/modules/health/health.service.ts index 1668fee..6b71803 100644 --- a/apps/api/src/modules/health/health.service.ts +++ b/apps/api/src/modules/health/health.service.ts @@ -1,14 +1,17 @@ import { Injectable } from '@nestjs/common'; -import { getPackageInfo } from '@nxt/core'; -import type { OrganizationTypeEnum } from '@nxt/core/types/supabase-types'; +import { SupabaseService } from '@nxt/core'; @Injectable() export class HealthService { - /** Golden-path probe: typecheck consumes generated Supabase types via @nxt/core subpath. */ - static readonly platformOperatorType = - 'PLATFORM_OPERATOR' satisfies OrganizationTypeEnum; + constructor(private readonly supabaseService: SupabaseService) {} - getHealth(): { name: string; version: string } { - return getPackageInfo(); + async getHealth(): Promise<{ status: 'ok' }> { + await this.supabaseService.adminClient + .from('organizations') + .select('id') + .limit(1) + .then(response => this.supabaseService.handleResponse(response)); + + return { status: 'ok' }; } } diff --git a/apps/api/src/modules/user-admin/dto/create-agent.dto.ts b/apps/api/src/modules/user-admin/dto/create-agent.dto.ts new file mode 100644 index 0000000..78e988b --- /dev/null +++ b/apps/api/src/modules/user-admin/dto/create-agent.dto.ts @@ -0,0 +1,24 @@ +import { + IsEmail, + IsNotEmpty, + IsNumber, + IsOptional, + IsPhoneNumber, + IsString, +} from 'class-validator'; + +export class CreateAgentDto { + @IsString() + @IsNotEmpty() + full_name!: string; + + @IsPhoneNumber() + phone!: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsNumber() + grid_id!: number; +} diff --git a/legacy/apps/tiamat/src/modules/user-admin/dto/invite-member.dto.ts b/apps/api/src/modules/user-admin/dto/invite-member.dto.ts similarity index 65% rename from legacy/apps/tiamat/src/modules/user-admin/dto/invite-member.dto.ts rename to apps/api/src/modules/user-admin/dto/invite-member.dto.ts index e5e4f3f..5416d2e 100644 --- a/legacy/apps/tiamat/src/modules/user-admin/dto/invite-member.dto.ts +++ b/apps/api/src/modules/user-admin/dto/invite-member.dto.ts @@ -1,16 +1,19 @@ -import { Constants, MemberTypeEnum } from '@core/types/supabase-types'; import { - IsString, - IsNumber, - IsOptional, IsEmail, - IsNotEmpty, IsIn, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, } from 'class-validator'; +import { + Constants, + type MemberTypeEnum, +} from '@nxt/core/types/supabase-types'; export class InviteMemberDto { @IsEmail() - email: string; + email!: string; @IsString() @IsOptional() @@ -18,13 +21,13 @@ export class InviteMemberDto { @IsString() @IsNotEmpty() - full_name: string; + full_name!: string; @IsNumber() - organization_id: number; + organization_id!: number; @IsIn(Constants.public.Enums.member_type_enum) - member_type: MemberTypeEnum; + member_type!: MemberTypeEnum; @IsNumber() @IsOptional() diff --git a/apps/api/src/modules/user-admin/dto/update-agent.dto.ts b/apps/api/src/modules/user-admin/dto/update-agent.dto.ts new file mode 100644 index 0000000..aa0e605 --- /dev/null +++ b/apps/api/src/modules/user-admin/dto/update-agent.dto.ts @@ -0,0 +1,24 @@ +import { + IsEmail, + IsNotEmpty, + IsNumber, + IsOptional, + IsPhoneNumber, + IsString, +} from 'class-validator'; + +export class UpdateAgentDto { + @IsNumber() + id!: number; + + @IsString() + @IsNotEmpty() + full_name!: string; + + @IsPhoneNumber() + phone!: string; + + @IsEmail() + @IsOptional() + email?: string; +} diff --git a/legacy/apps/tiamat/src/modules/user-admin/dto/update-customer.dto.ts b/apps/api/src/modules/user-admin/dto/update-customer.dto.ts similarity index 57% rename from legacy/apps/tiamat/src/modules/user-admin/dto/update-customer.dto.ts rename to apps/api/src/modules/user-admin/dto/update-customer.dto.ts index f525ea9..382ca2d 100644 --- a/legacy/apps/tiamat/src/modules/user-admin/dto/update-customer.dto.ts +++ b/apps/api/src/modules/user-admin/dto/update-customer.dto.ts @@ -1,12 +1,22 @@ -import { IsBoolean, IsEmail, IsLatitude, IsLongitude, IsNotEmpty, IsNumber, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsLatitude, + IsLongitude, + IsNotEmpty, + IsNumber, + IsOptional, + IsPhoneNumber, + IsString, +} from 'class-validator'; export class UpdateCustomerDto { @IsNumber() - id: number; + id!: number; @IsString() @IsNotEmpty() - full_name: string; + full_name!: string; @IsPhoneNumber() @IsOptional() @@ -25,5 +35,5 @@ export class UpdateCustomerDto { longitude?: number; @IsBoolean() - is_hidden_from_reporting: boolean; + is_hidden_from_reporting!: boolean; } diff --git a/legacy/apps/tiamat/src/modules/user-admin/dto/update-member.dto.ts b/apps/api/src/modules/user-admin/dto/update-member.dto.ts similarity index 56% rename from legacy/apps/tiamat/src/modules/user-admin/dto/update-member.dto.ts rename to apps/api/src/modules/user-admin/dto/update-member.dto.ts index 26a5c30..91afadb 100644 --- a/legacy/apps/tiamat/src/modules/user-admin/dto/update-member.dto.ts +++ b/apps/api/src/modules/user-admin/dto/update-member.dto.ts @@ -1,34 +1,37 @@ -import { Constants, MemberTypeEnum } from '@core/types/supabase-types'; import { - IsString, - IsNumber, IsBoolean, + IsIn, IsNotEmpty, + IsNumber, IsOptional, - IsIn, + IsString, } from 'class-validator'; +import { + Constants, + type MemberTypeEnum, +} from '@nxt/core/types/supabase-types'; export class UpdateMemberDto { @IsNumber() - id: number; + id!: number; @IsString() @IsNotEmpty() - full_name: string; + full_name!: string; @IsIn(Constants.public.Enums.member_type_enum) - member_type: MemberTypeEnum; + member_type!: MemberTypeEnum; @IsNumber() - training_level: number; + training_level!: number; @IsNumber() @IsOptional() busy_commissioning_id?: number; @IsBoolean() - subscribed_to_telegram_revenue_notifications: boolean; + subscribed_to_telegram_revenue_notifications!: boolean; @IsBoolean() - hidden: boolean; + hidden!: boolean; } diff --git a/legacy/apps/tiamat/src/modules/user-admin/user-admin.controller.ts b/apps/api/src/modules/user-admin/user-admin.controller.ts similarity index 51% rename from legacy/apps/tiamat/src/modules/user-admin/user-admin.controller.ts rename to apps/api/src/modules/user-admin/user-admin.controller.ts index 13894c2..ef68de2 100644 --- a/legacy/apps/tiamat/src/modules/user-admin/user-admin.controller.ts +++ b/apps/api/src/modules/user-admin/user-admin.controller.ts @@ -1,13 +1,24 @@ -import { Controller, Post, UseGuards, Body, Delete, Param } from '@nestjs/common'; -import { AuthenticationGuard } from '../auth/authentication.guard'; -import { UserAdminService } from './user-admin.service'; -import { InviteMemberDto } from './dto/invite-member.dto'; -import { UpdateMemberDto } from './dto/update-member.dto'; -import { CreateAgentDto } from './dto/create-agent.dto'; -import { UpdateAgentDto } from './dto/update-agent.dto'; -import { CreateCustomerDto } from '@core/modules/customers/dto/create-customer.dto'; -import { UpdateCustomerDto } from './dto/update-customer.dto'; -import { CurrentUser, NxtSupabaseUser } from '../auth/nxt-supabase-user'; +import { + Body, + Controller, + Delete, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { CreateCustomerDto } from '@nxt/core'; + +import { + CurrentUser, + type AuthenticatedUser, +} from '../auth/authenticated-user.js'; +import { AuthenticationGuard } from '../auth/authentication.guard.js'; +import { CreateAgentDto } from './dto/create-agent.dto.js'; +import { InviteMemberDto } from './dto/invite-member.dto.js'; +import { UpdateAgentDto } from './dto/update-agent.dto.js'; +import { UpdateCustomerDto } from './dto/update-customer.dto.js'; +import { UpdateMemberDto } from './dto/update-member.dto.js'; +import { UserAdminService } from './user-admin.service.js'; @UseGuards(AuthenticationGuard) @Controller('user-admin') @@ -17,7 +28,7 @@ export class UserAdminController { @Post('invite-member') inviteMember( @Body() body: InviteMemberDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.inviteMember(body, user); } @@ -25,7 +36,7 @@ export class UserAdminController { @Post('update-member') updateMember( @Body() body: UpdateMemberDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.updateMember(body, user); } @@ -33,7 +44,7 @@ export class UserAdminController { @Post('create-agent') createAgent( @Body() body: CreateAgentDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.createAgent(body, user); } @@ -41,7 +52,7 @@ export class UserAdminController { @Post('update-agent') updateAgent( @Body() body: UpdateAgentDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.updateAgent(body, user); } @@ -49,7 +60,7 @@ export class UserAdminController { @Post('create-customer') createCustomer( @Body() body: CreateCustomerDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.createCustomer(body, user); } @@ -57,7 +68,7 @@ export class UserAdminController { @Post('update-customer') updateCustomer( @Body() body: UpdateCustomerDto, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { return this.service.updateCustomer(body, user); } @@ -65,24 +76,24 @@ export class UserAdminController { @Delete('member/:id') deleteMember( @Param('id') id: string, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { - return this.service.deleteAccount(parseInt(id), 'MEMBER', user); + return this.service.deleteAccount(parseInt(id, 10), 'MEMBER', user); } @Delete('agent/:id') deleteAgent( @Param('id') id: string, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { - return this.service.deleteAccount(parseInt(id), 'AGENT', user); + return this.service.deleteAccount(parseInt(id, 10), 'AGENT', user); } @Delete('customer/:id') deleteCustomer( @Param('id') id: string, - @CurrentUser() user: NxtSupabaseUser, + @CurrentUser() user: AuthenticatedUser, ) { - return this.service.deleteAccount(parseInt(id), 'CUSTOMER', user); + return this.service.deleteAccount(parseInt(id, 10), 'CUSTOMER', user); } } diff --git a/apps/api/src/modules/user-admin/user-admin.module.ts b/apps/api/src/modules/user-admin/user-admin.module.ts new file mode 100644 index 0000000..d6a0c24 --- /dev/null +++ b/apps/api/src/modules/user-admin/user-admin.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { UserAdminController } from './user-admin.controller.js'; +import { UserAdminService } from './user-admin.service.js'; + +@Module({ + controllers: [ UserAdminController ], + providers: [ UserAdminService ], + exports: [ UserAdminService ], +}) +export class UserAdminModule {} diff --git a/apps/api/src/modules/user-admin/user-admin.service.ts b/apps/api/src/modules/user-admin/user-admin.service.ts new file mode 100644 index 0000000..92be0a8 --- /dev/null +++ b/apps/api/src/modules/user-admin/user-admin.service.ts @@ -0,0 +1,484 @@ +import { + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { UserResponse } from '@supabase/supabase-js'; +import { pick } from 'ramda'; +import { + CreateCustomerDto, + SupabaseService, + throwSupabaseError, +} from '@nxt/core'; +import type { + AccountTypeEnum, + MemberTypeEnum, +} from '@nxt/core/types/supabase-types'; + +import type { AuthenticatedUser } from '../auth/authenticated-user.js'; +import type { CreateAgentDto } from './dto/create-agent.dto.js'; +import type { InviteMemberDto } from './dto/invite-member.dto.js'; +import type { UpdateAgentDto } from './dto/update-agent.dto.js'; +import type { UpdateCustomerDto } from './dto/update-customer.dto.js'; +import type { UpdateMemberDto } from './dto/update-member.dto.js'; + +interface SupabaseUserMetadata { + full_name: string; +} + +interface SupabaseAppMetadata { + account_id: number; + account_type: AccountTypeEnum; + member_type?: MemberTypeEnum; + organization_id: number; + grid_id?: number; +} + +interface AccountEmbed { + id: number; + supabase_id: string; + organization_id: number; +} + +const logger = new Logger('UserAdminService'); + +const handleUserResponse = (res: UserResponse) => { + const { data, error } = res; + if (error) { + throwSupabaseError(error, undefined, logger); + } + return data.user; +}; + +/** + * Privileged user administration (members, agents, customers). + * + * Whole-method admin client: Auth Admin APIs and follow-on DB writes cannot run + * under RLS. Callers may be bearer or API-key; API-key principals currently have + * no RLS-bound user client — see ADR-014 §5.3 (near-future). + * + * @TODO Dual-write risk: Auth (GoTrue) and Postgres are updated sequentially with + * no cross-service transaction (supabase-js cannot bundle them). A mid-flow failure + * can leave orphan Auth users, missing member/agent/customer rows, stale JWT + * metadata, or a soft-deleted account whose Auth user was not banned. Acceptable + * for low-frequency admin paths today; proportional follow-ups — await all Auth + * metadata updates (some create paths use `void`), and on create failure after + * Auth user exists compensate with `auth.admin.deleteUser`. Full resumable/saga + * machinery is not planned unless this starts failing in practice. + */ +@Injectable() +export class UserAdminService { + constructor(private readonly supabase: SupabaseService) {} + + async inviteMember( + { + email, + redirectTo, + full_name, + organization_id, + member_type, + busy_commissioning_id, + }: InviteMemberDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + const user = await this.supabase.adminClient.auth.admin + .inviteUserByEmail(email, { data: user_metadata, redirectTo }) + .then(handleUserResponse); + + const account = await this.supabase.adminClient + .from('accounts') + .select('id') + .eq('supabase_id', user.id) + .single() + .then(this.supabase.handleSingle); + + const app_metadata: SupabaseAppMetadata = { + account_id: account.id, + account_type: 'MEMBER', + member_type, + organization_id, + }; + await this.supabase.adminClient.auth.admin + .updateUserById(user.id, { app_metadata }) + .then(handleUserResponse); + + const member = await this.supabase.adminClient + .from('members') + .insert({ + account_id: account.id, + member_type, + busy_commissioning_id, + }) + .select() + .single() + .then(this.supabase.handleSingle); + + const message = `${ author.full_name } invited a new member ${ full_name } (${ email }) with ${ member_type } role`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id, + member_id: member.id, + }) + .then(this.supabase.handleResponse); + + return member; + } + + async updateMember( + { + id, + full_name, + member_type, + training_level, + busy_commissioning_id, + subscribed_to_telegram_revenue_notifications, + hidden, + }: UpdateMemberDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const member = await this.supabase.adminClient + .from('members') + .update({ + member_type, + training_level, + busy_commissioning_id, + subscribed_to_telegram_revenue_notifications, + hidden, + }) + .eq('id', id) + .select('id, account:accounts(id, supabase_id, organization_id)') + .single() + .then(this.supabase.handleSingle); + + const account = member.account as unknown as AccountEmbed; + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + const app_metadata: Partial = { member_type }; + await this.supabase.adminClient.auth.admin + .updateUserById(account.supabase_id, { user_metadata, app_metadata }) + .then(handleUserResponse); + + const message = `${ author.full_name } made updates to member ${ full_name }`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id: account.organization_id, + member_id: member.id, + }) + .then(this.supabase.handleResponse); + + return member; + } + + async createAgent( + { phone, email, full_name, grid_id }: CreateAgentDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const { organization_id } = await this.supabase.adminClient + .from('grids') + .select('organization_id') + .eq('id', grid_id) + .single() + .then(this.supabase.handleSingle); + + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + const user = await this.supabase.adminClient.auth.admin + .createUser({ + phone, + email, + phone_confirm: true, + email_confirm: true, + user_metadata, + }) + .then(handleUserResponse); + + const account = await this.supabase.adminClient + .from('accounts') + .select('id') + .eq('supabase_id', user.id) + .single() + .then(this.supabase.handleSingle); + + const app_metadata: SupabaseAppMetadata = { + account_id: account.id, + account_type: 'AGENT', + organization_id, + grid_id, + }; + void this.supabase.adminClient.auth.admin.updateUserById(user.id, { + app_metadata, + }); + + const agent = await this.supabase.adminClient + .from('agents') + .insert({ + account_id: account.id, + grid_id, + }) + .select() + .single() + .then(this.supabase.handleSingle); + + await this.supabase.adminClient + .from('wallets') + .insert({ agent_id: agent.id }) + .then(this.supabase.handleResponse); + + const message = `${ author.full_name } created a new agent ${ full_name }`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id, + grid_id, + agent_id: agent.id, + }) + .then(this.supabase.handleResponse); + + return agent; + } + + async updateAgent( + { id, full_name, phone, email }: UpdateAgentDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const agent = await this.supabase.adminClient + .from('agents') + .select('id, account:accounts(id, supabase_id, organization_id), grid_id') + .eq('id', id) + .single() + .then(this.supabase.handleSingle); + + const account = agent.account as unknown as AccountEmbed; + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + await this.supabase.adminClient.auth.admin + .updateUserById(account.supabase_id, { phone, email, user_metadata }) + .then(handleUserResponse); + + const message = `${ author.full_name } updated agent ${ full_name }`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id: account.organization_id, + grid_id: agent.grid_id, + agent_id: agent.id, + }) + .then(this.supabase.handleResponse); + + return agent; + } + + async createCustomer( + createCustomerInput: CreateCustomerDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const { full_name, phone, email, grid_id } = createCustomerInput; + // Many customers cannot provide contact details — synthesize an email so Auth can create the user. + const resolvedEmail = email ? email : phone ? undefined : `${ randomUUID() }@gmail.com`; + + const { organization_id } = await this.supabase.adminClient + .from('grids') + .select('organization_id') + .eq('id', grid_id) + .single() + .then(this.supabase.handleSingle); + + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + const user = await this.supabase.adminClient.auth.admin + .createUser({ + phone, + email: resolvedEmail, + phone_confirm: true, + email_confirm: true, + user_metadata, + }) + .then(handleUserResponse); + + const account = await this.supabase.adminClient + .from('accounts') + .select('id') + .eq('supabase_id', user.id) + .single() + .then(this.supabase.handleSingle); + + const app_metadata: SupabaseAppMetadata = { + account_id: account.id, + account_type: 'CUSTOMER', + organization_id, + grid_id, + }; + void this.supabase.adminClient.auth.admin.updateUserById(user.id, { + app_metadata, + }); + + const customer = await this.supabase.adminClient + .from('customers') + .insert({ + account_id: account.id, + grid_id, + ...pick([ + 'latitude', + 'longitude', + 'is_hidden_from_reporting', + 'lives_primarily_in_the_community', + 'generator_owned', + 'gender', + 'total_connection_fee', + ], createCustomerInput), + }) + .select('*, account:accounts(*)') + .single() + .then(this.supabase.handleSingle); + + const message = `${ author.full_name } created a new customer ${ full_name }`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id, + grid_id, + customer_id: customer.id, + }) + .then(this.supabase.handleResponse); + + return customer; + } + + async updateCustomer( + { + id, + full_name, + phone, + email, + latitude, + longitude, + is_hidden_from_reporting, + }: UpdateCustomerDto, + author: AuthenticatedUser, + ) { + await author.validate(); + + const customer = await this.supabase.adminClient + .from('customers') + .update({ latitude, longitude, is_hidden_from_reporting }) + .eq('id', id) + .select('id, grid_id, account:accounts(id, supabase_id, organization_id)') + .single() + .then(this.supabase.handleSingle); + + const account = customer.account as unknown as AccountEmbed; + const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; + await this.supabase.adminClient.auth.admin + .updateUserById(account.supabase_id, { phone, email, user_metadata }) + .then(handleUserResponse); + + const message = `${ author.full_name } updated customer ${ full_name }`; + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id: account.organization_id, + grid_id: customer.grid_id, + customer_id: customer.id, + }) + .then(this.supabase.handleResponse); + + return customer; + } + + /** Soft-deletes the linked account and bans the Auth user. `id` is member/agent/customer id. */ + async deleteAccount( + id: number, + accountType: AccountTypeEnum, + author: AuthenticatedUser, + ) { + await author.validate(); + + const table = + accountType === 'AGENT' + ? 'agents' + : accountType === 'CUSTOMER' + ? 'customers' + : accountType === 'MEMBER' + ? 'members' + : null; + + if (!table) { + throw new Error(`Unsupported account type for delete: ${ accountType }`); + } + + const hasGrid = table === 'agents' || table === 'customers'; + + const { account_id, grid_id } = await this.supabase.adminClient + .from(table) + .select(hasGrid ? 'account_id, grid_id' : 'account_id') + .eq('id', id) + .single() + .then(this.supabase.handleSingle) + .then(row => row as unknown as { + account_id: number; + grid_id?: number | null; + }); + + const account = await this.supabase.adminClient + .from('accounts') + .update({ deleted_at: new Date().toISOString() }) + .eq('id', account_id) + .select('supabase_id, full_name, organization_id') + .single() + .then(this.supabase.handleSingle); + + if (!account.supabase_id) { + throw new NotFoundException('deleteAccount: account has no supabase_id'); + } + + await this.supabase.adminClient.auth.admin + .updateUserById(account.supabase_id, { ban_duration: '876000h' }) + .then(handleUserResponse); + + const entity = table.slice(0, -1); + const message = `${ author.full_name } deleted ${ entity } ${ account.full_name }`; + const entityIdField = + accountType === 'AGENT' + ? { agent_id: id } + : accountType === 'CUSTOMER' + ? { customer_id: id } + : accountType === 'MEMBER' + ? { member_id: id } + : {}; + + void this.supabase.adminClient + .from('audits') + .insert({ + message, + author_id: author.account_id, + organization_id: account.organization_id, + grid_id, + ...entityIdField, + }) + .then(this.supabase.handleResponse); + + return { accountType, full_name: account.full_name, deleted: true }; + } +} diff --git a/apps/api/test/e2e/auth.e2e.spec.ts b/apps/api/test/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..c166d40 --- /dev/null +++ b/apps/api/test/e2e/auth.e2e.spec.ts @@ -0,0 +1,92 @@ +import { + Injectable, + UnauthorizedException, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { PassportModule, PassportStrategy } from '@nestjs/passport'; +import { GlobalSupabaseModule } from '@nxt/core'; +import { Strategy as BearerStrategy } from 'passport-http-bearer'; +import request from 'supertest'; + +import { ApiKeyStrategy } from '../../src/modules/auth/api-key.strategy.js'; +import { AuthController } from '../../src/modules/auth/auth.controller.js'; +import { AuthenticationGuard } from '../../src/modules/auth/authentication.guard.js'; +import { + getLocalSupabaseEnv, + SEEDED_PLATFORM_API_KEY, + SEEDED_PLATFORM_SUPABASE_ID, +} from '../helpers/local-supabase-env.js'; + +/** + * Registers Passport name `supabase` so AuthenticationGuard's dual strategy + * list can run. Always fails — this suite only exercises X-API-KEY. + * + * Why not AppModule? Booting AuthModule pulls SupabaseStrategy → ESM-only + * `jose`, which Jest (CJS) cannot load without a heavier transform harness. + * Thin slice is intentional until a full-app e2e harness exists; do not + * proliferate thin modules as the default pattern. + */ +@Injectable() +class StubSupabaseStrategy extends PassportStrategy(BearerStrategy, 'supabase') { + constructor() { + super(); + } + + validate(): never { + throw new UnauthorizedException('Stub bearer strategy — not used in this suite'); + } +} + +const localEnv = getLocalSupabaseEnv(); +const describeE2e = localEnv ? describe : describe.skip; + +describeE2e('GET /auth/me (e2e — X-API-KEY)', () => { + let app: INestApplication; + let moduleRef: TestingModule; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ PassportModule, GlobalSupabaseModule ], + controllers: [ AuthController ], + providers: [ AuthenticationGuard, ApiKeyStrategy, StubSupabaseStrategy ], + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + transformOptions: { enableImplicitConversion: false }, + }), + ); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + await moduleRef?.close(); + }); + + it('returns the seeded platform principal for X-API-KEY', async () => { + const res = await request(app.getHttpServer()) + .get('/auth/me') + .set('X-API-KEY', SEEDED_PLATFORM_API_KEY) + .expect(200); + + expect(res.body).toMatchObject({ + email: 'superadmin@nxt-platform.com', + full_name: 'Platform Superadmin', + account_type: 'MEMBER', + member_type: 'SUPERADMIN', + organization_id: 1, + supabase_id: SEEDED_PLATFORM_SUPABASE_ID, + }); + expect(res.body.account_id).toBeGreaterThan(0); + }); + + it('rejects the request when X-API-KEY is missing', async () => { + await request(app.getHttpServer()).get('/auth/me').expect(401); + }); +}); diff --git a/apps/api/test/helpers/local-supabase-env.ts b/apps/api/test/helpers/local-supabase-env.ts new file mode 100644 index 0000000..4b44822 --- /dev/null +++ b/apps/api/test/helpers/local-supabase-env.ts @@ -0,0 +1,27 @@ +/** + * Local Supabase env helpers for integration / e2e suites under `apps/api/test/`. + * + * Nx loads `apps/api/.env` then workspace `.env` for `nx test*` the same way as + * `nx serve api` (project wins on conflicts). Returns null when keys are missing + * so stack-dependent suites can skip instead of failing a stack-free default run. + */ + +/** Seeded in `supabase/seed.sql` — platform SUPERADMIN (org 1). */ +export const SEEDED_PLATFORM_API_KEY = 'dev-api-key-platform-superadmin'; + +export const SEEDED_PLATFORM_SUPABASE_ID = + 'a0000000-0000-4000-8000-000000000001'; + +export interface LocalSupabaseEnv { + readonly url: string; + readonly secretKey: string; +} + +export function getLocalSupabaseEnv(): LocalSupabaseEnv | null { + const url = process.env.SUPABASE_URL?.trim(); + const secretKey = process.env.SUPABASE_SECRET_KEY?.trim(); + if (!url || !secretKey) { + return null; + } + return { url, secretKey }; +} diff --git a/apps/api/test/integration/auth/api-key.strategy.integration.spec.ts b/apps/api/test/integration/auth/api-key.strategy.integration.spec.ts new file mode 100644 index 0000000..b6b7e3d --- /dev/null +++ b/apps/api/test/integration/auth/api-key.strategy.integration.spec.ts @@ -0,0 +1,52 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { GlobalSupabaseModule } from '@nxt/core'; + +import { ApiKeyStrategy } from '../../../src/modules/auth/api-key.strategy.js'; +import { + getLocalSupabaseEnv, + SEEDED_PLATFORM_API_KEY, + SEEDED_PLATFORM_SUPABASE_ID, +} from '../../helpers/local-supabase-env.js'; + +const localEnv = getLocalSupabaseEnv(); +const describeIntegration = localEnv ? describe : describe.skip; + +describeIntegration('ApiKeyStrategy (integration — local Supabase + seed)', () => { + let moduleRef: TestingModule; + let strategy: ApiKeyStrategy; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ GlobalSupabaseModule ], + providers: [ ApiKeyStrategy ], + }).compile(); + + strategy = moduleRef.get(ApiKeyStrategy); + }); + + afterAll(async () => { + await moduleRef?.close(); + }); + + it('resolves the seeded platform API key to AuthenticatedUser claims', async () => { + const user = await strategy.validate(SEEDED_PLATFORM_API_KEY); + + expect(user).toMatchObject({ + email: 'superadmin@nxt-platform.com', + full_name: 'Platform Superadmin', + account_type: 'MEMBER', + member_type: 'SUPERADMIN', + organization_id: 1, + supabase_id: SEEDED_PLATFORM_SUPABASE_ID, + }); + expect(user.account_id).toBeGreaterThan(0); + expect(typeof user.validate).toBe('function'); + }); + + it('rejects an unknown API key', async () => { + await expect(strategy.validate('not-a-real-api-key')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); +}); diff --git a/apps/api/test/unit/helpers/local-supabase-env.spec.ts b/apps/api/test/unit/helpers/local-supabase-env.spec.ts new file mode 100644 index 0000000..61ecc4c --- /dev/null +++ b/apps/api/test/unit/helpers/local-supabase-env.spec.ts @@ -0,0 +1,33 @@ +import { getLocalSupabaseEnv } from '../../helpers/local-supabase-env.js'; + +describe('getLocalSupabaseEnv', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns null when SUPABASE_URL is missing', () => { + delete process.env.SUPABASE_URL; + process.env.SUPABASE_SECRET_KEY = 'test-secret'; + + expect(getLocalSupabaseEnv()).toBeNull(); + }); + + it('returns null when SUPABASE_SECRET_KEY is missing', () => { + process.env.SUPABASE_URL = 'http://127.0.0.1:54321'; + delete process.env.SUPABASE_SECRET_KEY; + + expect(getLocalSupabaseEnv()).toBeNull(); + }); + + it('returns trimmed url and secret when both are set', () => { + process.env.SUPABASE_URL = ' http://127.0.0.1:54321 '; + process.env.SUPABASE_SECRET_KEY = ' test-secret '; + + expect(getLocalSupabaseEnv()).toEqual({ + url: 'http://127.0.0.1:54321', + secretKey: 'test-secret', + }); + }); +}); diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json index 313f4c6..751825c 100644 --- a/apps/api/tsconfig.app.json +++ b/apps/api/tsconfig.app.json @@ -7,8 +7,6 @@ ], "rootDir": "src", "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, "target": "es2021" }, "include": [ diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json index ea4d76a..4f85359 100644 --- a/apps/api/tsconfig.spec.json +++ b/apps/api/tsconfig.spec.json @@ -5,16 +5,16 @@ "types": [ "jest", "node" - ], - "experimentalDecorators": true, - "emitDecoratorMetadata": true + ] }, "include": [ "jest.config.ts", "jest.config.cts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" + "jest.integration.config.cts", + "jest.e2e.config.cts", + "jest.shared.cjs", + "src/**/*.d.ts", + "test/**/*.ts" ], "references": [ { diff --git a/apps/worker/.env.example b/apps/worker/.env.example new file mode 100644 index 0000000..493d419 --- /dev/null +++ b/apps/worker/.env.example @@ -0,0 +1,11 @@ +# Host-specific env for `worker`. +# Copy to `apps/worker/.env`. +# +# Shared secrets (`SUPABASE_URL`, `SUPABASE_SECRET_KEY`, LOG_*, NXT_CONFIG_*) live in +# the repo-root `.env` — see root `.env.example`. Nx merges: this file first, then root +# (first definition wins). +# +# Worker does not need publishable / JWKS auth secrets (api-only, Task 7). + +# --- Optional host port ----------------------------------------------------------- +# PORT=3000 diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts index a456bb9..e478baa 100644 --- a/apps/worker/src/main.ts +++ b/apps/worker/src/main.ts @@ -1,18 +1,19 @@ -import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { loadConfig } from '@nxt/core'; +import { Logger } from '@nestjs/common'; +import { loadConfig } from '@nxt/core/config'; async function bootstrap() { - // Config must be loaded before AppModule is imported: its capability contribution - // functions run at module-decoration time, which a static import would otherwise - // evaluate before this line runs (ADR-007 decision 3). + // Config subpath has no Nest module side effects. loadConfig before NestFactory.create + // so forRootAsync factories / providers can call getConfig() (ADR-007 decision 3). loadConfig(); const { AppModule } = await import('./modules/app.module.js'); const app = await NestFactory.create(AppModule); + const logger = new Logger('Bootstrap'); + const port = process.env.PORT || 3000; await app.listen(port); - Logger.log(`🚀 Application is running on: http://localhost:${ port }`); + logger.log(`Application is running on: http://localhost:${ port }`); } bootstrap(); diff --git a/apps/worker/src/modules/app.module.ts b/apps/worker/src/modules/app.module.ts index f11bcd4..f20b9a3 100644 --- a/apps/worker/src/modules/app.module.ts +++ b/apps/worker/src/modules/app.module.ts @@ -1,13 +1,23 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; -import { demoModules, getConfig } from '@nxt/core'; +import { + GlobalHttpModule, + GlobalLoggerModule, + GlobalSupabaseModule, +} from '@nxt/core'; import { HeartbeatModule } from './heartbeat/heartbeat.module'; -const alwaysOn = [ ScheduleModule.forRoot(), HeartbeatModule ]; +/** Cross-cutting infra — Logger, Supabase, HTTP (3rd-party integrations). */ +const infrastructure = [ GlobalLoggerModule, GlobalSupabaseModule, GlobalHttpModule ]; + +/** Always-on Foundation for this host (scheduler + heartbeat). */ +const foundation = [ ScheduleModule.forRoot(), HeartbeatModule ]; + +// Tier-1 capability conditionals (empty until capabilities are imported). @Module({ - imports: [ ...alwaysOn, ...demoModules(getConfig()) ], + imports: [ ...infrastructure, ...foundation ], controllers: [], }) export class AppModule {} diff --git a/apps/worker/tsconfig.app.json b/apps/worker/tsconfig.app.json index 313f4c6..751825c 100644 --- a/apps/worker/tsconfig.app.json +++ b/apps/worker/tsconfig.app.json @@ -7,8 +7,6 @@ ], "rootDir": "src", "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo", - "experimentalDecorators": true, - "emitDecoratorMetadata": true, "target": "es2021" }, "include": [ diff --git a/apps/worker/tsconfig.spec.json b/apps/worker/tsconfig.spec.json index ea4d76a..2958eab 100644 --- a/apps/worker/tsconfig.spec.json +++ b/apps/worker/tsconfig.spec.json @@ -5,9 +5,7 @@ "types": [ "jest", "node" - ], - "experimentalDecorators": true, - "emitDecoratorMetadata": true + ] }, "include": [ "jest.config.ts", diff --git a/config.default.json b/config.default.json index 393cac2..93a2d5c 100644 --- a/config.default.json +++ b/config.default.json @@ -1,6 +1,5 @@ { "$schemaVersion": "1", - "deployment": {}, "public": { "platformName": "NXT Grid Platform" }, diff --git a/config.example.json b/config.example.json index 340a5d4..376a1fd 100644 --- a/config.example.json +++ b/config.example.json @@ -1,14 +1,8 @@ { "$schemaVersion": "1", - "deployment": { - "adminOrganizationId": 1, - "systemWalletId": 1 - }, "public": { "platformName": "Acme Mini-Grid" }, - "capabilities": { - "demo": { "enabled": true } - }, + "capabilities": {}, "integrations": {} } diff --git a/docs/architecture/004-open-source-architecture-and-capability-modularization.md b/docs/architecture/004-open-source-architecture-and-capability-modularization.md index 2832c66..7b86a6e 100644 --- a/docs/architecture/004-open-source-architecture-and-capability-modularization.md +++ b/docs/architecture/004-open-source-architecture-and-capability-modularization.md @@ -153,17 +153,27 @@ a capability is off* (keep) vs *deprecated/historical-only* (exclude from baseli - Existing dual-ORM usage (legacy TypeORM + Supabase client on the primary DB) complicates module extraction and must be paid down alongside. -## Out of Scope / Deferred to Follow-up ADRs -- **ADR-005 — Inter-host communication:** shared-DB vs HTTP mesh vs internal event bus (today both a - shared DB and a bidirectional HTTP mesh are in use). -- **ADR-006 — Monorepo tooling & CI/CD:** Nx suitability / fresh setup, affected-only builds, remote - caching, per-host build & deploy, replacing the DigitalOcean-coupled stub workflow. -- **ADR-007 — Configuration & wiring mechanism:** config file format, conditional NestJS dynamic-module - loading per capability, boot-time validation of flags/providers. -- **ADR-008 — Open-source migration strategy:** re-scaffold + incremental module import; database - baseline/squash; deprecated-table (e.g. `directives` / `lorawan-directives`) phase-out; parity + cutover. -- **ADR-009 — Database migration deployment & governance:** operator-controlled (non-push-triggered) - migration application; keep migrations in the monorepo; separate-migrations-repo rejected. +## Follow-up ADRs + +Mechanism-level decisions that this ADR intentionally left to separate documents. Several are now +**Accepted** (summaries below); see each file for full status and any remaining deferred items. + +- **ADR-005 — Inter-host communication:** **Accepted (2026-07-17).** Independent hosts; shared DBs + carry state; residual sync HTTP (prefer worker→`api`); async via per-capability DB jobs; retire + the bidirectional `*_API` mesh; no broker as inter-host bus. See + `docs/architecture/005-inter-host-communication.md`. +- **ADR-006 — Monorepo tooling & CI/CD:** **Accepted.** Nx suitability / fresh setup, affected-only + builds, remote caching, per-host build & deploy, replacing the DigitalOcean-coupled stub workflow. +- **ADR-007 — Configuration & wiring mechanism:** **Accepted.** Config file format, conditional NestJS + dynamic-module loading per capability, boot-time validation of flags/providers. +- **ADR-008 — Open-source migration strategy:** **Accepted (strategy).** Re-scaffold + incremental + module import; database baseline/squash; deprecated-table (e.g. `directives` / `lorawan-directives`) + phase-out; parity + cutover. +- **ADR-009 — Database migration deployment & governance:** **Accepted.** Operator-controlled + (non-push-triggered) migration application; keep migrations in the monorepo; separate-migrations-repo + rejected. + +### Still out of scope (not decided here) - **Per-organization provider overrides** (generalizing ADR-003) as an optional payments feature. - **Dual-ORM consolidation** (TypeORM → Supabase client) as it interacts with capability extraction. diff --git a/docs/architecture/005-inter-host-communication.md b/docs/architecture/005-inter-host-communication.md index 63bf8b9..921d7cc 100644 --- a/docs/architecture/005-inter-host-communication.md +++ b/docs/architecture/005-inter-host-communication.md @@ -1,66 +1,225 @@ # ADR-005: Inter-Host Communication -**Date:** 2026-06-26 -**Status:** Open (deferred from ADR-004; to be resolved in a dedicated session) +**Date:** 2026-06-26 (decided 2026-07-17) +**Status:** Accepted — explicit prerequisite of authoring **002e** (Energy Production Monitoring). +002d (Foundation) completed 2026-07-17 without needing this lock. --- -## Purpose of this document +## Context -This is a **scoped stub** so the question can be picked up later in its own conversation with full -context. It records the problem, the current state, the options, and the constraints inherited from -ADR-004 — but does **not** yet make a decision. +ADR-004 defines a modular monolith: capability modules wired onto thin runtime hosts. The default +deployable footprint is **`api`** (HTTP-facing; tiamat + folded-in talos) and **`worker`** +(background/collector domains; config-driven composition along capability seams). A separate +deployable is justified only by a divergent runtime profile — never by code tidiness. -## Inherited context (from ADR-004) +The OSS migration roadmap (`docs/plans/002-oss-migration.md`) accepts this ADR as a prerequisite of +Production Monitoring import (**002e**): that is the first sub-plan that gives `worker` a real +capability and therefore real cross-host questions — i.e. the first import that applies this +accepted policy. Foundation (**002d**) wired Supabase on both hosts but carried no cross-host +capability traffic. -- Single-track open source, one monorepo, **modular monolith** with capability modules on thin - runtime hosts. -- Default hosts: **`api`** (tiamat + folded talos) and **`worker`** (background/collector domains), - with worker composition **config-driven along capability seams**. -- Decomposition principle: a separate deployable is justified **only by a divergent runtime profile**. -- Configuration is **per-deployment**. +### Legacy coupling (what we are leaving) -## The question +The four legacy apps were coupled **two ways at once**: -How should runtime hosts (and any future split-out services such as device-messaging) communicate — -efficiently and generically — especially when co-hosted on the same platform? +1. **Shared databases (dominant):** the same primary ops DB and the same Timescale instance — + effectively the integration bus. +2. **Bidirectional HTTP mesh (secondary):** `TALOS_API` / `YETI_API` / `LOCH_API` / `TIAMAT_API` + (+ API keys). Many of those edges were artifacts of the 4-app split (e.g. tiamat→talos) and + collapse to in-process calls once modules share a host. A smaller set are true residuals + (e.g. Epicollect sync calling `user-admin/create-customer`; collector jobs updating ops status + columns; notification send pipeline owning `notifications` rows). -## Current state (as observed) +Legacy also used Valkey/Redis heavily inside device-messaging queues, and Socket.IO for +client-facing realtime — neither is a general host↔host bus for the OSS baseline. -The apps are coupled **two ways at once**: +Company deploy today (DigitalOcean App Platform: `api` + *n* workers under one App, private VPC) +makes **private HTTP** a natural residual channel; it does not require a broker, and it does not +excuse missing app-level machine auth. + +### Constraints -1. **Shared databases (dominant coupling):** all apps connect to the *same* primary DB twice — - `NXT_DB_*` (legacy TypeORM) **and** `SUPABASE_*` (Supabase client) — plus the same - `NXT_TIMESCALE_DB_*`. The database is effectively the integration bus today. -2. **HTTP mesh (secondary):** `tiamat` holds `TALOS_API` / `YETI_API` / `LOCH_API`; `loch` and `yeti` - each hold `TIAMAT_API` + `TIAMAT_API_KEY`. A bidirectional, API-key-authenticated HTTP mesh sits on - top of the shared DB. +- **Self-hostability:** lean default footprint (`api` + `worker` + DBs); no mandatory broker for + inter-host communication (ADR-004). +- **Genericness:** mechanisms must not hardcode NXT-specific topology. +- **Explicit seams:** cross-host cost and availability must remain visible at call sites. +- **Capability boundaries:** respect ADR-004 / ADR-013 ownership; communication is not a way to + smuggle capability logic across the wrong module. +- **Flexibility with accountability:** strong defaults; exceptions allowed when justified and + **recorded** (this ADR’s exception bar, or the importing sub-plan decisions log) — not silent + mesh creep. -Note: `iovalkey` (a Valkey/Redis client) is already a dependency, and Socket.IO/WebSockets are in use. +## Decision -## Options to consider (not yet decided) +### 1. Hosts are independent by default; shared DBs carry state -1. **In-process calls when co-located.** Because of the modular monolith, many cross-host "calls" - collapse into direct service calls inside the same process once capabilities are wired onto a host. - Reduces the mesh to only genuinely cross-process interactions. -2. **Shared DB as the integration bus.** Lean on the database (and/or Postgres `LISTEN/NOTIFY`) for - coordination. Simple, no new infra, but couples hosts through schema and can hide implicit contracts. -3. **Internal event bus.** Introduce Valkey/Redis (already a dependency) or a lightweight broker for - pub/sub between hosts. Decouples producers/consumers; adds infra a self-hoster must run. -4. **Keep/standardize the HTTP mesh.** Formalize internal APIs with typed contracts and shared API - keys. Familiar, but adds network hops and an availability dependency between hosts. +`api` and `worker` are independent composition roots. Coordination is primarily through **shared +databases** (ops Supabase/`public`, Timescale, and any specialty stores a capability owns). +Residual host↔host calls are **allowed but exceptional** — the intent is maximum independence, not +a zero-RPC fantasy. The residual count need not be known up front; capability imports apply this +policy as edges appear. -## Constraints / evaluation criteria +**Client realtime** (Socket.IO, Supabase realtime to frontends) is **out of scope** for this ADR — +that is host→browser, not host→host. -- **Self-hostability:** every added piece of infra is a burden on the default operator; prefer the - smallest viable footprint (see ADR-004 lean-default goal). -- **Genericness:** the chosen mechanism must not hardcode NXT-specific topology. -- **Co-location efficiency:** avoid unnecessary network hops when hosts run on the same platform. -- **Capability boundaries:** communication should respect the capability seams from ADR-004. +### 2. Legitimate residual patterns -## To decide in the dedicated session +| Pattern | Meaning | Baseline mechanism | +|---|---|---| +| **Sync orchestration** | Caller needs another host’s business logic *and* an in-line result/error (e.g. Epicollect → create-customer) | Authenticated HTTP | +| **Async kick** | Caller wants work done later; does not need the outcome in the same request | Prefer **DB job / outbox** (per capability); HTTP only transitional/escape | +| **Shared-state dual access** | Both hosts read/write stores they are allowed to touch | Not “messaging” — just dual access under §7 | -- The default communication mechanism (and when it is allowed to differ). -- Whether to remove or formalize the existing HTTP mesh. -- Whether an event bus is justified for the OSS baseline or is an opt-in for larger deployments. -- How co-located vs distributed hosts are abstracted so code does not care which it is. +### 3. Sync orchestration = authenticated internal HTTP + +- **Reuse normal `api` routes** when they are a fit for machines (e.g. existing create-customer), + authenticated with machine credentials. +- Add a **narrow dedicated surface** (e.g. `/internal/...`) only when the public/human route is + wrong-shaped (authz, payload, side effects). Do not invent a second full API up front. +- Auth is **ADR-014** (`X-API-KEY` / `api_keys`, evolving scopes + route allowlist). No parallel + “internal shared secret” system. Private network / VPC is defense-in-depth, not a substitute. + +### 4. Async kicks prefer per-capability DB jobs + +- Prefer **per-capability / per-domain tables** (today’s `notifications` lifecycle is the + archetype; `pd_*` job state is similar). No mandatory global platform outbox in Foundation. +- Shared claim/retry **helpers** may appear when a second real user shows the same shape; do not + design a grand job bus for 002e. +- Fire-and-forget HTTP to enqueue work is a **transitional/escape** path only when no job table + exists yet for that flow. + +**Postgres `LISTEN`/`NOTIFY`** (or equivalent DB wake signals) is **not** a baseline mechanism. +Hosts poll/claim on their own schedule (cron/loops) or use HTTP for sync. Revisit later as an +optimization if a hot path demands it. + +### 5. Prefer unidirectional residual HTTP (workers → `api`) + +**Default:** in-stack residual HTTP is **worker (or other non-`api` host) → `api`**. Baseline +config gives those callers **`api`’s base URL + machine credential** — not a mesh of every host +URL. + +**`api` → worker HTTP** is allowed only when **all** of the following hold (or are explicitly +waived in the importing sub-plan), and the exception is **recorded**: + +1. Sync orchestration is truly required (in-line result), **and** +2. The work cannot live on `api` and cannot be a DB job the worker claims, **and** +3. The callee’s runtime profile justifies a separate host (ADR-004), **and** +4. The endpoint and rationale are written down (this ADR’s exceptions note or the sub-plan + decisions log). + +Operationally an exception is a **named** internal client + URL/env for that one surface — not a +return to a full bidirectional host matrix. + +### 6. No location-transparent RPC layer + +- **Same process (same host):** call Nest services in-process. Former HTTP between modules that now + share a host (e.g. talos folded into `api`) must not remain HTTP. +- **Cross-process:** use an **explicit** HTTP client or an explicit DB job write at the seam. +- Do **not** build a transport-switching port that hides whether a collaborator is local or remote. + Seams should look like seams. + +“Same toy box” means the same running process — not “merge `api` and `worker`.” Default topology +remains two hosts; modules that are worker-only by design may safely assume they dial `api`. + +### 7. Capability-owned ops DB writes (not “api sole writer”) + +Legacy already proved that “only tiamat writes ops” is tedious and routinely violated for good +reasons (telemetry status columns, notification send pipeline, field-ops job state). Baseline: + +- **Writers follow behavior ownership (ADR-013):** the host that runs a behavior’s writer may + write the tables/columns that behavior owns — including workers writing ops DB when appropriate. +- **HTTP → `api`** when the mutation needs shared orchestration or invariants (create-customer, + payouts, issue recalculation, etc.). +- Workers may write, without apology: + - **Job / outbox / pipeline state** they own (e.g. `notifications` status), + - **Narrow telemetry / denormalized status** that is a side-effect of collection (e.g. DCU + online flags, meter last-consumption timestamps, production-owned `grids` monitor fields), + - **Specialty stores** they own (e.g. Timescale for Production Monitoring). +- This is **not** “any host may write anything.” Random cross-cutting writes of another + capability’s orchestration path remain forbidden; use HTTP or move the behavior. + +### 8. Retire the bidirectional `*_API` mesh + +The legacy env matrix (`TALOS_API` / `YETI_API` / `LOCH_API` / `TIAMAT_API` + keys) is **migration +debt**, not a pattern to formalize. As each capability is imported: + +- Delete obsolete host-to-host URLs. +- Keep **`api` base URL + machine credential** on in-stack callers that need sync orchestration. +- Add **named reverse URLs** only for recorded §5 exceptions. +- Integrable extracted services (§9) use their own client config (service URL + callback + registration), not the old mesh variable names. + +### 9. `worker` is not an HTTP peer server by default + +Default `worker` shape: **cron / consumer** — outbound HTTP to `api` when needed; inbound HTTP +limited to **health/probes** (and whatever the deploy platform requires). + +Narrow inbound HTTP on a worker is allowed as a **recorded exception**, especially when a +particular worker composition needs it. Time and composition will force some exceptions; the +default remains “not a peer API server.” + +### 10. No broker as the inter-host integration bus + +Valkey/Redis (or any message broker) is **not required** for `api`↔`worker` communication in the +OSS baseline. Async prefers DB jobs; sync uses HTTP. + +A broker may still appear: + +- as **private implementation** inside an extracted service (device-messaging in-flight state — + ADR-010), or +- as an **opt-in** for large deployments later, + +but ADR-005 does not make a broker the platform mesh. + +### 11. Integrable extracted services (device-messaging) are not in-stack peers + +**In-stack hosts** (`api` ↔ `worker`): same household — swap phone numbers under §5–§6; fridge +notes (DB) are normal. + +**Integrable extracted services** (device-messaging per ADR-010, and similar future split-outs): +more like a delivery company any adopter can hire: + +- Call **their** HTTP API to enqueue/inspect/cancel work. +- **Register callback URL(s)** up front (or equivalently configure webhooks) so they know where + to deliver updates — the callback need not be on every request. +- Their Redis/Valkey (or other volatile store) is **private** to that service. +- Interactions with third-party platform components (Chirpstack, STS token generator, …) are that + service’s outbound integrations, not a second nxt host mesh. + +Endpoint and webhook details remain owned by **ADR-010**. This ADR only classifies the +relationship so imports do not pretend device-messaging is “just another worker.” + +## Consequences + +### Positive + +- Lean self-host default: `api` + `worker` + DBs; no mandatory broker or location-transparent RPC. +- Most legacy mesh edges disappear (same-host in-process) or become DB dual-access / job tables. +- Residual sync paths stay simple (HTTP + ADR-014) and fit DO VPC and docker-compose private + networks alike. +- Write rules match production reality (telemetry + job pipelines) without reintroducing + “everything must finish via api.” +- Device-messaging stays adoptable outside the nxt stack while sharing one cross-host *policy* + family (HTTP + callbacks), not a special platform bus. + +### Negative / Risks + +- Capability imports must apply judgment: HTTP vs DB job vs direct write — wrong choice recreates + mesh tedium or hidden coupling. Mitigated by this ADR’s tables and the migration decisions log. +- Unidirectional HTTP default will need recorded exceptions; discipline required so exceptions do + not become the mesh again. +- Per-capability job tables may duplicate claim/retry patterns until a second user justifies shared + helpers. +- Direct worker writes to ops columns on shared entities need clear ownership (ADR-013) so + “status side-effect” does not become unbounded schema coupling. + +## Related + +- **ADR-004** — hosts, decomposition principle, lean default footprint, capability map. +- **ADR-007** — per-deployment config; where `api` base URL / machine credentials are supplied. +- **ADR-010** — device-messaging extraction; HTTP API + webhook callbacks; private Redis. +- **ADR-012** — cutover notes `api`/`worker` flip asymmetry; database as primary integration point. +- **ADR-013** — capability-owned behavior over shared entities (write/ownership companion). +- **ADR-014** — machine credentials, scopes, route allowlist for residual HTTP. +- **002e** — Energy Production Monitoring import; first major consumer of this ADR. diff --git a/docs/architecture/007-configuration-and-wiring-mechanism.md b/docs/architecture/007-configuration-and-wiring-mechanism.md index 17f4124..8303fa2 100644 --- a/docs/architecture/007-configuration-and-wiring-mechanism.md +++ b/docs/architecture/007-configuration-and-wiring-mechanism.md @@ -338,8 +338,9 @@ Once a host wires Foundation/Supabase infrastructure, it **requires DB connectiv on missing `SUPABASE_*` env** at boot (`requireEnv` in the Supabase provider). "Evaluation mode" (decision 4 — "a bare clone runs") is clarified to mean **capabilities off + a local Supabase**, not **DB-less**. Both `api` and `worker` wire Supabase infra in 002d, so both require DB env from then -on. Per-host env differs: admin-client vars (`SUPABASE_API_URL`, `SUPABASE_SERVICE_ROLE_KEY`) on -both; `SUPABASE_ANON_KEY` + `SUPABASE_JWT_SECRET` only where auth runs (`api`). `SupabaseService` +on. Per-host env differs: admin-client vars (`SUPABASE_URL`, `SUPABASE_SECRET_KEY`) on +both; `SUPABASE_PUBLISHABLE_KEY` + JWT verification (`SUPABASE_JWKS_URL` preferred; +`SUPABASE_JWT_SECRET` legacy fallback) only where auth runs (`api`). `SupabaseService` builds its client in a **provider** (no import-time `export const supabase` singleton); the query-type-generation shortcut returns later as a **type-only probe** (no runtime client). diff --git a/docs/architecture/012-company-cutover-strategy.md b/docs/architecture/012-company-cutover-strategy.md index 8c4ffea..7116cf1 100644 --- a/docs/architecture/012-company-cutover-strategy.md +++ b/docs/architecture/012-company-cutover-strategy.md @@ -21,8 +21,9 @@ available/considered when ADR-008 was authored: - **Host consolidation:** the 4 legacy apps (`tiamat`, `talos`, `loch`, `yeti`) collapse into **2** runtime hosts per ADR-004/ADR-005 — `api` (`tiamat` + folded-in `talos`) and `worker` (background/collector domains, `loch` + `yeti`). Cutover is a 2-deployable flip, not a 4-app one. -- **The database is the primary integration mechanism** (ADR-005: "the database is effectively the - integration bus today"). The HTTP mesh between apps is secondary and shrinks as hosts merge. +- **The database is the primary integration mechanism** (ADR-005 Accepted: shared DBs carry state; + residual HTTP is exceptional and prefer worker→`api`). The legacy bidirectional HTTP mesh is + retired as a pattern and shrinks further as hosts merge. Beyond the backend, **all 5 frontends** (each with a single `VITE_API_URL` *and* direct `VITE_SUPABASE_URL`/anon-key access, bypassing the API), **Grafana** (`grafana_readonly` role), and **Make.com** (`make_readonly` role + grid webhook triggers) all depend directly on the same @@ -104,8 +105,8 @@ sub-plans describe reality rather than speculation. (one point, covers all 5 frontends) → activate pre-staged Grafana/Make.com updates → smoke test → resume traffic. 5. Post-cutover: bake period with elevated monitoring, then decommission old infra, retire the - private repo (ADR-008 exit condition), resolve ADR-005's open inter-host-mesh question, close out - the roadmap. + private repo (ADR-008 exit condition), close out the roadmap. (ADR-005 inter-host policy was + locked 2026-07-17 — flip coordination stays DB-primary with residual HTTP under that ADR.) ## Consequences @@ -140,8 +141,9 @@ sub-plans describe reality rather than speculation. - The just-in-time "Parity verification & company cutover" sub-plan is authored — reconcile this ADR's illustrative sequence with it; the sub-plan supersedes it where they conflict. -- ADR-005's open inter-host communication question is decided — affects whether `api`/`worker` need - coordination beyond the shared database during the flip. +- ADR-005 was Accepted (2026-07-17) — flip coordination remains DB-primary; residual HTTP follows + that ADR (prefer worker→`api`; no mandatory broker). Revisit only if a recorded exception + changes cutover sequencing. - The maintenance-window tolerance changes (e.g., a new integration is added that cannot tolerate any downtime), invalidating decision 6. - **NXT Grid production Postgres major-version lag** — OSS baseline targets **Postgres 17** (002b @@ -154,7 +156,8 @@ sub-plans describe reality rather than speculation. - **ADR-004** — target architecture; the `api`/`worker` host consolidation this ADR's flip mechanics depend on. -- **ADR-005** — inter-host communication; database-as-integration-bus context. +- **ADR-005** — inter-host communication (Accepted); shared DBs + residual HTTP policy for + `api`/`worker` during and after cutover. - **ADR-008** — migration strategy; the exit condition this ADR operationalizes. - **ADR-009** — migration deployment & governance; the safety rails (PITR, forward-only) this ADR builds on. diff --git a/docs/architecture/014-api-key-and-machine-credentials.md b/docs/architecture/014-api-key-and-machine-credentials.md new file mode 100644 index 0000000..4884625 --- /dev/null +++ b/docs/architecture/014-api-key-and-machine-credentials.md @@ -0,0 +1,142 @@ +# ADR-014: Machine Credentials — API Keys, Scopes, Postgres Roles, and MCP + +**Date:** 2026-07-17 +**Status:** Accepted (direction); implementation lands incrementally with consumers +**Related:** ADR-004 (Foundation / capabilities), ADR-007 (wiring / secrets), 002d Foundation +import (`auth`, `api-keys`), schema deviation register #2/#3 (company readonly roles) + +--- + +## Context + +Foundation authenticates humans via Supabase JWTs (bearer) and machines via `X-API-KEY` +(`public.api_keys`). The API-key path currently resolves a key to an `AuthenticatedUser` +(account / org / member claims) using the **admin** Supabase client and does **not** attach a +per-request user client. Handlers that then use `service_role` bypass RLS. + +That is acceptable for a few trusted local/dev machine keys. It becomes dangerous as: + +- More integrations need narrow, specific data. +- MCP (and similar agent) servers grow tools and call many endpoints. +- The same powerful key (e.g. platform-operator SUPERADMIN) is reused for every tenant’s MCP + session, with “who may see what” enforced only in application code. + +Separately, the legacy company database provisioned **Postgres login roles** +(`grafana_readonly`, `make_readonly`, `snaplet_readonly_2`) for direct DB access. Those are a +different credential family from Nest API keys and were **omitted from the OSS baseline** +(parameterized company infra). + +This ADR records the credential model, when to use which mechanism, and the hardening path — +including for MCP’s two audiences (internal platform ops vs customer-org). + +### Why this document is in the open repo + +Security **design** (roles, RLS, scopes, least privilege) is already implied by schema and auth +code. Publishing the strategy: + +- Helps adopters avoid shipping a single god-key. +- Is not a substitute for secrets management; **keys, passwords, and company recipes stay out of + git**. + +Do **not** put production key material, allowlists of real integrations, or exploit runbooks here. +Company-specific Postgres role recipes remain under operator docs (e.g. +`docs/database/optional/`), not the generic baseline migration. + +--- + +## Decision + +### 1. Three credential layers (do not conflate “role”) + +| Layer | What it is | Examples | RLS? | +|---|---|---|---| +| **A. Postgres login roles** | DB users that connect with a DB password (Grafana, Make, Snaplet, …) | `grafana_readonly`, `make_readonly` | Policies may target the role; often `USING (true)` read-all for that tool | +| **B. Supabase Data API JWT roles** | Role PostgREST assumes from the JWT | `anon`, `authenticated`, `service_role` | `authenticated` / `anon`: RLS applies. `service_role`: **RLS bypassed** | +| **C. App API keys** | Product machine credentials in `api_keys`, validated by Nest | `X-API-KEY`, seed `dev-api-key-…` | Only if Nest then uses a **user-scoped** client (B=`authenticated`) with that principal’s claims | + +Nest `SupabaseService.adminClient` is layer **B / `service_role`**. Bearer auth’s per-request +client is **B / `authenticated`**. API keys are layer **C** until mapped onto B. + +### 2. When to use which strategy + +| Use case | Prefer | Avoid | +|---|---|---| +| Human operators / dashboards (Pegasus, etc.) | Bearer JWT → `authenticated` + RLS | Long-lived API keys for interactive use | +| Narrow machine job needing one slice of data (report, webhook, single integration) | **Scoped API key** on a least-privilege service account; route allowlist; prefer RLS-bound session | Platform SUPERADMIN key; admin client for the whole request path | +| MCP / agent tools — **customer** (own org’s grids) | Per-tenant (or per-customer-user) session: org-bound claims + **RLS**; scopes limited to MCP tool surface | One shared platform key for all customers; “filter org in MCP code only” | +| MCP / agent tools — **internal team** (cross-org / all grids) | Dedicated **platform-ops** service principal (or platform-operator membership) with **explicit** broad scopes; few keys; audited | Reusing a human SUPERADMIN’s personal key; silent `service_role` for every tool | +| BI / Grafana / ETL reading many tables directly | Postgres role (**A**) + optional `TO ` RLS policies; company recipe | Nest API keys “because we already have them” | +| DB cloning / Snaplet-style tooling | Postgres role with needed schema grants (legacy pattern); company recipe | Service role in app config handed to third-party SaaS carelessly | +| Pre-auth / privileged Nest internals (validate API key row, invite user, …) | Admin client (`service_role`) **only** for that privileged step | Admin client as default for all handlers after auth | +| Break-glass / migrations | `service_role` or postgres superuser, offline or tightly gated | Everyday MCP or integration traffic | + +### 3. Scopes on API keys + +Scopes answer **which capabilities/routes** a key may invoke (e.g. `mcp:grids:read`, +`reports:revenue:read`). They grow with MCP tools and must be **granted deliberately**. + +Scopes do **not** replace tenancy. “Which org’s rows?” remains JWT / `app_metadata` claims + RLS +(or an explicit platform-ops policy set). + +**Growth rule:** new MCP tool → new scope(s) → grant only to principals that need them. Internal +ops keys may accumulate broader scopes; customer keys stay on a small allowlist. + +### 4. MCP must not share one god-key across tenants + +If one platform API key backs MCP for every customer and the server filters access in app logic, +that is **authorization in the app instead of (or duplicated poorly beside) RLS**. It does not +scale: every new tool must remember checks; agents can be steered into over-fetching. + +**Target:** + +- **Customer MCP:** session identity = that org (customer key or short-lived token); DB via + `authenticated` + RLS. +- **Team MCP:** separate platform-ops principal; broad scopes by design; still prefer + RLS-bound policies for platform ops over blanket `service_role` for tool handlers. + +### 5. Hardening direction (implementation order) + +1. **Policy (now):** Never issue platform-operator SUPERADMIN keys to customer MCP or narrow + integrations. Dedicated service accounts; seed platform key stays **local/dev only**. +2. **Route allowlist:** API-key (and later scoped) principals may only hit approved surfaces + (e.g. MCP / machine routes), not the full human API by default. +3. **RLS-bound machine sessions (near-future — do next for API-key hardening):** After API-key + (or token-exchange) auth, attach a user-scoped Supabase client with that principal’s JWT + claims (`AuthenticatedUser.supabase`) so request handlers can exercise RLS — **not** admin — + except true privileged steps. Today `ApiKeyStrategy` returns claims only (no client); + machine handlers that need PostgREST fall through to `service_role` (high privilege). Confirmed + during 002d Task 9 (`user-admin` / create-customer machine callers). Land this **before** + growing more machine-callable data paths (e.g. Task 10 grids reads). Privileged Auth Admin + surfaces may stay whole-method admin regardless. +4. **Scopes on `api_keys`:** Persist and enforce scopes in Nest (guard/interceptor). Taxonomy + owned by Foundation + each capability that exposes machine APIs. +5. **Optional later:** Short-lived token exchange (key → JWT with scopes + expiry); separate MCP + gateway host; OAuth2 client-credentials for external MCP clients. + +### 6. Postgres readonly roles stay company-optional + +`grafana_readonly` / `make_readonly` / `snaplet_*` remain **out of the OSS init migration**. +Operators who need them apply recipes; they are not a substitute for designing Nest API keys or +MCP tenancy. + +--- + +## Consequences + +- Adopters get a clear map: direct DB tools ≠ Nest API keys ≠ Supabase `service_role`. +- MCP product work must budget for **per-tenant credentials** (or equivalent sessions), not only + tool implementations. +- Foundation will grow `api_keys` (scopes) and auth middleware; capabilities register scopes for + their machine endpoints rather than inventing parallel auth. +- Until items 3–4 land, treat existing API keys as **high privilege** and minimize their use and + distribution. + +--- + +## Notes + +- Seed key `dev-api-key-platform-superadmin` is for local Foundation auth tests only. +- Manual API checks: `apps/api/http/` (httpYac); prefer bearer for human-shaped tests. +- Related omission: schema deviation register #2 / #3 (Grafana / Make roles). +- 2026-07-17 — §5.3 elevated to **near-future** after 002d Task 9: pointer also on + `apps/api/.../auth/api-key.strategy.ts` and 002d decisions log. diff --git a/docs/deployment/supabase.md b/docs/deployment/supabase.md index 176580f..93e6a50 100644 --- a/docs/deployment/supabase.md +++ b/docs/deployment/supabase.md @@ -16,55 +16,76 @@ Check the location too, ## 4. Connect and apply migrations -From the repo root (Supabase CLI pinned in `package.json`; make sure you have run `pnpm install` first. -Invoke via `pnpm supabase`): +From the repo root (Supabase CLI pinned in `package.json`; make sure you have run `pnpm install` +first). Prefer `pnpm exec supabase` so you always hit the pinned binary: ```bash -pnpm supabase login # If not already logged in -pnpm supabase link --project-ref -pnpm supabase db push +pnpm exec supabase login # If not already logged in +pnpm exec supabase link --project-ref +pnpm exec supabase db push ``` -Local development: `pnpm supabase start` applies migrations automatically. +### Local development -### Verify Data API grants (optional smoke test) +```bash +pnpm exec supabase start # applies migrations on a fresh local DB +pnpm exec supabase db reset # recreates DB, re-applies migrations, then runs seed.sql +``` + +`supabase/config.toml` has `[db.seed]` enabled with `sql_paths = ["./seed.sql"]`. Seed runs on +**`db reset`**, not on every plain `start` against an already-initialized volume. + +## 5. Local-dev seed (Foundation bootstrap) -After `db push`, confirm explicit table grants work (register #33) — dashboard “Data API enabled” -alone is not enough: +Migrations ship **schema only**. For **local development**, Foundation fixtures live in +`supabase/seed.sql` and grow as capability imports land. This seed is **not** for remote/prod — +production still needs an operator-specific one-time bootstrap outside this file. + +### Reset and seed ```bash -curl -s -o /dev/null -w "%{http_code}\n" \ - -H "apikey: " \ - -H "Authorization: Bearer " \ - "https://.supabase.co/rest/v1/organizations?select=id&limit=1" +pnpm exec supabase db reset ``` -Expect **200** (empty `[]` is fine before bootstrap). +That drops the local DB, applies migrations, then runs `supabase/seed.sql`. + +### What the seed creates + +| Fixture | Details | +|---------|---------| +| Orgs | `1` **NXT Platform Operator** (`PLATFORM_OPERATOR`) + wallet; `2` **NXT Solar Developer** (`SOLAR_DEVELOPER`) + wallet | +| Auth users | `superadmin@nxt-platform.com` / `superadmin`; `admin@nxt-solar.com` / `admin` (confirmed; fixed UUIDs in seed) | +| Accounts | Created by `handle_new_user` on auth insert; `organization_id` set via `handle_update_user` when `app_metadata` is applied | +| Claims | JWT `app_metadata`: `account_id`, `account_type` (`MEMBER`), `member_type`, `organization_id` | +| Members | Platform → `SUPERADMIN` (org 1); Solar → `DEVELOPER` (org 2) | +| API key | `dev-api-key-platform-superadmin` on the platform superadmin account (`X-API-KEY` / auth tests) | +| Grid | **Demo Solar Grid** on org 2 | + +Invite flow mirrored in SQL: insert auth user → account trigger → update `raw_app_meta_data` → +account `organization_id` sync → insert `members`. -**API keys (Supabase dashboard):** Settings → API Keys. New projects show **publishable** and -**secret** keys by default. The familiar **anon** / **service_role** JWT pair lives under the -**Legacy API Keys** tab — use either the secret key or legacy `service_role` for the curl above. +### Sign-in (local) -## 5. Bootstrap data +Use the emails/passwords above against the local Auth API / Studio +(`pnpm exec supabase status` for URLs and keys). Prefer the seeded API key for machine auth +smoke tests (`X-API-KEY` / httpYac under `apps/api/http/`). Api auth also needs +`SUPABASE_PUBLISHABLE_KEY` plus `SUPABASE_JWKS_URL` (or `SUPABASE_JWT_SECRET`) in +`apps/api/.env` — see `apps/api/.env.example`. -Once per fresh database we need to enter the minimum viable amount of data to get up and running. -Concretely, this means: -- The operator `organization` -- A `wallet` for that organization -- A supabase `user` (which automatically creates a row in the accounts table too) -- Make that user a `SUPERADMIN` `member` of the operator organization +### REST / httpYac (manual API checks) -### Bootstrap Operator (Admin) Organization -Migrations ship schema only. Before the app is usable, add **one platform operator organization** -and **its wallet** (1:1 via `wallets.organization_id`). Only one `PLATFORM_OPERATOR` org is -allowed. +File-based requests live under `apps/api/http/` using **httpYac** (`anweber.vscode-httpyac`). +Shared login via `# @import ./login.http` + `# @ref loginPlatform` — see `apps/api/http/README.md`. +Uninstall Huachao REST Client if present (conflicts on `.http` files). -**Dashboard** — Table Editor → `organizations`: insert a row with `organization_type = PLATFORM_OPERATOR`. Then `wallets`: insert a row with `organization_id` set to that org's `id`. +### Automated tests (`api`) -### Bootstrap Superadmin Organization Member -In the Supabase dashboard, go to Authentication → Users → Add user → Create new user. Enter your -email and password and check **Auto Confirm User**. +Specs live under `apps/api/test/` (not co-located with `src/`): -If you now go to 'Table editor' -> 'accounts' you see that your account is created. Update the `organization_id` column with the id of your Platform Operator Organization. +| Target | Command | Needs local Supabase + seed? | +|--------|---------|------------------------------| +| Unit (default / lint bar) | `pnpm exec nx test api` | No | +| Integration | `pnpm exec nx run api:test-integration` | Yes | +| E2E | `pnpm exec nx run api:test-e2e` | Yes | -After this you can make yourself the SUPERADMIN member of the organization, by going to 'Table editor' -> 'members' -> Insert -> 'Insert row'. Make sure you select `SUPERADMIN` as `member_type` and `account_id` to the `id` of the account that was just created. +Suites under integration/e2e skip when `SUPABASE_URL` / `SUPABASE_SECRET_KEY` are missing. diff --git a/docs/plans/002-oss-migration.md b/docs/plans/002-oss-migration.md index 90817b7..d23c8d6 100644 --- a/docs/plans/002-oss-migration.md +++ b/docs/plans/002-oss-migration.md @@ -1,7 +1,7 @@ # Open-Source Migration — Roadmap -**Decisions:** ADR-004 (architecture), ADR-005 (open — see decision points), ADR-006 (tooling/CI), -ADR-007 (config), ADR-008 (migration strategy), ADR-009 (migration governance) +**Decisions:** ADR-004 (architecture), ADR-005 (inter-host communication — Accepted 2026-07-17), +ADR-006 (tooling/CI), ADR-007 (config), ADR-008 (migration strategy), ADR-009 (migration governance) **Plan number:** 002 (family) **Created:** 2026-07-08 **Status:** In progress @@ -190,8 +190,8 @@ Sub-plans live in `docs/plans/002-oss-migration/`. Keep this table current. | 002a | Repo restructure (Step 0) | Create `oss-migration` branch; atomic rename-only move to `legacy/`, freeze notice, verification | Completed | | 002b | Database baseline | Inventory, four-bucket classification, canonical init migration, A/B diff verification (old chain from `legacy/supabase/migrations`), deviation register, staged rollout (local → fresh Supabase project → adopter) | **Completed** (2026-07-13) | | 002c | Scaffold, pipeline & config skeleton | Fresh Nx 23 workspace (ADR-006), CI with affected + type-drift guard, Dockerfile, DO deploy baseline, ADR-007 config loader/schema skeleton, hooks reintroduction | **Completed** (2026-07-14) | -| 002d | Platform core import (Foundation) | Import the always-on Foundation (auth, api-keys, accounts, members, organizations, user-admin, grids) over infra (Supabase provider, HTTP, pino logging); retire ops-DB TypeORM; drop `deployment` config group; explicit per-host composition | **In progress** — authored 2026-07-15 | -| 002e | Energy Production Monitoring import | Capability (1), incl. TimescaleDB estate; **exclude device registry** (register #12 — see assumption #10) | Just-in-time — not yet authored | +| 002d | Platform core import (Foundation) | Import the always-on Foundation (auth, api-keys, accounts, members, organizations, user-admin, grids) over infra (Supabase provider, HTTP, logging); retire ops-DB TypeORM; drop `deployment` config group; explicit per-host composition | **Completed** (2026-07-17) | +| 002e | Energy Production Monitoring import | Capability (1), incl. TimescaleDB estate; **exclude device registry** (register #12 — see assumption #10). **Prerequisite:** **ADR-005** (Accepted 2026-07-17) | Just-in-time — not yet authored | | 002f… | Remaining capability imports | (2) Metering, (3) Payments, (4) Notifications, (5) Field Ops, (6) Automation — one sub-plan each; IDs assigned when authored | Just-in-time — not yet authored | | (last) | Parity verification & company cutover | Parity checklist, company DB convergence migration (from deviation register), cutover, private-repo retirement. Strategy-level decisions (host flip mechanics, rollback stance, maintenance window) recorded early in **ADR-012** — reconcile with it when authoring | Just-in-time — not yet authored | @@ -200,7 +200,7 @@ Sub-plans live in `docs/plans/002-oss-migration/`. Keep this table current. | # | Item | Standing position | Resolves when | |---|---|---|---| | 1 | **Device-messaging (plan 001 / ADR-010)** | Runs as a parallel effort; this migration treats device-messaging as **arriving as an external service**. The Metering import sub-plan depends on plan 001 being (near) complete | Checked when the Metering sub-plan is authored | -| 2 | **ADR-005 inter-host communication** | Deliberately open. Groundwork (002a–002c) does not need it; 002d wires `worker`'s Foundation infra (Supabase) but gives it no cross-host capability traffic, so it is still not needed. Much of the current HTTP mesh collapses into in-process calls in the modular monolith | **Explicit prerequisite of authoring 002e** (Production Monitoring) — the first sub-plan that gives `worker` a real capability. Lock ADR-005 before/at 002e authoring | +| 2 | **ADR-005 inter-host communication** | **Accepted (2026-07-17).** Independent hosts; shared DBs carry state; residual sync HTTP (prefer worker→`api`, ADR-014 auth); async via per-capability DB jobs; retire bidirectional `*_API` mesh; no broker / `LISTEN`/`NOTIFY` baseline; capability-owned ops writes (ADR-013); device-messaging = integrable HTTP+callbacks (ADR-010) | **Done** — apply during 002e+ capability imports | | 3 | **TimescaleDB schema** | Out of the 002b baseline (Supabase primary DB only). Belongs to the Production Monitoring capability import, where its consumers live | 002e authoring | | 4 | **Production schema = migrations** | Production has had no schema changes outside `supabase/migrations`. Certified by read-only drift check at 002b Task 1 (2026-07-08) | Done (002b Task 1) | | 5 | **Adopter requirements surface during execution** | Outside requirements (renames, omissions, additions) are discovered *while executing* sub-plans, not gathered up-front — and always recorded (see below) | Continuous | @@ -234,6 +234,7 @@ cutover. Weigh each rename individually; record all of them. ## Related documents - **ADR-004** — target architecture; capability map; three-tier flags. +- **ADR-005** — inter-host communication (Accepted 2026-07-17); apply from 002e onward. - **ADR-006** — tooling/CI decisions executed by 002c. - **ADR-007** — config mechanism executed by 002c (skeleton) and each capability import (flags). - **ADR-008** — the four-phase strategy this roadmap operationalizes. @@ -288,3 +289,26 @@ cutover. Weigh each rename individually; record all of them. "no-cracks" governance and standing assumption 12 (module-split → ADR-013); updated assumptions 2 (ADR-005 = explicit 002e prerequisite) and 9 (deployment group dropped). Foundation noted as a single-pass exception to the dual-pass import model. +- 2026-07-16 — **002d Task 4 done** (infra + explicit composition): Supabase provider, HTTP on both + hosts, Nest `Logger` + `GlobalLoggerModule` stub (nestjs-pino deferred), demo + `deployment` + config group removed, env rename (`SUPABASE_URL` / `SUPABASE_SECRET_KEY`). Next: Task 5 seed. +- 2026-07-16 — **002d Task 5 done** (seed harness): local `supabase/seed.sql` + + `docs/deployment/supabase.md` §5. Next: Task 6 (`accounts` + `api-keys`). +- 2026-07-17 — **002d Task 7 signed off** (auth): Passport strategies + guard, `AuthenticatedUser`, + JWKS/`jose`, inline API-key select, `/auth/me` + httpYac, ADR-014, CORS/`ValidationPipe`. + Seed verify (bearer + `X-API-KEY`) passed. Next: Task 8 (scoped test spike). +- 2026-07-17 — **002d Task 8 adopted** (test spike): `apps/api/test/` layout; unit default / + integration+e2e opt-in; ApiKeyStrategy + thin X-API-KEY e2e green. Next: Task 9. +- 2026-07-17 — **002d Task 8 signed off.** Next: Task 9 (`organizations` + `user-admin`). +- 2026-07-17 — **002d Task 9 signed off:** Nest `organizations` skipped; `user-admin` imported + (whole-method admin); `CreateCustomerDto` in `@nxt/core`; `handleSingle` + Cloudflare→503. + Near-future: ADR-014 §5.3 API-key → RLS-bound user client. Next was Task 10 (`grids`). +- 2026-07-17 — **002d Task 10 signed off:** Nest `grids` skipped (table/RLS/seed stay). Next: + Task 11 (close-out; grids = annotate service + remove `GET /:id` only). +- 2026-07-17 — **002d Completed** (Task 11 close-out): legacy Foundation deletes per ledger; + grids annotated + `GET /:id` removed; lint bar green; `demo`/`deployment` gone. Next was: + lock ADR-005, then author **002e**. +- 2026-07-17 — **ADR-005 Accepted** (inter-host communication). Independent hosts + shared DBs; + residual HTTP prefer worker→`api` (ADR-014); async per-capability DB jobs; retire `*_API` mesh; + no broker/`LISTEN` baseline; capability-owned ops writes; device-messaging = HTTP+callbacks. + Assumption 2 → Done. Next: author **002e** (Energy Production Monitoring). diff --git a/docs/plans/002-oss-migration/002b-schema-deviation-register.md b/docs/plans/002-oss-migration/002b-schema-deviation-register.md index 7daf7f1..079f98b 100644 --- a/docs/plans/002-oss-migration/002b-schema-deviation-register.md +++ b/docs/plans/002-oss-migration/002b-schema-deviation-register.md @@ -58,6 +58,7 @@ spec for the convergence data migration that brings the company DB in line with | 32 | **Normalize RLS policy invocation pattern — D3:** wrap 18 bare helper-function calls in `( SELECT public.fn() AS fn )` — 14 × `rls_check_if_admin_org_member()` on `grids`/`meters`/`notes`/`organizations`/`pd_sites`/`poles`/`wallets` ("Allow NXT Grid to insert" `WITH CHECK`) and `accounts`/`connections`/`grids`/`meters`/`organizations`/`pd_site_submissions`/`pd_sites` ("Allow NXT Grid to update" `USING`); 4 × `rls_get_member_org_id()` on `notes`/`poles` ("Allow org members to insert" `WITH CHECK`) and `accounts`/`orders` ("Allow org members to update" `USING`) | Rewrite policy clause | Task 3d D3 — parsed all 123 `CREATE POLICY` statements (single migration, never altered later); found 69 helper-function calls across keep-table policies, 51 already wrapped (the Postgres/Supabase-recommended `InitPlan`-cacheable pattern) and 18 bare. Clean split: every bare call is on `INSERT`/`WITH CHECK` or `UPDATE`/`USING` — zero on `SELECT` (all 26 `SELECT`-side calls already wrapped). Normalizing removes the inconsistency and closes the bulk-`UPDATE` per-row-reevaluation gap; pairs with register #31 (`STABLE`), which is what makes the wrap's caching valid. Full list: `002b-schema-performance-audit.md` RLS policy invocation pattern § D3 | Company DB at cutover: `ALTER POLICY`/recreate the 18 policies with the wrapped clause; no behavior change (same boolean result), read-path performance only | confirmed | | 33 | **Data API grants — keep explicit per-object grants; omit auto-expose default:** init migration carries forward `GRANT ALL ON TABLE/SEQUENCE/FUNCTION … TO "anon"/"authenticated"/"service_role"` for every **keep** table/sequence/function (mirrors the legacy dump, keep-bucket scope only); **omits** the 3 `ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES/SEQUENCES/FUNCTIONS TO "anon"/"authenticated"/"service_role"` statements | Keep (explicit) + omit (default-privileges) | Found during final Task 3d wrap-up sanity check: Supabase changed platform defaults — new projects created on/after 2026-05-30 no longer auto-grant `anon`/`authenticated`/`service_role` access to `public` tables (PostgREST returns `42501 permission denied` without an explicit `GRANT`, before RLS is even evaluated); enforced on all existing projects from 2026-10-30 (new tables added after that date, on any project). Explicit per-object grants on keep objects make the baseline self-contained — reachable via the Data API regardless of the project-creation toggle, local vs. hosted vs. self-hosted vanilla Postgres. Omitting the default-privileges statements is a deliberate choice to align with Supabase's now-recommended pattern (explicit `GRANT` + RLS + policy as one reviewable unit per migration) rather than re-introduce the auto-expose-everything default the platform itself is moving away from. Production's 57 existing tables are unaffected either way — grandfathered permanently per Supabase's changelog; this only governs objects added going forward | Company DB: no-op (existing grants/default-privileges setup untouched — this deviation only shapes the OSS baseline template, not company production). Adopters and 002c capability imports must add explicit `GRANT` statements for any new `public` table going forward — no ambient default; Task 9.2 verifies a keep table is actually reachable via the Data API on a fresh hosted project | confirmed | | 34 | **FK cycle hardening — add `ON DELETE SET NULL` to 5 denormalized "latest pointer" FKs:** `meters.last_metering_hardware_install_session_id` → `metering_hardware_install_sessions.id`; `metering_hardware_install_sessions.last_metering_hardware_import_id` → `metering_hardware_imports.id`; `metering_hardware_install_sessions.last_meter_commissioning_id` → `meter_commissionings.id`; `dcus.last_metering_hardware_install_session_id` → `metering_hardware_install_sessions.id`; `meters.last_encountered_issue_id` → `issues.id` | Harden (`ON DELETE`) | Found during final sanity wrap-up (item 4/4): systematic FK-cycle sweep across all 35 keep tables found exactly **5 direct 2-cycles** (no cycles of length 3+) — each a denormalized "latest child" pointer column (`last_X_id`) paired with the child's own structural back-reference to its parent. Confirmed legitimate, not redundant: `dcus.service.ts` explicitly comments "we have a dcu property point at the latest dcu session, so it's easily retrievable"; the pattern is read throughout `meters_with_account_and_statuses` (chains 4 of the 5 cycles in one view), PostgREST embeds, and a TypeORM `@OneToOne`. Both sides of every cycle are already indexed (forward pointers via pre-existing `UNIQUE` constraints; structural back-pointers via register #30/Task 3d D1) — the query-efficiency half of the concern is already resolved. The real gap: all 10 FKs in these cycles default to `NO ACTION`, so deleting a row on either side while still referenced fails outright — matches the "hard to delete" pain reported. A full `legacy/` codebase sweep found **zero** hard-deletes (`.delete()`/`DELETE FROM`/`.remove()`) against any table — every removal is a soft-delete or status transition — so this isn't live-impacting today, but is a latent trap for future/manual/ops-level deletes. Fix scoped to the 5 *forward* pointers only; the 5 structural back-pointers correctly stay `NO ACTION` (a child row should not silently lose its real parent reference) | Company DB at cutover: drop + recreate each of the 5 FKs with `ON DELETE SET NULL` (no data impact — existing rows unaffected; changes only what happens on a future `DELETE`, which nothing currently triggers). See FK design adjustments §1 | confirmed | +| 35 | `grids.timezone` column default | Parameterize/neutralize default: `'Africa/Lagos'` → `'UTC'` | De-Nigeria the OSS baseline (002d decision 12; i18n register #1) | **New inserts only** — existing company grid rows keep stored values. At cutover: optional `ALTER TABLE grids ALTER COLUMN timezone SET DEFAULT 'UTC'` if NXT Grid wants the column default aligned; no row data migration required | confirmed | ## Column adjustments diff --git a/docs/plans/002-oss-migration/002d-platform-core-import.md b/docs/plans/002-oss-migration/002d-platform-core-import.md index 3b7e538..af5fdb7 100644 --- a/docs/plans/002-oss-migration/002d-platform-core-import.md +++ b/docs/plans/002-oss-migration/002d-platform-core-import.md @@ -5,7 +5,7 @@ amended by this plan), ADR-008 (Phase 3 incremental import), **ADR-013** (capability-owned behavior over shared core entities — *authored by this plan*, Task 1) **Created:** 2026-07-15 -**Status:** Authored 2026-07-15 — execution not started +**Status:** **Completed** (2026-07-17) — Tasks 1–11 done; next 002e (ADR-005 Accepted 2026-07-17) **Depends on:** 002b (database baseline) and 002c (scaffold, pipeline & config skeleton) complete; interlock reached 2026-07-14. **Execution model:** collaborative — division of labor is decided **per task/subtask as we go** @@ -44,9 +44,11 @@ These were resolved during authoring. Execute to them; if reality contradicts on it, don't silently diverge. 1. **Scope (see table below).** Foundation = infra + `auth`, `api-keys`, `accounts`, - `members` (type-only), `organizations`, `user-admin` (whole), `grids` (partial). Deferred / - re-homed: `download`, `routers`, notification-core, `websocket`, `agents`, `dcus`, `poles`. - These are **amendments to ADR-004 §5** (Task 1). + `members` (type-only), org **table** (Nest module skipped — Task 9), `user-admin` (whole), + grids **table** (Nest module skipped — Task 10). Deferred / re-homed: Nest `grids` CRUD/read, + metering-coupled grid methods, `download`, `routers`, notification-core, `websocket`, + `agents`, `dcus`, `poles`. These are **amendments to ADR-004 §5** (Task 1; Task 10 refined + Nest `grids` from “partial import” to “table only”). 2. **Operations-DB TypeORM is dropped entirely.** No ops-DB `*.entity.ts`, no `CoreTypeOrmModule` in Foundation. Services are (re-)expressed against the Supabase client using generated `supabase-types`. Timeseries TypeORM is untouched (decided at Monitoring, 002e). @@ -74,14 +76,16 @@ it, don't silently diverge. amendment (Task 1). 8. **Supabase infra:** `SupabaseService` builds the admin client in a **provider** with `requireEnv` (fail-fast at boot); **no `export const supabase` singleton**. Per-host env - validated at each provider's wiring — admin client (`SUPABASE_API_URL`, - `SUPABASE_SERVICE_ROLE_KEY`) both hosts; `SUPABASE_ANON_KEY` + `SUPABASE_JWT_SECRET` in auth + validated at each provider's wiring — admin client (`SUPABASE_URL`, + `SUPABASE_SECRET_KEY`) both hosts; `SUPABASE_PUBLISHABLE_KEY` + JWT verification + (`SUPABASE_JWKS_URL` preferred; `SUPABASE_JWT_SECRET` legacy fallback) in auth (`api` only). The query-type shortcut returns later via a **type-only probe** (no runtime client). Cloudflare-5xx handling and `SUPABASE_QUERY_LIMIT` kept as-is. -9. **Logging:** **`nestjs-pino`** in `@nxt/core`, structured JSON to stdout (dev pretty-print), - `LOG_LEVEL` env (soft default `info`), **config-assembled transports** with Loki/Sentry as - deferred **Tier-3** integrations (documented `LOKI_URL` / `SENTRY_DSN` slots, Loki-via-stdout - documented as the free path). `console.*` replaced **only within imported Foundation modules**. +9. **Logging:** Nest built-in `Logger` for now; **`GlobalLoggerModule` kept as a no-op slot** + in host `infrastructure` for a later `nestjs-pino` restore (option builders in + `logger.options.ts`). Structured JSON / pretty / Loki-Sentry remain Tier-3. No HTTP + auto-logging when pino returns (`autoLogging: false`). `console.*` fine for local + debug; replace within imported Foundation modules when structured logging returns. 10. **Types:** the **adjusted layer is reintroduced from the get-go** — `libs/core/src/types/supabase-types-adjusted.ts` augments the generated `Database` with the PostGIS `location_geom` types for `grids` **and** `poles`, exposed at its own subpath @@ -90,8 +94,9 @@ it, don't silently diverge. enforced with a restricted-import lint rule). 11. **Placement principle (ADR-013):** core entities are shared **data**; capability-specific **behavior** lives in the owning capability, never bolted onto the core module (no - conditional-unlock methods, no plugin registry in the baseline). This is why `grids` is a - partial import (see Task 10) and why the "no-cracks" governance below is mandatory. + conditional-unlock methods, no plugin registry in the baseline). Task 10 applied this by + **not** porting Nest `grids` (call-site queries; metering methods → Metering) — refined from + the earlier “partial Nest import” framing. The "no-cracks" governance below remains mandatory. 12. **i18n / de-brand:** `grids.timezone` default `'Africa/Lagos'` → **`'UTC'`** via **amending the 002b init migration** (Task 2). A running **internationalization & de-brand register** is created (Task 1) and grows per import. Foundation code-level de-brand is done inline @@ -100,7 +105,7 @@ it, don't silently diverge. 13. **Verification:** typecheck + a growing **seed** + a single **keep-or-dismiss test spike**; **manual maintainer sign-off** is the standing fallback. Request-handling reads shift to the **per-request user client** (admin client only pre-auth / privileged); RLS parity is part of - the bar. `accounts.findOne` relation-trimming is **decided on the spot** at implementation. + the bar. (Superseded: `accounts` is type-only — see Task 6 / decisions log.) ## Scope @@ -108,16 +113,18 @@ it, don't silently diverge. |---|---|---| | Infra: Supabase client, HTTP, **new pino logging** (config/types already in `@nxt/core`) | `download` | later (ADR-004 §5 amend) | | `auth` (supabase + api-key strategies + guard) | `routers` | Production Monitoring (ADR-004 §5 amend) | -| `api-keys`, `accounts`, `members` (type-only) | notification-core | first capability that writes notifications | -| `organizations`, `user-admin` (whole) | `websocket` | Metering (first realtime emitter) | -| `grids` (partial — read/CRUD; metering-coupled methods re-homed) | `agents` | Metering/Payments (entity rides identity graph) | +| `api-keys` lookup lives in auth (Task 7); `accounts` + `members` (type-only) | notification-core | first capability that writes notifications | +| `user-admin` (whole); org **table** (no Nest module) | `websocket` | Metering (first realtime emitter) | +| grids **table** + RLS + seed (no Nest module — Task 10) | `agents` | Metering/Payments (entity rides identity graph) | +| | Nest `grids` CRUD/read; metering-coupled grid methods | call-site queries / Metering (ADR-013) | | | `dcus`, `poles` | Metering | ## Non-goals (do not build here) - Any capability module (production, metering, payments, notifications, field-ops, automation). - An auth-provider port / pluggable-auth SPI (ADR-001 trigger governs). -- ADR-005 inter-host communication — **prerequisite of authoring 002e**, not resolved here. +- ADR-005 inter-host communication — was out of scope here (002e prerequisite); **Accepted + 2026-07-17** on the parent roadmap. - Loki/Sentry transports (Tier-3, deferred — only the config-assembled slots are left open). - Re-enabling the CI type-drift guard (stays deferred per 002c Task 7). - A full integration-test harness on spec (only the single scoped spike — Task 8). @@ -151,17 +158,19 @@ deleted only when fully superseded.** For entangled files, record the destinatio | Legacy source | Disposition | Notes | |---|---|---| -| `libs/core/src/modules/supabase.module.ts` | to import (Task 4) | provider refactor; drop `export const supabase`; type-only-probe forward pattern | -| `libs/core/src/modules/logger-module.ts` (+ dead LokiService) | **superseded — delete** | replaced by pino (Task 4); not ported | -| `libs/core/src/modules/global-http-module.ts` | to import (Task 4) | as-is / minimal | -| `libs/core/src/modules/accounts/**` | to import (Task 6) | rewrite to Supabase; entities dropped | -| `libs/core/src/modules/api-keys/**` | to import (Task 6) | rewrite to Supabase; entities dropped | -| `libs/core/src/modules/members/**` | type-only (Task 6) | empty service — no module; entity dropped | -| `libs/core/src/modules/organizations/**` + `apps/tiamat/.../organizations/**` | to import (Task 9) | move (already Supabase) | -| `apps/tiamat/src/modules/auth/**` | to import (Task 7) | move; `AuthenticatedUser`; drop admin-org flag | -| `apps/tiamat/src/modules/user-admin/**` | to import (Task 9) | whole; delete dead test code | -| `apps/tiamat/src/modules/grids/**` + `libs/core/.../grids/**` | **partial** (Task 10) | grid CRUD/read now; connectivity-stats → **re-home to Metering**; legacy file retained until both halves absorbed | -| `libs/core/src/types/supabase-types-adjusted.ts` | reintroduce (Task 3) | grids+poles geom; own subpath | +| `libs/core/src/modules/supabase.module.ts` | **deleted** (Task 11) | Was imported Task 4 → `libs/core/src/modules/supabase/` | +| `libs/core/src/modules/logger-module.ts` (+ LokiService) | **deleted** (Task 11) | Superseded by `GlobalLoggerModule` no-op stub; Loki not ported | +| `libs/core/src/modules/global-http-module.ts` | **deleted** (Task 11) | Was imported Task 4 | +| `libs/core/src/modules/accounts/**` | **deleted** (Task 11) | Type-only disposition (Task 6); tiamat `accounts` host module also deleted | +| `libs/core/src/modules/api-keys/**` | **deleted** (Task 11) | Absorbed into auth (Task 7); tiamat `api-keys` host module also deleted | +| `libs/core/src/modules/members/**` | **deleted** (Task 11) | Type-only (Task 6) | +| `apps/tiamat/src/modules/api-keys/**` | **deleted** (Task 11) | Absorbed into auth (Task 7) | +| `libs/core/src/modules/organizations/**` + `apps/tiamat/.../organizations/**` | **deleted** (Task 11) | Nest skipped Task 9; org table remains Foundation data | +| `libs/core/src/modules/customers/dto/create-customer.dto.ts` | **deleted** (Task 11) | Imported Task 9 → `@nxt/core`; rest of legacy customers module retained | +| `apps/tiamat/src/modules/auth/**` | **deleted** (Task 11) | Imported Task 7 → `apps/api/.../auth/` | +| `apps/tiamat/src/modules/user-admin/**` | **deleted** (Task 11) | Imported Task 9 → `apps/api/.../user-admin/` | +| `apps/tiamat/src/modules/grids/**` + `libs/core/.../grids/**` | **Nest skipped** (Task 10); metering **pending**; `GET /:id` **removed** (Task 11) | Annotated services retained. Controller kept minus Flow XO `GET /grids/:id`. Entity + service stay until Metering absorbs connectivity/cabin-credit | +| `libs/core/src/types/supabase-types-adjusted.ts` | **deleted** (Task 11) | Reintroduced Task 3 → `@nxt/core/types/supabase-types-adjusted` | | `libs/helpers/src/*.ts` | per-use, file-granular | `git mv` each file at first import (with its `.spec`) | --- @@ -209,7 +218,7 @@ added (ADR-index row deemed sufficient, per "read-if-relevant, no bloat"). ## Task 2 — Schema: `grids.timezone` default → `UTC` -- [ ] **Status:** Not started +- [x] **Status:** Done (2026-07-16) - **Depends on:** Task 1 - **Executor:** maintainer (`supabase/**` under CODEOWNERS) @@ -226,7 +235,7 @@ own default or aligns*. Cross-link the i18n register. ## Task 3 — Reintroduce the adjusted types layer -- [ ] **Status:** Not started +- [x] **Status:** Done (2026-07-16) - **Depends on:** Task 2 1. Add `libs/core/src/types/supabase-types-adjusted.ts` augmenting the generated `Database` with @@ -242,41 +251,49 @@ green; the import convention is documented (and lint-guarded if adopted). --- -## Task 4 — Infra + explicit composition (Supabase provider, pino logging, demo removal) +## Task 4 — Infra + explicit composition (Supabase provider, logging slot, demo removal) -- [ ] **Status:** Not started +- [x] **Status:** Done (2026-07-16) - **Depends on:** Task 3 The foundational infra task (rule 1). Both hosts boot on real infra. 1. **Supabase infra** in `@nxt/core`: `SupabaseService` builds the admin client in its constructor - via `requireEnv('SUPABASE_API_URL')` + `requireEnv('SUPABASE_SERVICE_ROLE_KEY')`; **remove the + via `requireEnv('SUPABASE_URL')` + `requireEnv('SUPABASE_SECRET_KEY')`; **remove the `export const supabase` singleton**. Port `handleResponse` / `throwSupabaseError` / - Cloudflare-5xx handling as-is but route logging through pino. Keep `SUPABASE_QUERY_LIMIT` a - plain constant. (The query-type-shortcut `lib/supabase.ts` files are in deferred capabilities; - the **type-only probe** pattern is documented for their return — nothing to build here.) -2. **HTTP infra:** port `global-http-module.ts` (minimal). -3. **Logging** in `@nxt/core`: `nestjs-pino` module — structured JSON to stdout, dev pretty-print - (gated by `NODE_ENV`/`LOG_PRETTY`), `LOG_LEVEL` env (soft default `info`), transport list - **assembled from config** with documented (unbuilt) Loki/Sentry Tier-3 slots. Retire - `logger-module.ts`/`LokiService` (not ported). Wire as the Nest logger in each `main.ts`. + Cloudflare-5xx handling as-is but route logging through Nest `Logger` (pino deferred). Keep + `SUPABASE_QUERY_LIMIT` a plain constant. (The query-type-shortcut `lib/supabase.ts` files are in + deferred capabilities; the **type-only probe** pattern is documented for their return — nothing + to build here.) +2. **HTTP infra:** port `global-http-module.ts` (minimal); wired in **both** hosts. +3. **Logging** in `@nxt/core`: Nest built-in `Logger` for now; keep `GlobalLoggerModule` as a + no-op slot in host `infrastructure` (+ `logger.options.ts` for nestjs-pino restore). + Structured JSON / pretty / Loki–Sentry remain Tier-3. Retire legacy + `logger-module.ts`/`LokiService` (not ported; delete at Task 11). 4. **Explicit composition:** replace `demoModules()` in both `app.module.ts` with plain named `infrastructure` / `foundation` arrays + inline Tier-1 conditionals (empty capability set for - now). `api` infra = Logger + Supabase + HTTP; `worker` infra = Logger + Supabase (+ existing + now). `api` infra = Logger + Supabase + HTTP; `worker` infra = Logger + Supabase + HTTP (+ schedule/heartbeat). **Remove the `demo` capability** (`modules/demo/`, `demoModules` export, `demo` from `capabilities` schema) and the `deployment` group from `config/schema.ts` + `config.example.json` / `config.default.json`. -5. Update `.env.example` with per-host Supabase + `LOG_LEVEL` documentation. +5. Update `.env.example` (root + per-host) with Supabase + logging documentation. **Done when:** both hosts boot on default config against a local Supabase; missing `SUPABASE_*` -fails fast with a clear `MISSING …`; logs are structured JSON (pretty in dev); `demo`/`deployment` +fails fast with a clear `MISSING …`; logs use Nest `Logger` (`GlobalLoggerModule` stub ready for +pino); `demo`/`deployment` are gone; `nx run-many -t lint typecheck build test -p api,worker,core` green. +**Done (2026-07-16):** Supabase provider + HTTP + explicit `infrastructure`/`foundation` composition +on both hosts; demo + `deployment` removed; env layout + renamed Supabase vars; nestjs-pino tried +then deferred (webpack transport workers / console DX) with `GlobalLoggerModule` stub retained; +scaffold golden-path proofs removed (`getPackageInfo`, health type probes); `/health` keeps a +lightweight Supabase probe. Lint bar green. Deployment-docs polish left for Task 5 alongside seed. + --- ## Task 5 — Seed harness -- [ ] **Status:** Not started +- [x] **Status:** Done (2026-07-16) — awaiting sign-off - **Depends on:** Task 3 (schema/types stable) Establish `supabase/seed.sql` (or a seed script) that grows per import. 002d fixtures: an @@ -284,39 +301,60 @@ organization (including the `PLATFORM_OPERATOR` row), accounts, members, an `api grid — plus a **test auth user with `app_metadata` claims** (account_id, member_type, organization_id) for auth/e2e. Doubles as the local-dev bootstrap. -**Done when:** a fresh `pnpm supabase start` + seed yields a coherent Foundation dataset and a +**Done when:** a fresh `pnpm exec supabase db reset` yields a coherent Foundation dataset and a usable test user; documented in `docs/deployment/supabase.md` (or the local-dev doc). +**Done:** +- `supabase/seed.sql` wired via `config.toml` `[db.seed]`; loop is `pnpm exec supabase db reset` + (local only — not for remote/prod). +- Fixtures: PLATFORM_OPERATOR + SOLAR_DEVELOPER orgs/wallets; two claimed auth users + (`superadmin@nxt-platform.com` / `SUPERADMIN`, `admin@nxt-solar.com` / `DEVELOPER`); + `api_keys` `dev-api-key-platform-superadmin`; grid **Demo Solar Grid** on org 2. +- Invite-path mirrored: auth insert → `handle_new_user` → `app_metadata` update → + `handle_update_user` → `members` insert. +- `docs/deployment/supabase.md` §4–5 updated (local seed + env pointer; dashboard bootstrap + replaced for local). + --- -## Task 6 — `accounts` + `api-keys` (TypeORM → Supabase rewrites) +## Task 6 — Disposition only (`accounts` / `members` / `api-keys`) + +- [x] **Status:** Done (2026-07-16) — docs/ledger only; awaiting sign-off +- **Depends on:** Task 4 (+ Task 5 context) -- [ ] **Status:** Not started -- **Depends on:** Task 4 (+ Task 5 for verification) +No Nest modules land in this task. Decisions: -Rewrite both services against the Supabase **admin** client (they run pre-auth, during token -validation): `accounts.findOne` (nested identity relations) and -`api-keys.findByKeyAndIsLocked(key, false)` (nested account graph). Drop the TypeORM entities. -Reconcile entity-vs-generated type gaps (enums, nullability); express computed/aliased fields as -explicit mapped/response types, not silent entity props. **`accounts.findOne` relation-trimming is -decided on the spot** (lean to keep what the frontend needs — e.g. wallet — record the call in the -decisions log). `members` is **type-only** (no module). +- **`accounts` + `members`:** type-only (generated / inferred row types; no services). +- **`api-keys`:** absorbed into **Task 7** `ApiKeyStrategy` — inject `SupabaseService` admin + client and select at the call site (`is_locked` already gone). No `@nxt/core` api-keys module. +- Legacy TypeORM entities/services stay under `legacy/` until **Task 11** deletes them per ledger + (fully superseded once auth ships without them). -**Done when:** both compile against generated types; the seeded reads return the expected nested -shapes/values (Task 8 covers automated proof or manual sign-off); no ops TypeORM remains in scope. +**Done when:** ledger + Task 7/8 wording reflect the above; no code changes required in Task 6. --- ## Task 7 — `auth` -- [ ] **Status:** Not started +- [x] **Status:** Done & signed off (2026-07-17) - **Depends on:** Task 6 Move the two Passport strategies + `AuthenticationGuard`. Rename `NxtSupabaseUser` → -**`AuthenticatedUser`**; **drop the admin-org membership flag** (no in-scope reader); keep exposing -raw `organization_id`. `console.*` → pino; de-brand comments. Validate `SUPABASE_ANON_KEY` + -`SUPABASE_JWT_SECRET` via `requireEnv` at auth wiring (`api` only). Restore `enableCors()` + a -global `ValidationPipe` on the `api` bootstrap. Add passport / `jsonwebtoken` deps to `api`. +**`AuthenticatedUser`**; **drop the admin-org membership flag** (no in-scope reader); **drop the +embedded `account` object** (TEMPORARY legacy); keep exposing raw `organization_id` + +`account_id` / claims. Bearer path does not call an accounts service. **API-key path:** inject +`SupabaseService` (admin) in `ApiKeyStrategy` and select the key + account graph inline (no +`ApiKeysService`). `console.*` → Nest `Logger` (pino restore later); de-brand comments. Validate +`SUPABASE_PUBLISHABLE_KEY` + JWT verification (`SUPABASE_JWKS_URL` preferred; +`SUPABASE_JWT_SECRET` legacy fallback) via `requireEnv` at auth wiring (`api` only). Restore +`enableCors()` + a global `ValidationPipe` on the `api` bootstrap. Add `@nestjs/passport`, +`passport`, strategy packages, and `jose` (JWKS) to `api` — not `@nestjs/jwt` / +`jsonwebtoken`. + +**Landed:** `apps/api/src/modules/auth/*` (strategies, guard, module, `/auth/me`); httpYac under +`apps/api/http/`; ADR-014; bootstrap CORS + `ValidationPipe`; ledger + i18n register updated. +**Verified:** seeded bearer JWT + `X-API-KEY` against local Supabase (maintainer, httpYac / +`/auth/me`). **Done when:** `api` authenticates a seeded Supabase JWT (bearer) and an `X-API-KEY`; the guard attaches a populated `AuthenticatedUser`; `worker` requires none of the auth secrets. @@ -325,15 +363,31 @@ attaches a populated `AuthenticatedUser`; `worker` requires none of the auth sec ## Task 8 — Scoped test spike (keep-or-dismiss) -- [ ] **Status:** Not started +- [x] **Status:** Done — **adopted** & signed off (2026-07-17) - **Depends on:** Tasks 5, 7 - **Executor:** collaborative; **dismiss → maintainer manual sign-off** A single, self-contained effort — do not muddle across other tasks. Stand up **one** integration -test (`api-keys.findByKeyAndIsLocked` against local Supabase + seed) and **one** e2e auth test (the -`X-API-KEY` guard path). Evaluate: adopt as the pattern others copy, **or** dismiss cleanly and -fall back to maintainer manual sign-off for the remaining modules. If the agent is burning -excessive tokens or getting stuck, dismiss and hand to manual sign-off. +test (API-key admin select / strategy lookup against local Supabase + seed) and **one** e2e auth +test (the `X-API-KEY` guard path). Evaluate: adopt as the pattern others copy, **or** dismiss +cleanly and fall back to maintainer manual sign-off for the remaining modules. If the agent is +burning excessive tokens or getting stuck, dismiss and hand to manual sign-off. + +**Adopted pattern (copy this):** + +- Tests under `apps/api/test/` only (not co-located with `src/`): `unit/`, `integration/`, `e2e/`, + `helpers/`. Same for libs: `libs/core/test/unit/` only (libs stay unit-only; no integration/e2e + in the shared kernel — those belong on host apps). +- **Default** `nx test api` = **unit only** (stack-free; part of the lint bar). +- Opt-in: `nx run api:test-integration` / `nx run api:test-e2e` (local Supabase + seed; skip if + `SUPABASE_*` missing). See `docs/deployment/supabase.md` § automated tests. +- Integration: Nest testing module + real admin client against seed (e.g. `ApiKeyStrategy`). +- E2E today: **thin** HTTP slice for the path under test (real guard/controller/API-key strategy; + stub bearer). **Not** full `AppModule` — Jest CJS cannot load ESM-only `jose` via + `SupabaseStrategy`. Do **not** proliferate thin modules; full-app e2e is a follow-up when an + ESM/Jest harness exists. Bearer JWT remains httpYac until then. +- Remaining Foundation modules: use this pattern where it fits; otherwise maintainer manual + sign-off (httpYac). **Done when:** the spike runs green (adopted) *or* is explicitly dismissed with the rationale recorded and manual sign-off adopted as the standing bar. @@ -342,49 +396,82 @@ recorded and manual sign-off adopted as the standing bar. ## Task 9 — `organizations` + `user-admin` -- [ ] **Status:** Not started +- [x] **Status:** Done — signed off (2026-07-17) - **Depends on:** Task 7 -Move `organizations` (already Supabase) and `user-admin` **whole** (members + agents + customers — -the recorded user-admin-specific exception). Delete the dead commented test-user code; de-brand -comments. Shift request-handling reads to the **per-request user client** where the operation acts -as the user (RLS-exercising); admin client only where genuinely privileged. Bring their DTOs. +Import `user-admin` **whole** (members + agents + customers — the recorded user-admin-specific +exception). Delete the dead commented test-user code; de-brand comments. Bring DTOs +(`CreateCustomerDto` in `@nxt/core` for worker reuse; other DTOs on the api module). + +**Nest `organizations` skipped:** legacy service had a single `findOne` and the only controller +route was commented out; nothing else injected the service. Org table stays Foundation data +(seed, RLS, health probe). Legacy Nest org files → delete at Task 11. + +**Client policy (Task 9):** `user-admin` uses the **admin client for the whole method** (Auth Admin +APIs + privileged follow-on writes). Do not mix user-client reads mid-flow here. API-key callers +still have no RLS-bound user client — **near-future:** ADR-014 §5.3 (attach `authenticated` client +after key validation) before growing more machine-callable data paths. User-client-by-default +returns with the **first real bearer read** that needs RLS (deferred — Task 10 Nest `grids` +skipped; no longer a grids HTTP smoke). -**Done when:** endpoints compile and behave against the seed (automated per Task 8 outcome, or -manual sign-off); RLS parity confirmed for user-client reads. +**Landed:** `apps/api/.../user-admin/`; `CreateCustomerDto` in core; httpYac `user-admin.http`; +`handleSingle` + Cloudflare→503; ledger + i18n #4. Maintainer local serve + happy with smoke. + +**Done when:** endpoints compile and behave against the seed (maintainer httpYac / manual +sign-off; Task 8 pattern optional); ledger + i18n register updated. --- -## Task 10 — `grids` (partial, per ADR-013) +## Task 10 — `grids` (Nest skipped; table stays Foundation) -- [ ] **Status:** Not started +- [x] **Status:** Done & signed off (2026-07-17) - **Depends on:** Task 7 -Import the org-scoped grid **read / list / update** (rewritten to Supabase, user-client where -acting as user). **Do not** import the metering-coupled methods (connectivity stats, DCU/meter -orchestration) — per ADR-013 these are **re-homed to Metering**, authored fresh there, not restored -to grids. Retain `legacy/.../grids/grids.service.ts` with a ledger note ("connectivity-stats → -Metering, pending"); delete only when both halves are absorbed. +**Disposition (no Nest import):** audit showed no strong Foundation Nest surface. Shared +`findOne` is a TypeORM-era habit; with Supabase, callers should select at the call site. +`GET /grids/:id` is Flow XO-only (company automation) — **not ported**; **delete at Task 11**. +`PUT /grids` / `updateMany` existed so loch weather could write through api under the old +“only Tiamat writes ops DB” rule — that rule is **dropped** (modules stay self-contained and +may write ops DB directly). Create never existed as an API. Org-scoped list helpers / +`findAll` had **no in-repo callers**. + +**Do not** import metering-coupled methods (connectivity stats, cabin-credit / DCU–meter +orchestration) — per ADR-013 re-home to Metering when that capability lands. Retain +`legacy/.../grids/grids.service.ts` until that half is absorbed; Nest CRUD/read pieces +delete at Task 11 per ledger (entity stays with the retained metering half). -**Done when:** grids CRUD/read works against the seed; the import ledger records grids as partially -imported with the pending re-home; no `dcus`/`meters` dependency pulled into 002d. +Grid **table** + RLS + seed (id `1` on org 2) + adjusted geom types remain Foundation data — +same posture as Nest `organizations` skipped (Task 9). + +**Done when:** ledger + this task record the skip; decisions log notes the write-funnel drop and +cleanup of `findOne` / `GET /:id`; no Nest `grids` module in `@nxt/core` / `api`; no +`dcus`/`meters` pulled into 002d. --- ## Task 11 — Close-out -- [ ] **Status:** Not started +- [x] **Status:** Done & signed off (2026-07-17) - **Depends on:** Tasks 1–10 - Delete superseded legacy Foundation files per the import ledger (whole-file, when fully - superseded). + superseded). For grids (**annotate + remove `GET /:id`**): leave `grids.service.ts` + entity + intact with a ledger/annotation note (Metering pending; Nest CRUD not ported); **keep** + `grids.controller.ts` but drop the Flow XO `GET /grids/:id` route (`PUT` / connectivity / + download remain until their owners absorb them). - Finalize the import ledger, schema deviation register, and i18n register. - Verify the lint bar green across `api`, `worker`, `core`, and any imported `helpers` files. -- Roadmap: 002d → Completed; 002e next (ADR-005 as its authoring prerequisite). +- Roadmap: 002d → Completed; 002e next (ADR-005 was still the authoring prerequisite at close-out; + Accepted later the same day). - Confirm `demo` and the `deployment` config group are fully gone. **Done when:** the done/exit bar (below) is met and recorded. +**Landed (close-out):** Legacy Foundation deletes per ledger; grids annotated + `GET /:id` +removed; `demo`/`deployment` confirmed gone; lint bar green +(`nx run-many -t lint typecheck build test -p api,worker,core`); roadmap 002d → Completed; +ADR-005 was still open at 002d close-out; **Accepted 2026-07-17** before 002e authoring. + --- ## Done / exit bar for 002d @@ -392,15 +479,20 @@ imported with the pending re-home; no `dcus`/`meters` dependency pulled into 002 - `api` boots via explicit central composition; `worker` boots with logging + Supabase infra; both fail-fast on missing `SUPABASE_*`; `demo`/`demoModules` removed. - Foundation imported on the Supabase client (no ops TypeORM; `CoreTypeOrmModule` gone from scope): - `auth` (`AuthenticatedUser`, admin-org flag dropped), `api-keys`, `accounts`, `members` - (type-only), `organizations`, `user-admin` (whole), `grids` (partial). pino logging live; - `console.*` replaced within imported modules; user-client-by-default in request handlers. + `auth` (`AuthenticatedUser`, admin-org flag dropped; no embedded `account`; API-key select + inline in strategy), `accounts` + `members` (type-only), org **table** (no Nest module), + `user-admin` (whole; admin-client privileged surface), grids **table** (no Nest module — + Task 10). Structured logging: Nest `Logger` + `GlobalLoggerModule` **no-op stub** (nestjs-pino + deferred Task 4); user-client-by-default on request-handler reads when those land (except + whole-method admin privileged surfaces like `user-admin`; first bearer RLS smoke deferred past + Nest-grids skip). - Adjusted types layer live (grids+poles geom) with uniform `Database` import; `deployment` group dropped; `grids.timezone` default `UTC` (init migration amended); types regenerated clean. - Verification: lint bar green; seed established (fixtures + claimed test user); the test spike run - (adopted or dismissed → manual sign-off); e2e auth verified; RLS parity checked. -- Legacy Foundation files deleted per ledger (`grids.service.ts` retained with re-home note); all - three registers updated. + (adopted or dismissed → manual sign-off); e2e auth verified; RLS parity checked when a + user-client read surface exists. +- Legacy Foundation files deleted per ledger (`grids.service.ts` retained + annotated for Metering + re-home; Nest `GET /grids/:id` removed at close-out); all three registers updated. - Records updated: ADR-013 authored + indexed; ADR-004 §5 & ADR-007 amended; roadmap assumptions/index; deployment docs; ADR-005 flagged as 002e prerequisite. @@ -411,10 +503,12 @@ imported with the pending re-home; no `dcus`/`meters` dependency pulled into 002 - [x] ADR-007 amended (wiring / deployment-group drop / boot model) - [x] Roadmap `002-oss-migration.md`: sub-plan index, standing assumptions (module-split → ADR-013, ADR-005 timing, no-cracks governance), notes log -- [ ] Schema deviation register: `grids.timezone` (Task 2) -- [x] Internationalization & de-brand register (created + seeded) -- [ ] Deployment docs: Supabase required for foundation hosts, Loki-via-stdout, `LOG_LEVEL` (Task 4/5) -- [ ] Import ledger (this file) kept current (ongoing) +- [x] Schema deviation register: `grids.timezone` (#35, Task 2) +- [x] Internationalization & de-brand register (created + seeded; Task 7 auth rows updated) +- [x] Deployment docs: Supabase required for foundation hosts, seed/bootstrap (Task 5; env + examples already updated in Task 4) +- [x] Import ledger (this file) kept current through Task 11 (Foundation deletes + grids annotate / + `GET /:id` removed; metering half pending) ## Notes & decisions log @@ -427,3 +521,125 @@ imported with the pending re-home; no `dcus`/`meters` dependency pulled into 002 ADR-013, ADR-004 §5 + ADR-007 amendments, AGENTS.md ADR-index row, the internationalization & de-brand register, and the roadmap updates (index, no-cracks governance, assumptions 2/9/12, single-pass note). Next: Task 2 (`grids.timezone` → UTC — maintainer-owned). +- 2026-07-16 — [Task 3] Done. Reintroduced `supabase-types-adjusted.ts` (grids+poles + `location_geom`); `@nxt/core/types/supabase-types-adjusted` subpath export; import convention + documented (`libs/core/README.md` + module JSDoc); ESLint `no-restricted-imports` blocks + `Database` from generated subpath; golden-path probes in `health.service.ts`. Next: Task 4. +- 2026-07-16 — [Task 4 side] In-package imports: `@nxt/` across packages; within a + package use Node subpath `"imports"` `#config/`, `#modules/`, `#types/` (not `#/` — invalid + for TypeScript; see webpro.nl subpath-imports article). Direct files only — avoids + in-package barrel imports. Sibling `./` kept. Jest `moduleNameMapper` + `source`/`default` + conditions (`src`/`dist`) on `@nxt/core`. +- 2026-07-16 — [Task 4] Worker infra includes `GlobalHttpModule` (same as api) — workers are + integration-heavy; plan’s “api-only HTTP” narrowed at implementation. +- 2026-07-16 — [Task 4] Config lives only at `@nxt/core/config` (not re-exported from the fat + `@nxt/core` barrel). Bootstrap must not pull Nest modules when loading config. ESLint + `no-restricted-imports` blocks config symbols from `@nxt/core`. `GlobalLoggerModule` keeps + `forRootAsync` so option factories run at Nest init, after `loadConfig()`. +- 2026-07-16 — [Task 4] Prefer `constructor(logger: PinoLogger)` + `setContext` over + `@InjectPinoLogger(Service.name)`: nestjs-pino snapshots decorated tokens at + `LoggerModule.forRoot*` eval time; importing `GlobalLoggerModule` from another module file + (or barrel order) can register providers before those decorators run → missing + `PinoLogger:ServiceName`. `GlobalSupabaseModule` imports `GlobalLoggerModule` for DI. +- 2026-07-16 — [Task 4 / env] Local env files: root `.env` = shared; `apps//.env` = + host-specific. Nx loads project then workspace `.env` on `nx serve`/`build` (no + `dotenv-safe`; no mandatory `@nestjs/config` for local). Examples: + `.env.example`, `apps/api/.env.example`, `apps/worker/.env.example`. Production still + injects env via the platform. ADR-007 layer-2 `requireEnv` unchanged. +- 2026-07-16 — [Task 4 / env] Supabase env names → current terminology: + `SUPABASE_URL` + `SUPABASE_SECRET_KEY` (admin client, both hosts); + `SUPABASE_PUBLISHABLE_KEY` + `SUPABASE_JWKS_URL` (auth, api / Task 7); + `SUPABASE_JWT_SECRET` kept as documented legacy HS256 fallback only. + Local CLI may still print anon/service_role labels; values work under either naming. + Admin `createClient` options: `persistSession: false`, `autoRefreshToken: false`. +- 2026-07-16 — [Task 4 / logging] Keep nestjs-pino; `autoLogging: false` always (no HTTP + request dumps). `LOG_PRETTY` opt-in only (default off) so `console.*` stays usable next + to JSON Nest/Pino logs. +- 2026-07-16 — [Task 4 / logging] Deferred nestjs-pino: webpack serve + transport workers + break pretty (“log once then silence”); console DX suffered. `GlobalLoggerModule` is a + no-op stub still wired in both hosts’ `infrastructure`; `logger.options.ts` retained for + one-place restore. Hosts use Nest `Logger` / `console.*` until structured logging returns. +- 2026-07-16 — [Task 4 / 4.4c] Dropped `deployment` from config schema + default/example + + fixtures/specs. Removed scaffold golden-path proofs (`getPackageInfo`, type probes on + HealthService); `/health` keeps a lightweight Supabase `organizations` probe. +- 2026-07-16 — [Task 4] **Done & signed off.** Infra + composition complete (see Task 4 Done + block). Next: Task 5 (seed harness). +- 2026-07-16 — [Task 5] Seed harness established: `supabase/seed.sql` (orgs/wallets, two auth + users with claims + members, api key, solar grid); documented in + `docs/deployment/supabase.md` §5 as local-only (`pnpm exec supabase db reset`). **Done — + awaiting sign-off.** Next: Task 6 (`accounts` + `api-keys`). +- 2026-07-16 — [Task 6 / typing] **Do not** hand-build nested relation response types up front. + Typed Supabase client infers select return types. Prefer inference at the call site; write a + narrow manual type only when a method’s contract needs it; for complex selects use + `QueryData` (see legacy `meter-interactions/lib/supabase.ts`). Reverted an + early `*.types.ts` draft that reconstructed joins. +- 2026-07-16 — [Task 6 / accounts] **`accounts` is type-only** (same posture as `members`). Sole + legacy caller was `supabase.strategy` `@TEMPORARY` Account attach; Foundation already uses + `account_id` / JWT claims. No `AccountsService` / accounts Nest module in OSS. Task 7: drop + `account` from `AuthenticatedUser`. +- 2026-07-16 — [Task 6 / api-keys] **No standalone `ApiKeysService`.** Sole caller is + `ApiKeyStrategy` → absorb into Task 7: inject `SupabaseService` admin client and select at the + call site. Task 6 is **docs/ledger only**. Legacy `accounts` / `api-keys` / `members` files + remain under `legacy/` until **Task 11** deletes fully superseded Foundation sources. +- 2026-07-16 — [Task 6] **Done (docs-only) — awaiting sign-off.** Next: Task 7 (`auth`). +- 2026-07-17 — [Task 7 / security] ADR-014 authored: machine credentials (API keys, scopes, + Postgres readonly roles vs Supabase JWT roles, MCP team vs customer tenancy, hardening order). + Public by design (architecture for adopters); no secrets. +- 2026-07-16 — [Task 7 / deps] Minimal auth packages on `api`: `@nestjs/passport`, `passport` + (peer), `passport-http-bearer`, `passport-headerapikey`, `jose` (JWKS + verify), + `@supabase/supabase-js` (user client + types on the host). **No** `@nestjs/jwt` / + `jsonwebtoken` / `jwks-rsa`. Grafana RS256 endpoint skipped. Small `/auth/me` probe planned + for early JWKS verification. +- 2026-07-17 — [Task 7 / bootstrap] `enableCors()` + global `ValidationPipe` on `api` only; + `class-validator` / `class-transformer` on `@nxt/api` only (not core/worker — avoids dragging + Nest optional peers into every host). `throwSupabaseError` takes a structural logger so pnpm + dual `@nestjs/common` peer installs do not break typecheck across packages. +- 2026-07-17 — [Task 7 / ledger] Auth marked **imported**; api-keys rows note inline strategy + destination. i18n/de-brand register #2–#3 updated (destinations). Seed verify still open. +- 2026-07-17 — [Task 7] **Done — awaiting sign-off.** Maintainer verified seeded bearer + + `X-API-KEY` via httpYac `/auth/me`. Next: Task 8 (scoped test spike keep-or-dismiss). +- 2026-07-17 — [Task 7] **Signed off.** Next: Task 8. +- 2026-07-17 — [Task 8] **Adopted** (keep). Layout: `apps/api/test/{unit,integration,e2e,helpers}`; + default `nx test api` = unit only; `api:test-integration` / `api:test-e2e` opt-in + skip without + `SUPABASE_*`. Spike green: ApiKeyStrategy integration + thin `GET /auth/me` X-API-KEY e2e (not + full `AppModule` — `jose` ESM/Jest). Full-app e2e deferred; don’t proliferate thin e2e modules. + Bearer → httpYac. Next: Task 9. +- 2026-07-17 — [Task 8] **Signed off.** Next: Task 9 (`organizations` + `user-admin`). +- 2026-07-17 — [Task 8 / follow-up] `libs/core` specs moved to `libs/core/test/unit/` (mirrors api; + libs = unit-only by convention). +- 2026-07-17 — [Task 9] Nest **`organizations` skipped** (dead surface). `user-admin` imported to + `apps/api` with whole-method admin client; `CreateCustomerDto` in `@nxt/core` (`class-validator` + dep on core for shared DTO). Dead test-user block not ported. Maintainer local serve smoke OK. +- 2026-07-17 — [Task 9 / API keys] Confirmed gap: API-key auth attaches claims but **no** + per-request user client → machine handlers that need PostgREST fall through to `service_role`. + **Near-future (before more machine data paths):** implement ADR-014 §5.3 — after key validation, + attach an `authenticated` client with the account’s JWT claims. Privileged Auth Admin surfaces + (`user-admin`) stay whole-method admin regardless. Comment left on `ApiKeyStrategy`. +- 2026-07-17 — [Task 9 / Supabase helpers] `SupabaseService.handleSingle` for `.single()` (value or + throw); `handleResponse` stays permissive for `.maybeSingle()` / lists. Cloudflare HTML 5xx + **throws** (no soft-`null`); `isCloudflareHtmlError` (file-local) collapses the HTML body to a + one-liner and maps to **503 Service Unavailable** — amends Task 4 soft-return. Rationale: legacy + soft-`null` was a panic brake when handlers/jobs were not throw-ready; empty-data lied about + outages and broke `.single()` contracts. Retries for this infra class = **far-future** (not in + 002d). +- 2026-07-17 — [Task 9] **Signed off.** Next: Task 10 (`grids` partial, per ADR-013). +- 2026-07-17 — [Task 10] **Nest `grids` skipped** (docs/ledger only; organizations posture). Usage + audit: `findOne` / `GET /:id` ≈ Flow XO only → delete at Task 11, not port; `updateMany` / + `PUT /grids` served loch weather under the old api-only ops-DB write funnel; org list / + `findAll` / create had no real Nest consumers. **Ops-DB write funnel dropped:** hosts/modules + may write operations data directly (self-contained); do not reintroduce “only api writes.” + Metering-coupled grid methods remain ledger **re-home to Metering, pending**. Grid table + + RLS + seed stay Foundation. User-client-by-default smoke deferred to the next real bearer + read. **Done (docs-only) — signed off.** Next: Task 11 (close-out). +- 2026-07-17 — [Task 11] Started. Grids close-out: **annotate** service; **remove only** + `GET /grids/:id` from the controller (keep `PUT` / connectivity / download). Do not strip + `findOne` from the service (legacy dcus/payouts/lost-revenue callers). +- 2026-07-17 — [Task 11 / deletes] Removed superseded Foundation from `legacy/`: supabase + + logger-module + global-http; accounts / api-keys / members / organizations (core + tiamat); + auth + user-admin; create-customer.dto; supabase-types-adjusted. Grids: annotated services; + controller kept minus `GET /:id`; unwired deleted Foundation modules from tiamat `AppModule`. + Legacy `@core` barrel trimmed. +- 2026-07-17 — [Task 11] **Done & signed off.** Exit bar met: lint bar green; `demo`/`deployment` + gone; ledger finalized; roadmap 002d → **Completed**. Next family step was: lock ADR-005, then + author **002e**. +- 2026-07-17 — **ADR-005 Accepted** (recorded on parent roadmap). 002e authoring unblocked. diff --git a/docs/plans/002-oss-migration/internationalization-and-debrand-register.md b/docs/plans/002-oss-migration/internationalization-and-debrand-register.md index f80fe14..ed024ce 100644 --- a/docs/plans/002-oss-migration/internationalization-and-debrand-register.md +++ b/docs/plans/002-oss-migration/internationalization-and-debrand-register.md @@ -31,10 +31,10 @@ Two flavors live here: | # | Location | Finding | Kind | Disposition | Cross-ref | Status | |---|---|---|---|---|---|---| -| 1 | `grids.timezone` column default (002b init migration; legacy `grid.entity.ts` `'Africa/Lagos'`) | Timezone defaults to Lagos — a Nigeria assumption baked into the **schema**, not just code | i18n | **neutralized** — default changed to `'UTC'` in the init migration (002d Task 2). New inserts only; existing company grids keep stored values | Schema deviation register (002d entry) | candidate | -| 2 | `legacy/apps/tiamat/src/modules/auth/nxt-supabase-user.ts` — type `NxtSupabaseUser` | Brand-named auth user type (`Nxt…`) | de-brand | **neutralized** — renamed `AuthenticatedUser` on import (002d Task 7) | 002d Task 7 | candidate | -| 3 | `legacy/apps/tiamat/src/modules/auth/supabase.strategy.ts` — comment "let all NXT Grid pass" + `console.info` | Brand reference in comment; also the (deferred) admin-org membership flag `is_nxt_grid_member` | de-brand | **neutralized** — comment cleaned on import; flag **deferred** (no in-scope reader), reintroduced as `is_admin_org_member` with its consumer | 002d Task 7; ADR-007 Amendment 2026-07-15 §B | candidate | -| 4 | `legacy/apps/tiamat/src/modules/user-admin/user-admin.service.ts` — commented-out test-user block (`bobby.bol@nxtgrid.co`, `+31…` phone) + "Create a NXT Grid member" comment | Brand emails/phone in dead code; brand reference in comment | de-brand | **neutralized** — dead block deleted (not ported); comment de-branded on import (002d Task 9) | 002d Task 9 | candidate | +| 1 | `grids.timezone` column default (002b init migration; legacy `grid.entity.ts` `'Africa/Lagos'`) | Timezone defaults to Lagos — a Nigeria assumption baked into the **schema**, not just code | i18n | **neutralized** — default changed to `'UTC'` in the init migration (002d Task 2). New inserts only; existing company grids keep stored values | Schema deviation register #35 | confirmed | +| 2 | `legacy/apps/tiamat/src/modules/auth/nxt-supabase-user.ts` — type `NxtSupabaseUser` | Brand-named auth user type (`Nxt…`) | de-brand | **neutralized** — `apps/api/src/modules/auth/authenticated-user.ts` exports `AuthenticatedUser` (002d Task 7) | 002d Task 7 | confirmed | +| 3 | `legacy/apps/tiamat/src/modules/auth/supabase.strategy.ts` — comment "let all NXT Grid pass" + `console.info` | Brand reference in comment; also the (deferred) admin-org membership flag `is_nxt_grid_member` | de-brand | **neutralized** — brand comment not ported; Nest `Logger` instead of `console.*`; flag **deferred** (no in-scope reader), reintroduce as `is_admin_org_member` with its consumer | 002d Task 7; ADR-007 Amendment 2026-07-15 §B | confirmed | +| 4 | `legacy/apps/tiamat/src/modules/user-admin/user-admin.service.ts` — commented-out test-user block (`bobby.bol@nxtgrid.co`, `+31…` phone) + "Create a NXT Grid member" comment | Brand emails/phone in dead code; brand reference in comment | de-brand | **neutralized** — dead block deleted (not ported); comment de-branded on import (`apps/api/.../user-admin/`) | 002d Task 9 | confirmed | | 5 | `organizations` (no `timezone` column today) | An org-level timezone would be the natural i18n home once multi-region operators exist (grids currently carry timezone) | i18n | **noted** — future candidate; no action in 002d | — | candidate | ## Notes @@ -46,3 +46,11 @@ Two flavors live here: rest are code-level de-brand items handled inline during their module's import. Currency was **not** found hardcoded in the foundation scope — expect currency findings to surface in the Payments import; add them here then. +- 2026-07-16 — [002d Task 2] Init migration amended (`grids.timezone` default → `'UTC'`); schema + deviation register #35 added. Maintainer sign-off 2026-07-16 after `db reset` + + `generate-types:local` (empty diff on `supabase-types.ts` as expected). +- 2026-07-17 — [002d Task 7] Auth de-brand rows #2–#3 updated with OSS destinations. Status stays + `candidate` until Task 7 seed verify + maintainer sign-off (then → `confirmed`). +- 2026-07-17 — [002d Task 7] Seed verify passed (maintainer); rows #2–#3 → `confirmed`. +- 2026-07-17 — [002d Task 9] `user-admin` imported; row #4 → `confirmed` (dead brand block not + ported; local serve smoke OK). diff --git a/eslint.config.mjs b/eslint.config.mjs index 3e8dff4..57e8963 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -41,6 +41,32 @@ const teamRules = { varsIgnorePattern: '^_', }, ], + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@nxt/core/types/supabase-types', + importNames: ['Database'], + message: + 'Import Database from @nxt/core/types/supabase-types-adjusted; use supabase-types for enums and row aliases only.', + }, + { + name: '@nxt/core', + importNames: [ + 'getConfig', + 'loadConfig', + 'setConfig', + 'requireEnv', + 'NxtConfig', + 'LoadConfigOptions', + ], + message: + 'Import config from @nxt/core/config — the fat @nxt/core barrel must not load Nest modules during bootstrap.', + }, + ], + }, + ], }; export default [ @@ -76,7 +102,13 @@ export default [ 'error', { enforceBuildableLibDependency: true, - allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'], + allow: [ + '^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$', + // In-package Node subpath imports (package.json "imports") — not the barrel. + '#config/**', + '#modules/**', + '#types/**', + ], depConstraints: [ { sourceTag: '*', diff --git a/legacy/apps/tiamat/src/modules/accounts/accounts.module.ts b/legacy/apps/tiamat/src/modules/accounts/accounts.module.ts deleted file mode 100644 index 71430cd..0000000 --- a/legacy/apps/tiamat/src/modules/accounts/accounts.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Account } from '@core/modules/accounts/entities/account.entity'; -import { AccountsService } from '@core/modules/accounts/accounts.service'; - -@Global() -@Module({ - imports: [ TypeOrmModule.forFeature([ Account ]) ], - providers: [ AccountsService ], - exports: [ AccountsService ], -}) -export class AccountsModule { } diff --git a/legacy/apps/tiamat/src/modules/api-keys/api-keys.module.ts b/legacy/apps/tiamat/src/modules/api-keys/api-keys.module.ts deleted file mode 100644 index 1636857..0000000 --- a/legacy/apps/tiamat/src/modules/api-keys/api-keys.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { ApiKeysService } from '@core/modules/api-keys/api-keys.service'; -import { ApiKey } from '@core/modules/api-keys/entities/api-key.entity'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -@Global() -@Module({ - imports: [ TypeOrmModule.forFeature([ ApiKey ]) ], - providers: [ ApiKeysService ], - exports: [ ApiKeysService ], -}) -export class ApiKeysModule {} diff --git a/legacy/apps/tiamat/src/modules/app.module.ts b/legacy/apps/tiamat/src/modules/app.module.ts index 46ee571..9bab17f 100644 --- a/legacy/apps/tiamat/src/modules/app.module.ts +++ b/legacy/apps/tiamat/src/modules/app.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; -import { CoreTypeOrmModule, GlobalHttpModule, CoreLoggerModule, GlobalSupabaseModule, CorePgModule } from '@core'; +import { CoreTypeOrmModule, CorePgModule } from '@core'; import { TimeseriesTypeOrmModule } from '@timeseries'; // Core modules @@ -10,11 +10,10 @@ import { CoreEnergyTrackingModule } from '@core/modules/energy-tracking/energy-t import { SoftwareDevAlertModule } from '@core/modules/software-dev-alert/software-dev-alert.module'; // Local modules -import { UserAdminModule } from './user-admin/user-admin.module'; -import { AccountsModule } from './accounts/accounts.module'; +// 002d Task 11: Foundation Nest modules deleted from legacy (auth, user-admin, accounts, +// organizations, api-keys, supabase/http/logger). GridsModule retained (service only; controller gone). import { AfricastalkingModule } from './africastalking/africastalking.module'; import { AgentsModule } from './agents/agents.module'; -import { AuthModule } from './auth/auth.module'; import { ConnectionsModule } from './connections/connections.module'; import { DcusModule } from './dcus/dcus.module'; import { FlutterwaveModule } from './flutterwave/flutterwave.module'; @@ -22,7 +21,6 @@ import { GridsModule } from './grids/grids.module'; import { MetersModule } from './meters/meters.module'; import { MpptsModule } from './mppts/mppts.module'; import { OrdersModule } from './orders/orders.module'; -import { OrganizationsModule } from './organizations/organizations.module'; import { DirectiveBatchesModule } from './directive-batches/directive-batches.module'; import { DirectiveBatchExecutionsModule } from './directive-batch-executions/directive-batch-executions.module'; import { UssdSessionsModule } from './ussd-sessions/ussd-sessions.module'; @@ -31,7 +29,6 @@ import { WebsocketModule } from './websocket/websocket.module'; import { DownloadModule } from './download/download.module'; import { CoreVictronModule } from '@core/modules/victron/victron.module'; import { MeteringHardwareInstallSessionsModule } from './metering-hardware-install-sessions/metering-hardware-install-sessions.module'; -import { ApiKeysModule } from './api-keys/api-keys.module'; import { TelegramModule } from './telegram/telegram.module'; import { IssuesModule } from './issues/issues.module'; import { EpicollectModule } from './epicollect/epicollect.module'; @@ -53,10 +50,7 @@ const modules = process.env.IS_HIBERNATED === 'true' ? [] : [ ScheduleModule.forRoot(), CoreTypeOrmModule, TimeseriesTypeOrmModule, - GlobalHttpModule, - GlobalSupabaseModule, CorePgModule, - CoreLoggerModule, CoreEnergyTrackingModule, CoreSpendingModule, @@ -65,14 +59,10 @@ const modules = process.env.IS_HIBERNATED === 'true' ? [] : [ MeterInteractionsModule, MeterInstallsModule, DataAnalyticsModule, - UserAdminModule, - AccountsModule, AgentsModule, - AuthModule, DcusModule, CoreVictronModule, MeteringHardwareInstallSessionsModule, - OrganizationsModule, FlutterwaveModule, GridsModule, MetersModule, @@ -87,7 +77,6 @@ const modules = process.env.IS_HIBERNATED === 'true' ? [] : [ MpptsModule, WebsocketModule, IssuesModule, - ApiKeysModule, TelegramModule, EpicollectModule, JiraModule, diff --git a/legacy/apps/tiamat/src/modules/auth/api-key.strategy.ts b/legacy/apps/tiamat/src/modules/auth/api-key.strategy.ts deleted file mode 100644 index ba9ee92..0000000 --- a/legacy/apps/tiamat/src/modules/auth/api-key.strategy.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; -import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ApiKeysService } from '@core/modules/api-keys/api-keys.service'; -import { ApiKey } from '@core/modules/api-keys/entities/api-key.entity'; -import { NxtSupabaseUser } from './nxt-supabase-user'; -import { NXT_ORG_ID } from '@core/constants'; - -@Injectable() -export class ApiKeyStrategy extends PassportStrategy(HeaderAPIKeyStrategy) { - constructor(private readonly apiKeysService: ApiKeysService) { - super({ header: 'X-API-KEY', prefix: '' }, false); - } - - public validate = async (apiKey: string): Promise => { - const key: ApiKey = await this.apiKeysService.findByKeyAndIsLocked(apiKey, false); - if (!key?.account || key.account.deleted_at) - throw new UnauthorizedException('The API key used does not have a corresponding account'); - - const { - id, - full_name, - email, - member: { member_type }, - organization, - supabase_id, - } = key.account; - - return { - email, - full_name, - account_type: 'MEMBER', - member_type, - account_id: id, - organization_id: organization.id, - is_nxt_grid_member: organization.id === NXT_ORG_ID, - supabase_id, - account: key.account, - async validate() { return {}; }, // Fake validation method - }; - }; -} diff --git a/legacy/apps/tiamat/src/modules/auth/auth.controller.ts b/legacy/apps/tiamat/src/modules/auth/auth.controller.ts deleted file mode 100644 index f3980c8..0000000 --- a/legacy/apps/tiamat/src/modules/auth/auth.controller.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; -import { AuthService } from './auth.service'; -import { CurrentUser, NxtSupabaseUser } from './nxt-supabase-user'; -import { AuthenticationGuard } from './authentication.guard'; - -@UseGuards(AuthenticationGuard) -@Controller('auth') -export class AuthController { - constructor(private readonly authService: AuthService, - ) { } - - @Get('grafana-token') - getGrafanaToken( - @CurrentUser() user: NxtSupabaseUser, - ) { - return this.authService.generateAccessToken(user.account); - } -} diff --git a/legacy/apps/tiamat/src/modules/auth/auth.module.ts b/legacy/apps/tiamat/src/modules/auth/auth.module.ts deleted file mode 100644 index b5e5c87..0000000 --- a/legacy/apps/tiamat/src/modules/auth/auth.module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { AuthService } from './auth.service'; -import { AccountsModule } from '../accounts/accounts.module'; -import { PassportModule } from '@nestjs/passport'; -import { JwtModule } from '@nestjs/jwt'; -import { ApiKeyStrategy } from './api-key.strategy'; -import { AuthController } from './auth.controller'; -import { SupabaseStrategy } from './supabase.strategy'; - -@Global() -@Module({ - imports: [ - AccountsModule, - PassportModule, - JwtModule.register({ - privateKey: process.env.PRIVATE_KEY, - signOptions: { - expiresIn: process.env.NXT_JWT_DURATION, - algorithm: 'RS256', - }, - }) ], - providers: [ - AuthService, - ApiKeyStrategy, - SupabaseStrategy, - ], - controllers: [ AuthController ], - exports: [ AuthService ], -}) -export class AuthModule { } - diff --git a/legacy/apps/tiamat/src/modules/auth/auth.service.ts b/legacy/apps/tiamat/src/modules/auth/auth.service.ts deleted file mode 100644 index c5d43ea..0000000 --- a/legacy/apps/tiamat/src/modules/auth/auth.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Account } from '@core/modules/accounts/entities/account.entity'; - -@Injectable() -export class AuthService { - constructor(private jwtService: JwtService) { } - - async generateAccessToken(account: Account) { - const payload = { username: account.email, sub: String(account.id) }; - const refresh_token = this.jwtService.sign(payload, { expiresIn: '30d' }); - - return { - access_token: this.jwtService.sign({ - username: account.email, - sub: String(account.id), - }), - refresh_token: refresh_token, - }; - } -} diff --git a/legacy/apps/tiamat/src/modules/auth/authentication.guard.ts b/legacy/apps/tiamat/src/modules/auth/authentication.guard.ts deleted file mode 100644 index 4cba19a..0000000 --- a/legacy/apps/tiamat/src/modules/auth/authentication.guard.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { Observable } from 'rxjs'; - -@Injectable() -export class AuthenticationGuard extends AuthGuard([ 'supabase', 'headerapikey' ]) implements CanActivate { - canActivate(context: ExecutionContext): boolean | Promise | Observable { - return super.canActivate(context); - } - - // @TODO-GQL :: Check if we even need this!? - // getRequest(context: ExecutionContext) { - // if (context.getType() === 'http') { - // return context.switchToHttp().getRequest(); - // } - // } -} diff --git a/legacy/apps/tiamat/src/modules/auth/nxt-supabase-user.ts b/legacy/apps/tiamat/src/modules/auth/nxt-supabase-user.ts deleted file mode 100644 index 16849e2..0000000 --- a/legacy/apps/tiamat/src/modules/auth/nxt-supabase-user.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { Account } from '@core/modules/accounts/entities/account.entity'; -import { SupabaseClient, PostgrestError } from '@supabase/supabase-js'; -import { AccountTypeEnum, Database, MemberTypeEnum } from '@core/types/supabase-types'; - -export interface NxtSupabaseUser { - email: string; - full_name: string; - account_type: AccountTypeEnum; - member_type: MemberTypeEnum; - account_id: number; - organization_id: number; - is_nxt_grid_member: boolean; - supabase_id: string; - supabase?: { - client: SupabaseClient - handleResponse({ data, error, status }: { data: T, error: PostgrestError, status: number }): T - } - account?: Account - validate(): any -} - -export const CurrentUser = createParamDecorator( - (_data: unknown, context: ExecutionContext): NxtSupabaseUser => { - return context.getArgs()[0].user; - }, -); diff --git a/legacy/apps/tiamat/src/modules/auth/supabase.strategy.ts b/legacy/apps/tiamat/src/modules/auth/supabase.strategy.ts deleted file mode 100644 index 6d78662..0000000 --- a/legacy/apps/tiamat/src/modules/auth/supabase.strategy.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { Strategy } from 'passport-http-bearer'; -import { verify, JwtPayload } from 'jsonwebtoken'; -import { createClient, PostgrestError } from '@supabase/supabase-js'; -import { NxtSupabaseUser } from './nxt-supabase-user'; -import { throwSupabaseError } from '@core/modules/supabase.module'; -import { AccountsService } from '@core/modules/accounts/accounts.service'; -import { NXT_ORG_ID } from '@core/constants'; - -import type { Database } from '@core/types/supabase-types-adjusted'; - -const { SUPABASE_API_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET } = process.env; - -@Injectable() -export class SupabaseStrategy extends PassportStrategy(Strategy, 'supabase') { - constructor( - @Inject(AccountsService) - private readonly accountsService: AccountsService, - ) { super(); } - - async validate(token: string): Promise { - let decoded: JwtPayload; - try { - decoded = verify(token, SUPABASE_JWT_SECRET) as JwtPayload; - } - catch(err) { - console.error('Error verifying Supabase token', err); - throw new UnauthorizedException('Invalid or expired token'); - } - if(!decoded?.sub) throw new UnauthorizedException('No UID in token'); - - const { - email, - sub, - user_metadata: { full_name }, - app_metadata: { account_type, member_type, account_id, organization_id }, - } = decoded; - if(!account_id) throw new UnauthorizedException('This auth.user does not have a corresponding account'); - - const supabaseClient = createClient( - SUPABASE_API_URL, - SUPABASE_ANON_KEY, - { global: { headers: { Authorization: `Bearer ${ token }` } } }, - ); - - // @TEMPORARY :: Our old system is expecting an Account object, - // so we tack on the Account for now until it is not needed anymore - const account = await this.accountsService.findOne(account_id); - if(!account) throw new UnauthorizedException('No account for this user'); - - return { - email, - full_name, - account_type, - member_type, - account_id, - organization_id, - is_nxt_grid_member: organization_id === NXT_ORG_ID, - supabase_id: sub, - supabase: { - client: supabaseClient, - handleResponse({ data, error, status }: { data: T, error: PostgrestError, status: number }): T { - if (error) throwSupabaseError(error, status); - return data; - }, - }, - validate({ organization_id }: { organization_id?: number } = {}) { - if(organization_id) { - // @TODO :: Match against this.organization_id (let all NXT Grid pass) - console.info('Validate against organization id', organization_id); - } - return this.supabase.client.auth - .getUser() - .then(({ data, error }) => { - if(error) throwSupabaseError(error, 401); - if (!data?.user) throw new UnauthorizedException('No valid user data found'); - return data.user; - }) - ; - }, - account, - }; - } -} diff --git a/legacy/apps/tiamat/src/modules/grids/grids.controller.ts b/legacy/apps/tiamat/src/modules/grids/grids.controller.ts index c36101d..e2590d3 100644 --- a/legacy/apps/tiamat/src/modules/grids/grids.controller.ts +++ b/legacy/apps/tiamat/src/modules/grids/grids.controller.ts @@ -2,9 +2,12 @@ import { Body, Controller, Get, Param, Put, StreamableFile, UseGuards } from '@n import { GridsService } from './grids.service'; import { UpdateGridInput } from '@core/modules/grids/dto/update-grid.input'; import { DownloadService } from '../download/download.service'; -// import { CurrentUser, NxtSupabaseUser } from '../auth/nxt-supabase-user'; import { AuthenticationGuard } from '../auth/authentication.guard'; +/** + * 002d Task 11: `GET /grids/:id` (Flow XO) removed — not ported to OSS Foundation. + * Remaining routes stay until their owning capabilities absorb them (Metering / download). + */ @UseGuards(AuthenticationGuard) @Controller('grids') export class GridsController { @@ -14,6 +17,7 @@ export class GridsController { ) {} // Called by Yeti when done updating the DCU status, the MPPT info and the grid's diagnostics + // (loch weather also PUTs here under the legacy api-only ops-DB write funnel — dropped in OSS) @Put() updateGrids(@Body() body: UpdateGridInput[]) { return this.service.updateMany(body); @@ -25,15 +29,9 @@ export class GridsController { return new StreamableFile(file); } - // Used by Google App Script + // Used by Google App Script — re-home to Metering (ADR-013) @Get('/:id/metering_hardware_connectivity_stats') async getConnectivityStatsByGridId(@Param('id') gridId: number): Promise { return this.service.getMeteringHardwareConnectivityStatsByGridId(gridId); } - - // This is used by Flow XO to look for grids - @Get(':id') - getGrid(@Param('id') id) { - return this.service.findOne(id); - } } diff --git a/legacy/apps/tiamat/src/modules/grids/grids.module.ts b/legacy/apps/tiamat/src/modules/grids/grids.module.ts index dce373e..9b410a7 100644 --- a/legacy/apps/tiamat/src/modules/grids/grids.module.ts +++ b/legacy/apps/tiamat/src/modules/grids/grids.module.ts @@ -4,6 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { GridsController } from './grids.controller'; import { Grid } from '@core/modules/grids/entities/grid.entity'; +/** + * 002d Task 11: Nest `GET /grids/:id` removed from controller (not ported). + * Service retained for in-legacy callers + Metering re-home (connectivity / cabin-credit). + */ @Global() @Module({ imports: [ TypeOrmModule.forFeature([ Grid ]) ], diff --git a/legacy/apps/tiamat/src/modules/grids/grids.service.ts b/legacy/apps/tiamat/src/modules/grids/grids.service.ts index b888ece..34f6316 100644 --- a/legacy/apps/tiamat/src/modules/grids/grids.service.ts +++ b/legacy/apps/tiamat/src/modules/grids/grids.service.ts @@ -19,6 +19,14 @@ import { UpdateGridInput } from '@core/modules/grids/dto/update-grid.input'; import { getConnectivityStats } from './queries/getConnectivityStats.query'; import { SupabaseService } from '@core/modules/supabase.module'; +/** + * 002d Task 10/11 — Nest grids HTTP not ported to OSS Foundation (table/RLS/seed only). + * `GET /grids/:id` removed from controller at Task 11. Retain this service until Metering absorbs: + * - getMeteringHardwareConnectivityStatsByGridId → Metering (pending) + * - recalculateCabinCreditDepletion* → Metering (pending; also unused in-repo) + * Call-site Supabase selects replace shared findOne/update for new code; ops-DB write + * funnel through api is dropped. + */ @Injectable() export class GridsService extends CoreGridsService { constructor( diff --git a/legacy/apps/tiamat/src/modules/organizations/organizations.controller.ts b/legacy/apps/tiamat/src/modules/organizations/organizations.controller.ts deleted file mode 100644 index f573d6a..0000000 --- a/legacy/apps/tiamat/src/modules/organizations/organizations.controller.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Controller, /* Post, Body, */ UseGuards/* , Get, Param, ParseIntPipe */ } from '@nestjs/common'; -import { OrganizationsService } from './organizations.service'; -import { AuthenticationGuard } from '../auth/authentication.guard'; - -@UseGuards(AuthenticationGuard) -@Controller('organizations') -export class OrganizationsController { - constructor(protected readonly service: OrganizationsService) {} - - // This is used by Flow XO to look for organzations - // @Get(':id') - // getOrganization( - // @Param('id', ParseIntPipe) id: number, - // ) { - // // @TODO :: Check if we need this one - // console.info(` - // ================================================================= - // ================================================================= - // GET ORGANIZATION ${ id } IS CALLED - // ================================================================= - // ================================================================= - // `); - // return this.service.findOne(id); - // } -} diff --git a/legacy/apps/tiamat/src/modules/organizations/organizations.module.ts b/legacy/apps/tiamat/src/modules/organizations/organizations.module.ts deleted file mode 100644 index 3cf1585..0000000 --- a/legacy/apps/tiamat/src/modules/organizations/organizations.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { OrganizationsService } from './organizations.service'; -import { OrganizationsController } from './organizations.controller'; - -@Global() -@Module({ - providers: [ OrganizationsService ], - controllers: [ OrganizationsController ], -}) -export class OrganizationsModule { } diff --git a/legacy/apps/tiamat/src/modules/organizations/organizations.service.ts b/legacy/apps/tiamat/src/modules/organizations/organizations.service.ts deleted file mode 100644 index f2788d4..0000000 --- a/legacy/apps/tiamat/src/modules/organizations/organizations.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { SupabaseService } from '@core/modules/supabase.module'; - -@Injectable() -export class OrganizationsService { - constructor( - private readonly supabaseService: SupabaseService, - ) {} - async findOne(id: number) { - return this.supabaseService.adminClient - .from('organizations') - .select('*, wallet:wallets(*)') - .eq('id', id) - .maybeSingle() - .then(this.supabaseService.handleResponse) - ; - } -} diff --git a/legacy/apps/tiamat/src/modules/user-admin/dto/create-agent.dto.ts b/legacy/apps/tiamat/src/modules/user-admin/dto/create-agent.dto.ts deleted file mode 100644 index ee62a31..0000000 --- a/legacy/apps/tiamat/src/modules/user-admin/dto/create-agent.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; - -export class CreateAgentDto { - @IsString() - @IsNotEmpty() - full_name: string; - - @IsPhoneNumber() - phone: string; - - @IsEmail() - @IsOptional() - email?: string; - - @IsNumber() - grid_id: number; -} diff --git a/legacy/apps/tiamat/src/modules/user-admin/dto/update-agent.dto.ts b/legacy/apps/tiamat/src/modules/user-admin/dto/update-agent.dto.ts deleted file mode 100644 index 070ab22..0000000 --- a/legacy/apps/tiamat/src/modules/user-admin/dto/update-agent.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; - -export class UpdateAgentDto { - @IsNumber() - id: number; - - @IsString() - @IsNotEmpty() - full_name: string; - - @IsPhoneNumber() - phone: string; - - @IsEmail() - @IsOptional() - email?: string; -} diff --git a/legacy/apps/tiamat/src/modules/user-admin/user-admin.module.ts b/legacy/apps/tiamat/src/modules/user-admin/user-admin.module.ts deleted file mode 100644 index cb193ee..0000000 --- a/legacy/apps/tiamat/src/modules/user-admin/user-admin.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { UserAdminController } from './user-admin.controller'; -import { UserAdminService } from './user-admin.service'; - -@Global() -@Module({ - controllers: [ UserAdminController ], - providers: [ UserAdminService ], - exports: [ UserAdminService ], -}) -export class UserAdminModule { } diff --git a/legacy/apps/tiamat/src/modules/user-admin/user-admin.service.ts b/legacy/apps/tiamat/src/modules/user-admin/user-admin.service.ts deleted file mode 100644 index 6d698b4..0000000 --- a/legacy/apps/tiamat/src/modules/user-admin/user-admin.service.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { pick } from 'ramda'; -import { v4 as uuidv4 } from 'uuid'; -import { SupabaseService, throwSupabaseError } from '@core/modules/supabase.module'; -import { InviteMemberDto } from './dto/invite-member.dto'; -import { UpdateMemberDto } from './dto/update-member.dto'; -import { CreateAgentDto } from './dto/create-agent.dto'; -import { UpdateAgentDto } from './dto/update-agent.dto'; -import { CreateCustomerDto } from '@core/modules/customers/dto/create-customer.dto'; -import { UpdateCustomerDto } from './dto/update-customer.dto'; -import { NxtSupabaseUser } from '../auth/nxt-supabase-user'; -import { AccountTypeEnum, MemberTypeEnum } from '@core/types/supabase-types'; -import { UserResponse } from '@supabase/supabase-js'; - -export interface SupabaseUserMetadata { - full_name: string -} - -export interface SupabaseAppMetadata { - account_id: number - account_type: AccountTypeEnum - member_type?: MemberTypeEnum - organization_id: number - grid_id?: number -} - -const handleUserResponse = (res: UserResponse) => { - const { data, error } = res; - if (error) { - console.error('error handling User Admin', res); - throwSupabaseError(error); - } - return data.user; -}; - -@Injectable() -export class UserAdminService { - constructor(private readonly supabase: SupabaseService) { - // const password = 'PASSWORD'; - - // Superadmin - // this.createTestUser({ email: 'bobby.bol@nxtgrid.co', full_name: 'Bobby Bol', organization_id: 2, member_type: MemberType.SUPERADMIN, password }); - // Developer - // this.createTestUser({ email: 'bobby.bol+developer@nxtgrid.co', full_name: 'Bobby Bol Developer', organization_id: 9, member_type: MemberType.DEVELOPER, password }); - // Tech - // this.createTestUser({ email: 'bobby.bol+tech@nxtgrid.co', full_name: 'Bobby Bol Tech', organization_id: 9, member_type: MemberType.TECH, password }); - // Agent - // this.createTestUser({ isAgent: true, email: 'bobby.bol+agent3@nxtgrid.co', phone: '+31622748374', full_name: 'Bobby Bol Agent', grid_id: 5, password }); - } - - // async createTestUser(options) { - // if(process.env.NXT_ENV === 'production') return; - // const { email, phone, full_name, password, member_type, organization_id, grid_id, isAgent } = options; - - // let account_id; - - // if(isAgent) { - // const agent = await this.createAgent({ phone, email, full_name, grid_id }); - // account_id = agent.account_id; - // } - // else { - // const member = await this.inviteMember({ - // email, - // full_name, - // organization_id, - // member_type, - // redirectTo: '', - // }); - // account_id = member.account_id; - // } - - // const [ account ] = await this.supabase.adminClient - // .from('accounts') - // .select('supabase_id') - // .eq('id', account_id) - // .then(this.supabase.handleResponse) - // ; - - // await this.supabase.adminClient.auth.admin.updateUserById(account.supabase_id, { - // password, - // email_confirm: true, - // phone_confirm: true, - // }); - // } - - async inviteMember({ - email, - redirectTo, - full_name, - organization_id, - member_type, - busy_commissioning_id, - }: InviteMemberDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - // 1) Create a Supabase user which also creates an account - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - const user = await this.supabase.adminClient.auth.admin - .inviteUserByEmail(email, { data: user_metadata, redirectTo }) - .then(handleUserResponse) - ; - - // 2 Fetch the automatically created account - const account = await this.supabase.adminClient - .from('accounts') - .select('id') - .eq('supabase_id', user.id) - .single() - .then(this.supabase.handleResponse) - ; - - // 3) Now we have to update the Supabase user to store some ACL RLS properties - const app_metadata: SupabaseAppMetadata = { - account_id: account.id, - account_type: 'MEMBER', - member_type, - organization_id, - }; - await this.supabase.adminClient.auth.admin - .updateUserById(user.id, { app_metadata }) - .then(handleUserResponse) - ; - - // 4) Create a NXT Grid member tied to that account - const member = await this.supabase.adminClient - .from('members') - .insert({ - account_id: account.id, - member_type, - busy_commissioning_id, - }) - .select() - .single() - .then(this.supabase.handleResponse) - ; - - // 5) Create audit - const message = `${ author.full_name } invited a new member ${ full_name } (${ email }) with ${ member_type } role`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id, - member_id: member.id, - }) - .then(this.supabase.handleResponse) - ; - - return member; - } - - async updateMember({ - id, - full_name, - member_type, - training_level, - busy_commissioning_id, - subscribed_to_telegram_revenue_notifications, - hidden, - }: UpdateMemberDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - // 1) Update and get the member with account - const member = await this.supabase.adminClient - .from('members') - .update({ member_type, training_level, busy_commissioning_id, subscribed_to_telegram_revenue_notifications, hidden }) - .eq('id', id) - .select('id, account:accounts(id, supabase_id, organization_id)') - .single() - .then(this.supabase.handleResponse) - ; - - // 2) Update the Supabase User (which automatically updates account too) - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - const app_metadata: Partial = { member_type }; - await this.supabase.adminClient.auth.admin - .updateUserById(member.account.supabase_id, { user_metadata, app_metadata }) - .then(handleUserResponse) - ; - - // 3) Create audit - const message = `${ author.full_name } made updates to member ${ full_name }`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id: member.account.organization_id, - member_id: member.id, - }) - .then(this.supabase.handleResponse) - ; - - return member; - } - - async createAgent({ phone, email, full_name, grid_id }: CreateAgentDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - // 1) We create agents for grids but we need organization_id for RLS - const { organization_id } = await this.supabase.adminClient - .from('grids') - .select('organization_id') - .eq('id', grid_id) - .single() - .then(this.supabase.handleResponse) - ; - - // 2) Create a Supabase user which also creates an account - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - const user = await this.supabase.adminClient.auth.admin - .createUser({ - phone, - email, - phone_confirm: true, - email_confirm: true, - user_metadata, - }) - .then(handleUserResponse) - ; - - // 3) Fetch the automatically created account - const account = await this.supabase.adminClient - .from('accounts') - .select('id') - .eq('supabase_id', user.id) - .single() - .then(this.supabase.handleResponse) - ; - - // 4) Now we have to update the Supabase user to store some ACL RLS properties - const app_metadata: SupabaseAppMetadata = { - account_id: account.id, - account_type: 'AGENT', - organization_id, - grid_id, - }; - this.supabase.adminClient.auth.admin - .updateUserById(user.id, { app_metadata }) - ; - - // 5) Create an Agent tied to that account - const agent = await this.supabase.adminClient - .from('agents') - .insert({ - account_id: account.id, - grid_id, - }) - .select() - .single() - .then(this.supabase.handleResponse) - ; - - // 6) Create a Wallet for the agent - await this.supabase.adminClient - .from('wallets') - .insert({ agent_id: agent.id }) - .then(this.supabase.handleResponse) - ; - - // 7) Create audit - const message = `${ author.full_name } created a new agent ${ full_name }`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id, - grid_id, - agent_id: agent.id, - }) - .then(this.supabase.handleResponse) - ; - - return agent; - } - - async updateAgent({ id, full_name, phone, email }: UpdateAgentDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - // 1) Get the agent for some extra properties - const agent = await this.supabase.adminClient - .from('agents') - .select('id, account:accounts(id, supabase_id, organization_id), grid_id') - .eq('id', id) - .single() - .then(this.supabase.handleResponse) - ; - - // 2) Do the actual update - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - await this.supabase.adminClient.auth.admin - .updateUserById(agent.account.supabase_id, { phone, email, user_metadata }) - .then(handleUserResponse) - ; - - // 3) Create audit - const message = `${ author.full_name } updated agent ${ full_name }`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id: agent.account.organization_id, - grid_id: agent.grid_id, - agent_id: agent.id, - }) - .then(this.supabase.handleResponse) - ; - - return agent; - } - - async createCustomer(createCustomerInput: CreateCustomerDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - const { full_name, phone, email, grid_id } = createCustomerInput; - // If no email or phone number is passed (many customers can't provide a phone number), - // we make a fake email instead so we can still create the account - const _email = email ? email : phone ? undefined : `${ uuidv4() }@gmail.com`; - - // 1) We create customers for grids but we need organization_id for RLS - const { organization_id } = await this.supabase.adminClient - .from('grids') - .select('organization_id') - .eq('id', grid_id) - .single() - .then(this.supabase.handleResponse) - ; - - // 2) Create a Supabase user which also creates an account - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - const user = await this.supabase.adminClient.auth.admin - .createUser({ - phone, - email: _email, - phone_confirm: true, - email_confirm: true, - user_metadata, - }) - .then(handleUserResponse) - ; - - // 3) Fetch the automatically created account - const account = await this.supabase.adminClient - .from('accounts') - .select('id') - .eq('supabase_id', user.id) - .single() - .then(this.supabase.handleResponse) - ; - - // 4) Now we have to update the Supabase user to store some ACL RLS properties - const app_metadata: SupabaseAppMetadata = { - account_id: account.id, - account_type: 'CUSTOMER', - organization_id, - grid_id, - }; - this.supabase.adminClient.auth.admin - .updateUserById(user.id, { app_metadata }) - ; - - // 5) Create a customer tied to that account - const customer = await this.supabase.adminClient - .from('customers') - .insert({ - account_id: account.id, - grid_id, - ...pick([ - 'latitude', - 'longitude', - 'is_hidden_from_reporting', - 'lives_primarily_in_the_community', - 'generator_owned', - 'gender', - 'total_connection_fee', - ], createCustomerInput), - }) - .select('*, account:accounts(*)') - .single() - .then(this.supabase.handleResponse) - ; - - // 6) Create audit - const message = `${ author.full_name } created a new customer ${ full_name }`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id, - grid_id, - customer_id: customer.id, - }) - .then(this.supabase.handleResponse) - ; - - return customer; - } - - async updateCustomer({ - id, - full_name, - phone, - email, - latitude, - longitude, - is_hidden_from_reporting, - }: UpdateCustomerDto, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - // 1) Update and get the customer with account - const customer = await this.supabase.adminClient - .from('customers') - .update({ latitude, longitude, is_hidden_from_reporting }) - .eq('id', id) - .select('id, grid_id, account:accounts(id, supabase_id, organization_id)') - .single() - .then(this.supabase.handleResponse) - ; - - // 2) Update the Supabase User (which automatically updates account too) - const user_metadata: SupabaseUserMetadata = { full_name: full_name.trim() }; - await this.supabase.adminClient.auth.admin - .updateUserById(customer.account.supabase_id, { phone, email, user_metadata }) - .then(handleUserResponse) - ; - - // 3) Create audit - const message = `${ author.full_name } updated customer ${ full_name }`; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id: customer.account.organization_id, - grid_id: customer.grid_id, - customer_id: customer.id, - }) - .then(this.supabase.handleResponse) - ; - - return customer; - } - - // The ID passed here is the agent, customer, or member ID - async deleteAccount(id: number, accountType: AccountTypeEnum, author: NxtSupabaseUser) { - // 0) Since we're going to use the admin client, we want to validate the authoring user - await author.validate(); - - const table = - accountType === 'AGENT' ? 'agents' : - accountType === 'CUSTOMER' ? 'customers' : - accountType === 'MEMBER' ? 'members' : - null; - - const hasGrid = [ 'agents', 'customers' ].includes(table); - - // 1) Get appropriate account_id - const { account_id, grid_id } = await this.supabase.adminClient - .from(table) - .select(`account_id ${ hasGrid ? ', grid_id' : '' }`) - .eq('id', id) - .single() - .then(this.supabase.handleResponse) as { - account_id: number; - grid_id?: number | null; - } - ; - - // 3) Soft delete the account - const { supabase_id, full_name, organization_id } = await this.supabase.adminClient - .from('accounts') - .update({ deleted_at: new Date().toISOString() }) - .eq('id', account_id) - .select('supabase_id, full_name, organization_id') - .single() - .then(this.supabase.handleResponse) - ; - - // 4) Ban the Supabase user for a 100 years - await this.supabase.adminClient.auth.admin - .updateUserById(supabase_id, { ban_duration: '876000h' }) - .then(handleUserResponse) - ; - - const entity = table.slice(0, -1); - - // 5) Create audit - const message = `${ author.full_name } deleted ${ entity } ${ full_name }`; - const entityIdField = - accountType === 'AGENT' ? { agent_id: id } : - accountType === 'CUSTOMER' ? { customer_id: id } : - accountType === 'MEMBER' ? { member_id: id } : - {}; - this.supabase.adminClient - .from('audits') - .insert({ - message, - author_id: author.account_id, - organization_id, - grid_id, - ...entityIdField, - }) - .then(this.supabase.handleResponse) - ; - - return { accountType, full_name, deleted: true }; - } -} diff --git a/legacy/libs/core/src/index.ts b/legacy/libs/core/src/index.ts index cc4f2c8..a7f79d5 100644 --- a/legacy/libs/core/src/index.ts +++ b/legacy/libs/core/src/index.ts @@ -1,5 +1,2 @@ export { CoreTypeOrmModule } from './modules/core-typeorm.module'; -export { GlobalHttpModule } from './modules/global-http-module'; -export { GlobalSupabaseModule } from './modules/supabase.module'; -export { CoreLoggerModule } from './modules/logger-module'; export { CorePgModule } from './modules/core-pg'; diff --git a/legacy/libs/core/src/modules/accounts/accounts.service.ts b/legacy/libs/core/src/modules/accounts/accounts.service.ts deleted file mode 100644 index 84e1089..0000000 --- a/legacy/libs/core/src/modules/accounts/accounts.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; - -import { Account } from './entities/account.entity'; - -@Injectable() -export class AccountsService { - constructor( - @InjectRepository(Account) - protected accountRepository: Repository, - ) { } - - findOne(id: number) { - return this.accountRepository.findOne({ - relations: { - agent: { - grid: { - organization: true, - }, - wallet: true, - }, - organization: { - wallet: true, - }, - member: { - busy_commissioning: true, - }, - }, - where: { id }, - }); - } -} diff --git a/legacy/libs/core/src/modules/accounts/entities/account.entity.ts b/legacy/libs/core/src/modules/accounts/entities/account.entity.ts deleted file mode 100644 index be985d1..0000000 --- a/legacy/libs/core/src/modules/accounts/entities/account.entity.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Column, DeleteDateColumn, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; -import { CoreEntity } from '@core/types/core-entity'; -import { Agent } from '@core/modules/agents/entities/agent.entity'; -import { Customer } from '@core/modules/customers/entities/customer.entity'; -import { UssdSession } from '@core/modules/ussd-sessions/entities/ussd-session.entity'; -import { Organization } from '@core/modules/organizations/entities/organization.entity'; -import { Order } from '@core/modules/orders/entities/order.entity'; -import { DirectiveBatch } from '../../directive-batches/entities/directive-batch.entity'; -// import { Audit } from '@core/modules/audits/entities/audit.entity'; -import { MeteringHardwareInstallSession } from '@core/modules/metering-hardware-install-sessions/entities/metering-hardware-install-session.entity'; -import { ApiKey } from '@core/modules/api-keys/entities/api-key.entity'; -import { Note } from '@core/modules/notes/entities/note.entity'; -import { Payout } from '@core/modules/payouts/entities/payout.entity'; -import { MeterCreditTransfer } from '@core/modules/meter-credit-transfers/entities/meter-credit-transfer.entity'; -import { Notification } from '@core/modules/notifications/entities/notification.entity'; -import { Member } from '@core/modules/members/entities/member.entity'; - -@Entity('accounts') -export class Account extends CoreEntity { - @OneToOne(() => Agent, agent => agent.account) - agent?: Agent; - - @OneToOne(() => Customer, customer => customer.account) - customer?: Customer; - - @OneToOne(() => Member, member => member.account) - member?: Member; - - @Column('varchar', { nullable: true }) - supabase_id?: string; - - @Column('varchar', { nullable: true }) - full_name?: string; - - @Column('varchar', { nullable: true }) - email?: string; - - @Column('varchar', { nullable: true }) - phone?: string; - - @Column('varchar', { nullable: true, unique: true }) - telegram_id?: string; - - @Column('varchar', { nullable: true, unique: true }) - telegram_link_token?: string; - - @ManyToOne(() => Organization, organization => organization.accounts) - @JoinColumn({ name: 'organization_id' }) - organization?: Organization; - - @OneToMany(() => UssdSession, ussdSession => ussdSession.account) - ussd_sessions?: UssdSession[]; - - @OneToMany(() => Order, order => order.author) - orders?: Order[]; - - @OneToMany(() => DirectiveBatch, tou_rule => tou_rule.author) - tou_rules?: DirectiveBatch[]; - - @DeleteDateColumn({ type: 'timestamp', precision: 3, nullable: true }) - deleted_at?: Date; - - @OneToMany(() => MeteringHardwareInstallSession, metering_hardware_install_session => metering_hardware_install_session.author) - metering_hardware_install_sessions?: MeteringHardwareInstallSession[]; - - @OneToMany(() => ApiKey, api_key => api_key.account) - api_keys?: ApiKey[]; - - @OneToMany(() => Note, note => note.author) - notes?: Note[]; - - @OneToMany(() => Payout, payout => payout.approved_by) - payouts?: Payout[]; - - @OneToMany(() => Notification, notification => notification.account) - notifications?: Notification[]; -} diff --git a/legacy/libs/core/src/modules/api-keys/api-keys.service.ts b/legacy/libs/core/src/modules/api-keys/api-keys.service.ts deleted file mode 100644 index 7161fac..0000000 --- a/legacy/libs/core/src/modules/api-keys/api-keys.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ApiKey } from './entities/api-key.entity'; -import { Repository } from 'typeorm'; -import { InjectRepository } from '@nestjs/typeorm'; - -@Injectable() -export class ApiKeysService { - constructor( - @InjectRepository(ApiKey) - protected readonly apiKeyRepository: Repository, - ) { } - - findByKeyAndIsLocked(key: string, isLocked: false) { - const qb = this.apiKeyRepository.createQueryBuilder('api_keys'); - - return qb - .leftJoinAndSelect('api_keys.account', 'account') - .leftJoinAndSelect('account.customer', 'customer') - .leftJoinAndSelect('account.agent', 'agent') - .leftJoinAndSelect('account.member', 'member') - .leftJoinAndSelect('account.organization', 'organization') - .where('api_keys.is_locked = :is_locked', { is_locked: isLocked }) - .andWhere('api_keys.key = :key', { key: key }) - .getOne(); - } -} diff --git a/legacy/libs/core/src/modules/api-keys/entities/api-key.entity.ts b/legacy/libs/core/src/modules/api-keys/entities/api-key.entity.ts deleted file mode 100644 index bcdeec2..0000000 --- a/legacy/libs/core/src/modules/api-keys/entities/api-key.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CoreEntity } from '@core/types/core-entity'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; -import { Account } from '@core/modules/accounts/entities/account.entity'; - -@Entity('api_keys') -export class ApiKey extends CoreEntity { - @ManyToOne(() => Account, author => author.api_keys) - @JoinColumn({ name: 'account_id' }) - account: Account; - - @Column('bool', { default: false }) - is_locked: boolean; - - @Column('varchar', { nullable: true, unique: true }) - key: string; -} diff --git a/legacy/libs/core/src/modules/grids/grids.service.ts b/legacy/libs/core/src/modules/grids/grids.service.ts index e9e5159..e587e26 100644 --- a/legacy/libs/core/src/modules/grids/grids.service.ts +++ b/legacy/libs/core/src/modules/grids/grids.service.ts @@ -4,6 +4,11 @@ import { Repository } from 'typeorm'; import { Grid } from './entities/grid.entity'; // import { ExternalSystemEnum } from '@core/types/supabase-types'; +/** + * 002d Task 10/11 — not ported to OSS Foundation Nest. Grid table stays Foundation data; + * new hosts query at the call site. Retain until Metering / legacy absorption completes. + * findAll / findByOrganizationId* had no in-repo callers; findOne kept for legacy injectors. + */ @Injectable() export class GridsService { constructor( diff --git a/legacy/libs/core/src/modules/logger-module.ts b/legacy/libs/core/src/modules/logger-module.ts deleted file mode 100644 index ae64331..0000000 --- a/legacy/libs/core/src/modules/logger-module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { LokiService } from './loki/loki.service'; - -@Global() -@Module({ - providers: [ LokiService ], - exports: [ LokiService ], -}) -export class CoreLoggerModule {} diff --git a/legacy/libs/core/src/modules/members/entities/member.entity.ts b/legacy/libs/core/src/modules/members/entities/member.entity.ts deleted file mode 100644 index ce2a954..0000000 --- a/legacy/libs/core/src/modules/members/entities/member.entity.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Entity, Column, OneToOne, JoinColumn, ManyToOne } from 'typeorm'; -import { CoreEntity } from '@core/types/core-entity'; -import { Account } from '@core/modules/accounts/entities/account.entity'; -import { Grid } from '@core/modules/grids/entities/grid.entity'; -import { MemberTypeEnum } from '@core/types/supabase-types'; - -@Entity('members') -export class Member extends CoreEntity { - @Column('boolean', { default: false }) - subscribed_to_telegram_revenue_notifications?: boolean; - - @Column({ type: 'varchar' }) - member_type?: MemberTypeEnum; - - @OneToOne(() => Account) - @JoinColumn({ name: 'account_id' }) - account?: Account; - - @ManyToOne(() => Grid, grid => grid.being_commissioned_by) - @JoinColumn({ name: 'busy_commissioning_id' }) - busy_commissioning?: Grid; -} diff --git a/legacy/libs/core/src/modules/organizations/entities/organization.entity.ts b/legacy/libs/core/src/modules/organizations/entities/organization.entity.ts deleted file mode 100644 index 3b4912b..0000000 --- a/legacy/libs/core/src/modules/organizations/entities/organization.entity.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Entity, Column, OneToMany, OneToOne } from 'typeorm'; -import { Grid } from '@core/modules/grids/entities/grid.entity'; -import { Wallet } from '@core/modules/wallets/entities/wallet.entity'; -import { CoreEntity } from '@core/types/core-entity'; -import { Account } from '@core/modules/accounts/entities/account.entity'; -// import { Audit } from '@core/modules/audits/entities/audit.entity'; -import { BankAccount } from '@core/modules/bank-accounts/entities/bank-account.entity'; -import { Notification } from '@core/modules/notifications/entities/notification.entity'; - -@Entity('organizations') -export class Organization extends CoreEntity { - - @Column('varchar') - name?: string; // Why optional if required? - - @Column('varchar', { nullable: true }) - formal_name?: string; // Why optional if required? - - @Column('varchar', { nullable: true }) - email?: string; - - @Column('varchar', { nullable: true }) - phone?: string; - - @Column('varchar', { nullable: true }) - address?: string; - - @Column('varchar', { nullable: true }) - epicollect_contract_survey_slug?: string; - - @Column('varchar', { nullable: true }) - epicollect_contract_survey_secret?: string; - - @Column('varchar', { nullable: true }) - epicollect_contract_survey_client_id?: string; - - @Column({ type: 'timestamptz', nullable: true, precision: 3 }) - epicollect_contract_last_sync_at?: Date; - - @Column('varchar', { nullable: true, unique: false }) - developer_group_telegram_chat_id?: string; - - /** - * Relations - **/ - - @OneToMany(() => Grid, grid => grid.organization) - grids?: Grid[]; - - @OneToMany(() => Account, account => account.organization) - accounts?: Account[]; - - @OneToOne(() => Wallet, wallet => wallet.organization) - wallet?: Wallet; - - // @OneToMany(() => Audit, audit => audit.organization) - // audits?: Audit[]; - - @OneToMany(() => Notification, notification => notification.organization) - notifications?: Notification[]; -} diff --git a/legacy/libs/core/src/modules/supabase.module.ts b/legacy/libs/core/src/modules/supabase.module.ts deleted file mode 100644 index 3e62f75..0000000 --- a/legacy/libs/core/src/modules/supabase.module.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable, Global, Module, HttpException } from '@nestjs/common'; -import { createClient, PostgrestError } from '@supabase/supabase-js'; -import type { Database } from '@core/types/supabase-types-adjusted'; - -export const supabase = createClient(process.env.SUPABASE_API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); - -const isCloudflareHtmlError = (error: unknown): boolean => { - const msg = (error as any)?.message; - // Check if message exists, and if HTML - if(typeof msg !== 'string') return false; - const checkableMsg = msg.trim().toLowerCase(); - return checkableMsg.includes('html') || checkableMsg.includes('internal server error'); -}; - -export const throwSupabaseError = (error: unknown, status?: number): never => { - console.error('[SUPABASE RESPONSE ERROR]', { error, status }); - const message = typeof error === 'string' - ? error - : error instanceof Error - ? error.message - : (error as any)?.message ?? JSON.stringify(error); - throw new HttpException(message, status || 500); -}; - -@Injectable() -export class SupabaseService { - constructor() { } - - adminClient = supabase; - - HANDLE_RESPONSE_UNTYPED({ data, error, status }) { - if (error) { - if(isCloudflareHtmlError(error)) { - console.warn('Supabase service unavailable (5xx), returning null data'); - return null; - } - throwSupabaseError(error, status); - } - return data; - } - - handleResponse({ data, error, status }: { data: T, error: PostgrestError, status: number }): T { - if (error) { - // @TOCHECK :: See if this is sustainable, or whether we need to throw an error (and catch everywhere) - if(isCloudflareHtmlError(error)) { - console.warn('Supabase service unavailable (5xx), returning null data'); - return null; - } - throwSupabaseError(error, status); - } - return data; - } -} - -@Global() -@Module({ - providers: [ SupabaseService ], - exports: [ SupabaseService ], -}) -export class GlobalSupabaseModule { - constructor() { } -} diff --git a/libs/core/README.md b/libs/core/README.md index 0b50a3b..487ac8c 100644 --- a/libs/core/README.md +++ b/libs/core/README.md @@ -1,6 +1,29 @@ # core -This library was generated with [Nx](https://nx.dev). +Shared kernel (`@nxt/core`): config, generated Supabase types, cross-cutting infra. + +## Import style + +| Scope | Specifier | Notes | +|---|---|---| +| Bootstrap / config | `@nxt/core/config` | `loadConfig` / `getConfig` / `setConfig` / `requireEnv` — **no Nest modules** | +| Nest infra & helpers | `@nxt/core` | Logger, Global*Module, constants — not config | +| Generated / adjusted types | `@nxt/core/types/…` | Public type subpaths | +| Inside this package (cross-folder) | `#config/…`, `#modules/…`, `#types/…` | Package `"imports"` — direct files | +| Same folder / co-located sibling | relative `./…` | Prefer for local peers | + +Do **not** import config from the fat `@nxt/core` barrel (it is not re-exported there). + +Example (in-package): `import { requireEnv } from '#config/require-env.js'`. + +## Supabase types + +| Import | From | +|---|---| +| `Database` (client typing) | `@nxt/core/types/supabase-types-adjusted` | +| Enums, `Json`, table row/insert/update aliases | `@nxt/core/types/supabase-types` | + +Regenerate generated types: `pnpm generate-types:local` (requires `pnpm supabase start`). ## Building diff --git a/libs/core/jest.config.cts b/libs/core/jest.config.cts index a068211..52ae915 100644 --- a/libs/core/jest.config.cts +++ b/libs/core/jest.config.cts @@ -17,11 +17,18 @@ module.exports = { '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig] }, moduleFileExtensions: ['ts', 'js', 'html'], + // Libs: unit only under test/unit/ (integration/e2e live on host apps). + testMatch: [ '/test/unit/**/*.(spec|test).ts' ], // Source uses explicit `.js` extensions on relative imports (required by the NodeNext // module resolution used for the real build); strip them so Jest's resolver finds the // sibling `.ts` file instead. moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1' + // In-package subpath imports (package.json "imports"); strip `.js` for TS sources. + '^#config/(.*)\\.js$': '/src/config/$1', + '^#modules/(.*)\\.js$': '/src/modules/$1', + '^#types/(.*)\\.js$': '/src/types/$1', + // Source uses explicit `.js` extensions on relative imports (NodeNext); strip for Jest. + '^(\\.{1,2}/.*)\\.js$': '$1', }, coverageDirectory: 'test-output/jest/coverage' }; diff --git a/libs/core/package.json b/libs/core/package.json index 4203e9f..fc4c94c 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -17,6 +17,20 @@ "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "imports": { + "#config/*.js": { + "source": "./src/config/*.ts", + "default": "./dist/config/*.js" + }, + "#modules/*.js": { + "source": "./src/modules/*.ts", + "default": "./dist/modules/*.js" + }, + "#types/*.js": { + "source": "./src/types/*.ts", + "default": "./dist/types/*.js" + } + }, "exports": { "./package.json": "./package.json", ".": { @@ -25,18 +39,33 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./config": { + "source": "./src/config/index.ts", + "types": "./dist/config/index.d.ts", + "import": "./dist/config/index.js", + "default": "./dist/config/index.js" + }, "./types/supabase-types": { "source": "./src/types/supabase-types.ts", "types": "./dist/types/supabase-types.d.ts", "import": "./dist/types/supabase-types.js", "default": "./dist/types/supabase-types.js" + }, + "./types/supabase-types-adjusted": { + "source": "./src/types/supabase-types-adjusted.ts", + "types": "./dist/types/supabase-types-adjusted.d.ts", + "import": "./dist/types/supabase-types-adjusted.js", + "default": "./dist/types/supabase-types-adjusted.js" } }, "nx": { "name": "core" }, "dependencies": { + "@nestjs/axios": "^4.0.1", "@nestjs/common": "^11.0.0", + "@supabase/supabase-js": "^2.103.3", + "class-validator": "^0.15.1", "tslib": "^2.3.0", "zod": "^4.4.3" } diff --git a/libs/core/src/config/index.ts b/libs/core/src/config/index.ts index 493fe9b..6a48e24 100644 --- a/libs/core/src/config/index.ts +++ b/libs/core/src/config/index.ts @@ -26,3 +26,4 @@ export function getConfig(): NxtConfig { export { loadConfig } from './loader.js'; export type { LoadConfigOptions } from './loader.js'; export type { NxtConfig } from './schema.js'; +export { requireEnv } from './require-env.js'; diff --git a/libs/core/src/config/loader.ts b/libs/core/src/config/loader.ts index 6e4ae46..55f5723 100644 --- a/libs/core/src/config/loader.ts +++ b/libs/core/src/config/loader.ts @@ -17,7 +17,7 @@ export interface LoadConfigOptions { } /** - * Resolves, parses, validates, freezes, and stores the deployment configuration. Must run + * Resolves, parses, validates, freezes, and stores the host configuration. Must run * before `NestFactory.create` (ADR-007 decision 3) — nothing may read `getConfig()` earlier. * * Precedence: `NXT_CONFIG_JSON` (inline) → `NXT_CONFIG_URL` (fetch — reserved slot, not diff --git a/libs/core/src/config/require-env.ts b/libs/core/src/config/require-env.ts index 1d3de00..1adde6b 100644 --- a/libs/core/src/config/require-env.ts +++ b/libs/core/src/config/require-env.ts @@ -1,7 +1,7 @@ /** * Reads a required environment variable, throwing a clear, fail-fast error if it is unset or - * empty. This is the co-located pattern every Tier-2 adapter's `forRoot`/constructor copies - * (ADR-007 decision 9, layer 2) and the pattern the `demo` capability demonstrates. + * empty. Co-located pattern for Tier-2 adapter `forRoot`/constructors and Foundation providers + * (ADR-007 decision 9, layer 2) — e.g. `SupabaseService` admin client secrets. */ export function requireEnv(name: string): string { const value = process.env[name]; diff --git a/libs/core/src/config/schema.ts b/libs/core/src/config/schema.ts index 1cba207..320ed35 100644 --- a/libs/core/src/config/schema.ts +++ b/libs/core/src/config/schema.ts @@ -1,35 +1,21 @@ import { z } from 'zod'; -import { demoCapabilitySchema } from '../modules/demo/demo.schema.js'; - /** - * Category (B) data-references: deployment-specific row identifiers. + * Category (C) presentation/content — the only browser-safe subtree (ADR-007 decision 5). * - * Both fields stay plain configured values — not sourced from the database — per the 002c - * Task 3 decisions log (2026-07-14): `adminOrganizationId`'s DB-side fact - * (`organizations.organization_type = 'PLATFORM_OPERATOR'`, ADR-007 Amendment) and this config - * value are two independently-set facts accepted to agree without being mechanically linked. - * Optional so a zero-config boot still validates; a capability that depends on one is - * responsible for failing clearly at its own point of use when it is missing. + * Category (B) data-references (`deployment.adminOrganizationId` / `systemWalletId`) were + * dropped in 002d — see ADR-007 Amendment 2026-07-15 §B. Admin org is DB-native; system + * wallet returns under Payments capability config when that capability is imported. */ -const deploymentSchema = z.object({ - adminOrganizationId: z.number().int().positive().optional(), - systemWalletId: z.number().int().positive().optional(), -}).strict(); - -/** Category (C) presentation/content — the only browser-safe subtree (ADR-007 decision 5). */ const publicConfigSchema = z.object({ platformName: z.string(), }).strict(); /** * Category (A) Tier-1/Tier-2 flags. Grows with capability imports — do not speculate ahead. - * `demo` is temporary scaffolding proving Tier-1 gating (002c Task 3); delete it alongside - * `modules/demo/` once a real capability lands. + * Empty until the first real Tier-1 capability lands (demo scaffolding removed in 002d Task 4). */ -const capabilitiesSchema = z.object({ - demo: demoCapabilitySchema.optional(), -}).strict(); +const capabilitiesSchema = z.object({}).strict(); /** Category (A) Tier-3 optional augmentations. Grows with capability imports. */ const integrationsSchema = z.object({}).strict(); @@ -37,7 +23,6 @@ const integrationsSchema = z.object({}).strict(); export const nxtConfigSchema = z.object({ $schema: z.string().optional(), $schemaVersion: z.literal('1'), - deployment: deploymentSchema.default({}), public: publicConfigSchema, capabilities: capabilitiesSchema.default({}), integrations: integrationsSchema.default({}), diff --git a/libs/core/src/constants.ts b/libs/core/src/constants.ts new file mode 100644 index 0000000..5813f52 --- /dev/null +++ b/libs/core/src/constants.ts @@ -0,0 +1,2 @@ +/** Default row cap for Supabase `.limit()` calls — kept as a plain constant (002d Task 4). */ +export const SUPABASE_QUERY_LIMIT = 10_000; diff --git a/libs/core/src/index.ts b/libs/core/src/index.ts index a855905..d4a272f 100644 --- a/libs/core/src/index.ts +++ b/libs/core/src/index.ts @@ -1,8 +1,9 @@ -export { getPackageInfo } from './modules/platform/package-info.js'; - -export { getConfig, loadConfig, setConfig } from './config/index.js'; -export type { LoadConfigOptions, NxtConfig } from './config/index.js'; -export { requireEnv } from './config/require-env.js'; - -export { demoModules } from './modules/demo/demo-modules.js'; - +export { SUPABASE_QUERY_LIMIT } from './constants.js'; +export { CreateCustomerDto } from './modules/customers/dto/create-customer.dto.js'; +export { GlobalHttpModule } from './modules/global-http-module.js'; +export { GlobalLoggerModule } from './modules/logger/logger.module.js'; +export { + GlobalSupabaseModule, + SupabaseService, + throwSupabaseError, +} from './modules/supabase/supabase.module.js'; diff --git a/legacy/libs/core/src/modules/customers/dto/create-customer.dto.ts b/libs/core/src/modules/customers/dto/create-customer.dto.ts similarity index 61% rename from legacy/libs/core/src/modules/customers/dto/create-customer.dto.ts rename to libs/core/src/modules/customers/dto/create-customer.dto.ts index 5c9887c..d54c9a4 100644 --- a/legacy/libs/core/src/modules/customers/dto/create-customer.dto.ts +++ b/libs/core/src/modules/customers/dto/create-customer.dto.ts @@ -1,11 +1,27 @@ -import { GenderEnum, GeneratorTypeEnum } from '@core/types/supabase-types'; -import { IsBoolean, IsEmail, IsIn, IsLatitude, IsLongitude, IsNotEmpty, IsNumber, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; -import { Constants } from '@core/types/supabase-types'; - +import { + IsBoolean, + IsEmail, + IsIn, + IsLatitude, + IsLongitude, + IsNotEmpty, + IsNumber, + IsOptional, + IsPhoneNumber, + IsString, +} from 'class-validator'; + +import { + Constants, + type GenderEnum, + type GeneratorTypeEnum, +} from '#types/supabase-types.js'; + +/** Shared create-customer payload (api user-admin + worker). */ export class CreateCustomerDto { @IsString() @IsNotEmpty() - full_name: string; + full_name!: string; @IsPhoneNumber() @IsOptional() @@ -24,10 +40,10 @@ export class CreateCustomerDto { longitude?: number; @IsBoolean() - is_hidden_from_reporting: boolean; + is_hidden_from_reporting!: boolean; @IsNumber() - grid_id: number; + grid_id!: number; @IsBoolean() @IsOptional() diff --git a/libs/core/src/modules/demo/demo-modules.spec.ts b/libs/core/src/modules/demo/demo-modules.spec.ts deleted file mode 100644 index ab19a81..0000000 --- a/libs/core/src/modules/demo/demo-modules.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NxtConfig } from '../../config/schema.js'; -import { DemoModule } from './demo.module.js'; -import { demoModules } from './demo-modules.js'; - -function configWithDemo(demo?: { enabled: boolean }): NxtConfig { - return { - $schemaVersion: '1', - deployment: {}, - public: { platformName: 'Test' }, - capabilities: demo ? { demo } : {}, - integrations: {}, - }; -} - -describe('demoModules', () => { - const originalEnv = { ...process.env }; - - afterEach(() => { - process.env = { ...originalEnv }; - }); - - it('is not instantiated when the flag is off (default)', () => { - expect(demoModules(configWithDemo())).toEqual([]); - }); - - it('blocks boot with a clear MISSING error when enabled but its env var is absent', () => { - delete process.env.DEMO_REQUIRED_TOKEN; - - expect(() => demoModules(configWithDemo({ enabled: true }))).toThrow('MISSING DEMO_REQUIRED_TOKEN'); - }); - - it('loads the module when enabled and its env var is present', () => { - process.env.DEMO_REQUIRED_TOKEN = 'secret'; - - expect(demoModules(configWithDemo({ enabled: true }))).toEqual([ DemoModule ]); - }); -}); diff --git a/libs/core/src/modules/demo/demo-modules.ts b/libs/core/src/modules/demo/demo-modules.ts deleted file mode 100644 index 7b15383..0000000 --- a/libs/core/src/modules/demo/demo-modules.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { DynamicModule, Type } from '@nestjs/common'; - -import { requireEnv } from '../../config/require-env.js'; -import type { NxtConfig } from '../../config/schema.js'; -import { DemoModule } from './demo.module.js'; - -/** - * Demo capability contribution function — the pattern every later capability copies - * (ADR-007 decision 7). Proves the three Tier-1 honesty behaviors (decision 8): - * - * - flag off (default) → module not instantiated (returns `[]`) - * - flag on → module loads - * - flag on + its declared env var missing → boot blocks with `MISSING …` - * - * Temporary scaffolding: delete alongside the rest of `modules/demo/` once a real Tier-1 - * capability demonstrates the same pattern (002c decisions log, 2026-07-14). - */ -export function demoModules(config: NxtConfig): Array { - if (!config.capabilities.demo?.enabled) { - return []; - } - - requireEnv('DEMO_REQUIRED_TOKEN'); - - return [ DemoModule ]; -} diff --git a/libs/core/src/modules/demo/demo.module.ts b/libs/core/src/modules/demo/demo.module.ts deleted file mode 100644 index 32d6f48..0000000 --- a/libs/core/src/modules/demo/demo.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; - -import { DemoService } from './demo.service.js'; - -/** - * Temporary demo capability — see `demo.schema.ts`. - */ -@Module({ - providers: [ DemoService ], -}) -export class DemoModule {} diff --git a/libs/core/src/modules/demo/demo.schema.ts b/libs/core/src/modules/demo/demo.schema.ts deleted file mode 100644 index 9c2c543..0000000 --- a/libs/core/src/modules/demo/demo.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from 'zod'; - -/** - * Temporary demo capability — proves Tier-1 gating honesty (002c Task 3). Delete this schema - * alongside the rest of `modules/demo/` once a real capability demonstrates the same pattern - * (see the 002c decisions log, 2026-07-14). - */ -export const demoCapabilitySchema = z.object({ - enabled: z.boolean().default(false), -}).strict(); - -export type DemoCapabilityConfig = z.infer; diff --git a/libs/core/src/modules/demo/demo.service.ts b/libs/core/src/modules/demo/demo.service.ts deleted file mode 100644 index 4b2f9a8..0000000 --- a/libs/core/src/modules/demo/demo.service.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; - -@Injectable() -export class DemoService implements OnModuleInit { - private readonly logger = new Logger(DemoService.name); - - onModuleInit(): void { - this.logger.log('demo capability loaded (capabilities.demo.enabled=true)'); - } -} diff --git a/legacy/libs/core/src/modules/global-http-module.ts b/libs/core/src/modules/global-http-module.ts similarity index 71% rename from legacy/libs/core/src/modules/global-http-module.ts rename to libs/core/src/modules/global-http-module.ts index 55e9667..bdb0af6 100644 --- a/legacy/libs/core/src/modules/global-http-module.ts +++ b/libs/core/src/modules/global-http-module.ts @@ -1,6 +1,7 @@ import { HttpModule } from '@nestjs/axios'; import { Global, Module } from '@nestjs/common'; +/** Global Nest `HttpModule` (`@nestjs/axios`) for Foundation outbound HTTP. */ @Global() @Module({ imports: [ HttpModule ], diff --git a/libs/core/src/modules/logger/logger.module.ts b/libs/core/src/modules/logger/logger.module.ts new file mode 100644 index 0000000..46cea40 --- /dev/null +++ b/libs/core/src/modules/logger/logger.module.ts @@ -0,0 +1,28 @@ +import { Global, Module } from '@nestjs/common'; + +/** + * Logging slot for Foundation hosts. + * + * **Current:** no-op. Hosts use Nest’s built-in `Logger` and free `console.*`. + * `nestjs-pino` is deferred: under `nx serve` (webpack), pino transport workers + * break pretty logging (“one line then silence”), and pino-http / pretty ANSI + * fight quick `console` debugging. + * + * Keep this module in each host’s `infrastructure` array so restore is one place. + * + * **Restore nestjs-pino:** + * 1. Re-add deps on `@nxt/core`: `nestjs-pino`, `pino-http`; optional `pino-pretty`. + * 2. Replace this module body with: + * `LoggerModule.forRootAsync({ useFactory: () => ({ pinoHttp: buildPinoHttpOptions() }) })` + * — option builders live in `./logger.options.ts` (`autoLogging: false`, + * `LOG_PRETTY` opt-in only). + * 3. Host `main.ts`: `bufferLogs: true` + `app.useLogger(app.get(Logger))` + * (re-export nestjs-pino `Logger` from `@nxt/core` if useful). + * 4. Services that need DI logging: `PinoLogger` + `setContext` (not + * `@InjectPinoLogger(name)` — token snapshot race with barrel/module order). + * 5. Before enabling pretty under webpack: externals / `pino-webpack-plugin` + * so transport workers resolve. + */ +@Global() +@Module({}) +export class GlobalLoggerModule {} diff --git a/libs/core/src/modules/logger/logger.options.ts b/libs/core/src/modules/logger/logger.options.ts new file mode 100644 index 0000000..aad3996 --- /dev/null +++ b/libs/core/src/modules/logger/logger.options.ts @@ -0,0 +1,79 @@ +import type { NxtConfig } from '#config/schema.js'; +import { getConfig } from '#config/index.js'; + +/** + * Soft default; override with `LOG_LEVEL` (e.g. `debug`, `warn`). + * Not fail-fast — logging must not block boot the way secrets do. + * + * Used when nestjs-pino is restored via `GlobalLoggerModule` — see that module’s JSDoc. + */ +const DEFAULT_LOG_LEVEL = 'info'; + +interface PinoTransportTarget { + target: string; + level?: string; + options?: Record; +} + +/** + * Pretty-print only when `LOG_PRETTY` is a truthy env string (`1`, `true`, `yes`). + * Off by default. Under webpack serve, pino transport workers need externals / + * pino-webpack-plugin or pretty will log once and then stall. + */ +export function shouldPrettyPrint(): boolean { + const prettyFlag = process.env.LOG_PRETTY?.trim().toLowerCase(); + return prettyFlag === '1' || prettyFlag === 'true' || prettyFlag === 'yes'; +} + +/** + * Assembles pino transport targets from deployment config + env. + * + * **Tier-3 slots (unbuilt — document only):** + * - `integrations.loki` + `LOKI_URL` — dedicated Loki push transport (later). + * Free path today: ship structured JSON on stdout and let the platform scrape it + * (“Loki-via-stdout”). + * - `integrations.sentry` + `SENTRY_DSN` — Sentry transport / SDK (later). + * + * Until those integration flags exist on the schema, this only adds the optional + * `pino-pretty` target when `LOG_PRETTY` is set. + */ +export function assembleLogTransports(config: NxtConfig = getConfig()): PinoTransportTarget[] { + const targets: PinoTransportTarget[] = []; + const { integrations } = config; + + // Tier-3 — Loki (deferred): when `integrations.loki` lands on the schema — + // if (integrations.loki?.enabled && process.env.LOKI_URL) { … push Loki target } + + // Tier-3 — Sentry (deferred): when `integrations.sentry` lands on the schema — + // if (integrations.sentry?.enabled && process.env.SENTRY_DSN) { … push Sentry target } + + void integrations; + + if (shouldPrettyPrint()) { + targets.push({ + target: 'pino-pretty', + options: { + colorize: true, + singleLine: true, + translateTime: 'SYS:standard', + }, + }); + } + + return targets; +} + +/** + * Options for nestjs-pino / pino-http — wire from `GlobalLoggerModule` on restore. + * HTTP auto-logging stays off permanently unless product requirements change. + */ +export function buildPinoHttpOptions(config: NxtConfig = getConfig()): Record { + const level = process.env.LOG_LEVEL?.trim() || DEFAULT_LOG_LEVEL; + const targets = assembleLogTransports(config); + + return { + level, + autoLogging: false, + ...(targets.length > 0 ? { transport: { targets } } : {}), + }; +} diff --git a/libs/core/src/modules/platform/package-info.ts b/libs/core/src/modules/platform/package-info.ts deleted file mode 100644 index b9811c6..0000000 --- a/libs/core/src/modules/platform/package-info.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function getPackageInfo(): { name: string; version: string } { - return { - name: '@nxt/core', - version: '0.0.1', - }; -} diff --git a/libs/core/src/modules/supabase/supabase.errors.ts b/libs/core/src/modules/supabase/supabase.errors.ts new file mode 100644 index 0000000..ef43529 --- /dev/null +++ b/libs/core/src/modules/supabase/supabase.errors.ts @@ -0,0 +1,68 @@ +import { HttpException, ServiceUnavailableException } from '@nestjs/common'; + +/** Structural logger — avoids pnpm dual `@nestjs/common` peer identity mismatches. */ +interface ErrorLogger { + error(message: string, ...optionalParams: unknown[]): void; +} + +interface ErrorWithMessage { + message: unknown; +} + +function hasMessage(error: unknown): error is ErrorWithMessage { + return typeof error === 'object' && error !== null && 'message' in error; +} + +/** + * Detects HTML / generic 5xx bodies sometimes returned through the Data API + * (e.g. Cloudflare). Used to collapse gigantic HTML into a one-line log/response + * and map to HTTP 503 (infra unavailable — not an empty result set). + * + * Legacy soft-`null` was a panic brake when call sites were not throw-ready; OSS + * throws instead. Retries for this class of failure are a far-future optimization. + */ +function isCloudflareHtmlError(error: unknown): boolean { + const msg = hasMessage(error) ? error.message : undefined; + if (typeof msg !== 'string') { + return false; + } + const checkableMsg = msg.trim().toLowerCase(); + return checkableMsg.includes('html') || checkableMsg.includes('internal server error'); +} + +const CLOUDFLARE_HTML_SUMMARY = 'Supabase service unavailable (HTML/5xx body — likely Cloudflare)'; + +function resolveErrorMessage(error: unknown): string { + if (typeof error === 'string') { + return error; + } + if (error instanceof Error) { + return error.message; + } + if (hasMessage(error) && typeof error.message === 'string') { + return error.message; + } + return JSON.stringify(error); +} + +/** Logs then throws an HTTP exception — exported for auth and other Foundation consumers. */ +export function throwSupabaseError( + error: unknown, + status: number | undefined, + logger: ErrorLogger, +): never { + if (isCloudflareHtmlError(error)) { + // @TEMPORARY :: Logging the entire error so we can tighten the html check + console.info(error); + logger.error( + `[SUPABASE RESPONSE ERROR] status=${ status ?? 'unknown' } ${ CLOUDFLARE_HTML_SUMMARY }`, + ); + throw new ServiceUnavailableException(CLOUDFLARE_HTML_SUMMARY); + } + + const message = resolveErrorMessage(error); + logger.error( + `[SUPABASE RESPONSE ERROR] status=${ status ?? 'unknown' } ${ message }`, + ); + throw new HttpException(message, status ?? 500); +} diff --git a/libs/core/src/modules/supabase/supabase.module.ts b/libs/core/src/modules/supabase/supabase.module.ts new file mode 100644 index 0000000..0804e44 --- /dev/null +++ b/libs/core/src/modules/supabase/supabase.module.ts @@ -0,0 +1,91 @@ +import { + Global, + Injectable, + InternalServerErrorException, + Logger, + Module, +} from '@nestjs/common'; +import { createClient, type PostgrestError, type SupabaseClient } from '@supabase/supabase-js'; + +import { requireEnv } from '#config/require-env.js'; +import type { Database } from '#types/supabase-types-adjusted.js'; +import { throwSupabaseError } from './supabase.errors.js'; + +export { throwSupabaseError } from './supabase.errors.js'; + +interface SupabaseResponse { + data: T; + error: PostgrestError | null; + status: number; +} + +@Injectable() +export class SupabaseService { + readonly adminClient: SupabaseClient; + private readonly logger = new Logger(SupabaseService.name); + + constructor() { + this.adminClient = createClient( + requireEnv('SUPABASE_URL'), + requireEnv('SUPABASE_SECRET_KEY'), + { + auth: { + // Server-side admin client: no end-user session to persist or refresh. + persistSession: false, + autoRefreshToken: false, + }, + }, + ); + } + + /** + * Permissive: for `.maybeSingle()`, multi-row selects, etc. + * Errors (including Cloudflare HTML 5xx) throw — never soft-return null. + */ + HANDLE_RESPONSE_UNTYPED = ({ + data, + error, + status, + }: SupabaseResponse): unknown => { + if (error) { + throwSupabaseError(error, status, this.logger); + } + return data; + }; + + /** + * Permissive: for `.maybeSingle()`, multi-row selects, etc. + * `data` may be null when zero rows. Errors throw (no Cloudflare soft-null). + */ + handleResponse = ({ data, error, status }: SupabaseResponse): T => { + if (error) { + throwSupabaseError(error, status, this.logger); + } + return data; + }; + + /** + * Strict: use after `.single()` only — value or throw. + * PostgREST usually sets `error` on 0 rows; null `data` without error is treated as invariant break (500). + */ + handleSingle = ({ + data, + error, + status, + }: SupabaseResponse): NonNullable => { + if (error) { + throwSupabaseError(error, status, this.logger); + } + if (data == null) { + throw new InternalServerErrorException('Expected a single row, got none'); + } + return data; + }; +} + +@Global() +@Module({ + providers: [ SupabaseService ], + exports: [ SupabaseService ], +}) +export class GlobalSupabaseModule {} diff --git a/legacy/libs/core/src/types/supabase-types-adjusted.ts b/libs/core/src/types/supabase-types-adjusted.ts similarity index 50% rename from legacy/libs/core/src/types/supabase-types-adjusted.ts rename to libs/core/src/types/supabase-types-adjusted.ts index eed79bf..033c421 100644 --- a/legacy/libs/core/src/types/supabase-types-adjusted.ts +++ b/libs/core/src/types/supabase-types-adjusted.ts @@ -1,4 +1,12 @@ -import { Database as OriginalDatabase } from './supabase-types'; +/** + * Augmented `Database` type — PostGIS `location_geom` as GeoJSON `Point` where the generator + * emits `unknown`. + * + * **Import convention (002d):** + * - `Database` → `@nxt/core/types/supabase-types-adjusted` (this module) + * - Enums, row/insert/update aliases, `Json`, etc. → `@nxt/core/types/supabase-types` + */ +import { Database as OriginalDatabase } from './supabase-types.js'; export type Database = OriginalDatabase & { public: OriginalDatabase['public'] & { @@ -7,17 +15,17 @@ export type Database = OriginalDatabase & { Row: OriginalDatabase['public']['Tables']['grids']['Row'] & { location_geom: { type: 'Point'; - coordinates: number[]; - } | null - } + coordinates: [ number, number ]; + } | null; + }; }; poles: OriginalDatabase['public']['Tables']['poles'] & { Row: OriginalDatabase['public']['Tables']['poles']['Row'] & { location_geom: { type: 'Point'; - coordinates: number[]; - } - } + coordinates: [ number, number ]; + }; + }; }; }; }; diff --git a/libs/core/src/types/supabase-types.ts b/libs/core/src/types/supabase-types.ts index 0430430..0bc7c86 100644 --- a/libs/core/src/types/supabase-types.ts +++ b/libs/core/src/types/supabase-types.ts @@ -12,7 +12,7 @@ export type Database = { id: number; organization_id: number | null; phone: string | null; - supabase_id: string | null; + supabase_id: string; telegram_id: string | null; telegram_link_token: string | null; }; @@ -24,7 +24,7 @@ export type Database = { id?: number; organization_id?: number | null; phone?: string | null; - supabase_id?: string | null; + supabase_id: string; telegram_id?: string | null; telegram_link_token?: string | null; }; @@ -36,7 +36,7 @@ export type Database = { id?: number; organization_id?: number | null; phone?: string | null; - supabase_id?: string | null; + supabase_id?: string; telegram_id?: string | null; telegram_link_token?: string | null; }; diff --git a/libs/core/src/config/__fixtures__/from-default.config.json b/libs/core/test/unit/config/__fixtures__/from-default.config.json similarity index 87% rename from libs/core/src/config/__fixtures__/from-default.config.json rename to libs/core/test/unit/config/__fixtures__/from-default.config.json index 8f70029..43fa908 100644 --- a/libs/core/src/config/__fixtures__/from-default.config.json +++ b/libs/core/test/unit/config/__fixtures__/from-default.config.json @@ -1,6 +1,5 @@ { "$schemaVersion": "1", - "deployment": {}, "public": { "platformName": "From bundled default" }, diff --git a/libs/core/src/config/__fixtures__/from-path.config.json b/libs/core/test/unit/config/__fixtures__/from-path.config.json similarity index 87% rename from libs/core/src/config/__fixtures__/from-path.config.json rename to libs/core/test/unit/config/__fixtures__/from-path.config.json index ff810e2..6c6d204 100644 --- a/libs/core/src/config/__fixtures__/from-path.config.json +++ b/libs/core/test/unit/config/__fixtures__/from-path.config.json @@ -1,6 +1,5 @@ { "$schemaVersion": "1", - "deployment": {}, "public": { "platformName": "From NXT_CONFIG_PATH" }, diff --git a/libs/core/src/config/__fixtures__/invalid-schema-version.config.json b/libs/core/test/unit/config/__fixtures__/invalid-schema-version.config.json similarity index 86% rename from libs/core/src/config/__fixtures__/invalid-schema-version.config.json rename to libs/core/test/unit/config/__fixtures__/invalid-schema-version.config.json index 43c905a..a94e72c 100644 --- a/libs/core/src/config/__fixtures__/invalid-schema-version.config.json +++ b/libs/core/test/unit/config/__fixtures__/invalid-schema-version.config.json @@ -1,6 +1,5 @@ { "$schemaVersion": "2", - "deployment": {}, "public": { "platformName": "Should be rejected" }, diff --git a/libs/core/src/config/index.spec.ts b/libs/core/test/unit/config/index.spec.ts similarity index 79% rename from libs/core/src/config/index.spec.ts rename to libs/core/test/unit/config/index.spec.ts index 87f4984..3556df6 100644 --- a/libs/core/src/config/index.spec.ts +++ b/libs/core/test/unit/config/index.spec.ts @@ -1,9 +1,8 @@ -import { getConfig, setConfig } from './index.js'; -import type { NxtConfig } from './schema.js'; +import { getConfig, setConfig } from '../../../src/config/index.js'; +import type { NxtConfig } from '../../../src/config/schema.js'; const testConfig: NxtConfig = Object.freeze({ $schemaVersion: '1', - deployment: {}, public: { platformName: 'Test' }, capabilities: {}, integrations: {}, diff --git a/libs/core/src/config/loader.spec.ts b/libs/core/test/unit/config/loader.spec.ts similarity index 97% rename from libs/core/src/config/loader.spec.ts rename to libs/core/test/unit/config/loader.spec.ts index 77066d4..68bccf1 100644 --- a/libs/core/src/config/loader.spec.ts +++ b/libs/core/test/unit/config/loader.spec.ts @@ -1,7 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { loadConfig } from './loader.js'; +import { loadConfig } from '../../../src/config/loader.js'; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), '__fixtures__'); const FROM_PATH_FIXTURE = join(FIXTURES_DIR, 'from-path.config.json'); @@ -10,7 +10,6 @@ const INVALID_SCHEMA_VERSION_FIXTURE = join(FIXTURES_DIR, 'invalid-schema-versio const INLINE_JSON = JSON.stringify({ $schemaVersion: '1', - deployment: {}, public: { platformName: 'From NXT_CONFIG_JSON' }, capabilities: {}, integrations: {}, diff --git a/libs/core/tsconfig.spec.json b/libs/core/tsconfig.spec.json index 20f4919..8d7d1ca 100644 --- a/libs/core/tsconfig.spec.json +++ b/libs/core/tsconfig.spec.json @@ -11,9 +11,8 @@ "include": [ "jest.config.ts", "jest.config.cts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" + "src/**/*.d.ts", + "test/**/*.ts" ], "references": [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c8539a..e07048a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 23.0.2(@babel/traverse@7.29.7)(@swc/core@1.15.43(@swc/helpers@0.5.23))(nx@23.0.2(@swc/core@1.15.43(@swc/helpers@0.5.23))) '@nx/nest': specifier: 23.0.2 - version: 23.0.2(92f82e6bb47fe288748742e8fb76ebec) + version: 23.0.2(d5d1d2d47871c69071379b8e39d48666) '@nx/node': specifier: 23.0.2 version: 23.0.2(@babel/traverse@7.29.7)(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@22.20.1)(@zkochan/js-yaml@0.0.7)(babel-plugin-macros@3.1.0)(eslint@9.39.5(jiti@2.7.0))(express@5.2.1)(jest@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@22.20.1)(typescript@5.9.3)))(nx@23.0.2(@swc/core@1.15.43(@swc/helpers@0.5.23)))(ts-jest@29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.3.0)(jest@30.3.0(@types/node@22.20.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@22.20.1)(typescript@5.9.3)))(typescript@5.9.3))(ts-node@10.9.1(@swc/core@1.15.43(@swc/helpers@0.5.23))(@types/node@22.20.1)(typescript@5.9.3))(typescript@5.9.3) @@ -112,16 +112,43 @@ importers: dependencies: '@nestjs/common': specifier: ^11.0.0 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': + specifier: ^11.0.5 + version: 11.0.5(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-express': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) '@nxt/core': specifier: workspace:* version: link:../../libs/core + '@supabase/supabase-js': + specifier: ^2.110.5 + version: 2.110.5 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.15.1 + version: 0.15.1 + jose: + specifier: ^6.2.3 + version: 6.2.3 + passport: + specifier: ^0.7.0 + version: 0.7.0 + passport-headerapikey: + specifier: ^1.2.2 + version: 1.2.2 + passport-http-bearer: + specifier: ^1.0.1 + version: 1.0.1 + ramda: + specifier: ^0.31.3 + version: 0.31.3 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -134,22 +161,37 @@ importers: devDependencies: '@nestjs/testing': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + '@types/passport': + specifier: ^1.0.17 + version: 1.0.17 + '@types/passport-http-bearer': + specifier: ^1.0.42 + version: 1.0.42 + '@types/ramda': + specifier: ^0.31.1 + version: 0.31.1 + '@types/supertest': + specifier: ^7.2.1 + version: 7.2.1 + supertest: + specifier: ^7.2.2 + version: 7.2.2 apps/worker: dependencies: '@nestjs/common': specifier: ^11.0.0 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) '@nestjs/schedule': specifier: ^6.1.3 - version: 6.1.3(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + version: 6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) '@nxt/core': specifier: workspace:* version: link:../../libs/core @@ -165,13 +207,22 @@ importers: devDependencies: '@nestjs/testing': specifier: ^11.0.0 - version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) + version: 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28) libs/core: dependencies: + '@nestjs/axios': + specifier: ^4.0.1 + version: 4.0.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.18.1)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.0 - version: 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@supabase/supabase-js': + specifier: ^2.103.3 + version: 2.110.5 + class-validator: + specifier: ^0.15.1 + version: 0.15.1 tslib: specifier: ^2.3.0 version: 2.8.1 @@ -1237,6 +1288,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/axios@4.0.1': + resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + axios: ^1.3.1 + rxjs: ^7.0.0 + '@nestjs/common@11.1.28': resolution: {integrity: sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==} peerDependencies: @@ -1268,6 +1326,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/passport@11.0.5': + resolution: {integrity: sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + passport: ^0.5.0 || ^0.6.0 || ^0.7.0 + '@nestjs/platform-express@11.1.28': resolution: {integrity: sha512-hU+9Sz4m+onHrR5AmelI59QKmY/Re546bPnygnpqqeQdHDiJpBgjWbL4t6Jr73CBpS60cpyng7WzjgphNB9iwA==} peerDependencies: @@ -1508,6 +1572,9 @@ packages: '@nx/workspace@23.0.2': resolution: {integrity: sha512-7As52HT7l7ypwukR0cKZIDZ771O0hYTDwkfJmzWPHl3YghiROnK3/Xlh/84ITo+4gtCMORUyZXZgQfOi8136ug==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1655,6 +1722,10 @@ packages: '@sinonjs/fake-timers@15.4.0': resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@supabase/auth-js@2.110.5': + resolution: {integrity: sha512-QSlI5CNeEefHP95/GbeMhNgr8aHEHiXFh8c1IWthYyI9ZzBwEigYzJFCw4Ff+GkL8OK9tArBmGGv6rpg5zLXSw==} + engines: {node: '>=22.0.0'} + '@supabase/cli-darwin-arm64@2.109.1': resolution: {integrity: sha512-tkn8tfunyqIL7RE+7DVjg6Ql2cJLPkGgh9cPafp2LbXI0qDgds0TaS+UOTHQEjci8JQXXe2wS00+122ko2QI8A==} cpu: [arm64] @@ -1699,6 +1770,29 @@ packages: cpu: [x64] os: [win32] + '@supabase/functions-js@2.110.5': + resolution: {integrity: sha512-nuQuoIoEGT8ukrwr6THlY5v50bSMJ2rqti1FFsGyDaC98POLmcFQVFFh23PVPhRsKWUxrFc0MpmEoHKa7CY4mg==} + engines: {node: '>=22.0.0'} + + '@supabase/phoenix@0.4.4': + resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + + '@supabase/postgrest-js@2.110.5': + resolution: {integrity: sha512-eObfgBjxLPzFakwMpUmsv8nuNPOhoDhzzN8cwvEvGkxkmuRIZcOCYH9+DRbpKnxh7tgsBZW+KWtaZI2j98b2/g==} + engines: {node: '>=22.0.0'} + + '@supabase/realtime-js@2.110.5': + resolution: {integrity: sha512-VtOZxw5jfrc37KgIfFdp9SOFJjtvJ0qU+gT8CPQjL7cqy4DJlaTXacw5aBBogLZksqAI0YN67qhlDaMKFwfOuw==} + engines: {node: '>=22.0.0'} + + '@supabase/storage-js@2.110.5': + resolution: {integrity: sha512-7GkOZlrYknVGVPyijoDbu5OXGvQ2oQ6dkU0juAkIMPZ9QgtmJ8IPrxe76zGSbmzbaC1GdKw7pqeXEi2HT67sbA==} + engines: {node: '>=22.0.0'} + + '@supabase/supabase-js@2.110.5': + resolution: {integrity: sha512-cAO1Nm+CCogRNVXN93bBkh0vjOdLM5e6J9gB/cHV9Lqni/gEmN2HJFrnn4NI33GYIfmlh4Wbm6siH+XXRgpexA==} + engines: {node: '>=22.0.0'} + '@swc/core-darwin-arm64@1.15.43': resolution: {integrity: sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA==} engines: {node: '>=10'} @@ -1829,6 +1923,9 @@ packages: '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + '@types/accepts@1.3.7': + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1853,6 +1950,15 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/content-disposition@0.5.9': + resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cookies@0.9.2': + resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} + '@types/esquery@1.5.4': resolution: {integrity: sha512-yYO4Q8H+KJHKW1rEeSzHxcZi90durqYgWVfnh5K6ZADVBjBv2e1NEveYX5yT2bffgN7RqzH3k9930m+i2yBoMA==} @@ -1865,6 +1971,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/http-assert@1.5.6': + resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1886,9 +1995,21 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.9': + resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} + + '@types/koa@3.0.3': + resolution: {integrity: sha512-TdtNEJ7sYSrFQcVuS2ySsVqnq5EyE3oJbnfFJvkC9UtGP4Kpem5KE7r+ivHIbIAQAofSqnlB5D3vkfYO69TQpg==} + '@types/luxon@3.7.2': resolution: {integrity: sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -1898,9 +2019,18 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/passport-http-bearer@1.0.42': + resolution: {integrity: sha512-cGezyf9hy3Cth+zWS779FR9XYhIX/DExsVZURqcSeUU/nhj0Aw8PUhvCyfS35ScwOSd5AFiFhtfWmqHa/2aYZg==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + '@types/ramda@0.31.1': + resolution: {integrity: sha512-Vt6sFXnuRpzaEj+yeutA0q3bcAsK7wdPuASIzR9LXqL4gJPyFw8im9qchlbp4ltuf3kDEIRmPJTD/Fkg60dn7g==} + '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} @@ -1925,6 +2055,15 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/superagent@8.1.11': + resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==} + + '@types/supertest@7.2.1': + resolution: {integrity: sha512-4CbBvoYVLHL7+yhbYrZET0vsvuyXTC05aRe7dNQkwMzm56auceoy6Yu3K50uZmwfHna1os3CMSgM/3QVkUtPTw==} + + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2304,6 +2443,9 @@ packages: array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.10: resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} engines: {node: '>=12.0.0'} @@ -2324,6 +2466,9 @@ packages: axios@1.16.1: resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + babel-jest@30.3.0: resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2573,6 +2718,12 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.15.1: + resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2650,6 +2801,9 @@ packages: common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -2702,6 +2856,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + copy-anything@2.0.6: resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} @@ -2935,6 +3092,9 @@ packages: engines: {node: '>= 16.0.0'} hasBin: true + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -3300,6 +3460,10 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3495,6 +3659,10 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -4024,6 +4192,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.13.8: + resolution: {integrity: sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==} + license-webpack-plugin@4.0.2: resolution: {integrity: sha512-771TFWFD70G1wLTC4oU2Cw4qvtmNrIw+wRvBtn+okgHl7slJVi7zfNcdmqDL72BojM30VNJ2UHylr1o77U37Jw==} peerDependencies: @@ -4088,6 +4259,9 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4193,6 +4367,11 @@ packages: engines: {node: '>=4'} hasBin: true + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4470,6 +4649,21 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + passport-headerapikey@1.2.2: + resolution: {integrity: sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==} + + passport-http-bearer@1.0.1: + resolution: {integrity: sha512-SELQM+dOTuMigr9yu8Wo4Fm3ciFfkMq5h/ZQ8ffi4ELgZrX1xh9PlglqZdcUZ1upzJD/whVyt+YWF62s3U6Ipw==} + engines: {node: '>= 0.4.0'} + + passport-strategy@1.0.0: + resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==} + engines: {node: '>= 0.4.0'} + + passport@0.7.0: + resolution: {integrity: sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==} + engines: {node: '>= 0.4.0'} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -4506,6 +4700,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pause@0.0.1: + resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4821,6 +5018,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + ramda@0.31.3: + resolution: {integrity: sha512-xKADKRNnqmDdX59PPKLm3gGmk1ZgNnj3k7DryqWwkamp4TJ6B36DdpyKEQ0EoEYmH2R62bV4Q+S0ym2z8N2f3Q==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -5374,6 +5574,14 @@ packages: resolution: {integrity: sha512-N2yP2MHTxOxXBWhfn3poudpJn4pkPosAUo7J/46FTou/l7wOwFi9tox8NSN6HljWkfM0zhwPRimNNGC9XBMoxQ==} hasBin: true + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5569,6 +5777,9 @@ packages: '@swc/wasm': optional: true + ts-toolbelt@9.6.0: + resolution: {integrity: sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==} + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -5617,6 +5828,9 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + types-ramda@0.31.0: + resolution: {integrity: sha512-vaoC35CRC3xvL8Z6HkshDbi6KWM1ezK0LHN0YyxXWUn9HKzBNg/T3xSGlJZjCYspnOD3jE7bcizsp0bUXZDxnQ==} + typescript-eslint@8.63.0: resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5707,6 +5921,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + varint@6.0.0: resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} @@ -7355,7 +7573,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/axios@4.0.1(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.18.1)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + axios: 1.18.1 + rxjs: 7.8.2 + + '@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.4 iterare: 1.2.1 @@ -7364,12 +7588,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.15.1 transitivePeerDependencies: - supports-color - '@nestjs/core@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.4.2 @@ -7378,12 +7605,17 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) - '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': + '@nestjs/passport@11.0.5(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + + '@nestjs/platform-express@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': + dependencies: + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.6 express: 5.2.1 multer: 2.2.0 @@ -7392,10 +7624,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': + '@nestjs/schedule@6.1.3(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.9.5)(typescript@5.9.3)': @@ -7411,13 +7643,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': + '@nestjs/testing@11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28)(@nestjs/platform-express@11.1.28)': dependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) + '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.28) '@noble/ciphers@1.3.0': {} @@ -7578,7 +7810,7 @@ snapshots: - nx - supports-color - '@nx/nest@23.0.2(92f82e6bb47fe288748742e8fb76ebec)': + '@nx/nest@23.0.2(d5d1d2d47871c69071379b8e39d48666)': dependencies: '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.9.5)(typescript@5.9.3) '@nx/devkit': 23.0.2(nx@23.0.2(@swc/core@1.15.43(@swc/helpers@0.5.23))) @@ -7588,8 +7820,8 @@ snapshots: semver: 7.8.5 tslib: 2.8.1 optionalDependencies: - '@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.28(@nestjs/common@11.1.28(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 transitivePeerDependencies: @@ -7780,6 +8012,10 @@ snapshots: - '@swc-node/register' - '@swc/core' + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -7956,6 +8192,10 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@supabase/auth-js@2.110.5': + dependencies: + tslib: 2.8.1 + '@supabase/cli-darwin-arm64@2.109.1': optional: true @@ -7980,6 +8220,34 @@ snapshots: '@supabase/cli-windows-x64@2.109.1': optional: true + '@supabase/functions-js@2.110.5': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.4': {} + + '@supabase/postgrest-js@2.110.5': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.110.5': + dependencies: + '@supabase/phoenix': 0.4.4 + tslib: 2.8.1 + + '@supabase/storage-js@2.110.5': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.110.5': + dependencies: + '@supabase/auth-js': 2.110.5 + '@supabase/functions-js': 2.110.5 + '@supabase/postgrest-js': 2.110.5 + '@supabase/realtime-js': 2.110.5 + '@supabase/storage-js': 2.110.5 + '@swc/core-darwin-arm64@1.15.43': optional: true @@ -8085,6 +8353,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@types/accepts@1.3.7': + dependencies: + '@types/node': 22.20.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -8124,6 +8396,17 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/content-disposition@0.5.9': {} + + '@types/cookiejar@2.1.5': {} + + '@types/cookies@0.9.2': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 4.17.25 + '@types/keygrip': 1.0.6 + '@types/node': 22.20.1 + '@types/esquery@1.5.4': dependencies: '@types/estree': 1.0.9 @@ -8144,6 +8427,8 @@ snapshots: '@types/qs': 6.15.1 '@types/serve-static': 1.15.10 + '@types/http-assert@1.5.6': {} + '@types/http-errors@2.0.5': {} '@types/http-proxy@1.17.17': @@ -8167,8 +8452,27 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.9': + dependencies: + '@types/koa': 3.0.3 + + '@types/koa@3.0.3': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.9 + '@types/cookies': 0.9.2 + '@types/http-assert': 1.5.6 + '@types/http-errors': 2.0.5 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.9 + '@types/node': 22.20.1 + '@types/luxon@3.7.2': {} + '@types/methods@1.1.4': {} + '@types/mime@1.3.5': {} '@types/node@22.20.1': @@ -8177,8 +8481,22 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/passport-http-bearer@1.0.42': + dependencies: + '@types/express': 4.17.25 + '@types/koa': 3.0.3 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.25 + '@types/qs@6.15.1': {} + '@types/ramda@0.31.1': + dependencies: + types-ramda: 0.31.0 + '@types/range-parser@1.2.7': {} '@types/retry@0.12.2': {} @@ -8208,6 +8526,20 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/superagent@8.1.11': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.20.1 + form-data: 4.0.6 + + '@types/supertest@7.2.1': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.11 + + '@types/validator@13.15.10': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.1 @@ -8580,6 +8912,8 @@ snapshots: array-timsort@1.0.3: {} + asap@2.0.6: {} + asn1js@3.0.10: dependencies: pvtsutils: 1.3.6 @@ -8609,6 +8943,16 @@ snapshots: - debug - supports-color + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + form-data: 4.0.6 + https-proxy-agent: 5.0.1(supports-color@7.2.0) + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + babel-jest@30.3.0(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -8932,6 +9276,14 @@ snapshots: cjs-module-lexer@2.2.0: {} + class-transformer@0.5.1: {} + + class-validator@0.15.1: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.8 + validator: 13.15.35 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -8999,6 +9351,8 @@ snapshots: common-path-prefix@3.0.0: {} + component-emitter@1.3.1: {} + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -9046,6 +9400,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + copy-anything@2.0.6: dependencies: is-what: 3.14.1 @@ -9264,6 +9620,11 @@ snapshots: dependencies: address: 2.0.3 + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + diff@4.0.4: {} dns-packet@5.6.1: @@ -9723,6 +10084,12 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -9928,6 +10295,8 @@ snapshots: hyperdyperid@1.2.0: {} + iceberg-js@0.8.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -10772,6 +11141,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.13.8: {} + license-webpack-plugin@4.0.2(webpack@5.108.4): dependencies: webpack-sources: 3.5.1 @@ -10831,6 +11202,8 @@ snapshots: lodash.uniq@4.5.0: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -10932,6 +11305,8 @@ snapshots: mime@1.6.0: {} + mime@2.6.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -11274,6 +11649,23 @@ snapshots: parseurl@1.3.3: {} + passport-headerapikey@1.2.2: + dependencies: + lodash: 4.18.1 + passport-strategy: 1.0.0 + + passport-http-bearer@1.0.1: + dependencies: + passport-strategy: 1.0.0 + + passport-strategy@1.0.0: {} + + passport@0.7.0: + dependencies: + passport-strategy: 1.0.0 + pause: 0.0.1 + utils-merge: 1.0.1 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -11297,6 +11689,8 @@ snapshots: path-type@4.0.0: {} + pause@0.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -11587,6 +11981,8 @@ snapshots: queue-microtask@1.2.3: {} + ramda@0.31.3: {} + range-parser@1.2.1: {} range-parser@1.3.0: {} @@ -12156,6 +12552,28 @@ snapshots: '@supabase/cli-windows-arm64': 2.109.1 '@supabase/cli-windows-x64': 2.109.1 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@7.2.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.6 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.3 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -12319,6 +12737,8 @@ snapshots: optionalDependencies: '@swc/core': 1.15.43(@swc/helpers@0.5.23) + ts-toolbelt@9.6.0: {} + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -12365,6 +12785,10 @@ snapshots: typedarray@0.0.6: {} + types-ramda@0.31.0: + dependencies: + ts-toolbelt: 9.6.0 + typescript-eslint@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) @@ -12461,6 +12885,8 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validator@13.15.35: {} + varint@6.0.0: {} vary@1.1.2: {} diff --git a/supabase/migrations/20260710120000_init.sql b/supabase/migrations/20260710120000_init.sql index 0bd299b..93dd5f3 100644 --- a/supabase/migrations/20260710120000_init.sql +++ b/supabase/migrations/20260710120000_init.sql @@ -327,7 +327,7 @@ CREATE TABLE public.accounts ( telegram_id character varying, telegram_link_token character varying, deleted_at timestamp(3) without time zone, - supabase_id uuid, + supabase_id uuid NOT NULL, organization_id integer ); @@ -545,7 +545,7 @@ CREATE TABLE public.grids ( is_hps_on boolean DEFAULT false NOT NULL, is_hps_on_updated_at timestamp(3) with time zone, walkthrough_external_id character varying, - timezone character varying DEFAULT 'Africa/Lagos'::character varying NOT NULL, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, kwp double precision DEFAULT '0'::double precision NOT NULL, kwh double precision DEFAULT '0'::double precision NOT NULL, kwp_tariff double precision DEFAULT '0'::double precision NOT NULL, diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..9e55407 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,218 @@ +-- Local-dev Foundation seed (002d Task 5). +-- Runs after migrations on `pnpm exec supabase db reset` (see supabase/config.toml [db.seed]). +-- Not for remote/prod bootstrap. +-- +-- 1. organizations + wallets +-- 2. auth users → handle_new_user creates bare accounts +-- 3. app_metadata claims → handle_update_user; then members +-- 4. api_keys + grid on solar org + +-- ============================================================================= +-- Organizations +-- ============================================================================= + +INSERT INTO public.organizations (id, name, organization_type) +VALUES + (1, 'NXT Platform Operator', 'PLATFORM_OPERATOR'), + (2, 'NXT Solar Developer', 'SOLAR_DEVELOPER'); + +SELECT setval( + pg_get_serial_sequence('public.organizations', 'id'), + (SELECT MAX(id) FROM public.organizations) +); + +-- ============================================================================= +-- Wallets (1:1 with org for bootstrap; trigger may set rls_organization_id) +-- ============================================================================= + +-- BEFORE INSERT trigger copies organization_id → rls_organization_id. +INSERT INTO public.wallets (id, organization_id, wallet_type, balance) +VALUES + (1, 1, 'REAL', 0), + (2, 2, 'REAL', 0); + +SELECT setval( + pg_get_serial_sequence('public.wallets', 'id'), + (SELECT MAX(id) FROM public.wallets) +); + +-- ============================================================================= +-- Auth users (Step 2) +-- ============================================================================= +-- Fixed UUIDs so later steps can UPDATE by id. +-- AFTER INSERT trigger public.handle_new_user() creates public.accounts +-- (supabase_id, email, full_name, telegram_link_token) — organization_id stays +-- null until app_metadata is set in Step 3. +-- auth.identities rows are required for email/password sign-in. + +INSERT INTO auth.users ( + instance_id, + id, + aud, + role, + email, + encrypted_password, + email_confirmed_at, + raw_app_meta_data, + raw_user_meta_data, + created_at, + updated_at, + confirmation_token, + recovery_token, + email_change_token_new, + email_change +) +VALUES + ( + '00000000-0000-0000-0000-000000000000', + 'a0000000-0000-4000-8000-000000000001', + 'authenticated', + 'authenticated', + 'superadmin@nxt-platform.com', + extensions.crypt('superadmin', extensions.gen_salt('bf')), + now(), + '{"provider": "email", "providers": ["email"]}'::jsonb, + '{"full_name": "Platform Superadmin"}'::jsonb, + now(), + now(), + '', + '', + '', + '' + ), + ( + '00000000-0000-0000-0000-000000000000', + 'a0000000-0000-4000-8000-000000000002', + 'authenticated', + 'authenticated', + 'admin@nxt-solar.com', + extensions.crypt('admin', extensions.gen_salt('bf')), + now(), + '{"provider": "email", "providers": ["email"]}'::jsonb, + '{"full_name": "Solar Admin"}'::jsonb, + now(), + now(), + '', + '', + '', + '' + ); + +INSERT INTO auth.identities ( + provider_id, + user_id, + identity_data, + provider, + last_sign_in_at, + created_at, + updated_at +) +VALUES + ( + 'a0000000-0000-4000-8000-000000000001', + 'a0000000-0000-4000-8000-000000000001', + jsonb_build_object( + 'sub', 'a0000000-0000-4000-8000-000000000001', + 'email', 'superadmin@nxt-platform.com', + 'email_verified', true, + 'phone_verified', false + ), + 'email', + now(), + now(), + now() + ), + ( + 'a0000000-0000-4000-8000-000000000002', + 'a0000000-0000-4000-8000-000000000002', + jsonb_build_object( + 'sub', 'a0000000-0000-4000-8000-000000000002', + 'email', 'admin@nxt-solar.com', + 'email_verified', true, + 'phone_verified', false + ), + 'email', + now(), + now(), + now() + ); + +-- ============================================================================= +-- App metadata + members (Step 3) — mirrors inviteMember flow +-- ============================================================================= +-- 1) UPDATE auth.users.raw_app_meta_data with ACL claims (account_id from the +-- account the insert trigger just created). +-- 2) AFTER UPDATE trigger public.handle_update_user() copies organization_id +-- onto public.accounts. +-- 3) INSERT public.members; BEFORE INSERT trigger sets rls_organization_id +-- from accounts.organization_id (so step 2 must run first). + +UPDATE auth.users AS u +SET + raw_app_meta_data = coalesce(u.raw_app_meta_data, '{}'::jsonb) || jsonb_build_object( + 'account_id', a.id, + 'account_type', 'MEMBER', + 'member_type', 'SUPERADMIN', + 'organization_id', 1 + ), + updated_at = now() +FROM public.accounts AS a +WHERE + u.id = 'a0000000-0000-4000-8000-000000000001' + AND a.supabase_id = u.id; + +UPDATE auth.users AS u +SET + raw_app_meta_data = coalesce(u.raw_app_meta_data, '{}'::jsonb) || jsonb_build_object( + 'account_id', a.id, + 'account_type', 'MEMBER', + 'member_type', 'DEVELOPER', + 'organization_id', 2 + ), + updated_at = now() +FROM public.accounts AS a +WHERE + u.id = 'a0000000-0000-4000-8000-000000000002' + AND a.supabase_id = u.id; + +INSERT INTO public.members (account_id, member_type) +SELECT a.id, 'SUPERADMIN'::public.member_type_enum +FROM public.accounts AS a +WHERE a.supabase_id = 'a0000000-0000-4000-8000-000000000001'; + +INSERT INTO public.members (account_id, member_type) +SELECT a.id, 'DEVELOPER'::public.member_type_enum +FROM public.accounts AS a +WHERE a.supabase_id = 'a0000000-0000-4000-8000-000000000002'; + +SELECT setval( + pg_get_serial_sequence('public.members', 'id'), + (SELECT MAX(id) FROM public.members) +); + +-- ============================================================================= +-- API key + grid (Step 4) +-- ============================================================================= +-- One known key on the platform superadmin account (X-API-KEY / Task 7–8). +-- Grid belongs to NXT Solar Developer (organization_id = 2). + +INSERT INTO public.api_keys (id, key, account_id) +SELECT + 1, + 'dev-api-key-platform-superadmin', + a.id +FROM public.accounts AS a +WHERE a.supabase_id = 'a0000000-0000-4000-8000-000000000001'; + +SELECT setval( + pg_get_serial_sequence('public.api_keys', 'id'), + (SELECT MAX(id) FROM public.api_keys) +); + +INSERT INTO public.grids (id, name, organization_id) +VALUES (1, 'Demo Solar Grid', 2); + +SELECT setval( + pg_get_serial_sequence('public.grids', 'id'), + (SELECT MAX(id) FROM public.grids) +); diff --git a/tsconfig.base.json b/tsconfig.base.json index eabe6bc..885e17f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,6 +3,8 @@ "composite": true, "declarationMap": true, "emitDeclarationOnly": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, "importHelpers": true, "isolatedModules": true, "lib": ["es2022"],