From ebb2c74554c3a20b66a2e93f655d51c358c42dbb Mon Sep 17 00:00:00 2001 From: telivity-otaip Date: Wed, 5 Aug 2026 18:37:51 -0500 Subject: [PATCH 1/5] docs(ops): add operator harden pack docs --- ops/harden/.env.harden.example | 28 +++++++++++ ops/harden/CHECKLIST.md | 56 ++++++++++++++++++++++ ops/harden/PRODUCTION.md | 10 ++++ ops/harden/README.md | 85 ++++++++++++++++++++++++++++++++++ ops/harden/SURFACE_SMOKE.md | 61 ++++++++++++++++++++++++ ops/harden/TENANT_ISOLATION.md | 49 ++++++++++++++++++++ 6 files changed, 289 insertions(+) create mode 100644 ops/harden/.env.harden.example create mode 100644 ops/harden/CHECKLIST.md create mode 100644 ops/harden/PRODUCTION.md create mode 100644 ops/harden/README.md create mode 100644 ops/harden/SURFACE_SMOKE.md create mode 100644 ops/harden/TENANT_ISOLATION.md diff --git a/ops/harden/.env.harden.example b/ops/harden/.env.harden.example new file mode 100644 index 0000000..cb029b4 --- /dev/null +++ b/ops/harden/.env.harden.example @@ -0,0 +1,28 @@ +# HAIP operator harden — live probe env +# +# cp ops/harden/.env.harden.example .env.harden +# # edit values, then: +# set -a && source .env.harden && set +a +# pnpm harden:live +# +# Do NOT commit .env.harden — it contains bearer tokens. + +# API base ending in /api (compose default shown) +HAIP_API_BASE=http://localhost:3000/api + +# Keycloak bearer JWTs (property_ids claim must match the property below) +TOKEN_A= +TOKEN_B= + +# Property UUIDs +PROPERTY_A= +PROPERTY_B= + +# Optional: reservation that belongs only to property B +# RESERVATION_IN_B= + +# Optional: JWT missing property_ids or from wrong issuer (expect 401) +# TOKEN_BAD= + +# Optional: override request timeout ms (default 15000) +# HARDEN_TIMEOUT_MS=15000 diff --git a/ops/harden/CHECKLIST.md b/ops/harden/CHECKLIST.md new file mode 100644 index 0000000..6966506 --- /dev/null +++ b/ops/harden/CHECKLIST.md @@ -0,0 +1,56 @@ +# Production go-live checklist + +Use this before exposing HAIP to real guests or chargeable traffic. +Details: [`docs/deployment.md`](../../docs/deployment.md) and +[`.env.production.example`](../../.env.production.example). + +## Compose & boot + +- [ ] Copied `.env.production.example` → `.env.production` and filled secrets +- [ ] Started with prod overlay + auth profile: + ```bash + docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + ``` +- [ ] `AUTH_ENABLED=true` (required in production) +- [ ] `HAIP_ALLOW_INSECURE` is **unset / empty** (never `true` in production) +- [ ] `STRIPE_MODE` is `test` until ready for real charges; then `live` with live keys +- [ ] API boots cleanly; `GET /api/v1/health` returns `status: ok` + +## Auth (Keycloak) + +- [ ] Keycloak runs in production mode with TLS (not `start-dev` exposed publicly) +- [ ] Strong Keycloak admin credentials (changed from defaults) +- [ ] Realm `haip` (or your realm) issues JWTs with `property_ids` and `roles` +- [ ] Unauthenticated `GET /api/v1/reservations?propertyId=` → **401** +- [ ] Dashboard built / served with auth on (`VITE_AUTH_ENABLED=true` in prod overlay) + +## Multi-tenancy + +- [ ] At least two test properties exist +- [ ] User A JWT has only property A; user B only property B +- [ ] `pnpm harden:live` passes tenant-isolation probes (see [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md)) +- [ ] SPA routes always include `propertyId` on detail pages + +## Payments & Connect + +- [ ] Stripe secret + webhook secret match `STRIPE_MODE` +- [ ] Stripe webhook endpoint receives events (signature verified) +- [ ] `CONNECT_API_KEY` set when auth is on (OTAIP / Connect agents) +- [ ] Booking engine keys generated under Settings → Booking Engine (when auth on) + +## Network & ops + +- [ ] TLS terminated at reverse proxy (Caddy/nginx/etc.) +- [ ] `CORS_ORIGINS` set if browser origin ≠ API host +- [ ] Night audit / group cutoff cron configured ([`docs/operations/cron.md`](../../docs/operations/cron.md)) +- [ ] Database backup + restore tested once +- [ ] Rate limiting considered for public origins + +## Product smoke (manual) + +- [ ] [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) completed on property A — zero Critical on check-in, rooms, folios, night audit, housekeeping +- [ ] At least three desk [`vignettes/`](./vignettes/) exercised (recommend `base-01`, `base-07`, `base-15`) + +## Gate + +Do **not** put real hotel traffic on the instance while Critical surface failures or failed tenant-isolation probes remain open. diff --git a/ops/harden/PRODUCTION.md b/ops/harden/PRODUCTION.md new file mode 100644 index 0000000..254bfbc --- /dev/null +++ b/ops/harden/PRODUCTION.md @@ -0,0 +1,10 @@ +# Harden pack production pointer + +Before going live, run the operator harden pack: + +```bash +pnpm harden:local +pnpm harden:live +``` + +See [`README.md`](./README.md) and the root README **Production checklist** section. diff --git a/ops/harden/README.md b/ops/harden/README.md new file mode 100644 index 0000000..aa7ae74 --- /dev/null +++ b/ops/harden/README.md @@ -0,0 +1,85 @@ +# HAIP operator harden pack + +Playbooks and a small CLI so operators can harden a **self-hosted HAIP** +deployment (Keycloak + docker compose) before go-live and against a live URL. + +## What this is + +| Piece | Purpose | +|-------|---------| +| [`CHECKLIST.md`](./CHECKLIST.md) | Production go-live checklist | +| [`TENANT_ISOLATION.md`](./TENANT_ISOLATION.md) | Cross-tenant deny criteria (Keycloak JWTs) | +| [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) | Full SPA surface walk before chargeable use | +| [`vignettes/`](./vignettes/) | Desk ops stories (guest → staff → delight/block) | +| [`cli/`](./cli/) | `harden:local` and `harden:live` HTTP probes | + +## Quick start + +### Pre-go-live (local / compose) + +From the HAIP repo root: + +```bash +# 1. Configure production env +cp .env.production.example .env.production +# Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc. + +# 2. Bring up the prod overlay (auth on) +docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + +# 3. Static + HTTP local checks +pnpm harden:local +``` + +Then walk [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) and a few [`vignettes/`](./vignettes/) against the dashboard. + +### Live instance + +```bash +cp ops/harden/.env.harden.example .env.harden +# Fill HAIP_API_BASE, TOKEN_A, TOKEN_B, PROPERTY_A, PROPERTY_B + +set -a && source .env.harden && set +a +pnpm harden:live +``` + +Exit code `0` = all probes passed; non-zero = at least one fail. See the printed table. + +## Keycloak tokens + +Tokens must be JWTs issued by your Keycloak realm (`haip` by default) for the +API client (`haip-api`). Claims the API expects: + +| Claim | Meaning | +|-------|---------| +| `property_ids` | Array of property UUIDs this user may access | +| `roles` | HAIP roles (e.g. `admin`, `front_desk`, `readonly`) | + +**User A** should have `property_ids=[PROPERTY_A]` only. +**User B** should have `property_ids=[PROPERTY_B]` only. + +How you mint tokens (password grant, client credentials + user impersonation, +or your IdP’s token endpoint) is up to your deployment — the CLI only needs the +bearer strings. + +`HAIP_API_BASE` is the API origin **including** `/api` if your reverse proxy +serves the API under `/api` (compose default: `http://localhost:3000/api`). +Probes call `{HAIP_API_BASE}/v1/...`. + +## Modes + +| Command | Needs running stack? | Needs tokens? | +|---------|----------------------|---------------| +| `pnpm harden:local` | Preferred (for HTTP checks); file checks always run | Optional — if tokens are set, also runs live probes against local base | +| `pnpm harden:live` | Yes (your URL) | **Required** | + +## Manual ops (after CLI green) + +1. Complete [`SURFACE_SMOKE.md`](./SURFACE_SMOKE.md) on property A. +2. Run several vignettes from [`vignettes/`](./vignettes/) (start with `base-01`, `base-07`, `base-15`). +3. Re-run `pnpm harden:live` after any auth or multi-tenancy change. + +## Non-goals + +- Automated full-SPA Playwright campaigns (use surface smoke + vignettes manually in v1) +- Exhaustive penetration testing — this pack is an operator readiness gate, not a red-team suite diff --git a/ops/harden/SURFACE_SMOKE.md b/ops/harden/SURFACE_SMOKE.md new file mode 100644 index 0000000..d473aa5 --- /dev/null +++ b/ops/harden/SURFACE_SMOKE.md @@ -0,0 +1,61 @@ +# Full-surface smoke (self-hosted HAIP) + +Run this on every candidate production (or staging twin) deploy before putting +real guests or chargeable traffic on the instance. Desk vignettes alone are +**not** enough. + +## Environment + +- Dashboard served by your HAIP API (compose: `http://localhost:3000`) +- Sign in via Keycloak with a staff/admin user for **property A** +- Always open routes with `?propertyId=` +- Never put passwords in notes or screenshots you share + +## Rule + +Click the real control. If nothing happens → **BROKEN**. +Pretty empty page with no path to do the job → **EMPTY_SHELL**. +Nav that lies / no feedback after a critical action → **CONFUSING**. + +Classifications: `BROKEN` | `EMPTY_SHELL` | `CONFUSING` | `PARTIAL` | `WORKS` + +## Checklist (engine SPA — no skipping) + +| # | Surface | Route | Must prove | +|---|---------|-------|------------| +| 1 | Dashboard | `/` | Loads for the property; not a dead shell | +| 2 | Check-in / Front desk | `/front-desk` | Confirm check-in completes; walk-in path works; clear errors | +| 3 | Reservations | `/reservations` | List + open a reservation; create/modify path clear | +| 4 | Guests | `/guests` | Profile useful mid-shift (stay context / notes — not name-only) | +| 5 | Rooms status | `/rooms` | Change status sticks + visible feedback | +| 6 | Room types | `/rooms/types` | Types list/edit operable | +| 7 | Housekeeping | `/housekeeping` | Tasks load (or clear actionable empty + generate works) | +| 8 | Folios | `/folios` | See bill; post/settle path clear | +| 9 | Cashier | `/cashier` | Post/settle flow usable when folio has balance | +| 10 | Night audit | `/night-audit` | After run: success state + history; not “run again?” forever | +| 11 | Rate plans | `/rate-plans` | Calculate/edits not decorative | +| 12 | Reports | `/reports` | Primary reports load; errors visible | +| 13 | Groups | `/groups` | Group block list/detail operable | +| 14 | Channels | `/channels` | Connection path or honest “not configured” | +| 15 | Communications | `/communications` | Connected path or honest “not configured” | +| 16 | Reviews | `/reviews` | Sync/manual path clear — not a dead empty | +| 17 | Settings / Users | `/settings` | Property settings + users/roles operable | +| 18 | Booking admin | Settings → Booking Engine | Booking key generate/rotate when auth on | +| 19 | Import | `/import` | Import entry honest (works or clear empty) | + +Also smoke Walk-In party link + Guest Details — but **never instead of** this list. +Pair with [`vignettes/`](./vignettes/) for desk realism. + +## Outputs (suggested) + +Write under a local folder you keep private (e.g. `harden-runs/YYYY-MM-DD-surface-smoke/`): + +1. `AUDIT.json` — `{id, surface, route, classification, notes}` × each row +2. `SUMMARY.md` — counts of WORKS / BROKEN / EMPTY_SHELL / … +3. Screenshots only if useful; scrub guest PII + +## Gate + +**Invalid run:** skipped surfaces, or vignette-only run labeled as full product smoke. +**Valid run:** every row attempted. +**Go-live:** zero Critical on check-in, room status, folios, night-audit feedback, housekeeping. diff --git a/ops/harden/TENANT_ISOLATION.md b/ops/harden/TENANT_ISOLATION.md new file mode 100644 index 0000000..00674a1 --- /dev/null +++ b/ops/harden/TENANT_ISOLATION.md @@ -0,0 +1,49 @@ +# Tenant isolation gate (self-hosted HAIP) + +Run before enabling real hotel tenants. Uses two Keycloak users / properties. + +## Prerequisites + +- HAIP API with `AUTH_ENABLED=true` +- Two Keycloak users whose JWTs include: + - User A: `property_ids=[PROPERTY_A]`, roles include staff or `admin` + - User B: `property_ids=[PROPERTY_B]` +- Environment (see [`.env.harden.example`](./.env.harden.example)): + +| Variable | Meaning | +|----------|---------| +| `HAIP_API_BASE` | API base ending in `/api` (e.g. `http://localhost:3000/api`) | +| `TOKEN_A` / `TOKEN_B` | Bearer JWTs for users A and B | +| `PROPERTY_A` / `PROPERTY_B` | Property UUIDs | +| `RESERVATION_IN_B` | Optional — reservation id that belongs only to B | + +## Automated probe + +```bash +set -a && source .env.harden && set +a +pnpm harden:live +``` + +## Pass criteria + +1. `GET /v1/health` → **200** with `status: ok` (public) +2. `GET /v1/reservations?propertyId=PROPERTY_A` with **no** token → **401** +3. `GET /v1/reservations?propertyId=PROPERTY_B` with `TOKEN_A` → **403** (or **401** if claim missing) +4. `GET /v1/reservations?propertyId=PROPERTY_A` with `TOKEN_A` → **200** (A may read A) +5. If `RESERVATION_IN_B` is set: `GET /v1/reservations/RESERVATION_IN_B?propertyId=PROPERTY_B` with `TOKEN_A` → **403** or **404** +6. Token without usable `property_ids` / wrong issuer → **401** (configure a bad token as `TOKEN_BAD` optional) + +## Multi-property owner invariants (same account) + +An owner JWT may hold `property_ids=[A, B]`. Still required: + +1. Request with `propertyId=A` + entity id that only exists under B → **404** +2. `POST /reservations` with `propertyId=A` and a `guestId` only linked at B → **404** +3. SPA property switch clears cached detail data; detail routes key by `propertyId` + +These owner invariants are checklist items in v1 (exercise manually or with your own scripts). +The CLI covers the two-user cross-tenant deny path above. + +## After changes + +Re-run `pnpm harden:live` after any change to auth guards, property scoping, or JWT claim mapping. From ec83056e9d8f1c96a24dde6dc55da6d558345760 Mon Sep 17 00:00:00 2001 From: telivity-otaip Date: Wed, 5 Aug 2026 18:38:35 -0500 Subject: [PATCH 2/5] docs(ops): add operator harden CLI --- ops/harden/cli/harden.mjs | 102 +++++++++++++++ ops/harden/cli/lib.mjs | 97 ++++++++++++++ ops/harden/cli/package.json | 18 +++ ops/harden/cli/probes/auth-on.mjs | 71 +++++++++++ ops/harden/cli/probes/health.mjs | 27 ++++ ops/harden/cli/probes/local.mjs | 111 ++++++++++++++++ ops/harden/cli/probes/tenant-isolation.mjs | 139 +++++++++++++++++++++ 7 files changed, 565 insertions(+) create mode 100644 ops/harden/cli/harden.mjs create mode 100644 ops/harden/cli/lib.mjs create mode 100644 ops/harden/cli/package.json create mode 100644 ops/harden/cli/probes/auth-on.mjs create mode 100644 ops/harden/cli/probes/health.mjs create mode 100644 ops/harden/cli/probes/local.mjs create mode 100644 ops/harden/cli/probes/tenant-isolation.mjs diff --git a/ops/harden/cli/harden.mjs b/ops/harden/cli/harden.mjs new file mode 100644 index 0000000..b8589f0 --- /dev/null +++ b/ops/harden/cli/harden.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * HAIP operator harden CLI + * + * node ops/harden/cli/harden.mjs local + * node ops/harden/cli/harden.mjs live + * node ops/harden/cli/harden.mjs all + * + * Env: see ops/harden/.env.harden.example + */ + +import { printReport, env } from './lib.mjs'; +import { runLocalFileProbes } from './probes/local.mjs'; +import { runHealthProbes } from './probes/health.mjs'; +import { runAuthOnProbes } from './probes/auth-on.mjs'; +import { runTenantIsolationProbes } from './probes/tenant-isolation.mjs'; + +function usage() { + console.log(`Usage: haip-harden + + local File/compose checklist (+ optional HTTP if HAIP_API_BASE is set) + live Health + auth-on + tenant-isolation against HAIP_API_BASE + all local then live + +Env: copy ops/harden/.env.harden.example → .env.harden and source it. +`); +} + +async function runLive() { + /** @type {import('./lib.mjs').ProbeResult[]} */ + const results = []; + results.push(...(await runHealthProbes())); + results.push(...(await runAuthOnProbes())); + results.push(...(await runTenantIsolationProbes())); + return results; +} + +async function runLocal() { + /** @type {import('./lib.mjs').ProbeResult[]} */ + const results = [...(await runLocalFileProbes())]; + + // If API base is configured, also hit health (and auth/tenant when tokens present) + if (env('HAIP_API_BASE')) { + console.log('HAIP_API_BASE set — running HTTP probes against local/target API…'); + results.push(...(await runHealthProbes())); + if (env('PROPERTY_A')) { + results.push(...(await runAuthOnProbes())); + } + if (env('TOKEN_A') && env('TOKEN_B') && env('PROPERTY_A') && env('PROPERTY_B')) { + results.push(...(await runTenantIsolationProbes())); + } else { + results.push({ + id: 'tenant-live-optional', + ok: true, + skip: true, + detail: 'TOKEN_A/B + PROPERTY_A/B not all set — skipped live tenant probes', + }); + } + } else { + results.push({ + id: 'http-optional', + ok: true, + skip: true, + detail: 'HAIP_API_BASE not set — file checks only; set it to probe a running API', + }); + } + + return results; +} + +async function main() { + const mode = (process.argv[2] ?? '').toLowerCase(); + if (!['local', 'live', 'all'].includes(mode)) { + usage(); + process.exit(2); + } + + console.log(`HAIP harden — mode=${mode}`); + + /** @type {import('./lib.mjs').ProbeResult[]} */ + let results = []; + if (mode === 'local' || mode === 'all') { + console.log('\n== local =='); + results = results.concat(await runLocal()); + } + if (mode === 'live' || mode === 'all') { + console.log('\n== live =='); + if (!env('HAIP_API_BASE')) { + console.error('HAIP_API_BASE is required for live mode'); + process.exit(2); + } + results = results.concat(await runLive()); + } + + const passed = printReport(results); + process.exit(passed ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/ops/harden/cli/lib.mjs b/ops/harden/cli/lib.mjs new file mode 100644 index 0000000..769fdf6 --- /dev/null +++ b/ops/harden/cli/lib.mjs @@ -0,0 +1,97 @@ +/** + * Shared helpers for HAIP operator harden probes. + * Plain Node fetch — no third-party auth SDKs. + */ + +export function env(name, fallback) { + const v = process.env[name]; + if (v === undefined || v === '') return fallback; + return v; +} + +export function requireEnv(name) { + const v = process.env[name]; + if (!v) { + throw new Error(`Missing required env: ${name}`); + } + return v; +} + +/** Normalize to base ending without trailing slash; expect .../api */ +export function apiBase() { + const raw = requireEnv('HAIP_API_BASE').replace(/\/+$/, ''); + return raw; +} + +export function timeoutMs() { + const n = Number(env('HARDEN_TIMEOUT_MS', '15000')); + return Number.isFinite(n) && n > 0 ? n : 15000; +} + +/** + * @param {string} path - path under API base, e.g. `/v1/health` + * @param {{ method?: string, token?: string | null, headers?: Record }} [opts] + */ +export async function request(path, opts = {}) { + const base = apiBase(); + const url = `${base}${path.startsWith('/') ? path : `/${path}`}`; + const headers = { Accept: 'application/json', ...(opts.headers ?? {}) }; + if (opts.token) { + headers.Authorization = `Bearer ${opts.token}`; + } + const res = await fetch(url, { + method: opts.method ?? 'GET', + headers, + signal: AbortSignal.timeout(timeoutMs()), + }); + let bodyText = ''; + try { + bodyText = await res.text(); + } catch { + bodyText = ''; + } + let json = null; + try { + json = bodyText ? JSON.parse(bodyText) : null; + } catch { + json = null; + } + return { url, status: res.status, ok: res.ok, json, bodyText }; +} + +/** + * @typedef {{ id: string, ok: boolean, detail: string, skip?: boolean }} ProbeResult + */ + +/** @param {ProbeResult[]} results */ +export function printReport(results) { + const width = Math.max(...results.map((r) => r.id.length), 8); + console.log(''); + console.log(`${'PROBE'.padEnd(width)} RESULT DETAIL`); + console.log(`${'-'.repeat(width)} ------ ------`); + for (const r of results) { + const label = r.skip ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'; + console.log(`${r.id.padEnd(width)} ${label.padEnd(6)} ${r.detail}`); + } + const failed = results.filter((r) => !r.skip && !r.ok); + const skipped = results.filter((r) => r.skip); + console.log(''); + console.log( + `Summary: ${results.length - failed.length - skipped.length} pass, ${failed.length} fail, ${skipped.length} skip`, + ); + return failed.length === 0; +} + +/** @param {boolean} expected @param {number} status @param {number[]} codes */ +export function statusIn(status, codes) { + return codes.includes(status); +} + +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +export function repoRootFromCli() { + // ops/harden/cli → repo root is ../../.. + const cliDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(cliDir, '../../..'); +} diff --git a/ops/harden/cli/package.json b/ops/harden/cli/package.json new file mode 100644 index 0000000..641e16b --- /dev/null +++ b/ops/harden/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@telivityhaip/harden-cli", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "bin": { + "haip-harden": "./harden.mjs" + }, + "scripts": { + "harden": "node harden.mjs", + "harden:local": "node harden.mjs local", + "harden:live": "node harden.mjs live" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/ops/harden/cli/probes/auth-on.mjs b/ops/harden/cli/probes/auth-on.mjs new file mode 100644 index 0000000..da979dd --- /dev/null +++ b/ops/harden/cli/probes/auth-on.mjs @@ -0,0 +1,71 @@ +import { env, request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runAuthOnProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + const propertyA = env('PROPERTY_A'); + + if (!propertyA) { + results.push({ + id: 'auth-unauthenticated', + ok: false, + skip: true, + detail: 'PROPERTY_A not set — skip unauth probe', + }); + return results; + } + + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + ); + const ok = statusIn(res.status, [401]); + results.push({ + id: 'auth-unauthenticated', + ok, + detail: ok + ? `no token → ${res.status}` + : `expected 401 without token, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'auth-unauthenticated', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const tokenBad = env('TOKEN_BAD'); + if (tokenBad) { + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + { token: tokenBad }, + ); + const ok = statusIn(res.status, [401]); + results.push({ + id: 'auth-bad-token', + ok, + detail: ok + ? `TOKEN_BAD → ${res.status}` + : `expected 401 for TOKEN_BAD, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'auth-bad-token', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } else { + results.push({ + id: 'auth-bad-token', + ok: true, + skip: true, + detail: 'TOKEN_BAD not set', + }); + } + + return results; +} diff --git a/ops/harden/cli/probes/health.mjs b/ops/harden/cli/probes/health.mjs new file mode 100644 index 0000000..2d01034 --- /dev/null +++ b/ops/harden/cli/probes/health.mjs @@ -0,0 +1,27 @@ +import { request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runHealthProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + try { + const res = await request('/v1/health'); + const ok = + statusIn(res.status, [200]) && + (res.json?.status === 'ok' || res.bodyText.includes('"ok"')); + results.push({ + id: 'health', + ok, + detail: ok + ? `${res.status} ${res.url}` + : `expected 200 status=ok, got ${res.status} ${res.bodyText.slice(0, 120)}`, + }); + } catch (err) { + results.push({ + id: 'health', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + return results; +} diff --git a/ops/harden/cli/probes/local.mjs b/ops/harden/cli/probes/local.mjs new file mode 100644 index 0000000..73cc7c4 --- /dev/null +++ b/ops/harden/cli/probes/local.mjs @@ -0,0 +1,111 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { repoRootFromCli } from '../lib.mjs'; + +/** + * Local / pre-go-live file and compose checks. + * @returns {Promise + */ +export async function runLocalFileProbes() { + const root = repoRootFromCli(); + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + + const requiredFiles = [ + 'docker-compose.yml', + 'docker-compose.prod.yml', + 'docker-compose.auth.yml', + '.env.production.example', + 'docs/deployment.md', + 'ops/harden/CHECKLIST.md', + 'ops/harden/TENANT_ISOLATION.md', + 'ops/harden/SURFACE_SMOKE.md', + ]; + + for (const rel of requiredFiles) { + const full = path.join(root, rel); + const ok = fs.existsSync(full); + results.push({ + id: `file:${rel}`, + ok, + detail: ok ? 'present' : `missing at ${full}`, + }); + } + + // .env.production — warn if missing (operators may use other secret injection) + const envProd = path.join(root, '.env.production'); + if (fs.existsSync(envProd)) { + const text = fs.readFileSync(envProd, 'utf8'); + const checks = [ + { id: 'env:AUTH_ENABLED', re: /^\s*AUTH_ENABLED\s*=\s*true\s*$/m }, + { + id: 'env:no-insecure', + re: null, + ok: !/^\s*HAIP_ALLOW_INSECURE\s*=\s*true\s*$/m.test(text), + detailFail: 'HAIP_ALLOW_INSECURE=true must not be set in production', + }, + { + id: 'env:DATABASE_URL', + re: /^\s*DATABASE_URL\s*=\s*.+/m, + }, + { + id: 'env:REDIS_URL', + re: /^\s*REDIS_URL\s*=\s*.+/m, + }, + ]; + for (const c of checks) { + if (c.re) { + const ok = c.re.test(text); + results.push({ + id: c.id, + ok, + detail: ok ? 'ok' : `check failed in .env.production`, + }); + } else { + results.push({ + id: c.id, + ok: c.ok, + detail: c.ok ? 'ok' : c.detailFail, + }); + } + } + } else { + results.push({ + id: 'env:.env.production', + ok: true, + skip: true, + detail: + 'no .env.production yet — copy from .env.production.example before go-live', + }); + } + + // Prod compose must force AUTH_ENABLED + const prodCompose = path.join(root, 'docker-compose.prod.yml'); + if (fs.existsSync(prodCompose)) { + const text = fs.readFileSync(prodCompose, 'utf8'); + const authOn = /AUTH_ENABLED:\s*['"]?true['"]?/.test(text); + results.push({ + id: 'compose:prod-auth', + ok: authOn, + detail: authOn + ? 'docker-compose.prod.yml sets AUTH_ENABLED=true' + : 'docker-compose.prod.yml should set AUTH_ENABLED=true', + }); + } + + // Vignette pack present + const vignetteDir = path.join(root, 'ops/harden/vignettes'); + let vignetteCount = 0; + if (fs.existsSync(vignetteDir)) { + vignetteCount = fs + .readdirSync(vignetteDir) + .filter((f) => f.startsWith('base-') && f.endsWith('.md')).length; + } + results.push({ + id: 'vignettes', + ok: vignetteCount >= 20, + detail: `${vignetteCount} vignette files in ops/harden/vignettes`, + }); + + return results; +} diff --git a/ops/harden/cli/probes/tenant-isolation.mjs b/ops/harden/cli/probes/tenant-isolation.mjs new file mode 100644 index 0000000..2fc923a --- /dev/null +++ b/ops/harden/cli/probes/tenant-isolation.mjs @@ -0,0 +1,139 @@ +import { env, requireEnv, request, statusIn } from '../lib.mjs'; + +/** @returns {Promise} */ +export async function runTenantIsolationProbes() { + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + + let tokenA; + let tokenB; + let propertyA; + let propertyB; + try { + tokenA = requireEnv('TOKEN_A'); + tokenB = requireEnv('TOKEN_B'); + propertyA = requireEnv('PROPERTY_A'); + propertyB = requireEnv('PROPERTY_B'); + } catch (err) { + results.push({ + id: 'tenant-env', + ok: false, + detail: err instanceof Error ? err.message : String(err), + }); + return results; + } + + if (propertyA === propertyB) { + results.push({ + id: 'tenant-env', + ok: false, + detail: 'PROPERTY_A and PROPERTY_B must be different UUIDs', + }); + return results; + } + + results.push({ + id: 'tenant-env', + ok: true, + detail: 'TOKEN_A/B and PROPERTY_A/B present', + }); + + // A may read A + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyA)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [200]); + results.push({ + id: 'tenant-a-reads-a', + ok, + detail: ok + ? `TOKEN_A + PROPERTY_A → ${res.status}` + : `expected 200, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-reads-a', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // A must not read B + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [403, 401]); + results.push({ + id: 'tenant-a-denied-b', + ok, + detail: ok + ? `TOKEN_A + PROPERTY_B → ${res.status}` + : `expected 403 or 401, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-denied-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + // B may read B (sanity) + try { + const res = await request( + `/v1/reservations?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenB }, + ); + const ok = statusIn(res.status, [200]); + results.push({ + id: 'tenant-b-reads-b', + ok, + detail: ok + ? `TOKEN_B + PROPERTY_B → ${res.status}` + : `expected 200, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-b-reads-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const reservationInB = env('RESERVATION_IN_B'); + if (reservationInB) { + try { + const res = await request( + `/v1/reservations/${encodeURIComponent(reservationInB)}?propertyId=${encodeURIComponent(propertyB)}`, + { token: tokenA }, + ); + const ok = statusIn(res.status, [403, 404, 401]); + results.push({ + id: 'tenant-a-denied-res-b', + ok, + detail: ok + ? `TOKEN_A + RESERVATION_IN_B → ${res.status}` + : `expected 403/404/401, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'tenant-a-denied-res-b', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } else { + results.push({ + id: 'tenant-a-denied-res-b', + ok: true, + skip: true, + detail: 'RESERVATION_IN_B not set', + }); + } + + return results; +} From 32e6089a9ef2ef634d37cb9f132f2f0345417454 Mon Sep 17 00:00:00 2001 From: telivity-otaip Date: Wed, 5 Aug 2026 18:39:11 -0500 Subject: [PATCH 3/5] docs(ops): add operator harden vignettes (1-9) --- ops/harden/vignettes/README.md | 30 +++++++++++++++++++ .../vignettes/base-01-multi-room-walk-in.md | 22 ++++++++++++++ .../base-02-accompanying-same-room.md | 22 ++++++++++++++ .../base-03-arrival-unassigned-reg-card.md | 22 ++++++++++++++ .../base-04-inhouse-guest-details.md | 22 ++++++++++++++ ops/harden/vignettes/base-05-room-move-dnm.md | 22 ++++++++++++++ .../base-06-early-checkout-zero-balance.md | 22 ++++++++++++++ .../base-07-checkout-blocked-balance.md | 22 ++++++++++++++ .../base-08-group-multi-res-checkin.md | 22 ++++++++++++++ .../base-09-vip-loyalty-recognition.md | 22 ++++++++++++++ 10 files changed, 228 insertions(+) create mode 100644 ops/harden/vignettes/README.md create mode 100644 ops/harden/vignettes/base-01-multi-room-walk-in.md create mode 100644 ops/harden/vignettes/base-02-accompanying-same-room.md create mode 100644 ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md create mode 100644 ops/harden/vignettes/base-04-inhouse-guest-details.md create mode 100644 ops/harden/vignettes/base-05-room-move-dnm.md create mode 100644 ops/harden/vignettes/base-06-early-checkout-zero-balance.md create mode 100644 ops/harden/vignettes/base-07-checkout-blocked-balance.md create mode 100644 ops/harden/vignettes/base-08-group-multi-res-checkin.md create mode 100644 ops/harden/vignettes/base-09-vip-loyalty-recognition.md diff --git a/ops/harden/vignettes/README.md b/ops/harden/vignettes/README.md new file mode 100644 index 0000000..d8ab2ce --- /dev/null +++ b/ops/harden/vignettes/README.md @@ -0,0 +1,30 @@ +# Desk ops vignettes + +Guest situation → staff role uses HAIP → delight or learn what blocked it. + +Run these **after** [`../SURFACE_SMOKE.md`](../SURFACE_SMOKE.md). They do not replace it. + +| File | Theme | +|------|--------| +| `base-01-multi-room-walk-in.md` | Two rooms, one party (BLOCKER weight) | +| `base-02-accompanying-same-room.md` | Couple walk-in, both names | +| `base-03-arrival-unassigned-reg-card.md` | Arrival, room unassigned | +| `base-04-inhouse-guest-details.md` | Folio / accompanying from stay | +| `base-05-room-move-dnm.md` | Quiet room move / DNM | +| `base-06-early-checkout-zero-balance.md` | Early checkout, $0 | +| `base-07-checkout-blocked-balance.md` | Due out with balance | +| `base-08-group-multi-res-checkin.md` | Group arrival | +| `base-09-vip-loyalty-recognition.md` | VIP recognition | +| `base-10-notes-handover.md` | Shift handover notes | +| `base-11-locale-pt-BR-walk-in.md` | pt-BR desk UI | +| `base-12-near-full-second-type.md` | Second type sold out mid-flow | +| `base-13-hk-stayover-family-waiting.md` | HK dirty, family waiting | +| `base-14-reservations-corporate-modify.md` | Corporate modify / direct bill | +| `base-15-night-audit-due-out-balance.md` | Night audit + due-out balance | +| `base-16-gm-peak-occupancy-flash.md` | GM peak occupancy | +| `base-17-privacy-adjoining-request.md` | Privacy / adjoining | +| `base-18-cashier-post-and-settle.md` | Cashier post + settle | +| `base-19-no-show-morning-release.md` | No-show release | +| `base-20-reservations-walk-in-handoff.md` | Reservations → desk handoff | + +Classify failures with the same language as surface smoke: delight / annoy / block. diff --git a/ops/harden/vignettes/base-01-multi-room-walk-in.md b/ops/harden/vignettes/base-01-multi-room-walk-in.md new file mode 100644 index 0000000..9602b92 --- /dev/null +++ b/ops/harden/vignettes/base-01-multi-room-walk-in.md @@ -0,0 +1,22 @@ +# base-01 — Exhausted family, two rooms, one pass + +## Guest story +Saturday peak. A family of five walks in after a delayed flight — kids melting down in the lobby. They need **two rooms**, same nights, preferably one bill / one party so they aren’t split across the desk twice. + +## Staff job +`front_desk` on `/front-desk`: create both rooms under **one party**, assign, send them upstairs in one conversation. + +## Surfaces +`/front-desk` + +## Delight if +Shared conf# / party visible; both rooms assigned; family leaves the desk once. + +## Annoy if +Party invisible in the list; extra hops to Reservations/Folios to finish. + +## Block if +Second room requires a second independent walk-in; no party link; double-assign a room. + +## Severity if fail +**BLOCKER** diff --git a/ops/harden/vignettes/base-02-accompanying-same-room.md b/ops/harden/vignettes/base-02-accompanying-same-room.md new file mode 100644 index 0000000..ab663ee --- /dev/null +++ b/ops/harden/vignettes/base-02-accompanying-same-room.md @@ -0,0 +1,22 @@ +# base-02 — Couple walk-in, one room, both names + +## Guest story +A couple walks in late with no booking. They want **one room** and both names on the stay so either can request a key or ask about the folio later. + +## Staff job +`front_desk` on `/front-desk`: one walk-in, one room, accompanying / second guest recorded without inventing a second reservation. + +## Surfaces +`/front-desk` + +## Delight if +Both names visible later from Guest Details / in-house without a scavenger hunt. + +## Annoy if +Only one name sticks; second guest buried or requires a note hack. + +## Block if +Product forces a second reservation for the accompanying guest. + +## Severity if fail +**BLOCKER** if accompanying impossible; **FRICTION** if obscure but in-flow. diff --git a/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md new file mode 100644 index 0000000..85c1205 --- /dev/null +++ b/ops/harden/vignettes/base-03-arrival-unassigned-reg-card.md @@ -0,0 +1,22 @@ +# base-03 — Arrival waiting, room still unassigned + +## Guest story +OTA guest arrives on time. Reservation exists but **no room assigned**. They’re standing at the desk with ID ready; next guest is already in line. + +## Staff job +`front_desk` on `/front-desk`: assign a clean room and complete check-in / reg in one Arrivals pass. + +## Surfaces +`/front-desk` + +## Delight if +Assigned + checked in without leaving Arrivals; guest gets a room number quickly. + +## Annoy if +Must open Reservations or Rooms to finish assign. + +## Block if +Cannot assign from desk flow; check-in succeeds with no room and no hard warning. + +## Severity if fail +**BLOCKER** if check-in cannot complete; **FRICTION** if assign is buried. diff --git a/ops/harden/vignettes/base-04-inhouse-guest-details.md b/ops/harden/vignettes/base-04-inhouse-guest-details.md new file mode 100644 index 0000000..35a9862 --- /dev/null +++ b/ops/harden/vignettes/base-04-inhouse-guest-details.md @@ -0,0 +1,22 @@ +# base-04 — In-house guest asks for folio / PIN / who’s on the room + +## Guest story +An in-house guest calls the desk: “What’s my folio balance, and can my spouse get a key?” Staff needs Guest Details, folio entry, and accompanying names **from the stay**. + +## Staff job +`front_desk` on `/front-desk` (folio link OK as secondary): open stay context, answer balance/PIN/accompanying without losing the guest on hold. + +## Surfaces +`/front-desk` (optional secondary `/folios`) + +## Delight if +Guest Details + folio path from In-House in a few clicks. + +## Annoy if +Must search Guests/Reservations by name with no stay link. + +## Block if +In-house stay has no usable guest/folio path. + +## Severity if fail +**FRICTION** slow path; **BLOCKER** if unreachable. diff --git a/ops/harden/vignettes/base-05-room-move-dnm.md b/ops/harden/vignettes/base-05-room-move-dnm.md new file mode 100644 index 0000000..dd57ea8 --- /dev/null +++ b/ops/harden/vignettes/base-05-room-move-dnm.md @@ -0,0 +1,22 @@ +# base-05 — Guest wants a quieter room (move / DNM) + +## Guest story +Stayover guest complains about noise and asks to move. Housekeeping or desk may have marked do-not-move; guest is at the desk now, not “later via email.” + +## Staff job +`front_desk` on `/front-desk`: move to another vacant room; if DNM, explicit override/ack path — not a silent ignore. + +## Surfaces +`/front-desk` + +## Delight if +Move completes on Front Desk; new room shows on the stay immediately. + +## Annoy if +Must bounce to Rooms/Reservations to finish the move. + +## Block if +No in-house room move at all. + +## Severity if fail +**BLOCKER** if move impossible; **FRICTION** if path is buried. diff --git a/ops/harden/vignettes/base-06-early-checkout-zero-balance.md b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md new file mode 100644 index 0000000..7ee4a00 --- /dev/null +++ b/ops/harden/vignettes/base-06-early-checkout-zero-balance.md @@ -0,0 +1,22 @@ +# base-06 — Early flight, zero balance, just leave + +## Guest story +Guest needs to leave at 6am for a flight. Folio is **$0**. They want a clean checkout and to go — no lecture, no scavenger hunt. + +## Staff job +`front_desk` or `cashier` on `/front-desk` (or folio if required): checkout with zero balance; room released; guest gone. + +## Surfaces +`/front-desk` (optional `/folios`) + +## Delight if +One clear checkout; stay leaves in-house; guest out in under a minute of desk time. + +## Annoy if +Extra unrelated confirms when balance is already zero. + +## Block if +Zero-balance checkout fails with no actionable reason. + +## Severity if fail +**BLOCKER** diff --git a/ops/harden/vignettes/base-07-checkout-blocked-balance.md b/ops/harden/vignettes/base-07-checkout-blocked-balance.md new file mode 100644 index 0000000..4bfc340 --- /dev/null +++ b/ops/harden/vignettes/base-07-checkout-blocked-balance.md @@ -0,0 +1,22 @@ +# base-07 — Due out but still owes money + +## Guest story +Guest tries to leave with an open minibar / unpaid charge. They’ll get angry if the desk “checks them out anyway” or if nobody can show the balance and take payment. + +## Staff job +`front_desk` / `cashier`: attempt checkout; **block or force settlement**; show amount and next action. + +## Surfaces +`/front-desk`, `/folios` + +## Delight if +Clear balance + pay/settle path; no silent bypass. + +## Annoy if +Error is opaque (“failed”) with no folio jump. + +## Block if +Checkout succeeds while balance remains outstanding with no warning. + +## Severity if fail +**BLOCKER** on silent bypass; **FRICTION** if blocked but settle path is obscure. diff --git a/ops/harden/vignettes/base-08-group-multi-res-checkin.md b/ops/harden/vignettes/base-08-group-multi-res-checkin.md new file mode 100644 index 0000000..09333d3 --- /dev/null +++ b/ops/harden/vignettes/base-08-group-multi-res-checkin.md @@ -0,0 +1,22 @@ +# base-08 — Small group arrives together + +## Guest story +A wedding party / small group block arrives at once — several rooms, same dates, one organizer at the desk with a list. Lobby is backing up. + +## Staff job +`front_desk` (or reservations → desk): identify the set and check in **multiple rooms in one session** without treating each as a random solo. + +## Surfaces +`/front-desk` + +## Delight if +Batch or guided multi-select check-in; party/group still linked after. + +## Annoy if +Each room is a full separate check-in with no shared context. + +## Block if +Cannot recover the set; forced into unrelated solo check-ins only. + +## Severity if fail +**BLOCKER** if multi-res party cannot check in as a set; **FRICTION** if slow but possible. diff --git a/ops/harden/vignettes/base-09-vip-loyalty-recognition.md b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md new file mode 100644 index 0000000..4d5992d --- /dev/null +++ b/ops/harden/vignettes/base-09-vip-loyalty-recognition.md @@ -0,0 +1,22 @@ +# base-09 — Loyalty guest expects to be recognized + +## Guest story +A repeat / VIP guest arrives. They expect the desk to see status without them “proving” who they are for three minutes. + +## Staff job +`front_desk` on `/front-desk` or arrivals: spot VIP/loyalty (if product supports it) and open the profile without losing the arrival. + +## Surfaces +`/front-desk`, `/guests` + +## Delight if +Status visible at arrivals / match; desk greets accordingly. + +## Annoy if +Status exists but never shown at desk surfaces. + +## Block if +Wrong guest’s VIP data shown (trust break). + +## Severity if fail +**FRICTION** missing/hard to see; **BLOCKER** on cross-guest bleed; **NOTE** if feature absent from shipped UI (`NEEDS_DOMAIN` OK). From ce8cae7ceda64d63c7d2acbec6fae6d775d576f2 Mon Sep 17 00:00:00 2001 From: telivity-otaip Date: Wed, 5 Aug 2026 18:39:35 -0500 Subject: [PATCH 4/5] docs(ops): add operator harden vignettes (10-20) --- .../vignettes/base-10-notes-handover.md | 22 +++++++++++++++++++ .../vignettes/base-11-locale-pt-BR-walk-in.md | 22 +++++++++++++++++++ .../base-12-near-full-second-type.md | 22 +++++++++++++++++++ .../base-13-hk-stayover-family-waiting.md | 22 +++++++++++++++++++ .../base-14-reservations-corporate-modify.md | 22 +++++++++++++++++++ .../base-15-night-audit-due-out-balance.md | 22 +++++++++++++++++++ .../base-16-gm-peak-occupancy-flash.md | 22 +++++++++++++++++++ .../base-17-privacy-adjoining-request.md | 22 +++++++++++++++++++ .../base-18-cashier-post-and-settle.md | 22 +++++++++++++++++++ .../base-19-no-show-morning-release.md | 22 +++++++++++++++++++ .../base-20-reservations-walk-in-handoff.md | 22 +++++++++++++++++++ 11 files changed, 242 insertions(+) create mode 100644 ops/harden/vignettes/base-10-notes-handover.md create mode 100644 ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md create mode 100644 ops/harden/vignettes/base-12-near-full-second-type.md create mode 100644 ops/harden/vignettes/base-13-hk-stayover-family-waiting.md create mode 100644 ops/harden/vignettes/base-14-reservations-corporate-modify.md create mode 100644 ops/harden/vignettes/base-15-night-audit-due-out-balance.md create mode 100644 ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md create mode 100644 ops/harden/vignettes/base-17-privacy-adjoining-request.md create mode 100644 ops/harden/vignettes/base-18-cashier-post-and-settle.md create mode 100644 ops/harden/vignettes/base-19-no-show-morning-release.md create mode 100644 ops/harden/vignettes/base-20-reservations-walk-in-handoff.md diff --git a/ops/harden/vignettes/base-10-notes-handover.md b/ops/harden/vignettes/base-10-notes-handover.md new file mode 100644 index 0000000..9d1c138 --- /dev/null +++ b/ops/harden/vignettes/base-10-notes-handover.md @@ -0,0 +1,22 @@ +# base-10 — Mid-shift handover note + +## Guest story +Day shift learns “extra towels + late arrival after 11.” Night shift must see it when the guest shows up — not in someone’s head or Discord. + +## Staff job +`front_desk`: leave a note on the reservation/guest; next agent finds it from Front Desk without archaeology. + +## Surfaces +`/front-desk` + +## Delight if +Note survives navigation and is obvious on reopen. + +## Annoy if +Notes exist but are hard to find. + +## Block if +Note appears saved then disappears. + +## Severity if fail +**BLOCKER** on data loss; **FRICTION** if hard to find. diff --git a/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md new file mode 100644 index 0000000..5a9d813 --- /dev/null +++ b/ops/harden/vignettes/base-11-locale-pt-BR-walk-in.md @@ -0,0 +1,22 @@ +# base-11 — Brazilian guest, desk in pt-BR + +## Guest story +Property serves Brazilian guests; desk runs UI in `pt-BR`. Guest asks for a 2-night walk-in — copy must not look broken or untrustworthy. + +## Staff job +`front_desk`: complete walk-in with locale `pt-BR` if switcher exists; nights pluralize cleanly; flow still completable. + +## Surfaces +`/front-desk` (locale control wherever shipped) + +## Delight if +Clear pt-BR nights copy; walk-in finishes. + +## Annoy if +Nested English plural junk like `(2 NIGHT(S))` in pt-BR strings. + +## Block if +Locale switch prevents completing the walk-in. + +## Severity if fail +**FRICTION** for bad copy; **BLOCKER** only if task blocked; **ENV_BLOCKED** if locale unavailable. diff --git a/ops/harden/vignettes/base-12-near-full-second-type.md b/ops/harden/vignettes/base-12-near-full-second-type.md new file mode 100644 index 0000000..584bd59 --- /dev/null +++ b/ops/harden/vignettes/base-12-near-full-second-type.md @@ -0,0 +1,22 @@ +# base-12 — Peak night, second room type sold out mid-flow + +## Guest story +Family needs two rooms at arrival peak. First type is fine; **second type shows unavailable** mid-flow. They’re already emotionally invested — don’t leave a half-booking mess. + +## Staff job +`front_desk` on `/front-desk`: recover — alternate type, reduce rooms, or abort cleanly; keep first selection coherent. + +## Surfaces +`/front-desk` + +## Delight if +Clear unavailable message + clean recovery without orphan stays. + +## Annoy if +Confusing error but recoverable. + +## Block if +Silent fail, corrupt partial booking, or forced unlinked second walk-in. + +## Severity if fail +**BLOCKER** on corrupt/duplicate-walk-in; **FRICTION** if recoverable but messy. diff --git a/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md new file mode 100644 index 0000000..fce27f6 --- /dev/null +++ b/ops/harden/vignettes/base-13-hk-stayover-family-waiting.md @@ -0,0 +1,22 @@ +# base-13 — Family waiting; stayover room still dirty + +## Guest story +Arriving family is in the lobby. Their assigned room is still a **stayover dirty**. Kids are restless; GM is glancing at the desk. Someone must free a clean room without making the family wait through a folio tour. + +## Staff job +`housekeeping_supervisor` on `/housekeeping` (+ `/rooms` if needed): see the blocked arrival, push clean/inspect or reassign path so Front Desk can put them up — **without** wandering into Folios/Billing. + +## Surfaces +`/housekeeping`, `/rooms` + +## Delight if +Clear dirty/stayover signal + action; family gets a room without ops chaos. + +## Annoy if +Must leave HK into unrelated modules to understand the block. + +## Block if +HK dashboard broken (e.g. 500) so supervisor cannot act. + +## Severity if fail +**BLOCKER** if HK cannot run the board; **FRICTION** if path is slow/confusing. diff --git a/ops/harden/vignettes/base-14-reservations-corporate-modify.md b/ops/harden/vignettes/base-14-reservations-corporate-modify.md new file mode 100644 index 0000000..b3debbc --- /dev/null +++ b/ops/harden/vignettes/base-14-reservations-corporate-modify.md @@ -0,0 +1,22 @@ +# base-14 — Corporate guest extends stay, direct bill + +## Guest story +A corporate traveler on **direct bill** calls/emails: extend two nights. Company will pay; they don’t want a panic “we lost your rate” conversation. + +## Staff job +`reservations` on `/reservations` (rate glance OK): find the stay, extend dates, keep corporate/direct-bill context without rebuilding the booking from scratch. + +## Surfaces +`/reservations` + +## Delight if +Modify dates in one coherent flow; confirmation still makes sense. + +## Annoy if +Must cancel + recreate; direct bill context dropped. + +## Block if +No modify path and no honest failure — silent corruption. + +## Severity if fail +**FRICTION** if workaround exists; **BLOCKER** if stay cannot be extended safely; `NEEDS_DOMAIN` if product has no modify at all. diff --git a/ops/harden/vignettes/base-15-night-audit-due-out-balance.md b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md new file mode 100644 index 0000000..6c5d70f --- /dev/null +++ b/ops/harden/vignettes/base-15-night-audit-due-out-balance.md @@ -0,0 +1,22 @@ +# base-15 — Night audit blocked by due-out with balance + +## Guest story +It’s roll time. One due-out still has an open balance. Night auditor must not blindly close the day and invent a ghost checkout — and must not spend 40 minutes hunting the folio. + +## Staff job +`night_auditor` on `/night-audit` + `/folios` / Front Desk departures: see the due-out with balance, resolve or consciously defer before audit. + +## Surfaces +`/night-audit`, `/folios`, `/front-desk` + +## Delight if +Audit surface flags the problem; clear next action. + +## Annoy if +Audit runs “fine” while due-out balance is invisible. + +## Block if +Occupancy/audit tools 500 so NA cannot close the day. + +## Severity if fail +**BLOCKER** if day cannot close safely; **FRICTION** if visible but painful. diff --git a/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md new file mode 100644 index 0000000..a8b7be2 --- /dev/null +++ b/ops/harden/vignettes/base-16-gm-peak-occupancy-flash.md @@ -0,0 +1,22 @@ +# base-16 — GM needs a peak occupancy flash + +## Guest story +Saturday peak. Front desk is slammed. GM walks in: “Are we full? Any walk-in left?” They need a trustworthy occupancy flash — not a broken report and not a 20-click export. + +## Staff job +`gm` on `/` and `/reports`: read occupancy / today’s flash quickly while FD works. + +## Surfaces +`/`, `/reports` + +## Delight if +Dashboard or occupancy report answers in one glance. + +## Annoy if +Numbers disagree across screens with no explanation. + +## Block if +Occupancy report 500 / unusable at peak. + +## Severity if fail +**BLOCKER** if GM cannot get occupancy; **FRICTION** if slow/inconsistent. diff --git a/ops/harden/vignettes/base-17-privacy-adjoining-request.md b/ops/harden/vignettes/base-17-privacy-adjoining-request.md new file mode 100644 index 0000000..911fe6c --- /dev/null +++ b/ops/harden/vignettes/base-17-privacy-adjoining-request.md @@ -0,0 +1,22 @@ +# base-17 — Two guests, privacy / adjoining ask + +## Guest story +Two adults check in with separate names. They ask for **adjoining or privacy-sensitive** placement (e.g. not announcing names loudly; prefer rooms near each other). Desk must service the ask without inventing a loyalty program. + +## Staff job +`front_desk` on `/front-desk`: capture the request (notes / room choice), assign appropriately, keep both guests serviceable. + +## Surfaces +`/front-desk` + +## Delight if +Request noted and rooms chosen in one pass; no lobby theater. + +## Annoy if +No place for the request; staff uses sticky notes IRL. + +## Block if +Cannot assign two related stays without unlinked chaos (ties to multi-room party). + +## Severity if fail +**FRICTION** if notes missing but assign works; **BLOCKER** if party/assign breaks; `NEEDS_DOMAIN` only if asking for a product concept that truly isn’t shipped. diff --git a/ops/harden/vignettes/base-18-cashier-post-and-settle.md b/ops/harden/vignettes/base-18-cashier-post-and-settle.md new file mode 100644 index 0000000..59dc9f8 --- /dev/null +++ b/ops/harden/vignettes/base-18-cashier-post-and-settle.md @@ -0,0 +1,22 @@ +# base-18 — Restaurant charge then settle at desk + +## Guest story +In-house guest signed a dinner charge to the room. At checkout they want to **see the charge and pay**. Cashier/desk must post/settle without a treasure hunt. + +## Staff job +`cashier` / `front_desk` on `/folios` (+ Front Desk): find folio, confirm charge, take payment / settle, then checkout path. + +## Surfaces +`/folios`, `/front-desk` + +## Delight if +Charge visible; settle → checkout coherent. + +## Annoy if +Charge exists in ops lore but not on the folio UI. + +## Block if +Cannot post/settle; checkout silently ignores balance (ties to base-07). + +## Severity if fail +**BLOCKER** on financial integrity; **FRICTION** if path is slow. diff --git a/ops/harden/vignettes/base-19-no-show-morning-release.md b/ops/harden/vignettes/base-19-no-show-morning-release.md new file mode 100644 index 0000000..2f4e0ab --- /dev/null +++ b/ops/harden/vignettes/base-19-no-show-morning-release.md @@ -0,0 +1,22 @@ +# base-19 — Morning no-show, free the room + +## Guest story +It’s morning. A guaranteed/no-show reservation didn’t arrive. House needs the room for a walk-in; desk/NA must release inventory without ghosting revenue policy blindly. + +## Staff job +`front_desk` or `night_auditor`: mark/handle no-show and free the room for sale; don’t leave a phantom arrival blocking Walk-In. + +## Surfaces +`/front-desk`, `/reservations` + +## Delight if +Clear no-show / release action; room available for next walk-in. + +## Annoy if +Status confusing; room stuck “arriving” all day. + +## Block if +Cannot free inventory; walk-ins blocked by ghosts. + +## Severity if fail +**FRICTION** if awkward; **BLOCKER** if inventory stuck. diff --git a/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md new file mode 100644 index 0000000..f31e744 --- /dev/null +++ b/ops/harden/vignettes/base-20-reservations-walk-in-handoff.md @@ -0,0 +1,22 @@ +# base-20 — Reservations books walk-in that FD must finish + +## Guest story +Phone rings at reservations during peak: walk-in-ish same-day stay. Reservations starts the booking; guest will be at the desk in 20 minutes for keys. + +## Staff job +`reservations` creates/holds the stay on `/reservations`; `front_desk` completes assign/check-in on `/front-desk` without re-entering the whole guest as a new walk-in. + +## Surfaces +`/reservations`, `/front-desk` + +## Delight if +Handoff is one stay; FD finishes assign/check-in fast. + +## Annoy if +FD cannot find the booking; duplicates created. + +## Block if +Forced second independent create at the desk. + +## Severity if fail +**BLOCKER** if duplicate create required; **FRICTION** if findable but slow. From 8bee7038e9be88b60e0fa0e62947d4090b1f05da Mon Sep 17 00:00:00 2001 From: telivity-otaip Date: Wed, 5 Aug 2026 18:39:47 -0500 Subject: [PATCH 5/5] docs(ops): wire harden scripts in package.json --- .gitignore | 1 + package.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 1fd50e2..8297260 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist/ .env .env.local .env.production +.env.harden .env.*.local # IDE diff --git a/package.json b/package.json index 4c8af16..472f444 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "db:studio": "pnpm --filter @telivityhaip/database run db:studio", "docker:up": "docker compose up -d", "docker:down": "docker compose down", + "harden:local": "node ops/harden/cli/harden.mjs local", + "harden:live": "node ops/harden/cli/harden.mjs live", + "harden": "node ops/harden/cli/harden.mjs all", "clean": "pnpm -r run clean && rm -rf node_modules", "integrations:demo": "bash integrations/demos/run.sh" },