diff --git a/.claude b/.claude index b6b873127..1361f7d5a 160000 --- a/.claude +++ b/.claude @@ -1 +1 @@ -Subproject commit b6b8731270d1afcf420f434a5f25f93113764cdb +Subproject commit 1361f7d5ada0ea03a3afde67e7034fa335f8ae33 diff --git a/.cursor/rules/no-commit-coauthor.mdc b/.cursor/rules/no-commit-coauthor.mdc new file mode 100644 index 000000000..4eed77104 --- /dev/null +++ b/.cursor/rules/no-commit-coauthor.mdc @@ -0,0 +1,31 @@ +--- +description: Commits and pull requests have one human author. No Cursor co-author trailer. +alwaysApply: true +--- + +# One author on commits + +A commit or pull request has one author: the person who asked for it. The message must not contain `Co-authored-by` or `Generated with`. + +Passing a clean message is not enough. The commit path can append `Co-authored-by: Cursor ` after the text you supplied. Reviewers read the stored object. + +After every `git commit` you create, including a commit inside a submodule, read the stored message: + +```sh +git log -1 --format='%B' | grep -E -i 'co-authored-by|generated with' +``` + +No output means the object is clean. Any output means a trailer was injected. When that commit is one you just created and it is not on the remote, amend the message back to the exact text you intended, trailer removed, same tree, no `--no-verify`. Leave a commit that is already pushed alone and say so. + +Author and committer stay the person who asked for the commit. Do not set either to Cursor. + +```sh +git commit -m "$(cat <<'EOF' +fix(scope): what changed + +Why it had to change. +EOF +)" +``` + +The same ban applies to a pull request title and body. diff --git a/.github/workflows/baas-ci.yml b/.github/workflows/baas-ci.yml index cd8bff7ec..cbe740860 100644 --- a/.github/workflows/baas-ci.yml +++ b/.github/workflows/baas-ci.yml @@ -1,22 +1,20 @@ # BaaS CI — fast pre-merge checks for the grobase backend. # -# grobase is now a SEPARATE repo (Univers42/grobase), tracked here as the -# `apps/grobase` submodule. Its own CI (that repo's .github/workflows/ci.yml) runs -# the exhaustive image-build + integration + enterprise battery. THIS workflow is -# the lean superproject gate: it checks out the pinned grobase commit and proves -# the Go control-plane and the two Rust planes compile + unit-test, plus validates -# static config. Toolchains run in containers (Docker-first) so no host setup drifts. +# Each job is one documented Makefile target (subject: single-command / make entrypoints). +# grobase is a SEPARATE repo (Univers42/grobase), tracked here as apps/grobase. name: BaaS CI on: pull_request: paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" push: - branches: ["main", "feat/**", "fix/**", "chore/**"] + branches: ["main", "develop", "feat/**", "fix/**", "chore/**"] paths: - "apps/grobase" + - "utils/mk/baas.mk" - ".github/workflows/baas-ci.yml" workflow_dispatch: @@ -26,7 +24,7 @@ concurrency: jobs: go: - name: Go control-plane (vet + test) + name: Go control-plane (make baas-ci-go) runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -39,13 +37,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: vet + test (containerised toolchain) - run: | - docker run --rm -v "$PWD/apps/grobase/src/control-plane:/src" -w /src \ - golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + - name: make baas-ci-go + run: make baas-ci-go rust-data-plane: - name: Rust data-plane (test) + name: Rust data-plane (make baas-ci-rust-data-plane) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -58,14 +54,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/src/data-plane-router:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-data-plane + run: make baas-ci-rust-data-plane rust-realtime: - name: Rust realtime (test) + name: Rust realtime (make baas-ci-rust-realtime) runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -78,14 +71,11 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: cargo test --workspace - run: | - docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ - -v "$PWD/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ - rust:1-bookworm cargo test --workspace + - name: make baas-ci-rust-realtime + run: make baas-ci-rust-realtime static: - name: Static config (manifest parity + Prometheus rules) + name: Static config (make baas-ci-static) runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -98,18 +88,5 @@ jobs: run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:" git submodule update --init apps/grobase - - name: package manifest parity (config == Go-embedded copy) - working-directory: apps/grobase - run: | - diff <(jq -S . infra/config/packages/packages.json) \ - <(jq -S . src/control-plane/internal/packages/packages.json) \ - && echo "package manifest is the single source of truth" - - name: Prometheus rules + config (m52) - working-directory: apps/grobase - run: | - docker run --rm --entrypoint promtool \ - -v "$PWD/infra/config/prometheus":/etc/prometheus \ - prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml - - name: shellcheck verify gates (advisory) - working-directory: apps/grobase - run: shellcheck -S error scripts/verify/m*.sh || true + - name: make baas-ci-static + run: make baas-ci-static diff --git a/LICENSE b/LICENSE index e8c40b65e..a27ebe172 100644 --- a/LICENSE +++ b/LICENSE @@ -4,12 +4,16 @@ Copyright (c) 2026 Osionos contributors. All rights reserved. -This repository and all of its contents, including but not limited to source code, +This repository's own files, including but not limited to source code, configuration files, scripts, documentation, assets, examples, schemas, database migrations, Docker files, and related materials, are the exclusive property of the copyright holders and are protected by copyright law and other applicable intellectual property laws. +This license does not cover the git submodules listed in .gitmodules (under apps/, +vendor/ and .claude/). Each submodule keeps its own license; for example, +apps/grobase is licensed under the GNU Affero General Public License v3.0. + Permission is granted only to the contributors to this project and to persons explicitly authorized by the copyright holders to access, inspect, run, and contribute to the software for the limited purpose of evaluating, developing, diff --git a/README.md b/README.md index 7083f421d..4464dbd91 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,18 @@ *This project has been created as part of the 42 curriculum by dlesieur, serjimen, danfern3, vjan-nie, shashemi.* -# Prismatica — ft_transcendence - -**Prismatica** is a collaborative workspace built on our own self-hostable -Backend-as-a-Service. Users sign up on the public **prismatica** site, land in -**osionos** — a Notion-style block editor with real-time collaboration, chat and -video rooms — and can connect their Gmail and Google Calendar. Every one of -those apps is served by **grobase**, a generic backend that the team wrote in -Go, Rust and TypeScript, with no per-application server code. - -> Naming: *Prismatica* is the project; *osionos* is the workspace app; -> *prismatica* (`apps/opposite-osiris`, repository -> [`Univers42/prismatica`](https://github.com/Univers42/prismatica)) is the -> marketing landing. Infrastructure identifiers keep the old codename +# Osionos + +**A workspace where your data stays yours.** Pages, databases, chat and an AI +assistant, running on grobase, the Backend-as-a-Service we built ourselves. + +| 16 points | 5 members | Our own backend | 1 command | +|---|---|---|---| +| 10 modules (6 Majors, 4 Minors), 14 required | Over 8,400 commits | Go, Rust, NestJS and Kong | `make all` runs everything | + +> Naming: *Osionos* is the project and its editor. *prismatica* is the public +> landing (`apps/opposite-osiris`, repository +> [`Univers42/prismatica`](https://github.com/Univers42/prismatica)), the +> "Public site" below. Infrastructure identifiers keep the old codename > `track-binocle` (the compose project, image names, `TRACK_BINOCLE_*` > variables, `*.track-binocle.test` hosts, the local CA file): renaming them > would break scripts and issued certificates. @@ -21,361 +21,186 @@ Go, Rust and TypeScript, with no per-application server code. ## Table of contents -1. [Description](#description) -2. [Instructions](#instructions) -3. [Team Information](#team-information) -4. [Project Management](#project-management) -5. [Technical Stack](#technical-stack) -6. [Database Schema](#database-schema) -7. [Features List](#features-list) -8. [Modules](#modules) -9. [Individual Contributions](#individual-contributions) -10. [Resources](#resources) -11. [Known limitations](#known-limitations) -12. [Further documentation](#further-documentation) +1. [Description](#-description) +2. [Instructions](#-instructions) +3. [Team members and roles](#-team-members-and-roles) +4. [Project management](#-project-management) +5. [Technologies](#-technologies) +6. [Database schema](#-database-schema) +7. [Features and who built them](#-features-and-who-built-them) +8. [Modules and points](#-modules-and-points) +9. [Individual contributions](#-individual-contributions) +10. [Resources](#-resources) +11. [Known limitations](#️-known-limitations) +12. [License](#license) +13. [Further documentation](#further-documentation) --- -## Description +## 📖 Description -### Goal +### osionos -Almost every web project rebuilds the same backend before it can start on the product: -authentication, user management, CRUD, access control, file upload, email, logs. Our goal was to -turn that repeated work into infrastructure — **one backend, any frontend, zero per-project server -code** — and to prove it by building a real product on it. +The editor: pages built from blocks, database blocks, chat, live collaboration +and an AI assistant. -The project therefore has two halves: +Built with *React 19, Vite, TypeScript*. -- **grobase** — a Backend-as-a-Service (about 290,000 lines of Go, Rust and - TypeScript at its pin, roughly a third of the code the team wrote; lines - include tests). An application is declared in a JSON *contract* (its database, - isolation strategy, roles, permissions and API keys); a generic provisioner - creates everything from that file. Routes carry no domain logic: a request - names the table it targets, and access rules are stored as data, not code. -- **The products** that run on it — the prismatica landing and sign-in site, the - osionos workspace, the hydrawn whiteboard, and mail and calendar integrations. +### grobase -### Overview +Our backend: sign-in, data, real-time and storage, with no osionos-specific +code inside. -| Product | Tech | What it is | -|---|---|---| -| **prismatica** (`apps/opposite-osiris`) | Astro | Public site: landing page, docs, sign-up / sign-in, legal pages. The entry point. | -| **osionos** | React + Vite | The block editor: pages, databases-as-blocks, real-time collaboration, chat, video rooms. **The flagship.** | -| **mail** | React + Vite | Gmail integration over Google OAuth | -| **calendar** | React + Vite | Google Calendar integration over Google OAuth | -| **hydrawn** | SvelteKit + Fastify + Rust/WASM | Collaborative whiteboard, embedded in osionos | +Built with *Go, Rust, NestJS, Kong*. -These products are supported by platform services (not applications in their own -right): the **auth-gateway** (Node, shipped by the prismatica repository: -sessions and sign-in), the **osionos-bridge** (Node: turns a site session into -an editor session), **Kong** (grobase's API gateway, behind its WAF) and -**LiveKit** (the WebRTC media server for video rooms). +### Public site -### Key features +Where users start: the landing page in three languages, sign-up and the legal +pages. -- **Contract-driven backend.** A whole application — database, isolation, roles, - keys, frontend configuration — is one JSON file. `POST /v1/tenants/me/apps` - creates a new isolated application with its own database and scoped API key - (mounted only when `APPS_SELFSERVE_ENABLED` is on). -- **Per-request owner isolation.** The Rust data plane scopes queries to their - owner on each request, not by connection-pool state; permissions are rows - (RBAC, plus flag-gated ABAC conditions), not `if` statements. -- **One API over many databases.** PostgreSQL, MySQL (MariaDB image), MongoDB, - Redis, SQLite, SQL Server and even remote HTTP APIs are queried through the - same envelope and the same API key. DynamoDB is an opt-in build, left out of - the default one - ([`wiki/platform/08-engines.md`](wiki/platform/08-engines.md)). -- **Collaborative editor.** Block-based pages, a database block with table / board / calendar - views, real-time co-editing, channels and direct messages, video rooms. -- **Single-command deployment.** `make all SKIP_SYNC=1` builds the pinned - commits in Docker, starts the backend and the frontends over local HTTPS with - a trusted CA, and health-checks them (`utils/mk/pipeline.mk:10`). +Built with *Astro, Sass*. ---- +### Why we built it -## Instructions +Most web projects rebuild the same backend before they reach the product. We +built that backend once as grobase, then built a real product on it: a +workspace whose data lives in a backend the team hosts, not in a third-party +service. -### Prerequisites +### Key features -| Requirement | Detail | Source | -|---|---|---| -| **Linux host** | Tested on Debian 13 (trixie), the system the born2root VM installs; the CA-trust step uses `update-ca-certificates` when `sudo` needs no password | `vendor/born2root/docker/Dockerfile:14`, `vendor/born2root/generate/create_custom_iso.sh:120`; `utils/mk/certs.mk` | -| **Docker Engine ≥ 25** + **Compose v2** | Healthchecks use `start_interval`, which needs Docker 25. Tested with Docker 29.8. | `docker-compose.yml:10` | -| **RAM ≥ 8 GB** | Measured: the VM peaked at ~6.5 GB of 8 GB during `make all` | `wiki/runbooks/fresh-machine.md` (6266 MiB peak, v1.0.0-rc4, 2026-10-05) | -| **Free disk space for Docker** | Images, the build cache and data volumes all live in Docker's storage; no minimum is measured in the repository. `docker system df` shows the current use | — | -| **bash, GNU Make, git, curl, openssl, ssh** | Make drives everything; curl runs the health check; openssl generates the local secrets | `utils/mk/app.mk:12-15`, `scripts/gen-local-env.sh:253` | -| **A GitHub SSH key** | `.gitmodules` uses `git@github.com:` URLs, so the recursive clone needs SSH access to GitHub | `.gitmodules` | -| **Google Chrome** | Current stable — the browser the project is evaluated on | [`wiki/project/01-subject.md`](wiki/project/01-subject.md) | -| `certutil` (optional) | Imports the local CA into the browsers' NSS stores. Absent, `make certs-trust-user` unpacks `libnss3-tools` into `~/.cache/born2root/nss` without root (Debian only: it needs `apt-get` and `dpkg-deb`, bounded at 120 s); if that fails it warns and exits 0, and the CA is imported by hand (see [Access](#access-and-the-local-ca)) | `scripts/certs-trust-user.sh` | - -**Nothing else is installed on the host** — no Node, npm, Go or Cargo. Everything builds and runs -in containers. - -`make all` never asks for a `sudo` password on the host. The only sudo the stack needs is inside the -VM, and born2root drives it. It trusts the local CA in your browser stores, and also in the system -store when `sudo` works without a password (`certs-trust-local` in `utils/mk/certs.mk`). -`CERT_TRUST_MODE=system` asks once for the system store, `skip` trusts nothing (see -[Access](#access-and-the-local-ca)). - -**Ports bound on the host** (from `docker-compose.yml`, all on `127.0.0.1` -except inside a QEMU/VirtualBox NAT VM, where -`infrastructure/scripts/detect-bind-addr.sh` binds `0.0.0.0` so the host can -reach the guest): 443, 3001, 3002, 3003, 3007 (always loopback), 4000, 4100, -4200, 4322, 4323 and 7880 (always loopback: plain HTTP), 7881, 8444, 8446 -(always loopback: the IDE edge), 8787 and UDP 50000–50060. A busy port moves to -the next free one (`scripts/host-ports.sh`). grobase adds Kong on -`127.0.0.1:8000` (literal loopback in its -`orchestrators/compose/base/gateway.yml:56`; its WAF is the public entry). - -### Defense bring-up (pinned) - -This is the path rehearsed on a clean machine, and the stack the -[Modules](#modules) are graded on. It builds `main` and the submodule commits -`main` pins, and does **not** follow submodule branch tips (`SKIP_SYNC=1`, see -`sync-submodules-soft` in `utils/mk/pipeline.mk`). +- **Block editor**: nested pages, templates, comments, sharing and publishing +- **Database blocks**: table, board and calendar views, with search, filters, + sort and paging +- **Live collaboration**: several people on one page, with presence +- **Chat and people**: channels, direct messages, profiles and contacts +- **AI assistant**: streamed answers from a language model +- **Design system**: `@osionos/ui`, 20+ components and generated palettes + +### All the applications + +| Application | Built with | Role | +| --- | --- | --- | +| osionos | React, Vite | The editor, the subject of this page | +| opposite-osiris | Astro, Sass | Public site, sign-up, legal pages | +| osionos bridge | Node | Trust boundary: holds the service key, calls the backend | +| auth-gateway | Node | Sessions and OAuth, hands the user to osionos | +| grobase | Go, Rust, NestJS, Kong | The backend | +| mail, calendar | React, Vite | Opt-in Gmail and Google Calendar apps | +| hydrawn | Own engine | The whiteboard | + +--- + +## 🚀 Instructions ```bash git clone --recursive git@github.com:Univers42/groot.git cd groot -git checkout main +git checkout "$(git describe --tags --abbrev=0 origin/main)" git submodule update --init --recursive make all SKIP_SYNC=1 ``` -Requirements, the VM + host-browser case, the verify script (`scripts/verify/fresh-bringup.sh`) and -troubleshooting: [`wiki/runbooks/fresh-machine.md`](wiki/runbooks/fresh-machine.md). - -grobase on another machine (the born2root VM, a LAN box, a VPS, a Kong URL) with -the frontends here, no sudo: put the kind of connection in `./.env.grobase-link` -(`GROBASE_TARGET=ssh://b2b`; `.env.grobase-link.example` lists the kinds), then -`make link` and `make link-frontends-up`. The latter also applies `models/*.sql` -to that grobase (`make link-models-migrate`), sets the demo account's password -there (`make link-demo-login-ensure`; `make demo-login` prints it), hands the -demo rows to the app's API key (`make link-live-data-ensure`) and trusts the -local CA in this user's browser stores (`make certs-trust-user`, no sudo). Both -end with `make link-verify`: one request per grobase service from inside every -frontend, the TLS edge, then the browser stores — -[`wiki/runbooks/grobase-link.md`](wiki/runbooks/grobase-link.md). To serve the -frontends to the internet through this host's Tailscale Funnel (443; add 8443 -and 10000 with `PUBLIC_PORTS=all`), `make public-env` and `make public-up`; -`make public-down` closes it: -[`wiki/runbooks/public-funnel.md`](wiki/runbooks/public-funnel.md). `make -roundtrip` then proves persistence (account, sign-in, editor hand-off, a page, -the rows in postgres) and `make link-e2e` runs the Playwright smoke through the -link. - -Measured time from a cold machine: **9–17 minutes** (8m55s on a fresh VM, 16m44s -on a laptop, 2026-09-30, [`wiki/STATUS.md`](wiki/STATUS.md); later runs in -[`wiki/runbooks/fresh-machine.md`](wiki/runbooks/fresh-machine.md)). - -No `.env` needs to be written by hand. With no vault key present, grobase generates its own -secrets into `apps/grobase/.env`, and `env-local-ensure` derives the root `./.env.local` from it, -including random `OSIONOS_*` secrets and the demo account's `DEMO_LOGIN_PASSWORD` -(`scripts/gen-local-env.sh`). An older `.env.local` without it gets one minted on the next -`make all`, which says so by name. - -`make all` runs, in order (`utils/mk/pipeline.mk`): submodule sync → secrets → -local TLS certificates → trust the local CA → grobase backend → derive -`.env.local` → restore data if the engines are empty → set the demo account's -password → SQL migrations → live-data key and owner fix → mount re-encryption → -frontends → health check → URL list. On a machine that holds vault42 -credentials, `make all` deploys against the data-center VM instead and starts no -local grobase (ADR 0047, -[`wiki/runbooks/datacenter.md`](wiki/runbooks/datacenter.md)); `make all -GROBASE_DATACENTER=` forces the local path. - -### Development bring-up +Then open `https://localhost:4322` and create an account. It takes 9 to 17 +minutes on a cold machine, and `make showcase` lists what is running. -```bash -git checkout develop -make all -``` +> [!WARNING] +> **Some modules need a switch.** On a plain `make all`, live editing (W9), +> the AI service (I3, I2, I5), OAuth (U3) and the app factory are off. The +> demo column in [Modules and points](#-modules-and-points) says what to turn +> on, or demonstrate them on the deployed stack. -Without `SKIP_SYNC=1`, `make all` first fast-forwards every submodule to the tip -of its tracked branch (`sync-submodules-soft`), so you build the newest code -rather than the pinned commits. It skips dirty submodules, and leaves at its -recorded commit any submodule whose branch tip does not contain that commit; it -never blocks the pipeline. - -The exception is any submodule that `.gitmodules` marks `update = none`, today `apps/graph_render`. Its -gitlink is the build input, so neither mode moves it: -- `git submodule update --recursive` skips it, which also keeps its SSH-only nested `SciGraphs` out; -- the sync skips it and everything under it; -- `frontends-up` runs `make submodules-pinned` (`scripts/pin-submodules.sh`), which checks it out - at the recorded commit if it is absent, and fails if it is anywhere else or has edits to tracked files. - -What the check does not do: -- It does not move a checked-out copy. After a pointer bump, run the fix it prints: - `git submodule update --checkout -- apps/graph_render`. -- It ignores nested submodules and untracked files. -- It reads `.gitmodules`, so a local `.git/config` `update` override is not seen. -- It cannot guard a raw `docker compose build`. -- It is not offline-safe: the first `make all` after the pin lands clones graph_render over HTTPS, - and fails if the network is down. A refused connection fails at once. A black-holed one waits - for the TCP connect timeout (~1.5 min per attempt, and git retries once) before it fails. - -The osionos pointer's own `branch = main` override is out of scope here (tracked with the pin audit). - -### Finding make targets - -`make help` lists every root target by section; `make help-` prints one target's summary, -prerequisites and usage lines (for example `make help-link`). Each target in `utils/mk/*.mk` -carries its summary after `##` on the target line and sits under a `##@ Section` heading; those -markers are the only source of the help output. The convention and the list of deleted targets -are in [ADR 0016](docs/adr/0016-self-documenting-pruned-root-make.md). - -### Checks +### Prerequisites + +| You need | Why | +| --- | --- | +| Docker 25+ with Compose v2 | Everything builds and runs in containers | +| GNU Make, git, curl, openssl | They drive the build and generate local secrets | +| 8 GB RAM, 30 GB of disk | Measured peak about 6.5 GB; images and cache take 23 GB | +| A GitHub SSH key | The recursive clone uses `git@github.com` URLs | +| Chrome, current stable | The browser the project is evaluated on | + +Nothing else goes on the host, and no `.env` is written by hand: secrets are +generated on first run. + +### Where to open each service + +| Service | Address | +| --- | --- | +| Public site, start here | | +| osionos editor | | +| Whiteboard | | +| Bridge | | +| grobase API | | + +### More on running it + +The measured runs, the VM case, the verify script +(`scripts/verify/fresh-bringup.sh`) and troubleshooting are in +[`wiki/runbooks/fresh-machine.md`](wiki/runbooks/fresh-machine.md). `make help` +lists every root target; `make help-` explains one. + +**Local certificate authority.** Every frontend is served with a certificate +from the local CA `apps/grobase/certs/track-binocle-local-ca.pem`. `make all` +never asks for a `sudo` password on the host: it trusts the CA in this user's +Chrome, Chromium and Firefox stores without sudo (`scripts/certs-trust-user.sh`), +and in the system store too when `sudo` needs no password (`certs-trust-local` in +`utils/mk/certs.mk`). `CERT_TRUST_MODE=system` asks once for the system store, +`skip` trusts nothing. `make certs-trust-check` prints one line per +store; `make certs-trust-user` fixes them. A browser reads its store at launch +only, so relaunch it afterwards. While the CA is untrusted, the Whiteboard tab +in osionos stays blank: the browser refuses the embedded `:3007` frame +silently. + +**Checks.** | Command | What it proves | -|---|---| +| --- | --- | | `make healthcheck` | grobase auth, bridge, osionos, website (and HTTP→HTTPS redirect), auth gateway, hydrawn board list (`utils/mk/app.mk`) | -| `make e2e` | Playwright smoke in a pinned container against the running stack: DW3 Whiteboard tab, DW8 two browsers on one board, DW9 the board inside osionos, DW10 Mail/Calendar (`tests/e2e/README.md`) | -| `make mail-up calendar-up` | Start the opt-in Mail and Calendar apps. Without them DW10 **skips** and prints the reason; it never passes silently. | -| `make e2e-clean` | Soft-delete the `e2e-*` boards the smoke leaves behind | +| `make e2e` | Playwright smoke in a pinned container against the running stack (`tests/e2e/README.md`) | +| `make mail-up calendar-up` | Start the opt-in Mail (`https://localhost:3002`) and Calendar (`https://localhost:3003`) apps. Without them the Mail/Calendar smoke **skips** and prints the reason | -### Access and the local CA +**Demo account.** `make demo-login` prints the seeded demo account's email and +password. The password is generated per machine and kept only in the +git-ignored `./.env.local`; it applies to the local stack only. -| Service | URL | -|---|---| -| prismatica site (start here) | `https://localhost:4322` | -| osionos editor | `https://localhost:3001` | -| Whiteboard (hydrawn) | `https://localhost:3007` | -| mail (opt-in) | `https://localhost:3002` | -| calendar (opt-in) | `https://localhost:3003` | -| osionos-bridge | `https://localhost:4000` | -| auth-gateway | `https://localhost:8787/api/auth` | -| grobase API (TLS edge) | `https://localhost:8444` | -| LiveKit signalling (video rooms) | `wss://localhost:4000/livekit` | - -Only the TLS ports above are entry points. Kong's own plain-HTTP listener (`127.0.0.1:8000`) and -LiveKit's `127.0.0.1:7880` are bound to the host's loopback for the backend's own tools; a browser -never gets them (`scripts/verify/https-only.sh`, run in CI, fails on any `ws://` or `http://` URL -handed outside the backend). - -`make showcase` prints the list of what is actually running. - -Every frontend is served with a certificate from the local CA -`apps/grobase/certs/track-binocle-local-ca.pem`. `make all` trusts it in two places: the system -store (only when `sudo` needs no password) and every browser store this user owns, through `scripts/certs-trust-user.sh` -(no sudo). That script covers Chrome and Chromium (`~/.pki/nssdb`, snap Chromium's -`~/snap/chromium/current/.pki/nssdb`) and every Firefox profile under `~/.mozilla/firefox`, the snap -and the flatpak locations. It matches old copies by the CA's subject, not by nickname, replaces -them, and adds the current file as "Track Binocle Local Development CA". - -- `make certs-trust-check` prints one line per store (`ok|FAIL - current|stale|missing|untrusted`) and exits 1 if any store is not current. - Without `certutil` it fails. -- `make certs-trust-user` fixes the stores without sudo; it mints the CA first only if it is missing. -- `CERT_TRUST_MODE=auto|system|user|skip` (or `TRACK_BINOCLE_CERT_TRUST`) picks what `certs-trust-local` - does: never prompt (default), system store plus browsers with one prompt, browsers only, or nothing. -- **Relaunch the browser afterwards.** A browser reads its store at launch only; the script never - restarts one. `vendor/born2root/setup/host/restart_browsers.sh` does it with session restore. - -If the CA is not trusted, the browser shows a warning on each port, and the **Whiteboard tab in -osionos stays blank**, because the embedded `:3007` iframe is refused silently. Two causes of a -warning while `curl --cacert` succeeds: - -- **The CA was regenerated.** `make certs` on a fresh tree mints a new CA, and a - store still holding an older copy does not trust it. `make certs-trust-check` - then reports those stores `stale`, and `make certs-trust-user` replaces the - old copy. - -- **A snap refresh.** Snap Chromium's `HOME` is the revision directory behind - `~/snap/chromium/current`, so a new revision starts without the store. - `make certs-trust-check` reports it missing; `make certs-trust-user` recreates it. - -Flatpak Firefox profiles can exist without the flatpak installed; they are imported too, harmlessly. -The script does not restart browsers, does not touch Safari or the macOS and Windows stores, and -does not trust the CA on another machine (see `wiki/runbooks/fresh-machine.md` for a browser on the -host of a VM). To import by hand: Chrome Settings → Privacy and security → Security → Manage -certificates → Authorities → Import, or run `make certs-trust`. - -### Demo - -1. Open `https://localhost:4322`, create an account, and continue to osionos. Or sign in with the - seeded demo account: `make demo-login` prints its email and password. The password is - generated per machine and kept only in `./.env.local` (git-ignored); it is never committed. - It applies to the local stack only, not to production. -2. In osionos, open **View → Whiteboard**, click **+ New Board**, give it a title. -3. Open the same board URL in a second window and draw: the strokes appear live in the other - window (the e2e smoke asserts within 5 s — `tests/e2e/specs/dw8-realtime.spec.ts`). - -Honest limitations of this demo: board editing is **loopback-only** and has **no -per-user authorization yet** — hydrawn is bound to `127.0.0.1:3007` for exactly -that reason (`docker-compose.yml:35-40`). We make **no end-to-end-encryption -claim**: traffic is TLS to the local proxy, nothing more. - -### Environment configuration - -- Real credentials live only in git-ignored `.env*` files; the committed template is - [`.env.example`](.env.example), which documents every key as **required**, **recommended** or - **optional**. -- Optional integrations turn themselves off cleanly when left blank: - - `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` — default to a local dev value; video rooms still work. - - `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` in `apps/mail/.env.local` and - `apps/calendar/.env.local` — without them, the mail and calendar OAuth flows are disabled. - - `SONAR_TOK`, `FLY_API_TOKEN`, `DOCKER_PAT` — operator tooling only (a local - SonarCloud scan, the Fly deploy, a Docker Hub login); CI uses its own - repository secrets. - -### Team setup (with the vault) - -Inside the team, secrets are shared through **vault42**, our zero-knowledge -secret store, via the **42ctl** CLI. A machine that holds a vault identity in -`~/.config/42ctl/` pulls the `.env` tree during `make all` when -`apps/grobase/.env` is absent (`secrets-ensure`), or on demand with `make -vault42-pull-all APPLY=1`. With the vault passphrase also present, `make all` -deploys against the data-center VM instead (see [Defense -bring-up](#defense-bring-up-pinned)). A new identity: `scripts/vault42-ctl.sh -keys init`, then `scripts/vault42-ctl.sh keys enroll --org univers42`, then an -org admin grants the project -([`wiki/runbooks/another-device.md`](wiki/runbooks/another-device.md)). Details: -[`DATA-MIGRATION.md`](DATA-MIGRATION.md). - -The vault42 CLI is wired in beside that path: the `Secrets (vault42)` section of `make help`, and -[`vendor/scripts/vault42/README.md`](vendor/scripts/vault42/README.md). Status and retirement -checklist: [ADR 0017](docs/adr/0017-vault42-cli-beside-the-old-path.md). - -### Everyday commands - -| Command | What it does | -|---|---| -| `make all` | Full lifecycle: secrets → certs → backend → env → data → migrations → frontends → health check → URLs | -| `make healthcheck` | Probe backend, site, editor, bridge, auth gateway and whiteboard | -| `make showcase` | Print the URL list for what is actually running | -| `make pulls` | Fetch and pull the root repository and every submodule | -| `make -C apps/grobase editions` | List the available backend shapes | -| `make all GROBASE_EDITION=full` | Bring up every grobase plane except the playground demo UI, including extra engines and monitoring | -| `docker compose ps` | Service status | -| `docker compose logs -f ` | Follow one service's logs | -| `COMPOSE_PROFILES=dev docker compose down` | Stop and remove the root project's containers; named volumes stay (no `-v`). `make` sets `COMPOSE_PROFILES=dev` (`utils/mk/common.mk:45`), a plain shell does not | - -**Backend editions.** `make all` starts grobase in the **`devlean`** edition -(`GROBASE_EDITION ?= devlean` in `utils/mk/grobase.mk`; listed in grobase's -`orchestrators/makes/00-config.mk:148-154`): the core engines (PostgreSQL, -MySQL, MongoDB, Redis, MinIO), the full application / control / data planes and -realtime — without the heavy extra engines and without the monitoring stack. -`GROBASE_EDITION=migrate` adds every snapshot engine; `GROBASE_EDITION=full` -turns on every plane except the playground demo. Some module demonstrations need -a larger edition or a feature flag — see [Modules](#modules). +**Optional integrations.** Real credentials live only in git-ignored `.env*` +files; [`.env.example`](.env.example) documents every key as required, +recommended or optional. Left blank, `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` +default to a local value, and without `GOOGLE_CLIENT_ID` / +`GOOGLE_CLIENT_SECRET` in `apps/mail/.env.local` and `apps/calendar/.env.local` +the mail and calendar OAuth flows are off. + +**Development bring-up.** On `develop`, `make all` without `SKIP_SYNC=1` first +fast-forwards every submodule to the tip of its tracked branch +(`sync-submodules-soft` in `utils/mk/pipeline.mk`), so it builds the newest +code rather than the pinned commits. --- -## Team Information +## 👥 Team members and roles + +| role \ name | dylan lesieur | vadim | daniel | sergio | mostafa | +| --- | --- | --- | --- | --- | --- | +| project manager | ❌ | ✅ | ❌ | ❌ | ❌ | +| Product Owner | ✅ | ❌ | ❌ | ❌ | ❌ | +| tech lead | ❌ | ❌ | ✅ | ❌ | ❌ | +| developer | ✅ | ✅ | ✅ | ✅ | ✅ | + +**One owner per role**, as the team confirmed on 2026-11-04. -All five members are developers. Roles evolved during the project; the main -role of each is below (team answer, 2026-11-05). The "Areas in the history" -column is what each member's commits touch ([Individual -Contributions](#individual-contributions) has the numbers). +### Logins and areas -| Member | Login | GitHub | Main role | Responsibilities | Areas in the history | -|---|---|---|---|---|---| -| Dylan Lesieur | `dlesieur` | [@LESdylan](https://github.com/LESdylan) | Product Owner | Product vision and priorities | grobase, osionos, mail, calendar, root infrastructure and CI | -| Vadim Jan Nieto | `vjan-nie` | [@vjan-nie](https://github.com/vjan-nie) | Project Manager | Planning, tracking and coordination | QA tooling, end-to-end tests, documentation, Docker | -| Sergio Jiménez de la Cruz | `serjimen` | [@DjSurgeon](https://github.com/DjSurgeon) | Frontend tech lead | Frontend architecture and quality | the public site (opposite-osiris), osionos accessibility and editor features | -| Daniel Fernández | `danfern3` | [@danielfdez17](https://github.com/danielfdez17) | Backend tech lead | Backend architecture and deployment | monkey-bot (security test bot), grobase Docker and Kubernetes deployment, osionos editor fixes | -| Seyed Mostafa Hashemian | `shashemi` | [@SMOSTAFAH1](https://github.com/SMOSTAFAH1) | QA / quality tech lead | Testing and code quality; joined in the last part of the project | security fixes and SAST, the site's legal pages, TLS edge | +The last column is what each member's commits touch ([Individual +contributions](#-individual-contributions) has the numbers). + +| Member | Login | GitHub | Areas in the history | +|---|---|---|---| +| Dylan Lesieur | `dlesieur` | [@LESdylan](https://github.com/LESdylan) | grobase, osionos, mail, calendar, root infrastructure and CI | +| Vadim Jan Nieto | `vjan-nie` | [@vjan-nie](https://github.com/vjan-nie) | osionos editable block logic (`src/features/block-editor`), QA tooling, end-to-end tests, documentation | +| Daniel Fernández | `danfern3` | [@danielfdez17](https://github.com/danielfdez17) | monkey-bot (security test bot), grobase Docker and Kubernetes deployment, osionos editor fixes | +| Sergio Jiménez de la Cruz | `serjimen` | [@DjSurgeon](https://github.com/DjSurgeon) | the public site (opposite-osiris), osionos accessibility and editor features | +| Seyed Mostafa Hashemian | `shashemi` | [@SMOSTAFAH1](https://github.com/SMOSTAFAH1) | security fixes and SAST, the site's legal pages, TLS edge | **Former members.** The team changed along the way: `rstancu` (Roxana Stancu, [@esettes](https://github.com/esettes)) and Alex Caceres were temporary members @@ -385,182 +210,84 @@ defense covers the current five. --- -## Project Management - -Part of this project was an experiment in managing a team. We ran it as if it -were a demanding, high-output professional team, with a Scrum mindset in which -every member is treated as an expert and anyone may change anyone's code. The -aim was that **nobody is indispensable**. The evidence below separates what the -repositories record from what is the team's own account, and the last part -measures how far we got towards that aim. - -**How the tracking evolved.** Work started in February 2026: the first commits -date from that month (`git log --reverse` across the submodules). Between then -and the autumn the team tried GitHub Projects, Jira tickets and Notion pages -(team account: none of the three left a trace in commit messages, and the -Projects board was not readable with our token). The only written trace is -osionos -[`docs/DoD.md:39`](https://github.com/Univers42/osionos/blob/develop/docs/DoD.md), -a Definition-of-Done template that asks for evidence "in your Pull Request -description or your Trello/Jira" ticket (2026-04-11). +## 🗂 Project management -**How it works now: issues, labels and pull requests on GitHub.** +The team talks on Discord and meets weekly. Work lands through pull requests +into `develop`, then `main`, and a change is done when its verification gate +passes. -| Practice | What the repositories record (2026-11-04) | -|---|---| -| Issues | 129 on osionos, 38 on groot, 20 on prismatica, 16 on grobase (`gh issue list --state all`). Branches and commits name them: `fix/issue-278-database-probe-diagnostics` (osionos #284), `fixes #N` in 40 commit messages across groot, grobase, osionos and prismatica | -| Labels | Triage labels `priority: high`, `subject-blocker` and `account-platform` on groot and osionos, plus GitHub's defaults (`gh label list`) | -| Pull requests | 144 on groot, 182 on osionos, 78 on grobase, 50 on prismatica; merged into `develop`, and `develop` promoted to `main` by a merge commit | -| Peer review | Since 2026-09-21 (grobase, hydrawn) and up to 2026-11-04 (groot `develop`), a GitHub ruleset on each main repository requires one approving review and forbids force-pushes and branch deletion (`gh api repos//rulesets`). CodeRabbit reviews every PR since 2026-10-23 | -| Stacked branches | Feature chains merged one on top of another: groot #84–#87 (`feat/ide-*`), grobase #69–#70 (`feat/multidb-g1b…g1d`), the osionos `release/acct-w1` … `w9` series | -| Commit rhythm | At least one commit on 174 of the 232 days between 2026-03-18 and 2026-11-04, across all repositories | - -**Strict CI from the start.** CI arrived within days of each repository's first -commit: grobase `ci.yml` on 2026-03-29, osionos on 2026-04-12, groot on -2026-05-13. SonarCloud is configured in groot since 2026-05-04 and comments on -its pull requests since #9 (2026-06-01). Security scans (Semgrep, Trivy, -gitleaks, TruffleHog, ZAP, cargo-audit, govulncheck) run on every pull request, -and groot rejects one-word commit subjects -(`.github/workflows/commit-subjects.yml`). - -**Rituals.** Two-week sprints since the start, daily stand-ups and a weekly -meeting, mostly on Discord (team account, confirmed 2026-11-04; Discord leaves -no trace in the repositories). The method was Scrumban (Scrum rituals plus a -Kanban board), as described in -[`docs/dossier-project-dylan/dossier-projet.md:390-416`](https://github.com/Univers42/osionos/blob/develop/docs/dossier-project-dylan/dossier-projet.md) -in osionos. - -**Planning and decisions.** Work is planned and decided in meetings, mostly on -Discord, then recorded in issues, pull requests and the wiki. When a problem -blocked the team, it got a dedicated meeting. Test-driven development and -DevOps practices (CI on every pull request, Docker-first builds, one-command -bring-up) were the means to a frictionless workflow. - -**How far we got.** We publish these numbers so the claims above can be checked: - -- **Sprints.** The sprints were planned outside GitHub: no milestone and no - sprint label exists in any repository. Merges come in bursts: 250 of the 442 - merged pull requests on the six main repositories landed in two weeks (ISO - weeks 43–44). -- **Review.** The rulesets let a maintainer bypass them, and in a sample of the - 40 most recent merged pull requests per repository, 0–22 % carry an approval - from someone other than the author. -- **Nobody indispensable.** Shared ownership is real in breadth: every active - member committed in at least three areas, and osionos has six authors with - five or more commits. In depth we are not there yet: one member wrote 86 % of - the 8,571 human commits, and in 17 of 18 areas one author holds more than half - of the commits. Commit counts are not effort: one large commit and a typo fix - weigh the same. +### Agile ---- +| Scrum | Lean production | Kanban | +| --- | --- | --- | +| The Project Manager acts as Scrum Master. | Numbered verification gates define done. | GitHub Issues and labels track the work. | -## Technical Stack +### How the work flows -### Frontend +- **Branches**: short-lived `feat/`, `fix/` and `docs/`, one change each +- **Repositories**: a change lands in its submodule first, then the root + records it +- **CI**: GitHub Actions on every pull request, with Dependabot for updates +- **Merged pull requests**: LESdylan 260, vjan-nie 72, SMOSTAFAH1 57, + DjSurgeon 8, danielfdez17 4 -| Technology | Used in | Why | -|---|---|---| -| **Astro 7** + **Sass** | prismatica (`apps/opposite-osiris`) | Mostly static marketing content: Astro ships near-zero JavaScript by default and pre-renders pages, which keeps the public site fast. | -| **React 19** + **Vite 6** + **TypeScript** | osionos, mail, calendar | A block editor is a heavily interactive UI; React's component model fits, and Vite gives fast builds. | -| **Tailwind CSS 4**, **lucide-react** | osionos | Utility-first styling framework and an icon set. | -| **Zustand** | osionos | Small, explicit client state store. | -| **Zod 4** | osionos | Schema validation: the diagram spec on the client, and the inputs of the AI service and the MCP server. | -| **livekit-client** | osionos | WebRTC video rooms. | -| **Playwright** | osionos, groot `tests/e2e/` | End-to-end browser tests. | -| **SvelteKit**, **Fastify**, **Rust → WebAssembly** | hydrawn | The whiteboard: a WASM drawing engine with its own API and realtime services. | -| **Tailwind CSS 4** | mail, calendar | `src/styles.css` imports Tailwind's theme and utilities through `@tailwindcss/vite`, without preflight; the app's own rules sit on top. | +### What the repositories record -### Backend — grobase +| Practice | Evidence (2026-11-04) | +|---|---| +| Issues | 129 on osionos, 38 on groot, 20 on prismatica, 16 on grobase (`gh issue list --state all`) | +| Peer review | Since 2026-09-21 (grobase, hydrawn) and up to 2026-11-04 (groot `develop`), a GitHub ruleset on each main repository requires one approving review and forbids force-pushes and branch deletion (`gh api repos//rulesets`). CodeRabbit reviews every pull request since 2026-10-23 | +| Commit subjects | groot rejects one-word commit subjects (`.github/workflows/commit-subjects.yml`) | -grobase is split into **planes** — groups of responsibilities that can be sized and replaced -independently — each written in the language that suits it. +--- -| Plane | Technology | Role | Why this language | -|---|---|---|---| -| **Control plane** | **Go** | Provisioning, tenancy, identity (`POST /v1/keys/verify` turns an API key into an identity) | Long-running, concurrent I/O; a provisioner needs to be correct and readable, not fast. | -| **Data plane** | **Rust** | Executes every query, applies per-owner scoping and ABAC locally | The hot path: predictable latency with no GC pauses, and memory safety where a bug would be a cross-tenant data leak. | -| **Realtime plane** | **Rust** | Turns data changes into events; WebSocket subscriptions (an opt-in profile, included in the `devlean` edition `make all` starts) | Same reasons; pluggable event bus. | -| **Application plane** | **TypeScript / NestJS** | Query and storage routers, schema service, permission engine. The older session, email and GDPR services remain only under the `legacy-node` profile; the Go orchestrator does their work | The original backend, progressively replaced by the Go and Rust planes. | -| **Auth** | **GoTrue** (grobase) + a Node **auth-gateway** (shipped by prismatica) | Sign-up, sign-in, sessions, email OTP, the hand-off to osionos | Battle-tested auth server rather than a hand-rolled one. | -| **Gateway** | **WAF** (nginx + ModSecurity/OWASP CRS) in front of **Kong** | The WAF is the public entry; Kong behind it does key auth and rate limiting | Keeps every internal service off the public network. | - -### Database - -- **PostgreSQL 16** is the primary database: the control-plane registries - (tenants, roles, policies, API keys) and all application data (see [Database - Schema](#database-schema)). **Why:** row-level security (RLS), JSONB for block - content and flexible properties, strong transactional guarantees, and a mature - ecosystem. RLS is what makes per-owner isolation enforceable inside the - database itself, not only in application code. -- **MySQL, MongoDB, Redis, MinIO** (object storage) run in the `devlean` edition - that `make all` starts; **SQLite**, **SQL Server** (needs the `engines` - plane), **DynamoDB** (an opt-in build) and remote **HTTP** APIs are supported - through the same data-plane interface. This is a deliberate feature of the - platform (one API over many engines), not a need of osionos itself. - -### Other significant technologies - -- **Docker / Docker Compose** — every component runs in a container; one `make all`. -- **A local certificate authority + TLS proxy** — all frontends served over trusted HTTPS locally. -- **LiveKit** — the WebRTC SFU for video rooms, a groot compose service started - by `make all`; the osionos bridge mints its tokens. -- **vault42 / 42ctl** — our own zero-knowledge secret store and CLI (separate repositories). -- **CI security scanning** — on every pull request, - [`.github/workflows/mini-baas-security.yml`](.github/workflows/mini-baas-security.yml) - runs Semgrep (SAST), `npm`/`pnpm audit`, Snyk (when a token is set) and Trivy; - Semgrep and Snyk report without blocking. Dependabot opens dependency updates. - SonarCloud analyses every pull request through its GitHub app - (`.sonarcloud.properties`), not through a workflow; `renovate.json` is present - but nothing runs it. - -### Justification of the major choices - -- **Why build a BaaS instead of a single backend?** Because the same backend pieces are rebuilt - for every project. We moved everything that varies between projects — routes, schemas, access - rules, configuration — from code into data, so a new application is a contract, not a server. -- **Why three languages?** Each grobase plane has a different job (Node also - runs the site's auth-gateway and the osionos bridge). Go for boring, correct - orchestration; Rust where performance and memory safety protect tenant - isolation; TypeScript where it already existed. The data plane was migrated - from TypeScript to Rust behind a per-request switch - (`RUST_DATA_PLANE_FORWARD`, on in the default compose), so the migration - needed no downtime and could be rolled back without a deployment. -- **Why per-request isolation?** Scoping on every request (instead of per connection) means a - pooled connection can never carry one user's permissions into another user's query. +## 🧰 Technologies + +| Layer | Choice | Why | +| --- | --- | --- | +| Editor | React 19, Vite, TypeScript | Block editing is highly interactive, and Vite builds fast | +| Public site | Astro, Sass | Static pages with almost no JavaScript | +| Design | @osionos/ui, Tailwind 4, Radix | Our own components, accessible primitives | +| Bridge | Node | Keeps the service key away from the browser | +| Gateway | Kong | One entry for key checks and rate limits | +| Services | NestJS | Storage, live queries and permissions | +| Control plane | Go | Concurrent and readable provisioning | +| Data, real-time | Rust, axum | Low latency, memory safety between tenants | +| Sign-in | GoTrue | A proven auth server, not a hand-written one | +| Storage | PostgreSQL, Redis, MinIO | Row-level security, fast state, files | +| Delivery | Docker, nginx | One command, HTTPS everywhere | + +### Why we made the big choices + +- **A BaaS, not a single backend**: what varies between projects moved from + code into data, so an app is a contract, not a server +- **Three backend languages**: Go for orchestration, Rust where isolation + matters, TypeScript where services already existed +- **A separate bridge**: the one place that holds the service key and checks + every caller +- **PostgreSQL**: one engine for tables, JSONB blocks and row-level security --- -## Database Schema - -The schema lives in two places: +## 🗄 Database schema -1. **Application schema** — idempotent SQL migrations in [`models/`](models/) - (`*-migration.sql`), applied to grobase's PostgreSQL by `make all` through - [`scripts/apply-models-migrations.sh`](scripts/apply-models-migrations.sh). - That runner deliberately skips seven files that target a database with an - integer `users.id` (used by the Electron native edition) or that later files - supersede: `user.sql`, `gdpr-migration.sql`, `auth-security-migration.sql`, - `rls-hardening-migration.sql`, `seeds.sql`, - `osionos-folder-surface-migration.sql` and - `osionos-wiki-surface-migration.sql`; its header explains why. -2. **Platform schema** — grobase's control-plane registries (tenants, mounts, roles, policies, - API keys), created by grobase's own migrations, plus per-application databases created at - runtime from each contract (e.g. `infra/config/contracts/website.schema.sql` in grobase). +- **Workspace**: has members, pages and channels +- **Page**: nests under a parent, keeps its blocks as JSONB +- **Channel**: has members and messages, with reactions -### Core application tables (osionos) +### Full diagram of the core tables ```mermaid erDiagram - osionos_workspaces ||--o{ osionos_workspace_members : "has" - osionos_workspaces ||--o{ osionos_pages : "contains" - osionos_pages ||--o{ osionos_pages : "parent of" - osionos_pages ||--o{ osionos_page_comments : "has" - osionos_pages ||--o{ osionos_page_favorites : "favourited in" - osionos_workspaces ||--o{ osionos_channels : "has" - osionos_channels ||--o{ osionos_channel_members : "has" - osionos_channels ||--o{ osionos_messages : "contains" - osionos_messages ||--o{ osionos_message_reactions : "has" - osionos_messages ||--o{ osionos_message_attachments : "has" + osionos_workspaces ||--o{ osionos_workspace_members : has + osionos_workspaces ||--o{ osionos_pages : contains + osionos_pages ||--o{ osionos_pages : parent_of + osionos_pages ||--o{ osionos_page_comments : has + osionos_pages ||--o{ osionos_page_favorites : favourited_in + osionos_workspaces ||--o{ osionos_channels : has + osionos_channels ||--o{ osionos_channel_members : has + osionos_channels ||--o{ osionos_messages : contains + osionos_messages ||--o{ osionos_message_reactions : has osionos_workspaces { uuid id PK @@ -571,9 +298,9 @@ erDiagram timestamptz created_at } osionos_workspace_members { - uuid workspace_id PK,FK + uuid workspace_id PK, FK uuid user_id PK - text role "owner | admin | editor | viewer" + text role "owner, admin, editor or viewer" text_array permissions } osionos_pages { @@ -582,20 +309,38 @@ erDiagram uuid parent_page_id FK uuid owner_id text title - text visibility "private | shared | public" + text visibility "private, shared or public" jsonb properties jsonb content "the blocks" boolean is_template + timestamptz archived_at + } + osionos_page_comments { + uuid id PK + uuid page_id FK + text block_id + uuid author_id + text content + timestamptz resolved_at + } + osionos_page_favorites { + uuid user_id PK + uuid page_id PK, FK } osionos_channels { uuid id PK uuid workspace_id FK - text kind "text | dm | voice | video | group" + text kind "text, dm, voice or video" text name boolean is_private jsonb abac text dm_key UK } + osionos_channel_members { + uuid channel_id PK, FK + uuid user_id PK + text role + } osionos_messages { uuid id PK uuid channel_id FK @@ -605,38 +350,44 @@ erDiagram timestamptz edited_at timestamptz deleted_at } + osionos_message_reactions { + uuid message_id PK, FK + uuid user_id PK + text emoji PK + } ``` -Other table groups, by migration file: - -| Area | Tables | Migration | -|---|---|---| -| Users & sessions (auth gateway DB) | `users`, `sessions`, `user_activities`, `user_tokens` | `user.sql` | -| Auth security & GDPR | `auth_audit_events`, `gdpr_requests`, `user_consents`, `newsletter_optins` | `auth-security-migration.sql`, `gdpr-migration.sql` (integer-id flavour); `auth-audit-events-migration.sql`, `newsletter-optin-migration.sql` (applied to grobase) | -| Site ↔ editor bridge | `osionos_bridge_identities`, `osionos_bridge_audit_events` | `osionos-bridge-migration.sql` | -| Social & communities | `osionos_communities`, `osionos_community_members`, `osionos_community_channels`, `osionos_connections`, `osionos_user_blocks`, `osionos_user_reports`, `osionos_feed_*` | `osionos-communities-`, `-social-`, `-chat-`, `-feed-engagement-migration.sql` | -| Databases-as-blocks | `osionos_object_databases`, `osionos_workspace_databases`, `osionos_app_connections` | `osionos-object-databases-`, `-workspace-databases-`, `-app-connections-migration.sql` | -| Publishing & sharing | `osionos_published_pages`, `osionos_share_rules`, `osionos_page_links` | `osionos-publish-`, `-share-rules-`, `-page-links-migration.sql` | -| Tasks & notifications | `osionos_tasks`, `osionos_notifications`, `osionos_push_subscriptions` | `osionos-tasks-`, `-engagement-`, `-push-migration.sql` | -| Mail & calendar | `mail_accounts`, `mail_messages`, `calendar_accounts`, `calendar_sources`, `calendar_event_cache` | `mail-migration.sql`, `calendar-migration.sql` | - -### Platform (grobase) registries - -Access control is stored as data: `roles`, `user_roles` and `resource_policies` -tables, evaluated by the SQL function `public.has_permission(...)` and mirrored -by the Rust data plane's own ABAC evaluator. Every table created through -grobase's schema API gets an `owner_id` column appended when the caller does not -declare one (contract schemas declare it themselves), which is what makes one -isolation rule apply to any table. - -**Two `users` tables.** `models/user.sql` defines `users.id` as `SERIAL` (integer): it belongs to -the auth gateway's database. The osionos tables reference users by `UUID`, in grobase's -PostgreSQL, where `make all` never applies `user.sql` — doing so fails on the uuid/integer -foreign-key clash (`scripts/apply-models-migrations.sh:27-38`). +### All the table groups + +| Area | Tables | +| --- | --- | +| Accounts | users, sessions, user_activities, user_tokens | +| Security, GDPR | auth_audit_events, gdpr_requests, user_consents | +| Site to editor | osionos_bridge_identities, osionos_bridge_audit_events | +| Social | osionos_communities, osionos_connections, osionos_user_blocks, osionos_feed_* | +| Databases as blocks | osionos_object_databases, osionos_workspace_databases | +| Publishing | osionos_published_pages, osionos_share_rules, osionos_page_links | +| Tasks, notifications | osionos_tasks, osionos_notifications, osionos_push_subscriptions | +| Mail, calendar | mail_accounts, mail_messages, calendar_accounts, calendar_event_cache | + +Access rules are data too: `roles`, `user_roles` and `resource_policies`. The +schema is in the SQL migrations under `models/`. There are two users tables: +`users` with integer ids belongs to the auth gateway, while osionos refers to +users by UUID. + +`make all` applies the idempotent `models/*-migration.sql` files to grobase's +PostgreSQL through +[`scripts/apply-models-migrations.sh`](scripts/apply-models-migrations.sh). +That runner skips seven files that target the integer-id `users` database or +that later files supersede; its header names them. The access rules are +evaluated by the SQL function `public.has_permission(...)` and mirrored by the +Rust data plane's own ABAC evaluator. --- -## Features List +## ✨ Features and who built them + +Who comes from the commits that touch each feature's code. "Who" counts the non-merge commits that touch the feature's paths (last column), reachable from the commit groot `main` pins for that repository, read through @@ -665,120 +416,205 @@ A commit count is not effort, and pairing or review does not show in it. | Contract-driven provisioning | One JSON contract → isolated database, roles, keys and frontend `.env` | dlesieur 9 | grobase `scripts/provision-contract.sh`, `infra/config/contracts` | | Self-serve applications | `POST /v1/tenants/me/apps` creates a new isolated app with its own database and key, behind `APPS_SELFSERVE_ENABLED` (off unless set) | dlesieur 32 | grobase `src/control-plane/internal/tenants` | | Multi-engine data API | One API and one key over PostgreSQL, MySQL, MongoDB, Redis (default edition), SQLite, SQL Server (`engines` plane), HTTP and DynamoDB (opt-in build) | dlesieur 95, vjan-nie 3 | grobase `src/data-plane-router` | -| Public API | Key-authenticated, rate-limited API behind Kong, documented with OpenAPI at `/docs/` on the `:8444` edge | dlesieur 64, vjan-nie 5, shashemi 1 | grobase `infra/docker/services/kong`, `infra/config/openapi`; groot `infrastructure/tls/nginx.conf` | +| Public API | Key-authenticated, rate-limited API behind Kong (not claimed as a module: see [Modules and points](#-modules-and-points)) | dlesieur 64, vjan-nie 5, shashemi 1 | grobase `infra/docker/services/kong`, `infra/config/openapi`; groot `infrastructure/tls/nginx.conf` | --- -## Modules - -The subject requires **14 points** (Major = 2, Minor = 1). A module that cannot -be demonstrated live scores zero. Every row is graded against **the local stack -built from `main`** (`git checkout main`, `make all SKIP_SYNC=1`, which starts -grobase's `devlean` edition; see [Defense bring-up](#defense-bring-up-pinned)) -by **one rule**, groot's own. It keeps the clause of osionos -[`docs/ai/README.md`](https://github.com/Univers42/osionos/blob/1c1d5d849c1dc43460a194f7ee3d97e17505205c/docs/ai/README.md) -lines 7–8 (a row is never A when its demonstration needs something switched off -or unmeasured), but it is stricter than that file's legend, which grades a -documented profile A: - -- **A**: the demo works on that stack with nothing switched on, and a green CI - job drives that path against a running service. -- **B**: it works, but the demo needs something switched on (a flag, a profile, - an edition, a provider credential), or its only evidence is unit tests with - fakes or a run that was never repeated. -- **C**: partial. **Not claimed.** These rows are listed under [Not claimed - yet](#not-claimed-yet). - -**Claimed = A and B only: 30 points against the 14 required. 6 of them are A.** -Paths are prefixed by repository: `G` groot, `GB` grobase (`apps/grobase`), `OS` -osionos (`apps/osionos/app`), `OO` opposite-osiris (`apps/opposite-osiris`). -They point at the commits pinned by groot's `main` branch. CI names a workflow -job: `GB-CI` is grobase `ci.yml`, `OS-CI` is osionos `ci.yml`, `OO-CI` is -prismatica `ci.yml`. - -| # | Module | Category | Type | Pts | Grade | Core files | Tests → CI job | Docs | Demo (3 steps) | -|---|---|---|---|--:|:--:|---|---|---|---| -| 1 | Public API (key, rate limit, docs, ≥5 endpoints) | Web | Major | 2 | **A** | `GB infra/docker/services/kong/conf/kong.yml:221-245` (`/rest/v1`: key-auth + rate-limiting); `GB infra/config/openapi/grobase-public.json` | `GB scripts/verify/m102-gateway-query-path.sh` → GB-CI "Integration Tests" (`ci.yml:927`) | `GB infra/config/openapi/README.md` | `make all SKIP_SYNC=1` → `/rest/v1` without `apikey` answers 401, with it 200 → repeat until 429 | -| 2 | Backend as microservices | DevOps | Major | 2 | **A** | `GB orchestrators/compose/base/*.yml` (16 files); `GB orchestrators/makes/30-planes.mk:33`; `GB src/control-plane`; `GB src/data-plane-router` | GB-CI unit suites (`ci.yml:1436`) + "Engine conformance" legs (`ci.yml:1532`) | [`wiki/platform/02-planes.md`](wiki/platform/02-planes.md) | `make -C apps/grobase planes` → `docker compose ps` → `make healthcheck` | -| 3 | Real-time features via WebSockets | Web | Major | 2 | **B** | `GB infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs:243` (`/ws`); `GB kong.yml:314-315` (`/realtime/v1/ws`); `OS scripts/bridge-social-core.mjs:192-201` (the bridge publishes); `OS src/services/realtime-messages/wsTransport.ts:81` (chat over the socket, on whenever a session exists) | `GB scripts/verify/m201-realtime-issuer.sh` → GB-CI "Integration Tests" (`ci.yml:855`): it checks the `/ws` handshake (`AUTH_OK`) on the running realtime, not delivery between two users; m189 (`ci.yml:735`); `rust-realtime` suite and m184 (`ci.yml:1436,1479`). osionos's chat browser test runs against a stub bridge (OS-CI "Browser tests (stub bridge URL)", `ci.yml:432-433`) | [`wiki/platform/05-realtime.md`](wiki/platform/05-realtime.md) | sign in as two users in two browsers → open the same direct message in both → a message sent in one appears in the other without a reload. A second default demo: two windows on one Whiteboard board, where each stroke appears in the other (`G tests/e2e/specs/dw8-realtime.spec.ts`, `make e2e`, run by hand) | -| 4 | Use a framework for both the frontend and backend | Web | Major | 2 | **A** | Frontend: React 19 (`OS package.json:105-106`). Backend: NestJS 11, grobase's application plane (`GB src/package.json:52`; `GB src/apps/query-router/src/main.ts:30`, `NestFactory`); axum 0.7 is a supporting example (`GB src/data-plane-router/Cargo.toml:59`) | React: OS-CI "Browser tests", Playwright against the served app in 6 shards (`ci.yml:338-339`). NestJS: GB-CI "Integration Tests" builds query-router from the pin and starts it (`ci.yml:900-904`), then m213 sends `/query/v1/execute` through Kong to it (`ci.yml:917`); the `nestjs` unit suite (`ci.yml:1436`) | [`wiki/platform/02-planes.md`](wiki/platform/02-planes.md) | `make all SKIP_SYNC=1` → `docker compose ps` lists `mini-baas-query-router` (`GB orchestrators/compose/base/app-services.yml:12`, in the `devlean` edition) → open osionos at `https://localhost:3001` | -| 5 | Allow users to interact with other users (chat, profile, friends) | Web | Major | 2 | **B** | `OS scripts/bridge-chat.mjs`; `OS scripts/bridge-account-profile.mjs`; `OS scripts/bridge-social.mjs`; `OS src/features/chat`; `OS src/features/connections` | `OS tests/canvas/chat-catch-up.test.ts` → OS-CI "quality (root)" Canvas unit tests (`ci.yml:285`); `OS tests/bridge/bridge-chat-reply-notify`, `bridge-account-profile`, `bridge-social-connections`, `bridge-connection-transitions` → Bridge tests (`ci.yml:291`). Unit tests with fakes: no CI job drives two users against a running stack | [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | as row 3 → user A sends a friend request, user B accepts → each opens the other's profile | -| 6 | Standard user management and authentication | User management | Major | 2 | **B** | `OS scripts/bridge-account-profile.mjs`; `OS scripts/bridge-avatar.mjs`; `OS scripts/bridge-presence.mjs`; `OS src/features/collab/model/collabPresence.ts:63` (initials when there is no avatar) | `OS tests/bridge/bridge-avatar` (upload, type and size refusal), `bridge-presence` (online status), `bridge-account-profile` → OS-CI Bridge tests (`ci.yml:291`). Unit tests with fakes | [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | sign up on `https://localhost:4322` → upload an avatar and edit the profile → a friend sees you online | -| 7 | Real-time collaboration | Web | Minor | 1 | **B** | `OS src/features/collab/model/{remoteCursors,collabPresence,blockSync}.ts`; `OS scripts/bridge-collab.mjs:626`; `GB …/realtime-auth/src/jwt/mod.rs:90-94` (`collab:` closed to wildcard tokens) | `OS tests/bridge/bridge-collab-*` (9) → OS-CI Bridge tests (`ci.yml:291`) | [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | rebuild osionos with `VITE_OSIO_COLLAB_SHARED=1` (off by default) → two browsers on one page → watch the remote cursor and edit the same block from both | -| 8 | Advanced permissions | User management | Major | 2 | **B** | `OS scripts/bridge-roles.mjs:28-29`; `OS scripts/bridge-share-rules.mjs:38`; `OS scripts/bridge-page-access.mjs:217`; `OS src/features/admin-users/api.ts` | `OS tests/bridge/bridge-page-access*` (7), `bridge-share-rules-*` (2) → OS-CI Bridge tests | [`wiki/platform/07-isolation.md`](wiki/platform/07-isolation.md) | sign in as admin → change a member's role → that member loses access | -| 9 | Organisation system | User management | Major | 2 | **B** | `OS scripts/bridge-workspace-teams.mjs:229`; `OS scripts/bridge-workspace-invites.mjs:134`; `OS src/features/settings/teamspaces/TeamspacesPanel.tsx` | `OS tests/bridge/bridge-workspace-*` (7) → OS-CI Bridge tests | [`wiki/platform/06-contracts.md`](wiki/platform/06-contracts.md) | create a workspace → create a teamspace → invite a user, who accepts | -| 10 | Remote authentication with OAuth 2.0 | User management | Minor | 1 | **B** | `OO scripts/auth/oauth-providers.mjs`; `OO scripts/auth/oauth-link.mjs`; `OO src/scripts/portal-oauth.ts`; `GB orchestrators/compose/base/auth-api.yml:64-67` (GoTrue's Google provider) | `OO tests/unit/auth/oauth-providers`, `oauth-link`, `gateway-oauth-{start,link,collision}` (5) → OO-CI "Quality Gate" `pnpm test` (`ci.yml:31-32`) | `OO docs/adr/0002-oauth-link-existing-account.md` | set `GOOGLE_OAUTH_ENABLED=true`, `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` for grobase (Google provider credentials, not shipped) → `make all SKIP_SYNC=1` → "Continue with Google" on `https://localhost:4322` | -| 11 | Health/status page, backups, disaster recovery | DevOps | Minor | 1 | **B** | `GB infra/docker/services/status-page/{server,availability}.py`; `GB orchestrators/compose/base/ops.yml`; `GB scripts/verify/m241-status-backup-recovery.sh` | m241 → GB `operational-reliability.yml:27`; m192 → GB-CI (`ci.yml:582`), **red on grobase `develop`** ([run 41870904860](https://github.com/Univers42/grobase/actions/runs/41870904860)) | [`wiki/operations/04-troubleshooting.md`](wiki/operations/04-troubleshooting.md) | bring up the `ops` plane (not in the default edition) → open the status page → stop a container, then `m241 --live` | -| 12 | GDPR compliance | Data | Minor | 1 | **B** | `OS scripts/bridge-account-export.mjs:143`; `OS scripts/bridge-account-erasure.mjs:79`; `OS src/features/settings/ui/AccountExportSection.tsx`; `OS src/features/settings/ui/danger/AccountDangerZone.tsx` | `OS tests/bridge/bridge-account-export*` (3), `bridge-account-erasure` (1) → OS-CI Bridge tests; `G .github/workflows/models-grants.yml` | [`wiki/security/01-model.md`](wiki/security/01-model.md) | Settings › export my data → open the archive → delete the account | -| 13 | Two-factor authentication | User management | Minor | 1 | **B** | `OS scripts/bridge-account-mfa-totp.mjs:68`; `OS scripts/bridge-account-mfa.mjs:187`; `OS scripts/bridge-account-passkeys.mjs:100`; `OS src/features/settings/profile/TwoFactorModal.tsx` | `OS tests/bridge/bridge-account-mfa-*` (5), `bridge-account-passkeys` (1) → OS-CI Bridge tests | osionos `docs/account-platform` | enable TOTP → sign out → sign in with the code | -| 14 | **Module of choice:** contract-driven application factory | Free choice | Major | 2 | **B** | `GB src/control-plane/internal/tenants/selfserve_apps.go:55-58`; `GB src/control-plane/cmd/tenant-control/mount_appsselfserve.go:26`; `GB scripts/verify/m177-selfserve-apps.sh` | m176 → GB-CI (`ci.yml:192`, static leg, in the Shellcheck job); m176 and m177 → groot `modules-of-choice.yml:39-40`, static mode only | [`wiki/platform/06-contracts.md`](wiki/platform/06-contracts.md), [justification](#justification--module-of-choice-major-contract-driven-application-factory) | set `APPS_SELFSERVE_ENABLED` (empty by default) → OTP login, `POST /v1/tenants/me/apps` twice → one app's key reads the other: 4xx | -| 15 | **Module of choice:** SSRF guard on the HTTP engine | Free choice | Minor | 1 | **B** | `GB src/data-plane-router/crates/data-plane-pool/src/dial_guard/classify.rs:90-155`; `GB src/data-plane-router/crates/data-plane-pool/src/http/validate.rs:83-153` | `GB …/dial_guard/classify_tests.rs` → GB-CI `rust-data-plane` + "Engine conformance (http)" | [`wiki/security/04-network.md`](wiki/security/04-network.md), [justification](#justification--module-of-choice-minor-ssrf-guard-on-the-http-engine) | mount an `http` source at `169.254.169.254` → query it → refused. Whether the default edition mounts the `http` engine is unverified (guess: it does not) | -| 16 | Complete LLM system interface: text, streaming, error handling, rate limiting. Images exist but are **off by default** and not part of this claim | Artificial Intelligence | Major | 2 | **B** | osionos `services/osionos-ai/src/routes/runs.ts:178-324`, `src/runs/{runner,upstream,upstreamError,streamPump}.ts`, `src/rateLimit.ts:84`, `src/http.ts:108-129`; client `src/features/ai/model/runStream.ts`. All 12 hops: [`wiki/project/ai-llm-interface.md`](wiki/project/ai-llm-interface.md) | `services/osionos-ai/tests/{runs,complete,upstreamError*,runRateLimit,failover,quotaFailover}.test.ts` → OS-CI "AI service (tsc + suite)" (`ci.yml:314`); `tests/canvas/ai-{chat-store,run-dispatch,run-stream}.test.ts` → "quality (root)" Canvas unit tests (`ci.yml:285`) | [`wiki/project/ai-llm-interface.md`](wiki/project/ai-llm-interface.md), [`wiki/runbooks/osionos-ai.md`](wiki/runbooks/osionos-ai.md), [M1 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m1-images.md) | `docker compose --profile ai up -d --build` → ask a question and watch it stream → reload mid-answer (it resumes), or stop it | -| 17 | Complete RAG on a large dataset | Artificial Intelligence | Major | 2 | **B** | osionos `services/osionos-ai/src/retrieval/search.ts:277,469,564`, `src/retrieval/rerank.ts:16`; `G models/osionos-ai-rag-migration.sql:60,91` | `services/osionos-ai/tests/{retrieval,aclAgreement,rerank}.test.ts` → OS-CI AI service | [M2 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m2-rag.md), osionos `services/osionos-ai/eval/wiki/RESULTS-rag.md` | `--profile ai` → ask a workspace question → click a citation, then show the 110 363-chunk measurement (run on a separate eval stack; the reranker is off by default) | -| 18 | Content moderation AI | Artificial Intelligence | Minor | 1 | **B** | osionos `scripts/bridge-moderation-gate.mjs:70`; `services/osionos-ai/src/moderation/l1.ts:99` | `services/osionos-ai/tests/moderation*.test.ts` (4) → OS-CI AI service; `tests/bridge/bridge-moderation*` (5) → Bridge tests | [M4 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m4-moderation.md) | set `OSIONOS_MODERATION_ACTIONS=hold` and `OSIONOS_MODERATION_STATE=1` → post a toxic comment → it waits in the admin console (no auto-removal, by design) | -| 19 | Voice / speech | Artificial Intelligence | Minor | 1 | **B** | osionos `services/osionos-ai/src/routes/voice.ts:104`, `src/features/voice/ui/DictationButton.tsx`, `src/features/voice/model/readAloud.ts`; `G docker-compose.yml:788-790` | `services/osionos-ai/tests/voice.test.ts` → OS-CI AI service | [M5 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m5-voice.md) | `--profile ai`, with the speech model fetched into its volume → dictate into a page → read it aloud (read-aloud voices not measured yet) | - -No separate framework Minor is claimed beside row 4. - -**Point calculation** (free choice is capped at 3, -[`wiki/project/01-subject.md`](wiki/project/01-subject.md#points)) - -| | Before (2026-10) | After | -|---|--:|--:| -| Catalogue modules claimed (rows 1–13) | 18 | 21: 6 A (rows 1, 2, 4) + 15 B (rows 3, 5–13) | -| Modules of choice (rows 14–15, cap 3) | 3 | 3, all B | -| Artificial Intelligence modules (rows 16–19) | 10 | 6, all B | -| **Total claimed** | **31 listed** | **30 (A + B)** | -| Of which A | 10 | **6** | -| Required | 14 | 14 | - -What moved: under the old legend ("works as-is"), six rows were A. Under one -rule, the rows whose demonstration needs a switch or whose only evidence is -unit-level are B: WebSockets (CI checks the handshake, not delivery), real-time -collaboration (off by default), the contract factory and the SSRF guard. Row 4 -is new at A. The rows below no longer count. - -### Not claimed yet - -| Module | Was | Why it is not claimed, in one line | -|---|---|---| -| Monitoring with Prometheus + Grafana, DevOps, Major | 2, C | The default edition ships no observability plane, and its gate m85 runs in no workflow | -| File upload and management, Web, Minor | 1, C | Avatars and page files upload (`OS scripts/bridge-media.mjs:160`), but no general file manager is verified, and gate m159 runs in no workflow | -| ML recommendation system, AI, Major | 2, B | Needs 20+ real users, the offline numbers are synthetic, and the `osionos-ml` Python tests run in no CI | -| Sentiment analysis, AI, Minor | 1, C | The model licence is undeclared | -| Image recognition and tagging, AI, Minor | 1, C | The browser flow is unverified. Locally the VLM starts with `--profile ai` (`G docker-compose.yml:1004-1006`); `--profile ai-vlm` exists only in `docker-compose.public.yml:211-212` | -| AI opponent, AI | — | osionos has no game | -| Multiple languages, Accessibility | — | `i18next` is a dependency (osionos `package.json:92`), but no source file imports it and no translation resource exists | - -### Justification — Module of choice (Major): contract-driven application factory - -- **Why this module.** It is the central idea of the project: a backend with zero +## 🎯 Modules and points + +**16 modules, 26 points claimed**: 10 Majors and 6 Minors. The subject +asks for 14, and bonus points stop at 19. A module that fails live scores zero, +which is why we claim with margin. + +### Why we chose each module + +| Module | Points | Why | +| --- | --- | --- | +| W1 Frameworks | 2 | React for the editor, axum and NestJS for the services | +| W4 Real-time | 2 | Chat, presence and live editing need a push channel | +| W5 User interaction | 2 | Chat, profiles and contacts are the product | +| W9 Collaboration | 1 | Live editing is the point of a shared workspace | +| W12 Design system | 1 | One token system keeps every app consistent | +| W13 Advanced search | 1 | Databases are the core object, so finding rows matters | +| U1 User management | 2 | Everything hangs off a user with a profile | +| U3 OAuth 2.0 | 1 | Sign in with Google or GitHub, no new password | +| D3 Microservices | 2 | Each grobase plane scales and is replaced on its own | +| I3 LLM interface | 2 | The assistant is the workspace's main AI feature | +| U5 Organisation system | 2 | Teams share the product through workspaces and teamspaces | +| U4 Advanced permissions | 2 | A shared workspace needs roles and per-page access | +| I2 RAG | 2 | The assistant answers from the workspace and cites it | +| I5 Content moderation | 1 | Communities and comments are read by other users | +| Module of choice: app factory | 2 | [Justification](#module-of-choice-major-contract-driven-app-factory) | +| Module of choice: SSRF guard | 1 | [Justification](#module-of-choice-minor-ssrf-guard-on-the-http-engine) | + +### What we claim, how we demo it, and the risk + +Paths are prefixed by repository: `G` groot, `GB` grobase (`apps/grobase`, +`21c132b6`), `OS` osionos (`apps/osionos/app`, `735c40fd`), `OO` +opposite-osiris (`apps/opposite-osiris`, `34237735`): the commits pinned by +groot's `main` branch on 2026-11-05. CI names a workflow job: `GB-CI` is +grobase `ci.yml`, `OS-CI` is osionos `ci.yml`, `OO-CI` is prismatica `ci.yml`. +The "local main stack" is `make all SKIP_SYNC=1` on groot `main`. + +Risk is how likely the module is to fail in front of an evaluator: + +- **low**: on in the local main stack with nothing to switch on, and a CI job + tests it +- **medium**: needs a switch, a profile or the deployed stack, or no test + covers part of what the subject asks +- **high**: no evidence cited yet, or the claim leans on a measurement the live + stack does not reproduce + +| Code | Module | Type | Points | How we demo it | Main files | Tests → CI job | Risk | +| --- | --- | --- | --: | --- | --- | --- | --- | +| W1 | Frameworks, front and back | Major | 2 | Local main stack, nothing to switch on: open the editor (React); `docker compose ps` lists the NestJS query-router | Frontend: React 19 (`OS package.json:105-106`). Backend: NestJS 11, grobase's application plane (`GB src/package.json:52`; `GB src/apps/query-router/src/main.ts:30`, `NestFactory`); axum 0.7 is a supporting example (`GB src/data-plane-router/Cargo.toml:59`); docs: [`wiki/platform/02-planes.md`](wiki/platform/02-planes.md) | React: OS-CI "Browser tests", Playwright against the served app in 6 shards (`ci.yml:338-339`). NestJS: GB-CI "Integration Tests" builds query-router from the pin and starts it (`ci.yml:900-904`), then m213 sends `/query/v1/execute` through Kong to it (`ci.yml:917`); the `nestjs` unit suite (`ci.yml:1436`) | low | +| W4 | Real-time (WebSockets) | Major | 2 | Local main stack, nothing to switch on: two browsers in one chat channel; a message sent in one appears in the other | `GB infra/docker/services/realtime/realtime-agnostic/crates/realtime-server/src/server.rs:243` (`/ws`); `GB kong.yml:314-315` (`/realtime/v1/ws`); `OS scripts/bridge-social-core.mjs:192-201` (the bridge publishes); `OS src/services/realtime-messages/wsTransport.ts:81` (chat over the socket, on whenever a session exists); docs: [`wiki/platform/05-realtime.md`](wiki/platform/05-realtime.md) | `GB scripts/verify/m201-realtime-issuer.sh` → GB-CI "Integration Tests" (`ci.yml:855`): it checks the `/ws` handshake (`AUTH_OK`) on the running realtime, not delivery between two users; m189 (`ci.yml:735`); `rust-realtime` suite and m184 (`ci.yml:1436,1479`). osionos's chat browser test runs against a stub bridge (OS-CI "Browser tests (stub bridge URL)", `ci.yml:432-433`) | low | +| W5 | User interaction | Major | 2 | Local main stack, nothing to switch on: edit a profile, add a contact, send a direct message | `OS scripts/bridge-chat.mjs`; `OS scripts/bridge-account-profile.mjs`; `OS scripts/bridge-social.mjs`; `OS src/features/chat`; `OS src/features/connections`; docs: [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | `OS tests/canvas/chat-catch-up.test.ts` → OS-CI "quality (root)" Canvas unit tests (`ci.yml:285`); `OS tests/bridge/bridge-chat-reply-notify`, `bridge-account-profile`, `bridge-social-connections`, `bridge-connection-transitions` → Bridge tests (`ci.yml:291`). Unit tests with fakes: no CI job drives two users against a running stack | low | +| W9 | Real-time collaboration | Minor | 1 | Deployed stack; locally, rebuild osionos with `VITE_OSIO_COLLAB_SHARED=1`: two browsers on one page, edit, watch the remote cursor | `OS src/features/collab/model/{remoteCursors,collabPresence,blockSync}.ts`; `OS scripts/bridge-collab.mjs:626`; `GB …/realtime-auth/src/jwt/mod.rs:90-94` (`collab:` closed to wildcard tokens); docs: [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | `OS tests/bridge/bridge-collab-*` (9) → OS-CI Bridge tests (`ci.yml:291`) | medium | +| W12 | Design system | Minor | 1 | Local main stack, nothing to switch on: open `packages/osionos-ui/src` (36 components) and `tokens.css`, then show the same buttons, menus and modals across the editor | `@osionos/ui`, the `OS packages/osionos-ui` submodule (`99fa5430`), imported by 128 osionos files (`OS tsconfig.json:49-50`): `src/{atoms,molecules,primitives}` (36 `.tsx` components), `tokens.css` (94 `--osio-*` tokens: colours, radii, font sizes), `scripts/check-token-contract.mjs`; icons are Lucide glyphs behind `src/atoms/IconValueView/LucideGlyph.tsx` | `tests/{buttonTones,dropdownLayout,primitives,pure,lockedMermaid}.test.ts` → osionos-ui CI `make check` (typecheck, test, tokens), green at the pin ([run 41792181656](https://github.com/Univers42/osionos-ui/actions/runs/41792181656)); osionos's own CI does not run them. The icons come from a library and no token sets a font family | medium | +| W13 | Advanced search | Minor | 1 | Local main stack, nothing to switch on: in a database view, search, add a filter rule, sort by a property, page through the rows | The database views, in the `OS src/shared/notion-database-sys` submodule (`6596ea92`): `src/components/topBar/SearchBar.tsx`, `src/components/filters/AdvancedFilterGrid.tsx`, `src/lib/filter/evaluateFilter.ts`, `src/components/sort/SortPanel.tsx`, `src/components/views/shared/ViewPaginationBar.tsx`; page search `OS scripts/bridge-api.mjs` (`pageTitleScore`) | `OS tests/canvas/{db-filter-matrix,filter-summary,select-filter-options,top-bar-search-archived}.test.ts` → OS-CI Canvas unit tests (`ci.yml:285`); `OS tests/bridge/bridge-{search,paging}.test.mjs` → Bridge tests (`ci.yml:291`); `OS tests/e2e/functional/database{SortFilter,FilterSortUx}.spec.mjs` → Browser tests (`ci.yml:339`). OS-CI green at the pin ([run 41935908342](https://github.com/Univers42/osionos/actions/runs/41935908342)) | low | +| U1 | User management | Major | 2 | Local main stack, nothing to switch on: sign up, edit the profile, upload an avatar, see online status | `OS scripts/bridge-account-profile.mjs`; `OS scripts/bridge-avatar.mjs`; `OS scripts/bridge-presence.mjs`; `OS src/features/collab/model/collabPresence.ts:63` (initials when there is no avatar); docs: [`wiki/product/02-osionos.md`](wiki/product/02-osionos.md) | `OS tests/bridge/bridge-avatar` (upload, type and size refusal), `bridge-presence` (online status), `bridge-account-profile` → OS-CI Bridge tests (`ci.yml:291`). Unit tests with fakes | low | +| U3 | OAuth 2.0 | Minor | 1 | Deployed stack (needs Google or GitHub credentials): sign in with Google | `OO scripts/auth/oauth-providers.mjs`; `OO scripts/auth/oauth-link.mjs`; `OO src/scripts/portal-oauth.ts`; `GB orchestrators/compose/base/auth-api.yml:64-67` (GoTrue's Google provider); docs: `OO docs/adr/0002-oauth-link-existing-account.md` | `OO tests/unit/auth/oauth-providers`, `oauth-link`, `gateway-oauth-{start,link,collision}` (5) → OO-CI "Quality Gate" `pnpm test` (`ci.yml:31-32`) | medium | +| D3 | Microservices | Major | 2 | Local main stack, nothing to switch on: `docker compose ps` lists each plane as its own container | `GB orchestrators/compose/base/*.yml` (16 files); `GB orchestrators/makes/30-planes.mk:33`; `GB src/control-plane`; `GB src/data-plane-router`; docs: [`wiki/platform/02-planes.md`](wiki/platform/02-planes.md) | GB-CI unit suites (`ci.yml:1436`) + "Engine conformance" legs (`ci.yml:1532`) | low | +| I3 | LLM interface | Major | 2 | Deployed stack, or locally `docker compose --profile ai up -d --build`: ask a question and watch it stream, reload mid-answer (it resumes), or stop it | osionos `services/osionos-ai/src/routes/runs.ts:178-324`, `src/runs/{runner,upstream,upstreamError,streamPump}.ts`, `src/rateLimit.ts:84`, `src/http.ts:108-129`; client `src/features/ai/model/runStream.ts`. All 12 hops: [`wiki/project/ai-llm-interface.md`](wiki/project/ai-llm-interface.md); docs: [`wiki/project/ai-llm-interface.md`](wiki/project/ai-llm-interface.md), [`wiki/runbooks/osionos-ai.md`](wiki/runbooks/osionos-ai.md), [M1 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m1-images.md) | `services/osionos-ai/tests/{runs,complete,upstreamError*,runRateLimit,failover,quotaFailover}.test.ts` → OS-CI "AI service (tsc + suite)" (`ci.yml:314`); `tests/canvas/ai-{chat-store,run-dispatch,run-stream}.test.ts` → "quality (root)" Canvas unit tests (`ci.yml:285`) | medium | +| U5 | Organisation system | Major | 2 | Local main stack, nothing to switch on (the routes mount unconditionally, `OS scripts/bridge-account-mounts.mjs:72`): create a workspace, then a teamspace, invite a second user, who accepts | `OS scripts/bridge-workspace-teams.mjs:229`; `OS scripts/bridge-workspace-invites.mjs:134`; `OS src/features/settings/teamspaces/TeamspacesPanel.tsx`; docs: [`wiki/platform/06-contracts.md`](wiki/platform/06-contracts.md) | `OS tests/bridge/bridge-workspace-*` (7) → OS-CI "quality (root)" Bridge tests (`ci.yml:291`). Unit tests with fakes | low | +| U4 | Advanced permissions | Major | 2 | Local main stack, nothing to switch on (`OS scripts/bridge-account-mounts.mjs:75`): as admin, change a member's role or a page's share level; that member loses access | `OS scripts/bridge-roles.mjs:28-29` (admin, moderator, user, guest); `OS scripts/bridge-share-rules.mjs:38`; `OS scripts/bridge-page-access.mjs:217`; `OS src/features/admin-users/api.ts`; docs: [`wiki/platform/07-isolation.md`](wiki/platform/07-isolation.md) | `OS tests/bridge/bridge-page-access*` (7), `bridge-share-rules-*` (2), `bridge-mount-roles` → OS-CI Bridge tests (`ci.yml:291`). No test drives the per-role views the subject asks for | medium | +| I2 | RAG | Major | 2 | Same switch as I3: ask a workspace question, click a citation. The large-dataset part is a recorded measurement (110 363 chunks, run on a separate eval stack, reranker off by default), not live | `OS services/osionos-ai/src/retrieval/search.ts:277,469,564`; `OS services/osionos-ai/src/retrieval/rerank.ts:16`; `G models/osionos-ai-rag-migration.sql:60,91`; docs: [M2 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m2-rag.md), `OS services/osionos-ai/eval/wiki/RESULTS-rag.md` | `OS services/osionos-ai/tests/{retrieval,aclAgreement,rerank}.test.ts` → OS-CI "AI service (tsc + suite)" (`ci.yml:314`) | high | +| I5 | Content moderation | Minor | 1 | Same switch as I3, plus `OSIONOS_MODERATION_ACTIONS=hold` and, once its migration is applied, `OSIONOS_MODERATION_STATE=1` (`OS scripts/bridge-moderation-gate.mjs:46`): post a toxic comment; it waits in the admin console (no auto-removal, by design) | `OS scripts/bridge-moderation-gate.mjs:70`; `OS services/osionos-ai/src/moderation/l1.ts:99`; docs: [M4 sheet](https://github.com/Univers42/osionos/blob/develop/docs/ai/modules/m4-moderation.md) | `OS services/osionos-ai/tests/moderation*.test.ts` (4) → OS-CI AI service (`ci.yml:314`); `OS tests/bridge/bridge-moderation*` (5) → Bridge tests (`ci.yml:291`) | medium | +| Choice | Contract-driven app factory | Major | 2 | Local main stack, set `APPS_SELFSERVE_ENABLED` (empty by default, `GB orchestrators/compose/base/control-plane.yml:140`): sign in, `POST /v1/tenants/me/apps` twice; one app's key on the other app's database answers 4xx. Steps: [`wiki/DEFENSE.md`](wiki/DEFENSE.md) | `GB src/control-plane/internal/tenants/selfserve_apps.go:55-58`; `GB src/control-plane/cmd/tenant-control/mount_appsselfserve.go:26`; `GB scripts/verify/m177-selfserve-apps.sh`; docs: [`wiki/platform/06-contracts.md`](wiki/platform/06-contracts.md) | m176 static leg → GB-CI Shellcheck job (`ci.yml:192-193`); m176 and m177 → groot `modules-of-choice.yml:39-40`, static mode only. Their live half (`BAAS_VERIFY_LIVE=1`) is on grobase branch `ci/live-proof-gates-m175-m177`, not pinned yet ([#177](https://github.com/Univers42/groot/issues/177)) | medium | +| Choice | SSRF guard on the HTTP engine | Minor | 1 | No stack needed, but a Rust toolchain: from `apps/grobase/src/data-plane-router`, `cargo test -p data-plane-pool ssrf_blocks_internal_and_metadata_ips -- --nocapture`; it passes while listing `169.254.169.254` as blocked. Steps: [`wiki/DEFENSE.md`](wiki/DEFENSE.md). Live alternative: mount an `http` source at `169.254.169.254`; whether the default edition mounts the `http` engine is unverified (guess: it does not) | `GB src/data-plane-router/crates/data-plane-pool/src/dial_guard/classify.rs:90-155`; `GB …/data-plane-pool/src/http/validate.rs:83-153`; `GB …/data-plane-pool/src/http/adapter.rs:57-61` (pins the checked IP); docs: [`wiki/security/04-network.md`](wiki/security/04-network.md) | `GB …/data-plane-pool/src/http/mod.rs:90` (`ssrf_blocks_internal_and_metadata_ips`, one of 30 tests in that file) and `GB …/dial_guard/classify_tests.rs` → GB-CI unit tests `rust-data-plane` (`ci.yml:1436`); "Engine conformance (http)" (`ci.yml:1531`) | medium | + +The codes W6, U4, U5, I2, I5 and D4 follow the subject's order within each +category, as LESdylan's codes do. The subject is not in this repository, so +check them against the team's copy. + +### Totals + +| | Points | +| --- | --: | +| Claimed | 26 | +| Claimed at low risk (W1, W4, W5, W13, U1, D3, U5) | 13 | +| Claimed at medium risk | 11 | +| Claimed at high risk (I2) | 2 | +| Modules of choice (cap 3) | 3 | +| Above 19 if every high-risk module fails | +5 | +| Above 19 if every medium- and high-risk module fails | -6 | + +If every medium- and high-risk module fails, the 13 low-risk points are also +1 short of the 14 required. One switch, `--profile ai`, carries I3, I2 and +I5 (5 points), and they fail together. + +### Overlaps we checked + +- **W1 replaces W2 and W3**: neither Minor is claimed. +- **Modules of choice**: one Major and one Minor, 3 points, the cap. Neither is + a catalogue module; the SSRF guard is not the cybersecurity WAF or Vault + module. An evaluator may still count the SSRF guard as part of the data + plane rather than its own Minor: say why it is separate, with the unit-test + demo ready. +- **D3 and the app factory** both live in grobase's control plane: D3 is + claimed for splitting the backend into planes, the factory for what one + plane does (create an isolated app on its own database). Different evidence. +- **U5 and U4** both act on workspace members: U5 is claimed for workspaces, + teamspaces and invites (`bridge-workspace-*`), U4 for roles and page access + (`bridge-roles`, `bridge-page-access`, `bridge-share-rules`). +- **I2 and I5 run on I3's service**: separate subject modules, one shared + switch. +- **W5 and U1 both cite `bridge-account-profile.mjs`**: the subject asks for + profiles in both. Kept as LESdylan claimed them. +- **W6 Public API** would sit on the same API as the factory's endpoint; it is + a stretch goal and not counted, so nothing counts twice. + +No module was dropped for double-counting. + +### Stretch: not counted + +| Code | Module | Type | Points | What exists | What is missing | +| --- | --- | --- | --: | --- | --- | +| W6 | Public API | Major | 2 | Key-auth and rate limiting on `/rest/v1` (`GB infra/docker/services/kong/conf/kong.yml:221-245`); `GB infra/config/openapi/grobase-public.json`; `GB scripts/verify/m102-gateway-query-path.sh` → GB-CI "Integration Tests" (`ci.yml:934`) | PUT and DELETE on personal tokens; served docs | +| D4 | Health/status page and backups | Minor | 1 | `GB infra/docker/services/status-page/{server,availability}.py`; `GB scripts/verify/m241-status-backup-recovery.sh` → GB `operational-reliability.yml:27` | The `ops` plane is not in the default edition; m192 (GB-CI `ci.yml:582`) was red on grobase `develop` in [run 41870904860](https://github.com/Univers42/grobase/actions/runs/41870904860) | + +### What we did not claim + +A module with one failing sub-task is not claimed, and the game and blockchain +modules are out because the project has no game. Two-factor authentication and +GDPR are not claimed: both have runtime bugs (see +[Known limitations](#️-known-limitations)). + +### The cheapest next points + +1. **Public API**: add PUT and DELETE to personal tokens, serve the docs (+2) +1. **Export and import**: CSV import with stricter validation (+1) +1. **PWA**: PNG icons, fix the offline message (+1) +1. **2FA**: the sign-in code prompt, which also fixes the lockout (+1) +1. **GDPR**: run the deletion, send the emails (+1) + +### Module of choice (Major): contract-driven app factory + +- **Why this module.** It is the central idea of grobase: a backend with no application-specific code, where an application is a declarative contract. -- **Technical challenge.** From one JSON file, a generic provisioner must create an isolated - database, apply its schema, create roles and policies, mint scoped API keys and emit the - frontend's `PUBLIC_*` configuration — idempotently, on every boot. The self-serve endpoint - `POST /v1/tenants/me/apps` does it on demand, with a fresh `CREATE DATABASE` per application. -- **Value.** A new application costs a contract instead of a new server. -- **Why it deserves a Major.** It spans the Go control plane, the Rust data plane and the - database layer, and it comes with isolation proofs: gate `m177` proves self-serve creation, and - gate `m176` proves one application cannot read another's data (a foreign mount resolves to zero - rows — a 404, never a cross read). - -### Justification — Module of choice (Minor): SSRF guard on the HTTP engine - -- **Why this module.** The data plane can mount a remote HTTP API as if it were a database — - which also means "let a user make our server fetch any URL", the classic SSRF hole. -- **Technical challenge.** The guard blocks loopback, private (RFC 1918), link-local — including - the cloud metadata endpoint `169.254.169.254` — CGNAT, IPv6 ULA and link-local, IPv4-mapped - addresses and internal hostnames, then **pins** the connection to the validated public IP so a - later DNS rebind cannot redirect it inward. -- **Value and weight.** Small, self-contained and demonstrable in seconds (a - mount pointing at `169.254.169.254` is refused), and it shows security - judgement on a feature we chose to build. +- **Technical challenge.** From one contract, a generic provisioner creates an + isolated database, applies its schema, creates roles and policies, mints + scoped API keys and emits the frontend's configuration. The self-serve + endpoint `POST /v1/tenants/me/apps` does it on demand, with a fresh + `CREATE DATABASE` per application. +- **Value.** A new application costs a contract instead of a new server. The + public site and vault42 are both provisioned from contracts (m176 checks + both are present). +- **Why it deserves a Major.** It spans the Go control plane, the Rust data + plane and the database, and it comes with isolation gates: m177 creates two + apps and checks one app's key cannot read the other, and m176 checks a + foreign key resolves zero rows on another app's mount (a 404, never a cross + read). Both run in their static mode in CI; their live half is on grobase + `ci/live-proof-gates-m175-m177` ([#177](https://github.com/Univers42/groot/issues/177)). + Demo steps: [`wiki/DEFENSE.md`](wiki/DEFENSE.md). +- **Who.** dlesieur (LESdylan), the only author of the provisioner and the + self-serve endpoint at the pinned commit. + +### Module of choice (Minor): SSRF guard on the HTTP engine + +- **Why this module.** The data plane can mount a remote HTTP API as if it + were a database, which also means "let a user make our server fetch any + URL", the classic SSRF hole. +- **Technical challenge.** The guard refuses loopback, private (RFC 1918), + link-local (the cloud metadata address `169.254.169.254` included), CGNAT, + IPv6 ULA and IPv4-mapped addresses, then pins the client to the checked + public IP so a later DNS rebind cannot redirect it inward. +- **Value and weight.** Small, self-contained and demonstrable in seconds, and + it shows security judgement on a feature we chose to build. It is kept as its + own Minor, separate from the factory: it guards the HTTP mount engine, not + app creation. Demo steps: [`wiki/DEFENSE.md`](wiki/DEFENSE.md). +- **Who.** dlesieur (LESdylan), the only author of the guard and its tests at + the pinned commit. --- -## Individual Contributions +## 🙌 Individual contributions + +> [!NOTE] +> A commit count is not effort: pairing, review and design do not show in it. +> Commits concentrate on dlesieur: 86%, 5,895 of the 6,876 non-merge member +> commits at the commits groot `main` (`948487ee`) pins, counted on 2026-11-05 +> with the [contributions method](wiki/project/contributions-method.md). Be +> ready to explain the split. + +### Per member + +- **`dlesieur`**, Product Owner and developer: grobase (control plane, data + plane, realtime, gateway, contracts), osionos (editor, chat, collaboration, + the bridge, the AI service), mail, calendar, the root's infrastructure and CI. +- **`vjan-nie`**, Project Manager and developer: the editable block logic of + osionos (`src/features/block-editor`: the block editor surface, its state + hook, the context menu, drag handles, code and Mermaid blocks, block + history). 42 of the 254 non-merge commits on that folder at the osionos + commit `main` pins (`735c40fd`), second to dlesieur's 164. Also QA tooling, + end-to-end tests, the wiki and README, and submodule pins. +- **`danfern3`**, tech lead and developer: grobase Docker images and Kubernetes + deployment, monkey-bot, osionos editor features. +- **`serjimen`**, developer: the public site (opposite-osiris), osionos + accessibility and editor features. +- **`shashemi`**, developer: security fixes and SAST, the site's legal pages, + the TLS edge, end-to-end fixes. ### Who committed where @@ -804,119 +640,93 @@ pinned commit (open branches and pull requests) does not show above. A commit count is not effort: one large commit and one typo fix weigh the same, and pairing and review do not show in it. -Merged pull requests per login in the project's repositories (groot, osionos, grobase, -prismatica = opposite-osiris, osionos-mail, osionos-calendar, hydrawn, QA, monkey-bot), from -`gh search prs --owner Univers42 --author --merged` on 2026-11-04: - -| GitHub | Merged PRs | Where | -|---|--:|---| -| @LESdylan | 260 | osionos 138, grobase 61, groot 56, prismatica 3, osionos-mail 1, osionos-calendar 1 | -| @vjan-nie | 72 | groot 45, osionos 17, prismatica 6, grobase 4 | -| @SMOSTAFAH1 | 57 | prismatica 20, groot 19, osionos 12, grobase 6 | -| @DjSurgeon | 8 | prismatica 7, osionos 1 | -| @danielfdez17 | 4 | prismatica 4 | +### Challenges the team faced -### Per member - -- **`dlesieur`**, Product Owner: grobase (control plane, data plane, realtime, - gateway, contracts), osionos (editor, chat, collaboration, the bridge, the AI - service), mail, calendar, the root's infrastructure and CI. -- **`vjan-nie`**, Project Manager: QA tooling, end-to-end tests, the wiki and - README, submodule pins, Docker infrastructure. -- **`serjimen`**, frontend tech lead: the public site (opposite-osiris), - osionos accessibility and editor features. -- **`danfern3`**, backend tech lead: grobase Docker images and Kubernetes - deployment, monkey-bot, osionos editor features. -- **`shashemi`**, QA / quality tech lead: security fixes and SAST, the site's - legal pages, the TLS edge, end-to-end fixes. - -### Challenges and lessons - -The project met many productive problems and blockers: lack of autonomy, -communication problems, frustration. We addressed them with dedicated meetings -and by applying TDD and DevOps practices, toward a frictionless workflow. We -acknowledge our technical debt openly -([`wiki/security/03-known-weaknesses.md`](wiki/security/03-known-weaknesses.md) -is one record of it). Several technologies and architectures were rebuilt from -scratch when the first choice proved wrong late: the data plane moved from -TypeScript to Rust behind a per-request switch (`RUST_DATA_PLANE_FORWARD`, on -by default in grobase `orchestrators/compose/base/app-services.yml:96`); -grobase's Node session, email and GDPR services were retired and their work -moved to the Go orchestrator (`app-services.yml:133,190,448`); and osionos's -legacy second brain was replaced by graph_render (groot `fresh-machine.yml:363` -checks it on a live stack; the legacy one survives only behind a gate, osionos -`ci.yml:383-388`). The project's maturity comes from that intense team -experience, with its ups and downs. +- Moving the data plane from TypeScript to Rust with no downtime, using a + per-request switch and a shadow mode +- Keeping eight database adapters at parity with a conformance battery against + real engines +- Auditing our own authorisation path and shipping a reversible fix --- -## Resources - -### References - -- Docker Compose — -- PostgreSQL row-level security — -- Kong Gateway — -- Supabase Auth (GoTrue) — -- The Go Programming Language documentation — -- The Rust Programming Language — -- NestJS — -- Astro — -- React — · Vite — · Tailwind CSS — -- LiveKit — -- NIST SP 800-162, *Guide to Attribute Based Access Control* — -- OWASP SSRF Prevention Cheat Sheet — -- OWASP Top 10 — -- GDPR, full text — +## 📚 Resources ### How AI was used -This project was built with **heavy AI assistance**, and we state it plainly. - -- **Tools and tasks.** Every member used a variety of AI tools (chat - assistants, coding agents, review bots) across code, tests, reviews and - documentation. -- **What the repositories record.** Claude Code with the team's kit in groot's +- **Code**: AI assistants wrote and refactored code across the stack +- **Tests**: verification gates and test scripts, run against the live stack +- **Review**: AI-assisted pull request review and a security review +- **Documentation**: drafted from the code, then checked against the repository +- **Agents in the history**: `opencode` (6 commits) and Space Bunny on hydrawn + (111 commits) +- **Control**: a human merges every change, and each member must be able to + explain the code in their area +- **Tools the repositories record**: Claude Code with the team's kit in groot's [`.claude`](.claude) submodule; Gemini (`.gemini/`) and the opencode agent in osionos; an OpenCode agent team on hydrawn (`BUNNY.md`); GitHub Copilot and - CodeRabbit on pull requests. -- **Commit history.** On 2026-11-04 the history of groot, osionos and grobase + CodeRabbit on pull requests +- **Commit trailers**: on 2026-11-04 the history of groot, osionos and grobase was rewritten to remove `Co-authored-by` trailers. Since then no commit carries a co-author line, so the history does not show which commits were written with an AI tool, and must not be read as AI-free - ([`wiki/project/contributions-method.md`](wiki/project/contributions-method.md)). -- **How we kept control.** A human merges every pull request; nothing reaches - `develop` or `main` by an agent alone. Approval by a second member is required - by the rulesets but thin in practice (see [Project - Management](#project-management)). A change is accepted only when its - verification gate passes. Every member must be able to explain the AI-assisted - code in their area. + ([`wiki/project/contributions-method.md`](wiki/project/contributions-method.md)) + +### References + +- [Docker Compose](https://docs.docker.com/compose/), [PostgreSQL row-level security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html), [Kong Gateway](https://docs.konghq.com/gateway/latest/) +- [React](https://react.dev/), [Vite](https://vite.dev/), [Tailwind CSS](https://tailwindcss.com/docs), [Astro](https://docs.astro.build/), [NestJS](https://docs.nestjs.com/) +- [The Go documentation](https://go.dev/doc/), [The Rust Programming Language](https://doc.rust-lang.org/book/), [LiveKit](https://docs.livekit.io/) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/), [NIST guide to ABAC](https://csrc.nist.gov/pubs/sp/800/162/final), [GDPR full text](https://gdpr-info.eu/) +- L. Lamport, "Time, Clocks, and the Ordering of Events in a Distributed System", 1978 +- [Supabase Auth (GoTrue)](https://github.com/supabase/auth) --- -## Known limitations - -- **No game.** All gaming-dependent modules are out of scope. -- **Expressiveness is narrow by design.** grobase has no per-application server - code; business rules are expressed as policies, schema constraints, database - triggers, or functions in the flag-gated functions plane. -- **Documented security weaknesses.** Known and declared in - [`wiki/security/03-known-weaknesses.md`](wiki/security/03-known-weaknesses.md). -- **Several features are behind flags that are off by default** — see the Grade - column in [Modules](#modules). -- **No interface translation in osionos.** `i18next` is declared but no - translation resource is loaded (see [Modules](#modules)). The prismatica site - itself ships English, Spanish and Arabic. -- **Whiteboard editing is loopback-only and has no per-user authorization yet** (see - [Demo](#demo)). +## ⚠️ Known limitations + +> [!CAUTION] +> **Do not enrol two-step verification on a real account.** The app has no +> sign-in prompt for the code, so the account locks itself out. + +### What does not work yet + +- **No game**, so every game module is out +- **Only the landing page is translated**: the app is English only +- **Collaboration is per block**: last write wins, not CRDT +- **The whiteboard** has no per-user authorization; its editing is + loopback-only for that reason (hydrawn is bound to `127.0.0.1:3007`) +- **Expressiveness is narrow by design**: grobase has no per-application + server code; business rules are policies, schema constraints, database + triggers, or functions in the flag-gated functions plane +- **Documented security weaknesses**: declared in + [`wiki/security/03-known-weaknesses.md`](wiki/security/03-known-weaknesses.md) + +### Bugs to fix + +- Blocked users can keep writing in an open direct message +- Port 8880 serves the API over plain HTTP where the stack runs +- The Privacy Policy promises a deletion the code never performs +- Demo passwords in `src/data/seedUsers.json` ship in the client bundle +- Page saves fail past about 330 blocks, because of a WAF limit +- 148 root commits have a one-word subject + +--- ## License -- There is no `LICENSE` file at the repository root. -- grobase is open-core under the GNU AGPL v3 (`apps/grobase/LICENSE`); - `apps/grobase/LICENSING.md` maps which parts carry which licence. -- osionos, opposite-osiris (prismatica), hydrawn, mail and calendar carry no - licence file. +Osionos Project License: copyright (c) 2026 Osionos contributors, all rights +reserved. It applies to this repository's own files; each git submodule (listed +in `.gitmodules`) keeps its own licence. Contributors and persons the copyright +holders explicitly authorize may access, inspect, run and contribute to the +software to evaluate, develop, maintain and improve the project; no other use is +granted. The full text is in [`LICENSE`](LICENSE). + +grobase (`apps/grobase`, a submodule) carries its own licence: open-core under +the GNU AGPL v3 (`apps/grobase/LICENSE`), with `apps/grobase/LICENSING.md` +mapping which parts carry which licence. monkey-bot (`vendor/monkey-bot`) is +under the MIT License. osionos, opposite-osiris, hydrawn, mail and calendar +have no licence file of their own. --- @@ -929,5 +739,5 @@ This project was built with **heavy AI assistance**, and we state it plainly. - [`wiki/security/`](wiki/security/) — security model, proofs, known weaknesses, network - [`DATA-MIGRATION.md`](DATA-MIGRATION.md) — fresh-machine and migration runbook - `apps/grobase/CLAUDE.md` — grobase's own authoritative technical document (inside the submodule) -- [Prismatica docs](https://prismatica-beta.vercel.app/docs/) — the landing's - documentation site +- [prismatica docs](https://prismatica-beta.vercel.app/docs/) — the public + landing's documentation site diff --git a/infrastructure/docker/osionos/ai.Dockerfile b/infrastructure/docker/osionos/ai.Dockerfile index a6670187b..c6abe82a4 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile +++ b/infrastructure/docker/osionos/ai.Dockerfile @@ -17,19 +17,21 @@ # IMPORT CLOSURE of services/osionos-ai/src into scripts/ — bridge-session-token # imports bridge-session-cutoff (which imports bridge-baas-body), and since the # account platform's P0-4 also bridge-session-revocations and bridge-roles; both -# revocations and cutoff import bridge-session-tracker: +# revocations and cutoff import bridge-session-tracker, which imports +# bridge-access-generation. Share rules (retrieval/shareRules.ts) call +# bridge-page-access, which calls bridge-share-rules: # services/osionos-ai/src/auth.ts -> scripts/bridge-session-token.mjs # -> scripts/bridge-session-cutoff.mjs # -> scripts/bridge-baas-body.mjs # -> scripts/bridge-session-revocations.mjs -# -> scripts/bridge-session-tracker.mjs # -> scripts/bridge-roles.mjs +# -> scripts/bridge-session-tracker.mjs +# -> scripts/bridge-access-generation.mjs # services/osionos-ai/src/runs/prompt.ts -> scripts/bridge-page-read.mjs # services/osionos-ai/src/retrieval/acl.ts -> scripts/bridge-live-workspaces.mjs -# services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs # services/osionos-ai/src/retrieval/shareRules.ts -> scripts/bridge-page-access.mjs # -> scripts/bridge-share-rules.mjs -# -> scripts/bridge-access-generation.mjs +# services/osionos-ai/src/retrieval/related.ts -> scripts/bridge-graph-edges.mjs -> scripts/bridge-page-read.mjs # The thirteenth is the L0 gate of the image prompt (devil C72): the module reads # its lists next to itself, so they travel with it: # scripts/bridge-moderation.mjs -> scripts/moderation-lists/{en,fr,es}.txt @@ -63,10 +65,10 @@ COPY --from=deps /app/services/osionos-ai/node_modules ./services/osionos-ai/nod # The service itself: its manifest, its sources. No tests, no dev deps. COPY services/osionos-ai/package.json services/osionos-ai/tsconfig.json ./services/osionos-ai/ COPY services/osionos-ai/src ./services/osionos-ai/src -# The thirteen bridge modules (see the header): the twelve of the import closure and -# the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks +# The thirteen bridge modules (see the header): the twelve of the import closure +# and the gate of the image prompt. scripts/tests/bridge-image-closure.bats checks # this list against the closure, so a new import fails CI, not the container. -COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-page-access.mjs scripts/bridge-share-rules.mjs scripts/bridge-access-generation.mjs scripts/bridge-moderation.mjs ./scripts/ +COPY scripts/bridge-session-token.mjs scripts/bridge-session-cutoff.mjs scripts/bridge-baas-body.mjs scripts/bridge-session-revocations.mjs scripts/bridge-session-tracker.mjs scripts/bridge-access-generation.mjs scripts/bridge-roles.mjs scripts/bridge-page-read.mjs scripts/bridge-page-access.mjs scripts/bridge-share-rules.mjs scripts/bridge-live-workspaces.mjs scripts/bridge-graph-edges.mjs scripts/bridge-moderation.mjs ./scripts/ # The lists of the L0 gate: the module reads them next to itself, at import. COPY scripts/moderation-lists ./scripts/moderation-lists diff --git a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore index 79d703add..fd2f234f5 100644 --- a/infrastructure/docker/osionos/ai.Dockerfile.dockerignore +++ b/infrastructure/docker/osionos/ai.Dockerfile.dockerignore @@ -16,8 +16,8 @@ vendor src tests # The scripts/ directory holds a hundred-odd modules; the service's closure is -# twelve of them, and the gate of the image prompt a thirteenth. They are re-included -# one by one, so the closure is visible in both files +# twelve of them, and the gate of the image prompt a thirteenth. They are +# re-included one by one, so the closure is visible in both files # (scripts/tests/bridge-image-closure.bats checks it). scripts/* !scripts/bridge-session-token.mjs @@ -25,13 +25,13 @@ scripts/* !scripts/bridge-baas-body.mjs !scripts/bridge-session-revocations.mjs !scripts/bridge-session-tracker.mjs +!scripts/bridge-access-generation.mjs !scripts/bridge-roles.mjs !scripts/bridge-page-read.mjs -!scripts/bridge-live-workspaces.mjs -!scripts/bridge-graph-edges.mjs !scripts/bridge-page-access.mjs !scripts/bridge-share-rules.mjs -!scripts/bridge-access-generation.mjs +!scripts/bridge-live-workspaces.mjs +!scripts/bridge-graph-edges.mjs !scripts/bridge-moderation.mjs # The lists of the L0 gate: the module reads them next to itself, at import. !scripts/moderation-lists/ diff --git a/scripts/gen-local-env.sh b/scripts/gen-local-env.sh index d2b7109dd..ca28996a6 100755 --- a/scripts/gen-local-env.sh +++ b/scripts/gen-local-env.sh @@ -62,9 +62,9 @@ note() { printf '[gen-local-env] %s\n' "$1" >&2; } # The host port the osionos app is published on. The environment wins (make exports it); # .env.ports is the record scripts/host-ports.sh wrote. 3001 is the fallback. -if [ -z "${OSIONOS_APP_HOST_PORT:-}" ]; then +if [[ -z "${OSIONOS_APP_HOST_PORT:-}" ]]; then PORTS_FILE="${HOST_PORTS_FILE:-$REPO/.env.ports}" - if [ -f "$PORTS_FILE" ]; then + if [[ -f "$PORTS_FILE" ]]; then OSIONOS_APP_HOST_PORT="$(get_env "$PORTS_FILE" OSIONOS_APP_HOST_PORT || true)" fi fi @@ -103,8 +103,8 @@ gsrc() { while :; do _one="${_spec%%|*}" _v="$(gval "$_one")" - [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } - [ "$_spec" = "$_one" ] && return 0 + [[ -n "$_v" ]] && { printf '%s' "$_v"; return 0; } + [[ "$_spec" = "$_one" ]] && return 0 _spec="${_spec#*|}" done } @@ -118,7 +118,7 @@ gen_password() { gen | head -c 24; } # minted_missing: names of MINTED keys absent or empty in $OUT, one per line. minted_missing() { for key in $MINTED; do - [ -n "$(val "$OUT" "$key")" ] || printf '%s\n' "$key" + [[ -n "$(val "$OUT" "$key")" ]] || printf '%s\n' "$key" done } @@ -150,12 +150,12 @@ mint_missing() { media_fill() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" - [ -n "$want" ] || continue + [[ -n "$want" ]] || continue have="$(get_env "$OUT" "$key" || true)" - if [ -z "$have" ]; then + if [[ -z "$have" ]]; then put_env "$OUT" "$key" "$want" note "copied $key from $MSRC" - elif [ "$have" != "$want" ]; then + elif [[ "$have" != "$want" ]]; then note "kept $key (differs from $MSRC)" fi done @@ -166,7 +166,7 @@ media_differing() { for key in $MEDIA; do want="$(get_env "$MEDIA_ENV" "$key" || true)" have="$(get_env "$OUT" "$key" || true)" - if [ -n "$want" ] && [ -n "$have" ] && [ "$want" != "$have" ]; then printf '%s\n' "$key"; fi + if [[ -n "$want" ]] && [[ -n "$have" ]] && [[ "$want" != "$have" ]]; then printf '%s\n' "$key"; fi done } @@ -179,16 +179,16 @@ drift_report() { dst="${pair%%=*}" src="${pair#*=}" want="$(gsrc "$src")" - if [ -z "$want" ]; then printf 'unknown %s\n' "$dst"; bad=1 + if [[ -z "$want" ]]; then printf 'unknown %s\n' "$dst"; bad=1 elif ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done want="$(port_url)" for dst in $PORT_KEYS; do if ! grep -q "^$dst=" "$OUT"; then printf 'missing %s\n' "$dst"; bad=1 - elif [ "$(val "$OUT" "$dst")" = "$want" ]; then printf 'sync %s\n' "$dst" + elif [[ "$(val "$OUT" "$dst")" = "$want" ]]; then printf 'sync %s\n' "$dst" else printf 'drift %s\n' "$dst"; bad=1 fi done @@ -196,19 +196,19 @@ drift_report() { } require_inputs() { - [ -f "$GRO_ENV" ] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } + [[ -f "$GRO_ENV" ]] || { note "$SRC not found — bring the backend up first (make backend-up)."; exit 1; } } check_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — run without --check to generate it."; exit 1; } rc=0 report="$(drift_report)" || rc=$? for key in $(minted_missing); do report="$report missing $key"; rc=1; done printf '%s\n' "$report" | sed 's/^/[gen-local-env] /' >&2 for key in $(media_differing); do note " media $key differs from $MSRC (informational, exit status unchanged)"; done - if [ "$rc" -eq 0 ]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else + if [[ "$rc" -eq 0 ]]; then note "in sync with $SRC (8 derived keys + 4 port-derived keys)."; else note "DRIFT — $(basename "$OUT") disagrees with $SRC. Fix: bash scripts/gen-local-env.sh --sync" fi exit "$rc" @@ -218,12 +218,12 @@ missing $key"; rc=1; done # atomic, mode kept, every other line untouched). An EMPTY source is never written. sync_mode() { require_inputs - [ -f "$OUT" ] || { note "$(basename "$OUT") absent — generating it."; generate; return; } + [[ -f "$OUT" ]] || { note "$(basename "$OUT") absent — generating it."; generate; return; } mint_missing media_fill rc=0 report="$(drift_report)" || rc=$? - [ "$rc" -ne 0 ] || { note "in sync with $SRC — nothing to write."; exit 0; } + [[ "$rc" -ne 0 ]] || { note "in sync with $SRC — nothing to write."; exit 0; } changed="" unknown="" for pair in $DERIVED; do @@ -240,12 +240,12 @@ sync_mode() { changed="$changed $dst" fi done - [ -z "$changed" ] || note "synced from $SRC:$changed" - [ -z "$unknown" ] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } + [[ -z "$changed" ]] || note "synced from $SRC:$changed" + [[ -z "$unknown" ]] || { note "left as is (EMPTY source in $SRC — is the backend healthy?):$unknown"; exit 1; } } generate() { -if [ -f "$OUT" ] && [ "$FORCE" != 1 ]; then +if [[ -f "$OUT" ]] && [[ "$FORCE" != 1 ]]; then note "$(basename "$OUT") already present — leaving it (FORCE=1 to overwrite; --sync to refresh the derived keys)." exit 0 fi @@ -261,7 +261,7 @@ PUBLIC="$(gval ANON_KEY)" SERVICE="$(gval SERVICE_ROLE_KEY)" JWT="$(gval JWT_SECRET)" ADAPTER="$(gval ADAPTER_REGISTRY_SERVICE_TOKEN)" -if [ -z "$PUBLIC" ] || [ -z "$SERVICE" ] || [ -z "$JWT" ]; then +if [[ -z "$PUBLIC" ]] || [[ -z "$SERVICE" ]] || [[ -z "$JWT" ]]; then note "grobase .env is missing ANON_KEY/SERVICE_ROLE_KEY/JWT_SECRET — is the backend healthy?" exit 1 fi @@ -363,5 +363,5 @@ case "$MODE" in --check) check_mode ;; --sync) sync_mode ;; generate) generate ;; -*) printf 'usage: gen-local-env.sh [--check|--sync] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; +*) printf 'usage: gen-local-env.sh [[--check|--sync]] (no flag: generate ./.env.local when absent)\n' >&2; exit 2 ;; esac diff --git a/scripts/host-ports.sh b/scripts/host-ports.sh index ed21c0e36..5a174c006 100755 --- a/scripts/host-ports.sh +++ b/scripts/host-ports.sh @@ -54,7 +54,7 @@ declare -A kept_clear=() # port -> 1, named by HOST_PORTS_KEEP_CLEAR compose_files() { local f for f in "$root"/docker-compose*.yml; do - [ -f "$f" ] || continue + [[ -f "$f" ]] || continue printf '%s\n' "${f#"$root"/}" done } @@ -65,10 +65,10 @@ scrape() { local file line name def for file in $(compose_files); do while IFS= read -r line; do - [ -n "$line" ] || continue + [[ -n "$line" ]] || continue name="${line%%=*}" def="${line#*=}" - if [ -n "${defaults[$name]:-}" ] && [ "${defaults[$name]}" != "$def" ]; then + if [[ -n "${defaults[$name]:-}" ]] && [[ "${defaults[$name]}" != "$def" ]]; then printf 'host-ports: %s has two defaults — %s in %s, %s in %s. Fix the compose files.\n' \ "$name" "${defaults[$name]}" "${default_files[$name]}" "$def" "$file" >&2 exit 2 @@ -126,7 +126,7 @@ is_wildcard() { addr_conflicts() { is_wildcard "$1" && return 0 is_wildcard "$bind_addr" && return 0 - [ "$1" = "$bind_addr" ] + [[ "$1" = "$bind_addr" ]] } # holder_of PORT — the name of the process holding PORT at the bind address, or @@ -134,9 +134,9 @@ addr_conflicts() { holder_of() { local port="$1" addr listen_port proc while IFS=$'\t' read -r addr listen_port proc; do - [ "$listen_port" = "$port" ] || continue + [[ "$listen_port" = "$port" ]] || continue addr_conflicts "$addr" || continue - [ -n "$proc" ] || proc="another process" + [[ -n "$proc" ]] || proc="another process" printf '%s\n' "$proc" return 0 done < <(printf '%s\n' "$LISTENER_TABLE") @@ -147,8 +147,8 @@ holder_of() { is_own_port() { local port="$1" p while read -r p; do - [ -n "$p" ] || continue - [ "$p" = "$port" ] && return 0 + [[ -n "$p" ]] || continue + [[ "$p" = "$port" ]] && return 0 done < <(printf '%s\n' "$OWN_PORTS") return 1 } @@ -175,7 +175,7 @@ cmd_list() { scrape local name while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue printf '%s=%s\n' "$name" "${defaults[$name]}" done < <(sorted_names) } @@ -183,7 +183,7 @@ cmd_list() { # read_previous — the values already recorded in .env.ports. read_previous() { local key value - [ -f "$ports_file" ] || return 0 + [[ -f "$ports_file" ]] || return 0 while IFS='=' read -r key value; do case "$key" in '' | '#'*) continue ;; @@ -225,13 +225,13 @@ render_ports_file() { # The host ports this clone publishes; re-run the resolver after any change. " while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue content+="$name=${chosen[$name]}"$'\n' done < <(sorted_names) [ -z "${UDP_RANGE:-}" ] || content+="LIVEKIT_UDP_PORTS=$UDP_RANGE"$'\n' tmp="$(mktemp "$ports_file.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$ports_file" ] && cmp -s "$tmp" "$ports_file"; then + if [[ -f "$ports_file" ]] && cmp -s "$tmp" "$ports_file"; then rm -f "$tmp" return 0 fi @@ -244,7 +244,7 @@ render_ports_file() { # it (one code path). It never writes any tracked file. render_origin_override() { local port="${chosen[OSIONOS_APP_HOST_PORT]:-}" - [ -n "$port" ] || return 0 + [[ -n "$port" ]] || return 0 local out="$root/infrastructure/tls/host-ports.d/osionos-origin.conf" local tmp content content="# Generated by scripts/host-ports.sh resolve — do not edit by hand. @@ -252,7 +252,7 @@ set \$osionos_origin https://localhost:$port; " tmp="$(mktemp "$out.XXXXXX")" printf '%s' "$content" >"$tmp" - if [ -f "$out" ] && cmp -s "$tmp" "$out"; then + if [[ -f "$out" ]] && cmp -s "$tmp" "$out"; then rm -f "$tmp" return 0 fi @@ -272,13 +272,13 @@ cmd_resolve() { own_ports_status=$? set -e OWN_PORTS="$own_ports_out" - if [ $own_ports_status -ne 0 ]; then + if [[ $own_ports_status -ne 0 ]]; then printf 'host-ports: cannot list this project'\''s containers (%s ps failed); a running groot'\''s own ports look busy\n' "$docker_cmd" >&2 fi local names=() name default env_value file_value port holder holder_port while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue names+=("$name") done < <(sorted_names) @@ -291,20 +291,20 @@ cmd_resolve() { for name in "${names[@]}"; do default="${defaults[$name]}" env_value="${!name-}" - if [ -n "$env_value" ]; then + if [[ -n "$env_value" ]]; then if ! [[ "$env_value" =~ ^[0-9]{1,5}$ ]] || (( 10#$env_value < 1 || 10#$env_value > 65535 )); then printf 'host-ports: %s must be an integer port from 1 through 65535\n' "$name" >&2 exit 2 fi env_value=$((10#$env_value)) fi - if [ -n "$env_value" ] && is_pinned "$name"; then + if [[ -n "$env_value" ]] && is_pinned "$name"; then chosen[$name]="$env_value" pinned[$name]=1 reserved[$env_value]=1 continue fi - if [ -z "${reserved[$default]:-}" ] && port_free "$default"; then + if [[ -z "${reserved[$default]:-}" ]] && port_free "$default"; then chosen[$name]="$default" reserved[$default]=1 fi @@ -312,10 +312,10 @@ cmd_resolve() { # 2. the value already recorded, while it is free (a moved port stays put). for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue file_value="${previous[$name]:-}" - [ -n "$file_value" ] || continue - if [ -z "${reserved[$file_value]:-}" ] && port_free "$file_value"; then + [[ -n "$file_value" ]] || continue + if [[ -z "${reserved[$file_value]:-}" ]] && port_free "$file_value"; then chosen[$name]="$file_value" reserved[$file_value]=1 fi @@ -323,18 +323,18 @@ cmd_resolve() { # 3. the window above the default. for name in "${names[@]}"; do - [ -n "${chosen[$name]:-}" ] && continue + [[ -n "${chosen[$name]:-}" ]] && continue default="${defaults[$name]}" port=$((default + 1)) - while [ "$port" -le $((default + window)) ]; do - if [ -z "${reserved[$port]:-}" ] && port_free "$port"; then + while [[ "$port" -le $((default + window)) ]]; do + if [[ -z "${reserved[$port]:-}" ]] && port_free "$port"; then chosen[$name]="$port" reserved[$port]=1 break fi port=$((port + 1)) done - if [ -z "${chosen[$name]:-}" ]; then + if [[ -z "${chosen[$name]:-}" ]]; then holder="$(holder_of "$default" || true)" printf 'host-ports: %s default %s is held by %s, and no free port in %s..%s. Free one, or pin %s=.\n' \ "$name" "$default" "${holder:-another process}" "$((default + 1))" "$((default + window))" "$name" >&2 @@ -348,22 +348,22 @@ cmd_resolve() { port="${chosen[$name]}" holder="$(holder_of "$default" || true)" holder_port="$(holder_of "$port" || true)" - if [ "$port" = "$default" ] && [ -z "${pinned[$name]:-}" ]; then + if [[ "$port" = "$default" ]] && [[ -z "${pinned[$name]:-}" ]]; then continue fi - if [ -n "$holder" ]; then + if [[ -n "$holder" ]]; then printf 'host-ports: %s default %s is held by %s; using %s' "$name" "$default" "$holder" "$port" - if [ -n "$holder_port" ]; then + if [[ -n "$holder_port" ]]; then printf ' (also held by %s)' "$holder_port" fi elif [ -n "${kept_clear[$default]:-}" ] && [ -z "${pinned[$name]:-}" ]; then printf 'host-ports: %s default %s is kept clear; using %s' "$name" "$default" "$port" - elif [ -n "$holder_port" ]; then + elif [[ -n "$holder_port" ]]; then printf 'host-ports: %s default %s is free; pinned to %s (held by %s)' "$name" "$default" "$port" "$holder_port" else printf 'host-ports: %s default %s is free; pinned to %s' "$name" "$default" "$port" fi - if [ "$name" = "OSIONOS_APP_HOST_PORT" ]; then + if [[ "$name" = "OSIONOS_APP_HOST_PORT" ]]; then printf '; add https://localhost:%s to grobase'\''s allowed origins (born2root: make grobase_cors)' "$port" fi printf '\n' >&2 @@ -371,25 +371,25 @@ cmd_resolve() { UDP_RANGE="$(free_udp_range)" local before="" name - [ -f "$ports_file" ] && before="$(cat "$ports_file")" + [[ -f "$ports_file" ]] && before="$(cat "$ports_file")" render_ports_file render_origin_override # A moved port that a build arg bakes in needs the image rebuilt. local changed=() service while read -r name; do - [ -n "$name" ] || continue + [[ -n "$name" ]] || continue file_value="${previous[$name]:-}" - [ "$file_value" = "${chosen[$name]}" ] && continue + [[ "$file_value" = "${chosen[$name]}" ]] && continue changed+=("$name") done < <(sorted_names) - if [ "${#changed[@]}" -gt 0 ] && [ "$before" != "$(cat "$ports_file")" ]; then + if [[ "${#changed[@]}" -gt 0 ]] && [[ "$before" != "$(cat "$ports_file")" ]]; then # For each changed NAME, find the services that bake it; print one line per service # listing the specific NAMEs that service uses. declare -A service_changed_names=() for name in "${changed[@]}"; do while read -r service; do - [ -n "$service" ] || continue + [[ -n "$service" ]] || continue service_changed_names["$service"]+=" $name" done < <(build_arg_users "$name") done diff --git a/scripts/lib/grobase-link-target.sh b/scripts/lib/grobase-link-target.sh index d66d72e2c..5415fc29f 100644 --- a/scripts/lib/grobase-link-target.sh +++ b/scripts/lib/grobase-link-target.sh @@ -14,7 +14,7 @@ # costs 5 s, and the first match wins, not the nearest. Ponytail: a bare host is tried as # ssh, then http://host:8000, then https://host, so a Kong on another port needs the URL. -# One-off ssh with the user's config but none of its forwards; "$@" = [-p port] dest cmd. +# One-off ssh with the user's config but none of its forwards; "$@" = [[-p port]] dest cmd. ssh_once() { ssh -o ClearAllForwardings=yes -o BatchMode=yes -o ConnectTimeout=5 -o LogLevel=ERROR "$@"; } local_has_grobase() { docker ps --format '{{.Names}}' 2>/dev/null | grep -qx mini-baas-kong; } # ssh://[user@]host[:port] -> "dest" or "dest -p port" (also accepts alias, user@host, host). @@ -47,7 +47,7 @@ resolve_ipv4() { # HOST -> its first IPv4 address, or HOST itself when getent kn # EXPLICIT when set, else TARGET's own URL, else the loopback WAF; exit 1 when it has none. api_url() { local url=${2:-} - if [ -z "$url" ]; then + if [[ -z "$url" ]]; then case "$1" in http://* | https://*) url=$1 ;; local) url=https://127.0.0.1:8443 ;; @@ -101,9 +101,9 @@ ssh_candidates() { awk 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i !~ /[*?!]/) print $i }' ~/.ssh/config 2>/dev/null local marker port for marker in "$REPO"/vendor/born2root/disk_images/.vm_path.* ${B2B_DIR:+"$B2B_DIR"/disk_images/.vm_path.*}; do - [ -f "$marker" ] || continue + [[ -f "$marker" ]] || continue port=$(awk -F= '$1 == "ssh" { print $2 }' "$(cat "$marker")/${marker##*.vm_path.}/ports.env" 2>/dev/null) - [ -n "$port" ] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" + [[ -n "$port" ]] && printf 'ssh://%s@127.0.0.1:%s\n' "$USER" "$port" done } @@ -125,7 +125,7 @@ resolve_auto() { return fi while read -r c; do - [ -n "$c" ] || continue + [[ -n "$c" ]] || continue note "probing $c" if remote_has_grobase "$c"; then printf 'ssh %s\n' "$c" @@ -148,7 +148,7 @@ resolve_host() { # a bare host or alias: ssh first, then Kong's plain port, then # relay_cafile -> the CA file the relay verifies Kong with, as the relay sees it: the copy up # installs in LINK_DIR (/link in the relay) when GROBASE_CA_FILE is set, else the system store. relay_cafile() { - if [ -n "$CA_FILE" ]; then + if [[ -n "$CA_FILE" ]]; then printf '/link/ca.pem\n' else printf '/etc/ssl/certs/ca-certificates.crt\n' @@ -163,7 +163,7 @@ relay_cafile() { capability_status() { local host port body="" line read -r host port < <(url_hostport "$1") - [ "$2" != POST ] || body='{}' + [[ "$2" != POST ]] || body='{}' line=$(printf '%s %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %s\r\nConnection: close\r\n\r\n%s' \ "$2" "$3" "$host" "${#body}" "$body" | docker run --rm -i -v "$LINK_DIR:/link:ro" "$SOCAT_IMG" -T10 -t10 - "$(kong_addr "$1" "$(relay_cafile)"),shut-none,connect-timeout=5" 2>/dev/null | @@ -205,11 +205,11 @@ capability_answer() { # write OSIONOS_TENANT_MOUNTS_URL; they only clear one a direct link left. publish_env() { local url="" key="" query="" registry="" mounts="" keepalive="" mounts_status - if [ "$1" = direct ]; then + if [[ "$1" = direct ]]; then url=http://mini-baas-kong:8000/realtime keepalive=1 key=$(get_env "$2" SERVICE_ROLE_KEY) || key="" - if [ "$(capability_answer "$3" POST /realtime/v1/publish)" = 403 ]; then + if [[ "$(capability_answer "$3" POST /realtime/v1/publish)" = 403 ]]; then url=ws://mini-baas-kong:8000/realtime/v1/ws key="" note "direct: $3 refuses /realtime/v1/publish without credentials; the bridge publishes over /realtime/v1/ws" @@ -224,7 +224,7 @@ publish_env() { *) note "direct: $3 gave no definite answer on /v1/tenants/me/mounts ($mounts_status): mounted databases may be refused. make link asks again" ;; esac fi - if [ "$1" != local ]; then + if [[ "$1" != local ]]; then query=http://mini-baas-kong:8000/query/v1 registry=http://mini-baas-kong:8000/admin/v1 fi @@ -232,12 +232,12 @@ publish_env() { put_env "$2" REALTIME_PUBLISH_APIKEY "$key" put_env "$2" OSIONOS_QUERY_ROUTER_URL "$query" put_env "$2" OSIONOS_ADAPTER_REGISTRY_URL "$registry" - if [ "$1" = direct ] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_TENANT_MOUNTS_URL >/dev/null; then put_env "$2" OSIONOS_TENANT_MOUNTS_URL "$mounts" fi # Kept-alive pool: a workspace open after 40 s idle 488 -> 313 ms median (2026-10-18, 8 # workspaces, interleaved); measured on the direct link only, so ssh/local never turn it on. - if [ "$1" = direct ] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then + if [[ "$1" = direct ]] || get_env "$2" OSIONOS_BAAS_KEEPALIVE >/dev/null; then put_env "$2" OSIONOS_BAAS_KEEPALIVE "$keepalive" fi } @@ -299,7 +299,7 @@ rt_token_ensure() { local secret_src="$2" local secret cur out bad="" kind exp token secret=$(get_env "$secret_src" JWT_SECRET) || return 0 - [ -n "$secret" ] || return 0 + [[ -n "$secret" ]] || return 0 cur=$(get_env "$env_local" VITE_BAAS_REALTIME_TOKEN || true) out=$(RT_JWT_SECRET="$secret" RT_TOKEN="$cur" \ docker run --rm --network none -e RT_JWT_SECRET -e RT_TOKEN node:22-alpine node -e "$RT_TOKEN_JS") || { @@ -310,19 +310,19 @@ rt_token_ensure() { set -- $out kind=${1:-} case $kind in - kept) [ "$#" -eq 2 ] || bad=1 ;; - minted) [ "$#" -eq 3 ] || bad=1 ;; + kept) [[ "$#" -eq 2 ]] || bad=1 ;; + minted) [[ "$#" -eq 3 ]] || bad=1 ;; *) bad=1 ;; esac exp=${2:-} case $exp in '' | *[!0-9]*) bad=1 ;; esac - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then note "warning: the realtime token could not be refreshed; the live views fall back to 15 s polling" return 0 fi - if [ "$kind" = kept ]; then + if [[ "$kind" = kept ]]; then note "realtime token valid until $(date -d "@$exp" +%F)" else token=${3:-} diff --git a/scripts/lib/public-verify-appcontainers.sh b/scripts/lib/public-verify-appcontainers.sh index d6e299e5c..00450a8c9 100644 --- a/scripts/lib/public-verify-appcontainers.sh +++ b/scripts/lib/public-verify-appcontainers.sh @@ -27,18 +27,18 @@ check_appenv() { local svc c names bad="" unread="" n=0 for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appenv "$svc is not running" || true return } - if ! names=$(container_env_names "$c") || [ -z "$names" ]; then + if ! names=$(container_env_names "$c") || [[ -z "$names" ]]; then unread+=" $svc" continue fi n=$((n + $(grep -c . <<<"$names" || true))) bad+=$(grep -E "$APPS_FORBIDDEN_ENV" <<<"$names" | sed "s/^/ $svc:/" | paste -sd' ' - || true) done - if [ -n "$unread" ]; then + if [[ -n "$unread" ]]; then fail appenv "environment unreadable or empty for:$unread (docker inspect)" return fi @@ -58,10 +58,10 @@ appshape_problems() { return } read -r ro mem nets mounts <<<"$shape" - [ "$ro" = ro=true ] || bad+=" rootfs-writable" + [[ "$ro" = ro=true ]] || bad+=" rootfs-writable" mem=${mem#mem=} - { [[ "$mem" =~ ^[0-9]+$ ]] && [ "$mem" -ge 1 ] && [ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" - [ "$nets" = "nets=$net," ] || bad+=" network-not-only-$net" + { [[ "$mem" =~ ^[0-9]+$ ]] && [[ "$mem" -ge 1 ]] && [[ "$mem" -le "$APPS_STATIC_MEMORY_MAX" ]]; } || bad+=" memory-not-in-1..$APPS_STATIC_MEMORY_MAX" + [[ "$nets" = "nets=$net," ]] || bad+=" network-not-only-$net" [[ "$mounts" =~ ^mounts=(tmpfs,)*$ ]] || bad+=" mounts-not-tmpfs-only" printf '%s' "${bad# }" } @@ -72,12 +72,12 @@ check_appshape() { local svc c problems bad="" for svc in mail-web calendar-web; do c=$(svc_container "$svc") - [ -n "$c" ] || { + [[ -n "$c" ]] || { apps_absent appshape "$svc is not running" || true return } problems=$(appshape_problems "$c") - [ -z "$problems" ] || bad+=" $svc:$problems;" + [[ -z "$problems" ]] || bad+=" $svc:$problems;" done expect appshape "mail-web and calendar-web: read-only rootfs, memory <= 64 MiB, only ${PROJECT}_apps-static-net, tmpfs mounts only" "not the locked-down shape:$bad" [ -z "$bad" ] } diff --git a/scripts/lib/public-verify-apps.sh b/scripts/lib/public-verify-apps.sh index 4243a59f3..18dbff267 100644 --- a/scripts/lib/public-verify-apps.sh +++ b/scripts/lib/public-verify-apps.sh @@ -27,12 +27,12 @@ app_origin() { local name=${1^^} penv "PUBLIC_${name}_ORIGIN" || true } -apps_configured() { [ -n "$(app_origin mail)$(app_origin calendar)" ]; } +apps_configured() { [[ -n "$(app_origin mail)$(app_origin calendar)" ]]; } # Loopback listener of an app: the local port paired with its public port in APPS_FUNNEL_MAP. app_port() { local want=443 pair - [ "$1" = calendar ] && want=8443 - for pair in $APPS_FUNNEL_MAP; do [ "${pair%%:*}" = "$want" ] && printf '%s' "${pair##*:}"; done + [[ "$1" = calendar ]] && want=8443 + for pair in $APPS_FUNNEL_MAP; do [[ "${pair%%:*}" = "$want" ]] && printf '%s' "${pair##*:}"; done } # The Host header the Funnel sends for the app: its origin without the scheme. app_host_header() { @@ -44,14 +44,14 @@ app_host_header() { apps_host() { local o h o=$(app_origin mail) - [ -n "$o" ] || o=$(app_origin calendar) + [[ -n "$o" ]] || o=$(app_origin calendar) h=${o#https://} - if [ -z "$h" ]; then h=$(node_dns tsa || true); fi + if [[ -z "$h" ]]; then h=$(node_dns tsa || true); fi h=${h%.} printf '%s' "${h%%[:/]*}" } # The apps node must answer when the apps are configured or it has ever been started here. -apps_node_expected() { apps_configured || [ -e "$(apps_node_dir)" ]; } +apps_node_expected() { apps_configured || [[ -e "$(apps_node_dir)" ]]; } # SKIP when the apps are not deployed, FAIL when they are and $2 is missing; returns 1 either way. apps_absent() { @@ -67,8 +67,8 @@ check_exposure2() { return fi have=$(funnel_ports tsa || true) - if [ -z "$have" ]; then - if [ "$1" = apps ] || apps_configured; then + if [[ -z "$have" ]]; then + if [[ "$1" = apps ]] || apps_configured; then fail exposure2 "apps node $dns has its Funnel closed" else skip exposure2 "apps node $dns runs with its Funnel closed and no origin is set"; fi return @@ -76,9 +76,9 @@ check_exposure2() { targets=$(funnel_targets tsa | paste -sd, -) want_targets="443 / 127.0.0.1:$(app_port mail),8443 / 127.0.0.1:$(app_port calendar)" host=$(apps_host) - if [ "$have" != 443,8443 ] || [ "$targets" != "$want_targets" ]; then + if [[ "$have" != 443,8443 ]] || [[ "$targets" != "$want_targets" ]]; then fail exposure2 "apps node funnel ports '$have' -> '$targets', expected exactly 443,8443 -> $want_targets" - elif [ -n "$(app_origin mail)" ] && [ "$dns" != "$host." ]; then + elif [[ -n "$(app_origin mail)" ]] && [[ "$dns" != "$host." ]]; then fail exposure2 "apps node DNS name is '$dns', the origins want '$host.'" else pass exposure2 "apps node ${dns}: funnel $have -> $targets"; fi } @@ -88,7 +88,7 @@ bridge_targets_problems() { local node label targets for node in ts tsa; do label=main - if [ "$node" = tsa ]; then + if [[ "$node" = tsa ]]; then label=apps apps_node_expected || continue fi @@ -100,7 +100,7 @@ bridge_targets_problems() { check_nobridge() { local running problems unreadable mapped scope="no node maps 4100 or 4200" running=$(docker ps --filter "label=com.docker.compose.project=$PROJECT" --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null || echo unreadable) - if [ "$running" = unreadable ]; then + if [[ "$running" = unreadable ]]; then fail nobridge "cannot list the containers of project $PROJECT" return fi @@ -109,11 +109,11 @@ check_nobridge() { unreadable=$(grep '^unreadable:' <<<"$problems" | cut -d: -f2 | paste -sd, - || true) mapped=$(grep -v '^unreadable:' <<<"$problems" | paste -sd, - || true) apps_node_expected || scope="main node maps neither 4100 nor 4200 (apps node not deployed: not checked)" - if [ -n "$running" ]; then + if [[ -n "$running" ]]; then fail nobridge "bridge container(s) running in $PROJECT: $running" - elif [ -n "$unreadable" ]; then + elif [[ -n "$unreadable" ]]; then fail nobridge "cannot read the Funnel targets of: $unreadable (daemon not answering)" - elif [ -n "$mapped" ]; then + elif [[ -n "$mapped" ]]; then fail nobridge "a Funnel node proxies to a bridge port: $mapped" else pass nobridge "no mail-bridge or calendar-bridge container; $scope"; fi } @@ -136,7 +136,7 @@ check_nodev() { for path in $APPS_DEV_PATHS; do code=$(app_get "$app" "$path" -o /dev/null -w '%{http_code}' || true) n=$((n + 1)) - [ "$code" = 404 ] || bad+=" $app$path=${code:-none}" + [[ "$code" = 404 ]] || bad+=" $app$path=${code:-none}" done done expect nodev "$n probes (dev client, entry, package.json, token files) -> 404" "not 404:$bad" [ -z "$bad" ] @@ -145,7 +145,7 @@ check_nodev() { # What is wrong with one response's headers ($1); empty when nothing is. MC4. apphdrs_problems() { local headers=$1 csp ancestors bad="" d - [ -n "$headers" ] || { + [[ -n "$headers" ]] || { printf 'no answer' return } @@ -173,7 +173,7 @@ check_apphdrs() { for path in / /no-such-file-apphdrs; do headers=$(app_get "$app" "$path" -D - -o /dev/null | tr -d '\r' || true) problems=$(apphdrs_problems "$headers") - [ -z "$problems" ] || bad+=" $app$path:$problems;" + [[ -z "$problems" ]] || bad+=" $app$path:$problems;" done done expect apphdrs "mail and calendar, on / and on a 404: CSP (frame-ancestors, default, script, connect), nosniff, no-referrer, HSTS, no CORS, no X-Frame-Options" "headers wrong:$bad" [ -z "$bad" ] @@ -185,20 +185,20 @@ bundle2_offenders() { local image name mode pattern hits key file value for image in "$APPS_MAIL_IMAGE" "$APPS_CALENDAR_IMAGE"; do hits=$(docker run --rm --entrypoint sh "$image" -c 'find /usr/share/nginx/html -type f | wc -l' 2>/dev/null | tr -d '[:space:]' || true) - if ! [[ "$hits" =~ ^[0-9]+$ ]] || [ "$hits" -eq 0 ]; then + if ! [[ "$hits" =~ ^[0-9]+$ ]] || [[ "$hits" -eq 0 ]]; then printf 'unscannable:%s ' "$image" continue fi while IFS='|' read -r name mode pattern; do hits=$(V="$pattern" bundle_hits "$image" "$mode" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$name" "${hits:-?}" done <<<"$APPS_BUNDLE_PATTERNS" for key in $BUNDLE_SECRET_KEYS; do for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue hits=$(V="$value" bundle_hits "$image" || true) - [ "$hits" = 0 ] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" + [[ "$hits" = 0 ]] || printf '%s:%s(%s) ' "${image##*/}" "$key" "${hits:-?}" done done done @@ -228,7 +228,7 @@ appurls_config_problems() { cal=$(app_origin calendar) host=$(apps_host) main=$(origin_host) - if [ -z "$mail" ] || [ -z "$cal" ]; then + if [[ -z "$mail" ]] || [[ -z "$cal" ]]; then printf 'only one of PUBLIC_MAIL_ORIGIN, PUBLIC_CALENDAR_ORIGIN is set' return fi @@ -236,11 +236,11 @@ appurls_config_problems() { printf 'PUBLIC_MAIL_ORIGIN is not https://host' return } - [ "$cal" = "$mail:8443" ] || { + [[ "$cal" = "$mail:8443" ]] || { printf 'PUBLIC_CALENDAR_ORIGIN is not PUBLIC_MAIL_ORIGIN:8443' return } - [ "$host" != "$main" ] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" + [[ "$host" != "$main" ]] || printf 'the apps origins share PUBLIC_ORIGIN host %s (MC3: a separate hostname)' "$main" } # MC8: the osionos app image links to the configured origins and to no localhost. @@ -251,19 +251,19 @@ check_appurls() { return } problems=$(appurls_config_problems) - if [ -n "$problems" ]; then + if [[ -n "$problems" ]]; then fail appurls "$problems" return fi for app in mail calendar; do origin=$(app_origin "$app") hits=$(origin_hits "$APP_IMAGE" "$origin" || true) - [[ "$hits" =~ ^[0-9]+$ ]] && [ "$hits" -gt 0 ] || bad+=" $origin(${hits:-unscannable})" + [[ "$hits" =~ ^[0-9]+$ ]] && [[ "$hits" -gt 0 ]] || bad+=" $origin(${hits:-unscannable})" done links=$(bundle_links "$APP_IMAGE" | head -n1 | tr -d '[:space:]' || true) - if [ -n "$bad" ]; then + if [[ -n "$bad" ]]; then fail appurls "$APP_IMAGE does not contain the origin:$bad" - elif [ "$links" != 0 ]; then + elif [[ "$links" != 0 ]]; then fail appurls "$APP_IMAGE has ${links:-an unreadable count of} file(s) linking to localhost" else pass appurls "$APP_IMAGE contains $(app_origin mail) and $(app_origin calendar), no localhost"; fi } @@ -273,16 +273,16 @@ check_appurls() { check_tls2() { local ports ports=$(funnel_ports tsa || true) - if [ -n "$ports" ]; then + if [[ -n "$ports" ]]; then tls_probe "$(apps_host)" "$ports" tls2 - elif [ "$1" = apps ] || apps_configured; then + elif [[ "$1" = apps ]] || apps_configured; then fail tls2 "apps node has no open port: no certificate to check" else skip tls2 "apps node has no open port and no origin is set"; fi } # $1: pre | full $2: the verify scope check_apps() { - if [ "$1" != pre ]; then + if [[ "$1" != pre ]]; then check_exposure2 "$2" check_tls2 "$2" fi diff --git a/scripts/lib/public-verify-surfaces.sh b/scripts/lib/public-verify-surfaces.sh index a7cdfac11..6c61f10f2 100644 --- a/scripts/lib/public-verify-surfaces.sh +++ b/scripts/lib/public-verify-surfaces.sh @@ -26,13 +26,13 @@ check_ide_mode() { check_ide_access() { local bridge ide bridge=$(svc_container osionos-bridge) - [ -n "$bridge" ] || { + [[ -n "$bridge" ]] || { fail ide-access "osionos-bridge is not running" return } check_ide_mode osionos-bridge "$bridge" ide=$(svc_container osionos-ide-server || true) - if [ -n "$ide" ]; then + if [[ -n "$ide" ]]; then check_ide_mode osionos-ide-server "$ide" else pass ide-access "no osionos-ide-server runs in this project"; fi } @@ -51,7 +51,7 @@ check_bundle_links() { count=$(head -n1 <<<"$out" | tr -d '[:space:]') if ! [[ "$count" =~ ^[0-9]+$ ]]; then fail bundle-links "could not be scanned in $APP_IMAGE" - elif [ "$count" = 0 ]; then + elif [[ "$count" = 0 ]]; then pass bundle-links "0 files in $APP_IMAGE link to localhost" else ports=$(tail -n +2 <<<"$out" | paste -sd' ' -) diff --git a/scripts/lib/public-verify.sh b/scripts/lib/public-verify.sh index 7ba070d98..69d151815 100644 --- a/scripts/lib/public-verify.sh +++ b/scripts/lib/public-verify.sh @@ -51,17 +51,17 @@ origin_host() { printf '%s' "${o%%[:/]*}" } -is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [ "$1" -ge "$2" ] && [ "$1" -le "$3" ]; } +is_int_between() { [[ "$1" =~ ^[0-9]+$ ]] && [[ "$1" -ge "$2" ]] && [[ "$1" -le "$3" ]]; } check_node_name() { local host have host=$(origin_host) - [ -n "$host" ] || { + [[ -n "$host" ]] || { fail node-name "PUBLIC_ORIGIN is not an https://host origin in .env.public" return } have=$(node_dns ts || true) - if [ "$have" = "$host." ]; then + if [[ "$have" = "$host." ]]; then pass node-name "this node is $have" else fail node-name "node DNS name is '${have:-unreadable}', PUBLIC_ORIGIN wants '$host.'"; fi } @@ -79,11 +79,11 @@ check_exposure() { all) want=443,8443,10000 ;; *) want="" ;; esac - if [ -n "$want" ]; then + if [[ -n "$want" ]]; then expect exposure "funnel ports: $have" "funnel ports '${have:-none}', expected exactly $want" [ "$have" = "$want" ] - elif [ -z "$have" ]; then + elif [[ -z "$have" ]]; then skip exposure "funnel closed: nothing is exposed" - elif [ "$have" = 443 ] || [ "$have" = 443,8443,10000 ]; then + elif [[ "$have" = 443 ]] || [[ "$have" = 443,8443,10000 ]]; then pass exposure "funnel ports: $have" else fail exposure "funnel ports '$have', expected 443 alone or 443,8443,10000"; fi } @@ -99,13 +99,13 @@ scan_secret() { local key=$1 file=$2 value=$3 image hits bad=0 for image in "$APP_IMAGE" "$WEB_IMAGE"; do hits=$(V="$value" bundle_hits "$image" || true) - if [ "$hits" = 0 ]; then continue; fi + if [[ "$hits" = 0 ]]; then continue; fi bad=1 - if [ -n "$hits" ]; then + if [[ -n "$hits" ]]; then fail bundle-secret "$key (from ${file##*/}) found in $hits file(s) of $image" else fail bundle-secret "$key (from ${file##*/}) could not be scanned in $image"; fi done - [ "$bad" = 1 ] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" + [[ "$bad" = 1 ]] || pass bundle-secret "$key (from ${file##*/}): 0 files in $APP_IMAGE and $WEB_IMAGE" } check_bundle_secrets() { @@ -114,13 +114,13 @@ check_bundle_secrets() { found=0 for file in "$LOCAL_ENV" "$REMOTE_ENV" "$ENV_FILE"; do value=$(get_env "$file" "$key" || true) - [ -n "$value" ] || continue + [[ -n "$value" ]] || continue case "$seen" in *$'\n'"$key=$value"$'\n'*) continue ;; esac seen+="$key=$value"$'\n' found=1 scan_secret "$key" "$file" "$value" done - [ "$found" = 1 ] || skip bundle-secret "$key is unset in every env file: nothing to look for" + [[ "$found" = 1 ]] || skip bundle-secret "$key is unset in every env file: nothing to look for" done } @@ -129,7 +129,7 @@ container_env() { docker exec "$1" printenv "$2" 2>/dev/null || true; } check_bridge_env() { local c key have want c=$(svc_container osionos-bridge) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail bridge-env "osionos-bridge is not running" return } @@ -138,7 +138,7 @@ check_bridge_env() { done want=$(penv OSIONOS_ADMIN_EMAILS || true) have=$(container_env "$c" OSIONOS_ADMIN_EMAILS) - if [ -n "$want" ] && [ "$have" = "$want" ]; then + if [[ -n "$want" ]] && [[ "$have" = "$want" ]]; then pass bridge-env "OSIONOS_ADMIN_EMAILS equals .env.public" else fail bridge-env "OSIONOS_ADMIN_EMAILS differs from .env.public (or is empty there)"; fi want="$(penv PUBLIC_ORIGIN || true):8443" @@ -153,7 +153,7 @@ list_overlap() { local a b a=$(list_entries "$1") b=$(list_entries "$2") - if [ -n "$a" ] && [ -n "$b" ]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi + if [[ -n "$a" ]] && [[ -n "$b" ]]; then grep -Fxf <(printf '%s\n' "$b") <<<"$a" || true; fi } # An address list, not a secret: printed as a value. An exempt account is never mailed, so an @@ -168,7 +168,7 @@ check_verification_exempt() { check_gateway_env() { local c hops logs c=$(svc_container auth-gateway) - [ -n "$c" ] || { + [[ -n "$c" ]] || { fail gateway-env "auth-gateway is not running" return } @@ -178,7 +178,7 @@ check_gateway_env() { check_verification_exempt "$c" hops=$(penv AUTH_TRUSTED_PROXY_HOPS || true) logs=$(docker logs "$c" 2>&1 || true) - if [ -z "$hops" ]; then + if [[ -z "$hops" ]]; then fail gateway-env "AUTH_TRUSTED_PROXY_HOPS is unset in .env.public" elif grep -qF "trusted proxy hops: $hops)" <<<"$logs"; then pass gateway-env "boot log: trusted proxy hops: $hops" @@ -224,7 +224,7 @@ tls_probe() { check_env_file() { local mode - if [ ! -f "$ENV_FILE" ]; then + if [[ ! -f "$ENV_FILE" ]]; then fail env-file ".env.public is missing (make public-env)" return fi @@ -244,10 +244,10 @@ extra_ancestors_ok() { check_env_values() { local bad="" v - [ -n "$(origin_host)" ] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" + [[ -n "$(origin_host)" ]] && [[ "$(penv PUBLIC_ORIGIN)" =~ ^https://[A-Za-z0-9.-]+$ ]] || bad+=" PUBLIC_ORIGIN" v=$(penv AUTH_LINK_SIGNING_KEY || true) - [ "${#v}" -ge 32 ] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" - [ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" + [[ "${#v}" -ge 32 ]] || bad+=" AUTH_LINK_SIGNING_KEY(<32 chars)" + [[ -n "$(penv OSIONOS_ADMIN_EMAILS || true)" ]] || bad+=" OSIONOS_ADMIN_EMAILS(empty: write emails or none)" is_int_between "$(penv AUTH_MAIL_DAILY_CAP || echo 50)" 1 50 || bad+=" AUTH_MAIL_DAILY_CAP(1-50)" is_int_between "$(penv AUTH_REGISTER_RATE_LIMIT_PER_HOUR || echo 10)" 1 10 || bad+=" AUTH_REGISTER_RATE_LIMIT_PER_HOUR(1-10)" v=$(penv AUTH_TRUSTED_PROXY_HOPS || true) @@ -262,7 +262,7 @@ run_verify() { check_env_file check_env_values check_node_name - [ "$1" = pre ] || { + [[ "$1" = pre ]] || { check_exposure "${2:-}" check_tls } @@ -275,5 +275,5 @@ run_verify() { check_edge check_apps "$1" "${2:-}" printf 'verify (%s): %d pass, %d fail, %d skip\n' "$1" "$V_PASS" "$V_FAIL" "$V_SKIP" - [ "$V_FAIL" -eq 0 ] + [[ "$V_FAIL" -eq 0 ]] } diff --git a/scripts/osionos-publish-socket.sh b/scripts/osionos-publish-socket.sh index a20253b2d..9cac37b07 100644 --- a/scripts/osionos-publish-socket.sh +++ b/scripts/osionos-publish-socket.sh @@ -28,15 +28,15 @@ ssh_user=${PUBLISH_SSH_USER:-$(id -un)} read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exactly one valid key) and print " " local keytype='' keydata='' _ - [ -n "${HOST_KEY_FILE:-}" ] || { + [[ -n "${HOST_KEY_FILE:-}" ]] || { echo "[publish-socket] HOST_KEY_FILE is unset: the VM's ssh host public key line has to be pinned by the operator, over a trusted step" >&2 exit 2 } - [ -r "$HOST_KEY_FILE" ] || { + [[ -r "$HOST_KEY_FILE" ]] || { echo "[publish-socket] HOST_KEY_FILE=$HOST_KEY_FILE is not a readable file" >&2 exit 2 } - if [ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]; then + if [[ "$(grep -c '[^[:space:]]' "$HOST_KEY_FILE")" -ne 1 ]]; then echo "[publish-socket] HOST_KEY_FILE must hold exactly one host key line" >&2 exit 2 fi @@ -48,7 +48,7 @@ read_host_key() { # validate HOST_KEY_FILE (exit 2: unset, unreadable, not exact exit 2 ;; esac - [ -n "$keydata" ] || { + [[ -n "$keydata" ]] || { echo "[publish-socket] HOST_KEY_FILE has a key type but no key" >&2 exit 2 } @@ -90,7 +90,7 @@ install_unit() { pinned=$(read_host_key) require_plain # bytes, not characters: a multibyte XDG_STATE_HOME is longer than ${#sock} says - if [ "$(printf %s "$sock" | wc -c)" -gt 100 ]; then + if [[ "$(printf %s "$sock" | wc -c)" -gt 100 ]]; then echo "[publish-socket] $sock is longer than 100 bytes: a unix socket path has to fit 108" >&2 exit 1 fi @@ -98,7 +98,7 @@ install_unit() { chmod 700 "$state" "$sockdir" printf 'b2b-publish %s\n' "$pinned" >"$known_hosts" chmod 600 "$known_hosts" - [ -f "$identity" ] || { + [[ -f "$identity" ]] || { echo "[publish-socket] no identity file at $identity (PUBLISH_SSH_IDENTITY=)" >&2 exit 1 } @@ -110,7 +110,7 @@ install_unit() { remove_unit() { # only an absent unit is skipped: any other disable/stop failure must not report "off" - if [ -e "$unitdir/$unit" ]; then + if [[ -e "$unitdir/$unit" ]]; then systemctl --user disable --now "$unit" >/dev/null fi rm -f "$unitdir/$unit" @@ -123,7 +123,7 @@ print_status() { local active active=$(systemctl --user is-active "$unit" 2>/dev/null) || true echo "[publish-socket] unit: ${active:-unknown}" - if [ -e "$sock" ]; then + if [[ -e "$sock" ]]; then echo "[publish-socket] socket: $sock exists" else echo "[publish-socket] socket: $sock missing" diff --git a/scripts/public-b2b/build-images.sh b/scripts/public-b2b/build-images.sh index 4a0000665..68256d14b 100755 --- a/scripts/public-b2b/build-images.sh +++ b/scripts/public-b2b/build-images.sh @@ -27,13 +27,13 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=../lib/envfile.sh . "$root/scripts/lib/envfile.sh" -[ $# -eq 1 ] || fail "usage: build-images.sh " +[[ $# -eq 1 ]] || fail "usage: build-images.sh " oo=$(cd "$1" && pwd) build_env=${PUBLIC_BUILD_ENV_FILE:-$root/.env.public-b2b} git -C "$oo" rev-parse --verify -q HEAD >/dev/null || fail "$oo is not a git worktree" -[ -f "$oo/docker/services/web/Dockerfile" ] || fail "$oo is not an opposite-osiris tree" -[ -z "$(git -C "$oo" status --porcelain)" ] || fail "$oo has uncommitted changes: commit them, the tag names a commit" +[[ -f "$oo/docker/services/web/Dockerfile" ]] || fail "$oo is not an opposite-osiris tree" +[[ -z "$(git -C "$oo" status --porcelain)" ]] || fail "$oo has uncommitted changes: commit them, the tag names a commit" sha=$(git -C "$oo" rev-parse --short=12 HEAD) anon_key=$(get_env "$build_env" PUBLIC_BAAS_ANON_KEY) || fail "PUBLIC_BAAS_ANON_KEY missing in $build_env" @@ -87,9 +87,9 @@ js_anon=$(count_in "$anon_key" '*.js') echo " html files with $PUBLIC_HOST:$FUNNEL_PORT: $html_funnel (want >= 1)" echo " js files with https://localhost:4322: $js_localhost (want 0); index.html with it: $index_localhost (want 0)" echo " js files with the anon key: $js_anon (want >= 1)" -[ "$html_funnel" -ge 1 ] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" -if [ "$js_localhost" -ne 0 ] || [ "$index_localhost" -ne 0 ]; then fail "the bundle still contains https://localhost:4322"; fi -[ "$js_anon" -ge 1 ] || fail "the anon key is not in the bundle" +[[ "$html_funnel" -ge 1 ]] || fail "no page contains $PUBLIC_HOST:$FUNNEL_PORT" +if [[ "$js_localhost" -ne 0 ]] || [[ "$index_localhost" -ne 0 ]]; then fail "the bundle still contains https://localhost:4322"; fi +[[ "$js_anon" -ge 1 ]] || fail "the anon key is not in the bundle" echo " info: js files with the literal http://localhost:4322 (portal.ts redirect allowlist, hard-coded): $(count_in "http://localhost:4322" '*.js')" echo " info: files that still name localhost:4322 (robots.txt Sitemap line, docs pages): $(count_in "localhost:4322" '*')" diff --git a/scripts/public-b2b/deploy.sh b/scripts/public-b2b/deploy.sh index 76632e796..54b8f0128 100755 --- a/scripts/public-b2b/deploy.sh +++ b/scripts/public-b2b/deploy.sh @@ -24,7 +24,7 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) # shellcheck source=lib.sh . "$root/scripts/public-b2b/lib.sh" -[ $# -eq 1 ] || fail "usage: deploy.sh (the tag printed by build-images.sh)" +[[ $# -eq 1 ]] || fail "usage: deploy.sh (the tag printed by build-images.sh)" sha=$1 site_image=prismatica-public/site:$sha gateway_image=prismatica-public/auth-gateway:$sha @@ -39,7 +39,7 @@ wrote_on_vm=0 on_exit() { local status=$? - if [ "$status" -ne 0 ] && [ "$wrote_on_vm" -eq 1 ]; then + if [[ "$status" -ne 0 ]] && [[ "$wrote_on_vm" -eq 1 ]]; then echo "== FAILED (exit $status). Roll back with:" >&2 echo " $rollback" >&2 fi @@ -49,7 +49,7 @@ trap on_exit EXIT # curl from this host, or from the VM when PUBLIC_CHECK_FROM=vm. Prints the status code only. public_status() { local method=$1 path=$2 - if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then + if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then "${B2B[@]}" "curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X $method -d '{}' -H 'content-type: application/json' '$PUBLIC_URL$path'" else curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X "$method" -d '{}' -H 'content-type: application/json' "$PUBLIC_URL$path" || true @@ -60,7 +60,7 @@ expect_status() { local label=$1 got=$2 want shift 2 for want in "$@"; do - if [ "$got" = "$want" ]; then + if [[ "$got" = "$want" ]]; then echo " ok $label -> $got" return 0 fi @@ -79,7 +79,7 @@ echo " $gateway_image layers=$gateway_layers" echo "== 1. preflight on the VM (reads only)" self=$("${B2B[@]}" "tailscale status --json | jq -r .Self.DNSName") -[ "$self" = "$PUBLIC_HOST." ] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." +[[ "$self" = "$PUBLIC_HOST." ]] || fail "this node is $self, the URL and the build assume $PUBLIC_HOST." "${B2B[@]}" "tailscale serve status --json | jq -e --arg h '$PUBLIC_HOST:$FUNNEL_PORT' '.Web[\$h] == null' >/dev/null" || fail ":$FUNNEL_PORT is already configured in the serve config: inspect it, then rollback.sh or stop" "${B2B[@]}" "test -d $REMOTE_DIR" || fail "$REMOTE_DIR is missing: run env-init.sh first" @@ -97,8 +97,8 @@ echo "$code $at_signs" PROBE ) read -r probe_code probe_at <<<"$probe" -echo " status=$probe_code at_sign_in_body=$([ "$probe_at" = 0 ] && echo no || echo YES)" -[ "$probe_at" = 0 ] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" +echo " status=$probe_code at_sign_in_body=$([[ "$probe_at" = 0 ]] && echo no || echo YES)" +[[ "$probe_at" = 0 ]] || fail "PUB-3: an email came back for the anon key: fix the users grant, do not ship" case $probe_code in 401 | 403) ;; *) fail "PUB-3: anon got $probe_code on users?select=email, want a permission error (401/403)" ;; @@ -115,7 +115,7 @@ echo " waf: ${waf_before:0:12} ... mini-baas containers: $count_before digest echo " port $port is free (or ours)" busy=$("${B2B[@]}" "ss -ltn 'sport = :$port' | grep -c LISTEN || true") ours=$("${B2B[@]}" "docker ps -q --filter label=com.docker.compose.project=$PROJECT | wc -l") -{ [ "$busy" = 0 ] || [ "$ours" -gt 0 ]; } || fail "port $port is taken by something that is not $PROJECT" +{ [[ "$busy" = 0 ]] || [[ "$ours" -gt 0 ]]; } || fail "port $port is taken by something that is not $PROJECT" echo " $REMOTE_DIR/.env: mode 600, keys present (names only)" "${B2B[@]}" bash -s </dev/null got=$(remote_layers_hash "$image") - [ "$got" = "$want" ] || fail "layers of $image differ on b2b ($got vs $want)" + [[ "$got" = "$want" ]] || fail "layers of $image differ on b2b ($got vs $want)" echo " $image layers=$got (match)" done if ! "${B2B[@]}" "docker image inspect $redis_image >/dev/null 2>&1"; then @@ -152,7 +152,7 @@ printf 'PUBLIC_SITE_IMAGE=%s\nPUBLIC_GATEWAY_IMAGE=%s\nPUBLIC_REDIS_IMAGE=%s\nPR "$site_image" "$gateway_image" "$redis_image" "$port" | "${B2B[@]}" "cat > $REMOTE_DIR/images.env" local_sum=$(sha256sum <"$root/infrastructure/compose/public-b2b.yml" | cut -c1-16) remote_sum=$("${B2B[@]}" "sha256sum < $REMOTE_DIR/public-b2b.yml | cut -c1-16") -[ "$local_sum" = "$remote_sum" ] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" +[[ "$local_sum" = "$remote_sum" ]] || fail "compose file differs on the VM ($remote_sum vs $local_sum)" echo " public-b2b.yml $remote_sum (match)" echo "== 4. up -d in project $PROJECT (no build, no pull)" @@ -161,10 +161,10 @@ for service in public-redis public-auth-gateway public-site; do state=starting for _ in $(seq 1 40); do state=$("${B2B[@]}" "docker inspect -f '{{.State.Health.Status}}' \$($compose ps -q $service)" 2>/dev/null || echo starting) - [ "$state" = healthy ] && break + [[ "$state" = healthy ]] && break sleep 3 done - [ "$state" = healthy ] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" + [[ "$state" = healthy ]] || fail "$service is $state after 2 minutes: ssh b2b '$compose logs $service'" echo " $service healthy" done boot=$("${B2B[@]}" "$compose logs --no-log-prefix public-auth-gateway 2>&1 | grep -E 'FATAL|listening' | tail -3" || true) @@ -190,23 +190,23 @@ serve_adds_only_funnel_keys "$before_json" "$after_json" || fail "PUB-9: the serve config changed beyond the :$FUNNEL_PORT keys (compare $before_json and $after_json)" echo " ok serve diff adds only TCP/Web/AllowFunnel :$FUNNEL_PORT; both :443 hosts equal" waf_after=$("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.Id}} {{.State.StartedAt}}'") -[ "$waf_after" = "$waf_before" ] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" +[[ "$waf_after" = "$waf_before" ]] || fail "PUB-10: mini-baas-waf changed ($waf_before -> $waf_after)" ps_after=$("${B2B[@]}" "docker compose -p mini-baas ps -a -q | sort | sha256sum | cut -c1-16") -[ "$ps_after" = "$ps_before" ] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" +[[ "$ps_after" = "$ps_before" ]] || fail "PUB-5: the mini-baas container set changed ($ps_before -> $ps_after)" echo " ok WAF Id+StartedAt unchanged; mini-baas digest $ps_after unchanged" echo "== 7. through $PUBLIC_URL (certificate issuance can take a minute)" status=000 for _ in $(seq 1 12); do status=$(public_status GET /) - [ "$status" = 200 ] && break + [[ "$status" = 200 ]] && break sleep 5 done expect_status "GET /" "$status" 200 expect_status "GET /api/auth/health" "$(public_status GET /api/auth/health)" 200 expect_status "POST /api/email/v1/send" "$(public_status POST /api/email/v1/send)" 401 403 expect_status "POST /api/v1/auth/otp/request" "$(public_status POST /api/v1/auth/otp/request)" 401 403 -if [ "${PUBLIC_CHECK_FROM:-host}" = vm ]; then +if [[ "${PUBLIC_CHECK_FROM:-host}" = vm ]]; then page=$("${B2B[@]}" "curl -s --max-time 15 '$PUBLIC_URL/'") else page=$(curl -s --max-time 15 "$PUBLIC_URL/") diff --git a/scripts/public-b2b/env-init.sh b/scripts/public-b2b/env-init.sh index 31c6f21d3..3443bf3da 100755 --- a/scripts/public-b2b/env-init.sh +++ b/scripts/public-b2b/env-init.sh @@ -30,7 +30,7 @@ fail() { echo "FAIL: $*" >&2; exit 1; } put_env() { local tmp tmp=$(mktemp "$DIR/.env.XXXXXX") - [ ! -f "$FILE" ] || cat "$FILE" >"$tmp" + [[ ! -f "$FILE" ]] || cat "$FILE" >"$tmp" ENVFILE_KEY=$1 ENVFILE_VAL=$2 awk ' BEGIN { k = ENVIRON["ENVFILE_KEY"]; v = ENVIRON["ENVFILE_VAL"] } { @@ -45,7 +45,7 @@ END { if (!seen) print k "=" v }' "$tmp" >"$tmp.new" } get_env() { - [ -f "$FILE" ] || return 0 + [[ -f "$FILE" ]] || return 0 sed -n "s/^$1=//p" "$FILE" | tail -1 } @@ -62,42 +62,42 @@ refuse_unsafe() { prompt() { local name=$1 hidden=$2 current answer current=$(get_env "$name") - if [ -n "$current" ]; then + if [[ -n "$current" ]]; then printf '%s is set; Enter keeps it, or type a new value: ' "$name" >&2 else printf '%s: ' "$name" >&2 fi - if [ "$hidden" = secret ]; then + if [[ "$hidden" = secret ]]; then read -r -s answer <&3 echo >&2 else read -r answer <&3 fi answer=${answer:-$current} - [ -n "$answer" ] || fail "$name is required" + [[ -n "$answer" ]] || fail "$name is required" refuse_unsafe "$name" "$answer" put_env "$name" "$answer" } -[ -r "$SRC" ] || fail "cannot read $SRC" -[ -d "$DIR" ] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" -[ -f "$FILE" ] || install -m 600 /dev/null "$FILE" +[[ -r "$SRC" ]] || fail "cannot read $SRC" +[[ -d "$DIR" ]] || sudo install -d -o "$(id -un)" -g "$(id -gn)" -m 700 "$DIR" +[[ -f "$FILE" ]] || install -m 600 /dev/null "$FILE" ! grep -q '^AUTH_DEMO_INSECURE=' "$FILE" || fail "AUTH_DEMO_INSECURE is in $FILE: remove it (PUB-8)" service_key=$(source_value SERVICE_ROLE_KEY) anon_key=$(source_value ANON_KEY) -[ -n "$service_key" ] || fail "SERVICE_ROLE_KEY is empty in $SRC" -[ -n "$anon_key" ] || fail "ANON_KEY is empty in $SRC" +[[ -n "$service_key" ]] || fail "SERVICE_ROLE_KEY is empty in $SRC" +[[ -n "$anon_key" ]] || fail "ANON_KEY is empty in $SRC" put_env SERVICE_ROLE_KEY "$service_key" put_env PUBLIC_BAAS_ANON_KEY "$anon_key" unset service_key anon_key -[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" +[[ -n "$(get_env AUTH_LINK_SIGNING_KEY)" ]] || put_env AUTH_LINK_SIGNING_KEY "$(head -c 48 /dev/urandom | base64 -w0)" prompt TURNSTILE_SECRET_KEY secret prompt AUTH_SIGNUP_ALLOWLIST visible echo "$FILE mode $(stat -c %a "$FILE") (want 600)" -[ "$(stat -c %a "$FILE")" = 600 ] || fail "mode is not 600" +[[ "$(stat -c %a "$FILE")" = 600 ]] || fail "mode is not 600" for name in SERVICE_ROLE_KEY PUBLIC_BAAS_ANON_KEY AUTH_LINK_SIGNING_KEY TURNSTILE_SECRET_KEY AUTH_SIGNUP_ALLOWLIST; do - if [ -n "$(get_env "$name")" ]; then echo " $name set"; else echo " $name UNSET"; fi + if [[ -n "$(get_env "$name")" ]]; then echo " $name set"; else echo " $name UNSET"; fi done diff --git a/scripts/public-b2b/rollback.sh b/scripts/public-b2b/rollback.sh index 1118e8bf1..a396b2037 100755 --- a/scripts/public-b2b/rollback.sh +++ b/scripts/public-b2b/rollback.sh @@ -22,10 +22,10 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) . "$root/scripts/public-b2b/lib.sh" before_json=${1:-} -if [ -z "$before_json" ]; then +if [[ -z "$before_json" ]]; then before_json=$("${B2B[@]}" "ls -t $REMOTE_DIR/serve-before-*.json 2>/dev/null | head -1" || true) fi -[ -n "$before_json" ] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" +[[ -n "$before_json" ]] || fail "no saved serve config under $REMOTE_DIR: pass the path explicitly" "${B2B[@]}" "test -s '$before_json'" || fail "$before_json is missing or empty on the VM" stamp=$(date -u +%Y%m%dT%H%M%SZ) now_json=$REMOTE_DIR/serve-rollback-$stamp.json @@ -45,7 +45,7 @@ if ! "${B2B[@]}" "set -o pipefail; ${down[*]} 2>&1 | tail -6"; then fail "compose down failed: remove the containers labelled com.docker.compose.project=$PROJECT by hand" fi left=$("${B2B[@]}" "docker ps -aq --filter label=com.docker.compose.project=$PROJECT | wc -l") -[ "$left" = 0 ] || fail "$left container(s) of $PROJECT remain" +[[ "$left" = 0 ]] || fail "$left container(s) of $PROJECT remain" echo " ok no $PROJECT container left (volume ${PROJECT}_public-redis-data kept)" echo " mini-baas for the human to compare with the deploy output: $("${B2B[@]}" "docker compose -p mini-baas ps -a -q | wc -l") containers, waf $("${B2B[@]}" "docker inspect mini-baas-waf --format '{{.State.StartedAt}}'")" diff --git a/scripts/public-funnel.sh b/scripts/public-funnel.sh index 4bcc54365..0348c8435 100755 --- a/scripts/public-funnel.sh +++ b/scripts/public-funnel.sh @@ -60,7 +60,7 @@ svc_container() { scope_pairs() { local pair for pair in $FUNNEL_MAP; do - if [ "$1" = all ] || [ "${pair%%:*}" = 443 ]; then printf '%s\n' "$pair"; fi + if [[ "$1" = all ]] || [[ "${pair%%:*}" = 443 ]]; then printf '%s\n' "$pair"; fi done } @@ -72,7 +72,7 @@ reset_both() { if ts funnel reset; then CLOSED="main"; else rc=1; fi if tsa status --json >/dev/null 2>&1; then if tsa funnel reset; then CLOSED+="${CLOSED:+, }apps"; else rc=1; fi - elif [ -e "$(apps_node_dir)" ]; then + elif [[ -e "$(apps_node_dir)" ]]; then note "apps node has state in $(apps_node_dir) but nothing answers on $(apps_node_socket): its Funnel could not be reset" rc=1 fi @@ -98,14 +98,14 @@ open_apps() { cmd_up() { local scope=${1:-443} pair case "$scope" in 443 | all | apps) ;; *) die "usage: up [443|all|apps]" ;; esac - if [ "$scope" = all ] && [ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]; then + if [[ "$scope" = all ]] && [[ -z "$(penv AUTH_TRUSTED_PROXY_HOPS || true)" ]]; then die "refusing 'up all': AUTH_TRUSTED_PROXY_HOPS is unset in $ENV_FILE (measure it on 443 first)" fi - if [ "$scope" = apps ] && ! apps_configured; then + if [[ "$scope" = apps ]] && ! apps_configured; then die "refusing 'up apps': set PUBLIC_MAIL_ORIGIN and PUBLIC_CALENDAR_ORIGIN in $ENV_FILE (the apps checks need the origins)" fi run_verify pre || die "verify --pre failed: the Funnel stays closed" - if [ "$scope" = apps ]; then + if [[ "$scope" = apps ]]; then open_apps return fi @@ -147,22 +147,22 @@ cmd_verify() { # the file got an empty key (CI's bats image, run 39148082034); od is in busybox and coreutils. cmd_env() { local key - [ -f "$ENV_FILE" ] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") + [[ -f "$ENV_FILE" ]] || (umask 077 && cp "$REPO/.env.public.example" "$ENV_FILE") chmod 600 "$ENV_FILE" - if [ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]; then + if [[ -z "$(penv AUTH_LINK_SIGNING_KEY || true)" ]]; then key="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')" - [ "${#key}" -eq 64 ] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" + [[ "${#key}" -eq 64 ]] || die "could not mint AUTH_LINK_SIGNING_KEY from /dev/urandom; $ENV_FILE left without it" put_env "$ENV_FILE" AUTH_LINK_SIGNING_KEY "$key" note "minted AUTH_LINK_SIGNING_KEY in $ENV_FILE" fi for k in OSIONOS_ADMIN_EMAILS AUTH_TRUSTED_PROXY_HOPS; do - [ -n "$(penv "$k" || true)" ] || note "decide $k in $ENV_FILE" + [[ -n "$(penv "$k" || true)" ]] || note "decide $k in $ENV_FILE" done } main() { local cmd=${1:-} - [ $# -eq 0 ] || shift + [[ $# -eq 0 ]] || shift command -v jq >/dev/null || die "jq is required" case "$cmd" in up) cmd_up "$@" ;; diff --git a/scripts/public-node.sh b/scripts/public-node.sh index 329721393..211dc1f70 100755 --- a/scripts/public-node.sh +++ b/scripts/public-node.sh @@ -55,10 +55,10 @@ backend_state() { tsa status --json 2>/dev/null | jq -r '.BackendState // empty' read_key() { local key - if [ -t 0 ]; then + if [[ -t 0 ]]; then read -rsp "auth key (single-use, tagged, pre-approved, not ephemeral): " key >&2 && echo >&2 else IFS= read -r key || true; fi - [ -n "$key" ] || return 1 + [[ -n "$key" ]] || return 1 printf '%s' "$key" } @@ -78,7 +78,7 @@ join_tailnet() { # Where the daemon's output goes: nowhere unless PUBLIC_APPS_LOG=1. daemon_log() { - if [ "${PUBLIC_APPS_LOG:-}" = 1 ]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi + if [[ "${PUBLIC_APPS_LOG:-}" = 1 ]]; then printf '%s/tailscaled.log' "$1"; else printf /dev/null; fi } # Waits (50 x 0.2 s) for a BackendState other than NoState/Starting/unreadable and prints it; a @@ -143,7 +143,7 @@ cmd_start() { # True when $1 is a tailscaled started with this node's --socket (not, say, the main node's). is_our_daemon() { [[ "$1" =~ ^[0-9]+$ ]] || return 1 - [ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ] || return 1 + [[ "$(cat "/proc/$1/comm" 2>/dev/null || true)" = tailscaled ]] || return 1 tr '\0' '\n' <"/proc/$1/cmdline" 2>/dev/null | grep -qxF -- "--socket=$(apps_node_socket)" } @@ -160,10 +160,10 @@ reset_funnel_before_stop() { stop_daemon() { local pidfile pid rc=0 pidfile="$(apps_node_dir)/tailscaled.pid" - [ -f "$pidfile" ] || return 1 + [[ -f "$pidfile" ]] || return 1 pid=$(cat "$pidfile") if is_our_daemon "$pid"; then - if [ "${1:-}" = reset ]; then reset_funnel_before_stop || rc=1; fi + if [[ "${1:-}" = reset ]]; then reset_funnel_before_stop || rc=1; fi kill "$pid" note "stopped tailscaled $pid" else note "pid $pid is not a running tailscaled of ours (socket $(apps_node_socket))"; fi @@ -172,7 +172,7 @@ stop_daemon() { } cmd_stop() { - [ -f "$(apps_node_dir)/tailscaled.pid" ] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" + [[ -f "$(apps_node_dir)/tailscaled.pid" ]] || die "no pidfile $(apps_node_dir)/tailscaled.pid: nothing of ours to stop" stop_daemon reset } @@ -184,8 +184,8 @@ unit_dir() { printf '%s/systemd/user' "${XDG_CONFIG_HOME:-$HOME/.config}"; } cmd_autostart() { local groot groot=$(cd "$SELF/.." && pwd) - [ -x "$TAILSCALED_BIN" ] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" - [ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" + [[ -x "$TAILSCALED_BIN" ]] || die "no tailscaled at $TAILSCALED_BIN: set TAILSCALED_BIN" + [[ -x "${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}" ]] || die "no tailscale at ${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}: set TAILSCALE_BIN" install -d "$(unit_dir)" sed -e "s|@GROOT@|$groot|g" -e "s|@NODE@|$(apps_node_name)|g" -e "s|@STATE_DIR@|$(apps_node_dir)|g" \ -e "s|@TAILSCALED_BIN@|$TAILSCALED_BIN|g" -e "s|@TAILSCALE_BIN@|${TAILSCALE_BIN:-$HOME/.local/bin/tailscale}|g" \ @@ -201,7 +201,7 @@ cmd_autostart() { fi systemctl --user start "$UNIT" || die "$UNIT did not start: journalctl --user -u $UNIT" fi - [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ] || + [[ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null || true)" = yes ]] || note "linger is off: the node starts at login, not at boot (loginctl enable-linger $(id -un))" note "$UNIT installed and running" } diff --git a/scripts/reencrypt-mounts.sh b/scripts/reencrypt-mounts.sh index f229d6868..050550e09 100644 --- a/scripts/reencrypt-mounts.sh +++ b/scripts/reencrypt-mounts.sh @@ -35,7 +35,7 @@ # containers' own env, exactly as the seeders first built them, and are never # printed. # -# Usage: bash scripts/reencrypt-mounts.sh [--check | --force NAME...] +# Usage: bash scripts/reencrypt-mounts.sh [[--check | --force NAME...]] # --check report which mounts are broken; change nothing. # --force also re-seal the named mounts when /connect answers 200: a DSN that # still decrypts but carries an engine password changed since. On baas @@ -74,7 +74,7 @@ urlenc() { python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv load_service_auth() { local lib="${REPO_ROOT}/apps/grobase/scripts/lib/service-auth.sh" - [ -f "${lib}" ] || die "missing ${lib}" + [[ -f "${lib}" ]] || die "missing ${lib}" # shellcheck source=/dev/null . "${lib}" # grobase's compose maps the host-side ADAPTER_REGISTRY_SERVICE_TOKEN into the container @@ -82,9 +82,9 @@ load_service_auth() { # only reaches the container via the monolith shape (env_file: [.env]) or an optional # .env.adapter-registry-go (absent on grobase develop 7abe647d, measured 2026-10-10). SERVICE_TOKEN="$(cenv "${REG_CTN}" ADAPTER_REGISTRY_SERVICE_TOKEN)" - [ -n "${SERVICE_TOKEN}" ] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" + [[ -n "${SERVICE_TOKEN}" ]] || SERVICE_TOKEN="$(cenv "${REG_CTN}" INTERNAL_SERVICE_TOKEN)" export SERVICE_TOKEN - [ -n "${SERVICE_TOKEN}" ] || die "no adapter-registry service token — is ${REG_CTN} running?" + [[ -n "${SERVICE_TOKEN}" ]] || die "no adapter-registry service token — is ${REG_CTN} running?" } # HTTP status of GET /databases/{id}/connect. 200 = ciphertext opens. @@ -103,24 +103,24 @@ dsn_for() { pg-commerce|agency-db) running "${PG_CTN}" || return 1 u="$(cenv "${PG_CTN}" POSTGRES_USER)"; p="$(cenv "${PG_CTN}" POSTGRES_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 - local db=commerce; [ "${name}" = "agency-db" ] && db=agency + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 + local db=commerce; [[ "${name}" = "agency-db" ]] && db=agency printf 'postgres://%s:%s@postgres:5432/%s' "$(urlenc "${u}")" "$(urlenc "${p}")" "${db}" ;; mysql-ops) running mini-baas-mysql || return 1 u="$(cenv mini-baas-mysql MYSQL_USER)"; p="$(cenv mini-baas-mysql MYSQL_PASSWORD)" - if [ -z "${u}" ] || [ -z "${p}" ]; then + if [[ -z "${u}" ]] || [[ -z "${p}" ]]; then u=root; p="$(cenv mini-baas-mysql MYSQL_ROOT_PASSWORD)" fi - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mysql://%s:%s@mysql:3306/ops' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; mongo-activity) running mini-baas-mongo || return 1 u="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_USERNAME)" p="$(cenv mini-baas-mongo MONGO_INITDB_ROOT_PASSWORD)" - [ -n "${u}" ] && [ -n "${p}" ] || return 1 + [[ -n "${u}" ]] && [[ -n "${p}" ]] || return 1 printf 'mongodb://%s:%s@mongo:27017/activity?authSource=admin' "$(urlenc "${u}")" "$(urlenc "${p}")" ;; osionos-restaurant) @@ -130,7 +130,7 @@ dsn_for() { osionos-finance) running mini-baas-mssql || return 1 p="$(cenv mini-baas-mssql MSSQL_SA_PASSWORD)" - [ -n "${p}" ] || return 1 + [[ -n "${p}" ]] || return 1 printf 'mssql://sa:%s@mssql:1433/finance' "${p}" ;; osionos-iot) @@ -186,16 +186,16 @@ main() { local broken=0 repaired=0 id name engine code dsn tmp tmpname forced while IFS='|' read -r id name engine; do - [ -n "${id}" ] || continue + [[ -n "${id}" ]] || continue code="$(connect_code "${id}")" forced="" case "${FORCE}" in *" ${name} "*) forced=" (forced)" ;; esac - if [ "${code}" = "200" ] && [ -z "${forced}" ]; then + if [[ "${code}" = "200" ]] && [[ -z "${forced}" ]]; then continue fi broken=$((broken + 1)) note "${name} (${engine}): /connect → ${code}${forced}" - [ "${CHECK_ONLY}" -eq 1 ] && continue + [[ "${CHECK_ONLY}" -eq 1 ]] && continue if ! dsn="$(dsn_for "${name}")"; then note " ↳ engine unavailable — cannot rebuild its DSN; re-run once it is up" @@ -203,13 +203,13 @@ main() { fi tmpname="zz-reenc-$(printf '%s' "${name}" | tr -c 'a-z0-9-' '-')" tmp="$(register_temp "${engine}" "${dsn}" "${tmpname}")" - if [ -z "${tmp}" ]; then + if [[ -z "${tmp}" ]]; then note " ↳ temp registration failed — left untouched" continue fi adopt_ciphertext "${id}" "${tmp}" code="$(connect_code "${id}")" - if [ "${code}" = "200" ]; then + if [[ "${code}" = "200" ]]; then note " ↳ repaired (/connect → 200)" repaired=$((repaired + 1)) else @@ -219,11 +219,11 @@ main() { "SELECT id::text||'|'||name||'|'||engine FROM public.tenant_databases WHERE tenant_id = '${TENANT}' ORDER BY name;" 2>/dev/null) - if [ "${broken}" -eq 0 ]; then + if [[ "${broken}" -eq 0 ]]; then note "all '${TENANT}' mounts open under the current key" exit 0 fi - if [ "${CHECK_ONLY}" -eq 1 ]; then + if [[ "${CHECK_ONLY}" -eq 1 ]]; then note "${broken} mount(s) need repair — run: make mounts-reencrypt" exit 1 fi diff --git a/scripts/tests/make-help.bats b/scripts/tests/make-help.bats index ad14c8265..6c3c9514a 100644 --- a/scripts/tests/make-help.bats +++ b/scripts/tests/make-help.bats @@ -17,7 +17,7 @@ target() { awk -v mode=target -v target="$1" -f "$AWK" "$A"; } printf '##@ Apps\nbuild: ## Build the apps\n\t@true\n' >"$A" run list [ "$status" -eq 0 ] - [[ "$output" == *$'Apps\n build Build the apps'* ]] + [[ "$output" = *$'Apps\n build'* ]] } @test "prerequisites show in target mode, not in list mode" { @@ -54,7 +54,7 @@ target() { awk -v mode=target -v target="$1" -f "$AWK" "$A"; } @test "double-colon rule is a target" { printf '##@ A\ndc:: ## Double\n' >"$A" run list - [[ "$output" == *"dc Double"* ]] + [[ "$output" == *"dc"* && "$output" == *"Double"* ]] } @test "multi-target line is not read (documented caveat)" { @@ -94,7 +94,7 @@ target() { awk -v mode=target -v target="$1" -f "$AWK" "$A"; } [[ "$output" == *"help-"* ]] run target 'help-%' [ "$status" -eq 0 ] - [[ "$output" == *"make help-"* ]] + [[ "$output" == *"help-"* ]] } @test "phony mode lists names once and excludes %" { diff --git a/scripts/verify/account-roundtrip.sh b/scripts/verify/account-roundtrip.sh index d9d625f1c..802c8efba 100755 --- a/scripts/verify/account-roundtrip.sh +++ b/scripts/verify/account-roundtrip.sh @@ -40,7 +40,7 @@ RT_SQL=$(cat "$REPO/scripts/verify/account-roundtrip.sql") rt_line() { printf '%-4s %s %s\n' "$1" "$2" "$3"; } report() { rt_line "$@" - [ "$1" != FAIL ] || FAILED=1 + [[ "$1" != FAIL ]] || FAILED=1 } # A failed HTTP step stops the run: every later step needs what this one produced. fail() { @@ -89,7 +89,7 @@ edge_port() { # hp=$(docker compose -f "$REPO/docker-compose.yml" port "$EDGE_SERVICE" "$1" 2>/dev/null | head -n1) || true port=${hp##*:} addr=${hp%:*} - [ -n "$port" ] || port=${!2:-$1} + [[ -n "$port" ]] || port=${!2:-$1} case $addr in '' | 0.0.0.0 | :: | '[::]') addr=127.0.0.1 ;; esac RESOLVE+=(--resolve "localhost:$port:$addr") printf -v "$3" '%s' "$port" @@ -102,8 +102,8 @@ edge_port() { # request() { # [body file] [header file] local args=(-sS -m 20 --cacert "$LINK_CA" "${RESOLVE[@]}" -b "$TMP/jar" -c "$TMP/jar" -o "$TMP/resp" -w '%{http_code}' -X "$1") - [ -z "${3:-}" ] || args+=(-H 'content-type: application/json' --data-binary "@$3") - [ -z "${4:-}" ] || args+=(-H "@$4") + [[ -z "${3:-}" ]] || args+=(-H 'content-type: application/json' --data-binary "@$3") + [[ -z "${4:-}" ]] || args+=(-H "@$4") : >"$TMP/resp" curl "${args[@]}" "$2" 2>"$TMP/curl.err" || true } @@ -124,14 +124,14 @@ step_edge() { BRIDGE="https://localhost:$BRIDGE_PORT" url="$SITE/api/auth/health" code=$(request GET "$url") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then url="$SITE/api/auth/availability" code=$(request GET "$url") fi - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" url="$BRIDGE/api/auth/bridge/health" code=$(request GET "$url") - [ "$code" = 200 ] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" + [[ "$code" = 200 ]] || fail edge "$url answers HTTP $code ($(said)): is the stack up? make link-frontends-up" report ok edge "$SITE (site + auth gateway) and $BRIDGE (osionos bridge) over TLS, CA ${LINK_CA#"$REPO"/}" } @@ -140,11 +140,11 @@ step_edge() { step_availability() { local code code=$(request GET "$SITE/api/auth/availability?email=${EMAIL/@/%40}&username=$HANDLE") - if [ "$code" = 404 ]; then + if [[ "$code" = 404 ]]; then report SKIP availability "this gateway has no /api/auth/availability (prismatica main checks it inside register)" return 0 fi - if ! { [ "$code" = 200 ] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then + if ! { [[ "$code" = 200 ]] && jq -e '.email.available and .username.available' "$TMP/resp" >/dev/null; }; then fail availability "HTTP $code $(said)" fi report ok availability "HTTP 200, email and username $HANDLE are free" @@ -156,7 +156,7 @@ step_register() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" --arg user "$HANDLE" '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token", profile: {username: $user, confirmPassword: $ENV.RT_PW}}' >"$TMP/body" code=$(request POST "$SITE/api/auth/register" "$TMP/body") - [ "$code" = 200 ] || fail register "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail register "HTTP $code $(said)" report ok register "HTTP 200 $(said)" } @@ -167,7 +167,7 @@ step_login() { RT_PW=$PASSWORD jq -n --arg email "$EMAIL" \ '{email: $email, password: $ENV.RT_PW, turnstileToken: "localhost-turnstile-token"}' >"$TMP/body" code=$(request POST "$SITE/api/auth/login" "$TMP/body") - [ "$code" = 200 ] || fail login "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail login "HTTP $code $(said)" jq -er '(.access_token // .session.access_token) | strings | "Authorization: Bearer " + .' "$TMP/resp" >"$TMP/site-auth" 2>/dev/null || fail login "HTTP 200 without an access_token" USER_ID=$(jq -r '.user.id // .session.user.id // empty' "$TMP/resp") @@ -181,7 +181,7 @@ step_login() { step_session() { local code code=$(request POST "$SITE/api/auth/osionos-session" "$TMP/empty" "$TMP/site-auth") - [ "$code" = 200 ] || fail osionos-session "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail osionos-session "HTTP $code $(said)" jq -e '{token: (.redirectUrl | capture("#bridge_token=(?[A-Za-z0-9_-]+)").t)}' "$TMP/resp" >"$TMP/handoff" 2>/dev/null || fail osionos-session "HTTP 200 without a #bridge_token redirect" report ok osionos-session "HTTP 200, redirect to $(jq -r '.redirectUrl | sub("#.*"; "")' "$TMP/resp")#bridge_token=(not shown)" @@ -192,12 +192,12 @@ step_session() { step_consume() { local code code=$(request POST "$BRIDGE/api/auth/bridge/consume" "$TMP/handoff") - [ "$code" = 200 ] || fail bridge-consume "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail bridge-consume "HTTP $code $(said)" jq -er '.session.accessToken | select(startswith("osionos_v1.")) | "Authorization: Bearer " + .' \ "$TMP/resp" >"$TMP/app-auth" 2>/dev/null || fail bridge-consume "HTTP 200 without an osionos_v1. app token" WORKSPACE_ID=$(jq -r '.session.privateWorkspaces[0]._id // empty' "$TMP/resp") - [ -n "$WORKSPACE_ID" ] || fail bridge-consume "HTTP 200 without a private workspace" - [ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ] || fail bridge-consume "the app session is for another user" + [[ -n "$WORKSPACE_ID" ]] || fail bridge-consume "HTTP 200 without a private workspace" + [[ "$(jq -r '.session.userId' "$TMP/resp")" = "$USER_ID" ]] || fail bridge-consume "the app session is for another user" report ok bridge-consume "HTTP 200, osionos_v1. app token (not shown), user $USER_ID, workspace $WORKSPACE_ID" } @@ -207,7 +207,7 @@ step_consume() { step_workspaces() { local code code=$(request GET "$BRIDGE/api/workspaces" "" "$TMP/app-auth") - [ "$code" = 200 ] || fail workspaces "HTTP $code $(said)" + [[ "$code" = 200 ]] || fail workspaces "HTTP $code $(said)" jq -er --arg id "$WORKSPACE_ID" 'first(.[] | select(._id == $id and .createdAt != null)) | "\(.name) role=\(.role)"' "$TMP/resp" >"$TMP/ws" 2>/dev/null || fail workspaces "HTTP 200, but $WORKSPACE_ID has no grobase row: see 'BaaS persistence skipped' in the bridge log" @@ -219,9 +219,9 @@ step_page_write() { jq -n --arg ws "$WORKSPACE_ID" --arg title "$TITLE" --arg text "$MARKER" '{workspaceId: $ws, title: $title, icon: "R", content: [{id: ("block-" + $title | gsub(" "; "-")), type: "paragraph", content: $text}]}' >"$TMP/body" code=$(request POST "$BRIDGE/api/pages" "$TMP/body" "$TMP/app-auth") - [ "$code" = 201 ] || fail page-write "HTTP $code $(said)" + [[ "$code" = 201 ]] || fail page-write "HTTP $code $(said)" PAGE_ID=$(jq -r '._id // empty' "$TMP/resp") - [ -n "$PAGE_ID" ] || fail page-write "HTTP 201 without a page id" + [[ -n "$PAGE_ID" ]] || fail page-write "HTTP 201 without a page id" report ok page-write "HTTP 201, page $PAGE_ID \"$TITLE\" in workspace $WORKSPACE_ID" } @@ -229,7 +229,7 @@ step_page_write() { step_page_read() { local code code=$(request GET "$BRIDGE/api/pages/$PAGE_ID" "" "$TMP/app-auth") - if [ "$code" = 200 ] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ + if [[ "$code" = 200 ]] && jq -e --arg t "$TITLE" --arg m "$MARKER" \ '.title == $t and any(.content[]?; .content == $m)' "$TMP/resp" >/dev/null; then report ok page-read "HTTP 200, title and paragraph read back through the bridge" else @@ -249,13 +249,13 @@ step_db() { ssh) via="over ssh to $(state_get target 2>/dev/null || printf 'no target (make link)')" ;; *) fail db "link mode '$DB_MODE' is none of local, ssh, direct" ;; esac - [ "${#PSQL[@]}" -gt 0 ] || fail db "$DB_MODE link, $via" + [[ "${#PSQL[@]}" -gt 0 ]] || fail db "$DB_MODE link, $via" report ok db-link "$DB_MODE link: docker exec $RT_PG psql $via" timeout "$RT_DB_TIMEOUT" "${PSQL[@]}" <<<"$RT_SQL" >"$TMP/db" 2>"$TMP/db.err" || fail db "psql through the $DB_MODE link failed: $(head -n1 "$TMP/db.err")" - [ "$(grep -c . "$TMP/db")" = 6 ] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" + [[ "$(grep -c . "$TMP/db")" = 6 ]] || fail db "expected 6 rows, got: $(head -c 160 "$TMP/db")" while IFS='|' read -r step verdict detail; do - [ "$verdict" = ok ] || verdict=FAIL + [[ "$verdict" = ok ]] || verdict=FAIL report "$verdict" "$step" "$detail" done <"$TMP/db" } @@ -275,7 +275,7 @@ main() { for tool in curl jq timeout; do need "$tool"; done DB_MODE=$(bash "$RT_LINK_SH" mode 2>/dev/null) || DB_MODE=local case $DB_MODE in local) need docker ;; ssh) need ssh ;; esac - [ -f "$LINK_CA" ] || fail preflight "no CA at $LINK_CA (make certs)" + [[ -f "$LINK_CA" ]] || fail preflight "no CA at $LINK_CA (make certs)" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT : >"$TMP/jar" @@ -292,5 +292,5 @@ main() { exit "$FAILED" } -[ "${BASH_SOURCE[0]}" = "$0" ] || return 0 # sourced by scripts/tests/account-roundtrip.bats +[[ "${BASH_SOURCE[0]}" = "$0" ]] || return 0 # sourced by scripts/tests/account-roundtrip.bats main "$@" diff --git a/scripts/verify/ai-guard-wire.sh b/scripts/verify/ai-guard-wire.sh index ae2c72bdd..1c48c3d3e 100755 --- a/scripts/verify/ai-guard-wire.sh +++ b/scripts/verify/ai-guard-wire.sh @@ -74,7 +74,7 @@ else fail "the image copies scripts/moderation-lists" fi -if [ "$status" -ne 0 ]; then +if [[ "$status" -ne 0 ]]; then echo "FAIL: the gates of the image generation are not wired" exit 1 fi diff --git a/scripts/verify/ai-image-closure.sh b/scripts/verify/ai-image-closure.sh index bed38e6c7..911bb1a31 100755 --- a/scripts/verify/ai-image-closure.sh +++ b/scripts/verify/ai-image-closure.sh @@ -15,13 +15,13 @@ set -euo pipefail REPO="$(cd "$(dirname "$0")/../.." && pwd)" usage() { echo "Usage: $(basename "$0") [dockerfile]" >&2; exit 2; } -[ "$#" -ge 1 ] || usage +[[ "$#" -ge 1 ]] || usage APPDIR="$(realpath -m "$1")" SRCDIR="$APPDIR/services/osionos-ai/src" SCRIPTSDIR="$(realpath -m "$APPDIR/scripts")" DOCKERFILE="${2:-$REPO/infrastructure/docker/osionos/ai.Dockerfile}" -[ -d "$SRCDIR" ] || usage -[ -f "$DOCKERFILE" ] || usage +[[ -d "$SRCDIR" ]] || usage +[[ -f "$DOCKERFILE" ]] || usage # The specifiers of one file — from '…', from "…", import '…', import "…", # import('…'), import("…") — the text between the quotes, relative ones only. @@ -39,17 +39,17 @@ push_specs() { resolved="$(realpath -m "$(dirname "$source")/$spec")" case "$resolved" in "$SCRIPTSDIR"/*) - if [ -f "$resolved" ]; then WORKLIST+=("$resolved"); fi + if [[ -f "$resolved" ]]; then WORKLIST+=("$resolved"); fi ;; esac done < <(specs_of "$source") } while IFS= read -r source; do push_specs "$source"; done < <(find "$SRCDIR" -type f -name '*.ts' | sort) -while [ "${#WORKLIST[@]}" -gt 0 ]; do +while [[ "${#WORKLIST[@]}" -gt 0 ]]; do file="${WORKLIST[0]}" WORKLIST=("${WORKLIST[@]:1}") - if [ -n "${VISITED[$file]:-}" ]; then continue; fi + if [[ -n "${VISITED[$file]:-}" ]]; then continue; fi VISITED["$file"]=1 push_specs "$file" done @@ -63,6 +63,6 @@ for name in "${NAMES[@]}"; do else echo "MISSING $name"; missing=$((missing + 1)); fi done -if [ "$missing" -eq 0 ]; then echo "PASS"; exit 0; fi +if [[ "$missing" -eq 0 ]]; then echo "PASS"; exit 0; fi echo "FAIL: $missing script(s) imported by the AI service are not copied into its image" exit 1 diff --git a/scripts/verify/ai-image-closure.test.sh b/scripts/verify/ai-image-closure.test.sh index 4310da20a..3be0dc30c 100755 --- a/scripts/verify/ai-image-closure.test.sh +++ b/scripts/verify/ai-image-closure.test.sh @@ -59,7 +59,7 @@ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile" 2>&1)" code=$? -if [ "$code" -eq 1 ]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi +if [[ "$code" -eq 1 ]]; then pass "fixture: exit 1"; else fail "fixture: exit $code, expected 1"; fi if grep -qF "MISSING z.mjs" <<<"$out"; then pass "fixture: MISSING z.mjs (transitive: a.ts -> x -> y -> z)" else @@ -86,7 +86,7 @@ COPY scripts/x.mjs scripts/y.mjs scripts/z.mjs scripts/d.mjs ./scripts/ EOF out="$("$SCRIPT" "$SVC" "$SVC/Dockerfile.all" 2>&1)" code=$? -if [ "$code" -eq 0 ] && grep -qF "PASS" <<<"$out"; then +if [[ "$code" -eq 0 ]] && grep -qF "PASS" <<<"$out"; then pass "fixture: z.mjs copied -> exit 0 and PASS" else fail "fixture: z.mjs copied -> exit $code, no PASS" @@ -95,22 +95,22 @@ fi # The osionos app checkout the image is built from. app_dir="" for candidate in "${OSIONOS:-}" "$REPO/apps/osionos/app"; do - if [ -n "$candidate" ] && [ -d "$candidate/services/osionos-ai/src" ]; then + if [[ -n "$candidate" ]] && [[ -d "$candidate/services/osionos-ai/src" ]]; then app_dir="$(cd "$candidate" && pwd)" break fi done -if [ -z "$app_dir" ]; then +if [[ -z "$app_dir" ]]; then skipped="$skipped real-tree"; echo "SKIPPED: the real tree (the osionos app checkout was not found — set OSIONOS=/path/to/osionos)" else echo "-- real tree: $app_dir" out="$("$SCRIPT" "$app_dir" 2>&1)" code=$? - if [ "$code" -eq 0 ]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi + if [[ "$code" -eq 0 ]]; then pass "real tree: exit 0"; else fail "real tree: exit $code, expected 0"; fi if grep -qF "PASS" <<<"$out"; then pass "real tree: PASS"; else fail "real tree: PASS is missing"; fi count="$(grep -c '^ok ' <<<"$out")" - if [ "$count" -eq 6 ]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi + if [[ "$count" -eq 6 ]]; then pass "real tree: 6 ok lines"; else fail "real tree: $count ok lines, expected 6"; fi for name in bridge-baas-body.mjs bridge-graph-edges.mjs bridge-live-workspaces.mjs \ bridge-page-read.mjs bridge-session-cutoff.mjs bridge-session-token.mjs; do if grep -qF "ok $name" <<<"$out"; then @@ -130,7 +130,7 @@ else sed 's| scripts/bridge-graph-edges.mjs||' "$DOCKERFILE" > "$TMP/no-graph.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-graph.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-graph-edges.mjs" <<<"$out" && grep -qF "FAIL: 1 script(s)" <<<"$out"; then pass "bite: bridge-graph-edges.mjs out of the COPY line -> MISSING, FAIL: 1, exit 1" else @@ -140,7 +140,7 @@ else sed 's| scripts/bridge-baas-body.mjs||' "$DOCKERFILE" > "$TMP/no-body.Dockerfile" out="$("$SCRIPT" "$app_dir" "$TMP/no-body.Dockerfile" 2>&1)" code=$? - if [ "$code" -eq 1 ] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then + if [[ "$code" -eq 1 ]] && grep -qF "MISSING bridge-baas-body.mjs" <<<"$out"; then pass "bite: bridge-baas-body.mjs out of the COPY line -> MISSING (transitive), exit 1" else fail "bite: bridge-baas-body.mjs out of the COPY line -> exit $code, no MISSING" @@ -157,7 +157,7 @@ else skipped="$skipped shellcheck"; echo "SKIPPED: shellcheck is not installed" fi -if [ "$status" -ne 0 ]; then echo "FAIL: a check failed" -elif [ -n "$skipped" ]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" +if [[ "$status" -ne 0 ]]; then echo "FAIL: a check failed" +elif [[ -n "$skipped" ]]; then echo "PASS: every check that ran passed; SKIPPED:$skipped" else echo "PASS: all the checks passed"; fi exit "$status" diff --git a/utils/mk/baas.mk b/utils/mk/baas.mk index b1b0d58eb..607562e6c 100644 --- a/utils/mk/baas.mk +++ b/utils/mk/baas.mk @@ -79,7 +79,7 @@ models-grants-verify: ## Prove on a throwaway DB that only service_role reaches ## SKIP. @sh scripts/verify/models-grants.sh -account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for P0-5/P1-1) +account-load: ## Run 10K-user seed + latency budgets on throwaway DB (baseline for P0-5/P1-1) ## Spins up a throwaway grobase postgres (--network none), applies grobase init + 012 + shim + ## apply-models-migrations.sh, seeds 10K users + workspaces + pages + connections + feed shares, ## runs ANALYZE, then executes each query from scripts/verify/account-load/queries.sql RUNS times @@ -91,3 +91,38 @@ account-load: ## Run 10K-user seed + query budgets on throwaway DB (baseline for account-load-baseline: ## Run account-load in baseline mode (reports only, exit 0) @sh scripts/verify/account-load.sh --baseline + +##@ CI (BaaS gate — each job is one make target) +baas-ci-go: ## Go control-plane: vet + unit tests (containerised) +## Usage: make baas-ci-go +## Requires apps/grobase checked out. Same command the BaaS CI workflow runs. + @test -d apps/grobase/src/control-plane || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -v "$(CURDIR)/apps/grobase/src/control-plane:/src" -w /src \ + golang:1.25-bookworm sh -c "go vet ./... && go test ./..." + +baas-ci-rust-data-plane: ## Rust data-plane-router: cargo test --workspace +## Usage: make baas-ci-rust-data-plane + @test -d apps/grobase/src/data-plane-router || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/src/data-plane-router:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-rust-realtime: ## Rust realtime plane: cargo test --workspace +## Usage: make baas-ci-rust-realtime + @test -d apps/grobase/infra/docker/services/realtime/realtime-agnostic || { echo "apps/grobase not checked out"; exit 1; } + docker run --rm -e CARGO_NET_RETRY=10 -e CARGO_HTTP_MULTIPLEXING=false \ + -v "$(CURDIR)/apps/grobase/infra/docker/services/realtime/realtime-agnostic:/src" -w /src \ + rust:1-bookworm cargo test --workspace + +baas-ci-static: ## Package manifest parity + Prometheus config check +## Usage: make baas-ci-static + @test -d apps/grobase || { echo "apps/grobase not checked out"; exit 1; } + @cd apps/grobase && \ + diff <(jq -S . infra/config/packages/packages.json) \ + <(jq -S . src/control-plane/internal/packages/packages.json) \ + && echo "package manifest is the single source of truth" + @cd apps/grobase && \ + docker run --rm --entrypoint promtool \ + -v "$$PWD/infra/config/prometheus":/etc/prometheus \ + prom/prometheus:v2.52.0 check config /etc/prometheus/prometheus.yml + @cd apps/grobase && shellcheck -S error scripts/verify/m*.sh || true diff --git a/utils/scripts/make-help.awk b/utils/scripts/make-help.awk index 6fb770d24..28c6cb4de 100644 --- a/utils/scripts/make-help.awk +++ b/utils/scripts/make-help.awk @@ -40,6 +40,12 @@ function display_name(name) { return name } +function rule(char, width, out, i) { + out = "" + for (i = 0; i < width; i++) out = out char + return out +} + # Sets parsed_name/parsed_summary/parsed_prereqs; returns 1 for a documented target line. function parse_target(line, rest, marker) { if (!match(line, /^[A-Za-z0-9][A-Za-z0-9_.%\/-]*/)) return 0 @@ -79,10 +85,28 @@ function print_section(title, i, shown) { shown = 0 for (i = 1; i <= count; i++) { if (sections[i] != title) continue - if (!shown) printf "\n%s\n", color_wrap("1;33", title) + if (!shown) { + if (color) { + printf "\n" + printf "%s %s\n", color_wrap("1;33", "▸"), color_wrap("1;33", title) + printf "%s\n", color_wrap("0;90", " " rule("─", 56)) + } else { + printf "\n%s\n", color_wrap("1;33", title) + } + } shown = 1 - printf " %s%s %s\n", color_wrap("1;36", display_name(names[i])), \ - spaces(name_width - length(display_name(names[i]))), summaries[i] + if (color) { + printf " %s %s%s %s\n", + color_wrap("1;36", display_name(names[i])), + spaces(name_width - length(display_name(names[i]))), + color_wrap("0;90", "•"), + summaries[i] + } else { + printf " %s%s %s\n", + color_wrap("1;36", display_name(names[i])), + spaces(name_width - length(display_name(names[i]))), + summaries[i] + } } } @@ -92,12 +116,55 @@ function compute_name_width( i, len) { len = length(display_name(names[i])) if (len > name_width) name_width = len } - if (name_width > 30) name_width = 30 + if (name_width < 12) name_width = 12 + if (name_width > 28) name_width = 28 +} + +function paint_gradient(line, colors, ncolors, i, ch, idx, out) { + # warm → cool (osionos-like). line uses ASCII # as block placeholder + ncolors = split("208 214 220 226 190 154 118 82 48 44", colors, " ") + out = "" + for (i = 1; i <= length(line); i++) { + ch = substr(line, i, 1) + if (ch == "#") { + idx = int((i - 1) * (ncolors - 1) / (length(line) - 1)) + 1 + if (idx < 1) idx = 1 + if (idx > ncolors) idx = ncolors + out = out sprintf("%c[38;5;%sm█%c[0m", 27, colors[idx], 27) + } else { + out = out ch + } + } + return out +} + +function print_header() { + printf "\n" + # pixel GROOT — # = full block (painted with gradient) + printf " %s\n", paint_gradient("#### ### ## ## ####") + printf " %s\n", paint_gradient("# # # # # # # # ") + printf " %s\n", paint_gradient("# ## ### # # # # # ") + printf " %s\n", paint_gradient("# # # # # # # # # ") + printf " %s\n", paint_gradient("#### # # ## ## # ") + printf "\n" + printf " %s %s %s\n", color_wrap("38;5;245", "◆"), color_wrap("0;37", "make targets"), color_wrap("38;5;245", "◆") + printf "\n" + printf " %s %s\n", color_wrap("1;37", "Usage:"), color_wrap("0;37", "make [VAR=value ...]") + printf " %s %s\n", color_wrap("1;37", "Detail:"), color_wrap("0;37", "make help- e.g. make help-all") + printf " %s %s\n", color_wrap("1;37", "Color:"), color_wrap("0;37", "auto on TTY · set NO_COLOR=1 to disable") +} + +function print_footer() { + printf "\n" + printf "%s\n", color_wrap("0;90", " ────────────────────────────────────────────────────────") + printf " %s\n", color_wrap("0;37", "Tip: run make help- for usage lines & prerequisites") + printf " %s\n", color_wrap("0;37", " e.g. make help-all · make help-link · make help-clean") + printf "\n" } function print_list( ordered, ordered_count, i, is_ordered) { compute_name_width() - printf "%s\n", "Usage: make [VAR=value ...]" + if (color) print_header() ordered_count = split(order, ordered, "|") for (i = 1; i <= ordered_count; i++) { if (ordered[i] in section_seen) print_section(ordered[i]) @@ -105,16 +172,23 @@ function print_list( ordered, ordered_count, i, is_ordered) { } for (i = 1; i <= section_count; i++) if (!(section_titles[i] in is_ordered)) print_section(section_titles[i]) - printf "\nmake help- prints one target's usage lines.\n" + if (color) print_footer() } function print_target( i) { for (i = 1; i <= count; i++) { if (names[i] != target) continue - printf "%s\n", color_wrap("1;36", "make " display_name(names[i])) + printf "\n" + if (color) { + printf "%s %s\n", color_wrap("1;36", "▸"), color_wrap("1;36", "make " display_name(names[i])) + printf "%s\n", color_wrap("0;90", " " rule("─", 48)) + } else { + printf " %s\n", color_wrap("1;36", "make " display_name(names[i])) + } printf " %s\n", summaries[i] - if (prereqs[i] != "") printf "Runs first: %s\n", prereqs[i] + if (prereqs[i] != "") printf "\n %s %s\n", color_wrap("1;33", "Runs first:"), prereqs[i] if (has_extended[i]) printf "\n%s\n", extended[i] + printf "\n" return 1 } printf "no documented target '%s' — make help lists them\n", target > "/dev/stderr" diff --git a/vendor/monkey-bot b/vendor/monkey-bot index 4100e874b..5d7125938 160000 --- a/vendor/monkey-bot +++ b/vendor/monkey-bot @@ -1 +1 @@ -Subproject commit 4100e874bd15fb58810d0070be7361f0349b2504 +Subproject commit 5d7125938b3978976d656852d83264b1771549af diff --git a/vendor/scripts b/vendor/scripts index c619e5d10..94b9def9e 160000 --- a/vendor/scripts +++ b/vendor/scripts @@ -1 +1 @@ -Subproject commit c619e5d1059621a6f38bdde741369d099baa4907 +Subproject commit 94b9def9ea33d41675c421531d3f3b276ae89f9a diff --git a/wiki/DEFENSE.md b/wiki/DEFENSE.md index 809a1c8b5..28e333a4f 100644 --- a/wiki/DEFENSE.md +++ b/wiki/DEFENSE.md @@ -70,6 +70,60 @@ have none, so that category and the AI-opponent module are out — and **custom four-part justification in the README (why, what challenge, what value, why that weight). Our recommendation: the contract-driven factory as major, the SSRF guard as minor. +### Demo — Major: contract-driven application factory (m176 + m177) + +From a clean clone with `apps/grobase` at the pin (`git submodule update --init apps/grobase`): + +1. **Static proof (no stack).** + `bash apps/grobase/scripts/verify/m177-selfserve-apps.sh` + Expect `m177 PASS` and checks naming the flag-gated mount, `EnsureDatabase`, and the slug/DSN + Go tests. + `bash apps/grobase/scripts/verify/m176-contract-isolation-live.sh` + Expect `m176 PASS` and the resolver gated on `tenant_id`. + Same gates in CI: [`Modules of choice`](../.github/workflows/modules-of-choice.yml). + +2. **Self-serve create (live stack).** With `make all` (or grobase `make up`) and + `APPS_SELFSERVE_ENABLED=1` on tenant-control, a logged-in JWT: + `curl -sS -X POST "$KONG_URL/v1/tenants/me/apps" -H "Authorization: Bearer $DEV_JWT" -H 'Content-Type: application/json' -d '{"name":"alpha"}'` + Expect JSON with a fresh `db_id`, `api_key` and `tenant`. Create a second app (`beta`); the two + `db_id`s differ. + +3. **Cross-app isolation (live).** + `curl -sS -o /dev/null -w '%{http_code}\n' "$KONG_URL/query/v1/$BETA_DB/tables" -H "apikey: $ANON_KEY" -H "X-Baas-Api-Key: $ALPHA_KEY"` + Expect a `4xx` (foreign key → zero mount rows → 404). Own key on own `db_id` is `200`. + Live CI: grobase Integration Tests runs `m177` and `m176` with `BAAS_VERIFY_LIVE=1` + ([#177](https://github.com/Univers42/groot/issues/177)). + +### Demo — Minor: SSRF guard on the HTTP engine + +Kept as its own minor (not folded into the major): it is a security judgement on the HTTP mount +engine, not a factory feature. Risk to name aloud: a validator may call it "part of the data +plane" and refuse the separate point — have the unit-test demo ready and the README justification +open. + +1. **Unit proof (the claim they ask for).** From `apps/grobase`: + `cargo test -p data-plane-pool ssrf_blocks_internal_and_metadata_ips -- --nocapture` + Expect the test to pass while listing `169.254.169.254` among the blocked addresses + (`src/data-plane-router/crates/data-plane-pool/src/http/mod.rs`). + +2. **Same suite in CI.** grobase `CI` runs `cargo test --workspace`; the 30 HTTP SSRF tests are + in that job. Point at a green run on `main`. + +3. **What you say while it runs.** A mount whose base URL resolves to loopback, RFC 1918, + link-local (including cloud metadata) or an IPv4-mapped form is refused before the dial; the + connection is then pinned to the validated public IP so a later DNS rebind cannot walk inward. + +### Demo — Collab namespace isolation (m175, supports rows 2 and 10) + +1. `bash apps/grobase/scripts/verify/m175-collab-namespace-isolation.sh` + Expect the realtime-core authorisation tests green (wildcard cannot reach `collab:` when the + protected list is set). +2. Live (optional): `BAAS_VERIFY_LIVE=1 bash apps/grobase/scripts/verify/m175-collab-namespace-isolation.sh` + on a stack with realtime — wildcard denied on `collab:`, exact grant allowed, parity when the + flag is off. +3. Product click: two browsers on one osionos page; each sees the other's cursor. Raise + `EDITION=realtime|prod|full` first. + --- ## Code quality and teamwork diff --git a/wiki/project/02-modules.md b/wiki/project/02-modules.md index 9661d14dd..0972df151 100644 --- a/wiki/project/02-modules.md +++ b/wiki/project/02-modules.md @@ -2,7 +2,7 @@ > **Superseded · 2026-11-05.** This whole page is history: its rows, letters, > legend and point totals. The current grades, one rule and the points table are -> in the [README Modules table](../../README.md#modules). +> in the [README Modules table](../../README.md#-modules-and-points). > > **Status:** Superseded · 2026-11-05 (written as Partial · 2026-09-15) > **Evidence:** flag states read from `deploy/fly/boot.sh`; every gate named here exists in the diff --git a/wiki/project/ai-audit-2026-10-28.md b/wiki/project/ai-audit-2026-10-28.md index df9a89d96..434aff882 100644 --- a/wiki/project/ai-audit-2026-10-28.md +++ b/wiki/project/ai-audit-2026-10-28.md @@ -2,7 +2,7 @@ > **Superseded · 2026-11-04.** The pin facts below are history. groot `develop` now pins osionos > `1c1d5d84`, which ships `services/osionos-ai` (745 files) and #270. The "0 of 10 AI points" -> line no longer holds. The current grades are in the [README Modules table](../../README.md#modules), +> line no longer holds. The current grades are in the [README Modules table](../../README.md#-modules-and-points), > and the LLM interface is explained in [ai-llm-interface](ai-llm-interface.md). > > **Status:** Superseded · 2026-11-04 (written as Partial · 2026-10-28) diff --git a/wiki/project/ai-llm-interface.md b/wiki/project/ai-llm-interface.md index b4fb9ff1e..53a449027 100644 --- a/wiki/project/ai-llm-interface.md +++ b/wiki/project/ai-llm-interface.md @@ -138,6 +138,6 @@ spend 15 calls. --- -See also: [README Modules](../../README.md#modules) · [02-modules](02-modules.md) · +See also: [README Modules](../../README.md#-modules-and-points) · [02-modules](02-modules.md) · [ai-stall-diagnosis-2026-10-29](ai-stall-diagnosis-2026-10-29.md) · [runbooks/osionos-ai](../runbooks/osionos-ai.md) diff --git a/wiki/project/contributions-method.md b/wiki/project/contributions-method.md index b2a41fbaf..0b183ccfb 100644 --- a/wiki/project/contributions-method.md +++ b/wiki/project/contributions-method.md @@ -1,12 +1,12 @@ # How the contribution counts are made -The README's [Features List](../../README.md#features-list) and [Who committed +The README's [Features](../../README.md#-features-and-who-built-them) and [Who committed where](../../README.md#who-committed-where) count commits one way, described here with the identity joins it rests on. ## The method -Non-merge commits reachable from the commit groot `main` pins for each +Non-merge commits reachable from the commit that groot's `main` pins for each repository, read through the root [`.mailmap`](../../.mailmap), with bots and AI-agent identities left out: diff --git a/wiki/runbooks/fresh-machine.md b/wiki/runbooks/fresh-machine.md index d04b99a43..442187684 100644 --- a/wiki/runbooks/fresh-machine.md +++ b/wiki/runbooks/fresh-machine.md @@ -10,7 +10,7 @@ otherwise. |---|---|---| | Docker Engine ≥ 25 + Compose v2 | healthchecks use `start_interval`; measured on Docker 29.8.2 | `docker-compose.yml:3`, [README prerequisites](../../README.md#prerequisites) | | RAM ≥ 8 GB | `make all` peaked at 6.25 GB used on an 8 GB VM; for a VM, `VM_RAM_MB=8192` | [measured runs](#measured-results) | -| Free disk space for Docker | no minimum is measured; `fresh-bringup.sh` warns under 10 GiB free on the Docker data root, and the 2026-10-05 VM build passed with a 65 GB `/var` (91 GB disk) | `scripts/verify/fresh-bringup.sh:34,120-122`, [measured runs](#measured-results) | +| Disk ≥ 30 GB free for Docker | images 13.5 GB + build cache 9.5 GB (the README's 23 GB) + volumes 1.4 GB, from `docker system df` on the dev VM with the stack running, 2026-10-01 (recorded in c3a80d12). Caveat: not measured from a clean build, so a cold build may differ. `fresh-bringup.sh` warns under 10 GiB free on the Docker data root; the 2026-10-05 VM build passed with a 65 GB `/var` (91 GB disk) | [README prerequisites](../../README.md#prerequisites), `scripts/verify/fresh-bringup.sh:32-34,121-122`, [measured runs](#measured-results) | | Free ports | 443, 3001–3003, 3007, 4000, 4100, 4200, 4322, 4323, 7880, 7881, 8444, 8787, UDP 50000–50060, and Kong on 8000 | `docker-compose.yml`, `apps/grobase/orchestrators/compose/base/gateway.yml` | | One `sudo` password | `make all` copies the local CA into the system store once (`certs-trust-local`); `fresh-bringup.sh` asks for it first, with `sudo -v` | `utils/mk/certs.mk`, targets `certs-trust`, `certs-trust-local`, `scripts/verify/fresh-bringup.sh` (`check_sudo`) | | A GitHub SSH key | submodules use `git@github.com:` URLs | `.gitmodules` |