docs(tasks): update phase 1, 2, and 3 tasks with forensic audit-1 hardening

This commit is contained in:
Tyler Gillispie 2026-08-26 15:14:48 -07:00
parent 1ef1125b4a
commit e88512edad
3 changed files with 186 additions and 159 deletions

View File

@ -9,25 +9,25 @@
claims. claims.
- **Dependencies:** None. - **Dependencies:** None.
- **Additional Important Notes:** Follow zero-trust default-deny principles for - **Additional Important Notes:** Follow zero-trust default-deny principles for
ForwardAuth. Guest sessions minting needs to correctly log to the Merkle audit ForwardAuth. Guest session minting must correctly log to the Merkle audit
ledger for SIEM visibility. ledger for SIEM visibility.
--- ---
### 2. Architectural Considerations & Risks ## 2. Architectural Considerations & Risks
- **Risks:** - **Risks:**
- **ForwardAuth Security:** Modifying ForwardAuth to accept `guest` accounts - **ForwardAuth Security:** Modifying ForwardAuth to accept `guest` accounts
risks inadvertently allowing guests to access applications they are not risks inadvertently allowing guests to access applications they are not
explicitly scoped for. Strict validation of `auth.customScopes` against the explicitly scoped for. Strict validation of `auth.customScopes` against the
requested `appRecord.name` is critical. requested `appRecord.name` is critical.
- **Launchpad Leakage:** The `getDashboardApps` query modification must be - **Launchpad SQL Array Guard:** If a user or guest has no `app:` custom
robust. If the UNION query is not correctly structured, it could leak scopes, passing an empty array into an `IN ()` clause causes invalid SQL.
visibility of unregistered or unauthorized apps to guests. The query must use `if (appNames.length > 0)` or
`WHERE name = ANY(${appNames}::text[])`.
- **Audit Logging Integrity:** Failure to capture the audit events properly - **Audit Logging Integrity:** Failure to capture the audit events properly
during the `join` phase would break SIEM visibility. Using the non-blocking during the `join` phase would break SIEM visibility. Using the non-blocking
`auditWrapper.auditLog` is required to avoid impacting user request latency `auditWrapper.auditLog` with `getClientIp(c)` is required.
while ensuring compliance.
- **Alternatives:** - **Alternatives:**
- We considered a bypass in `ui/db_queries.ts` where guests completely skip - We considered a bypass in `ui/db_queries.ts` where guests completely skip
@ -35,51 +35,55 @@
seamlessly supports both pure guests and regular active users who might hold seamlessly supports both pure guests and regular active users who might hold
delegated scoped passes. It provides a unified data retrieval flow. delegated scoped passes. It provides a unified data retrieval flow.
### 3. Proposed Implementation ---
#### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`) ## 3. Proposed Implementation
1. **Permit Guest Accounts:** Update the account status validation to allow ### Phase 1: ForwardAuth Guest Ingress (`server/routes/auth_forward.ts`)
`user.account_status === 'guest'` in addition to `'active'`.
1. **Permit Guest Accounts:** Update the account status validation (around
line 120) to allow `user.account_status === 'guest'` in addition to
`'active'`.
2. **Custom Scopes Validation:** If the user is a guest (or relying on custom 2. **Custom Scopes Validation:** If the user is a guest (or relying on custom
scopes), verify that `auth.customScopes` contains the explicit application scopes), verify that `auth.customScopes` contains the explicit application
grant (e.g., `app:${appRecord.name}`). If not, return a 403 Forbidden. grant (e.g., `app:${appRecord.name}` or wildcard `*`). If not, return a
`403 Forbidden`.
3. **Header Injection:** For guest sessions, compute the scopes to inject into 3. **Header Injection:** For guest sessions, compute the scopes to inject into
`X-Forwarded-Scopes` by taking the comma-joined list of `auth.customScopes` `X-Forwarded-Scopes` by taking the comma-joined list of `auth.customScopes`
(`auth.customScopes.join(",")`). This propagates the app scope (e.g., (`auth.customScopes.filter(Boolean).join(",") || "viewer"`). This propagates
`app:ed-droid`) and role (e.g., `viewer`) to downstream reverse proxies. the app scope (e.g., `app:ed-droid`) and role (e.g., `viewer`) to downstream
reverse proxies.
#### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`) ### Phase 2: Launchpad App Query (`ui/db_queries.ts` & `ui/mod.ts`)
1. **Function Signature Update:** Update 1. **Function Signature Update:** Update
`getDashboardApps(userId: string, isAdmin: boolean, customScopes?: string[])` `getDashboardApps(userId: string, isAdmin: boolean, customScopes?: string[])`
to accept the session's custom scopes. to accept the session's custom scopes.
2. **UNION Query:** Update the non-admin SQL query to use a `UNION`. 2. **UNION Query with Array Guard:**
- The first part queries explicitly granted apps via the `grants` table. - Extract app names from `customScopes` (e.g. scopes starting with `app:`).
- The second part parses the provided `customScopes` array to extract allowed - If `appNames.length > 0`, execute a `UNION` with
app names (e.g., stripping the `app:` prefix), querying the `apps` table `SELECT id, name, description, domain, 'Guest (Viewer)' as role FROM apps WHERE domain IS NOT NULL AND name = ANY(${appNames}::text[])`.
for those matching names, and assigning them a pseudo-role such as - If `appNames.length === 0`, return only the primary `grants` query result.
`"Guest (Viewer)"` (or extracting the role from the scopes if possible). 3. **UI Integration:** In `ui/mod.ts` under `/dashboard`, pass
3. **UI Integration:** In `ui/mod.ts`, pass `auth.customScopes` from the `auth.customScopes` from the resolved session into `getDashboardApps`.
resolved session into `getDashboardApps`.
#### Phase 3: Join Audit Logging (`server/routes/events.ts`) ### Phase 3: Join Audit Logging (`server/routes/events.ts`)
1. **Audit `POST /api/join` (Web):** Immediately after minting the guest 1. **Import `getClientIp`:** Import `getClientIp` from `../middleware.ts`.
session, invoke 2. **Audit `POST /api/join` (Web):** Immediately after minting the guest
session, invoke:
`auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "web" }, getClientIp(c))`. `auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "web" }, getClientIp(c))`.
2. **Audit `GET /join/:slug` (CLI):** Similarly, wire 3. **Audit `GET /join/:slug` (CLI):** Similarly, wire:
`auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))` `auditWrapper.auditLog(guestUuid, "event_seat_claimed", event.id, { slug: event.slug, name: event.name, seatNumber: event.seats_claimed, method: "cli" }, getClientIp(c))`.
into this endpoint before returning the token.
#### Phase 4: Unit Testing ### Phase 4: Unit Testing
1. **ForwardAuth Tests (`server/tests/forward_auth.test.ts`):** 1. **ForwardAuth Tests (`server/tests/forward_auth.test.ts`):**
- Add tests simulating a guest session with an `app:{name}` scope to ensure a - Add test:
`200 OK` response with correctly formatted `X-Forwarded-Scopes`. `Tier 1 & 2: GET /api/forward-auth - Guest session with app scope allowed`.
- Add tests simulating a guest session without the necessary `app:{name}` - Add test:
scope to ensure a `403 Forbidden` response. `Tier 1 & 2: GET /api/forward-auth - Guest session without app scope rejected (403)`.
2. **Events Tests (`server/tests/events.test.ts`):** 2. **Events Tests (`server/tests/events.test.ts`):**
- Ensure the `POST /api/join` and `GET /join/:slug` assertions capture the - Ensure the `POST /api/join` and `GET /join/:slug` assertions verify the
`auditLog` spy calls and verify the exact payload (`event_seat_claimed` `auditLog` call payload (`event_seat_claimed` action, `event.id` as
action, `event.id` as resource, correct metadata structure). resource, and correct metadata structure).

