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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/incident-room.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Incident room
on:
push:
branches: [main]
paths: ['applications/incident-room/**', '.github/workflows/incident-room.yml']
pull_request:
paths: ['applications/incident-room/**', '.github/workflows/incident-room.yml']
workflow_dispatch:
permissions:
contents: read
jobs:
checks:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: applications/incident-room
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1
with:
otp-version: '28.5.0.7'
elixir-version: '1.20.4'
- run: mix deps.get --check-locked
- run: mix format --check-formatted
- run: mix compile --warnings-as-errors
- run: mix assets.build
- run: mix test
13 changes: 13 additions & 0 deletions applications/incident-room/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
PGHOST=your-service-hostname
PGPORT=5432
PGDATABASE=postgres
PGUSER=incident_app
PGPASSWORD=replace-with-runtime-password
PGSSLMODE=verify-full
PGSSLROOTCERT=/absolute/path/cloud-ca.pem
SECRET_KEY_BASE=replace-with-at-least-64-random-bytes-and-keep-stable-across-restarts
APP_ORIGIN=http://127.0.0.1:4000
PORT=4000
# Only seed command needs these; keep them out of the API environment.
SEED_CASEY_PASSWORD=replace-with-a-strong-password-at-least-16-bytes
SEED_MORGAN_PASSWORD=replace-with-a-different-strong-password
1 change: 1 addition & 0 deletions applications/incident-room/.formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[import_deps: [:ecto, :ecto_sql, :phoenix_live_view], plugins: [Phoenix.LiveView.HTMLFormatter], inputs: ["mix.exs", "{config,lib,test,priv,scripts}/**/*.{ex,exs,heex}"]]
6 changes: 6 additions & 0 deletions applications/incident-room/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/_build/
/deps/
/priv/static/assets/
.env
*.pem
/erl_crash.dump
2 changes: 2 additions & 0 deletions applications/incident-room/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
erlang 28.5.0.7
elixir 1.20.4-otp-28
149 changes: 149 additions & 0 deletions applications/incident-room/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Incident Room

A small Phoenix LiveView application backed by ClickHouse Managed Postgres. A trusted team signs in, opens an incident, appends permanent notes and moves its status from investigating to monitoring to resolved. Monitoring can return to investigating; resolved incidents accept no further notes or transitions.

The status row and its timeline entry commit in one Ecto transaction. Browsers send an expected version; the row lock serializes writers and a stale version is rejected. Phoenix PubSub runs **after commit**. It is an ephemeral notification: reconnecting browsers load authoritative database rows rather than replay broadcasts.

This is one shared team, not a tenant isolation example. All authenticated accounts can read and update every incident. Author IDs come from the verified session, never a browser field. The newest 100 incidents and latest 200 entries per room are displayed, with stable timestamp/UUID ordering; older entries remain stored. There is no pagination or historical export UI.

## Versions and layout

Pinned and tested: Elixir 1.20.4 / Erlang OTP 28.5.0.7, Phoenix 1.8.15, LiveView 1.2.12, Ecto 3.14.2 / Ecto SQL 3.14.0, Postgrex 0.22.4, Bandit 1.12.5 and esbuild 0.28.2. Exact direct dependencies are in `mix.exs`, transitive dependencies in `mix.lock`, runtime versions in `.tool-versions`.

- `lib/incident_room/incidents.ex`: transactions, validation, locking and post-commit notifications.
- `lib/incident_room/auth.ex`: BCrypt passwords and expiring, hashed database sessions.
- `lib/incident_room_web`: authenticated HTTP routes and LiveView mounts/events.
- `priv/repo/migrations`: Ecto migration; `scripts/migrate.exs` is the separate owner-only entry point.
- `sql`: administrator role bootstrap, owner grants and optional dedicated-fixture cleanup.
- `test`: independent domain checks and opt-in real Cloud checks.

Install the pinned Elixir/OTP pair with your preferred version manager. All commands below run from this directory. Use a native Linux development environment if following the recorded acceptance setup. PostgreSQL client tools are needed for the visible role steps.

## Create a Cloud service

Authenticate `clickhousectl` using its supported private environment configuration. A service incurs charges until deleted. This example needs no analytical ClickHouse service and no high availability configuration. The verified small shape is `c6gd.large` in AWS `us-east-1`; consult current Cloud availability before choosing another shape.

