@brendanbank/create-atrium-host 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENCE.md +25 -0
- package/README.md +94 -0
- package/bin/index.js +8 -0
- package/package.json +40 -0
- package/src/cli.js +279 -0
- package/src/git.js +48 -0
- package/src/names.js +91 -0
- package/src/render.js +90 -0
- package/template/.env.example +33 -0
- package/template/.github/dependabot.yml +56 -0
- package/template/.github/workflows/ci.yml +103 -0
- package/template/Dockerfile +46 -0
- package/template/Makefile +67 -0
- package/template/README.md +124 -0
- package/template/backend/alembic/env.py +116 -0
- package/template/backend/alembic/script.py.mako +27 -0
- package/template/backend/alembic/versions/0001_init.py +92 -0
- package/template/backend/alembic.ini +50 -0
- package/template/backend/pyproject.toml +17 -0
- package/template/backend/src/__HOST_PKG__/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/bootstrap.py +52 -0
- package/template/backend/src/__HOST_PKG__/models.py +51 -0
- package/template/backend/src/__HOST_PKG__/router.py +73 -0
- package/template/backend/src/__HOST_PKG__/scripts/__init__.py +0 -0
- package/template/backend/src/__HOST_PKG__/scripts/seed_host_bundle.py +48 -0
- package/template/backend/tests/test_smoke.py +47 -0
- package/template/compose.yaml +96 -0
- package/template/frontend/package.json +36 -0
- package/template/frontend/src/__BRAND_PASCAL__AdminTab.tsx +32 -0
- package/template/frontend/src/__BRAND_PASCAL__Page.tsx +34 -0
- package/template/frontend/src/__BRAND_PASCAL__ProfileItem.tsx +38 -0
- package/template/frontend/src/__BRAND_PASCAL__Widget.tsx +117 -0
- package/template/frontend/src/api.ts +38 -0
- package/template/frontend/src/main.tsx +55 -0
- package/template/frontend/src/queryClient.ts +11 -0
- package/template/frontend/src/test/__BRAND_PASCAL__Widget.test.tsx +102 -0
- package/template/frontend/src/test/setup.ts +21 -0
- package/template/frontend/tsconfig.json +21 -0
- package/template/frontend/vite.config.ts +7 -0
- package/template/frontend/vitest.config.ts +15 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
ENVIRONMENT=dev
|
|
2
|
+
APP_TIMEZONE=UTC
|
|
3
|
+
|
|
4
|
+
# Long random strings. `openssl rand -hex 48` for each.
|
|
5
|
+
APP_SECRET_KEY=replace-me-with-openssl-rand-hex-48
|
|
6
|
+
JWT_SECRET=replace-me-with-openssl-rand-hex-48
|
|
7
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
|
8
|
+
|
|
9
|
+
# The public URL the browser hits. Used in email links.
|
|
10
|
+
APP_BASE_URL=http://localhost:8000
|
|
11
|
+
|
|
12
|
+
# MySQL.
|
|
13
|
+
MYSQL_ROOT_PASSWORD=root-pw-change-me
|
|
14
|
+
MYSQL_DATABASE=__HOST_PKG__
|
|
15
|
+
MYSQL_USER=__HOST_PKG__
|
|
16
|
+
MYSQL_PASSWORD=db-pw-change-me
|
|
17
|
+
DATABASE_URL=mysql+aiomysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE}
|
|
18
|
+
|
|
19
|
+
# WebAuthn — the host the credential is bound to (no scheme/port) and
|
|
20
|
+
# the full origin the registration ceremony runs from. localhost is
|
|
21
|
+
# fine for dev; switch to your real hostname in prod.
|
|
22
|
+
WEBAUTHN_RP_ID=localhost
|
|
23
|
+
WEBAUTHN_RP_NAME=__BRAND_NAME__
|
|
24
|
+
WEBAUTHN_ORIGIN=http://localhost:8000
|
|
25
|
+
|
|
26
|
+
# Mail. `console` prints to stdout; switch to `smtp` and fill
|
|
27
|
+
# SMTP_HOST/SMTP_PORT/SMTP_USERNAME/SMTP_PASSWORD in prod.
|
|
28
|
+
MAIL_BACKEND=console
|
|
29
|
+
MAIL_FROM=no-reply@example.com
|
|
30
|
+
|
|
31
|
+
# Pin the atrium base image. X.Y for patch uptake; X.Y.Z for fully
|
|
32
|
+
# deterministic deploys.
|
|
33
|
+
ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
# Each ecosystem declares two groups:
|
|
3
|
+
# - *-version-updates (applies-to: version-updates)
|
|
4
|
+
# - *-security-updates (applies-to: security-updates)
|
|
5
|
+
#
|
|
6
|
+
# Without the explicit security-updates group, Dependabot opens one
|
|
7
|
+
# PR per advisory even when the `groups` block is set for version
|
|
8
|
+
# updates.
|
|
9
|
+
updates:
|
|
10
|
+
- package-ecosystem: "pip"
|
|
11
|
+
directory: "/backend"
|
|
12
|
+
schedule:
|
|
13
|
+
interval: "weekly"
|
|
14
|
+
groups:
|
|
15
|
+
pip-version-updates:
|
|
16
|
+
applies-to: version-updates
|
|
17
|
+
patterns: ["*"]
|
|
18
|
+
pip-security-updates:
|
|
19
|
+
applies-to: security-updates
|
|
20
|
+
patterns: ["*"]
|
|
21
|
+
|
|
22
|
+
- package-ecosystem: "npm"
|
|
23
|
+
directory: "/frontend"
|
|
24
|
+
schedule:
|
|
25
|
+
interval: "weekly"
|
|
26
|
+
groups:
|
|
27
|
+
npm-version-updates:
|
|
28
|
+
applies-to: version-updates
|
|
29
|
+
patterns: ["*"]
|
|
30
|
+
npm-security-updates:
|
|
31
|
+
applies-to: security-updates
|
|
32
|
+
patterns: ["*"]
|
|
33
|
+
|
|
34
|
+
- package-ecosystem: "github-actions"
|
|
35
|
+
directory: "/"
|
|
36
|
+
schedule:
|
|
37
|
+
interval: "weekly"
|
|
38
|
+
groups:
|
|
39
|
+
actions-version-updates:
|
|
40
|
+
applies-to: version-updates
|
|
41
|
+
patterns: ["*"]
|
|
42
|
+
actions-security-updates:
|
|
43
|
+
applies-to: security-updates
|
|
44
|
+
patterns: ["*"]
|
|
45
|
+
|
|
46
|
+
- package-ecosystem: "docker"
|
|
47
|
+
directory: "/"
|
|
48
|
+
schedule:
|
|
49
|
+
interval: "weekly"
|
|
50
|
+
groups:
|
|
51
|
+
docker-version-updates:
|
|
52
|
+
applies-to: version-updates
|
|
53
|
+
patterns: ["*"]
|
|
54
|
+
docker-security-updates:
|
|
55
|
+
applies-to: security-updates
|
|
56
|
+
patterns: ["*"]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
paths-ignore:
|
|
6
|
+
- "**.md"
|
|
7
|
+
- "docs/**"
|
|
8
|
+
- "LICENCE.md"
|
|
9
|
+
push:
|
|
10
|
+
branches: [main, master]
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
frontend:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
defaults:
|
|
20
|
+
run:
|
|
21
|
+
working-directory: frontend
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- uses: pnpm/action-setup@v4
|
|
27
|
+
with:
|
|
28
|
+
version: 10.33.1
|
|
29
|
+
|
|
30
|
+
- uses: actions/setup-node@v4
|
|
31
|
+
with:
|
|
32
|
+
node-version: "22"
|
|
33
|
+
cache: pnpm
|
|
34
|
+
cache-dependency-path: frontend/pnpm-lock.yaml
|
|
35
|
+
|
|
36
|
+
- name: Install deps
|
|
37
|
+
run: pnpm install --frozen-lockfile
|
|
38
|
+
|
|
39
|
+
- name: Typecheck
|
|
40
|
+
run: pnpm typecheck
|
|
41
|
+
|
|
42
|
+
- name: Unit tests
|
|
43
|
+
run: pnpm test
|
|
44
|
+
|
|
45
|
+
- name: Build
|
|
46
|
+
run: pnpm build
|
|
47
|
+
|
|
48
|
+
smoke:
|
|
49
|
+
# Stand up the full stack (atrium image + this host extension on
|
|
50
|
+
# top), run migrations, seed an admin, hit /readyz. Catches the
|
|
51
|
+
# bootstrap-time failures (missing env, broken init_app, alembic
|
|
52
|
+
# chain mismatch) the frontend job won't see.
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v4
|
|
56
|
+
|
|
57
|
+
- name: Prepare smoke .env
|
|
58
|
+
run: |
|
|
59
|
+
cat > .env <<'EOF'
|
|
60
|
+
ENVIRONMENT=dev
|
|
61
|
+
APP_TIMEZONE=UTC
|
|
62
|
+
APP_SECRET_KEY=smoke-secret
|
|
63
|
+
APP_BASE_URL=http://localhost:8000
|
|
64
|
+
MYSQL_ROOT_PASSWORD=smoke-root-pw
|
|
65
|
+
MYSQL_DATABASE=__HOST_PKG__
|
|
66
|
+
MYSQL_USER=__HOST_PKG__
|
|
67
|
+
MYSQL_PASSWORD=smoke-pw
|
|
68
|
+
DATABASE_URL=mysql+aiomysql://__HOST_PKG__:smoke-pw@mysql:3306/__HOST_PKG__
|
|
69
|
+
JWT_SECRET=smoke-jwt-secret
|
|
70
|
+
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
|
71
|
+
WEBAUTHN_RP_ID=localhost
|
|
72
|
+
WEBAUTHN_RP_NAME=__BRAND_NAME__
|
|
73
|
+
WEBAUTHN_ORIGIN=http://localhost:8000
|
|
74
|
+
MAIL_BACKEND=console
|
|
75
|
+
MAIL_FROM=no-reply@example.com
|
|
76
|
+
ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
|
|
77
|
+
EOF
|
|
78
|
+
|
|
79
|
+
- name: Build + start stack
|
|
80
|
+
run: docker compose up -d --build
|
|
81
|
+
|
|
82
|
+
- name: Wait for API
|
|
83
|
+
run: |
|
|
84
|
+
for i in $(seq 1 40); do
|
|
85
|
+
if curl -fsS http://localhost:8000/readyz > /dev/null; then exit 0; fi
|
|
86
|
+
sleep 3
|
|
87
|
+
done
|
|
88
|
+
echo "API did not become ready" >&2
|
|
89
|
+
docker compose logs api
|
|
90
|
+
exit 1
|
|
91
|
+
|
|
92
|
+
- name: Run migrations
|
|
93
|
+
run: make migrate
|
|
94
|
+
|
|
95
|
+
- name: Seed admin
|
|
96
|
+
run: make seed-admin EMAIL=admin@example.com PASSWORD=smoke-pw-12345
|
|
97
|
+
|
|
98
|
+
- name: Backend smoke tests
|
|
99
|
+
run: make test-backend
|
|
100
|
+
|
|
101
|
+
- name: Dump stack logs on failure
|
|
102
|
+
if: failure()
|
|
103
|
+
run: docker compose logs
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# __BRAND_NAME__ host extension image. Two stages:
|
|
2
|
+
#
|
|
3
|
+
# 1. frontend-builder — node + pnpm, builds the host SPA bundle
|
|
4
|
+
# (a single main.js dynamic-imported by atrium at runtime).
|
|
5
|
+
# 2. runtime — FROM the published atrium image, pip-installs the
|
|
6
|
+
# host backend package into atrium's venv, and copies the host
|
|
7
|
+
# bundle into /opt/atrium/static/host so atrium serves it at
|
|
8
|
+
# /host/main.js (same origin as the SPA, no CORS).
|
|
9
|
+
|
|
10
|
+
ARG ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__
|
|
11
|
+
|
|
12
|
+
# ---- frontend-builder ----
|
|
13
|
+
FROM node:25-alpine AS frontend-builder
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
RUN npm install -g pnpm@10.33.1
|
|
16
|
+
|
|
17
|
+
COPY frontend/package.json frontend/pnpm-lock.yaml* frontend/.npmrc ./
|
|
18
|
+
RUN pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
|
19
|
+
COPY frontend/ ./
|
|
20
|
+
|
|
21
|
+
ARG VITE_API_BASE_URL=""
|
|
22
|
+
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|
23
|
+
RUN pnpm build
|
|
24
|
+
|
|
25
|
+
# ---- runtime ----
|
|
26
|
+
FROM ${ATRIUM_IMAGE} AS runtime
|
|
27
|
+
|
|
28
|
+
USER root
|
|
29
|
+
COPY backend /opt/host_app
|
|
30
|
+
# atrium's runtime image uses uv to build the venv but doesn't install
|
|
31
|
+
# pip into it. ensurepip bootstraps pip so we can install the host
|
|
32
|
+
# package — slightly slower than uv but avoids adding uv to the
|
|
33
|
+
# runtime image just for this one install.
|
|
34
|
+
RUN /opt/venv/bin/python -m ensurepip --upgrade \
|
|
35
|
+
&& /opt/venv/bin/python -m pip install --no-cache-dir /opt/host_app
|
|
36
|
+
|
|
37
|
+
# Bake the host bundle into atrium's static dir at /host/main.js.
|
|
38
|
+
COPY --from=frontend-builder /app/dist /opt/atrium/static/host
|
|
39
|
+
|
|
40
|
+
USER app
|
|
41
|
+
|
|
42
|
+
# Re-declare HEALTHCHECK on the derived image. The base atrium image
|
|
43
|
+
# already ships one (curl /healthz on :8000), but image scanners flag
|
|
44
|
+
# absence at the leaf Dockerfile because they don't trace inheritance.
|
|
45
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
|
46
|
+
CMD curl -fsS http://localhost:8000/healthz || exit 1
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# __BRAND_NAME__ — top-level Make targets.
|
|
2
|
+
#
|
|
3
|
+
# Convenience wrappers around the docker-compose stack. Most of these
|
|
4
|
+
# assume you ran `make dev-bootstrap` first to bring everything up.
|
|
5
|
+
|
|
6
|
+
COMPOSE := docker compose
|
|
7
|
+
|
|
8
|
+
.PHONY: help dev-bootstrap up down build logs ps migrate \
|
|
9
|
+
seed-admin seed-bundle test test-frontend test-backend typecheck
|
|
10
|
+
|
|
11
|
+
help: ## show this help
|
|
12
|
+
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
|
13
|
+
|
|
14
|
+
dev-bootstrap: build up migrate ## build, start, migrate; run me first
|
|
15
|
+
@echo
|
|
16
|
+
@echo "Stack is up. Next: make seed-admin EMAIL=you@example.com PASSWORD=..."
|
|
17
|
+
@echo "Then: make seed-bundle && open http://localhost:8000"
|
|
18
|
+
|
|
19
|
+
up: ## start the stack
|
|
20
|
+
$(COMPOSE) up -d
|
|
21
|
+
|
|
22
|
+
down: ## stop the stack (keeps data volume)
|
|
23
|
+
$(COMPOSE) down
|
|
24
|
+
|
|
25
|
+
build: ## (re)build the image
|
|
26
|
+
$(COMPOSE) build
|
|
27
|
+
|
|
28
|
+
logs: ## tail logs from all services
|
|
29
|
+
$(COMPOSE) logs -f
|
|
30
|
+
|
|
31
|
+
ps: ## list services
|
|
32
|
+
$(COMPOSE) ps
|
|
33
|
+
|
|
34
|
+
migrate: ## run atrium + host alembic chains
|
|
35
|
+
$(COMPOSE) exec -T api alembic upgrade head
|
|
36
|
+
$(COMPOSE) exec -T api alembic -c /opt/host_app/alembic.ini upgrade head
|
|
37
|
+
|
|
38
|
+
seed-admin: ## seed a super_admin (EMAIL=... PASSWORD=... [FULL_NAME=...])
|
|
39
|
+
@if [ -z "$(EMAIL)" ] || [ -z "$(PASSWORD)" ]; then \
|
|
40
|
+
echo "usage: make seed-admin EMAIL=you@example.com PASSWORD=secret [FULL_NAME='Your Name']"; \
|
|
41
|
+
exit 2; \
|
|
42
|
+
fi
|
|
43
|
+
$(COMPOSE) exec -T api python -m app.scripts.seed_admin \
|
|
44
|
+
--email "$(EMAIL)" --password "$(PASSWORD)" \
|
|
45
|
+
--full-name "$${FULL_NAME:-Admin}" --super-admin
|
|
46
|
+
|
|
47
|
+
seed-bundle: ## point system.host_bundle_url at /host/main.js
|
|
48
|
+
$(COMPOSE) exec -T api python -m __HOST_PKG__.scripts.seed_host_bundle /host/main.js
|
|
49
|
+
|
|
50
|
+
# Tests. The atrium runtime image strips pytest/pytest-asyncio/httpx
|
|
51
|
+
# to keep the prod image small; install on demand. The pip install is
|
|
52
|
+
# idempotent (no-op if the deps are already there) but it does cost
|
|
53
|
+
# one round-trip per invocation — bake a `dev` Dockerfile stage if
|
|
54
|
+
# you'll be running tests often.
|
|
55
|
+
test: test-frontend test-backend ## run host tests (frontend + backend)
|
|
56
|
+
|
|
57
|
+
test-frontend: ## frontend unit tests via vitest
|
|
58
|
+
cd frontend && pnpm install --frozen-lockfile 2>/dev/null || (cd frontend && pnpm install)
|
|
59
|
+
cd frontend && pnpm test
|
|
60
|
+
|
|
61
|
+
test-backend: ## backend smoke tests inside the api container
|
|
62
|
+
$(COMPOSE) exec -u root -T api /opt/venv/bin/python -m pip install \
|
|
63
|
+
--quiet --no-cache-dir pytest pytest-asyncio httpx
|
|
64
|
+
$(COMPOSE) exec -T api /opt/venv/bin/python -m pytest /opt/host_app/tests
|
|
65
|
+
|
|
66
|
+
typecheck: ## tsc --noEmit on the host bundle
|
|
67
|
+
cd frontend && pnpm typecheck
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# __BRAND_NAME__
|
|
2
|
+
|
|
3
|
+
A host extension on top of [atrium](https://github.com/brendanbank/atrium).
|
|
4
|
+
Atrium ships the platform layer (auth, RBAC, audit, email, jobs,
|
|
5
|
+
notifications, admin shell); this repo adds the domain-specific routes,
|
|
6
|
+
models, and UI.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
cp .env.example .env
|
|
12
|
+
# Edit .env: set APP_SECRET_KEY, JWT_SECRET (openssl rand -hex 48 each),
|
|
13
|
+
# MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD.
|
|
14
|
+
|
|
15
|
+
make dev-bootstrap
|
|
16
|
+
make seed-admin EMAIL=you@example.com PASSWORD='a-good-password'
|
|
17
|
+
make seed-bundle
|
|
18
|
+
open http://localhost:8000
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Sign in with the seeded admin and the **__BRAND_NAME__** card appears on
|
|
22
|
+
the home page, with a sidebar link to `/__HOST_NAME__`, an admin tab,
|
|
23
|
+
and a profile-page card. Bump the counter to exercise the RBAC + audit
|
|
24
|
+
path end to end.
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
__HOST_NAME__/
|
|
30
|
+
Dockerfile # frontend-builder + FROM atrium runtime
|
|
31
|
+
compose.yaml # api + worker + mysql
|
|
32
|
+
.env.example # secrets template (copy to .env)
|
|
33
|
+
Makefile # dev-bootstrap / migrate / seed-* / test
|
|
34
|
+
backend/ # host Python package (__HOST_PKG__)
|
|
35
|
+
pyproject.toml
|
|
36
|
+
alembic.ini
|
|
37
|
+
alembic/ # alembic_version_app chain (separate from atrium)
|
|
38
|
+
src/__HOST_PKG__/
|
|
39
|
+
bootstrap.py # init_app(app), init_worker(host)
|
|
40
|
+
models.py # HostBase + your tables
|
|
41
|
+
router.py # FastAPI routes
|
|
42
|
+
scripts/seed_host_bundle.py
|
|
43
|
+
tests/ # pytest smoke tests
|
|
44
|
+
frontend/ # Vite library project (single main.js)
|
|
45
|
+
package.json
|
|
46
|
+
vite.config.ts # uses @brendanbank/atrium-host-bundle-utils/vite
|
|
47
|
+
src/
|
|
48
|
+
main.tsx # registry calls — ~10 lines
|
|
49
|
+
api.ts
|
|
50
|
+
queryClient.ts
|
|
51
|
+
__BRAND_PASCAL__Widget.tsx # home widget + admin tab + page demo
|
|
52
|
+
__BRAND_PASCAL__Page.tsx
|
|
53
|
+
__BRAND_PASCAL__AdminTab.tsx
|
|
54
|
+
__BRAND_PASCAL__ProfileItem.tsx
|
|
55
|
+
src/test/ # vitest setup + worked example using @brendanbank/atrium-test-utils
|
|
56
|
+
.github/workflows/ # CI (typecheck + tests + smoke)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## What atrium gives you (don't reimplement)
|
|
60
|
+
|
|
61
|
+
- Auth (password / TOTP / email OTP / WebAuthn) with role-mandatory 2FA
|
|
62
|
+
- RBAC: roles + permissions + super_admin + impersonation
|
|
63
|
+
- Account lifecycle: invite-only or self-serve signup, soft-delete + grace + hard-delete
|
|
64
|
+
- Audit log + retention pruning
|
|
65
|
+
- Email pipeline: templates per locale, durable outbox, retry/backoff
|
|
66
|
+
- In-app notifications + SSE bell
|
|
67
|
+
- Admin app-config (branding, system flags, translations, auth toggles)
|
|
68
|
+
- Maintenance mode (super_admin bypass)
|
|
69
|
+
|
|
70
|
+
If you find yourself writing one of these, stop — atrium has it. See
|
|
71
|
+
the atrium repo's `CLAUDE.md` for the contract.
|
|
72
|
+
|
|
73
|
+
## What lives in this repo
|
|
74
|
+
|
|
75
|
+
- Your domain models (on `HostBase`, never `app.db.Base`)
|
|
76
|
+
- Your routes (gated by `current_user` or `require_perm("...")`)
|
|
77
|
+
- Your migrations (on the `alembic_version_app` chain)
|
|
78
|
+
- Your frontend pages / widgets / admin tabs
|
|
79
|
+
- Your background jobs (`host.scheduler.add_job` for ticks,
|
|
80
|
+
`host.register_job_handler` for durable queue work)
|
|
81
|
+
|
|
82
|
+
## Adding things
|
|
83
|
+
|
|
84
|
+
| You want to add | Where it lives | Wire it via |
|
|
85
|
+
|-------------------------------|------------------------------------------------------|----------------------------------------------------------------------------|
|
|
86
|
+
| HTTP endpoint | `__HOST_PKG__/router.py` | `app.include_router(router)` in `init_app` |
|
|
87
|
+
| New permission | A new alembic migration | `seed_permissions_sync(op.get_bind(), [...], grants={...})` |
|
|
88
|
+
| Recurring tick | `__HOST_PKG__/schedule.py` async function | `host.scheduler.add_job(fn, "interval", seconds=N, ...)` in `init_worker` |
|
|
89
|
+
| Durable async job | A handler `(session, job, payload) -> None` | `host.register_job_handler(kind="...", handler=..., description="...")` |
|
|
90
|
+
| Admin-tunable flag | A Pydantic `BaseModel` config class | `register_namespace("ns", Model, public=False)` in `init_app` |
|
|
91
|
+
| Per-user notification | Inside the txn that mutated the row | `from app.services.notifications import notify_user` |
|
|
92
|
+
| Outbound email (queued) | A template row in `email_templates` + a callsite | `from app.email.sender import enqueue_and_log` |
|
|
93
|
+
| Audit row | Inside the txn that mutated the row | `from app.services.audit import record` |
|
|
94
|
+
| Home widget | A React component in `frontend/src/` | `reg.registerHomeWidget({ key, render })` |
|
|
95
|
+
| Dedicated route | A page component | `reg.registerRoute({ key, path, element, layout? })` |
|
|
96
|
+
| Sidebar link | A label + path | `reg.registerNavItem({ key, label, to, icon? })` |
|
|
97
|
+
| Admin tab | A component, gated by a permission | `reg.registerAdminTab({ key, label, icon?, perm, element })` |
|
|
98
|
+
| Profile-page card | A component | `reg.registerProfileItem({ key, slot?, render, condition? })` |
|
|
99
|
+
|
|
100
|
+
For the full backend extension surface see
|
|
101
|
+
[atrium's `docs/new-project/README.md`](https://github.com/brendanbank/atrium/blob/master/docs/new-project/README.md).
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
make test # frontend unit tests + backend smoke tests
|
|
107
|
+
make typecheck # tsc --noEmit on the host bundle
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Pinning atrium
|
|
111
|
+
|
|
112
|
+
`compose.yaml` reads `ATRIUM_IMAGE` from `.env` (default
|
|
113
|
+
`ghcr.io/brendanbank/atrium:__ATRIUM_VERSION__`). Override to test
|
|
114
|
+
against a specific release:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
ATRIUM_IMAGE=ghcr.io/brendanbank/atrium:0.15.0 make build up
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The frontend SDK packages
|
|
121
|
+
(`@brendanbank/atrium-host-types`, `@brendanbank/atrium-host-bundle-utils`,
|
|
122
|
+
`@brendanbank/atrium-test-utils`) version in lockstep with the image
|
|
123
|
+
— a pin of `^__ATRIUM_VERSION__` matches any patch of the
|
|
124
|
+
`__ATRIUM_VERSION__.x` line.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Host-side alembic environment.
|
|
2
|
+
|
|
3
|
+
Three things differ from atrium's ``backend/alembic/env.py``:
|
|
4
|
+
|
|
5
|
+
1. ``target_metadata`` points at the host's ``HostBase.metadata`` so
|
|
6
|
+
autogenerate only sees host tables. Atrium's tables are managed by
|
|
7
|
+
atrium's own chain — never touch them from here.
|
|
8
|
+
2. ``version_table`` is set to ``alembic_version_app`` so the two
|
|
9
|
+
chains track their heads independently. They share the database
|
|
10
|
+
but never collide on a revision.
|
|
11
|
+
3. ``process_revision_directives`` is wired to
|
|
12
|
+
``emit_host_foreign_keys`` so columns declared with
|
|
13
|
+
``HostForeignKey()`` get their cross-base ``ForeignKeyConstraint``
|
|
14
|
+
emitted into autogenerated migrations.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from logging.config import fileConfig
|
|
20
|
+
|
|
21
|
+
from sqlalchemy import pool
|
|
22
|
+
from sqlalchemy.engine import Connection
|
|
23
|
+
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
24
|
+
|
|
25
|
+
from alembic import context
|
|
26
|
+
from app.host_sdk.alembic import emit_host_foreign_keys
|
|
27
|
+
from app.settings import get_settings
|
|
28
|
+
|
|
29
|
+
# ``HostBase`` is the metadata target; importing it from
|
|
30
|
+
# ``__HOST_PKG__.models`` also runs the package's ``__init__``, which
|
|
31
|
+
# registers every model class on ``HostBase.metadata`` before
|
|
32
|
+
# autogenerate inspects it.
|
|
33
|
+
from __HOST_PKG__.models import HostBase
|
|
34
|
+
|
|
35
|
+
VERSION_TABLE = "alembic_version_app"
|
|
36
|
+
|
|
37
|
+
config = context.config
|
|
38
|
+
|
|
39
|
+
if config.config_file_name is not None:
|
|
40
|
+
fileConfig(config.config_file_name)
|
|
41
|
+
|
|
42
|
+
if not config.get_main_option("sqlalchemy.url") or config.get_main_option(
|
|
43
|
+
"sqlalchemy.url"
|
|
44
|
+
).startswith("driver://"):
|
|
45
|
+
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
|
46
|
+
|
|
47
|
+
target_metadata = HostBase.metadata
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _include_object(obj, name, type_, reflected, compare_to):
|
|
51
|
+
"""Filter atrium tables out of autogenerate.
|
|
52
|
+
|
|
53
|
+
Both atrium and the host write to the same MySQL database. With
|
|
54
|
+
``target_metadata = HostBase.metadata`` alembic sees atrium's
|
|
55
|
+
tables as "in the DB but not in the model" and would emit
|
|
56
|
+
``drop_table`` ops for every one of them on the first
|
|
57
|
+
autogenerate. Only emit ops for tables (and child objects) that
|
|
58
|
+
live in ``HostBase.metadata``.
|
|
59
|
+
"""
|
|
60
|
+
if type_ == "table":
|
|
61
|
+
return name in target_metadata.tables
|
|
62
|
+
if reflected:
|
|
63
|
+
parent = getattr(obj, "table", None)
|
|
64
|
+
if parent is not None and parent.name not in target_metadata.tables:
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_migrations_offline() -> None:
|
|
70
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
71
|
+
context.configure(
|
|
72
|
+
url=url,
|
|
73
|
+
target_metadata=target_metadata,
|
|
74
|
+
literal_binds=True,
|
|
75
|
+
dialect_opts={"paramstyle": "named"},
|
|
76
|
+
version_table=VERSION_TABLE,
|
|
77
|
+
include_object=_include_object,
|
|
78
|
+
process_revision_directives=emit_host_foreign_keys,
|
|
79
|
+
)
|
|
80
|
+
with context.begin_transaction():
|
|
81
|
+
context.run_migrations()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def do_run_migrations(connection: Connection) -> None:
|
|
85
|
+
context.configure(
|
|
86
|
+
connection=connection,
|
|
87
|
+
target_metadata=target_metadata,
|
|
88
|
+
version_table=VERSION_TABLE,
|
|
89
|
+
compare_type=True,
|
|
90
|
+
render_as_batch=False,
|
|
91
|
+
include_object=_include_object,
|
|
92
|
+
process_revision_directives=emit_host_foreign_keys,
|
|
93
|
+
)
|
|
94
|
+
with context.begin_transaction():
|
|
95
|
+
context.run_migrations()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def run_async_migrations() -> None:
|
|
99
|
+
connectable = async_engine_from_config(
|
|
100
|
+
config.get_section(config.config_ini_section, {}),
|
|
101
|
+
prefix="sqlalchemy.",
|
|
102
|
+
poolclass=pool.NullPool,
|
|
103
|
+
)
|
|
104
|
+
async with connectable.connect() as connection:
|
|
105
|
+
await connection.run_sync(do_run_migrations)
|
|
106
|
+
await connectable.dispose()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_migrations_online() -> None:
|
|
110
|
+
asyncio.run(run_async_migrations())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if context.is_offline_mode():
|
|
114
|
+
run_migrations_offline()
|
|
115
|
+
else:
|
|
116
|
+
run_migrations_online()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Sequence, Union
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
${imports if imports else ""}
|
|
15
|
+
|
|
16
|
+
revision: str = ${repr(up_revision)}
|
|
17
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
18
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
19
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
${upgrades if upgrades else "pass"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def downgrade() -> None:
|
|
27
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""__HOST_PKG__ initial schema + brand seed
|
|
2
|
+
|
|
3
|
+
Revision ID: 0001_init
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-04-28
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from alembic import op
|
|
14
|
+
from app.auth.rbac_seed import seed_permissions_sync
|
|
15
|
+
from sqlalchemy.dialects.mysql import DATETIME as MysqlDATETIME
|
|
16
|
+
|
|
17
|
+
revision: str = "0001_init"
|
|
18
|
+
down_revision: str | None = None
|
|
19
|
+
branch_labels: str | Sequence[str] | None = None
|
|
20
|
+
depends_on: str | Sequence[str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def upgrade() -> None:
|
|
24
|
+
# Demo singleton table — delete this and `__HOST_PKG__/models.py`
|
|
25
|
+
# once you've added your real domain models. The router and the
|
|
26
|
+
# frontend widget reference it; remove those callsites at the
|
|
27
|
+
# same time.
|
|
28
|
+
op.create_table(
|
|
29
|
+
"__HOST_PKG___state",
|
|
30
|
+
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=False),
|
|
31
|
+
sa.Column(
|
|
32
|
+
"message",
|
|
33
|
+
sa.String(length=255),
|
|
34
|
+
nullable=False,
|
|
35
|
+
server_default=sa.text("'Welcome to __BRAND_NAME__'"),
|
|
36
|
+
),
|
|
37
|
+
sa.Column(
|
|
38
|
+
"counter",
|
|
39
|
+
sa.BigInteger(),
|
|
40
|
+
nullable=False,
|
|
41
|
+
server_default=sa.text("0"),
|
|
42
|
+
),
|
|
43
|
+
sa.Column(
|
|
44
|
+
"updated_at",
|
|
45
|
+
MysqlDATETIME(fsp=6),
|
|
46
|
+
nullable=False,
|
|
47
|
+
server_default=sa.text("CURRENT_TIMESTAMP(6)"),
|
|
48
|
+
server_onupdate=sa.text("CURRENT_TIMESTAMP(6)"),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Singleton row so the demo route doesn't have to lazy-create.
|
|
53
|
+
op.execute(
|
|
54
|
+
"INSERT INTO __HOST_PKG___state (id, message, counter) "
|
|
55
|
+
"VALUES (1, 'Welcome to __BRAND_NAME__', 0)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Permissions belong with the schema. The runtime form
|
|
59
|
+
# (`seed_permissions`) exists for hosts that discover permissions
|
|
60
|
+
# at startup; for static codes, the migration form is the natural
|
|
61
|
+
# fit. Atrium auto-grants every seeded permission to super_admin;
|
|
62
|
+
# `grants` adds extra role bindings.
|
|
63
|
+
seed_permissions_sync(
|
|
64
|
+
op.get_bind(),
|
|
65
|
+
["__HOST_PKG__.read", "__HOST_PKG__.write"],
|
|
66
|
+
grants={"admin": ["__HOST_PKG__.write"]},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Materialise an initial BrandConfig so the SPA renders with the
|
|
70
|
+
# host's name + primary colour from the first page load. Atrium
|
|
71
|
+
# exposes this via /app-config and re-reads it within the 2 s
|
|
72
|
+
# cache TTL when the admin tweaks branding live.
|
|
73
|
+
brand = json.dumps(
|
|
74
|
+
{
|
|
75
|
+
"name": "__BRAND_NAME__",
|
|
76
|
+
"preset": "default",
|
|
77
|
+
"overrides": {"primaryColor": "__BRAND_PRIMARY__"},
|
|
78
|
+
}
|
|
79
|
+
)
|
|
80
|
+
op.execute(
|
|
81
|
+
sa.text(
|
|
82
|
+
"INSERT INTO app_settings (`key`, value) VALUES ('brand', :v) "
|
|
83
|
+
"ON DUPLICATE KEY UPDATE value = :v"
|
|
84
|
+
).bindparams(v=brand)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def downgrade() -> None:
|
|
89
|
+
# Permissions intentionally left in place — they're cheap to keep
|
|
90
|
+
# and removing them would orphan any UI that still references the
|
|
91
|
+
# code. Brand seed left alone for the same reason.
|
|
92
|
+
op.drop_table("__HOST_PKG___state")
|