diff --git a/.github/workflows/cloudflare-staging.yml b/.github/workflows/cloudflare-staging.yml new file mode 100644 index 0000000..61aaa75 --- /dev/null +++ b/.github/workflows/cloudflare-staging.yml @@ -0,0 +1,100 @@ +name: Cloudflare Trinity Staging + +on: + pull_request: + branches: [main] + workflow_dispatch: + inputs: + operation: + description: 'Validate only, or deploy inaccessible private staging Worker' + type: choice + required: true + default: validate + options: + - validate + - deploy-private + +permissions: + contents: read + +concurrency: + group: cloudflare-trinity-staging-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + name: Test Trinity and bundle Worker + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm test + - name: Validate private staging Worker bundle + run: npx --yes wrangler@4 deploy --dry-run --config wrangler.staging.toml --outdir .wrangler/staging-ci + + deploy-private: + name: Provision and deploy non-public staging + needs: validate + if: ${{ github.event_name == 'workflow_dispatch' && inputs.operation == 'deploy-private' }} + environment: trinity-staging + runs-on: ubuntu-22.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install dependencies + run: npm ci + + - name: Check Cloudflare credentials + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + test -n "$CLOUDFLARE_API_TOKEN" || { echo "::error::Missing CLOUDFLARE_API_TOKEN GitHub secret"; exit 1; } + test -n "$CLOUDFLARE_ACCOUNT_ID" || { echo "::error::Missing CLOUDFLARE_ACCOUNT_ID GitHub secret"; exit 1; } + + - name: Create/find isolated EU staging D1 and generate binding + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: python3 scripts/prepare_cloudflare_staging.py + + - name: Apply D1 dialogue schema + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npx --yes wrangler@4 d1 execute trinity-dialogue-staging-db --remote --file migrations/0001_trinity_dialogue.sql --config wrangler.staging.generated.toml --yes + + - name: Deploy separate Worker without routes or workers.dev + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: npx --yes wrangler@4 deploy --config wrangler.staging.generated.toml + + - name: Configure Gemini key if provided + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + if [ -n "$GEMINI_API_KEY" ]; then + printf '%s' "$GEMINI_API_KEY" | npx --yes wrangler@4 secret put GEMINI_API_KEY --config wrangler.staging.generated.toml + fi + + - name: Deployment summary + if: always() + run: | + echo "### Trinity Cloudflare staging" >> "$GITHUB_STEP_SUMMARY" + echo "- Worker: trinity-dialogue-staging (no public routes)" >> "$GITHUB_STEP_SUMMARY" + echo "- Dedicated EU database: trinity-dialogue-staging-db" >> "$GITHUB_STEP_SUMMARY" + echo "- Production and existing Workers: untouched" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2284b93..da2c64b 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -1,31 +1,55 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI +name: Trinity Worker CI on: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: trinity-worker-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - build: + check: + name: Test and bundle Cloudflare Worker + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 - runs-on: ubuntu-latest + - name: Set up Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + - name: Install locked dependencies + run: npm ci - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - run: npm ci - - run: npm run build --if-present - - run: npm test + - name: Run automated tests + run: npm test + + - name: Validate JavaScript entry points + run: | + node --check src/index.js + node --check src/routes/ai.js + node --check src/routes/dialogue.mjs + node --check src/core/conversation.mjs + node --check src/frontend/app.js + + - name: Build Cloudflare Worker without deploying + run: npx wrangler deploy --dry-run --outdir .wrangler/ci-output + + - name: Summary + if: always() + run: | + echo "### Trinity CI" >> "$GITHUB_STEP_SUMMARY" + echo "- Environment: validation only (no production deploy)" >> "$GITHUB_STEP_SUMMARY" + echo "- Node.js: 22" >> "$GITHUB_STEP_SUMMARY" + echo "- Scope: unit tests, source syntax, Workers dry-run bundle" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/webpack.yml b/.github/workflows/webpack.yml deleted file mode 100644 index 9626ff6..0000000 --- a/.github/workflows/webpack.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: NodeJS with Webpack - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x] - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Build - run: | - npm install - npx webpack diff --git a/.gitignore b/.gitignore index f83f50f..91d5953 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .env.local node_modules/ .dev.vars + +# Generated by Cloudflare staging CI (contains account-scoped D1 binding) +wrangler.staging.generated.toml +.wrangler/staging-ci/ diff --git a/docs/cloudflare-staging.md b/docs/cloudflare-staging.md new file mode 100644 index 0000000..1f66cc2 --- /dev/null +++ b/docs/cloudflare-staging.md @@ -0,0 +1,36 @@ +# Trinity Dialogue v3 — Cloudflare staging deployment + +## Scope + +The GitHub workflow `.github/workflows/cloudflare-staging.yml` is **manual for deployment**. It validates pull requests but can only deploy after selecting `deploy-private` on the default branch. It uses a new Cloudflare Worker named `trinity-dialogue-staging`; **does not deploy to `tenebris-core` or `aura-trinity`**. + +The staging config `wrangler.staging.toml` explicitly disables `workers_dev` and `preview_urls`. No public routes are configured, so a successful private deployment does not provide a public website. This is deliberate while access control is incomplete. + +## One-time connection (do not paste secrets into issues, PRs or chat) + +1. Create a **scoped Cloudflare API token** for the intended account with Cloudflare Workers Scripts **Edit** and D1 **Edit** permissions. Prefer the narrowest account/resources possible. Store it in the GitHub repository under **Settings → Secrets and variables → Actions → New repository secret** as `CLOUDFLARE_API_TOKEN`. +2. Store the Cloudflare account identifier there as `CLOUDFLARE_ACCOUNT_ID`. +3. Optionally store Gemini's secret under `GEMINI_API_KEY`; it will be set as a Worker secret after the staging deployment. Without it, conversational AI will report that the model is unavailable. +4. Configure a GitHub **trinity-staging** environment with restricted access and required reviewers if available. Workflow deployment requires that environment. +5. Ensure GitHub-hosted Actions runners can start. A run failing before assigning a runner cannot deploy anything. + +## How to use + +Merge the reviewed development branch after code security checks and successful CI. Under **Actions → Cloudflare Trinity Staging → Run workflow** select: +- `validate`: runs tests and compiles the Worker; does not access Cloudflare credentials. +- `deploy-private`: after approval checks, automatically finds or creates an EU-jurisdiction **trinity-dialogue-staging-db** D1 database, applies the dialogue schema, and deploys the isolated non-public Worker. No database ID needs to be manually copied. + +Every required Cloudflare credential must be configured before running `deploy-private`. Never reuse a production database for this staging process. + +## Requirements before enabling a public endpoint or production deployment + +- Replace the sample admin password/static 2FA and in-memory sessions from the legacy project with **verified Cloudflare Access identity**. Only trusted allowed administrators should have control. +- Configure Cloudflare Access application and policy for the approved hostname; validate signed JWTs at the Worker or origin. Setting a header without verifying its signature is not authentication. +- Do not serve the admin UI on an unprotected `workers.dev` endpoint or expose a direct route that bypasses Access. +- Review all AI write/permissions endpoints and replace demo state with real authorized policy checks and audit logs. +- Verify deployment version, target, scope and rollback before opening any route; chat approvals only record a decision and cannot deploy by themselves. +- Prove live API/model connectivity and authenticated D1 persistence, then perform a staging smoke test before production. + +## Notes + +The workflow intentionally does **not** create a Cloudflare Access application or domain route automatically; doing so would require known account routing and security policy. Do not claim production is deployed just because GitHub tests passed. diff --git a/docs/trinity-dialogue-v3.md b/docs/trinity-dialogue-v3.md new file mode 100644 index 0000000..d2d84eb --- /dev/null +++ b/docs/trinity-dialogue-v3.md @@ -0,0 +1,25 @@ +# Trinity Dialogue v3 — vývojová integrácia + +Konverzačná vrstva pre existujúci Cloudflare Worker, aktivovaná cez `POST /api/ai/command` (spätná kompatibilita) alebo `POST /api/ai/dialogue`. Obe cesty vyžadujú serverové prihlásenie. API vracia `response`, `engine`, `mode`, `conversation_id` a `persistence`. + +## Nové správanie + +- Prirodzená slovenčina, krátka história rozhovoru, explicitná identifikácia neúspešného volania modelu namiesto vymyslených úspechov. +- Pripravovanie nasadenia musí obsahovať názov služby, prostredie a konkrétnu verziu / commit. +- Schvaľovanie bez ručného ID: „Schvaľujem“, „Potvrdzujem“, „Áno“, „Zamietam“. Pri viacerých čakajúcich návrhoch „Schvaľujem trinity-gateway“. +- Žiadosť je platná 15 minút a viazaná na konkrétneho prihláseného používateľa aj konverzáciu. +- **Schválenie iba zmení stav návrhu. Neexistuje tu vykonávač deploymentu.** Žiadne nasadenie sa nespustí len na základe správy v chate. + +## D1 je potrebná pre trvalé schvaľovanie + +Na produkčnej Cloudflare službe vytvorte alebo určte existujúcu D1 databázu, pridajte binding s názvom `TRINITY_DB` a aplikujte SQL zo súboru `migrations/0001_trinity_dialogue.sql`. Identifikátor databázy sa nastavuje výhradne podľa skutočnej konfigurácie, nie podľa náhodného príkladu. Bez migrácie alebo bindingu beží iba prechodný konverzačný režim; systém **neprijíma schválenia**. + +Konfigurácia GEMINI kľúča používa Cloudflare secret `GEMINI_API_KEY` (tiež toleruje staršie názvy). Nikdy nevkladajte kľúče do klientského kódu. + +## Bezpečnosť pred produkciou + +Základný repozitár má ukážkové prihlasovacie údaje v zdrojovom kóde a nestále sessions. **Nezapájajte reálny deploy executor, kým nebude autentifikácia prepracovaná**, prednostne cez Cloudflare Access s presne obmedzenými oprávneniami. Treba ďalej zabezpečiť audit a vykonávanie iba schválených nezmeniteľných commitov. + +## Overenie + +`npm test` spustí testy spracovania slovenčiny, kontextu, zlyhania AI, izolácie session, schvaľovania, zamietnutia, viacnásobných návrhov a expirácie. diff --git a/migrations/0001_trinity_dialogue.sql b/migrations/0001_trinity_dialogue.sql new file mode 100644 index 0000000..91c00f2 --- /dev/null +++ b/migrations/0001_trinity_dialogue.sql @@ -0,0 +1,24 @@ +-- Deploy review and conversation state. No real deployment is triggered here. +CREATE TABLE IF NOT EXISTS trinity_chat_turns ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + conversation_id TEXT NOT NULL, + message TEXT NOT NULL, + response TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_trinity_chat_owner_conv ON trinity_chat_turns(owner,conversation_id,created_at); +CREATE TABLE IF NOT EXISTS trinity_deployment_approvals ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + conversation_id TEXT NOT NULL, + label TEXT NOT NULL, + target TEXT NOT NULL, + environment TEXT NOT NULL CHECK (environment IN ('production','staging')), + version TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','executed','expired')), + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + decided_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_trinity_approval_pending ON trinity_deployment_approvals(owner,conversation_id,status,expires_at); diff --git a/package.json b/package.json index 579f3df..6982d27 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Underground Street Collective - Tenebris API Gateway", "main": "src/index.js", "scripts": { - "deploy": "wrangler deploy" + "deploy": "wrangler deploy", + "test": "node --test tests/*.test.mjs" }, "devDependencies": { "wrangler": "^3.0.0" diff --git a/scripts/prepare_cloudflare_staging.py b/scripts/prepare_cloudflare_staging.py new file mode 100644 index 0000000..e60ea02 --- /dev/null +++ b/scripts/prepare_cloudflare_staging.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Prepare an isolated staging D1 binding using Wrangler credentials from CI. + +Never prints credentials, changes production DBs, or exposes public Worker routes. +Only run in an explicitly approved staging deployment job. +""" +import json +import pathlib +import re +import subprocess + +DB_NAME = "trinity-dialogue-staging-db" +CONFIG_PATH = pathlib.Path("wrangler.staging.generated.toml") +BASE_PATH = pathlib.Path("wrangler.staging.toml") +UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +def wrangler(*arguments): + return subprocess.run(["npx", "--yes", "wrangler@4", *arguments], check=True, text=True, capture_output=True).stdout + + +def find_db(): + entries = json.loads(wrangler("d1", "list", "--json")) + if not isinstance(entries, list): + raise RuntimeError("Wrangler returned an invalid D1 database list") + exact = [entry for entry in entries if isinstance(entry, dict) and entry.get("name") == DB_NAME] + if len(exact) > 1: + raise RuntimeError("Multiple staging D1 databases have the same name") + return exact[0] if exact else None + + +def main(): + db = find_db() + if db is None: + # Creates only a dedicated new staging database, with EU jurisdiction. + wrangler("d1", "create", DB_NAME, "--jurisdiction", "eu") + db = find_db() + if db is None: + raise RuntimeError("Staging D1 database was not found after creation") + database_id = db.get("uuid") or db.get("id") + if not isinstance(database_id, str) or not UUID.fullmatch(database_id): + raise RuntimeError("Invalid database identifier returned by Wrangler") + if CONFIG_PATH.exists(): + raise RuntimeError("Generated configuration already exists; refusing overwrite") + base = BASE_PATH.read_text(encoding="utf-8") + binding = ( + "\n[[d1_databases]]\n" + 'binding = "TRINITY_DB"\n' + f'database_name = "{DB_NAME}"\n' + f'database_id = "{database_id}"\n' + ) + CONFIG_PATH.write_text(base + binding, encoding="utf-8") + print("Dedicated private staging D1 database ready; generated local Wrangler config.") + + +if __name__ == "__main__": + main() diff --git a/src/core/conversation.mjs b/src/core/conversation.mjs new file mode 100644 index 0000000..18290b4 --- /dev/null +++ b/src/core/conversation.mjs @@ -0,0 +1,48 @@ +/** Pure language/intent helpers: never execute deployments. */ +const clean = text => String(text || '').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9._\s-]+/g, ' ').trim().replace(/\s+/g, ' '); +export const normalize = clean; + +export function approvalIntent(text) { + const s = clean(text); + // Negations must be checked before positive commands. + if (/^(nie|nechcem|neschvalujem|nesuhlasim|zamietam|zamietnut|odmietam|zrus|zrusit|stop|cancel)(\b|$)/.test(s)) return 'reject'; + if (/^(ano|hej|ok|okej|suhlasim|schvalujem|potvrdzujem|potvrdim|schvalene|approve)(\b|$)/.test(s)) return 'approve'; + if (/^(nasad to|spusti toto nasadenie|muzes to nasadit|mozes to nasadit)(\b|$)/.test(s)) return 'approve'; + return null; +} + +export function parseDeploy(message, explicit = undefined) { + const s = clean(message); + const asksDeployment = /\b(nasad|nasadit|nasadenie|deploy|deployment|publikuj|publikovat)\b/.test(s); + if (!asksDeployment && !explicit) return null; + const explicitIsObj = explicit && typeof explicit === 'object' && !Array.isArray(explicit); + const matchTarget = s.match(/\b(?:worker|sluzbu|aplikaciu|projekt|service)\s+([a-z0-9][a-z0-9._-]{1,79})/); + const namedWorker = s.match(/\b(trinity[-_][a-z0-9._-]{2,60})\b/); + const matchVersion = s.match(/\b(?:commit|sha|verziu|verzia|verzie|revision|revisions)\s+([a-z0-9._-]{5,64})/); + const target = explicitIsObj ? explicit.target : (matchTarget?.[1] || namedWorker?.[1]); + const environment = explicitIsObj ? explicit.environment : (/\b(produkci[aeu]|production|prod)\b/.test(s) ? 'production' : /\b(staging|test|testovaci[aeo])\b/.test(s) ? 'staging' : ''); + const version = explicitIsObj ? explicit.version : matchVersion?.[1]; + const safe = (value, rule) => typeof value === 'string' && rule.test(value) ? value : ''; + return { + target: safe(target, /^[a-zA-Z0-9][a-zA-Z0-9._-]{1,79}$/), + environment: safe(environment, /^(production|staging)$/), + version: safe(version, /^[a-zA-Z0-9][a-zA-Z0-9._-]{4,63}$/), + }; +} + +export function deployMissing(spec) { + return [!spec.target && 'cieľ (napríklad trinity-gateway)', !spec.environment && 'prostredie (produkcia alebo staging)', !spec.version && 'konkrétnu verziu alebo commit'].filter(Boolean); +} + +export function deploymentLabel(spec) { + const env = spec.environment === 'production' ? 'produkcie' : 'stagingu'; + return `Nasadiť ${spec.target}, verzia ${spec.version}, do ${env}`; +} + +export const SYSTEM_PROMPT = `Si Trinity, užitočná digitálna asistentka pre vývoj projektov. Píš po slovensky, prirodzene a jasne, bez robotických fráz. V nadväzujúcich otázkach rešpektuj kontext. Nezahlcuj používateľa technickými ID. Keď to pomôže, ponúkni jeden konkrétny ďalší krok. +Nepredstieraj vedomie, vlastné pocity, živé údaje, používateľské oprávnenia, spustenie kódu ani dokončené nasadenie. Ak je model alebo nástroj nedostupný, povedz to. Akcia sa vykonala len ak to potvrdzuje skutočný výsledok nástroja. Rizikové akcie si vyžadujú platné schválenie viazané na konkrétny cieľ, prostredie a verziu; bežný chat sám o sebe nič nenasadzuje. Nikdy nežiadaj interné ID schválenia. Odpovedaj stručne, ale vecne; pri práci s kódom buď presná.`; + +export function safeHistory(history) { + if (!Array.isArray(history)) return []; + return history.slice(-10).filter(x => x && ['user','assistant'].includes(x.role) && typeof x.text === 'string').map(x => ({role:x.role,text:x.text.slice(0,2500)})); +} diff --git a/src/frontend/app.js b/src/frontend/app.js index ca9c58f..928ac7f 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -48,6 +48,16 @@ export const HTML = `