```sh
mkdir -m 700 -p /tmp/incident-private
export ORG_ID=your-cloud-organization-id
clickhousectl cloud postgres create --org-id "$ORG_ID" --name incident-room-demo \
--provider aws --region us-east-1 --size c6gd.large \
--pg-version 18 --ha-type none --json > /tmp/incident-private/create.json
# Record the returned id as SERVICE_ID; keep the full receipt private.
clickhousectl cloud postgres get "$SERVICE_ID" --org-id "$ORG_ID" --json
# Continue only when the state is running.
clickhousectl cloud postgres certs get "$SERVICE_ID" --org-id "$ORG_ID" --output /tmp/incident-private/cloud-ca.pem
```

The create receipt contains the initial administrator password; later `get` calls do not return it. Set the returned hostname, port and database privately. Copy `.env.example` outside the repository, replace all placeholders and use an absolute CA path. Do not commit receipts, environment files, passwords or private endpoints. See the [Managed Postgres Phoenix guide](https://clickhouse.com/docs/products/managed-postgres/guides/phoenix).

`PGSSLMODE=verify-full` protects `psql`. The application explicitly supplies OTP `verify_peer`, the downloaded `cacertfile`, hostname SNI, and `pkix_verify_hostname_match_fun(:https)` through Postgrex. Setting `ssl: true` alone would not establish this verification claim.

## Bootstrap, migrate and seed

Generate different strong passwords for the migration and runtime roles. The administrator owns neither application tables nor the server process. Preserve a random `SECRET_KEY_BASE` of at least 64 bytes across restarts. Example generation inside your development environment:

```sh
openssl rand -base64 64
```

Load your private environment file with shell export enabled so the hostname, CA and origin reach Mix:

```sh
set -a
source /private/path/setup.env
set +a
```

Set `ADMIN_USER`, `ADMIN_PASSWORD`, `MIGRATION_PASSWORD` and `APP_PASSWORD` privately, then execute the following in order:

```sh
export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD"
psql -X -v migration_password="$MIGRATION_PASSWORD" \
-v app_password="$APP_PASSWORD" -f sql/bootstrap.sql

export PGUSER=incident_migration PGPASSWORD="$MIGRATION_PASSWORD"
mix deps.get
mix run scripts/migrate.exs
# Repeating migrations is safe and reports that they are already applied.
mix run scripts/migrate.exs

# Supply these only for seeding; each must contain at least 16 bytes.
export SEED_CASEY_PASSWORD='replace-with-a-private-strong-password'
export SEED_MORGAN_PASSWORD='replace-with-a-different-private-strong-password'
mix run priv/repo/seeds.exs
mix run priv/repo/seeds.exs
psql -X -f sql/grants.sql
```

The bootstrap creates an owned `incident_room` schema. Ecto creates its history table there; no database `CREATE` privilege is required. The explicit migration script uses conventional `Ecto.Migrator.run` in the owner process; the HTTP server never migrates at startup. Seed reruns retain existing passwords/data. Seeded accounts are `casey@example.test` and `morgan@example.test`, with the supplied passwords. There is no public registration or password reset flow.

The runtime role can select users; select/insert/delete sessions; select/insert/update incidents; and select/insert entries. It cannot alter users, edit/delete timeline rows, read migration history or create tables. Cooperative row locking and state/version checks belong to the application; the shared trusted runtime role could bypass those rules if used outside this code. The migration owner can still administer all tables.

## Run and use the room

```sh
export PGUSER=incident_app PGPASSWORD="$APP_PASSWORD"
unset ADMIN_USER ADMIN_PASSWORD MIGRATION_PASSWORD SEED_CASEY_PASSWORD SEED_MORGAN_PASSWORD
mix assets.build
mix phx.server
```

Open `http://127.0.0.1:4000`, sign in, open an incident and add a note. Sign in as the second account in another browser to see committed changes. The default listener is loopback. `APP_ORIGIN` must be a valid HTTP(S) origin and controls Phoenix's origin allowlist. If deploying, configure a deliberate proxy/listener and HTTPS origin. Secure cookies are enabled for an HTTPS origin; HTTP loopback development uses HttpOnly, SameSite=Lax encrypted/signed cookies. HTTP CSRF protection and WebSocket origin checks remain enabled.

Sessions contain a random token whose SHA-256 hash and eight-hour expiry live in Postgres. Every LiveView mount, write operation and received update validates the session. Sign-out removes the session and disconnects its sockets. Keeping `SECRET_KEY_BASE` stable lets surviving sessions work after a process restart. Seed/admin credentials are not needed by the server.

Titles allow 1–160 Unicode codepoints and notes 1–2,000 codepoints before trimming, reject whitespace-only input and embedded NUL. This matches PostgreSQL's character count; browser `maxlength` remains a convenience rather than the authority. HTTP bodies are capped at 16 KiB. Successful note submission clears its form; incoming updates preserve another responder's unsaved draft.

## Validate

```sh
mix format --check-formatted
mix compile --warnings-as-errors
mix assets.build
mix test
```

The default suite runs three domain checks without a database. CI uses only these checks and a build; it has no Cloud credentials. For the dedicated seeded Cloud fixture, set the runtime connection variables and additionally supply `MIGRATION_PASSWORD` to the **test process only**:

```sh
CLOUD_TEST=true mix test --include cloud
```

Cloud tests add rows to the dedicated fixture. They check TLS positive/negative controls, restricted permissions, concurrent expected-version updates, author/session authority, bounded Unicode/timeline behavior and actual blocked sessions. A temporary owner-only trigger, scoped to one test incident, forces the timeline insert to fail after the status update; the test verifies rollback and no broadcast, then removes the trigger. Never run this fault-injection suite against production.

For actual two-browser delivery/reconnect/restart checks, install Playwright in an isolated native environment and provide the seed passwords to the harness:

```sh
python3 -m venv /tmp/incident-browser-venv
/tmp/incident-browser-venv/bin/pip install playwright==1.58.0
/tmp/incident-browser-venv/bin/playwright install --with-deps chromium
export SEED_CASEY_PASSWORD='your-seeded-password'
export SEED_MORGAN_PASSWORD='your-other-seeded-password'
/tmp/incident-browser-venv/bin/python scripts/browser_acceptance.py
```

The harness starts an HTTP process containing only runtime credentials, uses separate Chromium contexts, counts actual received WebSocket frames, deliberately misses a broadcast, reconnects, and replaces the server process. A screenshot and private server log go to `EVIDENCE_DIR` (default `/tmp/incident-browser-evidence`). Keep logs outside Git.

## Cleanup and limits

Stop the application before cleanup. On a dedicated fixture only, the administrator can remove this schema and its roles after restoring the administrator credentials from your private setup file:

```sh
set -a
source /private/path/setup.env
set +a
export PGUSER="$ADMIN_USER" PGPASSWORD="$ADMIN_PASSWORD"
psql -X -f sql/cleanup.sql
```

This is destructive and not part of normal startup. It was used to verify clean bootstrap followed by repeated migration/seeding. Delete your own Cloud service when finished and verify its absence:

```sh
clickhousectl cloud postgres delete "$SERVICE_ID" --org-id "$ORG_ID"
clickhousectl cloud postgres list --org-id "$ORG_ID" --json
```

PubSub is local process coordination, not a durable queue or distributed-delivery guarantee. There is no monitoring ingestion, incident integration, email, escalation, tenant policy, account administration, expiry sweep, timeline pagination or performance claim. Provisioning here uses no HA. A larger deployment needs deliberate operational and authorization choices.

Primary references: [LiveView security model](https://phoenix-live-view.hexdocs.pm/security-model.html), [Ecto transactions](https://ecto.hexdocs.pm/Ecto.Repo.html#c:transact/2), [Ecto migrations](https://ecto-sql.hexdocs.pm/Ecto.Migration.html), [Postgrex connection options](https://postgrex.hexdocs.pm/Postgrex.html), [OTP 28 TLS options](https://www.erlang.org/docs/28/apps/ssl/ssl.html).
6 changes: 6 additions & 0 deletions applications/incident-room/assets/js/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
liveSocket.connect()
window.liveSocket = liveSocket
22 changes: 22 additions & 0 deletions applications/incident-room/config/config.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Config
config :incident_room, ecto_repos: [IncidentRoom.Repo]
config :incident_room, IncidentRoom.Repo, migration_default_prefix: "incident_room"

config :incident_room, IncidentRoomWeb.Endpoint,
url: [host: "127.0.0.1", port: 4000],
adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: IncidentRoomWeb.ErrorHTML], layout: false],
pubsub_server: IncidentRoom.PubSub,
live_view: [signing_salt: "live-room-signing"]