View File

@ -1,84 +1,101 @@
### 1. TASK METADATA # TASK METADATA
- **Target Files:** `server/routes/events.ts`, `ui/components/LoginPage.tsx`, - **Target Files:** `server/routes/events.ts`, `ui/components/LoginPage.tsx`,
`ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx` `ui/components/RegisterPage.tsx`, `ui/components/EventJoinPage.tsx`,
`server/tests/events.test.ts`
- **Core Objective:** Implement Phase 2 Event Overhaul: Add PIN discovery links, - **Core Objective:** Implement Phase 2 Event Overhaul: Add PIN discovery links,
normalize input, apply tiered Valkey rate limiting, and implement NAT-safe bifurcate slug/PIN input normalization, apply tiered Valkey rate limiting on
idempotent re-entry on join. failed brute-force attempts, and implement NAT-safe idempotent cookie
- **Dependencies:** `server/middleware.ts` (for getClientIp), re-entry.
- **Dependencies:** `server/middleware.ts` (for `getClientIp`),
`server/ratelimit.ts` (for rate limiting logic). `server/ratelimit.ts` (for rate limiting logic).
- **Additional Important Notes:** Frontend changes should mirror backend - **Additional Important Notes:** Attendee identity must remain 100% IP-agnostic
normalization. Re-entry must seamlessly return an existing active guest by relying on browser session cookies (NAT-safe for shared WiFi). IP tracking
session linked to the correct event without incrementing `seats_claimed`. is strictly reserved for throttling failed brute-force attacks and forensic
audit logs.
--- ---
### 2. Architectural Considerations & Risks ## 2. Architectural Considerations & Risks
- **Risks:** - **Risks:**
- If idempotent re-entry is flawed, returning a mismatched event session could - **Slug Hyphen Destruction:** Event slugs legitimately contain hyphens (e.g.,
allow access to an event a user didn't register for, or incorrectly redirect `testing-workshop-event-...`). Stripping hyphens globally breaks slug
them. We must strictly verify the existing session is a guest session lookups. Normalization MUST bifurcate: preserve hyphens for slugs
belonging to the target event. (`LOWER(slug)`), while stripping hyphens only for PIN matching
- Rate limiting logic might accidentally block legitimate NAT'ed traffic if (`REPLACE(pin_code, '-', '')`).
thresholds are set too low; 5 fail / 3 success per minute per IP should be - **NAT False-Positives:** Never bind attendee identity or successful seats
monitored. strictly to an IP. Shared conference/classroom WiFi shares one egress IP.
- Normalization on the backend must be implemented securely using robust Idempotent re-entry must inspect the incoming `session_id` cookie.
string replacements to prevent SQL injection or unexpected matching - **Rate Limiter State Machine:** Pre-check gates at the route entry must
behaviors, especially with PostgreSQL parameterized queries reject exceeded IPs (`429`), while post-execution increments must distinctly
(`sqlWrapper.sql`). separate failed code attempts (`ratelimit:join:fail:<ip>`) from successful
joins.
- **Alternatives:** - **Alternatives:**
- For normalization, instead of just runtime stripping, we could normalize - Storing pre-normalized columns was evaluated and rejected; runtime
upon creation and store both normalized and display values. However, normalization with `LOWER(slug)` and `REPLACE(pin_code, '-', '')` is fast
stripping at runtime with `REPLACE(pin_code, '-', '')` and `LOWER(slug)` is and zero-migration.
an acceptable and low-friction approach for our scale.
- Rate limiting could use sliding window algorithms, but Valkey-backed fixed
windows via the existing `rateLimitWrapper` are more efficient and standard
for our stack.
### 3. Proposed Implementation ---
#### Phase 1: Login & Registration Discovery Links ## 3. Proposed Implementation
- **File:** `ui/components/LoginPage.tsx` ### Phase 1: Login & Registration Discovery Links
- Add a "Join with PIN" link next to the existing "Register with Invite" in
the footer links section to ensure unauthenticated users can easily discover
the join portal.
- **File:** `ui/components/RegisterPage.tsx`
- Add a similar "Join with PIN" link to the footer for consistency across
entry points.
#### Phase 2: Input Normalization (Frontend & Backend) 1. **`ui/components/LoginPage.tsx`:** Add a "Join with PIN" link next to
"Register with Invite" in the footer links section.
2. **`ui/components/RegisterPage.tsx`:** Add a similar "Join with PIN" link to
the footer for consistency across all unauthenticated entry points.
- **File:** `ui/components/EventJoinPage.tsx` ### Phase 2: Bifurcated Input Normalization
- Enhance the UI script to tolerate input formatting, naturally handling
hyphens, spaces, and case differences during submission, providing immediate
visual feedback without rejecting valid raw input (e.g. `241881` vs
`241-881`).
- **File:** `server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`)
- Strip whitespace, hyphens, and force lowercase on the incoming code on the
backend (`code.trim().replace(/[-\s]/g, '').toLowerCase()`).
- Update the `sqlWrapper` query to match normalized inputs:
`WHERE (LOWER(slug) = ${code} OR REPLACE(pin_code, '-', '') = ${code})`.
#### Phase 3: Anti-DoS Rate Limiting 1. **`ui/components/EventJoinPage.tsx`:** Enhance the client-side script to
accept raw 6 digits (`241881`) or hyphenated PINs (`241-881`) and slugs
without throwing client-side validation errors.
2. **`server/routes/events.ts` (`POST /api/join` & `GET /join/:slug`):**
- Create two normalized representations:
- `rawNormalized = code.trim().toLowerCase()` (preserves hyphens for
slugs).
- `pinNormalized = code.trim().replace(/[-\s]/g, '')` (strips
hyphens/spaces for PINs).
- Update SQL query:
`WHERE (LOWER(slug) = ${rawNormalized} OR REPLACE(pin_code, '-', '') = ${pinNormalized})`
`AND is_active = TRUE AND (expires_at IS NULL OR expires_at > NOW())`
- **File:** `server/routes/events.ts` (`POST /api/join`) ### Phase 3: Anti-DoS Rate Limiting on `POST /api/join`
- Use `getClientIp(c)` from our middleware to determine the client IP address.
- Mount tiered Valkey rate limits using the keys `ratelimit:join:fail:<ip>`
(limit: 5 per 60s) and `ratelimit:join:success:<ip>` (limit: 3 per 60s).
- Apply the rate limiting logic prior to executing any database queries to
prevent database DoS on brute-force PIN attempts.
#### Phase 4: NAT-Safe Idempotent Re-entry 1. **Pre-Check Gate (Top of Route):**
- Resolve IP via `const clientIp = getClientIp(c)`.
- Check if `ratelimit:join:fail:${clientIp}` exceeds 5 attempts per 60s. If
so, return `429 Too Many Requests`.
2. **Post-Execution Increments:**
- If lookup returns 0 rows (invalid code / expired): increment
`ratelimit:join:fail:${clientIp}` (window: 60s) before returning `404`.
- If seat is successfully claimed: optionally increment
`ratelimit:join:success:${clientIp}` (limit: 3 per 60s).
- **File:** `server/routes/events.ts` (`POST /api/join`) ### Phase 4: NAT-Safe Idempotent Re-entry
- Before claiming a seat and generating a new session, check for an existing
valid user session using `getAuthenticatedUser(c)`. 1. **Session Cookie Inspection (`POST /api/join`):**
- If a valid session exists, query the database to determine if the `user_id` - Before executing the `seats_claimed + 1` update, check
is linked to a guest account for the target event (e.g., username matches `getAuthenticatedUser(c)`.
`guest_<event.slug>_%`). - If a valid session exists, verify if the session is a guest session
- If the user is already a guest of this event, bypass the `seats_claimed + 1` belonging to the matched event slug
update and early-return `{ success: true, sessionId, redirectUrl }`, keeping (`username.startsWith('guest_' + event.slug + '_')`).
the existing session intact. - If matched:
- Re-issue the `session_id` cookie with fresh TTL.
- Return
`{ success: true, sessionId: auth.sessionId, redirectUrl, reused: true }`
without incrementing `seats_claimed`.
### Phase 5: Unit Testing
1. **`server/tests/events.test.ts`:**
- Add test:
`POST /api/join - normalizes PIN without hyphens (241881 -> 241-881)`.
- Add test:
`POST /api/join - normalizes slug case-insensitively while preserving hyphens`.
- Add test:
`POST /api/join - idempotent re-entry returns existing session without burning seat`.
- Add test: `POST /api/join - enforces 5 failed attempts rate limit per IP`.

