diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index c6de86b..941a354 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -9,8 +9,9 @@ jobs:
# Adjust to match the labels of your Forgejo runner.
runs-on: docker
steps:
- - uses: https://data.forgejo.org/actions/checkout@v4
- - uses: https://github.com/astral-sh/setup-uv@v6
+ # Actions are pinned to commits so a moved tag can't change what runs here.
+ - uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: https://github.com/astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
with:
version: "0.13.6"
- run: uv sync --locked
@@ -23,8 +24,8 @@ jobs:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: docker
steps:
- - uses: https://data.forgejo.org/actions/checkout@v4
- - uses: https://github.com/superfly/flyctl-actions/setup-flyctl@master
+ - uses: https://data.forgejo.org/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+ - uses: https://github.com/superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # master, 2026-10
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
index ae52bc7..5a91643 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,13 +23,17 @@ CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py runserv
FROM base AS prod
COPY --from=litestream/litestream:0.5.17 /usr/local/bin/litestream /usr/local/bin/litestream
+# The app runs as this user; bin/start drops to it after making the data volume writable.
+RUN useradd --system --uid 1000 --create-home app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY litestream.yml /etc/litestream.yml
COPY . .
+# Placeholder settings: collectstatic needs the prod settings to import, nothing more.
RUN DJANGO_SETTINGS_MODULE=config.settings.prod DJANGO_SECRET_KEY=collectstatic \
+ EMAIL_BACKEND=django.core.mail.backends.dummy.EmailBackend \
python manage.py collectstatic --noinput
ENV DJANGO_SETTINGS_MODULE=config.settings.prod
EXPOSE 8000
diff --git a/bin/start b/bin/start
index 5de1914..036db3f 100755
--- a/bin/start
+++ b/bin/start
@@ -3,16 +3,24 @@
# because release machines don't mount the volume that holds the SQLite database.
set -eu
+# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
+if [ "$(id -u)" = 0 ]; then
+ chown -R app:app "${INTENT_DATA_DIR:?}"
+ exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
+fi
+
DB="${INTENT_DATA_DIR:?}/db.sqlite3"
SERVER="gunicorn config.wsgi --bind 0.0.0.0:8000 --workers 3 --access-logfile -"
if [ -z "${LITESTREAM_BUCKET:-}" ]; then
echo "LITESTREAM_BUCKET not set: running without backups" >&2
python manage.py migrate --noinput
+ python manage.py createcachetable
exec $SERVER
fi
# On a fresh volume, pull the latest backup before anything touches the database.
litestream restore -if-db-not-exists -if-replica-exists "$DB"
python manage.py migrate --noinput
+python manage.py createcachetable
exec litestream replicate -exec "$SERVER"
diff --git a/config/settings/base.py b/config/settings/base.py
index d12a8e0..2068ffc 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -76,6 +76,15 @@ DATABASES = {
}
}
+# Rate limits (allauth's and ours) live here, so the cache must be shared by every worker
+# and survive restarts. The database is the only shared store on a one-machine deploy.
+CACHES = {
+ "default": {
+ "BACKEND": "django.core.cache.backends.db.DatabaseCache",
+ "LOCATION": "cache",
+ }
+}
+
AUTH_USER_MODEL = "accounts.User"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -100,6 +109,10 @@ ACCOUNT_LOGIN_BY_CODE_ENABLED = True
ACCOUNT_LOGIN_BY_CODE_SUPPORTS_RESEND = True
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 600
ACCOUNT_LOGOUT_ON_GET = False
+# Adding an email address adds a way to sign in, so ask for a fresh code first, and tell
+# the existing addresses about it.
+ACCOUNT_REAUTHENTICATION_REQUIRED = True
+ACCOUNT_EMAIL_NOTIFICATIONS = True
SOCIALACCOUNT_PROVIDERS = {
"github": {"SCOPE": ["read:user", "user:email"]},
}
diff --git a/config/settings/dev.py b/config/settings/dev.py
index 4cdc7b7..380d0bc 100644
--- a/config/settings/dev.py
+++ b/config/settings/dev.py
@@ -5,3 +5,5 @@ SECRET_KEY = SECRET_KEY or "dev-insecure-secret-key" # noqa: F405
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
+# Avoid needing `createcachetable` for local development.
+CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
diff --git a/config/settings/prod.py b/config/settings/prod.py
index 535ee6b..b7655f2 100644
--- a/config/settings/prod.py
+++ b/config/settings/prod.py
@@ -7,6 +7,9 @@ from .base import * # noqa: F403
if not SECRET_KEY: # noqa: F405
raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set")
+# The console backend would print sign-in codes and invite links into the logs.
+if EMAIL_BACKEND == "django.core.mail.backends.console.EmailBackend": # noqa: F405
+ raise ImproperlyConfigured("EMAIL_BACKEND must be set to a real email backend in production")
ALLOWED_HOSTS = [h for h in os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h]
CSRF_TRUSTED_ORIGINS = [f"https://{h}" for h in ALLOWED_HOSTS]
diff --git a/config/settings/test.py b/config/settings/test.py
index 9567e0f..b908025 100644
--- a/config/settings/test.py
+++ b/config/settings/test.py
@@ -2,6 +2,7 @@ from .base import * # noqa: F403
SECRET_KEY = "test-secret-key"
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
+CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
STORAGES = {
**STORAGES, # noqa: F405
"default": {"BACKEND": "django.core.files.storage.InMemoryStorage"},
diff --git a/config/urls.py b/config/urls.py
index a1093ea..7d0712e 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -1,14 +1,31 @@
+import allauth.urls
+from allauth.account import urls as allauth_account_urls
from django.contrib import admin
-from django.urls import include, path
+from django.urls import URLResolver, include, path
from intent.core import views
from intent.orgs.urls import invite_urlpatterns
from intent.projects.views import project_create
+
+def _without_passwords(patterns):
+ """allauth's URLs minus the password views: regular users sign in with a code only."""
+ return [p for p in patterns if not str(p.pattern).startswith("password/")]
+
+
+account_urlpatterns = [
+ (
+ path("", include(_without_passwords(allauth_account_urls.urlpatterns)))
+ if isinstance(p, URLResolver) and p.urlconf_name is allauth_account_urls
+ else p
+ )
+ for p in allauth.urls.urlpatterns
+]
+
urlpatterns = [
path("", views.home, name="home"),
path("admin/", admin.site.urls),
- path("accounts/", include("allauth.urls")),
+ path("accounts/", include(account_urlpatterns)),
path("invites/", include(invite_urlpatterns)),
# Organizations own the rest of the URL space (see RESERVED_SLUGS in orgs and projects).
path("`) until uploads exist, so an author can't log readers' IP addresses. |
| Project owners | No last-owner guard: org owners can always administer every project. Leaving the org removes a user's project roles in it. |
| Feature editing | One form edits title, description, parent, status and replacement, with a required change note. Saving with no changes is refused. A "Preview description" button renders the Markdown server-side, so preview works without JS. Django admin shows features read-only so every change goes through revisions. |
@@ -46,8 +46,9 @@ Decisions made in the concept are not repeated. These fill the gaps.
| Topic | Default |
|---|---|
| Runtime | Python 3.13, Django 5.2 LTS, `uv` for dependencies, `ruff` for lint/format. |
-| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
-| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). |
+| Auth | `django-allauth`: GitHub, Discord, OpenID Connect provider per Forgejo instance (Forgejo supports OIDC). Sysadmins register each provider as a SocialApp in Django admin, so adding a Forgejo instance needs no deploy. Email sign-in is allauth's login-by-code: the "magic link" is an emailed one-time code, not a clickable link. Signup asks for email and username only; regular users never get a password, and allauth's password URLs are not routed at all. Adding an email address requires re-entering a fresh code, and existing addresses are notified. Multiple social accounts per user via allauth's connect flow (no automatic linking by email). Usernames: letters, digits, `-`, `_`, max 39. |
+| Sysadmins | Django `is_staff` users with passwords, Django admin at `/admin/`. A model constraint rejects staff users as org members. Regular users have no password (unusable). Only superusers can grant staff or superuser status or permissions in the admin. |
+| Cache | Django's database cache (`createcachetable` runs at start). It holds allauth's rate limits and the invite throttle, so it must be shared by all gunicorn workers and survive restarts; the database is the only shared store on a one-machine deploy. Dev and tests use the in-memory cache. |
| Database | SQLite on a Fly volume. `transaction_mode = "IMMEDIATE"`, WAL mode, `busy_timeout` 5 s. Litestream replicates to B2 continuously. |
| Files | Tigris via `django-storages` S3 backend, private bucket, time-limited signed URLs. HTML prototypes are shown in a sandboxed `