From 989be916ffdb2188274ad329534fa083d0f50565 Mon Sep 17 00:00:00 2001 From: Michael Tarassov Date: Sun, 23 Aug 2026 14:59:08 +0500 Subject: [PATCH] feat(init): --with-orgs and --minimal options; fix self-verification on pristine master (#57) --- CLAUDE.md | 10 +- README.md | 4 + REMOVING-THE-DEMO.md | 43 +++- docker/.env.everything | 2 + docs/CONFIG.md | 6 +- docs/INDEX.md | 5 +- docs/ORGS.md | 5 + frontend/nginx.conf | 4 +- frontend/src/lib/api/queryKeys.ts | 2 + frontend/src/pages/admin/Dashboard.tsx | 2 + frontend/src/routes/manifest.tsx | 6 +- helm/cpp-api/templates/deployment.yaml | 8 + helm/cpp-api/templates/secret.yaml | 2 + helm/cpp-api/values.yaml | 6 +- helm/cpp-env/templates/_helpers.tpl | 2 +- helm/cpp-env/values-demo.yaml | 2 + helm/cpp-frontend/templates/configmap.yaml | 4 +- scripts/add-orgs.sh | 78 ++++-- scripts/init-project.sh | 61 ++++- scripts/remove-content-module.sh | 277 +++++++++++++++++++++ src/api/AdminBillingController.hpp | 4 +- src/api/BillingController.hpp | 6 +- src/api/Guards.hpp | 2 + src/api/Middleware.cpp | 12 +- src/billing/Billing.hpp | 2 +- src/core/Core.hpp | 2 +- src/core/Modules.hpp | 6 +- src/utils/Strings.hpp | 23 +- tests/e2e/test_http_e2e.cpp | 6 + tests/unit/test_config.cpp | 2 + 30 files changed, 531 insertions(+), 63 deletions(-) create mode 100755 scripts/remove-content-module.sh diff --git a/CLAUDE.md b/CLAUDE.md index 8d2ace6..69d9028 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,15 @@ map of all documentation; `docs/CONVENTIONS.md` is the pattern reference. orgs kit below). - Multi-tenancy starter (one-shot, NOT a runtime flag): `./scripts/add-orgs.sh` — installs src/tenancy/, org guards, orgs API + migration + tests - (docs/ORGS.md). + (docs/ORGS.md). Also available at bootstrap: `init-project.sh --with-orgs`. +- Fork bootstrap: `./scripts/init-project.sh [registry] [domain]` — + `--no-demo` strips reference material; `--minimal` additionally runs + `./scripts/remove-content-module.sh` (one-shot removal of the content + module — posts/uploads/sitemap — via `init-project:content:*` marker + blocks + pattern edits, gates stay green; REMOVING-THE-DEMO.md). When + cutting content-module code into a shared file, keep it inside those + markers. init-project's self-verification skips docs/superpowers/ + (historical archives keep pre-rename tokens verbatim). - Single endpoint: `./scripts/new-endpoint.sh FooController Get /api/v1/foo [--with-test] [--patch-openapi]` - Background job: `./scripts/new-job.sh ` diff --git a/README.md b/README.md index 968fe44..6e6473f 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,10 @@ make warm-cache # optional: prime the vcpkg dependency layer (~30 min -> ~3) # Rename template identity (project name, image registry, helm charts, etc.) ./scripts/init-project.sh my-service docker.io/myorg +# --no-demo also strip the flask-base reference material + README demo block +# --minimal --no-demo + remove the content module (posts/uploads/sitemap +# — the worked example); all sync gates stay green (REMOVING-THE-DEMO.md) +# --with-orgs install the multi-tenancy starter kit after the rename (docs/ORGS.md) # Build + run Postgres + Redis + the API, wait for ready, hit /healthz make quickstart diff --git a/REMOVING-THE-DEMO.md b/REMOVING-THE-DEMO.md index d65ccfa..059d264 100644 --- a/REMOVING-THE-DEMO.md +++ b/REMOVING-THE-DEMO.md @@ -10,12 +10,49 @@ what is reference-only (safe to delete) and what is the actual application (keep # Strips the reference material as part of initialising your fork: ./scripts/init-project.sh --no-demo my-service docker.io/myorg example.org -# …or remove it by hand at any time: +# --no-demo PLUS removal of the content module (posts/uploads/sitemap): +./scripts/init-project.sh --minimal my-service docker.io/myorg example.org + +# …or remove the reference material by hand at any time: rm -rf _reference docs/PATTERNS-FROM-FLASK-BASE.md + +# …and the content module alone, scripted (what --minimal runs internally): +./scripts/remove-content-module.sh ``` -Both do the same thing; `--no-demo` also scrubs the now-dangling doc links -and strips the README "Live demo" block (demo URL + public demo credentials). +`--no-demo` also scrubs the now-dangling doc links and strips the README +"Live demo" block (demo URL + public demo credentials). + +## The content module (`--minimal` / `remove-content-module.sh`) + +The posts/uploads/sitemap feature set (PR #11) is the template's **worked +example** of a full feature module — real code, but demo-weight for a fork +that doesn't publish articles. `scripts/remove-content-module.sh` deletes it +whole while keeping every sync gate green by construction: + +- **Deleted:** `PostsController` / `UploadController` / `ContentPagesController` + (+ `.cpp` bodies), `Domain::Post`, `PostRepository`, `src/storage/`, + `migrations/006_add_posts.sql`, its unit/integration/e2e tests, the admin + SPA pages (`Posts.tsx`, `Media.tsx`). +- **Patched in lock-step with the gates:** `Endpoints.hpp` rows + + `docs/openapi.yaml` blocks (route triple-sync), `config.json`/`sample` + + `docs/CONFIG.md` + helm env/ConfigMap wiring (config-sync), both nginx + configs (frontend-nginx-sync), `docs/module-deps.txt` (module DAG), the + `public_paths` defaults in `Utils::Strings` and `config.json`. +- **Regenerated:** `frontend/src/lib/api/schema.gen.ts` from the shrunk spec + (CI fails on a stale copy; needs `npm` — the script tells you if it + couldn't). + +Mechanics: shared files carry `init-project:content:start` / `…:end` marker +comments; the script strips those blocks, deletes the module's own files and +pattern-edits the JSON/YAML that can't carry markers. It is one-shot and +refuses to run twice. + +Known cosmetic leftovers (deliberate): migration numbering keeps a gap at +006 (the runner sorts, contiguity is not required); narrative docs +(`docs/EXAMPLES.md`, `docs/CONVENTIONS.md`, ADRs, `docs/superpowers/` +archives) and a few comments citing `S3Storage` as a design precedent still +mention the module as history. ## What is reference-only (safe to delete) diff --git a/docker/.env.everything b/docker/.env.everything index 7290c72..ed28079 100644 --- a/docker/.env.everything +++ b/docker/.env.everything @@ -22,9 +22,11 @@ JOBS_ENABLED=true JOBS_RESULT_TTL=86400 JOBS_MAX_RETRIES=3 +# init-project:content:start # Content module (posts/uploads/sitemap) — api-side only, same on/off pattern # as Jobs above. CONTENT_ENABLED=true +# init-project:content:end # Tracing via the Jaeger OTLP collector from the with-monitoring profile OTLP_ENDPOINT=http://jaeger:4318/v1/traces diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 0a62011..7c98550 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -104,6 +104,7 @@ Set `CONFIG_FILE` to point at a different JSON file (e.g. | `DOCS_ENABLED` | `docs.enabled` | bool | `false` | Mount `/api/v1/docs` + `/api/v1/openapi.yaml` — dev only | | `DOCS_OPENAPI_PATH` | `docs.openapi_path` | string | `docs/openapi.yaml` | Path served at `/api/v1/openapi.yaml` | + ## Object storage `Storage::get()` is a get/put/remove seam (`src/storage/Storage.hpp`). Two @@ -122,6 +123,7 @@ else fails fast at boot. Swap in another store by subclassing `StorageBackend`. | `S3_SECRET_KEY` | `storage.s3.secret_key` | string | — | | | `S3_TIMEOUT_SEC` | `storage.s3.timeout_sec` | int | `10` | Per-request budget | | `S3_CONNECT_TIMEOUT_SEC` | `storage.s3.connect_timeout_sec` | int | `2` | Connect budget | + ## Observability @@ -214,6 +216,7 @@ For URL components: `REDIS_HOST`, `REDIS_PORT`. | `OUTBOX_DRAIN_INTERVAL_SEC` | `outbox.drain_interval_sec` | int | `0` | Transactional outbox (`src/jobs/Outbox.hpp`): how often the API pod relays `outbox` table rows to the job queue. `0` (default) disables draining — the pattern is opt-in; rows written via `Outbox::enqueue` sit in Postgres until a deploy enables this. Needs `jobs.enabled=true`. | | `DB_REPLICA_LAG_METRIC_REFRESH_SEC` | `database.replica_lag_metric_refresh_sec` | int | `15` | Refresh interval for the `db_replica_lag_seconds` gauge. Only registered when read replicas are configured (primary has no replay timestamp). | + ## Content | Env | JSON key | Type | Default | Notes | @@ -229,12 +232,13 @@ readers get 401/404 on routes the code otherwise treats as public. See without these and intentionally ships with content still gated off. With the additive `API_PUBLIC_PATHS_EXTRA` key this footgun is avoidable: keep the override minimal (or unset) and add module paths through the extra key. + ## Billing module | Env | JSON key | Type | Default | Notes | |---|---|---|---|---| -| `BILLING_ENABLED` | `billing.enabled` | bool | `false` | Master switch for the billing module (`Core::billing_enabled()`) — same on/off pattern as `CONTENT_ENABLED`; routes stay registered, handlers 404 while off. `Billing::initialize()` (called from `Core::initialize()`) throws at boot if this is `true` and `client_id`/`client_secret`/`webhook_id` are empty. | +| `BILLING_ENABLED` | `billing.enabled` | bool | `false` | Master switch for the billing module (`Core::billing_enabled()`) — same on/off pattern as `JOBS_ENABLED`; routes stay registered, handlers 404 while off. `Billing::initialize()` (called from `Core::initialize()`) throws at boot if this is `true` and `client_id`/`client_secret`/`webhook_id` are empty. | | — | `billing.provider` | string | `paypal` | Only provider supported today | | `BILLING_CURRENCY` | `billing.currency` | string | `USD` | ISO 4217; must be a 2-decimal currency (the cents parser rejects others) | | `BILLING_CREDITS_PER_UNIT` | `billing.credits_per_unit` | int | `100` | Credits minted per currency unit (100 cents) captured. Config default only — the live value is the `billing_settings` row (migration 008), editable at runtime by the admin API | diff --git a/docs/INDEX.md b/docs/INDEX.md index fbd0016..301b8c4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -12,7 +12,7 @@ question instead of grepping the tree. | [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | Pre-commit setup, dev workflow, commit-message convention, release flow | | [`../SECURITY.md`](../SECURITY.md) | Disclosure policy + production-hardening checklist | | [`../CHANGELOG.md`](../CHANGELOG.md) | Versioned change log (semver; authored via `changelog.d/` fragments — parallel-safe, see [`../changelog.d/README.md`](../changelog.d/README.md) — or directly under `## [Unreleased]`) | -| [`../REMOVING-THE-DEMO.md`](../REMOVING-THE-DEMO.md) | What's reference-only (flask-base) vs the real app, and how to strip it (`init-project.sh --no-demo`) | +| [`../REMOVING-THE-DEMO.md`](../REMOVING-THE-DEMO.md) | What's reference-only (flask-base) vs the real app, and how to strip it (`init-project.sh --no-demo` / `--minimal`, `remove-content-module.sh`) | ## Worked examples & deep-dives @@ -92,7 +92,8 @@ question instead of grepping the tree. | Script | Purpose | |---|---| -| `init-project.sh` | One-shot rename of template identity (project name, registry, helm charts) | +| `init-project.sh` | One-shot rename of template identity (project name, registry, helm charts); `--no-demo` / `--minimal` strip demo weight, `--with-orgs` chains `add-orgs.sh` | +| `remove-content-module.sh` | ONE-SHOT removal of the content module (posts/uploads/sitemap) with all sync gates kept green — see REMOVING-THE-DEMO.md | | `new-resource.sh` | Scaffold a FULL CRUD resource (domain + repository + controller + registry + openapi + test) per docs/CONVENTIONS.md; `--owned` per-user, `--org-scoped` per-tenant | | `add-orgs.sh` | ONE-SHOT installer of the multi-tenancy starter kit (src/tenancy/, org guards, orgs API, migration, tests) — see docs/ORGS.md | | `new-endpoint.sh` | Scaffold a single controller + registry row + optional test + optional OpenAPI patch | diff --git a/docs/ORGS.md b/docs/ORGS.md index 0bd5078..4ffd995 100644 --- a/docs/ORGS.md +++ b/docs/ORGS.md @@ -6,6 +6,11 @@ carries `org_id NOT NULL`, every read is scoped by it, and a "half-disabled" mode would be an illusion of isolation. If your fork needs tenants, run the script once and own the generated code; if it doesn't, don't install it. +A fresh fork that already knows it needs tenants can install the kit as part +of bootstrap: `./scripts/init-project.sh --with-orgs …` runs `add-orgs.sh` +right after the rename (the patch anchors are name-independent, so before / +after the rename makes no difference; it composes with `--minimal` too). + What lands: `src/tenancy/*` (domain, repositories, `OrgContext`, `OrgCrudBase`, permission matrix), `src/api/OrganizationsController.hpp` (org CRUD, member management, `/switch`), org guards in `src/api/Guards.hpp`, diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6f670f3..1b29ca1 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -43,7 +43,7 @@ server { # API proxy. `app` is the docker-compose service name; the same # network is shared. location /api/ { - # Media uploads accept up to 5 MB (UploadController cap); the nginx + # Media uploads accept up to 5 MB (the app's upload cap); the nginx # default is 1m, which 413s a normal photo before it reaches the # backend. 6m leaves headroom for multipart overhead. client_max_body_size 6m; @@ -57,6 +57,7 @@ server { proxy_connect_timeout 5s; } + # init-project:content:start # Content module (content.enabled): markdown posts, sitemap and uploads are # served by the backend, not the SPA — proxy them like /api/. With the # module off the backend answers 404 and nothing else changes. @@ -85,4 +86,5 @@ server { proxy_read_timeout 30s; proxy_connect_timeout 5s; } + # init-project:content:end } diff --git a/frontend/src/lib/api/queryKeys.ts b/frontend/src/lib/api/queryKeys.ts index 1a2aabb..6ce667c 100644 --- a/frontend/src/lib/api/queryKeys.ts +++ b/frontend/src/lib/api/queryKeys.ts @@ -21,6 +21,7 @@ export const qk = { page === undefined ? (['admin', 'users'] as const) : (['admin', 'users', page] as const), user: (id: string) => ['admin', 'user', id] as const, roles: () => ['admin', 'roles'] as const, + // init-project:content:start /** * Posts list. `filter` is the serialised active filter (q/status) — a * changed filter is a fresh cache entry; the bare prefix still matches @@ -29,6 +30,7 @@ export const qk = { posts: (filter?: string) => filter === undefined ? (['admin', 'posts'] as const) : (['admin', 'posts', filter] as const), media: () => ['admin', 'media'] as const, + // init-project:content:end jobs: (filter?: string, page?: number) => { if (filter === undefined) return ['admin', 'jobs'] as const; if (page === undefined) return ['admin', 'jobs', filter] as const; diff --git a/frontend/src/pages/admin/Dashboard.tsx b/frontend/src/pages/admin/Dashboard.tsx index 94f1ef7..8ffeea8 100644 --- a/frontend/src/pages/admin/Dashboard.tsx +++ b/frontend/src/pages/admin/Dashboard.tsx @@ -60,6 +60,7 @@ export function AdminDashboardPage() { + {/* init-project:content:start */} @@ -78,6 +79,7 @@ export function AdminDashboardPage() { + {/* init-project:content:end */} diff --git a/frontend/src/routes/manifest.tsx b/frontend/src/routes/manifest.tsx index a9a5724..66b88c5 100644 --- a/frontend/src/routes/manifest.tsx +++ b/frontend/src/routes/manifest.tsx @@ -47,12 +47,14 @@ const AdminJobsPage = lazy(() => const AdminAuditPage = lazy(() => import('@/pages/admin/Audit').then((m) => ({ default: m.AdminAuditPage })), ); +// init-project:content:start const AdminPostsPage = lazy(() => import('@/pages/admin/Posts').then((m) => ({ default: m.AdminPostsPage })), ); const AdminMediaPage = lazy(() => import('@/pages/admin/Media').then((m) => ({ default: m.AdminMediaPage })), ); +// init-project:content:end const AdminBillingPage = lazy(() => import('@/pages/admin/Billing').then((m) => ({ default: m.AdminBillingPage })), ); @@ -166,9 +168,11 @@ export const routes: RouteEntry[] = [ navLabel: 'Audit', navIcon: ScrollText, }, + // init-project:content:start { path: '/admin/posts', element: , guard: 'admin' }, { path: '/admin/media', element: , guard: 'admin' }, - // No navLabel — tiles-only like Posts/Media (the /admin dashboard tile is + // init-project:content:end + // No navLabel — tiles-only (the /admin dashboard tile is // the entry point, not the top nav). { path: '/admin/billing', element: , guard: 'admin' }, ]; diff --git a/helm/cpp-api/templates/deployment.yaml b/helm/cpp-api/templates/deployment.yaml index 36dfcf3..3cd4ac4 100644 --- a/helm/cpp-api/templates/deployment.yaml +++ b/helm/cpp-api/templates/deployment.yaml @@ -193,9 +193,11 @@ spec: value: {{ .Values.jobs.resultTtl | quote }} - name: JOBS_MAX_RETRIES value: {{ .Values.jobs.maxRetries | quote }} + # init-project:content:start # Content module (posts/uploads/sitemap) — api-side only - name: CONTENT_ENABLED value: {{ .Values.content.enabled | quote }} + # init-project:content:end # billing module — master switch plus the non-secret # billing.paypal.* config, matching how the app reads them # (see src/billing/PayPalClient.hpp). The client secret is the @@ -350,6 +352,7 @@ spec: name: {{ include "cpp-api.fullname" . }} key: mail-smtp-password {{- end }} + # init-project:content:start {{- if .Values.storage.s3.secretKey }} - name: S3_SECRET_KEY valueFrom: @@ -357,6 +360,7 @@ spec: name: {{ include "cpp-api.fullname" . }} key: s3-secret-key {{- end }} + # init-project:content:end - name: MAIL_SMTP_USE_TLS value: {{ .Values.mail.smtpUseTls | quote }} - name: MAIL_FROM @@ -381,8 +385,10 @@ spec: mountPath: /app/logs - name: tmp mountPath: /tmp + # init-project:content:start - name: uploads mountPath: /app/uploads + # init-project:content:end volumes: - name: config configMap: @@ -391,8 +397,10 @@ spec: emptyDir: {} - name: tmp emptyDir: {} + # init-project:content:start - name: uploads emptyDir: {} + # init-project:content:end {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/helm/cpp-api/templates/secret.yaml b/helm/cpp-api/templates/secret.yaml index 4c69279..7440d81 100644 --- a/helm/cpp-api/templates/secret.yaml +++ b/helm/cpp-api/templates/secret.yaml @@ -24,9 +24,11 @@ data: {{- if and .Values.mail .Values.mail.smtpPassword }} mail-smtp-password: {{ .Values.mail.smtpPassword | b64enc | quote }} {{- end }} + # init-project:content:start {{- if and .Values.storage .Values.storage.s3.secretKey }} s3-secret-key: {{ .Values.storage.s3.secretKey | b64enc | quote }} {{- end }} + # init-project:content:end {{- if and .Values.billing .Values.billing.paypal.clientSecret }} paypal-client-secret: {{ .Values.billing.paypal.clientSecret | b64enc | quote }} {{- end }} diff --git a/helm/cpp-api/values.yaml b/helm/cpp-api/values.yaml index b829c45..33649ef 100644 --- a/helm/cpp-api/values.yaml +++ b/helm/cpp-api/values.yaml @@ -238,6 +238,7 @@ jobs: resultTtl: 86400 maxRetries: 3 +# init-project:content:start # --------------------------------------------------------------- # Content module (posts/uploads/sitemap) — disabled by default, same on/off # pattern as Jobs above. API-side only, no cpp-worker equivalent. @@ -252,9 +253,10 @@ jobs: content: enabled: false +# init-project:content:end # --------------------------------------------------------------- # billing module — generated by scripts/new-module.sh; same on/off -# pattern as Content above. Env vars set: BILLING_ENABLED, PAYPAL_ENV, +# pattern as Jobs above. Env vars set: BILLING_ENABLED, PAYPAL_ENV, # PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET (via the chart Secret), # PAYPAL_WEBHOOK_ID, PAYPAL_RETURN_URL, PAYPAL_CANCEL_URL. # clientSecret MUST stay empty here — deploys pass it via --set or an @@ -394,6 +396,7 @@ mail: fromName: App subjectPrefix: "[App] " +# init-project:content:start # --------------------------------------------------------------- # Uploads storage (content module). local = pod-local disk (ephemeral, and # NOT shared between replicas — fine for dev, wrong for replicaCount > 1); @@ -410,6 +413,7 @@ storage: bucket: "" accessKey: "" secretKey: "" # via --set or external secret (sourced into S3_SECRET_KEY) +# init-project:content:end # --------------------------------------------------------------- # Graceful shutdown diff --git a/helm/cpp-env/templates/_helpers.tpl b/helm/cpp-env/templates/_helpers.tpl index 471af73..cb0d25a 100644 --- a/helm/cpp-env/templates/_helpers.tpl +++ b/helm/cpp-env/templates/_helpers.tpl @@ -40,7 +40,7 @@ metadata: normal photo at the EDGE — before the frontend nginx (6m) or the app's 5 MB upload cap ever see it. Found live: admin media upload "Upload failed" on the demo for any image over 1 MB. Keep the - chain in sync: UploadController cap (5 MB) < frontend nginx 6m + chain in sync: app upload cap (5 MB) < frontend nginx 6m <= this. */}} nginx.ingress.kubernetes.io/proxy-body-size: {{ $ctx.Values.ingress.proxyBodySize | default "6m" | quote }} {{- if $ctx.Values.ingress.externalDnsTarget }} diff --git a/helm/cpp-env/values-demo.yaml b/helm/cpp-env/values-demo.yaml index 22fe951..4827644 100644 --- a/helm/cpp-env/values-demo.yaml +++ b/helm/cpp-env/values-demo.yaml @@ -94,11 +94,13 @@ cpp-api: app: baseUrl: "https://app.demo.tarassov.me" name: "C++ REST Template — Demo" + # init-project:content:start # The demo showcases the content module: posts + markdown delivery + # admin uploads (stored on the pod's uploads emptyDir — throwaway, like # everything here). content: enabled: true + # init-project:content:end tracing: enabled: true otlpEndpoint: "http://tempo.monitoring.svc.cluster.local:4318/v1/traces" diff --git a/helm/cpp-frontend/templates/configmap.yaml b/helm/cpp-frontend/templates/configmap.yaml index 9d25888..27cf646 100644 --- a/helm/cpp-frontend/templates/configmap.yaml +++ b/helm/cpp-frontend/templates/configmap.yaml @@ -43,7 +43,7 @@ data: } location /api/ { - # Media uploads accept up to 5 MB (UploadController cap); the nginx + # Media uploads accept up to 5 MB (the app's upload cap); the nginx # default is 1m, which 413s a normal photo before it reaches the # backend. 6m leaves headroom for multipart overhead. client_max_body_size 6m; @@ -62,6 +62,7 @@ data: proxy_connect_timeout 5s; } + # init-project:content:start # Content module (content.enabled): markdown posts, sitemap and uploads # are backend routes, not SPA routes — proxy them like /api/. With the # module off the backend answers 404. KEEP IN SYNC with @@ -90,4 +91,5 @@ data: proxy_read_timeout 30s; proxy_connect_timeout 5s; } + # init-project:content:end } diff --git a/scripts/add-orgs.sh b/scripts/add-orgs.sh index 6b5ba02..d809dab 100755 --- a/scripts/add-orgs.sh +++ b/scripts/add-orgs.sh @@ -23,7 +23,7 @@ # Patches (anchor-checked, verified after insertion): # src/api/Guards.hpp API_REQUIRE_ORG / API_REQUIRE_ORG_PERM macros # src/api/Api.hpp #include of the controller -# src/api/AuthController.hpp org claim mint (login/refresh) + org_role in /auth/me +# src/api/AuthController.cpp org claim mint (login/refresh) + org_role in /auth/me # src/api/Endpoints.hpp the 8 org routes (triple-sync) # docs/openapi.yaml the 8 org routes (triple-sync) # @@ -52,7 +52,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" TENANCY="$ROOT/src/tenancy" GUARDS="$ROOT/src/api/Guards.hpp" API_HPP="$ROOT/src/api/Api.hpp" -AUTH_CTRL="$ROOT/src/api/AuthController.hpp" +# The de-inlining pass (ADR 0003 as amended; PR #46) moved AuthController's +# bodies into the .cpp — the org-claim mint and /auth/me patch sites live +# there now, at method-body (4-space) indentation. +AUTH_CTRL="$ROOT/src/api/AuthController.cpp" AUTH_HPP="$ROOT/src/security/Auth.hpp" ENDPOINTS_HPP="$ROOT/src/api/Endpoints.hpp" OPENAPI="$ROOT/docs/openapi.yaml" @@ -1337,7 +1340,7 @@ if ! grep -q '#include "api/OrganizationsController.hpp"' "$API_HPP"; then echo "==> Patched $API_HPP (#include OrganizationsController)" fi -# ── 11. Patch src/api/AuthController.hpp — org claim + org_role in /me ─── +# ── 11. Patch src/api/AuthController.cpp — org claim + org_role in /me ─── grep -q "tenancy/OrgContext.hpp" "$AUTH_CTRL" && die "$AUTH_CTRL already references tenancy — partial install?" cat >"$TMP/authctrl_includes.frag" <<'EOF' @@ -1350,42 +1353,42 @@ insert_after "$AUTH_CTRL" '#include "security/SessionStore.hpp"' "$TMP/authctrl_ # organization. Anchored on the issuer line that closes the access-claims # build; AuthController has exactly one (switchOrg's copy lives in # OrganizationsController). -[[ "$(grep -cxF ' if (!cfg.jwt_issuer.empty())' "$AUTH_CTRL")" == "1" ]] || +[[ "$(grep -cxF ' if (!cfg.jwt_issuer.empty())' "$AUTH_CTRL")" == "1" ]] || die "expected exactly one issuer anchor line in $AUTH_CTRL — patch mint_session by hand (see docs/ORGS.md)" cat >"$TMP/authctrl_mint.frag" <<'EOF' - // Org claim: set ONLY when the user belongs to exactly one - // organization — an unambiguous default. Zero memberships means - // nothing to default to; more than one means the client must pick - // explicitly via POST /orgs/{id}/switch. This same rule runs on BOTH - // login and refresh (mint_session is the sole minting path for - // both), so the claim is recomputed from current membership state - // every time — it self-heals a stale claim (the user's one org was - // deleted, a second org was added) instead of carrying forward a - // value that may no longer be valid. verify_jwt() never requires - // this claim, so tokens minted before add-orgs keep working. - Tenancy::OrgMemberRepository org_members; - auto memberships = org_members.list_for_user(user.id); - if (memberships.size() == 1) { - access_claims["org"] = memberships.front().org_id; - } + // Org claim: set ONLY when the user belongs to exactly one + // organization — an unambiguous default. Zero memberships means + // nothing to default to; more than one means the client must pick + // explicitly via POST /orgs/{id}/switch. This same rule runs on BOTH + // login and refresh (mint_session is the sole minting path for + // both), so the claim is recomputed from current membership state + // every time — it self-heals a stale claim (the user's one org was + // deleted, a second org was added) instead of carrying forward a + // value that may no longer be valid. verify_jwt() never requires + // this claim, so tokens minted before add-orgs keep working. + Tenancy::OrgMemberRepository org_members; + auto memberships = org_members.list_for_user(user.id); + if (memberships.size() == 1) { + access_claims["org"] = memberships.front().org_id; + } EOF -insert_before "$AUTH_CTRL" ' if (!cfg.jwt_issuer.empty())' "$TMP/authctrl_mint.frag" +insert_before "$AUTH_CTRL" ' if (!cfg.jwt_issuer.empty())' "$TMP/authctrl_mint.frag" grep -q 'access_claims\["org"\]' "$AUTH_CTRL" || die "failed to patch mint_session in $AUTH_CTRL" # /auth/me: annotate with the TENANT role (org_members.role), not the system # role — the SPA needs it to hide org-scoped sections. Replaces the plain # callback line; null when the token has no org claim or the membership was # revoked (same fail-closed as org_context_of). -ME_ANCHOR=' callback(Response::ok({{"user", *user}}));' +ME_ANCHOR=' callback(Response::ok({{"user", *user}}));' [[ "$(grep -cxF "$ME_ANCHOR" "$AUTH_CTRL")" == "1" ]] || die "expected exactly one '/auth/me' response line in $AUTH_CTRL — patch me() by hand (see docs/ORGS.md)" cat >"$TMP/authctrl_me.frag" <<'EOF' - // Tenant role (org_members.role), not the system role: the SPA needs - // it to hide org-scoped sections. null when the token has no org - // claim or the membership was revoked — the same fail-closed rule as - // Tenancy::org_context_of. - auto org_ctx = Tenancy::org_context_of(req); - callback(Response::ok({{"user", *user}, {"org_role", org_ctx ? json(org_ctx->role) : json(nullptr)}})); + // Tenant role (org_members.role), not the system role: the SPA needs + // it to hide org-scoped sections. null when the token has no org + // claim or the membership was revoked — the same fail-closed rule as + // Tenancy::org_context_of. + auto org_ctx = Tenancy::org_context_of(req); + callback(Response::ok({{"user", *user}, {"org_role", org_ctx ? json(org_ctx->role) : json(nullptr)}})); EOF awk -v anchor="$ME_ANCHOR" -v fragment="$TMP/authctrl_me.frag" ' $0 == anchor && !done { @@ -1596,6 +1599,27 @@ EOF echo "==> Appended OpenAPI block to $OPENAPI" fi +# ── 14b. docs/module-deps.txt — declare the new tenancy edges ──────────── +# The module-DAG gate (scripts/check-module-deps.sh, added after the first +# version of this kit) fails any cross-directory #include whose edge is not +# declared. The kit introduces api -> tenancy plus tenancy's own +# dependencies; insert each at its sorted position. Skipped edge-by-edge if +# already present, and entirely on a fork that predates the gate. +DEPS="$ROOT/docs/module-deps.txt" +if [[ -f "$DEPS" ]]; then + add_edge_before() { + local edge="$1" anchor="$2" + grep -qxF "$edge" "$DEPS" && return 0 + printf '%s\n' "$edge" >"$TMP/edge.frag" + insert_before "$DEPS" "$anchor" "$TMP/edge.frag" + } + add_edge_before 'api -> tenancy' 'api -> utils' + add_edge_before 'tenancy -> database' 'webhooks -> jobs' + add_edge_before 'tenancy -> repositories' 'webhooks -> jobs' + add_edge_before 'tenancy -> security' 'webhooks -> jobs' + echo "==> Declared tenancy edges in $DEPS" +fi + # ── 15. Tests ──────────────────────────────────────────────────────────── cat >"$ROOT/tests/unit/test_org_permissions.cpp" <<'EOF' /** diff --git a/scripts/init-project.sh b/scripts/init-project.sh index f94c748..bf5eba3 100755 --- a/scripts/init-project.sh +++ b/scripts/init-project.sh @@ -27,15 +27,22 @@ set -euo pipefail DRY_RUN=0 FORCE=0 NO_DEMO=0 +MINIMAL=0 +WITH_ORGS=0 ARGS=() for arg in "$@"; do case "$arg" in --dry-run | -n) DRY_RUN=1 ;; --force | -f) FORCE=1 ;; --no-demo) NO_DEMO=1 ;; + --minimal) + MINIMAL=1 + NO_DEMO=1 + ;; + --with-orgs) WITH_ORGS=1 ;; --help | -h) cat < [registry] [domain] +Usage: $0 [--dry-run] [--force] [--no-demo | --minimal] [--with-orgs] [registry] [domain] --dry-run, -n Print every file that would be touched and the patterns that would run, without modifying anything on disk. @@ -48,12 +55,22 @@ Usage: $0 [--dry-run] [--force] [registry] [domain] "Live demo" block (demo URL + public demo credentials). The C++ app — auth, User/Role/Audit, jobs — is NOT a demo and is kept. See REMOVING-THE-DEMO.md. + --minimal Everything --no-demo does, PLUS remove the content module + (posts / uploads / sitemap — the template's worked example + of a feature module) via scripts/remove-content-module.sh: + controllers, repository, migration 006, routes, OpenAPI + blocks, tests, admin SPA pages, helm/compose wiring. The + route/config sync gates stay green by construction. + --with-orgs After the rename, run scripts/add-orgs.sh — the one-shot + multi-tenancy (organizations) starter kit: src/tenancy/, + org guards, orgs API + migration + tests (docs/ORGS.md). domain Your host/domain — replaces the author's tarassov.me in badges / demo URLs / SECURITY.md (default: example.com). Example: $0 my-service docker.io/myorg example.org + $0 --minimal --with-orgs my-saas docker.io/myorg example.org $0 --dry-run my-service docker.io/myorg USAGE exit 0 @@ -162,8 +179,9 @@ done < <( -o -name '*.md' -o -name '*.conf' -o -name '*.env' -o -name '.env.*' \ -o -name '*.txt' -o -name '*.lock' -o -name '*.sample' \ -o -name '.gitignore' -o -name '.gitlab-ci.yml' \ - -o -name 'Chart.yaml' \) \ + -o -name 'Chart.yaml' -o -name '*.toml' \) \ -not -path './.git/*' \ + -not -path './docs/superpowers/*' \ -not -path './build/*' \ -not -path './vcpkg_installed/*' \ -not -path './vcpkg/*' \ @@ -281,6 +299,15 @@ if [[ $DRY_RUN -eq 1 ]]; then echo "==> Would strip the README 'Live demo' block + its Contents entry" fi fi + if [[ $MINIMAL -eq 1 ]]; then + echo "==> Would remove the content module (scripts/remove-content-module.sh):" + echo " posts/uploads/sitemap controllers + repository + migration 006 +" + echo " routes/OpenAPI blocks + tests + admin SPA pages + helm/compose wiring" + fi + if [[ $WITH_ORGS -eq 1 ]]; then + echo "==> Would install the multi-tenancy starter kit (scripts/add-orgs.sh):" + echo " src/tenancy/, org guards, orgs API + migration + tests (docs/ORGS.md)" + fi echo "" echo "DRY RUN complete. Re-run without --dry-run to apply." exit 0 @@ -343,7 +370,10 @@ echo "" # UNAMBIGUOUS author/template tokens are flagged: bare "cpp-api"/"cpp_api" are # excluded so a fork named e.g. "cpp-api-gateway" doesn't trip it. Vendor / # build / generated dirs and this script itself (which necessarily contains the -# tokens) are skipped. -I skips binaries (portable across GNU/BSD grep). +# tokens) are skipped, and so is docs/superpowers/ — the archived plan/spec +# records deliberately keep pre-rename identifiers verbatim (they are history, +# not live config), which is also why the sed pass above leaves them alone. +# -I skips binaries (portable across GNU/BSD grep). # Strip the flask-base reference material (opt-in). It exists to teach the # parity mapping; a shipped fork doesn't need ~21 MB of Python or the pattern # doc. The actual app (auth/User/Role/Audit/jobs) is NOT touched. @@ -381,6 +411,15 @@ if [[ $NO_DEMO -eq 1 ]]; then fi fi +# ── Strip the content module (--minimal) ──────────────────────────────────── +# Runs BEFORE the verification scan so a failure there covers the edited tree. +# The remover is a standalone one-shot script — everything it deletes/patches +# is path-anchored, not name-anchored, so it is rename-safe by construction. +if [[ $MINIMAL -eq 1 ]]; then + echo "==> --minimal: removing the content module" + "$ROOT/scripts/remove-content-module.sh" +fi + echo "==> Verifying rename completeness" LEFTOVER_RE='cpp-rapid-rest-template|cpp_api_template|cpp_api_bench|cpp_api_service|cpp_worker_service|cpp_producer|cpp-api-team|resert/|ghcr\.io/resert|tarassov\.me|46\.225\.37\.165|admin@talos-nbg1|DemoAdmin-2026|michael@tarassov\.me' leftovers="$(grep -rInE "$LEFTOVER_RE" . \ @@ -389,7 +428,8 @@ leftovers="$(grep -rInE "$LEFTOVER_RE" . \ --exclude-dir=.git --exclude-dir=build \ --exclude-dir=vcpkg_installed --exclude-dir=vcpkg \ --exclude-dir=node_modules --exclude-dir=dist \ - --exclude-dir=html --exclude-dir=_reference 2>/dev/null || true)" + --exclude-dir=html --exclude-dir=_reference \ + --exclude-dir=superpowers 2>/dev/null || true)" if [[ -n "$leftovers" ]]; then echo "" >&2 @@ -421,5 +461,18 @@ else echo "WARNING: helm not installed — if any helm/*/Chart.yaml was renamed, run" >&2 echo " 'helm dependency update' in each chart with a Chart.lock before CI." >&2 fi + +# ── Install the multi-tenancy starter kit (--with-orgs) ───────────────────── +# Runs LAST: add-orgs.sh generates fresh files (they carry no template tokens, +# so the verification above stays honest) and patches by content anchors that +# are rename-independent (#include lines, `namespace Api {`, mint_session's +# jwt_issuer branch), so it works identically before or after the rename. +# Its migration takes the next free NNN — after --minimal that keeps the +# deliberate gap at 006. +if [[ $WITH_ORGS -eq 1 ]]; then + echo "" + echo "==> --with-orgs: installing the multi-tenancy starter kit" + "$ROOT/scripts/add-orgs.sh" +fi echo "" echo "Done. Review the full diff with: git diff" diff --git a/scripts/remove-content-module.sh b/scripts/remove-content-module.sh new file mode 100755 index 0000000..f897bd5 --- /dev/null +++ b/scripts/remove-content-module.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# +# One-shot REMOVER for the content module (posts / uploads / sitemap) — the +# inverse of what PR #11 added. The content module is the template's worked +# example of a full feature module; a fork that doesn't publish articles can +# drop it entirely. Invoked by `init-project.sh --minimal`, or run standalone +# at any time BEFORE you build on the module. +# +# What it does, kept in lock-step with the triple-sync CI gates: +# 1. Deletes the module's own files: controllers (+ .cpp bodies), domain +# struct, repository, src/storage/, migration 006_add_posts.sql, the +# module's unit/integration tests, the admin SPA pages. +# 2. Strips every `init-project:content:start` … `init-project:content:end` +# marker block from the shared files (Guards/Modules/Strings, e2e + +# config tests, helm charts, nginx configs, docs/CONFIG.md, SPA +# manifest/queryKeys/Dashboard, docker envs). +# 3. Pattern-edits the structured files markers can't carry (JSON has no +# comments): config.json storage/content blocks + public_paths CSV, +# the helm ConfigMap's rendered config.json, openapi.yaml path blocks, +# Endpoints.hpp rows, Api.hpp includes, Core.cpp storage wiring, +# docs/module-deps.txt edges. +# 4. Regenerates frontend/src/lib/api/schema.gen.ts from the shrunk +# openapi.yaml (CI diffs the committed copy against a fresh render). +# 5. Verifies no functional reference survived. +# +# After this script the tree passes check-openapi-drift / +# check-routes-registered / check-frontend-nginx-sync / check-module-deps / +# check-config-sync by construction. Migration numbering keeps a gap at 006 — +# the runner applies files in numeric order and does not require contiguity. +# +# NOT idempotent by design: it refuses to run twice (the files it edits may +# have moved on). See REMOVING-THE-DEMO.md for what is and isn't "demo". +# +# Usage: +# ./scripts/remove-content-module.sh +set -euo pipefail + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +if [[ $# -gt 0 ]]; then + echo "Usage: $0 (no arguments — one-shot remover; see REMOVING-THE-DEMO.md)" >&2 + exit 2 +fi + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +MARK_START='init-project:content:start' +MARK_END='init-project:content:end' + +[[ -f src/api/PostsController.hpp ]] || + die "src/api/PostsController.hpp not found — the content module is already removed (or this isn't a template checkout)" + +# GNU sed accepts `-i`, BSD/macOS sed requires `-i ''`. +if sed --version >/dev/null 2>&1; then + SED_INPLACE=(sed -i) +else + SED_INPLACE=(sed -i '') +fi + +# ── 1. Whole files that ARE the module ─────────────────────────────────── +MODULE_FILES=( + src/api/PostsController.hpp + src/api/PostsController.cpp + src/api/UploadController.hpp + src/api/UploadController.cpp + src/api/ContentPagesController.hpp + src/api/ContentPagesController.cpp + src/domain/Post.hpp + src/repositories/PostRepository.hpp + src/storage/Storage.hpp + src/storage/Storage.cpp + migrations/006_add_posts.sql + tests/integration/test_posts_api.cpp + tests/integration/test_post_repository.cpp + tests/integration/test_content_pages.cpp + tests/integration/test_uploads_api.cpp + tests/unit/test_storage.cpp + tests/unit/test_storage_list.cpp + tests/unit/test_upload_validation.cpp + frontend/src/pages/admin/Posts.tsx + frontend/src/pages/admin/Media.tsx +) +for f in "${MODULE_FILES[@]}"; do + [[ -e "$f" ]] || die "$f not found — tree diverged from the template; remove the module by hand" + rm -f "$f" +done +rmdir src/storage 2>/dev/null || true +echo "==> Deleted ${#MODULE_FILES[@]} module files (+ src/storage/)" + +# ── 2. Marker blocks in shared files ───────────────────────────────────── +# Every file listed MUST contain at least one balanced marker pair — a +# missing marker means the block moved and this script rotted; fail loudly. +strip_marked() { + local f="$1" + grep -q "$MARK_START" "$f" || die "no '$MARK_START' marker in $f — script out of date, patch by hand" + awk -v s="$MARK_START" -v e="$MARK_END" ' + index($0, s) { depth++; next } + index($0, e) { depth--; if (depth < 0) exit 2; just_removed = 1; next } + depth == 0 { + # A block flanked by blank lines would leave TWO of them behind — + # swallow one so clang-format (MaxEmptyLinesToKeep) stays happy. + if (just_removed && last_blank && $0 ~ /^[[:space:]]*$/) { + just_removed = 0 + next + } + just_removed = 0 + print + last_blank = ($0 ~ /^[[:space:]]*$/) + } + END { if (depth != 0) exit 2 } + ' "$f" >"$f.tmp" || die "unbalanced content markers in $f" + mv "$f.tmp" "$f" +} +MARKED_FILES=( + src/core/Modules.hpp + src/api/Guards.hpp + src/api/Middleware.cpp + src/utils/Strings.hpp + tests/e2e/test_http_e2e.cpp + tests/unit/test_config.cpp + frontend/nginx.conf + frontend/src/routes/manifest.tsx + frontend/src/lib/api/queryKeys.ts + frontend/src/pages/admin/Dashboard.tsx + helm/cpp-frontend/templates/configmap.yaml + helm/cpp-api/values.yaml + helm/cpp-api/templates/deployment.yaml + helm/cpp-api/templates/secret.yaml + helm/cpp-env/values-demo.yaml + docs/CONFIG.md + docker/.env.everything +) +for f in "${MARKED_FILES[@]}"; do + strip_marked "$f" +done +echo "==> Stripped marker blocks from ${#MARKED_FILES[@]} shared files" + +# ── 3a. Api.hpp includes / Core.cpp storage wiring / misc single lines ─── +"${SED_INPLACE[@]}" \ + -e '\|#include "api/ContentPagesController.hpp"|d' \ + -e '\|#include "api/PostsController.hpp"|d' \ + -e '\|#include "api/UploadController.hpp"|d' \ + src/api/Api.hpp +"${SED_INPLACE[@]}" \ + -e '\|#include "storage/Storage.hpp"|d' \ + -e '\|Storage::initialize(cfg);|d' \ + src/core/Core.cpp +# Dashboard: the two lucide icons only the removed tiles used. +"${SED_INPLACE[@]}" -e '/^ FileText,$/d' -e '/^ Image,$/d' \ + frontend/src/pages/admin/Dashboard.tsx +# Compose env line. +"${SED_INPLACE[@]}" -e '/CONTENT_ENABLED:/d' docker/docker-compose.yml +# Module-dependency DAG: the storage node and the api->storage edge. +"${SED_INPLACE[@]}" \ + -e '/^api -> storage$/d' \ + -e '/^core -> storage$/d' \ + -e '/^storage -> utils$/d' \ + docs/module-deps.txt +# Helm Secret: drop the s3 term from the render-this-Secret-at-all condition +# (the s3-secret-key data block itself was a marker block above). +"${SED_INPLACE[@]}" -e 's| \.Values\.storage\.s3\.secretKey||' \ + helm/cpp-api/templates/secret.yaml +echo "==> Patched includes, storage wiring, compose env, module-deps, helm secret" + +# ── 3b. Endpoints.hpp — the module's rows in Api::get_endpoints() ──────── +ENDPOINTS_RE='"/api/v1/posts|"/api/v1/public/posts|"/posts/\{slug\}"|"/sitemap\.xml"|"/api/v1/admin/uploads|"/uploads/\{key\}"' +grep -vE "$ENDPOINTS_RE" src/api/Endpoints.hpp >src/api/Endpoints.hpp.tmp +mv src/api/Endpoints.hpp.tmp src/api/Endpoints.hpp +echo "==> Removed content routes from src/api/Endpoints.hpp" + +# ── 3c. openapi.yaml — whole path blocks ───────────────────────────────── +# A block starts at a 2-space-indented path key and runs until the next +# 2-space key (or a column-0 key). Keys removed = exactly the module's ten. +awk ' + { + if ($0 ~ /^[^ ]/) skip = 0 + else if ($0 ~ /^ [^ ]/) { + if ($0 ~ /^ \/(api\/v1\/posts(\/\{id\}(\/preview-token)?)?|api\/v1\/public\/posts(\/\{slug\})?|posts\/\{slug\}|sitemap\.xml|uploads\/\{key\}|api\/v1\/admin\/uploads(\/\{name\})?):[ ]*$/) + skip = 1 + else + skip = 0 + } + if (!skip) print + } +' docs/openapi.yaml >docs/openapi.yaml.tmp +mv docs/openapi.yaml.tmp docs/openapi.yaml +echo "==> Removed content path blocks from docs/openapi.yaml" + +# ── 3d. config.json / config.sample.json — storage+content blocks ──────── +# JSON carries no comments, so no markers: delete a top-level block by brace +# counting from its 2-space-indented key. ${VAR:-default} placeholders keep +# braces balanced per line, so per-line counting is safe here. +delete_json_block() { + local f="$1" key="$2" + grep -q "^ \"$key\": {" "$f" || die "no \"$key\" block in $f — patch by hand" + awk -v re="^ \"$key\": \\{" ' + skip == 0 && $0 ~ re { skip = 1; depth = 0 } + skip == 1 { + depth += split($0, _o, "{") - split($0, _c, "}") + if (depth == 0) skip = 0 + next + } + { print } + ' "$f" >"$f.tmp" + mv "$f.tmp" "$f" +} +for f in config/config.json config/config.sample.json; do + delete_json_block "$f" storage + delete_json_block "$f" content +done +# config.json's api.public_paths FULL-override default carries the module's +# public paths — drop exactly that segment (the sample's copy predates them). +"${SED_INPLACE[@]}" \ + -e 's|/posts/\*,/sitemap\.xml,/api/v1/public/posts,/api/v1/public/posts/\*,/uploads/\*,||' \ + config/config.json config/config.sample.json +# …and the API_PUBLIC_PATHS row in docs/CONFIG.md spells the same default out. +# shellcheck disable=SC2016 # the backticks are Markdown literals, not expansions +"${SED_INPLACE[@]}" \ + -e 's|, `/api/v1/public/posts`, `/api/v1/public/posts/\*`, `/posts/\*`, `/sitemap\.xml`, `/uploads/\*`||' \ + docs/CONFIG.md +# Helm ConfigMap renders config.json too: its "storage" block sits between +# "mail" and "messaging" and contains template comments (brace counting is +# unsafe over {{- /* */}}), so cut [storage-start, messaging-start). +awk ' + /^ "storage": \{/ { skip = 1 } + /^ "messaging": \{/ { skip = 0 } + !skip { print } +' helm/cpp-api/templates/configmap.yaml >helm/cpp-api/templates/configmap.yaml.tmp +mv helm/cpp-api/templates/configmap.yaml.tmp helm/cpp-api/templates/configmap.yaml +echo "==> Removed storage/content config blocks (config.json, sample, helm ConfigMap)" + +# ── 4. Regenerate the typed frontend client ────────────────────────────── +# CI's frontend job regenerates schema.gen.ts from docs/openapi.yaml and +# fails if the committed copy is stale — so regenerate it now, with the +# pinned devDependency (npm ci) rather than whatever npx would fetch. +if command -v npm >/dev/null 2>&1; then + if [[ ! -x frontend/node_modules/.bin/openapi-typescript ]]; then + echo "==> Installing frontend deps for schema regeneration (one-time npm ci)…" + (cd frontend && npm ci --no-audit --no-fund >/dev/null) || + die "npm ci failed — run 'make frontend-install && make frontend-gen-api' by hand" + fi + (cd frontend && npm run gen:api >/dev/null) + echo "==> Regenerated frontend/src/lib/api/schema.gen.ts" +else + echo "WARNING: npm not found — regenerate the typed client before pushing:" >&2 + echo " make frontend-install && make frontend-gen-api" >&2 +fi + +# ── 5. Verify nothing functional survived ──────────────────────────────── +LEFTOVER_RE='PostsController|UploadController|ContentPagesController|PostRepository|#include "domain/Post\.hpp"|#include "storage/|require_content_enabled|content_enabled|CONTENT_ENABLED|content\.enabled|STORAGE_BACKEND|STORAGE_LOCAL_ROOT|STORAGE_PUBLIC_BASE_URL|S3_SECRET_KEY|/api/v1/public/posts|/api/v1/admin/uploads|sitemap\.xml|init-project:content' +leftovers="$(grep -rInE "$LEFTOVER_RE" \ + src tests config helm docker frontend/src frontend/nginx.conf \ + docs/openapi.yaml docs/CONFIG.md docs/module-deps.txt Makefile 2>/dev/null | + grep -v 'node_modules' || true)" +if [[ -n "$leftovers" ]]; then + echo "" >&2 + echo "==> INCOMPLETE: content-module references survived the removal:" >&2 + printf '%s\n' "$leftovers" | sed 's/^/ /' >&2 + exit 1 +fi +echo "==> Verified: no functional content-module references remain." + +cat <<'EOF' + +Content module removed. Notes: + * migrations keep a numbering gap at 006 — harmless, the runner sorts. + * narrative docs (docs/EXAMPLES.md, docs/CONVENTIONS.md, ADRs, archived + plans under docs/superpowers/) still DESCRIBE the module as a worked + example; they are history, not wiring. + * verify: ./scripts/check-openapi-drift.sh && ./scripts/check-routes-registered.sh + && ./scripts/check-frontend-nginx-sync.sh && ./scripts/check-module-deps.sh + && ./scripts/check-config-sync.sh && ./scripts/check-test-buckets.sh +EOF diff --git a/src/api/AdminBillingController.hpp b/src/api/AdminBillingController.hpp index 764a193..e350daa 100644 --- a/src/api/AdminBillingController.hpp +++ b/src/api/AdminBillingController.hpp @@ -127,8 +127,8 @@ class AdminBillingController : public HttpController { private: /// Module gate — same contract as BillingController::require_billing_enabled - /// (mirrors Api::require_content_enabled in Guards.hpp; kept - /// controller-local because Guards.hpp has no generic per-module variant). + /// (kept controller-local because Guards.hpp has no generic per-module + /// variant). static bool require_billing_enabled(const std::function& callback); /// Acting admin's principal subject for the audit trail ("" when auth off). diff --git a/src/api/BillingController.hpp b/src/api/BillingController.hpp index 4c6b7b6..d44aa51 100644 --- a/src/api/BillingController.hpp +++ b/src/api/BillingController.hpp @@ -162,9 +162,9 @@ class BillingController : public HttpController { private: /// Gate a billing-module handler: when the module is off /// (BILLING_ENABLED=false, the default) the whole surface answers 404 - /// instead of a 500 against missing tables/credentials. Mirrors - /// Api::require_content_enabled (Guards.hpp); kept controller-local - /// because Guards.hpp has no generic per-module variant. Returns false + /// instead of a 500 against missing tables/credentials. Kept + /// controller-local because Guards.hpp has no generic per-module + /// variant. Returns false /// after responding — callers `if (!require_billing_enabled(callback)) /// return;`. static bool require_billing_enabled(const std::function& callback); diff --git a/src/api/Guards.hpp b/src/api/Guards.hpp index 23285a1..37c67b6 100644 --- a/src/api/Guards.hpp +++ b/src/api/Guards.hpp @@ -91,6 +91,7 @@ namespace Api { +// init-project:content:start /// Gate a content-module handler: when the content module is off /// (CONTENT_ENABLED=false) the whole surface answers 404 instead of a 500 /// against a missing table. Returns false after responding — callers @@ -101,6 +102,7 @@ inline bool require_content_enabled(const std::function_enabled() accessors) and // the shutdown flag (Core::is_shutting_down) live in core/Modules.hpp — // included above — so controllers can consult them without pulling in this // composition root. diff --git a/src/core/Modules.hpp b/src/core/Modules.hpp index 425b94a..7f39f83 100644 --- a/src/core/Modules.hpp +++ b/src/core/Modules.hpp @@ -33,6 +33,7 @@ inline bool is_shutting_down() { return shutting_down_flag.load(); } +// init-project:content:start /// Content module (posts/uploads/sitemap) master switch. Routes are /// statically registered, so handlers consult this per-request and 404 /// when the module is off. Same is_initialized() guard as @@ -44,10 +45,11 @@ inline bool content_enabled() { return false; return Config::get().get("content.enabled", "CONTENT_ENABLED", false); } +// init-project:content:end /// Billing module master switch — generated by scripts/new-module.sh; same -/// pattern (and same Config::is_initialized() guard) as content_enabled() -/// above: routes are statically registered, handlers consult this +/// pattern (and same Config::is_initialized() guard) as the module +/// switches above: routes are statically registered, handlers consult this /// per-request and 404 while the module is off. inline bool billing_enabled() { if (!Config::is_initialized()) diff --git a/src/utils/Strings.hpp b/src/utils/Strings.hpp index 714bb2f..0d3e697 100644 --- a/src/utils/Strings.hpp +++ b/src/utils/Strings.hpp @@ -31,11 +31,13 @@ namespace Utils::Strings { * `*-request` / `confirm-resend` routes are deliberately NOT here: * change-email-request and confirm-resend require an authenticated principal. * + * init-project:content:start * `/uploads` is here for the same reason as the posts routes: with the local * storage backend, post bodies embed same-origin image URLs * (UploadController::serveUpload) that anonymous readers have to be able to * fetch. * + * init-project:content:end * `/api/v1/billing/paypal/webhook` is PayPal's own server calling us, not a * browser — there is no session to authenticate against, and the handler * (BillingController::paypalWebhook) verifies PayPal's own request signature @@ -65,8 +67,10 @@ inline constexpr const char* kDefaultPublicPathsCsv = "/api/v1/account/confirm/*,/api/v1/account/reset-password-request," "/api/v1/account/reset-password/*,/api/v1/account/change-email/*," "/api/v1/account/join-from-invite/*," + // init-project:content:start "/api/v1/public/posts,/api/v1/public/posts/*," "/posts/*,/sitemap.xml,/uploads/*," + // init-project:content:end "/api/v1/billing/paypal/webhook"; /** @@ -75,14 +79,17 @@ inline constexpr const char* kDefaultPublicPathsCsv = * login & register (credential stuffing), refresh (token churn), * reset-password-request (mail bomb), the token-bearing links * (reset / confirm / change-email / invite — guessable-token attempts), - * the content module's public surface (posts list/detail, the - * Markdown mirror, the sitemap, and served uploads — all reachable by - * an anonymous caller, so all are scrapeable without this), and the - * PayPal webhook (a spoofed/replayed flood of POSTs here is real load - * on Billing::PayPalClient::verify_webhook_signature's own outbound - * call to PayPal — worth the strict per-IP tier same as the rest). + * and the PayPal webhook (a spoofed/replayed flood of POSTs here is + * real load on Billing::PayPalClient::verify_webhook_signature's own + * outbound call to PayPal — worth the strict per-IP tier same as the + * rest). + * init-project:content:start + * Also here: the content module's public surface (posts list/detail, + * the Markdown mirror, the sitemap, and served uploads) — all + * reachable by an anonymous caller, so all scrapeable without this. + * init-project:content:end * - * This is the auth/account/content subset of kDefaultPublicPathsCsv minus the + * This is kDefaultPublicPathsCsv minus the * infra and static surface (`/`, `/healthz`, `/ready`, `/health`, `/metrics`, * `/api/v1/docs`, `/api/v1/openapi.yaml`), which we never want to throttle. The * general limiter skips everything in api.public_paths; without this list the @@ -94,8 +101,10 @@ inline constexpr const char* kDefaultProtectedPathsCsv = "/api/v1/account/confirm/*,/api/v1/account/reset-password-request," "/api/v1/account/reset-password/*,/api/v1/account/change-email/*," "/api/v1/account/join-from-invite/*," + // init-project:content:start "/api/v1/public/posts,/api/v1/public/posts/*," "/posts/*,/sitemap.xml,/uploads/*," + // init-project:content:end "/api/v1/billing/paypal/webhook"; /** diff --git a/tests/e2e/test_http_e2e.cpp b/tests/e2e/test_http_e2e.cpp index f62bc00..0eda3be 100644 --- a/tests/e2e/test_http_e2e.cpp +++ b/tests/e2e/test_http_e2e.cpp @@ -90,6 +90,7 @@ class HttpServerEnvironment : public ::testing::Environment { cfg["mail"]["enabled"] = false; cfg["database"]["migrations_enabled"] = true; cfg["database"]["migrations_dir"] = "migrations"; + // init-project:content:start // Content module: enable so PostsController/ContentPagesController // routes aren't 404'd by Core::content_enabled(). No explicit // "api.public_paths" override exists in this config to extend — this @@ -97,6 +98,7 @@ class HttpServerEnvironment : public ::testing::Environment { // already carries /posts/* and /sitemap.xml (added alongside this // controller) as its fallback when the config omits the key. cfg["content"]["enabled"] = true; + // init-project:content:end config_path_ = TestHelpers::create_temp_config(cfg.dump(2), "e2e_test_config.json"); Core::initialize(config_path_); @@ -289,6 +291,7 @@ TEST(HttpE2E, ContentTypeComparisonIsCaseInsensitive) { expect_matches_schema(resp, "POST", "/api/v1/auth/login"); } +// init-project:content:start TEST(HttpE2E, MultipartPassesContentTypeGate) { // Uploads are multipart/form-data — the JSON content-type gate must let // them through to the auth/controller layers. An anonymous multipart POST @@ -305,6 +308,7 @@ TEST(HttpE2E, MultipartPassesContentTypeGate) { EXPECT_EQ(resp->statusCode(), k401Unauthorized) << resp->getBody(); expect_matches_schema(resp, "POST", "/api/v1/admin/uploads"); } +// init-project:content:end TEST(HttpE2E, AuthMiddlewareGuardsNonPublicPaths) { REQUIRE_E2E_ENV(); @@ -447,6 +451,7 @@ TEST(HttpE2E, AdminGateChecksPermissionBitmask) { expect_matches_schema(user_resp, "GET", "/api/v1/admin/users"); } +// init-project:content:start TEST(HttpE2E, PostMarkdownServedOverWire) { REQUIRE_E2E_ENV(); const auto now = Utils::Time::now_epoch_seconds(); @@ -502,6 +507,7 @@ TEST(HttpE2E, SitemapListsPublishedPost) { const std::string body(resp->getBody()); EXPECT_NE(body.find("/posts/e2e-sitemap-post"), std::string::npos) << body; } +// init-project:content:end } // namespace diff --git a/tests/unit/test_config.cpp b/tests/unit/test_config.cpp index a6dc65b..a15c1ba 100644 --- a/tests/unit/test_config.cpp +++ b/tests/unit/test_config.cpp @@ -277,6 +277,7 @@ TEST_F(ConfigTest, RequireReturnsEnvOverValue) { EXPECT_EQ(config.require("test.value", "CFG_REQUIRED"), "hello"); } +// init-project:content:start // ── Content module master switch (posts/uploads/sitemap) ────────────────── TEST_F(ConfigTest, ContentDisabledByDefault) { @@ -285,6 +286,7 @@ TEST_F(ConfigTest, ContentDisabledByDefault) { Config::initialize(test_config_file); EXPECT_FALSE(Config::get().get("content.enabled", "CONTENT_ENABLED", false)); } +// init-project:content:end // ── M3: string-shaped leaves coerce to the requested type ─────────────────── // substitute_env_placeholders writes every ${VAR:-default} expansion back as a