View File

@ -2,84 +2,90 @@
- **Target Files:** `ui/components/SessionsPage.tsx`, - **Target Files:** `ui/components/SessionsPage.tsx`,
`ui/components/sessions/WorkshopDrawer.tsx`, `ui/components/sessions/WorkshopDrawer.tsx`,
`ui/components/sessions/SessionsScript.tsx` `ui/components/sessions/SessionsScript.tsx`, `ui/ui_scripts.test.ts`
- **Core Objective:** Overhaul visual hierarchy of `/dashboard/sessions` and - **Core Objective:** Overhaul visual hierarchy of `/dashboard/sessions` and
enforce strict 2-state mutually exclusive rendering in `WorkshopDrawer.tsx`. enforce strict 2-state mutually exclusive rendering in `WorkshopDrawer.tsx`.
- **Dependencies:** None. - **Dependencies:** None.
- **Additional Important Notes:** Must use pure vanilla JavaScript for DOM - **Additional Important Notes:** Must use pure vanilla JavaScript for DOM
manipulation (no React/framework state). Ensure copy pills trigger accessible manipulation (no React/framework state). Ensure copy pills trigger accessible
notifications (aria-live/toast). Fix double emoji bug in JS. Ensure mobile notifications (aria-live/toast) with explicit `aria-label` tags. Fix double
title wrapping. All changes must pass `ui/ui_scripts.test.ts`. emoji bug in JS. Ensure mobile title wrapping. All changes must pass
`ui/ui_scripts.test.ts`.
--- ---
## Architectural Considerations & Risks ## 2. Architectural Considerations & Risks
- **Risks:** - **Risks:**
- **DOM Manipulation Regression:** Because this UI operates strictly on - **DOM Manipulation Regression:** Because this UI operates strictly on
vanilla JavaScript, changing the layout and grouping DOM elements within new vanilla JavaScript, changing the layout and grouping DOM elements within new
containers (like `#eventCreateState` and `#eventHandoffState`) risks containers (`#eventCreateState` and `#eventHandoffState`) risks breaking
breaking existing DOM ID bindings in `SessionsScript.tsx` if IDs are changed existing DOM ID bindings in `SessionsScript.tsx`. All existing DOM IDs must
or misplaced. We must strictly preserve all existing DOM IDs. be preserved.
- **Test Compatibility:** The client-side scripts are tested via hermetic - **Test Compatibility:** Client scripts are validated via hermetic execution
`new Function()` execution in `ui/ui_scripts.test.ts` (`validateJsSyntax`). in `ui/ui_scripts.test.ts`. Script modifications must remain structurally
The script modifications must remain structurally valid and strictly avoid valid and strictly avoid any external framework dependencies.
any unsupported syntax or external framework dependencies. - **Mobile Layout Breakage:** When fixing long titles to wrap correctly, CSS
- **Mobile Layout Breakage:** When fixing the long titles to wrap correctly, properties `overflow-wrap: break-word` and `word-break: break-word` must be
we must ensure CSS properties like `word-break: break-word` and paired with bounded max-widths to prevent horizontal container stretching.
`overflow-wrap: break-word` don't inadvertently stretch flex containers
horizontally on small screens.
- **Alternatives:** - **Alternatives:**
- The proposed container-based 2-state machine approach for - The container-based 2-state machine approach for `WorkshopDrawer.tsx` is
`WorkshopDrawer.tsx` is native and robust. It's the most appropriate native and robust. Using a class-based toggle was considered, but explicit
solution given our constraints (no client-side frameworks). Using a element display manipulation is clearer for the specific 2-state
class-based toggle could also work, but explicit element display requirement.
manipulation is clearer for the specific 2-state requirement.
## Proposed Implementation ---
### 1. Page Hierarchy Update (`ui/components/SessionsPage.tsx`) ## 3. Proposed Implementation
- Relocate the main header block (containing `<h1>Active Sessions & Passes</h1>` ### Phase 1: Page Hierarchy Update (`ui/components/SessionsPage.tsx`)
and the `[ 🔑 Delegate Session ]` button) to be the uppermost visible element
below the `#status-banner`.
- Move the `EventCockpitDeck` component to render below the new header block.
- Keep the `SessionTable` and `SessionDeck` at the bottom.
### 2. WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`) 1. **Header Block Relocation:** Move the main header block
(`<h1>Active Sessions & Passes</h1>`, subtitle, and `[ 🔑 Delegate Session ]`
button) to be the uppermost visible element below the `#status-banner`.
2. **Section Hierarchy:**
- Section 1: `EventCockpitDeck` (`Event Passes` cards). If 0 event passes
exist, render a clean empty state or let the deck cleanly return null
without an orphaned section heading.
- Section 2: `SessionTable` / `SessionDeck` (`Active Sessions`).
- Wrap the initial creation form (`#eventForm`), including inputs, submit ### Phase 2: WorkshopDrawer 2-State Machine (`ui/components/sessions/WorkshopDrawer.tsx`)
button, and cancel button, into a new container `div` with
`id="eventCreateState"`. Default this container to `display: block`.
- Rename or wrap the `#eventHandoffModal` content into a new container `div`
with `id="eventHandoffState"`. Default this container to `display: none`.
- In `#eventHandoffState`, ensure it contains the 3 cards (PIN + /join, Direct
Link, CLI 1-Liner) and update the final button to be a single "Dismiss"
button.
- Ensure `createdEventTitle` has `overflow-wrap: break-word` and
`word-break: break-word` along with a max-width to allow long titles to wrap
cleanly on mobile screens.
### 3. JavaScript Logic Update (`ui/components/sessions/SessionsScript.tsx`) 1. **State 1 (`#eventCreateState`):** Wrap the initial creation form
(`#eventForm`), inputs, submit button, and cancel button in
`<div id="eventCreateState" style="display: block;">`.
2. **State 2 (`#eventHandoffState`):** Wrap the credential cards in
`<div id="eventHandoffState" style="display: none;">`.
- Render the 3 distinct cards (PIN + /join, Direct Link, CLI 1-Liner).
- Add explicit `aria-label` on copy buttons (`"Copy Universal PIN"`,
`"Copy Direct Link"`, `"Copy Terminal curl command"`).
- Render a single **"Dismiss"** button at the bottom of `#eventHandoffState`.
3. **Drawer Title Transition:** Add `id="eventDrawerHeaderTitle"` to the top
header so JavaScript can transition it from `"New Event Pass"` $\rightarrow$
`"Event Pass Active"`.
4. **Mobile Title Wrapping:** Ensure `createdEventTitle` has
`overflow-wrap: break-word; word-break: break-word;` with safe max-width.
- **State Toggling:** Update `handleCreateEvent` so that upon successful ### Phase 3: JavaScript Logic Update (`ui/components/sessions/SessionsScript.tsx`)
creation, it sets
`document.getElementById('eventCreateState').style.display = 'none'` and 1. **State Toggling:** Update `handleCreateEvent` so that upon successful
creation:
- Sets `document.getElementById('eventCreateState').style.display = 'none'`.
- Sets
`document.getElementById('eventHandoffState').style.display = 'block'`. `document.getElementById('eventHandoffState').style.display = 'block'`.
- **Drawer Reset:** Ensure when the delegate drawer is closed (via - Updates
`closeDelegateDrawer` or the new Dismiss button), the states are reset: `document.getElementById('eventDrawerHeaderTitle').textContent = 'Event Pass Active'`.
`#eventCreateState` to block, `#eventHandoffState` to none. 2. **Drawer Reset:** When the drawer is closed or dismissed, reset state back:
- **Emoji Fix:** Remove the hardcoded `'🎟️ '` prefix from the event title - `#eventCreateState.style.display = 'block'`
- `#eventHandoffState.style.display = 'none'`
- Reset form inputs and restore header to `'New Event Pass'`.
3. **Emoji Fix:** Remove the hardcoded `'🎟️ '` prefix from the event title
injection line: injection line:
`document.getElementById('createdEventTitle').textContent = ev.name + ' Live!';` `document.getElementById('createdEventTitle').textContent = ev.name + ' Live!';`
- **Accessibility / Toast:** Ensure `copyEventHandoff` function already triggers
`showNotice` (which acts as a toast). Verify ARIA roles for the banner if
necessary, or just rely on the existing `showNotice` function which is already
in place.
### 4. Quality Gates ### Phase 4: Quality Gates
- Run Deno tests: `deno test --allow-all` (specifically validating 1. Run `deno fmt` and `deno task lint`.
2. Run Deno tests: `deno test --allow-all` (specifically validating
`ui/ui_scripts.test.ts`). `ui/ui_scripts.test.ts`).
- Run code formatting: `deno fmt`.