config :phoenix, :json_library, Jason

config :esbuild,
version: "0.28.2",
default: [
args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]

config :logger, level: :info
49 changes: 49 additions & 0 deletions applications/incident-room/config/runtime.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Config
cloud_test = System.get_env("CLOUD_TEST") == "true"
config :incident_room, :start_repo, config_env() != :test or cloud_test

if (config_env() != :test or cloud_test) and is_nil(System.get_env("PGHOST")),
do: raise("PGHOST is required; configure the verified Cloud endpoint")

if host = System.get_env("PGHOST") do
config :incident_room, IncidentRoom.Repo,
hostname: host,
port: String.to_integer(System.get_env("PGPORT", "5432")),
database: System.get_env("PGDATABASE", "postgres"),
username: System.fetch_env!("PGUSER"),
password: System.fetch_env!("PGPASSWORD"),
pool_size: 5,
queue_target: 5_000,
queue_interval: 5_000,
ssl: [
verify: :verify_peer,
cacertfile: String.to_charlist(System.fetch_env!("PGSSLROOTCERT")),
server_name_indication: String.to_charlist(host),
customize_hostname_check: [match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]
],
timeout: 30_000,
connect_timeout: 15_000
end

origin = System.get_env("APP_ORIGIN", "http://127.0.0.1:4000")
uri = URI.parse(origin)

