diff --git a/.github/workflows/incident-room.yml b/.github/workflows/incident-room.yml
new file mode 100644
index 00000000..f6c115a8
--- /dev/null
+++ b/.github/workflows/incident-room.yml
@@ -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
diff --git a/applications/incident-room/.env.example b/applications/incident-room/.env.example
new file mode 100644
index 00000000..0d16fbfb
--- /dev/null
+++ b/applications/incident-room/.env.example
@@ -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
diff --git a/applications/incident-room/.formatter.exs b/applications/incident-room/.formatter.exs
new file mode 100644
index 00000000..40bbbc03
--- /dev/null
+++ b/applications/incident-room/.formatter.exs
@@ -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}"]]
diff --git a/applications/incident-room/.gitignore b/applications/incident-room/.gitignore
new file mode 100644
index 00000000..512dc27b
--- /dev/null
+++ b/applications/incident-room/.gitignore
@@ -0,0 +1,6 @@
+/_build/
+/deps/
+/priv/static/assets/
+.env
+*.pem
+/erl_crash.dump
diff --git a/applications/incident-room/.tool-versions b/applications/incident-room/.tool-versions
new file mode 100644
index 00000000..fb2a1623
--- /dev/null
+++ b/applications/incident-room/.tool-versions
@@ -0,0 +1,2 @@
+erlang 28.5.0.7
+elixir 1.20.4-otp-28
diff --git a/applications/incident-room/README.md b/applications/incident-room/README.md
new file mode 100644
index 00000000..69cb4946
--- /dev/null
+++ b/applications/incident-room/README.md
@@ -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).
diff --git a/applications/incident-room/assets/js/app.js b/applications/incident-room/assets/js/app.js
new file mode 100644
index 00000000..25db5c21
--- /dev/null
+++ b/applications/incident-room/assets/js/app.js
@@ -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
diff --git a/applications/incident-room/config/config.exs b/applications/incident-room/config/config.exs
new file mode 100644
index 00000000..cd33f97d
--- /dev/null
+++ b/applications/incident-room/config/config.exs
@@ -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
diff --git a/applications/incident-room/config/runtime.exs b/applications/incident-room/config/runtime.exs
new file mode 100644
index 00000000..ca7db0c0
--- /dev/null
+++ b/applications/incident-room/config/runtime.exs
@@ -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"))]
diff --git a/applications/incident-room/lib/incident_room/application.ex b/applications/incident-room/lib/incident_room/application.ex
new file mode 100644
index 00000000..6499af16
--- /dev/null
+++ b/applications/incident-room/lib/incident_room/application.ex
@@ -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
diff --git a/applications/incident-room/lib/incident_room/auth.ex b/applications/incident-room/lib/incident_room/auth.ex
new file mode 100644
index 00000000..f25f02a6
--- /dev/null
+++ b/applications/incident-room/lib/incident_room/auth.ex
@@ -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
diff --git a/applications/incident-room/lib/incident_room/incidents.ex b/applications/incident-room/lib/incident_room/incidents.ex
new file mode 100644
index 00000000..00dcb943
--- /dev/null
+++ b/applications/incident-room/lib/incident_room/incidents.ex
@@ -0,0 +1,147 @@
+defmodule IncidentRoom.Incidents do
+ import Ecto.Query
+ alias IncidentRoom.{Repo, Auth, Incident, Entry}
+
+ @transitions %{
+ "investigating" => ["monitoring"],
+ "monitoring" => ["investigating", "resolved"],
+ "resolved" => []
+ }
+
+ def valid_transition?(from, to), do: to in Map.get(@transitions, from, [])
+
+ def valid_text?(text, limit),
+ do:
+ is_binary(text) and String.trim(text) != "" and length(String.to_charlist(text)) <= limit and
+ not String.contains?(text, <<0>>)
+
+ def list,
+ do: Repo.all(from i in Incident, order_by: [desc: i.inserted_at, desc: i.id], limit: 100)
+
+ def get(id), do: Repo.get(Incident, id)
+
+ def timeline(id),
+ do:
+ Repo.all(
+ from e in Entry,
+ where: e.incident_id == ^id,
+ order_by: [desc: e.inserted_at, desc: e.id],
+ limit: 200,
+ preload: :author
+ )
+ |> Enum.reverse()
+
+ def open(token, attrs) when is_map(attrs) do
+ result =
+ Repo.transact(fn ->
+ with %{} = user <- Auth.user(token),
+ true <- Map.keys(attrs) == ["title"],
+ true <- valid_text?(attrs["title"], 160),
+ {:ok, incident} <-
+ Repo.insert(Ecto.Changeset.change(%Incident{}, title: String.trim(attrs["title"]))),
+ {:ok, _entry} <- entry(incident, user, "opened", "Incident opened") do
+ {:ok, incident}
+ else
+ nil -> {:error, :unauthenticated}
+ false -> {:error, :invalid_input}
+ {:error, reason} -> {:error, reason}
+ end
+ end)
+
+ broadcast(result)
+ end
+
+ def open(_, _), do: {:error, :invalid_input}
+
+ def note(token, id, attrs) when is_map(attrs) do
+ result =
+ Repo.transact(fn ->
+ with %{} = user <- Auth.user(token),
+ true <- Map.keys(attrs) == ["body"],
+ true <- valid_text?(attrs["body"], 2000),
+ %{} = incident <- locked(id),
+ false <- incident.status == "resolved",
+ {:ok, _entry} <- entry(incident, user, "note", String.trim(attrs["body"])) do
+ {:ok, incident}
+ else
+ nil -> {:error, :not_found_or_unauthenticated}
+ true -> {:error, :resolved}
+ false -> {:error, :invalid_input}
+ {:error, reason} -> {:error, reason}
+ end
+ end)
+
+ broadcast(result)
+ end
+
+ def note(_, _, _), do: {:error, :invalid_input}
+
+ def transition(token, id, attrs) when is_map(attrs) do
+ result =
+ Repo.transact(fn ->
+ with %{} = user <- Auth.user(token),
+ true <- Enum.sort(Map.keys(attrs)) == ["status", "version"],
+ %{} = incident <- locked(id),
+ {:ok, version} <- version(attrs["version"]),
+ :ok <- check_version(incident, version),
+ true <- valid_transition?(incident.status, attrs["status"]),
+ {:ok, changed} <-
+ Repo.update(
+ Ecto.Changeset.change(incident,
+ status: attrs["status"],
+ version: incident.version + 1
+ )
+ ),
+ {:ok, _entry} <-
+ entry(
+ changed,
+ user,
+ "status",
+ "Status changed from #{incident.status} to #{changed.status}"
+ ) do
+ {:ok, changed}
+ else
+ nil -> {:error, :not_found_or_unauthenticated}
+ false -> {:error, :invalid_transition_or_input}
+ {:error, reason} -> {:error, reason}
+ end
+ end)
+
+ broadcast(result)
+ end
+
+ def transition(_, _, _), do: {:error, :invalid_input}
+
+ defp locked(id), do: Repo.one(from i in Incident, where: i.id == ^id, lock: "FOR UPDATE")
+ defp version(n) when is_integer(n) and n >= 0, do: {:ok, n}
+
+ defp version(n) when is_binary(n) do
+ case Integer.parse(n) do
+ {version, ""} when version >= 0 -> {:ok, version}
+ _ -> {:error, :invalid_version}
+ end
+ end
+
+ defp version(_), do: {:error, :invalid_version}
+ defp check_version(%{version: version}, version), do: :ok
+ defp check_version(_, _), do: {:error, :stale_version}
+
+ defp entry(incident, user, kind, body),
+ do:
+ Repo.insert(
+ Ecto.Changeset.change(%Entry{},
+ incident_id: incident.id,
+ author_id: user.id,
+ kind: kind,
+ body: body
+ )
+ )
+
+ defp broadcast({:ok, incident} = result) do
+ Phoenix.PubSub.broadcast(IncidentRoom.PubSub, "incident:#{incident.id}", :committed)
+ Phoenix.PubSub.broadcast(IncidentRoom.PubSub, "incidents", :committed)
+ result
+ end
+
+ defp broadcast(error), do: error
+end
diff --git a/applications/incident-room/lib/incident_room/models.ex b/applications/incident-room/lib/incident_room/models.ex
new file mode 100644
index 00000000..b4e486fb
--- /dev/null
+++ b/applications/incident-room/lib/incident_room/models.ex
@@ -0,0 +1,45 @@
+defmodule IncidentRoom.User do
+ use Ecto.Schema
+ @schema_prefix "incident_room"
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "users" do
+ field :email, :string
+ field :name, :string
+ field :password_hash, :string, redact: true
+ end
+end
+
+defmodule IncidentRoom.Session do
+ use Ecto.Schema
+ @schema_prefix "incident_room"
+ @primary_key {:token_hash, :binary, autogenerate: false}
+ schema "sessions" do
+ belongs_to :user, IncidentRoom.User, type: :binary_id
+ field :expires_at, :utc_datetime_usec
+ end
+end
+
+defmodule IncidentRoom.Incident do
+ use Ecto.Schema
+ @schema_prefix "incident_room"
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "incidents" do
+ field :title, :string
+ field :status, :string, default: "investigating"
+ field :version, :integer, default: 0
+ timestamps(type: :utc_datetime_usec)
+ end
+end
+
+defmodule IncidentRoom.Entry do
+ use Ecto.Schema
+ @schema_prefix "incident_room"
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "entries" do
+ belongs_to :incident, IncidentRoom.Incident, type: :binary_id
+ belongs_to :author, IncidentRoom.User, type: :binary_id
+ field :kind, :string
+ field :body, :string
+ timestamps(type: :utc_datetime_usec, updated_at: false)
+ end
+end
diff --git a/applications/incident-room/lib/incident_room/repo.ex b/applications/incident-room/lib/incident_room/repo.ex
new file mode 100644
index 00000000..c7c59437
--- /dev/null
+++ b/applications/incident-room/lib/incident_room/repo.ex
@@ -0,0 +1,3 @@
+defmodule IncidentRoom.Repo do
+ use Ecto.Repo, otp_app: :incident_room, adapter: Ecto.Adapters.Postgres
+end
diff --git a/applications/incident-room/lib/incident_room_web/auth.ex b/applications/incident-room/lib/incident_room_web/auth.ex
new file mode 100644
index 00000000..c1c60306
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/auth.ex
@@ -0,0 +1,24 @@
+defmodule IncidentRoomWeb.Auth do
+ import Plug.Conn
+ alias IncidentRoom.Auth
+
+ def fetch_user(conn, _) do
+ Plug.Conn.assign(conn, :current_user, Auth.user(get_session(conn, :user_token)))
+ end
+
+ def require_user(%{assigns: %{current_user: nil}} = conn, _),
+ do: conn |> Phoenix.Controller.redirect(to: "/sign-in") |> halt()
+
+ def require_user(conn, _), do: conn
+
+ def on_mount(:required, _params, session, socket) do
+ token = session["user_token"]
+
+ case Auth.user(token) do
+ nil -> {:halt, Phoenix.LiveView.redirect(socket, to: "/sign-in")}
+ user -> {:cont, Phoenix.Component.assign(socket, current_user: user, session_token: token)}
+ end
+ end
+
+ def active?(socket), do: Auth.user(socket.assigns.session_token) != nil
+end
diff --git a/applications/incident-room/lib/incident_room_web/endpoint.ex b/applications/incident-room/lib/incident_room_web/endpoint.ex
new file mode 100644
index 00000000..b6c211d1
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/endpoint.ex
@@ -0,0 +1,44 @@
+defmodule IncidentRoomWeb.Endpoint do
+ use Phoenix.Endpoint, otp_app: :incident_room
+
+ @session_options [
+ store: :cookie,
+ key: "_incident_room",
+ signing_salt: "session-signing",
+ encryption_salt: "session-encryption",
+ same_site: "Lax",
+ max_age: 8 * 60 * 60,
+ http_only: true
+ ]
+ socket("/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]],
+ longpoll: false
+ )
+
+ plug(Plug.Static, at: "/", from: :incident_room, gzip: false, only: ~w(assets app.css))
+ plug(Plug.RequestId)
+ plug(Plug.Telemetry, event_prefix: [:phoenix, :endpoint])
+
+ plug(Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Phoenix.json_library(),
+ length: 16_384
+ )
+
+ plug(Plug.MethodOverride)
+ plug(Plug.Head)
+ plug(:session)
+ plug(IncidentRoomWeb.Router)
+
+ defp session(conn, _) do
+ options =
+ Keyword.put(
+ @session_options,
+ :secure,
+ Application.get_env(:incident_room, :cookie_secure, false)
+ )
+
+ Plug.Session.call(conn, Plug.Session.init(options))
+ end
+end
diff --git a/applications/incident-room/lib/incident_room_web/index_live.ex b/applications/incident-room/lib/incident_room_web/index_live.ex
new file mode 100644
index 00000000..90edf6da
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/index_live.ex
@@ -0,0 +1,69 @@
+defmodule IncidentRoomWeb.IndexLive do
+ use Phoenix.LiveView, layout: false
+ alias IncidentRoom.{Incidents, Auth}
+
+ def mount(_, _, socket) do
+ if connected?(socket), do: Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incidents")
+
+ {:ok,
+ assign(socket, incidents: Incidents.list(), form: to_form(%{"title" => ""}, as: :incident))}
+ end
+
+ def handle_event("open", %{"incident" => attrs}, socket) do
+ case Incidents.open(socket.assigns.session_token, attrs) do
+ {:ok, incident} ->
+ {:noreply, push_navigate(socket, to: "/incidents/#{incident.id}")}
+
+ {:error, _} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ "Enter a title up to 160 characters; use only the title field."
+ )}
+ end
+ end
+
+ def handle_event(_, _, socket),
+ do: {:noreply, put_flash(socket, :error, "Invalid update fields.")}
+
+ def handle_info(:committed, socket) do
+ if Auth.user(socket.assigns.session_token),
+ do: {:noreply, assign(socket, incidents: Incidents.list())},
+ else: {:noreply, redirect(socket, to: "/sign-in")}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+ TEAM INCIDENTSKeep the response in one room.
+ Open an incident, add what you know, and follow the team's progress.
+
+
+ {@flash["error"]}
+ <.form for={@form} id="open-incident" phx-submit="open" class="open-form">
+
+
+
+
+
+ """
+ end
+end
diff --git a/applications/incident-room/lib/incident_room_web/layouts.ex b/applications/incident-room/lib/incident_room_web/layouts.ex
new file mode 100644
index 00000000..db75e032
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/layouts.ex
@@ -0,0 +1,25 @@
+defmodule IncidentRoomWeb.Layouts do
+ use Phoenix.Component
+
+ def root(assigns) do
+ ~H"""
+
+
+
+
+
+
+ Incident Room
+
+
+
+ {@inner_content}
+
+ """
+ end
+end
+
+defmodule IncidentRoomWeb.ErrorHTML do
+ def render(template, _assigns), do: Phoenix.Controller.status_message_from_template(template)
+end
diff --git a/applications/incident-room/lib/incident_room_web/room_live.ex b/applications/incident-room/lib/incident_room_web/room_live.ex
new file mode 100644
index 00000000..c453520f
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/room_live.ex
@@ -0,0 +1,167 @@
+defmodule IncidentRoomWeb.RoomLive do
+ use Phoenix.LiveView, layout: false
+ alias IncidentRoom.Incidents
+ alias IncidentRoomWeb.Auth
+
+ def mount(%{"id" => id}, _, socket) do
+ case Ecto.UUID.cast(id) do
+ {:ok, id} ->
+ if connected?(socket), do: Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{id}")
+
+ case Incidents.get(id) do
+ nil ->
+ {:ok, redirect(socket, to: "/")}
+
+ incident ->
+ {:ok,
+ socket
+ |> assign(
+ incident: incident,
+ timeline: Incidents.timeline(id),
+ connected: connected?(socket),
+ form: to_form(%{"body" => ""}, as: :note)
+ )}
+ end
+
+ :error ->
+ {:ok, redirect(socket, to: "/")}
+ end
+ end
+
+ def handle_event("note", %{"note" => attrs}, socket),
+ do:
+ event(
+ socket,
+ fn -> Incidents.note(socket.assigns.session_token, socket.assigns.incident.id, attrs) end,
+ true
+ )
+
+ def handle_event("transition", attrs, socket),
+ do:
+ event(socket, fn ->
+ Incidents.transition(socket.assigns.session_token, socket.assigns.incident.id, attrs)
+ end)
+
+ def handle_event("draft", %{"note" => attrs}, socket) when is_map(attrs),
+ do: {:noreply, assign(socket, form: to_form(Map.take(attrs, ["body"]), as: :note))}
+
+ def handle_event(_, _, socket),
+ do: {:noreply, put_flash(socket, :error, "Invalid update fields.")}
+
+ defp event(socket, callback, clear_note \\ false) do
+ if Auth.active?(socket) do
+ case callback.() do
+ {:ok, _} ->
+ {:noreply,
+ socket |> put_flash(:info, "Update saved.") |> reload() |> clear_form(clear_note)}
+
+ {:error, :stale_version} ->
+ {:noreply,
+ socket
+ |> reload()
+ |> put_flash(
+ :error,
+ "The status changed. Review the current state before trying again."
+ )}
+
+ {:error, _} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ "This update was not saved. Check the fields and current status."
+ )}
+ end
+ else
+ {:noreply, redirect(socket, to: "/sign-in")}
+ end
+ end
+
+ defp clear_form(socket, true), do: assign(socket, form: to_form(%{"body" => ""}, as: :note))
+ defp clear_form(socket, false), do: socket
+
+ def handle_info(:committed, socket) do
+ if Auth.active?(socket),
+ do: {:noreply, reload(socket)},
+ else: {:noreply, redirect(socket, to: "/sign-in")}
+ end
+
+ defp reload(socket),
+ do:
+ assign(socket,
+ incident: Incidents.get(socket.assigns.incident.id),
+ timeline: Incidents.timeline(socket.assigns.incident.id)
+ )
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ SHARED INCIDENT
{@incident.title}
@incident.status}
+ >{String.capitalize(@incident.status)}Version {@incident.version}
+ {if @connected, do: "● Live room", else: "Connecting…"}
+
+ {@flash["error"]}
+ {@flash["info"]}
+
+
+
+
Timeline
+ Latest 200 entries, in chronological order. Earlier entries remain in the database.
+
+ -
+
+ {entry.author.name}{entry.kind}
+
{entry.body}
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/applications/incident-room/lib/incident_room_web/router.ex b/applications/incident-room/lib/incident_room_web/router.ex
new file mode 100644
index 00000000..acb9c7ab
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/router.ex
@@ -0,0 +1,37 @@
+defmodule IncidentRoomWeb.Router do
+ use Phoenix.Router
+ import Plug.Conn
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ import IncidentRoomWeb.Auth
+
+ pipeline :browser do
+ plug(:accepts, ["html"])
+ plug(:fetch_session)
+ plug(:fetch_live_flash)
+ plug(:put_root_layout, html: {IncidentRoomWeb.Layouts, :root})
+ plug(:protect_from_forgery)
+ plug(:put_secure_browser_headers)
+ plug(:fetch_user)
+ end
+
+ pipeline :authenticated do
+ plug(:require_user)
+ end
+
+ scope "/", IncidentRoomWeb do
+ pipe_through(:browser)
+ get("/sign-in", SessionController, :new)
+ post("/sign-in", SessionController, :create)
+ post("/sign-out", SessionController, :delete)
+ end
+
+ scope "/", IncidentRoomWeb do
+ pipe_through([:browser, :authenticated])
+
+ live_session :team, on_mount: [{IncidentRoomWeb.Auth, :required}] do
+ live("/", IndexLive)
+ live("/incidents/:id", RoomLive)
+ end
+ end
+end
diff --git a/applications/incident-room/lib/incident_room_web/session_controller.ex b/applications/incident-room/lib/incident_room_web/session_controller.ex
new file mode 100644
index 00000000..dc01b759
--- /dev/null
+++ b/applications/incident-room/lib/incident_room_web/session_controller.ex
@@ -0,0 +1,63 @@
+defmodule IncidentRoomWeb.SessionController do
+ use Phoenix.Controller, formats: [:html]
+ import Plug.Conn
+ alias IncidentRoom.Auth
+ plug(:put_layout, false)
+ def new(conn, _), do: render(conn, :new, error: nil)
+
+ def create(conn, %{"session" => %{"email" => email, "password" => password} = attrs}) do
+ if Enum.sort(Map.keys(attrs)) == ["email", "password"] do
+ case Auth.authenticate(email, password) do
+ {:ok, user} ->
+ token = Auth.create_session(user)
+
+ conn
+ |> configure_session(renew: true)
+ |> clear_session()
+ |> put_session(:user_token, token)
+ |> put_session(:live_socket_id, Auth.socket_id(token))
+ |> redirect(to: "/")
+
+ _ ->
+ conn |> put_status(401) |> render(:new, error: "Check your email and password.")
+ end
+ else
+ conn |> put_status(400) |> render(:new, error: "Invalid sign-in fields.")
+ end
+ end
+
+ def create(conn, _),
+ do: conn |> put_status(400) |> render(:new, error: "Enter your email and password.")
+
+ def delete(conn, _) do
+ if token = get_session(conn, :user_token), do: Auth.delete_session(token)
+ conn |> clear_session() |> configure_session(drop: true) |> redirect(to: "/sign-in")
+ end
+end
+
+defmodule IncidentRoomWeb.SessionHTML do
+ use Phoenix.Component
+
+ def new(assigns) do
+ ~H"""
+
+ INCIDENT ROOM
+ A shared place to work through an incident.
+ Sign in with your team account to follow the timeline and add updates.
+ {@error}
+
+ Team accounts are seeded by the person running this example.
+
+ """
+ end
+end
diff --git a/applications/incident-room/mix.exs b/applications/incident-room/mix.exs
new file mode 100644
index 00000000..87549c27
--- /dev/null
+++ b/applications/incident-room/mix.exs
@@ -0,0 +1,36 @@
+defmodule IncidentRoom.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :incident_room,
+ version: "0.1.0",
+ elixir: "~> 1.20",
+ elixirc_paths: ["lib"],
+ deps: deps(),
+ aliases: [
+ "assets.build": ["esbuild default"],
+ "assets.deploy": ["esbuild default --minify"]
+ ]
+ ]
+ end
+
+ def application,
+ do: [mod: {IncidentRoom.Application, []}, extra_applications: [:logger, :runtime_tools, :ssl]]
+
+ defp deps do
+ [
+ {:phoenix, "== 1.8.15"},
+ {:phoenix_live_view, "== 1.2.12"},
+ {:phoenix_html, "== 4.3.0"},
+ {:ecto, "== 3.14.2"},
+ {:ecto_sql, "== 3.14.0"},
+ {:postgrex, "== 0.22.4"},
+ {:bcrypt_elixir, "== 3.3.2"},
+ {:bandit, "== 1.12.5"},
+ {:jason, "== 1.4.5"},
+ {:esbuild, "== 0.10.0", runtime: false},
+ {:floki, "== 0.38.4", only: :test}
+ ]
+ end
+end
diff --git a/applications/incident-room/mix.lock b/applications/incident-room/mix.lock
new file mode 100644
index 00000000..ee302b8b
--- /dev/null
+++ b/applications/incident-room/mix.lock
@@ -0,0 +1,27 @@
+%{
+ "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"},
+ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
+ "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
+ "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"},
+ "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
+ "ecto": {:hex, :ecto, "3.14.2", "99db28a864293a789c970651de711e3cae184291e0e7ea1166c54055ac41c1f3", [:mix], [{:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "25d60b8c816a07d19d85b80bdf60978bd8b102209dda198d768cd7c6745339a6"},
+ "ecto_sql": {:hex, :ecto_sql, "3.14.0", "06446ab8410d2f85bfbb80857ee224ab3b693700cbb38f6535d507449a627b2e", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.14.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.8", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f4d8d36faf294c9417b5a37ec7ac8217ee2abdef5fcf197ba690f361548d3949"},
+ "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
+ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
+ "floki": {:hex, :floki, "0.38.4", "10f98971e892aed2c2f1b3a0f928e488e3797e1c6dd3dfd98db40b14e9a78bcf", [:mix], [], "hexpm", "bdb34645eee8e79845c7edaca2d4099a52804ee4d4a3ecc683a69451f0244973"},
+ "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "phoenix": {:hex, :phoenix, "1.8.15", "dcdb304113660ec97e1579458824d3c34b5d2eed07a0fdf25a836a9515b63146", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "7b83ed6b3d544f24a29277eab7f051be38b76f390bb511bb6ddb7ec6e8e05b95"},
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.2.12", "35848150bbab579e9d0aff79525269d7c0801a4f61232b84f635c11222cee88f", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "656810d716e3369545dd63981196a5d68b77fdb253afe02ef0c6fa14cfd8dc2b"},
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.3.0", "03916bfbc31a5121945b3cfffe5aec647a5c97fe1dc172a319b94428562359c9", [:mix], [], "hexpm", "eec7be6e9cf02e2551d389b558402d6c637cd3973796326e7ba4bb03c6b2e91d"},
+ "phoenix_template": {:hex, :phoenix_template, "1.1.0", "b329582281b1e00e4dc664afd60ef5b489cbb356923caa3b1e00abe8651b9a18", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "eba70070de79b2c3501ef205a74a69f98ab352f3785aa15da9ed161f9fe0fd5d"},
+ "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
+ "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
+ "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
+ "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+ "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
+}
diff --git a/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs b/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs
new file mode 100644
index 00000000..2ba63931
--- /dev/null
+++ b/applications/incident-room/priv/repo/migrations/20261002000100_create_room.exs
@@ -0,0 +1,51 @@
+defmodule IncidentRoom.Repo.Migrations.CreateRoom do
+ use Ecto.Migration
+
+ def change do
+ create table(:users, primary_key: false) do
+ add :id, :uuid, primary_key: true
+ add :email, :string, null: false
+ add :name, :string, null: false
+ add :password_hash, :text, null: false
+ end
+
+ create unique_index(:users, [:email])
+
+ create table(:sessions, primary_key: false) do
+ add :token_hash, :binary, primary_key: true
+ add :user_id, references(:users, type: :uuid), null: false
+ add :expires_at, :utc_datetime_usec, null: false
+ end
+
+ create index(:sessions, [:expires_at])
+
+ create table(:incidents, primary_key: false) do
+ add :id, :uuid, primary_key: true
+ add :title, :string, size: 160, null: false
+ add :status, :string, null: false, default: "investigating"
+ add :version, :integer, null: false, default: 0
+ timestamps(type: :utc_datetime_usec)
+ end
+
+ create constraint(:incidents, :valid_status,
+ check: "status IN ('investigating', 'monitoring', 'resolved')"
+ )
+
+ create constraint(:incidents, :valid_version, check: "version >= 0")
+ create constraint(:incidents, :nonblank_title, check: "length(btrim(title)) > 0")
+
+ create table(:entries, primary_key: false) do
+ add :id, :uuid, primary_key: true
+ add :incident_id, references(:incidents, type: :uuid), null: false
+ add :author_id, references(:users, type: :uuid), null: false
+ add :kind, :string, null: false
+ add :body, :text, null: false
+ timestamps(type: :utc_datetime_usec, updated_at: false)
+ end
+
+ create constraint(:entries, :valid_entry_kind, check: "kind IN ('opened', 'note', 'status')")
+ create constraint(:entries, :bounded_body, check: "length(btrim(body)) BETWEEN 1 AND 2000")
+ create index(:entries, [:incident_id, :inserted_at, :id])
+ create index(:incidents, [:inserted_at, :id])
+ end
+end
diff --git a/applications/incident-room/priv/repo/seeds.exs b/applications/incident-room/priv/repo/seeds.exs
new file mode 100644
index 00000000..965fecfb
--- /dev/null
+++ b/applications/incident-room/priv/repo/seeds.exs
@@ -0,0 +1,33 @@
+alias IncidentRoom.{Repo, User, Incident, Entry}
+
+for {id, email, name, env} <- [
+ {"00000000-0000-0000-0000-000000000001", "casey@example.test", "Casey",
+ "SEED_CASEY_PASSWORD"},
+ {"00000000-0000-0000-0000-000000000002", "morgan@example.test", "Morgan",
+ "SEED_MORGAN_PASSWORD"}
+ ] do
+ password = System.fetch_env!(env)
+ if byte_size(password) < 16, do: raise("Seed passwords need at least 16 bytes")
+
+ Repo.insert!(
+ %User{id: id, email: email, name: name, password_hash: Bcrypt.hash_pwd_salt(password)},
+ on_conflict: :nothing
+ )
+end
+
+incident_id = "00000000-0000-0000-0000-000000000003"
+
+Repo.insert!(%Incident{id: incident_id, title: "Elevated API errors", status: "investigating"},
+ on_conflict: :nothing
+)
+
+Repo.insert!(
+ %Entry{
+ id: "00000000-0000-0000-0000-000000000004",
+ incident_id: incident_id,
+ author_id: "00000000-0000-0000-0000-000000000001",
+ kind: "opened",
+ body: "Incident opened"
+ },
+ on_conflict: :nothing
+)
diff --git a/applications/incident-room/priv/static/app.css b/applications/incident-room/priv/static/app.css
new file mode 100644
index 00000000..48ebc0fe
--- /dev/null
+++ b/applications/incident-room/priv/static/app.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}body{margin:0;background:#f4f6f8;color:#172b3a;font:16px/1.5 system-ui,sans-serif}a{color:inherit;text-decoration:none}.page{max-width:1180px;margin:auto;padding:30px 36px}.brand{font-weight:750;font-size:20px}header{display:flex;align-items:center;justify-content:space-between;padding-bottom:28px;border-bottom:1px solid #dde4e9}.account{display:flex;gap:22px;align-items:center;font-size:14px}button{border:0;border-radius:7px;background:#235ee7;color:white;padding:11px 17px;font:inherit;font-weight:650;cursor:pointer}button:hover{background:#1949b8}.quiet{color:#496270;background:transparent;padding:0;font-size:14px}.quiet:hover{background:transparent;text-decoration:underline}input,textarea{display:block;width:100%;padding:12px;border:1px solid #c8d3de;border-radius:7px;background:white;font:inherit;color:inherit}label{display:block;font-size:14px;font-weight:600;margin:18px 0 10px}label input,label textarea{margin-top:7px}.eyebrow{font-size:11px;letter-spacing:1.6px;font-weight:800;color:#607583}h1{font-size:34px;line-height:1.2;margin:14px 0}h2{font-size:20px;margin:0 0 18px}h3{font-size:14px}p{margin:10px 0}.intro{padding:40px 0 25px}.intro p,.muted,footer{color:#657887}.open-form{display:flex;gap:12px;max-width:740px;margin-bottom:30px}.open-form input{flex:1}.rooms{display:grid;gap:14px}.room-card{display:flex;justify-content:space-between;align-items:center;background:white;border:1px solid #dce4eb;border-radius:10px;padding:22px}.room-card h2{margin:10px 0 0}.status{font-size:12px;display:inline-block;border-radius:20px;padding:4px 11px;background:#fff0d2;color:#94631a;font-weight:750}.status.monitoring{background:#e3edff;color:#2856a7}.status.resolved{background:#dbf2e4;color:#237049}.incident-head{display:flex;justify-content:space-between;align-items:center;padding:36px 0}.incident-head .muted{font-size:12px;margin-left:12px}.live-badge{font-size:13px;color:#288058;background:#e4f4eb;border-radius:30px;padding:8px 14px}.workspace{display:grid;grid-template-columns:1fr 340px;gap:32px}.workspace>div,aside{background:white;border:1px solid #dce4eb;border-radius:10px;padding:26px}.timeline{list-style:none;padding:0;margin:0}.timeline li{padding:18px 0;border-bottom:1px solid #e9edf1}.entry-meta{display:flex;gap:12px;align-items:center;font-size:12px;color:#687d8b}.entry-meta strong{color:#243c4d}.entry-meta span{margin-left:auto;font-size:10px;text-transform:uppercase}.timeline p{white-space:pre-wrap;overflow-wrap:anywhere}.transitions{margin:26px 0}.transitions button{margin:4px 8px 4px 0;background:#e9eff9;color:#31547c}.error{color:#8d2b2b;background:#fff0f0;padding:12px 16px;border-radius:7px}.notice{color:#22623d;background:#e8f5ed;padding:12px 16px;border-radius:7px}footer{font-size:12px;margin:36px 0}.sign-in{max-width:460px;margin:80px auto;padding:32px;background:white;border:1px solid #dce4eb;border-radius:12px}.sign-in h1{font-size:29px}.sign-in>p{color:#657887;font-size:14px}.sign-in button{width:100%;margin-top:16px}.sign-in .muted{margin-top:24px;font-size:12px}@media(max-width:800px){.workspace{grid-template-columns:1fr}.page{padding:24px 18px}.incident-head{align-items:start;gap:16px}.open-form{flex-direction:column}.entry-meta{flex-wrap:wrap}.sign-in{margin:36px 18px}}
diff --git a/applications/incident-room/scripts/browser_acceptance.py b/applications/incident-room/scripts/browser_acceptance.py
new file mode 100644
index 00000000..e1940eeb
--- /dev/null
+++ b/applications/incident-room/scripts/browser_acceptance.py
@@ -0,0 +1,254 @@
+"""Real Chromium/LiveView acceptance against a dedicated seeded Cloud fixture.
+
+Install Playwright in a VM, then run with the README runtime variables and the
+seeded CASEY/MORGAN passwords. Output contains counts, never WebSocket contents.
+"""
+
+import asyncio
+import json
+import os
+from pathlib import Path
+import signal
+import subprocess
+import time
+import urllib.request
+from playwright.async_api import async_playwright, expect
+
+ORIGIN = os.environ.get("APP_ORIGIN", "http://127.0.0.1:4000")
+OUTPUT = Path(os.environ.get("EVIDENCE_DIR", "/tmp/incident-browser-evidence"))
+OUTPUT.mkdir(parents=True, exist_ok=True)
+
+
+def start_server():
+ # Do not give the HTTP process owner/admin/seed credentials.
+ keys = [
+ "PATH",
+ "HOME",
+ "LANG",
+ "PGHOST",
+ "PGPORT",
+ "PGDATABASE",
+ "PGSSLROOTCERT",
+ "SECRET_KEY_BASE",
+ "APP_ORIGIN",
+ ]
+ env = {key: os.environ[key] for key in keys if key in os.environ}
+ env.update(
+ PGUSER="incident_app", PGPASSWORD=os.environ["APP_PASSWORD"], PHX_SERVER="true"
+ )
+ log = open(OUTPUT / "server.log", "a")
+ process = subprocess.Popen(
+ ["mix", "phx.server"], env=env, stdout=log, stderr=log, start_new_session=True
+ )
+ for _ in range(150):
+ try:
+ with urllib.request.urlopen(ORIGIN + "/sign-in", timeout=2) as response:
+ if response.status == 200:
+ return process, log
+ except (OSError, TimeoutError):
+ time.sleep(0.2)
+ if process.poll() is not None:
+ raise RuntimeError("Server exited; inspect the private server log")
+ raise RuntimeError("Server did not start")
+
+
+def stop_server(process, log):
+ if process.poll() is not None:
+ log.close()
+ return
+ os.killpg(process.pid, signal.SIGTERM)
+ try:
+ process.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ os.killpg(process.pid, signal.SIGKILL)
+ process.wait()
+ log.close()
+
+
+async def sign_in(page, email, password):
+ await page.goto(ORIGIN + "/sign-in")
+ await page.locator('input[name="session[email]"]').fill(email)
+ await page.locator('input[name="session[password]"]').fill(password)
+ await page.get_by_role("button", name="Sign in", exact=True).click()
+ await page.wait_for_url(ORIGIN + "/")
+ await page.wait_for_function("window.liveSocket && window.liveSocket.isConnected()")
+
+
+async def push(page, event, payload):
+ await page.evaluate(
+ """([event, value]) => {
+ window.liveSocket.execJS(document.querySelector('[data-phx-main]'),
+ JSON.stringify([['push', {event, value}]]));
+ }""",
+ [event, payload],
+ )
+
+
+async def main():
+ server, log = start_server()
+ try:
+ async with async_playwright() as playwright:
+ browser = await playwright.chromium.launch()
+ one = await browser.new_context(viewport={"width": 1360, "height": 1000})
+ two = await browser.new_context(viewport={"width": 1360, "height": 1000})
+ anonymous = await browser.new_context()
+ casey, morgan, signed_out = (
+ await one.new_page(),
+ await two.new_page(),
+ await anonymous.new_page(),
+ )
+ frames = {"casey": 0, "morgan": 0}
+ for page, label in [(casey, "casey"), (morgan, "morgan")]:
+ page.on(
+ "websocket",
+ lambda socket, label=label: socket.on(
+ "framereceived",
+ lambda _: frames.__setitem__(label, frames[label] + 1),
+ ),
+ )
+ await sign_in(
+ casey, "casey@example.test", os.environ["SEED_CASEY_PASSWORD"]
+ )
+ await sign_in(
+ morgan, "morgan@example.test", os.environ["SEED_MORGAN_PASSWORD"]
+ )
+ title = "API recovery " + str(time.time_ns())
+ await casey.locator('input[name="incident[title]"]').fill(title)
+ await casey.get_by_role("button", name="Open incident").click()
+ await casey.wait_for_url("**/incidents/*")
+ room = casey.url
+ await morgan.goto(room)
+ await expect(morgan.get_by_role("heading", name=title)).to_be_visible()
+ await expect(morgan.locator(".live-badge")).to_have_text("● Live room")
+ response = await anonymous.request.post(
+ ORIGIN + "/sign-in",
+ form={"email": "casey@example.test", "password": "no-token"},
+ )
+ assert response.status == 403
+ print("PASS HTTP mutation without CSRF token returns native403")
+ await signed_out.goto(room)
+ await expect(signed_out).to_have_url(ORIGIN + "/sign-in")
+ print("PASS signed-out HTTP room read redirects before mount")
+
+ await morgan.locator("textarea").fill("Unsaved responder draft")
+ await casey.locator("textarea").fill("Errors are falling after rollback")
+ await casey.get_by_role("button", name="Add note").click()
+ await expect(casey.locator("textarea")).to_have_value("")
+ await expect(morgan.locator("#timeline")).to_contain_text(
+ "Errors are falling after rollback"
+ )
+ await expect(morgan.locator("textarea")).to_have_value(
+ "Unsaved responder draft"
+ )
+ print(
+ "PASS committed note broadcasts to second browser; own form clears, foreign draft survives"
+ )
+
+ before = await casey.locator("#timeline li").count()
+ await push(
+ casey,
+ "note",
+ {
+ "note": {
+ "body": "Forged author",
+ "author_id": "00000000-0000-0000-0000-000000000002",
+ }
+ },
+ )
+ await expect(casey.get_by_role("alert")).to_contain_text("not saved")
+ assert await casey.locator("#timeline li").count() == before
+ await push(casey, "note", {"note": []})
+ await expect(casey.get_by_role("alert")).to_contain_text("not saved")
+ await push(casey, "transition", {"status": "resolved", "version": "0"})
+ await expect(casey.get_by_role("alert")).to_contain_text("not saved")
+ await expect(casey.locator("#incident-status")).to_have_text(
+ "Investigating"
+ )
+ print(
+ "PASS forged author, malformed payload and invalid transition leave timeline unchanged"
+ )
+
+ await asyncio.gather(
+ push(casey, "transition", {"status": "monitoring", "version": "0"}),
+ push(morgan, "transition", {"status": "monitoring", "version": "0"}),
+ )
+ await expect(casey.locator("#incident-status")).to_have_text("Monitoring")
+ await expect(morgan.locator("#incident-status")).to_have_text("Monitoring")
+ await expect(casey.locator("#timeline")).to_contain_text(
+ "Status changed from investigating to monitoring"
+ )
+ assert await casey.locator("#timeline li").count() == before + 1
+ print(
+ "PASS two browser expected-version race commits exactly one transition entry"
+ )
+
+ await morgan.evaluate("window.liveSocket.disconnect()")
+ await casey.locator("textarea").fill("Missed broadcast while offline")
+ await casey.get_by_role("button", name="Add note").click()
+ await expect(casey.locator("#timeline")).to_contain_text(
+ "Missed broadcast while offline"
+ )
+ assert (
+ "Missed broadcast while offline"
+ not in await morgan.locator("#timeline").inner_text()
+ )
+ await morgan.evaluate("window.liveSocket.connect()")
+ await expect(morgan.locator("#timeline")).to_contain_text(
+ "Missed broadcast while offline"
+ )
+ print(
+ "PASS reconnect rehydrates committed data after deliberately missed broadcast"
+ )
+ await morgan.screenshot(
+ path=str(OUTPUT / "incident-room.png"), full_page=True
+ )
+
+ old_pid = server.pid
+ stop_server(server, log)
+ server, log = start_server()
+ assert old_pid != server.pid
+ await casey.reload()
+ await expect(casey.locator(".live-badge")).to_have_text("● Live room")
+ await expect(casey.locator("#timeline")).to_contain_text(
+ "Missed broadcast while offline"
+ )
+ await expect(casey.locator("#incident-status")).to_have_text("Monitoring")
+ await casey.locator("textarea").fill(
+ "Session still authorized after restart"
+ )
+ await casey.get_by_role("button", name="Add note").click()
+ await expect(casey.locator("#timeline")).to_contain_text(
+ "Session still authorized after restart"
+ )
+ print(
+ "PASS real server process restart retains DB timeline, state and authenticated session"
+ )
+
+ duplicate_tab = await one.new_page()
+ await duplicate_tab.goto(room)
+ await expect(duplicate_tab.locator(".live-badge")).to_have_text(
+ "● Live room"
+ )
+ preserved_cookie = await one.cookies()
+ await casey.get_by_role("button", name="Sign out", exact=True).click()
+ await expect(casey).to_have_url(ORIGIN + "/sign-in")
+ await duplicate_tab.evaluate("window.liveSocket.connect()")
+ await duplicate_tab.reload()
+ await expect(duplicate_tab).to_have_url(ORIGIN + "/sign-in")
+ replay = await browser.new_context()
+ await replay.add_cookies(preserved_cookie)
+ replay_page = await replay.new_page()
+ await replay_page.goto(room)
+ await expect(replay_page).to_have_url(ORIGIN + "/sign-in")
+ print(
+ "PASS independent replay of pre-logout cookie is rejected after DB revocation"
+ )
+ assert frames["casey"] > 0 and frames["morgan"] > 0
+ print("WebSocket received frame counts: " + json.dumps(frames))
+ await browser.close()
+ finally:
+ stop_server(server, log)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/applications/incident-room/scripts/migrate.exs b/applications/incident-room/scripts/migrate.exs
new file mode 100644
index 00000000..e975d4a1
--- /dev/null
+++ b/applications/incident-room/scripts/migrate.exs
@@ -0,0 +1,8 @@
+# Run with the schema owner's credentials, never the server's runtime role.
+Ecto.Migrator.run(
+ IncidentRoom.Repo,
+ Application.app_dir(:incident_room, "priv/repo/migrations"),
+ :up,
+ all: true,
+ prefix: "incident_room"
+)
diff --git a/applications/incident-room/sql/bootstrap.sql b/applications/incident-room/sql/bootstrap.sql
new file mode 100644
index 00000000..060fb7d5
--- /dev/null
+++ b/applications/incident-room/sql/bootstrap.sql
@@ -0,0 +1,6 @@
+\set ON_ERROR_STOP on
+CREATE ROLE incident_migration LOGIN PASSWORD :'migration_password';
+CREATE ROLE incident_app LOGIN PASSWORD :'app_password';
+REVOKE CREATE ON SCHEMA public FROM PUBLIC;
+CREATE SCHEMA incident_room AUTHORIZATION incident_migration;
+GRANT USAGE ON SCHEMA incident_room TO incident_app;
diff --git a/applications/incident-room/sql/cleanup.sql b/applications/incident-room/sql/cleanup.sql
new file mode 100644
index 00000000..ba63e9e7
--- /dev/null
+++ b/applications/incident-room/sql/cleanup.sql
@@ -0,0 +1,4 @@
+\set ON_ERROR_STOP on
+DROP SCHEMA IF EXISTS incident_room CASCADE;
+DROP ROLE IF EXISTS incident_app;
+DROP ROLE IF EXISTS incident_migration;
diff --git a/applications/incident-room/sql/grants.sql b/applications/incident-room/sql/grants.sql
new file mode 100644
index 00000000..e4d4b259
--- /dev/null
+++ b/applications/incident-room/sql/grants.sql
@@ -0,0 +1,5 @@
+\set ON_ERROR_STOP on
+GRANT SELECT ON incident_room.users TO incident_app;
+GRANT SELECT, INSERT, DELETE ON incident_room.sessions TO incident_app;
+GRANT SELECT, INSERT, UPDATE ON incident_room.incidents TO incident_app;
+GRANT SELECT, INSERT ON incident_room.entries TO incident_app;
diff --git a/applications/incident-room/test/cloud_test.exs b/applications/incident-room/test/cloud_test.exs
new file mode 100644
index 00000000..8422372f
--- /dev/null
+++ b/applications/incident-room/test/cloud_test.exs
@@ -0,0 +1,276 @@
+defmodule IncidentRoom.CloudTest do
+ use ExUnit.Case, async: false
+ import Ecto.Query
+ import ExUnit.CaptureLog
+ alias IncidentRoom.{Auth, Entry, Incidents, Repo, User}
+ @moduletag :cloud
+
+ setup do
+ token = Auth.create_session(Repo.get_by!(User, email: "casey@example.test"))
+ on_exit(fn -> Auth.delete_session(token) end)
+ %{token: token}
+ end
+
+ test "same-endpoint Postgrex accepts verified TLS and rejects wrong CA and hostname" do
+ assert %{rows: [[true]]} =
+ Repo.query!("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()")
+
+ opts = Application.fetch_env!(:incident_room, Repo)
+ rejected_tls(opts, :cacertfile, ~c"/etc/ssl/certs/ca-certificates.crt", "unknown_ca")
+
+ rejected_tls(
+ opts,
+ :server_name_indication,
+ ~c"wrong.example.invalid",
+ "hostname_check_failed"
+ )
+
+ assert %{rows: [[1]]} = Repo.query!("SELECT 1")
+ end
+
+ test "runtime cannot run DDL, read migration history, or rewrite identity and timeline", %{
+ token: token
+ } do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Privilege checks"})
+ id = Ecto.UUID.dump!(incident.id)
+
+ for {sql, params} <- [
+ {"CREATE TABLE incident_room.forbidden (id integer)", []},
+ {"SELECT * FROM incident_room.schema_migrations", []},
+ {"UPDATE incident_room.users SET name = name", []},
+ {"UPDATE incident_room.entries SET body = body WHERE incident_id = $1", [id]},
+ {"DELETE FROM incident_room.entries WHERE incident_id = $1", [id]}
+ ] do
+ assert {:error, %Postgrex.Error{postgres: %{code: :insufficient_privilege}}} =
+ Repo.query(sql, params)
+ end
+ end
+
+ test "concurrent expected-version transitions produce one committed status entry", %{
+ token: token
+ } do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Concurrent response"})
+
+ results =
+ 1..2
+ |> Enum.map(fn _ ->
+ Task.async(fn ->
+ Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0})
+ end)
+ end)
+ |> Enum.map(&Task.await(&1, 30_000))
+
+ assert Enum.count(results, &match?({:ok, _}, &1)) == 1
+ assert Enum.count(results, &match?({:error, :stale_version}, &1)) == 1
+ assert %{status: "monitoring", version: 1} = Incidents.get(incident.id)
+ assert Enum.count(Incidents.timeline(incident.id), &(&1.kind == "status")) == 1
+ end
+
+ test "invalid transition and forged author roll back; resolved room rejects notes", %{
+ token: token
+ } do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Terminal room"})
+
+ assert {:error, _} =
+ Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 0})
+
+ assert {:error, _} =
+ Incidents.note(token, incident.id, %{
+ "body" => "Forged",
+ "author_id" => Ecto.UUID.generate()
+ })
+
+ assert [%{kind: "opened"}] = Incidents.timeline(incident.id)
+ assert %{version: 0, status: "investigating"} = Incidents.get(incident.id)
+
+ {:ok, _} =
+ Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0})
+
+ {:ok, _} = Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 1})
+ assert {:error, :resolved} = Incidents.note(token, incident.id, %{"body" => "Too late"})
+ assert length(Incidents.timeline(incident.id)) == 3
+ end
+
+ test "session identity is authoritative and revocation denies subsequent writes", %{
+ token: token
+ } do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Session authority"})
+ {:ok, _} = Incidents.note(token, incident.id, %{"body" => "Verified author"})
+ assert List.last(Incidents.timeline(incident.id)).author.email == "casey@example.test"
+ Auth.delete_session(token)
+ assert {:error, _} = Incidents.note(token, incident.id, %{"body" => "Revoked"})
+ assert {:error, _} = Incidents.open(nil, %{"title" => "Signed out"})
+ assert length(Incidents.timeline(incident.id)) == 2
+ end
+
+ test "bounded timeline retains the newest entry and Unicode bounds agree with SQL", %{
+ token: token
+ } do
+ assert {:error, :invalid_input} =
+ Incidents.open(token, %{"title" => String.duplicate("e\u0301", 81)})
+
+ {:ok, incident} = Incidents.open(token, %{"title" => String.duplicate("e\u0301", 80)})
+ author = Auth.user(token)
+ now = DateTime.utc_now()
+
+ rows =
+ for n <- 1..205,
+ do: %{
+ id: Ecto.UUID.generate(),
+ incident_id: incident.id,
+ author_id: author.id,
+ kind: "note",
+ body: "Batch #{n}",
+ inserted_at: DateTime.add(now, n, :microsecond)
+ }
+
+ Repo.insert_all(Entry, rows)
+ timeline = Incidents.timeline(incident.id)
+ assert length(timeline) == 200
+ assert List.first(timeline).body == "Batch 6"
+ assert List.last(timeline).body == "Batch 205"
+ assert timeline == Enum.sort_by(timeline, &{&1.inserted_at, &1.id})
+ end
+
+ test "held database lock prevents writes and PubSub until committed", %{token: token} do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Commit boundary"})
+ Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{incident.id}")
+ parent = self()
+
+ holder =
+ Task.async(fn ->
+ Repo.transact(fn ->
+ Repo.one!(
+ from i in IncidentRoom.Incident, where: i.id == ^incident.id, lock: "FOR UPDATE"
+ )
+
+ send(parent, :locked)
+
+ receive do
+ :release -> {:ok, :released}
+ after
+ 15_000 -> {:error, :timeout}
+ end
+ end)
+ end)
+
+ assert_receive :locked, 15_000
+
+ writer =
+ Task.async(fn ->
+ Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0})
+ end)
+
+ assert wait_for_blocking()
+ refute_receive :committed, 200
+ assert Task.yield(writer, 0) == nil
+ send(holder.pid, :release)
+ assert {:ok, :released} = Task.await(holder, 30_000)
+ assert {:ok, %{version: 1}} = Task.await(writer, 30_000)
+ assert_receive :committed, 5_000
+ assert length(Incidents.timeline(incident.id)) == 2
+
+ assert {:error, :stale_version} =
+ Incidents.transition(token, incident.id, %{"status" => "resolved", "version" => 0})
+
+ refute_receive :committed, 200
+ end
+
+ test "entry failure after status update rolls back state and emits no PubSub", %{token: token} do
+ {:ok, incident} = Incidents.open(token, %{"title" => "Forced transaction rollback"})
+ Phoenix.PubSub.subscribe(IncidentRoom.PubSub, "incident:#{incident.id}")
+ opts = Application.fetch_env!(:incident_room, Repo)
+
+ owner_opts =
+ opts
+ |> Keyword.put(:username, "incident_migration")
+ |> Keyword.put(:password, System.fetch_env!("MIGRATION_PASSWORD"))
+ |> Keyword.put(:pool_size, 1)
+
+ {:ok, owner} = Postgrex.start_link(owner_opts)
+
+ Postgrex.query!(
+ owner,
+ """
+ CREATE FUNCTION incident_room.fail_entry_test() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ RAISE EXCEPTION 'forced_timeline_failure' USING ERRCODE = '23514', CONSTRAINT = 'forced_timeline_failure';
+ END;
+ $$
+ """,
+ []
+ )
+
+ # UUID is generated by Ecto and contains only hex digits/hyphens. This DDL is
+ # fixture-owner-only; production queries remain parameterized.
+ Postgrex.query!(
+ owner,
+ """
+ CREATE TRIGGER fail_entry_test BEFORE INSERT ON incident_room.entries
+ FOR EACH ROW WHEN (NEW.incident_id = '#{incident.id}'::uuid AND NEW.kind = 'status')
+ EXECUTE FUNCTION incident_room.fail_entry_test()
+ """,
+ []
+ )
+
+ try do
+ try do
+ Incidents.transition(token, incident.id, %{"status" => "monitoring", "version" => 0})
+ flunk("Expected the timeline write to fail after the incident update")
+ rescue
+ error in [Postgrex.Error, Ecto.ConstraintError] ->
+ assert Exception.message(error) =~ "forced_timeline_failure"
+ end
+
+ assert %{status: "investigating", version: 0} = Incidents.get(incident.id)
+ assert [%{kind: "opened"}] = Incidents.timeline(incident.id)
+ refute_receive :committed, 200
+ after
+ Postgrex.query!(owner, "DROP TRIGGER fail_entry_test ON incident_room.entries", [])
+ Postgrex.query!(owner, "DROP FUNCTION incident_room.fail_entry_test()", [])
+ GenServer.stop(owner, :normal, 30_000)
+ end
+ end
+
+ defp wait_for_blocking do
+ Enum.reduce_while(1..100, false, fn _, _ ->
+ Repo.query!("SELECT pg_stat_clear_snapshot()")
+
+ %{rows: [[count]]} =
+ Repo.query!(
+ "SELECT count(*) FROM pg_stat_activity WHERE usename = current_user AND cardinality(pg_blocking_pids(pid)) > 0"
+ )
+
+ if count > 0,
+ do: {:halt, true},
+ else:
+ (
+ Process.sleep(50)
+ {:cont, false}
+ )
+ end)
+ end
+
+ defp rejected_tls(opts, key, value, expected) do
+ ssl = Keyword.put(opts[:ssl], key, value)
+
+ connection =
+ opts
+ |> Keyword.put(:ssl, ssl)
+ |> Keyword.put(:backoff_type, :stop)
+ |> Keyword.put(:pool_size, 1)
+ |> Keyword.put(:max_restarts, 0)
+
+ previous = Process.flag(:trap_exit, true)
+
+ log =
+ capture_log(fn ->
+ {:ok, pid} = Postgrex.start_link(connection)
+ monitor = Process.monitor(pid)
+ assert_receive {:DOWN, ^monitor, :process, ^pid, _reason}, 20_000
+ end)
+
+ Process.flag(:trap_exit, previous)
+ assert log =~ expected
+ end
+end
diff --git a/applications/incident-room/test/domain_test.exs b/applications/incident-room/test/domain_test.exs
new file mode 100644
index 00000000..635a4c86
--- /dev/null
+++ b/applications/incident-room/test/domain_test.exs
@@ -0,0 +1,29 @@
+defmodule IncidentRoom.DomainTest do
+ use ExUnit.Case, async: true
+ alias IncidentRoom.Incidents
+
+ test "status graph closes resolved rooms" do
+ assert Incidents.valid_transition?("investigating", "monitoring")
+ assert Incidents.valid_transition?("monitoring", "resolved")
+ assert Incidents.valid_transition?("monitoring", "investigating")
+ refute Incidents.valid_transition?("investigating", "resolved")
+ refute Incidents.valid_transition?("resolved", "investigating")
+ end
+
+ test "notes have nonblank bounded text and no NUL" do
+ assert Incidents.valid_text?("Observed a recovery", 2000)
+ refute Incidents.valid_text?(" ", 2000)
+ refute Incidents.valid_text?(String.duplicate("x", 2001), 2000)
+ refute Incidents.valid_text?("hidden" <> <<0>>, 2000)
+ end
+
+ test "Unicode limits count PostgreSQL codepoints, not graphemes" do
+ assert Incidents.valid_text?(String.duplicate("e\u0301", 80), 160)
+ refute Incidents.valid_text?(String.duplicate("e\u0301", 81), 160)
+ assert Incidents.valid_text?(String.duplicate("🧑💻", 53), 160)
+ refute Incidents.valid_text?(String.duplicate("🧑💻", 54), 160)
+ assert {:error, :invalid_input} = Incidents.open(nil, [])
+ assert {:error, :invalid_input} = Incidents.note(nil, nil, "body")
+ assert {:error, :invalid_input} = Incidents.transition(nil, nil, nil)
+ end
+end
diff --git a/applications/incident-room/test/test_helper.exs b/applications/incident-room/test/test_helper.exs
new file mode 100644
index 00000000..b51f87a3
--- /dev/null
+++ b/applications/incident-room/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start(exclude: [cloud: true])