Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [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 <type>`
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 40 additions & 3 deletions REMOVING-THE-DEMO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions docker/.env.everything
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

<!-- init-project:content:start -->
## Object storage

`Storage::get()` is a get/put/remove seam (`src/storage/Storage.hpp`). Two
Expand All @@ -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 |
<!-- init-project:content:end -->

## Observability

Expand Down Expand Up @@ -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). |

<!-- init-project:content:start -->
## Content

| Env | JSON key | Type | Default | Notes |
Expand All @@ -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.
<!-- init-project:content:end -->

## 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 |
Expand Down
5 changes: 3 additions & 2 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/ORGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 3 additions & 1 deletion frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -85,4 +86,5 @@ server {
proxy_read_timeout 30s;
proxy_connect_timeout 5s;
}
# init-project:content:end
}
2 changes: 2 additions & 0 deletions frontend/src/lib/api/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/admin/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function AdminDashboardPage() {
</CardHeader>
</Card>
</Link>
{/* init-project:content:start */}
<Link to="/admin/posts">
<Card className="hover:bg-accent transition-colors h-full">
<CardHeader>
Expand All @@ -78,6 +79,7 @@ export function AdminDashboardPage() {
</CardHeader>
</Card>
</Link>
{/* init-project:content:end */}
<Link to="/admin/billing">
<Card className="hover:bg-accent transition-colors h-full">
<CardHeader>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/routes/manifest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
);
Expand Down Expand Up @@ -166,9 +168,11 @@ export const routes: RouteEntry[] = [
navLabel: 'Audit',
navIcon: ScrollText,
},
// init-project:content:start
{ path: '/admin/posts', element: <AdminPostsPage />, guard: 'admin' },
{ path: '/admin/media', element: <AdminMediaPage />, 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: <AdminBillingPage />, guard: 'admin' },
];
Expand Down
8 changes: 8 additions & 0 deletions helm/cpp-api/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -350,13 +352,15 @@ 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:
secretKeyRef:
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
Expand All @@ -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:
Expand All @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions helm/cpp-api/templates/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
6 changes: 5 additions & 1 deletion helm/cpp-api/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -410,6 +413,7 @@ storage:
bucket: ""
accessKey: ""
secretKey: "" # via --set or external secret (sourced into S3_SECRET_KEY)
# init-project:content:end

# ---------------------------------------------------------------
# Graceful shutdown
Expand Down
2 changes: 1 addition & 1 deletion helm/cpp-env/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions helm/cpp-env/values-demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion helm/cpp-frontend/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -90,4 +91,5 @@ data:
proxy_read_timeout 30s;
proxy_connect_timeout 5s;
}
# init-project:content:end
}
Loading
Loading