unless uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" and
uri.userinfo == nil and uri.path in [nil, ""] and uri.query == nil and
uri.fragment == nil,
do: raise("APP_ORIGIN must be an http(s) origin")

secret =
if config_env() == :test,
do: System.get_env("SECRET_KEY_BASE", String.duplicate("test-only-", 8)),
else: System.fetch_env!("SECRET_KEY_BASE")

if byte_size(secret) < 64, do: raise("SECRET_KEY_BASE must have at least 64 bytes")
config :incident_room, :cookie_secure, uri.scheme == "https"

config :incident_room, IncidentRoomWeb.Endpoint,
secret_key_base: secret,
server: System.get_env("PHX_SERVER") == "true",
url: [host: uri.host, port: uri.port, scheme: uri.scheme],
check_origin: [origin],
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT", "4000"))]
12 changes: 12 additions & 0 deletions applications/incident-room/lib/incident_room/application.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule IncidentRoom.Application do
use Application

def start(_type, _args) do
repo = if Application.get_env(:incident_room, :start_repo), do: [IncidentRoom.Repo], else: []
children = repo ++ [{Phoenix.PubSub, name: IncidentRoom.PubSub}, IncidentRoomWeb.Endpoint]
Supervisor.start_link(children, strategy: :one_for_one, name: IncidentRoom.Supervisor)
end

def config_change(changed, removed, _extra),
do: IncidentRoomWeb.Endpoint.config_change(changed, removed)
end
57 changes: 57 additions & 0 deletions applications/incident-room/lib/incident_room/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
defmodule IncidentRoom.Auth do
import Ecto.Query
alias IncidentRoom.{Repo, User, Session}
@ttl_seconds 8 * 60 * 60

def authenticate(email, password)
when is_binary(email) and is_binary(password) and byte_size(email) <= 254 and
byte_size(password) <= 128 do
user = Repo.get_by(User, email: email |> String.trim() |> String.downcase())

cond do
user && Bcrypt.verify_pass(password, user.password_hash) ->
{:ok, user}

user ->
{:error, :invalid_credentials}

true ->
Bcrypt.no_user_verify()
{:error, :invalid_credentials}
end
end

def authenticate(_, _), do: {:error, :invalid_credentials}

def create_session(user) do
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)

Repo.insert!(%Session{
token_hash: hash(token),
user_id: user.id,
expires_at: DateTime.add(DateTime.utc_now(), @ttl_seconds, :second)
})

token
end

def user(token) when is_binary(token) and byte_size(token) <= 128 do
Repo.one(
from s in Session,
join: u in User,
on: u.id == s.user_id,
where: s.token_hash == ^hash(token) and s.expires_at > ^DateTime.utc_now(),
select: u
)
end

def user(_), do: nil

def delete_session(token) do
Repo.delete_all(from s in Session, where: s.token_hash == ^hash(token))
IncidentRoomWeb.Endpoint.broadcast(socket_id(token), "disconnect", %{})
end

def socket_id(token), do: "session:" <> Base.url_encode64(hash(token), padding: false)
defp hash(token), do: :crypto.hash(:sha256, token)
end
Loading
Loading