diff --git a/.dockerignore b/.dockerignore index bd66447..2687247 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,12 @@ .git .env +.env.* +.venv/ +**/.pytest_cache/ +stitch_magent_media_operations_redesign/ +*.tar +*.tar.gz +*.zip bootstrap-admin.json release.tar *.log diff --git a/.gitignore b/.gitignore index 5ae516a..c4c8f77 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ backend/.pytest_cache/ frontend/node_modules/ frontend/.next/ *.log +**/.pytest_cache/ +.env.* +!.env.example +!.env.*.example +*.tar +*.tar.gz +*.zip diff --git a/PRODUCTION.md b/PRODUCTION.md index 03ce1cd..d15b604 100644 --- a/PRODUCTION.md +++ b/PRODUCTION.md @@ -1,64 +1,61 @@ -# Fresh production setup +# Production -Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and -`docker-compose.production.yml`. The legacy `prod` deployment and beta are not -overwritten. Main runs CI verification; production activation is deliberately -manual during the initial cutover. +Magent runs as one combined frontend/API image: `rephl3xnz/magent`. +The root `Dockerfile` is the supported build entry point. Source releases come +from `main`; use `prod-` tags to identify an exact release. -Only API connection URLs/credentials and SMTP configuration are exported by -`scripts/prepare_production_settings.py`. It reads the source's effective settings, -uses an explicit allowlist, refuses existing output directories, and creates -private files. It never copies a database, users, invite codes, issues, history, -tokens, sessions, branding or notification templates. A new bootstrap admin and -JWT secret are generated. Retrieve the bootstrap credentials from the protected -`bootstrap-admin.json` on the server; never commit them. +## Live deployment -The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables -`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic -imports and repair emails. The cover is not an authentication/security boundary; -normal API authentication remains in force. Administrators can use `/login`. +- Host: GRZ-DKR01 (`10.30.1.81`). +- Container and Compose service: `magent`; Compose project: `arrstack`. +- Compose file: `/home/zak/grizzlystack/arrstack/docker-compose.yml`. +- Persistent data: `/home/zak/grizzlystack/arrstack/magent/data` → `/app/data`. +- Public URL: `https://magent.grizzlyflix.co.nz`. +- Caddy runs on AMS-CAD01 and proxies production to `10.30.1.81:3002`. +- Beta remains separate on AMS-DEV01. Do not overwrite it or change its routes. -Run `docker compose -f docker-compose.production.yml up -d --build` from the -production directory. Caddy should proxy this hostname to `10.30.1.32:3200`; -Next forwards `/api` internally. The backend health port is localhost-only at -8200. Do not alter beta's route or other Caddy sites. +## Release checklist -Before public activation, validate Caddy config, save its existing configuration, -verify HTTPS, admin login, connection diagnostics and the empty-client-data state. -Do not send SMTP tests without approval. Keep the old upstream for rollback. +1. Run the backend tests and frontend production build. Review only the intended + changes, then commit and push `main`. +2. Build from a clean source export using the root Dockerfile. Never include + `.env`, databases or bootstrap credentials in the build context. +3. Publish `rephl3xnz/magent:prod-` and `:latest` to Docker Hub. + Confirm their digests match. +4. Pull the new image before stopping production. Keep the old image under a + rollback tag and back up the current Compose configuration. +5. Briefly stop only `magent`, then back up its complete data directory so SQLite + and its WAL files are consistent. Protect backups: they contain private data. +6. Recreate only this service with `docker compose -p arrstack -f + /home/zak/grizzlystack/arrstack/docker-compose.yml up -d --no-deps --no-build magent`. + Confirm that Compose selects the intended image before running this command. +7. Check container health, the API `/health` endpoint, public login, the changed + feature, database integrity and account counts. Do not trigger bulk permission + changes, email sends or user imports as a deployment smoke test. -At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`, -then recreate the container. External service records can then be imported through -normal synchronization; no beta client data is migrated. Review quality profiles, -root folders, invite policy and notification rules in admin settings before use. +For rollback, select the saved image and recreate only Magent. Restore data only +if needed; doing so can discard activity since the backup. Never restore a whole +shared Compose or Caddy file without checking for unrelated changes first. -## Initial cutover — 7 September 2026 +## Build metadata -- Public HTTPS cover and `/api/health` verified after cutover. -- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`. -- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`. -- Only the `magent.grizzlyflix.co.nz` upstream changed, from - `10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged. -- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`. - Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then - `sudo systemctl reload caddy`. Review subsequent edits before restoring the - whole file; the old application was not stopped or deleted. -- Initial database: one newly generated bootstrap admin; zero invites, issues, - cached requests, actions or snapshots. Login smoke-testing subsequently creates - normal admin login activity only. -- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on - AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active. -- No SMTP message was sent as part of validation. Background jobs remain paused. +`.build_number` and `backend/app/build_info.py` currently hold the same legacy +display build number as the frontend package files. `.env` should have exactly +one `BUILD_NUMBER` assignment, not a history of previous releases. Docker release +tags identify the deployed source commit independently of this display value. -## Cover resilience update +`scripts/process1.ps1` is a local development workflow: it updates metadata, +runs tests, rebuilds local Docker, and can commit changes/send Discord messages. +It is **not** the production deployment command. Its build-number helper can be +tested safely with `powershell -File scripts/test_env_build_number.ps1`. -The application host subsequently became unreachable over TCP from Caddy (both -3100 and 3200 timed out, despite responding to ping). The cover is now served -directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from -`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`. -It needs no application server, JavaScript, API or external assets. -Other paths retain the production reverse proxy. Full launch now also requires -removing the `@landing`/static `handle` block from the production Caddy site once -upstream connectivity is stable; the environment switch alone is insufficient. -Pre-static configuration backup: -`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`. +## Fresh instances and historical notes + +`scripts/prepare_production_settings.py` exports only allowlisted connection and +SMTP settings for a fresh instance. Do not use it to replace a live database. +`docker-compose.production.yml` is the separate fresh-instance template, not the +live GRZ-DKR01 Compose file. `docker-compose.hub.yml` is the generic Docker Hub +template; `docker-compose.yml` builds locally; `docker-compose.beta.yml` serves beta. + +The temporary AMS-DEV01 setup and coming-soon cutover are retained under +[archived cutover notes](docs/archive/production-cutover-2026-09-07.md). diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 4dfb611..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM python:3.12-slim - -WORKDIR /app - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -COPY backend/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY backend/app ./app -COPY data/branding /app/data/branding - -EXPOSE 8000 - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/db.py b/backend/app/db.py index eab17c4..e682f9e 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1573,7 +1573,7 @@ def delete_user_profile(profile_id: int) -> bool: def _row_to_signup_invite(row: Any) -> Dict[str, Any]: - max_uses = row[6] + max_uses = 1 if row[10] else row[6] use_count = int(row[7] or 0) expires_at = row[9] is_expired = _is_datetime_in_past(expires_at) @@ -1657,6 +1657,8 @@ def create_signup_invite( recipient_email: Optional[str] = None, created_by: Optional[str] = None, ) -> Dict[str, Any]: + if recipient_email: + max_uses = 1 timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: cursor = conn.execute( @@ -1714,6 +1716,11 @@ def update_signup_invite( expires_at: Optional[str], recipient_email: Optional[str], ) -> Optional[Dict[str, Any]]: + existing = get_signup_invite_by_id(invite_id) + if recipient_email or (existing and existing.get('recipient_email')): + max_uses = 1 + if existing and existing.get('recipient_email') and int(existing.get('use_count') or 0) > 0 and recipient_email != existing.get('recipient_email'): + raise ValueError('A used email invitation cannot be reassigned.') timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: cursor = conn.execute( @@ -1751,6 +1758,24 @@ def delete_signup_invite(invite_id: int) -> bool: return cursor.rowcount > 0 +def reserve_signup_invite_use(invite_id: int) -> bool: + """Atomically reserve capacity before any remote account is provisioned.""" + with _connect() as conn: + cursor = conn.execute(''' + UPDATE signup_invites SET use_count = use_count + 1 + WHERE id = ? AND enabled = 1 + AND (expires_at IS NULL OR julianday(expires_at) > julianday('now')) + AND ((recipient_email IS NOT NULL AND recipient_email != '' AND use_count < 1) + OR ((recipient_email IS NULL OR recipient_email = '') AND (max_uses IS NULL OR use_count < max_uses))) + ''', (invite_id,)) + return cursor.rowcount == 1 + + +def release_signup_invite_use(invite_id: int) -> None: + with _connect() as conn: + conn.execute('UPDATE signup_invites SET use_count = MAX(0, use_count - 1) WHERE id = ?', (invite_id,)) + + def increment_signup_invite_use(invite_id: int) -> None: timestamp = datetime.now(timezone.utc).isoformat() with _connect() as conn: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 7f87311..8273a6d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1919,6 +1919,20 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]: message = _normalize_optional_text(payload.get("message")) reason = _normalize_optional_text(payload.get("reason")) + if template_key == 'invited': + if not invite: + raise HTTPException(status_code=400, detail='Choose an invitation before sending it.') + if int(invite.get('use_count') or 0) > 0: + raise HTTPException(status_code=400, detail='This invitation has already been used. Create a new invitation.') + if invite.get('recipient_email') and normalize_delivery_email(invite['recipient_email']) != recipient_email: + raise HTTPException(status_code=400, detail='This invitation belongs to a different recipient. Create a new invitation.') + invite = update_signup_invite( + int(invite['id']), code=invite['code'], label=invite.get('label'), + description=invite.get('description'), profile_id=invite.get('profile_id'), + role=invite.get('role'), max_uses=1, enabled=bool(invite.get('enabled')), + expires_at=invite.get('expires_at'), recipient_email=recipient_email, + ) + try: result = await send_templated_email( template_key, diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b219748..b58847c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -28,7 +28,8 @@ from ..db import ( create_signup_invite, update_signup_invite, delete_signup_invite, - increment_signup_invite_use, + reserve_signup_invite_use, + release_signup_invite_use, get_user_profile, get_user_activity, get_user_activity_summary, @@ -398,6 +399,7 @@ def _auth_success_response(response: Response, token: str, user_payload: dict) - def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: return { "code": invite.get("code"), + "email_bound": bool(invite.get("recipient_email")), "label": invite.get("label"), "description": invite.get("description"), "enabled": bool(invite.get("enabled")), @@ -920,6 +922,16 @@ async def signup(payload: dict, response: Response) -> dict: if remaining_uses is not None and int(remaining_uses) <= 0: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses") + account_email = normalize_delivery_email(invite.get('recipient_email')) + if account_email: + supplied_email = str(payload.get('email') or '').strip() + if supplied_email and normalize_delivery_email(supplied_email) != account_email: + raise HTTPException(status_code=400, detail='This invitation is tied to the email address it was sent to.') + else: + account_email = normalize_delivery_email(payload.get('email')) + if not account_email: + raise HTTPException(status_code=400, detail='A valid email address is required to create your account.') + profile = None profile_id = invite.get("profile_id") if profile_id is not None: @@ -946,113 +958,120 @@ async def signup(payload: dict, response: Response) -> dict: if isinstance(account_expires_days, int) and account_expires_days > 0: expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() - runtime = get_runtime_settings() - auth_provider = "local" - local_password_value = password_value - matched_jellyseerr_user_id: int | None = None - - jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) - if jellyfin_client.configured(): - logger.info("signup provisioning jellyfin username=%s", username) - auth_provider = "jellyfin" + if not reserve_signup_invite_use(int(invite['id'])): + raise HTTPException(status_code=403, detail='This invitation has already been used or is unavailable.') + account_created = False + try: + runtime = get_runtime_settings() + auth_provider = "local" local_password_value = password_value - try: - await jellyfin_client.create_user_with_password(username, password_value) - except httpx.HTTPStatusError as exc: - status_code = exc.response.status_code if exc.response is not None else None - duplicate_like = status_code in {400, 409} - if duplicate_like: - try: - auth_response = await jellyfin_client.authenticate_by_name(username, password_value) - except Exception as auth_exc: - detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc) + matched_jellyseerr_user_id: int | None = None + + jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if jellyfin_client.configured(): + logger.info("signup provisioning jellyfin username=%s", username) + auth_provider = "jellyfin" + local_password_value = password_value + try: + await jellyfin_client.create_user_with_password(username, password_value) + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response is not None else None + duplicate_like = status_code in {400, 409} + if duplicate_like: + try: + auth_response = await jellyfin_client.authenticate_by_name(username, password_value) + except Exception as auth_exc: + detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Jellyfin account already exists and could not be authenticated: {detail}", + ) from exc + if not isinstance(auth_response, dict) or not auth_response.get("User"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Jellyfin account already exists for that username.", + ) from exc + else: + detail = _extract_http_error_detail(exc) raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Jellyfin account already exists and could not be authenticated: {detail}", + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Jellyfin account provisioning failed: {detail}", ) from exc - if not isinstance(auth_response, dict) or not auth_response.get("User"): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Jellyfin account already exists for that username.", - ) from exc - else: + except Exception as exc: detail = _extract_http_error_detail(exc) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Jellyfin account provisioning failed: {detail}", ) from exc - except Exception as exc: - detail = _extract_http_error_detail(exc) - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"Jellyfin account provisioning failed: {detail}", - ) from exc - await _refresh_jellyfin_user_cache(jellyfin_client) - jellyseerr_users = get_cached_jellyseerr_users() - candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) - if candidate_map: - matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map) + await _refresh_jellyfin_user_cache(jellyfin_client) + jellyseerr_users = get_cached_jellyseerr_users() + candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) + if candidate_map: + matched_jellyseerr_user_id = match_jellyseerr_user_id(username, candidate_map) - try: - create_user( - username, - local_password_value, - role=role, - email=normalize_delivery_email(invite.get("recipient_email")) if isinstance(invite, dict) else None, - auth_provider=auth_provider, - jellyseerr_user_id=matched_jellyseerr_user_id, - auto_search_enabled=auto_search_enabled, - profile_id=int(profile_id) if profile_id is not None else None, - expires_at=expires_at, - invited_by_code=invite.get("code"), - ) - except Exception as exc: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc - - increment_signup_invite_use(int(invite["id"])) - created_user = get_user_by_username(username) - if auth_provider == "jellyfin": - sync_jellyfin_password_state(username, password_value) - if ( - created_user - and created_user.get("jellyseerr_user_id") is None - and matched_jellyseerr_user_id is not None - ): - set_user_jellyseerr_id(username, matched_jellyseerr_user_id) - created_user = get_user_by_username(username) - if created_user: try: - await send_templated_email( - "welcome", - invite=invite, - user=created_user, + create_user( + username, + local_password_value, + role=role, + email=account_email, + auth_provider=auth_provider, + jellyseerr_user_id=matched_jellyseerr_user_id, + auto_search_enabled=auto_search_enabled, + profile_id=int(profile_id) if profile_id is not None else None, + expires_at=expires_at, + invited_by_code=invite.get("code"), ) except Exception as exc: - # Welcome email delivery is best-effort and must not break signup. - logger.warning("Welcome email send skipped for %s: %s", username, exc) - _assert_user_can_login(created_user) - token = create_access_token(username, role) - set_last_login(username) - logger.info( - "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s", - username, - role, - created_user.get("auth_provider") if created_user else auth_provider, - created_user.get("profile_id") if created_user else None, - invite.get("code"), - ) - return _auth_success_response( - response, - token, - { - "username": username, - "role": role, - "auth_provider": created_user.get("auth_provider") if created_user else auth_provider, - "profile_id": created_user.get("profile_id") if created_user else None, - "expires_at": created_user.get("expires_at") if created_user else None, - }, - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + account_created = True + created_user = get_user_by_username(username) + if auth_provider == "jellyfin": + sync_jellyfin_password_state(username, password_value) + if ( + created_user + and created_user.get("jellyseerr_user_id") is None + and matched_jellyseerr_user_id is not None + ): + set_user_jellyseerr_id(username, matched_jellyseerr_user_id) + created_user = get_user_by_username(username) + if created_user: + try: + await send_templated_email( + "welcome", + invite=invite, + user=created_user, + ) + except Exception as exc: + # Welcome email delivery is best-effort and must not break signup. + logger.warning("Welcome email send skipped for %s: %s", username, exc) + _assert_user_can_login(created_user) + token = create_access_token(username, role) + set_last_login(username) + logger.info( + "signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s", + username, + role, + created_user.get("auth_provider") if created_user else auth_provider, + created_user.get("profile_id") if created_user else None, + invite.get("code"), + ) + return _auth_success_response( + response, + token, + { + "username": username, + "role": role, + "auth_provider": created_user.get("auth_provider") if created_user else auth_provider, + "profile_id": created_user.get("profile_id") if created_user else None, + "expires_at": created_user.get("expires_at") if created_user else None, + }, + ) + finally: + if not account_created: + release_signup_invite_use(int(invite['id'])) @router.post("/password/forgot") diff --git a/backend/tests/test_invite_email_signup.py b/backend/tests/test_invite_email_signup.py new file mode 100644 index 0000000..df99eb1 --- /dev/null +++ b/backend/tests/test_invite_email_signup.py @@ -0,0 +1,54 @@ +import asyncio +import unittest +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from fastapi import HTTPException, Response +from backend.app import db +from backend.app.routers import auth +from backend.tests.test_backend_quality import TempDatabaseMixin + + +class InviteEmailSignupTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): + async def signup(self, code, username, **extra): + with patch.object(auth, 'get_runtime_settings', return_value=SimpleNamespace(jellyfin_base_url=None, jellyfin_api_key=None)), patch.object(auth, 'send_templated_email', new_callable=AsyncMock), patch.object(auth, 'create_access_token', return_value='test-token'): + return await auth.signup({'invite_code': code, 'username': username, 'password': 'Strong-Test-Password123!', **extra}, Response()) + + async def test_email_invite_binds_account_and_cannot_be_reused(self): + invite = db.create_signup_invite(code='EMAILTEST', recipient_email='recipient@example.com', max_uses=20) + self.assertEqual(invite['max_uses'], 1) + public = auth._public_invite_payload(invite) + self.assertTrue(public['email_bound']) + self.assertNotIn('recipient@example.com', str(public)) + await self.signup('EMAILTEST', 'first-user') + self.assertEqual(db.get_user_by_username('first-user')['email'], 'recipient@example.com') + with self.assertRaises(HTTPException): + await self.signup('EMAILTEST', 'second-user') + + async def test_email_invite_rejects_recipient_override(self): + db.create_signup_invite(code='BOUNDTEST', recipient_email='recipient@example.com') + with self.assertRaises(HTTPException): + await self.signup('BOUNDTEST', 'override-user', email='different@example.com') + self.assertEqual(db.get_signup_invite_by_code('BOUNDTEST')['use_count'], 0) + + async def test_manual_invite_requires_and_saves_email(self): + db.create_signup_invite(code='MANUALTEST', max_uses=3) + for email in ['', 'invalid']: + with self.assertRaises(HTTPException): + await self.signup('MANUALTEST', 'manual-user', email=email) + await self.signup('MANUALTEST', 'manual-user', email='manual@example.com') + self.assertEqual(db.get_user_by_username('manual-user')['email'], 'manual@example.com') + self.assertEqual(db.get_signup_invite_by_code('MANUALTEST')['remaining_uses'], 2) + + async def test_failed_creation_releases_reservation(self): + invite = db.create_signup_invite(code='FAILTEST', recipient_email='recipient@example.com') + with patch.object(auth, 'create_user', side_effect=RuntimeError('test failure')): + with self.assertRaises(HTTPException): + await self.signup('FAILTEST', 'failed-user') + self.assertEqual(db.get_signup_invite_by_id(invite['id'])['use_count'], 0) + + async def test_single_use_reservation_is_atomic(self): + invite = db.create_signup_invite(code='RACETEST', recipient_email='recipient@example.com') + with ThreadPoolExecutor(max_workers=4) as pool: + results = list(pool.map(db.reserve_signup_invite_use, [invite['id']] * 4)) + self.assertEqual(sum(results), 1) diff --git a/docs/archive/production-cutover-2026-09-07.md b/docs/archive/production-cutover-2026-09-07.md new file mode 100644 index 0000000..574b6e0 --- /dev/null +++ b/docs/archive/production-cutover-2026-09-07.md @@ -0,0 +1,68 @@ +# Historical production cutover notes — superseded + +These notes describe the temporary AMS-DEV01 setup, not the current production +deployment. Do not run these cutover or rollback instructions against the live +service. See [current production instructions](../../PRODUCTION.md). + +Production uses `main`, `/home/zak/magent-production` on AMS-DEV01 and +`docker-compose.production.yml`. The legacy `prod` deployment and beta are not +overwritten. Main runs CI verification; production activation is deliberately +manual during the initial cutover. + +Only API connection URLs/credentials and SMTP configuration are exported by +`scripts/prepare_production_settings.py`. It reads the source's effective settings, +uses an explicit allowlist, refuses existing output directories, and creates +private files. It never copies a database, users, invite codes, issues, history, +tokens, sessions, branding or notification templates. A new bootstrap admin and +JWT secret are generated. Retrieve the bootstrap credentials from the protected +`bootstrap-admin.json` on the server; never commit them. + +The initial production `.env` enables `MAGENT_COMING_SOON=true` and disables +`BACKGROUND_TASKS_ENABLED`. This presents the cover at `/` and pauses automatic +imports and repair emails. The cover is not an authentication/security boundary; +normal API authentication remains in force. Administrators can use `/login`. + +Run `docker compose -f docker-compose.production.yml up -d --build` from the +production directory. Caddy should proxy this hostname to `10.30.1.32:3200`; +Next forwards `/api` internally. The backend health port is localhost-only at +8200. Do not alter beta's route or other Caddy sites. + +Before public activation, validate Caddy config, save its existing configuration, +verify HTTPS, admin login, connection diagnostics and the empty-client-data state. +Do not send SMTP tests without approval. Keep the old upstream for rollback. + +At launch, set `MAGENT_COMING_SOON=false` and `BACKGROUND_TASKS_ENABLED=true`, +then recreate the container. External service records can then be imported through +normal synchronization; no beta client data is migrated. Review quality profiles, +root folders, invite policy and notification rules in admin settings before use. + +## Initial cutover — 7 September 2026 + +- Public HTTPS cover and `/api/health` verified after cutover. +- Caddy: AMS-CAD01, `/etc/caddy/Caddyfile`, systemd `caddy.service`. +- SSH worked via `10.30.40.254` using `HostKeyAlias=10.30.41.254`. +- Only the `magent.grizzlyflix.co.nz` upstream changed, from + `10.30.1.81:3002` to `10.30.1.32:3200`. Both beta blocks were unchanged. +- Rollback configuration: `/etc/caddy/Caddyfile.bak-magent-prod-20260907T0130`. + Restore it, run `sudo caddy validate --config /etc/caddy/Caddyfile`, then + `sudo systemctl reload caddy`. Review subsequent edits before restoring the + whole file; the old application was not stopped or deleted. +- Initial database: one newly generated bootstrap admin; zero invites, issues, + cached requests, actions or snapshots. Login smoke-testing subsequently creates + normal admin login activity only. +- Retrieve `/home/zak/magent-production/bootstrap-admin.json` securely on + AMS-DEV01. Sign in at `/login`, then open `/admin` while the cover is active. +- No SMTP message was sent as part of validation. Background jobs remain paused. + +## Cover resilience update + +The application host subsequently became unreachable over TCP from Caddy (both +3100 and 3200 timed out, despite responding to ping). The cover is now served +directly by Caddy from `/var/lib/caddy/magent-cover/index.html`, sourced from +`docker/coming-soon.html`, for `/`, `/coming-soon` and `/coming-soon/`. +It needs no application server, JavaScript, API or external assets. +Other paths retain the production reverse proxy. Full launch now also requires +removing the `@landing`/static `handle` block from the production Caddy site once +upstream connectivity is stable; the environment switch alone is insufficient. +Pre-static configuration backup: +`/etc/caddy/Caddyfile.bak-magent-static-20260907T0145`. diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index dfa29a1..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -FROM node:20-alpine AS builder - -WORKDIR /app - -ENV NEXT_TELEMETRY_DISABLED=1 - -COPY package.json ./ -RUN npm install - -COPY app ./app -COPY public ./public -COPY next-env.d.ts ./next-env.d.ts -COPY next.config.js ./next.config.js -COPY tsconfig.json ./tsconfig.json - -RUN npm run build - -FROM node:20-alpine - -WORKDIR /app - -ENV NEXT_TELEMETRY_DISABLED=1 \ - NODE_ENV=production - -COPY --from=builder /app/.next ./.next -COPY --from=builder /app/public ./public -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/next.config.js ./next.config.js - -EXPOSE 3000 - -CMD ["npm", "run", "start"] diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 98a0e30..60c7ec6 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -3631,13 +3631,51 @@ textarea:focus { } .issue-portal-page .portal-item-list { + display: flex; + flex-direction: column; + justify-content: flex-start; gap: 8px; max-height: 100%; padding-right: 4px; } .issue-portal-page .portal-item-row { - padding: 11px; + display: block; + flex: 0 0 auto; + width: 100%; + height: auto; + min-height: min-content; + max-height: none; + padding: 14px; + white-space: normal; + text-align: left; + line-height: 1.45; +} + +.issue-portal-page .portal-item-row-main { + display: flex; + flex-direction: column; + min-width: 0; +} + +.issue-portal-page .portal-item-row-title strong { + flex-basis: 100%; + min-width: 0; + font-size: 0.95rem; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.issue-portal-page .portal-item-row-meta { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--ops-line-soft); + line-height: 1.5; + overflow-wrap: anywhere; +} + +.issue-portal-page .portal-item-row-meta > span:last-child { + flex-basis: 100%; } .issue-portal-page .portal-item-row p { diff --git a/frontend/app/signup/page.tsx b/frontend/app/signup/page.tsx index 0dee25e..295c1e5 100644 --- a/frontend/app/signup/page.tsx +++ b/frontend/app/signup/page.tsx @@ -7,6 +7,7 @@ import { clearToken, getApiBase, setToken } from '../lib/auth' type InviteInfo = { code: string + email_bound?: boolean label?: string | null description?: string | null enabled: boolean @@ -38,14 +39,15 @@ function SignupPageContent() { const [inviteLoading, setInviteLoading] = useState(false) const [loading, setLoading] = useState(false) const [username, setUsername] = useState('') + const [email, setEmail] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [error, setError] = useState(null) const [status, setStatus] = useState(null) const canSubmit = useMemo(() => { - return Boolean(invite?.is_usable && username.trim() && password && !loading) - }, [invite, username, password, loading]) + return Boolean(invite?.is_usable && (invite.email_bound || email.trim()) && username.trim() && password && !loading && !inviteLoading) + }, [invite, email, username, password, loading, inviteLoading]) const lookupInvite = async (code: string) => { const trimmed = code.trim() @@ -110,6 +112,7 @@ function SignupPageContent() { body: JSON.stringify({ invite_code: inviteCode, username: username.trim(), + ...(!invite.email_bound ? { email: email.trim() } : {}), password, }), }) @@ -120,7 +123,7 @@ function SignupPageContent() { const data = await response.json() if (data?.authenticated) { setToken('cookie') - window.location.href = '/' + window.location.href = '/welcome' return } throw new Error('Sign-up did not complete') @@ -140,7 +143,7 @@ function SignupPageContent() {
setInviteCode(e.target.value)} + onChange={(e) => { setInviteCode(e.target.value); setInvite(null); setEmail('') }} placeholder="Paste your invite code" autoCapitalize="characters" /> @@ -171,6 +174,10 @@ function SignupPageContent() {
)} + {invite?.email_bound ?

Your account will use the email address this invitation was sent to. This invitation can be used once.

: }