An open API service indexing awesome lists of open source software.

https://github.com/tyemirov/loopaware

Feedback Service to gather users' feedback
https://github.com/tyemirov/loopaware

feedback feedback-form website

Last synced: 1 day ago
JSON representation

Feedback Service to gather users' feedback

Awesome Lists containing this project

README

          

# LoopAware

[![CI](https://github.com/tyemirov/loopaware/actions/workflows/ci.yml/badge.svg)](https://github.com/tyemirov/loopaware/actions/workflows/ci.yml)
[![License: Source Available](https://img.shields.io/badge/License-Source%20Available-blue)](./LICENSE)
[![Go 1.26](https://img.shields.io/badge/Go-1.26-00ADD8?logo=go)](https://go.dev)
[![Latest Release](https://img.shields.io/github/v/release/tyemirov/loopaware)](https://github.com/tyemirov/loopaware/releases)

**Privacy-first feedback widget, traffic analytics, and developer monitoring.** Drop a single script tag on your site to collect customer feedback, capture email subscribers, track visits, and report browser errors -- all backed by a role-aware dashboard and a self-hosted SQLite backend.

- **Free** for personal and non-revenue projects
- **Commercial license** required for revenue-generating use
- See [LICENSE](./LICENSE) for details

## Quick Start

```bash
# Clone and start the development stack
git clone https://github.com/tyemirov/loopaware.git
cd loopaware
./scripts/up.sh

# Open the dashboard
open http://localhost:8080/login
```

Embed the feedback widget on any page:

```html

```

## Highlights

- Shared `mpr-ui` sign-in with TAuth-issued sessions and TAuth verifier-backed API protection
- Role-aware dashboard (`/app`) with admin, creator/owner, and per-site team-member scopes
- YAML configuration for privileged accounts (`configs/config.loopaware.yml`)
- REST API to create, update, and inspect sites, feedback, subscribers, and traffic
- Background favicon refresh scheduler with live dashboard notifications
- Embeddable JavaScript widget with strict origin validation
- Email subscription capture via an embeddable subscribe form
- Privacy-safe traffic pixel with per-site visit and visitor counts
- Daily, weekly, or monthly traffic report emails delivered through Pinguin to a manager, the whole site team, or selected members
- First-class LA Sentry developer error monitoring with protected server-to-server ingest and origin-bound browser capture
- SQLite-first storage with pluggable drivers
- Public privacy policy and compliance endpoints for visibility
- Table-driven tests and fast in-memory SQLite fixtures

## Configuration

### 1. Admin roster (`configs/config.loopaware.yml`)

Edit the tracked YAML file at `configs/config.loopaware.yml` with the email addresses that should receive administrator privileges (the file is optional if you prefer environment-only configuration):

```yaml
admins:
- temirov@gmail.com
```

LoopAware loads the file specified by `--config` (default `configs/config.loopaware.yml`) before starting the HTTP server.
Set the `ADMINS` environment variable with a comma-separated list (for example `ADMINS=alice@example.com,bob@example.com`) to override the YAML roster without editing the file. When neither source is present the server starts without administrators and records a warning in the logs.

### 2. Environment variables

Backend (`cmd/server`):

| Variable | Required | Description |
|------------------------|----------|-------------------------------------------------------------|
| `SESSION_SECRET` | ✅ | 32+ byte secret for subscription confirmation tokens |
| `TAUTH_BASE_URL` | ✅ | Base URL for the TAuth API |
| `TAUTH_TENANT_ID` | ✅ | Tenant identifier configured in TAuth |
| `TAUTH_JWT_SIGNING_KEY`| ✅ | JWT signing key used to validate `app_session` |
| `TAUTH_SESSION_COOKIE_NAME` | ⚙️ | Session cookie name set by TAuth (defaults to `app_session`) |
| `PINGUIN_ADDR` | ✅ | Pinguin gRPC address |
| `PINGUIN_AUTH_TOKEN`¹ | ✅ | Bearer token passed to the Pinguin gRPC service |
| `PINGUIN_TENANT_ID` | ✅ | Tenant identifier used when calling the Pinguin gRPC API |
| `TRAFFIC_REPORT_EMAILS_ENABLED` | ⚙️ | Enables scheduled/test traffic report emails (default `true`) |
| `ADMINS` | ⚙️ | Comma-separated admin emails; overrides the YAML roster |
| `PUBLIC_BASE_URL` | ⚙️ | Frontend origin used for CORS and subscription links |
| `APP_ADDR` | ⚙️ | Listen address (default `:8080`) |
| `DB_DRIVER` | ⚙️ | Storage driver (`sqlite`, etc.) |
| `DB_DSN` | ⚙️ | Driver-specific DSN |

Secrets must come from the environment; only non-sensitive settings belong in `configs/config.loopaware.yml`.

When running via Docker Compose, copy the tracked env templates under `configs/` and edit the local `.env.*` files:

```bash
cp configs/.env.loopaware.example configs/.env.loopaware
cp configs/.env.tauth.example configs/.env.tauth
cp configs/.env.pinguin.example configs/.env.pinguin
$EDITOR configs/.env.loopaware configs/.env.tauth configs/.env.pinguin
```

¹Pinguin and LoopAware must share the **exact same** bearer secret. Provide identical values for `GRPC_AUTH_TOKEN` and `PINGUIN_AUTH_TOKEN`, for example:

```dotenv
GRPC_AUTH_TOKEN=loopaware-local-secret
PINGUIN_AUTH_TOKEN=loopaware-local-secret
```

LoopAware falls back to `GRPC_AUTH_TOKEN` when `PINGUIN_AUTH_TOKEN` is empty, so exporting the shared value once at runtime also works.

### 3. Flags

All configuration options are also exposed as Cobra flags:

```
loopaware --config=configs/config.loopaware.yml \
--app-addr=:8080 \
--db-driver=sqlite \
--db-dsn="file:loopaware.sqlite?_foreign_keys=on" \
--session-secret=$SESSION_SECRET \
--tauth-base-url=$TAUTH_BASE_URL \
--tauth-tenant-id=$TAUTH_TENANT_ID \
--tauth-signing-key=$TAUTH_JWT_SIGNING_KEY \
--tauth-session-cookie-name=$TAUTH_SESSION_COOKIE_NAME \
--traffic-report-emails=true \
--public-base-url=https://feedback.example.com
```

Flags are optional when the equivalent environment variables are set.

## Running locally

For Docker-based local development, use the helper script:

```bash
./scripts/up.sh
```

Stop the local stack with:

```bash
./scripts/down.sh
```

`scripts/up.sh` is the canonical startup path for Dockerized LoopAware. With no argument it opens an interactive selector.
You can also call it explicitly as `./scripts/up.sh local` or `./scripts/up.sh computercat`.
The local compose stack now includes a gHTTP proxy that serves `web/` at `http://localhost:8080` and forwards `/api`,
`/auth`, and `/public` to the backend services. That proxy is also responsible for the browser-facing
security headers on the static HTML and proxied API responses in the local stack.

If you want to run only the API process without Docker, use:

```bash
SESSION_SECRET=$(openssl rand -hex 32) \
TAUTH_BASE_URL=http://localhost:8081 \
TAUTH_TENANT_ID=loopaware \
TAUTH_JWT_SIGNING_KEY=replace-with-tauth-jwt-signing-key \
TAUTH_SESSION_COOKIE_NAME=loopaware_development_session \
PUBLIC_BASE_URL=http://localhost:8080 \
go run ./cmd/server --config=configs/config.loopaware.yml
```

When serving the static frontend directly from `web/`, no preparation step is required. Keep the tracked runtime
config in `web/config.yml` and serve `web/` from the frontend origin or reverse proxy that will answer `/config.yml`,
`/api`, and `/auth`.

Then open `/app` on that frontend origin to trigger the shared sign-in flow.
Ensure the TAuth service is running at `TAUTH_BASE_URL` with a tenant that matches `TAUTH_TENANT_ID`.
Administrators listed in `configs/config.loopaware.yml` can manage every site; other users see sites they own, sites
they originally created with their authenticated account, or sites where an admin added their email as a team member.

The static frontend pins `mpr-ui` through CDN URLs and lets `mpr-ui` own browser authentication scaffolding. Do not copy
third-party browser bundles into `web/`; non-CDN frontend dependencies are forbidden by architecture.

## Authentication flow

1. Users visit `/login` (automatic redirect from protected routes).
2. `mpr-ui` drives the browser sign-in lifecycle against the configured TAuth tenant.
3. TAuth issues and refreshes the session cookie configured by `TAUTH_SESSION_COOKIE_NAME` (defaults to `app_session`).
4. `api.AuthManager` validates the session with TAuth's verifier, injects user details into the request context, and enforces admin,
owner, or team-member site access.
5. The dashboard and JSON APIs consume the authenticated context.

## Static frontend

LoopAware’s frontend lives in `web/` and is hosted separately (CDN or reverse proxy). It includes:

- `/login` — landing page with shared `mpr-ui`/TAuth sign-in.
- `/resources` — crawlable public resource index with focused product and use-case pages.
- `/privacy` — static privacy policy linked from the landing and dashboard footers.
- `/app` — dashboard shell (data loaded via `/api/*`).
- `/subscriptions/confirm` and `/subscriptions/unsubscribe` — email link pages.
- `/widget.js`, `/subscribe.js`, `/pixel.js`, `/la-sentry.js` — embeddable JavaScript assets.
- `/sentry/errors` — protected server-to-server developer error ingest.
- `/sentry/browser-errors` — origin-bound browser developer error ingest.

The repository does not vendor third-party browser dependencies into `web/`. External JavaScript and CSS, including UI
libraries, must be referenced through pinned CDN URLs. Any browser dependency that is not delivered by CDN is
forbidden. `web/` is reserved for LoopAware-authored assets only, so deployments, cache behavior, and browser tests
exercise the same delivery path used in production.

Set `PUBLIC_BASE_URL` to the frontend origin so the API emits correct links and CORS allows browser access. Use
absolute `data-api-origin` attributes (or `api_origin` query params) on embed scripts when the API runs on a different
origin. The dashboard and login pages call `/api` and `/auth` relative to the frontend origin, so split-origin
deployments should use a reverse proxy or update the static HTML in `web/` to point at those services.
The tracked runtime host mapping lives in `web/config.yml`, which `web/runtime-env.js` fetches directly at runtime.
Canonical SEO metadata, Open Graph URLs, `robots.txt`, and `sitemap.xml` are fixed to the single public site
`https://loopaware.mprlab.com` and are not environment-specific.
Each environment may also define `services.siteWidgetSiteId` there to bootstrap the first-party feedback widget on
`/login` and `/app` without hard-coding a site UUID into the static HTML.

## REST API

All authenticated endpoints live under `/api` and require the TAuth session cookie configured by `TAUTH_SESSION_COOKIE_NAME`. Public collection endpoints for
feedback, subscriptions, and visits do not require a session but still enforce per-site origin rules. JSON responses
include Unix timestamps in seconds.

| Method | Path | Role | Description |
|---------|---------------------------------------|-------------|---------------------------------------------------------------------------------------------------------|
| `GET` | `/api/me` | any | Current account metadata (email, name, `role`, `avatar.url`) |
| `GET` | `/api/sites` | any | Sites visible to the caller; each row includes `access_role` (`admin` or `team_member`) |
| `POST` | `/api/sites` | any | Create a site (requires `name`, `allowed_origin`, `owner_email`) |
| `PATCH` | `/api/sites/:id` | owner/admin | Update name/origin; admins may reassign ownership |
| `DELETE`| `/api/sites/:id` | owner/admin | Delete a site |
| `GET` | `/api/sites/:id/team` | owner/admin | List per-site team member email assignments |
| `POST` | `/api/sites/:id/team` | owner/admin | Add a per-site team member by email |
| `DELETE`| `/api/sites/:id/team/:member_id` | owner/admin | Remove a per-site team member assignment |
| `GET` | `/api/sites/:id/mobile-apps` | owner/admin/team member | List native mobile apps registered for feedback submissions |
| `POST` | `/api/sites/:id/mobile-apps` | owner/admin | Register a native mobile app for mobile feedback submissions |
| `GET` | `/api/sites/:id/messages` | owner/admin/team member | List feedback messages (newest first) |
| `GET` | `/api/sites/:id/subscribers` | owner/admin/team member | List subscribers for a site |
| `GET` | `/api/sites/:id/subscribers/export` | owner/admin/team member | Download subscribers as CSV |
| `PATCH` | `/api/sites/:id/subscribers/:subscriber_id` | owner/admin | Update a subscriber’s status (confirm or unsubscribe) |
| `DELETE`| `/api/sites/:id/subscribers/:subscriber_id` | owner/admin | Delete a subscriber |
| `GET` | `/api/sites/:id/visits/stats` | owner/admin/team member | Aggregate visit and unique visitor counts plus recent visits and top pages (optional `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/visits/export` | owner/admin/team member | Download traffic visits as CSV (optional `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/sentry/issues` | owner/admin/team member | List grouped developer error issues for a site |
| `GET` | `/api/sites/:id/sentry/issues/:issue_id` | owner/admin/team member | Inspect latest and recent LA Sentry error occurrences |
| `PATCH` | `/api/sites/:id/sentry/issues/:issue_id` | owner/admin | Update issue status (`unresolved`, `resolved`, or `ignored`) |
| `POST` | `/api/sites/:id/sentry/token` | owner/admin | Rotate and reveal a per-site LA Sentry ingest token |
| `GET` | `/api/sites/:id/visits/trend` | owner/admin/team member | Daily visit trend (default 7 days, optional `days` query param up to 30, or `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/visits/attribution` | owner/admin/team member | Source/medium/campaign attribution breakdown (optional `limit` query param up to 50; optional `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/visits/engagement` | owner/admin/team member | Visitor engagement metrics (default 30 days, optional `days` query param up to 90, or `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/visits/devices` | owner/admin/team member | Device, screen resolution, and viewport breakdowns (optional `limit` query param up to 50; optional `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/visits/locations` | owner/admin/team member | Inferred visitor locations from edge geo, timezone, locale, network, or unknown signals with confidence metadata (optional `limit` query param up to 50; optional `interval=all\|1day\|30days`) |
| `GET` | `/api/sites/:id/traffic-report-schedule` | owner/admin | Read the selected-site traffic report schedule, including `recipient_mode` (`manager`, `team`, or `selected`) and selected team member emails |
| `PUT` | `/api/sites/:id/traffic-report-schedule` | owner/admin | Save the selected-site traffic report schedule; `recipient_mode: "selected"` accepts only current per-site team member emails in `recipient_emails` |
| `POST` | `/api/sites/:id/traffic-report-schedule/test` | owner/admin | Send the selected-site traffic report immediately to the schedule's resolved recipients |
| `GET` | `/api/sites/favicons/events` | any | Server-sent events stream announcing refreshed site favicons |
| `GET` | `/api/sites/feedback/events` | any | Server-sent events stream announcing new feedback |
| `POST` | `/public/feedback` | public | Submit feedback (requires `site_id`, valid `contact` as email or phone, and at least one of `message` or `sentiment`) |
| `POST` | `/public/mobile-feedback` | public | Submit feedback from a registered mobile app with screen/app context |
| `POST` | `/public/subscriptions` | public | Submit an email subscription (JSON body with `site_id`, `email`, optional `name` and `source_url`) |
| `POST` | `/public/subscriptions/confirm` | public | Confirm a subscription for a given `site_id` and email |
| `POST` | `/public/subscriptions/unsubscribe` | public | Unsubscribe an email address for a given `site_id` |
| `GET` | `/public/visits` | public | Record a page visit for a site (returns a 1×1 GIF for use as a tracking pixel) |
| `POST` | `/sentry/errors` | ingest token | Submit developer error events with `Authorization: Bearer ` or `X-LoopAware-Sentry-Token` |
| `POST` | `/sentry/browser-errors` | site origin | Submit browser JavaScript error events from configured site origins |

Subscriptions use confirmation and unsubscribe links sent via email: the static frontend pages at
`/subscriptions/confirm?token=...` and `/subscriptions/unsubscribe?token=...` call the API without requiring browser
origin headers.

LA Sentry ingest accepts JSON with `site_id`, `event_id`, `timestamp`, `platform`, `environment`, `release`, `level`,
`message`, `exception_type`, `stacktrace`, `request`, `user_hash`, `tags`, and `extra`. Rotate the per-site token from
the dashboard `LA Sentry` tab; tokens are shown only once and are intended for server-side clients. The browser harness uses
`/sentry/browser-errors` without a token. Browser events are accepted only from the site's configured `allowed_origin`
values, are rate-limited by client IP, and store minimized request metadata.

The `allowed_origin` field for a site may contain multiple origins separated by spaces or commas (for example `https://mprlab.com http://localhost:8080`); widgets, subscribe forms, and pixels will accept requests from any configured origin while still rejecting traffic from unknown sites.

The `/api/me` response includes a `role` value of `admin` or `user` and an `avatar.url` pointing to the caller's cached
profile image (served from `/api/me/avatar`). The dashboard uses this payload to render the account card and determine
site scope.

Authenticated users can create sites. Owners, creators, and global admins can update and delete sites, can add
per-site team member emails, and can choose whether selected-site traffic reports go only to the manager, the whole site
team, or selected team members. Team members can read assigned site data after signing in with the matching Google email,
but cannot manage site settings, memberships, or schedules.

Deployments upgraded from versions prior to LA-57 should allow the server startup migration to run once; it backfills any
sites missing a `creator_email` with `temirov@gmail.com` to preserve creator-based visibility rules. New site creations
store the authenticated creator separately from the configured owner mailbox.

## Dashboard (`/app`)

The Bootstrap front end consumes the APIs above. Features include:

- Account Settings modal with avatar, email, role badge, reports, and inactivity controls
- Site creation and owner reassignment available to every authenticated user; administrators additionally see all sites
- Owner/admin editor for site metadata, with per-site team member emails managed from the Admin dashboard section
- Selected-site traffic report scheduling with recipient selection for only the manager, the whole site team, or checked team members
- Widget appearance controls that persist the bubble’s accent color, side (left/right), and bottom offset without code changes
- Feedback table with human-readable timestamps
- Subscribers panel with per-site subscriber counts, table, CSV export, and a copyable `subscribe.js` snippet
- Section selector tabs to switch between Feedback, Subscriptions, Traffic, LA Sentry, and manager-only Admin tools
- Subscriber deletion via a confirmation modal
- Traffic card with visit and unique visitor counts, recent visits, and a copyable `pixel.js` snippet
- Real-time favicon refresh notifications delivered through the SSE stream
- Sign-out button provided by the shared `mpr-ui`/TAuth shell
- Inactivity prompt appears after the configured delay (defaults to 60 seconds) and logs out automatically after the configured timeout (defaults to 120 seconds) if unanswered

The dashboard automatically redirects unauthenticated visitors to `/login`.

## Embedding the widget

1. Create a site (admin) and copy the generated `` tag from the API response.
2. Embed the script on any page served from one of the site’s configured `allowed_origin` values (you can supply multiple origins separated by spaces or commas). Include the `defer` attribute so the widget loads without blocking the page; the script waits for the body before rendering the UI.
3. Visitors can open the floating bubble, submit feedback with a valid email or phone plus a message and/or sentiment, and the messages appear under `/api/sites/:id/messages` and
in the dashboard.

Example snippet (replace the base URL with your LoopAware deployment and the site identifier with the value returned by the API):

```html
<script defer src="https://loopaware.mprlab.com/widget.js?site_id=6f50b5f4-8a8f-4e4a-9d69-1b2a3c4d5e6f">
```

## Adding mobile feedback

React Native and Expo apps can use the first-party client under `clients/react-native` to render a native feedback
button on selected screens. Mobile feedback is separate from LA Sentry error capture.

1. Register the native app for the site:

```json
POST /api/sites/6f50b5f4-8a8f-4e4a-9d69-1b2a3c4d5e6f/mobile-apps
{
"platform": "ios",
"app_identifier": "com.example.app",
"display_name": "Example iOS"
}
```

2. Store the returned public `client_id` in the app configuration.
3. Render the feedback button on screens where users should be able to comment:

```tsx




```

The public `client_id` identifies the app registration; it is not a secret. Mobile submissions validate that the client
ID, platform, and application identifier match the registered site app, then store the supplied screen, app version, and
bounded context with the feedback message.

## Embedding the subscribe form

Each site exposes a subscribe snippet that renders an email capture form and posts subscriptions to `/public/subscriptions`.

1. In the dashboard, select a site and use the Subscribers panel to copy the subscribe snippet.
2. Embed the script on pages served from any of the site’s `allowed_origin` entries. The basic form looks like:

```html

```

3. Optional query parameters let you adjust behavior and styling:
- `mode=inline` (default) or `mode=bubble` for a floating button.
- `accent=#0d6efd` to override the accent color.
- `cta=Subscribe` to customize the button text.
- `success=You%27re+on+the+list%21` and `error=Please+try+again.` for inline messages.
- `name_field=false` to hide the optional name field.

The form enforces the site’s `allowed_origin` list using request headers and `source_url` and responds with inline success or
error messages so visitors never leave the page.

## Embedding the traffic pixel

The traffic pixel records page visits per site and powers the dashboard Traffic card and top-pages table.

1. In the dashboard, select a site and use the Traffic panel to copy the pixel snippet.
2. Embed the script on every page served from any of the site’s `allowed_origin` entries:

```html

```

3. On load, `pixel.js` sends a beacon to `/public/visits` with the site ID, current URL, referrer, browser timezone,
browser locale, viewport, screen resolution, and a stable visitor ID stored in `localStorage`. The server also stores
supported edge geo headers from Cloudflare, Vercel, and CloudFront when the deployment provides them, then prefers
that location signal over browser timezone and locale hints. Requests from origins outside the site’s `allowed_origin`
list are rejected. Traffic from known bot user-agent signatures is stored but excluded from default dashboard totals,
top-page rankings, trends, attribution, engagement, devices, and locations.

For non-JavaScript environments you can fall back to a plain image pixel:

```html

```

## Capturing developer errors

Server-side clients should use the protected `/sentry/errors` endpoint with a per-site ingest token. The repository
collects first-party client entrypoints under `clients/`:

- Go: `clients/go/lasentry`
- Python: `clients/python/la_sentry`
- Browser: `clients/browser` documents the harness served from `web/la-sentry.js`

Browser pages can use the standalone harness without exposing the server-side token:

```html

```

The browser harness installs `window.LASentry.captureError(error, attrs)` and automatically captures uncaught
`error` and `unhandledrejection` events. It sends sanitized URL/referrer/user-agent metadata, stack frames, tags, and
explicit `extra` values supplied by application code.

## Development workflow

```bash
make format
make lint
make test
```

`make test` runs the Playwright integration suite against `tests/docker-compose.yml`, with test-owned env fixtures under
`tests/configs/`. That stack builds the API image, serves `web/` via gHTTP, and exercises both UI and `/api/*` flows.
Use `make test-unit` for Go-only tests and `make test-integration-api` to focus on API specs. Playwright artifacts
(traces, screenshots, videos) land under `tests/test-results/` on failure. The integration runner tears its compose
project down on exit, including failures and signal exits.

Use `make test-live-favicons` when validating customer-site favicon collection against known public websites. That
target performs live network requests and is intentionally outside `make ci` so third-party uptime does not gate normal
development.

## Release, Publish, Deploy

Use the deterministic release-to-production sequence:

```bash
make release
make publish
make deploy
```

`make release` cuts a repository release from `master`: it preflights the default branch,
rejects dirty worktrees and open PRs into `master`, runs `make ci`, updates
`CHANGELOG.md`, pushes the release commit, creates the tag, publishes the GitHub Release
object, and verifies remote release state. It does not publish Docker images, publish
Pages, or deploy production.

`make publish` publishes the Docker runtime artifact from a clean `master` checkout after
verifying that a pushed `vMAJOR.MINOR.PATCH` tag points at `HEAD` and rerunning `make ci`.
It pushes:

- `ghcr.io/tyemirov/loopaware:latest`
- `ghcr.io/tyemirov/loopaware:`
- `ghcr.io/tyemirov/loopaware:`

`make deploy` reruns `make ci`, then hands `deploy/app.yml` to
`mprlab-gateway`. Gateway Ansible deploys and verifies the backend first, then
executes the app-owned GitHub Pages workflow resource from the manifest and
verifies `https://loopaware.mprlab.com/`. This keeps Pages behind the backend
version it depends on without splitting the deploy contract between repos. The
release tag is derived from the v* tag at the app repository `HEAD`; operators
do not select a revision during deploy.

The Docker image and Pages workflows are manual dispatch workflows. They do not publish
automatically on tag push; the Makefile targets own the release-to-production ordering.

## Docker

The previous Docker and Compose files remain compatible. Ensure the container receives the OAuth environment variables
and mounts `configs/config.loopaware.yml` containing the admin roster.

```bash
cp configs/.env.loopaware.example configs/.env.loopaware
cp configs/.env.tauth.example configs/.env.tauth
cp configs/.env.pinguin.example configs/.env.pinguin
$EDITOR configs/.env.loopaware configs/.env.tauth configs/.env.pinguin
./scripts/up.sh
```

The compose file binds `configs/config.loopaware.yml` into the LoopAware container at `/app/configs/config.loopaware.yml`
and loads per-service environment variables via `env_file` from `configs/.env.*`.
The container now runs as root so the SQLite data volume remains writable; if you need to switch back to an unprivileged
user, update the Docker image to chown the mounted directory before starting the binary.

For the computercat TLS stack, use:

```bash
./scripts/up.sh computercat
```