Compare commits

...

5 Commits

Author SHA1 Message Date
64d07d9add removed git runner 2026-09-30 19:59:28 +01:00
4dcf85d205 commit check 2026-09-30 16:08:20 +01:00
wolf-demon
c41b937acb Gate B release candidate
Some checks failed
Build and verify web migration / verify (push) Has been cancelled
2026-09-30 15:17:34 +01:00
wolf-demon
f11a21aae8 milestone 9 completed 2026-09-30 11:39:51 +01:00
wolf-demon
0c1f39d891 milestone 18 complete 2026-09-30 10:24:27 +01:00
63 changed files with 2680 additions and 240 deletions

View File

@ -11,7 +11,7 @@ AI_MODEL=
# Optional private host-side JSON file binding internal hotel IDs to OHIP credentials.
# Default example has no connections. Never commit the real configuration.
PMS_CONFIG_FILE_HOST=./deploy/pms.example.json
# CI produces image archives. Set these to the loaded, reviewed commit tags.
# Ansible builds the reviewed source package. Set these to its full-commit image tags.
GUESTOPS_API_IMAGE=guestops-api:local
GUESTOPS_WORKER_IMAGE=guestops-worker:local
@ -20,3 +20,14 @@ PAYMENTS_CONFIG_FILE_HOST=./deploy/payments.example.json
# Enable only after Google delivery and FAQ test-mode acceptance.
AUTO_REPLY_ENABLE_LIVE=false
# Keep enforcement off until every user has enrolled and the security review passes.
IDENTITY_REQUIRE_MFA=false
# Authenticated STARTTLS SMTP. Keep credentials private and enable only after synthetic delivery acceptance.
SMTP_ENABLED=false
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=
SMTP_FROM_NAME=GuestOps

View File

@ -1,98 +0,0 @@
name: Build and verify web migration
on:
push:
branches: [main, 'codex/**']
tags: ['[0-9]+.[0-9]+.[0-9]+']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8.0
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ping:1}).ok'"
--health-interval 10s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm, cache-dependency-path: web/package-lock.json }
- name: Build services
run: dotnet build src/GuestOps.Worker/GuestOps.Worker.csproj -c Release
- name: Verify backup validation and failure recovery
run: python3 -m unittest discover -s tests -p 'test_*.py'
- name: Build interface
working-directory: web
run: npm ci && npm run build
- name: Start isolated preview API
run: |
ASPNETCORE_ENVIRONMENT=Development Preview=true dotnet src/GuestOps.Api/bin/Release/net10.0/GuestOps.Api.dll --urls http://127.0.0.1:5180 > /tmp/guestops-api.log 2>&1 &
for i in $(seq 1 30); do curl -fsS http://127.0.0.1:5180/health && exit 0; sleep 1; done
cat /tmp/guestops-api.log
exit 1
- name: Verify MongoDB and HTTP boundaries
env:
MONGO_TEST_URI: mongodb://127.0.0.1:27017
TEST_API_URL: http://127.0.0.1:5180
run: dotnet run --project tests/GuestOps.Tests/GuestOps.Tests.csproj -c Release
- name: Build Linux images
run: |
docker build --target api -t guestops-api:${{ github.sha }} .
docker build --target worker -t guestops-worker:${{ github.sha }} .
- name: Package reviewed images
if: github.event_name != 'pull_request'
run: |
docker save guestops-api:${{ github.sha }} guestops-worker:${{ github.sha }} | gzip -n > guestops-images.tar.gz
python3 deploy/release_record.py \
--artifact guestops-images.tar.gz \
--commit '${{ github.sha }}' \
--api-image 'guestops-api:${{ github.sha }}' \
--api-id "$(docker image inspect --format '{{.Id}}' 'guestops-api:${{ github.sha }}')" \
--worker-image 'guestops-worker:${{ github.sha }}' \
--worker-id "$(docker image inspect --format '{{.Id}}' 'guestops-worker:${{ github.sha }}')" \
--output release-record.json
sha256sum --check <(python3 -c "import json; r=json.load(open('release-record.json')); print(r['artifact']['sha256'] + ' ' + r['artifact']['name'])")
- name: Smoke test production containers and restart persistence
env:
GUESTOPS_API_IMAGE: guestops-api:${{ github.sha }}
GUESTOPS_WORKER_IMAGE: guestops-worker:${{ github.sha }}
BOOTSTRAP_EMAIL: ci-owner@example.invalid
BOOTSTRAP_HOTEL: CI test hotel
run: |
export MONGO_ROOT_PASSWORD=$(openssl rand -hex 32)
export MONGO_APP_PASSWORD=$(openssl rand -hex 32)
export BOOTSTRAP_PASSWORD=$(openssl rand -hex 24)
trap 'docker compose down --volumes' EXIT
docker compose config --quiet
docker compose up -d --no-build
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
docker compose run --rm --no-deps -e BOOTSTRAP_EMAIL -e BOOTSTRAP_HOTEL -e BOOTSTRAP_PASSWORD api --bootstrap
python3 tests/production_smoke.py
docker compose restart api worker
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health
python3 tests/production_smoke.py --read
install -m 600 /dev/null .env
python3 deploy/ops.py preflight --offline
mkdir -m 700 .guestops-test-keyring
export GNUPGHOME="$PWD/.guestops-test-keyring"
gpg --batch --pinentry-mode loopback --passphrase '' --quick-generate-key 'GuestOps CI <ci@example.invalid>' rsa2048 encr 1d
BACKUP_RECIPIENT=$(gpg --batch --with-colons --list-keys | awk -F: '$1=="fpr" {print $10; exit}')
backup_dir=$(mktemp -d)
python3 deploy/ops.py backup --recipient "$BACKUP_RECIPIENT" --output "$backup_dir/fixture.tar.gpg" --confirm-maintenance
python3 deploy/ops.py restore-drill "$backup_dir/fixture.tar.gpg" --api-image "$GUESTOPS_API_IMAGE"
curl --retry 30 --retry-delay 2 --retry-all-errors --fail http://127.0.0.1:8080/health/ready
python3 tests/production_smoke.py --read
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: guestops-linux-${{ github.run_number }}
path: |
guestops-images.tar.gz
release-record.json
retention-days: 90

View File

@ -1,10 +1,36 @@
# GuestOps Milestone Report
Version: **0.2.0 release candidate**
Last updated: **29 September 2026**
Last updated: **30 September 2026**
This is the working delivery tracker for GuestOps Web. Update a milestone when its state changes and link the pull request, release artifact, test run, or acceptance record that proves the change.
## Milestones at a glance
This summary explains what each milestone delivers and where it currently stands. The release-gate and delivery tables below contain the detailed evidence and exit conditions.
| # | Milestone | What it delivers | Current position |
| ---: | --- | --- | --- |
| 1 | Web foundation | The React application, ASP.NET Core API, tenant-isolated MongoDB storage, preview mode, and container foundation. | **Implemented.** The application foundation and automated tests are on `main`. |
| 2 | AI suggestions and reviewed Gmail sending | AI-assisted reply drafts and staff-reviewed Gmail delivery with safety and duplicate-send controls. | **Implemented; acceptance required.** Real Gmail threading, revocation, uncertain-send, and staff-review scenarios still need live evidence. |
| 3 | OHIP PMS workflow | Internal proposal, approval, and execution controls for PMS operations. | **Implemented; acceptance required.** The workflow exists, but provider-contract and sandbox acceptance remain outstanding. |
| 4 | NMI payment workflow | Internal payment proposal, approval, status, expiry, and reconciliation controls. | **Implemented; acceptance required.** The workflow exists, but real payment-provider sandbox acceptance remains outstanding. |
| 5 | FAQ automation | Knowledge-based FAQ drafting, test mode, approval controls, and guarded live automation. | **Implemented; acceptance required.** Live mode remains disabled pending quality, false-positive, monitoring, and rollback acceptance. |
| 6 | Team onboarding and account recovery | Staff invitations, password setup/reset, access disable/restore, and administrator recovery. | **Implemented; acceptance required.** Deployed link delivery, expiry, recovery, and administrator procedures still need operational evidence. |
| 7 | Google connection recovery | OAuth reconnect, checkpoint recovery, grant revocation handling, and worker restart safety. | **Implemented; acceptance required.** Dedicated Google-account and worker-restart exercises have not yet been accepted. |
| 8 | Operational readiness tooling | Release verification, diagnostics, encrypted backup, restore, preflight, and persistence tools. | **Implemented; acceptance required.** The tools must still be run against the exact release on the Debian host. |
| 9 | Gitea and reproducible releases | A checksummed source package, Ansible-controlled installation, recorded image identities, retained artifacts, and approval tagging. | **In progress.** The Gitea Action has been removed to match the CMS/CMSFront deployment model; the exact-commit package, Ansible playbook evidence, and post-Gate-B tag are still required. |
| 10 | Debian deployment and persistence | Secure Debian/Compose deployment, HTTPS, persistent database and key volumes, and reboot/recreation proof. | **In progress.** Acceptance tooling is ready, but Docker, correct HTTPS/network exposure, exact artifacts, privileged installation, and the supervised drills remain open. |
| 11 | Backups, monitoring, and recovery | Scheduled encrypted backups, verified off-host transfer, Zabbix monitoring, restore, and rollback rehearsal. | **In progress.** Repository tooling is ready; installation and timed operational evidence are blocked until Milestones 9 and 10 pass. |
| 12 | Google mailbox acceptance | End-to-end Gmail consent, import, recovery, reviewed sending, reconciliation, and revocation evidence. | **In progress.** The runbook and validator exist; the live synthetic-data exercise and independent review remain outstanding. |
| 13 | Rezlynx/Guestline adapter | The real PMS provider adapter, mappings, idempotency, reconciliation, and ambiguous-write handling. | **Planned.** Provider contract and sandbox access are still required before implementation and acceptance. |
| 14 | Payment links and status | The real payment-provider integration, webhooks, expiry, replay protection, and reconciliation. | **Planned.** The provider path and sandbox acceptance plan still need to be confirmed and completed. |
| 15 | Knowledge, AI, and FAQ activation | Supervised knowledge-quality, AI-draft, FAQ test-mode, staff-training, and stop-control acceptance. | **Implemented; acceptance required.** The evaluation tooling exists; the supervised evaluation and independent approval remain outstanding. |
| 16 | Identity, preferences, and privacy | Account/session controls, hotel preferences, privacy inventory, retention decisions, and audit review. | **Implemented; acceptance required.** Legal and operational decisions, identity checks, and independent review remain outstanding. |
| 17 | Inbox usability and desktop parity | Stable pagination, protected unsaved drafts, hotel-timezone display, and desktop workflow parity. | **Implemented; acceptance required.** Automated checks pass; the supervised desktop exercise and independent approval remain outstanding. |
| 18 | Pilot, capacity, and release approval | Capacity proof, incident exercise, five-business-day hotel pilot, findings closure, and Gate B approval. | **In progress.** Validators and targets exist; Gate A/B prerequisites, capacity evidence, incident rehearsal, pilot, and named approvals remain open. |
| 19 | Account security and self-service | TOTP MFA, recovery codes, transactional email, granular roles, preferences, and security notifications. | **Implemented on the development branch; acceptance required.** Keep it separate until `0.2.0` is approved and tagged, then review, merge, and version it as `0.3.0`. |
## Status key
- **Implemented** — present on `main` and supported by code or automated-test evidence.
@ -33,29 +59,31 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i
| 6 | Team onboarding and account recovery | B | Implemented / acceptance required | Invitation, password reset, and recovery flows are promoted to local `main`; verify deployed links, mail delivery, token expiry, and administrator recovery procedures. |
| 7 | Google connection recovery | B | Implemented / acceptance required | Connection epochs, checkpoint recovery, and revocation handling are promoted to local `main`; complete real Google acceptance and worker-restart exercises. |
| 8 | Operational readiness tooling | A | Implemented / acceptance required | Backup, restore, release, and diagnostic tooling is promoted to local `main`; execute it on the actual Debian host and retain evidence. |
| 9 | Gitea and reproducible releases | A | In progress | The `0.2.0` candidate is versioned on `main`. CI records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Retain the successful default-branch evidence off-host and create the immutable approval tag only after Gate B approval; the existing `0.1.0` tag remains attached to the foundation release. |
| 9 | Gitea and reproducible releases | A | In progress | Package the exact versioned `main` commit as a checksummed source archive and hand it to a version-selected Ansible playbook, following the CMS/CMSFront pattern. Ansible installs the archive, builds commit-tagged images, records their immutable IDs, and deploys without using a Gitea runner. Retain the package/install evidence off-host and create the immutable approval tag only after Gate B approval; the existing `0.1.0` tag remains attached to the foundation release. |
| 10 | Debian deployment and persistence | A | In progress | Compose uses separate named database and shared key volumes, private host configuration, loopback-only API access and bounded logs. The confirmation-gated persistence drill verifies restart and container-recreation behaviour. Run it on the provisioned Debian host, complete HTTPS and controlled-reboot acceptance, and retain the evidence. |
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling without command-line secrets. Install and test it on Debian, configure monitored off-host transfer and durable logs, name alert/retention owners, and retain evidence from a timed restore and recovery drill. |
| 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling, checksum-verified rsync transfer, a restricted Zabbix status boundary, guarded local retention and a release-bound acceptance validator. Install and test it on Debian, configure the restricted store and alerts, name operational/review owners, and retain independently reviewed evidence from the timed restore and rollback drill. |
| 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented. Complete every scenario against the accepted Debian release and dedicated Google sandbox accounts, independently review the evidence, and retain the validated record. |
| 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. |
| 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. |
| 15 | Knowledge, AI, and FAQ activation | B | Implemented / acceptance required | Owners can run a bounded no-send batch evaluation, and a release-bound acceptance record enforces positive/negative coverage, zero FAQ errors, separate AI review, staff training, stop-control evidence and named monitoring/rollback owners. Complete the supervised evaluation and retain independent approval. |
| 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. |
| 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. |
| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Retain the exact source-package and Ansible-install evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. |
| 19 | Account security and self-service | Post-B | Implemented / acceptance required | Implemented on `milestone/19-account-security`: TOTP MFA and recovery codes, protected SMTP outbox and non-enumerating recovery, fixed granular roles with legacy `Staff` compatibility, per-user timezone/inbox preferences, aggregate monitoring, administrator Owner-MFA recovery, automated security tests, and a restricted acceptance validator. Keep SMTP and MFA enforcement disabled until synthetic delivery tests, full role/tenant checks, recovery and restart exercises, and independent security review pass. Merge and version as `0.3.0` only after `0.2.0` Gate B approval and tagging. |
## Delivery sequence
The current critical path is:
`9 → 10 → 11 → 12 → 15 → 18`
`9 → 10 → 11 → 12 → 15 → 18 → 19`
Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel provider tracks. They do not need to delay a Google-only supervised pilot, but both remain independently gated before Gate C.
## Next actions
- [ ] Push the local release-candidate promotion to the intended default branch and retain its successful CI evidence.
- [ ] Retain successful `0.2.0` default-branch CI evidence, then create and archive the immutable approval tag only after Gate B approval.
- [ ] Merge the Action removal and release-process update to the intended default branch; that resulting commit becomes the new package candidate.
- [ ] Create and checksum the `0.2.0` source archive, add/select it in the GuestOps Ansible playbook, and retain the package and installation evidence.
- [ ] Create and archive the immutable `0.2.0` approval tag only after Gate B approval.
- [ ] Deploy to the target Debian environment with persistent MongoDB and data-protection keys.
- [ ] Run and record backup, restore, restart, monitoring, and rollback exercises.
- [ ] Complete real Google mailbox acceptance without using production guest data.
@ -63,6 +91,7 @@ Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel pro
- [ ] Obtain the Guestline/Rezlynx interface contract and sandbox access.
- [ ] Agree the payment-provider acceptance and reconciliation plan.
- [ ] Capture named owners and target dates for milestones 9–18.
- [ ] After Gate B approval, independently review milestone 19, validate its restricted acceptance record, merge the development branch, and create the `0.3.0` release candidate.
## Tracking convention

View File

@ -66,7 +66,7 @@ dotnet run --project tests/GuestOps.Tests
cd web && npm ci && npm run build
```
Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. CI runs both integrations and builds both Linux images.
Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. Run these checks before creating a versioned source package. GuestOps does not require a Gitea Actions runner; deployment follows the existing Futuresens source-package and Ansible process described in the [deployment guide](docs/deployment.md).
See [controlled FAQ automation](docs/auto-replies.md), [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md).

View File

@ -1,8 +1,9 @@
# GuestOps Release Notes
## 0.2.0 — Gate B release candidate
## test
This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, CI and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.0` tag is created.
This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, a checksummed source package and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.0` tag is created.
### Promoted scope
@ -19,7 +20,7 @@ These capabilities still require their separately documented provider, host and
### Known limitations and launch conditions
- Gate A still requires a successful default-branch CI run, durable off-host release archive, target-Debian deployment, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
- Gate A still requires a checksummed source package from the exact default-branch commit, Ansible installation on the target Debian host, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
- Gate B still requires real Google acceptance and supervised staff testing, including desktop-parity acceptance of pagination, draft protection, proxy-aware login throttling and saved-hotel-timezone rendering.
- Gate C still requires the Rezlynx/Guestline adapter and independently accepted PMS/payment workflows, plus privacy, identity, capacity, and release approvals.
- FAQ live mode and all external write actions must remain disabled until their corresponding acceptance gate has passed.

View File

@ -8,6 +8,14 @@ x-app-env: &app-env
Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-}
AutoReply__EnableLive: ${AUTO_REPLY_ENABLE_LIVE:-false}
Google__EnableSending: ${GOOGLE_ENABLE_SENDING:-false}
Identity__RequireMfa: ${IDENTITY_REQUIRE_MFA:-false}
Mail__Enabled: ${SMTP_ENABLED:-false}
Mail__Host: ${SMTP_HOST:-}
Mail__Port: ${SMTP_PORT:-587}
Mail__Username: ${SMTP_USERNAME:-}
Mail__Password: ${SMTP_PASSWORD:-}
Mail__FromAddress: ${SMTP_FROM_ADDRESS:-}
Mail__FromName: ${SMTP_FROM_NAME:-GuestOps}
Logging__LogLevel__Default: Warning
Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Warning
x-logging: &logging

View File

@ -0,0 +1,32 @@
{
"schemaVersion": 1,
"system": "guestops-account-security",
"milestone": 19,
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.example.invalid",
"operator": "REPLACE OPERATOR",
"reviewedBy": "REPLACE REVIEWER",
"securityReviewedBy": "REPLACE SECURITY REVIEWER",
"startedAt": "2026-10-05T09:00:00Z",
"endedAt": "2026-10-05T12:00:00Z",
"reviewedAt": "2026-10-05T13:00:00Z",
"securityReviewedAt": "2026-10-05T14:00:00Z",
"securityReviewEvidence": [],
"productionControls": {"smtpEnabled": false, "requireMfa": false, "enabledAfterSecurityReview": false},
"scenarios": [
{"id":"administrator-recovery","status":"not-run","evidence":[]},
{"id":"audit-review","status":"not-run","evidence":[]},
{"id":"authenticator-enrollment","status":"not-run","evidence":[]},
{"id":"legacy-staff","status":"not-run","evidence":[]},
{"id":"monitoring","status":"not-run","evidence":[]},
{"id":"preferences","status":"not-run","evidence":[]},
{"id":"recovery-codes","status":"not-run","evidence":[]},
{"id":"restart-recovery","status":"not-run","evidence":[]},
{"id":"role-boundaries","status":"not-run","evidence":[]},
{"id":"secret-free-interfaces","status":"not-run","evidence":[]},
{"id":"smtp-delivery-revocation","status":"not-run","evidence":[]},
{"id":"totp-window-replay","status":"not-run","evidence":[]}
],
"findings": []
}

View File

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Validate restricted milestone 19 account-security acceptance evidence."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
SCENARIOS={
"authenticator-enrollment","totp-window-replay","recovery-codes","administrator-recovery",
"smtp-delivery-revocation","role-boundaries","legacy-staff","preferences","restart-recovery",
"monitoring","audit-review","secret-free-interfaces",
}
def require(value:bool,message:str)->None:
if not value: raise ValueError(message)
def timestamp(value:object,field:str)->dt.datetime:
require(isinstance(value,str) and value.endswith("Z"),f"{field} must be a UTC timestamp ending in Z.")
try:return dt.datetime.fromisoformat(value.removesuffix("Z")+"+00:00")
except ValueError as error:raise ValueError(f"{field} is not a valid timestamp.") from error
def person(value:object,field:str)->str:
result=str(value or "").strip();require(2<=len(result)<=120 and "@" not in result,f"{field} requires a name without an email address.");return result
def refs(value:object,field:str)->None:
require(isinstance(value,list) and 1<=len(value)<=12 and all(isinstance(item,str) and 3<=len(item)<=200 and "@" not in item and "http" not in item.casefold() for item in value),f"{field} requires safe opaque evidence references.")
def validate(record:object)->None:
require(isinstance(record,dict),"Acceptance record must be a JSON object.")
require(record.get("schemaVersion")==1,"Unsupported account-security acceptance schema.")
require(record.get("system")=="guestops-account-security","system must be guestops-account-security.")
require(record.get("milestone")==19,"milestone must be 19.")
require(re.fullmatch(r"[0-9a-f]{40}",str(record.get("releaseCommit",""))) is not None,"releaseCommit must be a full lowercase Git SHA.")
require(re.fullmatch(r"[0-9a-f]{64}",str(record.get("releaseRecordSha256",""))) is not None,"releaseRecordSha256 must be a SHA-256 digest.")
origin=urlparse(str(record.get("environment","")));require(origin.scheme=="https" and origin.hostname and origin.path in ("","/") and not origin.query and not origin.fragment and origin.username is None,"environment must be an HTTPS origin without credentials, path, query or fragment.")
operator=person(record.get("operator"),"operator");reviewer=person(record.get("reviewedBy"),"reviewedBy");security=person(record.get("securityReviewedBy"),"securityReviewedBy")
require(len({operator.casefold(),reviewer.casefold(),security.casefold()})==3,"operator, reviewer, and security reviewer must be different people.")
started=timestamp(record.get("startedAt"),"startedAt");ended=timestamp(record.get("endedAt"),"endedAt");reviewed=timestamp(record.get("reviewedAt"),"reviewedAt");security_at=timestamp(record.get("securityReviewedAt"),"securityReviewedAt")
require(started<=ended<=reviewed and ended<=security_at,"Acceptance timestamps are out of order.")
refs(record.get("securityReviewEvidence"),"securityReviewEvidence")
controls=record.get("productionControls");require(isinstance(controls,dict),"productionControls is required.")
require(set(controls)=={"smtpEnabled","requireMfa","enabledAfterSecurityReview"},"productionControls has unexpected fields.")
require(all(isinstance(controls[key],bool) for key in controls),"productionControls values must be booleans.")
require(controls["enabledAfterSecurityReview"],"Production SMTP and MFA enforcement must be enabled only after independent security review.")
scenarios=record.get("scenarios");require(isinstance(scenarios,list),"scenarios must be a list.")
ids=[item.get("id") for item in scenarios if isinstance(item,dict)];require(len(ids)==len(scenarios)==len(set(ids)) and set(ids)==SCENARIOS,"Acceptance record requires the exact milestone 19 scenario set.")
for item in scenarios:
require(item.get("status")=="pass",f"Scenario {item['id']} has not passed.");refs(item.get("evidence"),f"Scenario {item['id']}")
findings=record.get("findings");require(isinstance(findings,list),"findings must be a list.")
for finding in findings:
require(isinstance(finding,dict) and finding.get("status") in ("resolved","accepted"),"Every finding must be resolved or explicitly accepted.")
require(3<=len(str(finding.get("id","")))<=80,"Every finding requires an opaque ID.")
person(finding.get("owner"),f"Finding {finding.get('id')} owner");refs(finding.get("evidence"),f"Finding {finding.get('id')}")
def main()->None:
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument("record",type=Path);args=parser.parse_args();validate(json.loads(args.record.read_text(encoding="utf-8")))
print("Milestone 19 account-security acceptance record is structurally complete. This validates the record, not its restricted evidence.")
if __name__=="__main__":
try:main()
except (OSError,ValueError,json.JSONDecodeError) as error:
print(f"Account-security acceptance record rejected: {error}",file=__import__("sys").stderr);raise SystemExit(1)

View File

@ -0,0 +1,81 @@
{
"schemaVersion": 1,
"system": "guestops-backup-recovery",
"evidenceId": "backup-restore",
"dataClassification": "synthetic-only",
"releaseVersion": "0.2.0",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.futuresens.co.uk",
"hostIdentifier": "guestops-sandbox-01",
"images": {
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
"mongo": {"reference": "mongo:8.0", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
},
"owners": {
"backup": "REPLACE",
"monitoring": "REPLACE",
"recoveryOperator": "REPLACE",
"technicalEscalation": "REPLACE",
"retention": "REPLACE",
"independentReviewer": "REPLACE"
},
"startedAt": "2026-10-01T09:00:00Z",
"endedAt": "2026-10-01T13:00:00Z",
"reviewedAt": "2026-10-01T14:00:00Z",
"recoveryObjectives": {
"targetRpoHours": 24,
"observedRpoHours": 25,
"targetRtoMinutes": 240,
"observedRtoMinutes": 241
},
"retention": {
"localVerifiedDays": 7,
"offHostDaily": 35,
"offHostMonthly": 12,
"legalHoldOverrideTested": false
},
"backup": {
"createdAt": "2026-10-01T08:00:00Z",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"transferredSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"privateKeyPresentOnHost": false
},
"rollback": {
"previousReleaseCommit": "1111111111111111111111111111111111111111",
"previousArchiveSha256": "1111111111111111111111111111111111111111111111111111111111111111",
"previousImages": {
"api": {"reference": "guestops-api:1111111111111111111111111111111111111111", "id": "sha256:1111111111111111111111111111111111111111111111111111111111111111"},
"worker": {"reference": "guestops-worker:1111111111111111111111111111111111111111", "id": "sha256:1111111111111111111111111111111111111111111111111111111111111111"}
},
"persistentVolumesReplaced": false,
"restoredReleaseCommit": "0000000000000000000000000000000000000000",
"unresolvedOperations": 1,
"finalControls": {
"googleSending": "disabled",
"faqLiveMode": "disabled",
"pmsWrites": "disabled",
"paymentCreation": "disabled"
}
},
"monitoringState": "not-configured",
"unresolvedCriticalFindings": 1,
"scenarios": [
{"id": "alert-escalation", "status": "not-run", "evidence": []},
{"id": "atomic-off-host-transfer", "status": "not-run", "evidence": []},
{"id": "controlled-return-to-service", "status": "not-run", "evidence": []},
{"id": "data-protection-recovery", "status": "not-run", "evidence": []},
{"id": "database-inventory", "status": "not-run", "evidence": []},
{"id": "durable-secret-free-logs", "status": "not-run", "evidence": []},
{"id": "encrypted-manual-backup", "status": "not-run", "evidence": []},
{"id": "image-rollback", "status": "not-run", "evidence": []},
{"id": "isolated-restore", "status": "not-run", "evidence": []},
{"id": "monitoring-coverage", "status": "not-run", "evidence": []},
{"id": "off-host-checksum", "status": "not-run", "evidence": []},
{"id": "production-service-recovery", "status": "not-run", "evidence": []},
{"id": "retention-and-legal-hold", "status": "not-run", "evidence": []},
{"id": "scheduled-backup", "status": "not-run", "evidence": []}
]
}

View File

@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Validate a restricted GuestOps backup, monitoring and recovery record."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
VERSION = "0.2.0"
SCENARIOS = {
"encrypted-manual-backup",
"scheduled-backup",
"production-service-recovery",
"atomic-off-host-transfer",
"off-host-checksum",
"retention-and-legal-hold",
"monitoring-coverage",
"alert-escalation",
"durable-secret-free-logs",
"isolated-restore",
"data-protection-recovery",
"database-inventory",
"image-rollback",
"controlled-return-to-service",
}
SHA256 = re.compile(r"[0-9a-f]{64}")
GIT_SHA = re.compile(r"[0-9a-f]{40}")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def timestamp(value: object, field: str) -> dt.datetime:
require(isinstance(value, str) and value.endswith("Z"),
f"{field} must be a UTC timestamp ending in Z.")
try:
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
return parsed
def safe_name(value: object, field: str) -> str:
name = str(value or "").strip()
require(2 <= len(name) <= 120 and "@" not in name and "/" not in name and "\\" not in name,
f"{field} requires a name without an email address or path.")
return name
def validate(record: object, expected_commit: str, expected_release_sha256: str) -> None:
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported backup/recovery schema.")
require(record.get("system") == "guestops-backup-recovery",
"system must be guestops-backup-recovery.")
require(record.get("evidenceId") == "backup-restore",
"evidenceId must be backup-restore.")
require(record.get("dataClassification") == "synthetic-only",
"Recovery acceptance must use synthetic data only.")
require(record.get("releaseVersion") == VERSION, f"releaseVersion must be {VERSION}.")
require(GIT_SHA.fullmatch(str(expected_commit)) is not None,
"Expected release commit must be a full lowercase Git SHA.")
require(SHA256.fullmatch(str(expected_release_sha256)) is not None,
"Expected release-record checksum must be a lowercase SHA-256 digest.")
require(record.get("releaseCommit") == expected_commit,
"releaseCommit does not match the approved candidate.")
require(record.get("releaseRecordSha256") == expected_release_sha256,
"releaseRecordSha256 does not match the retained release record.")
require(SHA256.fullmatch(str(record.get("archiveSha256", ""))) is not None,
"archiveSha256 must be a lowercase SHA-256 digest.")
origin = urlparse(str(record.get("environment", "")))
require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/")
and not origin.query and not origin.fragment and origin.username is None
and origin.password is None,
"environment must be an HTTPS origin without credentials, path, query or fragment.")
safe_name(record.get("hostIdentifier"), "hostIdentifier")
images = record.get("images")
require(isinstance(images, dict) and set(images) == {"api", "worker", "mongo"},
"images must contain exactly api, worker and mongo.")
for name in ("api", "worker"):
image = images[name]
require(isinstance(image, dict) and set(image) == {"reference", "id"},
f"images.{name} must contain exactly reference and id.")
require(image["reference"] == f"guestops-{name}:{expected_commit}",
f"images.{name}.reference must use the full approved commit.")
require(re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
f"images.{name}.id must be immutable.")
mongo = images["mongo"]
require(isinstance(mongo, dict) and set(mongo) == {"reference", "id"}
and mongo["reference"] == "mongo:8.0"
and re.fullmatch(r"sha256:[0-9a-f]{64}", str(mongo["id"])) is not None,
"images.mongo must identify the immutable mongo:8.0 image.")
owners = record.get("owners")
owner_keys = {"backup", "monitoring", "recoveryOperator", "technicalEscalation",
"retention", "independentReviewer"}
require(isinstance(owners, dict) and set(owners) == owner_keys,
"owners must contain the exact operational and review roles.")
names = {key: safe_name(value, f"owners.{key}") for key, value in owners.items()}
reviewer = names["independentReviewer"].casefold()
require(reviewer not in {names[key].casefold() for key in owner_keys - {"independentReviewer"}},
"independentReviewer must be different from every operational owner.")
started = timestamp(record.get("startedAt"), "startedAt")
ended = timestamp(record.get("endedAt"), "endedAt")
reviewed = timestamp(record.get("reviewedAt"), "reviewedAt")
require(started <= ended <= reviewed, "Acceptance timestamps are out of order.")
recovery = record.get("recoveryObjectives")
require(isinstance(recovery, dict) and set(recovery) == {
"targetRpoHours", "observedRpoHours", "targetRtoMinutes", "observedRtoMinutes",
}, "recoveryObjectives must contain exact target and observed RPO/RTO values.")
for field in recovery:
require(isinstance(recovery[field], (int, float)) and not isinstance(recovery[field], bool)
and recovery[field] >= 0, f"recoveryObjectives.{field} must be non-negative.")
require(recovery["targetRpoHours"] == 24 and recovery["observedRpoHours"] <= 24,
"Observed RPO must meet the approved 24-hour target.")
require(recovery["targetRtoMinutes"] == 240 and recovery["observedRtoMinutes"] <= 240,
"Observed RTO must meet the approved four-hour target.")
retention = record.get("retention")
require(retention == {
"localVerifiedDays": 7,
"offHostDaily": 35,
"offHostMonthly": 12,
"legalHoldOverrideTested": True,
}, "Retention must record seven local days, 35 daily and 12 monthly off-host copies, and legal-hold testing.")
backup = record.get("backup")
require(isinstance(backup, dict) and set(backup) == {
"createdAt", "sha256", "transferredSha256", "privateKeyPresentOnHost",
}, "backup must contain exact creation, checksum, transfer and private-key fields.")
created = timestamp(backup["createdAt"], "backup.createdAt")
require(created <= started, "The accepted backup must exist when the timed exercise starts.")
require(abs(recovery["observedRpoHours"] - (started - created).total_seconds() / 3600) < 0.01,
"Observed RPO must match the backup and exercise timestamps.")
require(abs(recovery["observedRtoMinutes"] - (ended - started).total_seconds() / 60) < 0.01,
"Observed RTO must match the exercise timestamps.")
require(SHA256.fullmatch(str(backup["sha256"])) is not None
and backup["transferredSha256"] == backup["sha256"],
"Local and transferred backup checksums must match.")
require(backup["privateKeyPresentOnHost"] is False,
"The recovery private key must not be present on the Debian host.")
rollback = record.get("rollback")
require(isinstance(rollback, dict) and set(rollback) == {
"previousReleaseCommit", "previousArchiveSha256", "previousImages",
"persistentVolumesReplaced", "restoredReleaseCommit", "unresolvedOperations", "finalControls",
}, "rollback must contain the exact rehearsal and final-state fields.")
require(GIT_SHA.fullmatch(str(rollback["previousReleaseCommit"])) is not None
and rollback["previousReleaseCommit"] != expected_commit,
"Rollback must use a different retained previous release.")
require(SHA256.fullmatch(str(rollback["previousArchiveSha256"])) is not None,
"Rollback requires the previous archive checksum.")
previous_images = rollback["previousImages"]
require(isinstance(previous_images, dict) and set(previous_images) == {"api", "worker"},
"Rollback requires exact previous API and worker images.")
for name in ("api", "worker"):
image = previous_images[name]
require(isinstance(image, dict) and set(image) == {"reference", "id"}
and image["reference"] == f"guestops-{name}:{rollback['previousReleaseCommit']}"
and re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
f"rollback.previousImages.{name} must use the retained previous release identity.")
require(rollback["persistentVolumesReplaced"] is False,
"Image rollback must not replace persistent volumes.")
require(rollback["restoredReleaseCommit"] == expected_commit,
"The exercise must finish on the approved candidate.")
require(rollback["unresolvedOperations"] == 0,
"The exercise must finish without unresolved operations.")
require(rollback["finalControls"] == {
"googleSending": "disabled", "faqLiveMode": "disabled",
"pmsWrites": "disabled", "paymentCreation": "disabled",
}, "The exercise must finish with all unaccepted external writes disabled.")
scenarios = record.get("scenarios")
require(isinstance(scenarios, list), "scenarios must be a list.")
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS,
"Acceptance record requires the exact backup/recovery scenario set.")
for item in scenarios:
scenario_id = item["id"]
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
evidence = item.get("evidence")
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value
and "\\" not in value and not value.startswith("/") for value in evidence
), f"Scenario {scenario_id} requires safe opaque evidence references.")
require(record.get("monitoringState") == "healthy",
"Monitoring must be healthy at acceptance completion.")
require(record.get("unresolvedCriticalFindings") == 0,
"Acceptance cannot pass with unresolved critical findings.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("record", type=Path)
parser.add_argument("--expected-commit", required=True)
parser.add_argument("--expected-release-record-sha256", required=True)
args = parser.parse_args()
validate(json.loads(args.record.read_text(encoding="utf-8")),
args.expected_commit, args.expected_release_record_sha256)
print("Backup, monitoring and recovery acceptance record is structurally complete and passed. "
"This validates the record, not its restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Backup/recovery acceptance record rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

238
deploy/backup_transfer.py Normal file
View File

@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Atomically transfer encrypted GuestOps backups to restricted storage."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import stat
import subprocess
import tempfile
import time
import uuid
BACKUP_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg")
SAFE_HOST = re.compile(r"[A-Za-z0-9.-]{1,253}")
SAFE_USER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,31}")
SAFE_REMOTE_PATH = re.compile(r"/[A-Za-z0-9._/-]{1,500}")
SHA256 = re.compile(r"[0-9a-f]{64}")
def require(condition: bool, message: str) -> None:
if not condition:
raise RuntimeError(message)
def digest(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
def private_directory(value: str) -> Path:
requested = Path(value)
require(requested.is_absolute() and not requested.is_symlink(),
"BACKUP_DIRECTORY must be an absolute, non-symlink path.")
directory = requested.resolve()
require(directory.is_dir(), "BACKUP_DIRECTORY must exist.")
require(stat.S_IMODE(directory.stat().st_mode) & 0o077 == 0,
"BACKUP_DIRECTORY must not be accessible to group or other users.")
return directory
def regular_file(value: str, field: str, *, private: bool) -> Path:
requested = Path(value)
require(requested.is_absolute() and not requested.is_symlink(),
f"{field} must be an absolute, non-symlink path.")
path = requested.resolve()
require(path.is_file(), f"{field} must be an existing regular file.")
if private:
require(stat.S_IMODE(path.stat().st_mode) & 0o077 == 0,
f"{field} must not be accessible to group or other users.")
return path
def configuration(environment: dict[str, str]) -> dict[str, object]:
directory = private_directory(environment.get("BACKUP_DIRECTORY", ""))
host = environment.get("BACKUP_REMOTE_HOST", "")
user = environment.get("BACKUP_REMOTE_USER", "")
remote = environment.get("BACKUP_REMOTE_DIRECTORY", "")
require(SAFE_HOST.fullmatch(host) is not None, "BACKUP_REMOTE_HOST is invalid.")
require(SAFE_USER.fullmatch(user) is not None, "BACKUP_REMOTE_USER is invalid.")
require(SAFE_REMOTE_PATH.fullmatch(remote) is not None and "//" not in remote
and "/../" not in remote + "/" and not remote.endswith("/.."),
"BACKUP_REMOTE_DIRECTORY must be a safe absolute path.")
identity = regular_file(environment.get("BACKUP_SSH_IDENTITY", ""),
"BACKUP_SSH_IDENTITY", private=True)
known_hosts = regular_file(environment.get("BACKUP_SSH_KNOWN_HOSTS", ""),
"BACKUP_SSH_KNOWN_HOSTS", private=False)
require(shutil.which("ssh") is not None and shutil.which("rsync") is not None,
"ssh and rsync are required.")
return {
"directory": directory,
"host": host,
"user": user,
"remote": remote.rstrip("/"),
"identity": identity,
"known_hosts": known_hosts,
}
def ssh_base(config: dict[str, object]) -> list[str]:
return [
"ssh", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15",
"-o", f"UserKnownHostsFile={config['known_hosts']}",
"-i", str(config["identity"]), f"{config['user']}@{config['host']}",
]
def run(args: list[str], *, environment: dict[str, str] | None = None) -> bytes:
result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=900, env=environment)
require(result.returncode == 0,
f"{Path(args[0]).name} step failed; review the restricted operator logs.")
return result.stdout
def remote_digest(config: dict[str, object], remote_path: str) -> str | None:
command = f"if test -f {remote_path} && test ! -L {remote_path}; then sha256sum -- {remote_path}; fi"
output = run([*ssh_base(config), command]).decode("utf-8", "strict").strip()
if not output:
return None
value = output.split()[0]
require(SHA256.fullmatch(value) is not None, "Remote checksum response was invalid.")
return value
def marker_path(backup: Path) -> Path:
return backup.with_name(backup.name + ".transferred.json")
def write_marker(backup: Path, checksum: str) -> None:
marker = marker_path(backup)
payload = json.dumps({
"schemaVersion": 1,
"backup": backup.name,
"sha256": checksum,
"verifiedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}, sort_keys=True) + "\n"
fd, temporary = tempfile.mkstemp(prefix=marker.name + ".", dir=marker.parent)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, marker)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def transfer_one(config: dict[str, object], backup: Path) -> None:
require(backup.is_file() and not backup.is_symlink()
and BACKUP_NAME.fullmatch(backup.name) is not None,
"Refusing to transfer an unexpected backup path.")
checksum = digest(backup)
remote_final = f"{config['remote']}/{backup.name}"
existing = remote_digest(config, remote_final)
if existing is not None:
require(existing == checksum, "A remote backup with this name has a different checksum.")
write_marker(backup, checksum)
return
remote_partial = f"{config['remote']}/.{backup.name}.partial-{uuid.uuid4().hex}"
rsh = shlex.join([
"ssh", "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=15",
"-o", f"UserKnownHostsFile={config['known_hosts']}",
"-i", str(config["identity"]),
])
rsync_environment = os.environ.copy()
rsync_environment["RSYNC_RSH"] = rsh
try:
run(["rsync", "--archive", "--chmod=F600", "--protect-args", "--",
str(backup), f"{config['user']}@{config['host']}:{remote_partial}"],
environment=rsync_environment)
require(remote_digest(config, remote_partial) == checksum,
"Transferred backup checksum does not match the local file.")
command = (f"test ! -e {remote_final} && mv -T -- {remote_partial} {remote_final} "
f"&& chmod 600 -- {remote_final}")
run([*ssh_base(config), command])
require(remote_digest(config, remote_final) == checksum,
"Final remote backup checksum does not match the local file.")
write_marker(backup, checksum)
except Exception:
# The name contains a fresh random suffix and is the only remote object this run may remove.
subprocess.run([*ssh_base(config), f"rm -f -- {remote_partial}"], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30)
raise
def transfer_pending(config: dict[str, object]) -> int:
directory = config["directory"]
backups = sorted(path for path in directory.iterdir()
if path.is_file() and not path.is_symlink()
and BACKUP_NAME.fullmatch(path.name) is not None)
for backup in backups:
marker = marker_path(backup)
if marker.is_file() and not marker.is_symlink():
try:
recorded = json.loads(marker.read_text(encoding="utf-8"))
if recorded.get("sha256") == digest(backup):
continue
except (OSError, ValueError, json.JSONDecodeError):
pass
transfer_one(config, backup)
return len(backups)
def prune_verified(config: dict[str, object], retention_days: int, now: float | None = None) -> int:
require(1 <= retention_days <= 365, "Local retention must be between 1 and 365 days.")
threshold = (time.time() if now is None else now) - retention_days * 86400
removed = 0
for backup in config["directory"].iterdir():
if not backup.is_file() or backup.is_symlink() or BACKUP_NAME.fullmatch(backup.name) is None:
continue
marker = marker_path(backup)
if backup.stat().st_mtime >= threshold or not marker.is_file() or marker.is_symlink():
continue
try:
recorded = json.loads(marker.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
continue
if recorded.get("backup") != backup.name or recorded.get("sha256") != digest(backup):
continue
backup.unlink()
marker.unlink()
removed += 1
return removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--prune-verified", action="store_true")
parser.add_argument("--retention-days", type=int, default=7)
args = parser.parse_args()
config = configuration(dict(os.environ))
observed = transfer_pending(config)
removed = prune_verified(config, args.retention_days) if args.prune_verified else 0
print(f"Backup transfer completed: {observed} encrypted backup(s) inspected; "
f"{removed} verified local backup(s) expired.")
if __name__ == "__main__":
try:
main()
except (OSError, RuntimeError, subprocess.SubprocessError, json.JSONDecodeError) as error:
print(f"Backup transfer failed: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -0,0 +1,45 @@
{
"schemaVersion": 1,
"system": "guestops-debian-host",
"evidenceId": "debian-host",
"releaseVersion": "0.2.0",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.futuresens.co.uk",
"hostIdentifier": "guestops-sandbox-01",
"images": {
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
},
"hostFacts": {
"debianMajor": 12,
"cpuCores": 4,
"memoryBytes": 8140382208,
"freeDiskBytes": 14275686400,
"publicTcpPorts": []
},
"featureControls": {
"googleSending": "disabled",
"faqLiveMode": "disabled",
"pmsWrites": "disabled",
"paymentCreation": "disabled"
},
"operator": "REPLACE",
"reviewedBy": "REPLACE",
"startedAt": "2026-09-30T09:00:00Z",
"endedAt": "2026-09-30T10:00:00Z",
"reviewedAt": "2026-09-30T11:00:00Z",
"unresolvedCriticalFindings": 1,
"scenarios": [
{"id": "boot-services", "status": "not-run", "evidence": []},
{"id": "controlled-reboot", "status": "not-run", "evidence": []},
{"id": "durable-log-retrieval", "status": "not-run", "evidence": []},
{"id": "host-baseline", "status": "not-run", "evidence": []},
{"id": "https-and-redirect", "status": "not-run", "evidence": []},
{"id": "network-exposure", "status": "not-run", "evidence": []},
{"id": "proxy-trust", "status": "not-run", "evidence": []},
{"id": "secret-free-logs", "status": "not-run", "evidence": []},
{"id": "workspace-health", "status": "not-run", "evidence": []}
]
}

206
deploy/debian_acceptance.py Normal file
View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Validate restricted GuestOps Debian-host and persistence acceptance records."""
from __future__ import annotations
import argparse
import datetime as dt
import json
from pathlib import Path
import re
from urllib.parse import urlparse
VERSION = "0.2.0"
SYSTEMS = {
"guestops-debian-host": {
"evidenceId": "debian-host",
"scenarios": {
"host-baseline",
"network-exposure",
"https-and-redirect",
"proxy-trust",
"boot-services",
"controlled-reboot",
"workspace-health",
"durable-log-retrieval",
"secret-free-logs",
},
},
"guestops-persistence": {
"evidenceId": "persistence",
"scenarios": {
"separate-volume-layout",
"service-restart",
"container-recreation",
"database-inventory",
"data-protection-key",
"image-identity",
"post-reboot-persistence",
},
},
}
SHA256 = re.compile(r"[0-9a-f]{64}")
GIT_SHA = re.compile(r"[0-9a-f]{40}")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def timestamp(value: object, field: str) -> dt.datetime:
require(isinstance(value, str) and value.endswith("Z"),
f"{field} must be a UTC timestamp ending in Z.")
try:
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
except ValueError as error:
raise ValueError(f"{field} is not a valid timestamp.") from error
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
return parsed
def safe_text(value: object, field: str, minimum: int = 2, maximum: int = 160) -> str:
text = str(value or "").strip()
require(minimum <= len(text) <= maximum and "@" not in text and "\\" not in text,
f"{field} must be safe text without an email address or local path.")
return text
def validate_common(record: object, expected_commit: str, expected_release_sha256: str) -> str:
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported Debian acceptance schema.")
system = record.get("system")
require(system in SYSTEMS, "Unknown Debian acceptance record system.")
require(record.get("evidenceId") == SYSTEMS[system]["evidenceId"],
f"{system} has the wrong evidenceId.")
require(record.get("releaseVersion") == VERSION,
f"releaseVersion must be {VERSION}.")
require(GIT_SHA.fullmatch(str(expected_commit)) is not None,
"Expected release commit must be a full lowercase Git SHA.")
require(SHA256.fullmatch(str(expected_release_sha256)) is not None,
"Expected release-record checksum must be a lowercase SHA-256 digest.")
require(record.get("releaseCommit") == expected_commit,
"releaseCommit does not match the approved candidate.")
require(record.get("releaseRecordSha256") == expected_release_sha256,
"releaseRecordSha256 does not match the retained release record.")
require(SHA256.fullmatch(str(record.get("archiveSha256", ""))) is not None,
"archiveSha256 must be a lowercase SHA-256 digest.")
origin = urlparse(str(record.get("environment", "")))
require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/")
and not origin.query and not origin.fragment and origin.username is None
and origin.password is None,
"environment must be an HTTPS origin without credentials, path, query or fragment.")
host = safe_text(record.get("hostIdentifier"), "hostIdentifier")
images = record.get("images")
require(isinstance(images, dict) and set(images) == {"api", "worker"},
"images must contain exactly api and worker.")
for name in ("api", "worker"):
image = images[name]
require(isinstance(image, dict) and set(image) == {"reference", "id"},
f"images.{name} must contain exactly reference and id.")
require(image["reference"] == f"guestops-{name}:{expected_commit}",
f"images.{name}.reference must use the full approved commit.")
require(re.fullmatch(r"sha256:[0-9a-f]{64}", str(image["id"])) is not None,
f"images.{name}.id must be an immutable image ID.")
operator = safe_text(record.get("operator"), "operator")
reviewer = safe_text(record.get("reviewedBy"), "reviewedBy")
require(operator.casefold() != reviewer.casefold(),
"operator and reviewedBy must be different people.")
started = timestamp(record.get("startedAt"), "startedAt")
ended = timestamp(record.get("endedAt"), "endedAt")
reviewed = timestamp(record.get("reviewedAt"), "reviewedAt")
require(started <= ended <= reviewed, "Acceptance timestamps are out of order.")
scenarios = record.get("scenarios")
require(isinstance(scenarios, list), "scenarios must be a list.")
ids = [item.get("id") for item in scenarios if isinstance(item, dict)]
required = SYSTEMS[system]["scenarios"]
require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == required,
f"{system} requires its exact acceptance scenario set.")
for item in scenarios:
scenario_id = item["id"]
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
evidence = item.get("evidence")
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value
and "\\" not in value and not value.startswith("/") for value in evidence
), f"Scenario {scenario_id} requires safe opaque evidence references.")
require(record.get("unresolvedCriticalFindings") == 0,
"Acceptance cannot pass with unresolved critical findings.")
return host
def validate_host(record: object, expected_commit: str, expected_release_sha256: str) -> str:
host = validate_common(record, expected_commit, expected_release_sha256)
require(record.get("system") == "guestops-debian-host",
"First record must be guestops-debian-host.")
facts = record.get("hostFacts")
require(isinstance(facts, dict) and set(facts) == {
"debianMajor", "cpuCores", "memoryBytes", "freeDiskBytes", "publicTcpPorts",
}, "hostFacts must contain the exact reviewed host facts.")
require(isinstance(facts["debianMajor"], int) and facts["debianMajor"] >= 12,
"Debian 12 or newer is required.")
require(isinstance(facts["cpuCores"], int) and facts["cpuCores"] >= 4,
"At least four CPU cores are required.")
require(isinstance(facts["memoryBytes"], int) and facts["memoryBytes"] >= 7_500_000_000,
"At least 7.5 GB of memory is required.")
require(isinstance(facts["freeDiskBytes"], int) and facts["freeDiskBytes"] >= 8 * 1024**3,
"At least 8 GiB of free disk space is required.")
require(facts["publicTcpPorts"] == [80, 443],
"Only TCP ports 80 and 443 may be public.")
require(record.get("featureControls") == {
"googleSending": "disabled",
"faqLiveMode": "disabled",
"pmsWrites": "disabled",
"paymentCreation": "disabled",
}, "Unaccepted external writes and FAQ live mode must remain disabled.")
return host
def validate_persistence(record: object, expected_commit: str, expected_release_sha256: str) -> str:
host = validate_common(record, expected_commit, expected_release_sha256)
require(record.get("system") == "guestops-persistence",
"Second record must be guestops-persistence.")
require(record.get("drillCommand") == "python3 deploy/ops.py persistence-drill --confirm-restart",
"drillCommand must identify the confirmation-gated persistence drill.")
return host
def validate_pair(host_record: object, persistence_record: object,
expected_commit: str, expected_release_sha256: str) -> None:
host = validate_host(host_record, expected_commit, expected_release_sha256)
persistence_host = validate_persistence(
persistence_record, expected_commit, expected_release_sha256)
require(host == persistence_host, "Both records must identify the same host.")
for field in ("environment", "releaseVersion", "releaseCommit", "releaseRecordSha256",
"archiveSha256", "images"):
require(host_record.get(field) == persistence_record.get(field),
f"Both records must use the same {field}.")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("host_record", type=Path)
parser.add_argument("persistence_record", type=Path)
parser.add_argument("--expected-commit", required=True)
parser.add_argument("--expected-release-record-sha256", required=True)
args = parser.parse_args()
host_record = json.loads(args.host_record.read_text(encoding="utf-8"))
persistence_record = json.loads(args.persistence_record.read_text(encoding="utf-8"))
validate_pair(host_record, persistence_record,
args.expected_commit, args.expected_release_record_sha256)
print("Debian-host and persistence acceptance records are structurally complete and passed. "
"This validates the records, not their restricted evidence.")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"Debian acceptance records rejected: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

189
deploy/monitor_status.py Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Write non-sensitive GuestOps host status for a read-only monitoring agent."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import socket
import ssl
import stat
import subprocess
import tempfile
import urllib.request
from urllib.parse import urlparse
BACKUP_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg")
MARKER_NAME = re.compile(r"guestops-[0-9]{8}T[0-9]{6}Z\.tar\.gpg\.transferred\.json")
AUTH = ('const c=new Mongo("mongodb://127.0.0.1");'
'c.getDB("admin").auth(process.env.MONGO_INITDB_ROOT_USERNAME,'
'process.env.MONGO_INITDB_ROOT_PASSWORD);')
HEARTBEAT = ('const x=c.getDB("guestops").workerheartbeat.findOne({_id:"worker"});'
'print(JSON.stringify(x&&x.At?x.At:null));')
def require(condition: bool, message: str) -> None:
if not condition:
raise RuntimeError(message)
def run(args: list[str], root: Path, timeout: int = 30) -> bytes:
result = subprocess.run(args, cwd=root, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
require(result.returncode == 0, f"{Path(args[0]).name} probe failed.")
return result.stdout
def utc_now() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def age_seconds(path: Path, pattern: re.Pattern[str], now: dt.datetime) -> int | None:
if not path.is_dir() or path.is_symlink():
return None
times = [item.stat().st_mtime for item in path.iterdir()
if item.is_file() and not item.is_symlink() and pattern.fullmatch(item.name)]
return max(0, int(now.timestamp() - max(times))) if times else None
def https_status(origin: str, now: dt.datetime) -> dict[str, object]:
parsed = urlparse(origin)
require(parsed.scheme == "https" and parsed.hostname and parsed.port in (None, 443)
and parsed.path in ("", "/") and not parsed.query and not parsed.fragment
and parsed.username is None and parsed.password is None,
"GUESTOPS_ORIGIN must be an HTTPS origin without credentials or a path.")
context = ssl.create_default_context()
with socket.create_connection((parsed.hostname, 443), timeout=10) as connection:
with context.wrap_socket(connection, server_hostname=parsed.hostname) as secured:
certificate = secured.getpeercert()
expires = dt.datetime.strptime(certificate["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(
tzinfo=dt.timezone.utc)
with urllib.request.urlopen(origin.rstrip("/") + "/health/ready", timeout=15,
context=context) as response:
ready = response.status == 200 and json.load(response) == {"status": "ready"}
return {"ready": ready, "certificateDaysRemaining": max(0, int((expires - now).total_seconds() // 86400))}
def parse_compose(value: bytes) -> dict[str, dict[str, str]]:
text = value.decode("utf-8", "strict").strip()
if not text:
return {}
try:
parsed = json.loads(text)
rows = parsed if isinstance(parsed, list) else [parsed]
except json.JSONDecodeError:
rows = [json.loads(line) for line in text.splitlines() if line.strip()]
result = {}
for row in rows:
if not isinstance(row, dict):
continue
service = str(row.get("Service", ""))
if service in {"api", "worker", "mongo"}:
result[service] = {
"state": str(row.get("State", "unknown")).lower(),
"health": str(row.get("Health", "none") or "none").lower(),
}
return result
def worker_heartbeat_age(root: Path, now: dt.datetime) -> int | None:
output = run(["docker", "compose", "exec", "-T", "mongo", "mongosh", "--quiet",
"--nodb", "--eval", AUTH + HEARTBEAT], root).decode("utf-8", "strict").strip()
value = json.loads(output)
if value is None:
return None
require(isinstance(value, str), "Worker heartbeat response was invalid.")
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
require(parsed.tzinfo is not None, "Worker heartbeat must contain a timezone.")
return max(0, int((now - parsed.astimezone(dt.timezone.utc)).total_seconds()))
def build_status(root: Path, backup_directory: Path, origin: str,
now: dt.datetime | None = None) -> tuple[dict[str, object], list[str]]:
moment = now or utc_now()
errors: list[str] = []
try:
https = https_status(origin, moment)
except Exception:
https = {"ready": False, "certificateDaysRemaining": None}
errors.append("https-probe-failed")
try:
containers = parse_compose(run(["docker", "compose", "ps", "--format", "json"], root))
if set(containers) != {"api", "worker", "mongo"}:
errors.append("container-set-incomplete")
except Exception:
containers = {}
errors.append("container-probe-failed")
try:
heartbeat_age = worker_heartbeat_age(root, moment)
if heartbeat_age is None:
errors.append("worker-heartbeat-missing")
except Exception:
heartbeat_age = None
errors.append("worker-heartbeat-probe-failed")
disk = shutil.disk_usage(root)
status = {
"schemaVersion": 1,
"generatedAt": moment.isoformat().replace("+00:00", "Z"),
"https": https,
"containers": containers,
"workerHeartbeatAgeSeconds": heartbeat_age,
"backupAgeSeconds": age_seconds(backup_directory, BACKUP_NAME, moment),
"verifiedTransferAgeSeconds": age_seconds(backup_directory, MARKER_NAME, moment),
"diskFreePercent": round(disk.free * 100 / disk.total, 2),
"persistentJournal": Path("/var/log/journal").is_dir(),
"errors": sorted(set(errors)),
}
return status, errors
def write_status(path: Path, status: dict[str, object]) -> None:
require(path.is_absolute() and not path.is_symlink(),
"Status output must be an absolute, non-symlink path.")
parent = path.parent.resolve()
require(parent.is_dir() and not path.parent.is_symlink(), "Status output directory must exist.")
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(status, stream, sort_keys=True, separators=(",", ":"))
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--backup-directory", type=Path, default=Path("/var/backups/guestops"))
parser.add_argument("--origin", default="https://sandbox-guestops.futuresens.co.uk")
parser.add_argument("--output", type=Path, default=Path("/run/guestops-monitor/status.json"))
args = parser.parse_args()
root = args.root.resolve()
require(root.is_dir(), "GuestOps root must exist.")
backup_directory = args.backup_directory.resolve()
status, errors = build_status(root, backup_directory, args.origin)
write_status(args.output, status)
print("GuestOps monitoring status updated." if not errors
else "GuestOps monitoring status updated with failed probes.")
raise SystemExit(1 if errors else 0)
if __name__ == "__main__":
try:
main()
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as error:
print(f"GuestOps monitoring probe failed: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

View File

@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"system": "guestops-persistence",
"evidenceId": "persistence",
"releaseVersion": "0.2.0",
"releaseCommit": "0000000000000000000000000000000000000000",
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"archiveSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"environment": "https://sandbox-guestops.futuresens.co.uk",
"hostIdentifier": "guestops-sandbox-01",
"images": {
"api": {"reference": "guestops-api:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"},
"worker": {"reference": "guestops-worker:0000000000000000000000000000000000000000", "id": "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
},
"drillCommand": "python3 deploy/ops.py persistence-drill --confirm-restart",
"operator": "REPLACE",
"reviewedBy": "REPLACE",
"startedAt": "2026-09-30T09:00:00Z",
"endedAt": "2026-09-30T10:00:00Z",
"reviewedAt": "2026-09-30T11:00:00Z",
"unresolvedCriticalFindings": 1,
"scenarios": [
{"id": "container-recreation", "status": "not-run", "evidence": []},
{"id": "data-protection-key", "status": "not-run", "evidence": []},
{"id": "database-inventory", "status": "not-run", "evidence": []},
{"id": "image-identity", "status": "not-run", "evidence": []},
{"id": "post-reboot-persistence", "status": "not-run", "evidence": []},
{"id": "separate-volume-layout", "status": "not-run", "evidence": []},
{"id": "service-restart", "status": "not-run", "evidence": []}
]
}

View File

@ -11,7 +11,7 @@ import re
GATE_B = {
"release-ci", "debian-host", "persistence", "backup-restore", "google-mailbox",
"release-package", "debian-host", "persistence", "backup-restore", "google-mailbox",
"automation", "identity-privacy", "inbox-usability", "capacity",
"incident-support", "pilot-findings",
}

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Create deterministic evidence for a reviewed GuestOps image archive."""
"""Bind a reviewed GuestOps source package to its installed image identities."""
from __future__ import annotations

View File

@ -0,0 +1,6 @@
BACKUP_DIRECTORY=/var/backups/guestops
BACKUP_REMOTE_HOST=restricted-store.example.invalid
BACKUP_REMOTE_USER=guestops_upload
BACKUP_REMOTE_DIRECTORY=/restricted/guestops/backups
BACKUP_SSH_IDENTITY=/etc/guestops/backup-transfer.key
BACKUP_SSH_KNOWN_HOSTS=/etc/guestops/backup-known-hosts

View File

@ -0,0 +1,2 @@
BACKUP_RECIPIENT=0123456789ABCDEF0123456789ABCDEF01234567
BACKUP_DIRECTORY=/var/backups/guestops

View File

@ -0,0 +1,19 @@
[Unit]
Description=Transfer GuestOps encrypted backups to restricted storage
Wants=network-online.target
After=network-online.target guestops-backup.service
ConditionPathIsDirectory=/srv/guestops
ConditionPathIsDirectory=/var/backups/guestops
[Service]
Type=oneshot
WorkingDirectory=/srv/guestops
EnvironmentFile=/etc/guestops/backup-transfer.env
UMask=0077
ExecStart=/usr/bin/python3 /srv/guestops/deploy/backup_transfer.py --prune-verified --retention-days 7
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths=/var/backups/guestops
TimeoutStartSec=30min

View File

@ -0,0 +1,11 @@
[Unit]
Description=Retry GuestOps off-host backup transfer
[Timer]
OnBootSec=10min
OnUnitActiveSec=15min
Persistent=true
Unit=guestops-backup-transfer.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,21 @@
[Unit]
Description=Write non-sensitive GuestOps monitoring status
Requires=docker.service
After=docker.service network-online.target
ConditionPathIsDirectory=/srv/guestops
ConditionPathIsDirectory=/var/backups/guestops
[Service]
Type=oneshot
WorkingDirectory=/srv/guestops
UMask=0022
ExecStart=/usr/bin/python3 /srv/guestops/deploy/monitor_status.py
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
RuntimeDirectory=guestops-monitor
RuntimeDirectoryMode=0755
RuntimeDirectoryPreserve=yes
ReadWritePaths=/run/guestops-monitor
TimeoutStartSec=2min

View File

@ -0,0 +1,11 @@
[Unit]
Description=Refresh GuestOps monitoring status each minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
Persistent=true
Unit=guestops-monitor-status.service
[Install]
WantedBy=timers.target

View File

@ -0,0 +1,2 @@
# The privileged systemd probe owns Docker/database access. Zabbix reads only this aggregate JSON.
UserParameter=guestops.status,cat /run/guestops-monitor/status.json

118
deploy/verify_release.py Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Verify a GuestOps source package and its immutable release record."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
SHA = re.compile(r"[0-9a-f]{40}")
DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
def require(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def loaded_image_id(reference: str) -> str:
result = subprocess.run(
["docker", "image", "inspect", "--format", "{{.Id}}", reference],
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def validate(
archive: Path,
record_path: Path,
expected_commit: str,
expected_version: str,
verify_loaded_images: bool = False,
) -> dict[str, object]:
require(archive.is_file(), "Release archive does not exist.")
require(record_path.is_file(), "Release record does not exist.")
require(SHA.fullmatch(expected_commit) is not None, "Expected commit must be a full lowercase Git SHA.")
require(re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", expected_version) is not None,
"Expected version must use MAJOR.MINOR.PATCH.")
record = json.loads(record_path.read_text(encoding="utf-8"))
require(isinstance(record, dict), "Release record must be a JSON object.")
require(record.get("schemaVersion") == 1, "Unsupported release-record schema.")
require(record.get("commit") == expected_commit, "Release-record commit does not match the approved candidate.")
require(record.get("version") == expected_version, "Release-record version does not match the approved version.")
artifact = record.get("artifact")
require(isinstance(artifact, dict), "Release record has no artifact object.")
require(artifact.get("name") == archive.name, "Release archive filename does not match the record.")
require(artifact.get("size") == archive.stat().st_size, "Release archive size does not match the record.")
archive_sha = sha256(archive)
require(artifact.get("sha256") == archive_sha, "Release archive SHA-256 does not match the record.")
images = record.get("images")
require(isinstance(images, dict) and set(images) == {"api", "worker"},
"Release record must contain exactly API and worker images.")
expected_references = {
"api": f"guestops-api:{expected_commit}",
"worker": f"guestops-worker:{expected_commit}",
}
verified_images: dict[str, dict[str, str]] = {}
for name, reference in expected_references.items():
image = images.get(name)
require(isinstance(image, dict), f"Release record has no {name} image object.")
require(image.get("reference") == reference, f"{name} image reference is not bound to the full candidate SHA.")
image_id = str(image.get("id", ""))
require(DIGEST.fullmatch(image_id) is not None, f"{name} image ID is not an immutable SHA-256 digest.")
if verify_loaded_images:
require(loaded_image_id(reference) == image_id, f"Loaded {name} image ID does not match the release record.")
verified_images[name] = {"reference": reference, "id": image_id}
return {
"schemaVersion": 1,
"verified": True,
"commit": expected_commit,
"version": expected_version,
"archive": {"name": archive.name, "size": archive.stat().st_size, "sha256": archive_sha},
"releaseRecord": {"name": record_path.name, "sha256": sha256(record_path)},
"images": verified_images,
"loadedImageIdsVerified": verify_loaded_images,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--archive", required=True, type=Path)
parser.add_argument("--record", required=True, type=Path)
parser.add_argument("--commit", required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--verify-loaded-images", action="store_true")
parser.add_argument("--output", type=Path, help="Optional path for the non-sensitive verification summary.")
args = parser.parse_args()
result = validate(args.archive, args.record, args.commit, args.version, args.verify_loaded_images)
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.write_text(rendered, encoding="utf-8")
print(rendered, end="")
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError) as error:
print(f"Release verification failed: {error}", file=__import__("sys").stderr)
raise SystemExit(1)

87
docs/account-security.md Normal file
View File

@ -0,0 +1,87 @@
# Account security and self-service
Milestone 19 is implemented on the `milestone/19-account-security` development branch for the planned `0.3.0` release. It must not be merged into `main` or enabled in production until the `0.2.0` Gate B candidate is approved and tagged.
## Roles and authorization
GuestOps uses fixed server-enforced roles. Hiding a control in the browser is not an authorization boundary.
| Role | Access |
| --- | --- |
| Owner | All hotel, integration, provider approval, automation, privacy, team, and security operations. |
| Manager | Inbox and reviewed Gmail sending; knowledge and FAQ test-mode management; hotel display settings; activity and health; management of Agent and Auditor accounts. |
| Agent | Inbox, drafts, status changes, reviewed Gmail sending, PMS/payment lookups, and preparation of proposals. |
| Auditor | Read-only activity and aggregate workspace health. No inbox bodies, guest workflow, team, or provider controls. |
Existing `Staff` records are normalized to Agent in sessions and permission checks. New invitations write `Agent`, `Manager`, or `Auditor`. Managers can manage only Agent and Auditor accounts. Only Owners can manage Managers, reset another user's MFA, enable integrations or writes, approve PMS/payment actions, enable live FAQ sending, or change security controls.
Role, password, disable/restore, and MFA changes rotate the security stamp and invalidate affected sessions. Tenant scope is always derived from the authenticated session; client-provided hotel identifiers are not authorization inputs.
## TOTP MFA
MFA uses six-digit, 30-second TOTP with SHA-1 compatibility, a one-step clock window, and atomic last-step replay prevention. Enrollment creates ten single-use recovery codes. The data-protection key ring protects TOTP secrets; only SHA-256 recovery-code hashes are stored. Enrollment and regeneration return recovery codes once.
When `Identity__RequireMfa=false`, current password login remains available. When it is `true`, successful password verification creates only a five-minute, HttpOnly, SameSite=Strict protected challenge cookie. A normal eight-hour session is created only after authenticator or recovery-code verification. Existing sessions without `mfa=true` are rejected on their next request. Unenrolled users are routed through enrollment after password verification.
Do not enable enforcement until every user has enrolled, recovery-code storage has been verified, and an independent security review has passed. Password recovery preserves MFA.
An Owner may reset MFA for a Manager, Agent, or Auditor after identity verification. Owner MFA reset is deliberately excluded from web controls. A server administrator performs the audited recovery:
```sh
read -r -p 'Verified owner email: ' RECOVERY_EMAIL
export RECOVERY_EMAIL
docker compose run --rm --no-deps -e RECOVERY_EMAIL api --reset-owner-mfa
unset RECOVERY_EMAIL
```
This clears the Owner's enrollment, rotates the security stamp, ends existing sessions, and writes an audit event. It never prints a secret or recovery code. The administrator must verify identity using the approved procedure before running it.
## Transactional email and self-service recovery
`POST /api/auth/recovery` always returns the same accepted response. Known and unknown addresses receive the same response. Requests are limited independently by client IP and a SHA-256 partition of the normalized address.
Production invitation and reset APIs return only `userId`, `deliveryState`, and `expiresAt`. Development preview may return a direct link for interface testing. Issuing another link invalidates the previous account token.
The API encrypts recipient, subject, body, and token link into an `AccountMail` outbox record. The worker atomically claims each record with a lease and sends it with a stable Message-ID. Clearly pre-submission failures receive at most five bounded retries. Any exception after SMTP submission begins is treated as ambiguous and moved to `NeedsReview`; it is never automatically resent. Expired records are not submitted. Workspace health exposes aggregate counts only.
SMTP configuration is private environment state:
```text
SMTP_ENABLED=false
SMTP_HOST=smtp.example.invalid
SMTP_PORT=587
SMTP_USERNAME=...
SMTP_PASSWORD=...
SMTP_FROM_ADDRESS=guestops@example.invalid
SMTP_FROM_NAME=GuestOps
```
The transport requires authenticated STARTTLS and normal platform certificate validation. There is no insecure-certificate option. Do not put credentials in JSON acceptance records, API responses, screenshots, logs, or source control.
Mandatory security notices are queued for password, MFA, role, disable/restore, and recovery events. They have no preference switch.
## User preferences
`GET/PUT /api/me/preferences` stores `displayTimeZone` and `defaultInboxFilter` with optimistic concurrency. Timezones are validated by server timezone data. Supported filters are `All`, `NeedsAttention`, `DraftReady`, and `Completed`. An empty display timezone uses the hotel's timezone. The session response includes effective permissions, MFA state, stored preferences, and the effective timezone.
## Safe rollout
1. Deploy with `SMTP_ENABLED=false` and `IDENTITY_REQUIRE_MFA=false`.
2. Configure SMTP through private environment values and send only to synthetic accounts.
3. Verify success, expiration, retry, restart, duplicate-claim, revocation, and ambiguous-outcome evidence; then enable SMTP.
4. Enroll every user and verify their recovery-code storage procedure.
5. Complete independent security review and record its evidence.
6. Set `IDENTITY_REQUIRE_MFA=true`. Confirm password-only sessions are rejected and unenrolled users enter enrollment.
7. Run the full role matrix, tenant-isolation, preferences, monitoring, audit, backup/restore, and restart checks.
SMTP or MFA failure must never weaken authorization or create a password-only bypass. A lost Owner authenticator uses only the server command above.
## Acceptance
Copy `deploy/account-security-acceptance.example.json` to the restricted evidence store, replace all placeholders, and keep guest data, addresses, tokens, secrets, raw mail, and screenshots outside Git. Validate the completed record with:
```sh
python3 deploy/account_security_acceptance.py /restricted/path/account-security-acceptance.json
```
Retain the record, validator output, checksum, independent security approval, and evidence references against the same full release commit and release-record SHA-256. The validator checks structure and approval separation; it does not inspect or prove the underlying evidence.

View File

@ -1,5 +1,7 @@
# Team access and hotel setup
> The post-pilot MFA, granular-role, SMTP-outbox, self-service recovery, and per-user preference design is documented in [account-security.md](account-security.md). This file describes the `0.2.0` Gate B account behavior retained on `main` until that release is approved.
Owners manage colleagues in **Your team**. The **Hotel setup** page shows progress derived from the hotel's saved MongoDB settings, approved answers, mailbox synchronization and active staff accounts. It does not enable any external action. Preview accounts and progress are temporary.
## Invite and recover staff

View File

@ -8,19 +8,30 @@ Check the existing Nginx, Docker, MongoDB and firewall configuration before inst
Install Docker Engine/Compose and Nginx using their official Debian instructions. Keep SSH access unchanged. Only HTTPS/HTTP for this hostname need public access; port 8080 is loopback-only and MongoDB has no published port.
Clone the private repository using an authorized GitHub account. Place the checkout in a dedicated application directory. Copy `.env.example` to `.env`, set permissions to 600, and fill two different MongoDB passwords generated with `openssl rand -hex 32`. Use hex values so they are safe in the MongoDB URI. Store real values only on the server or in its secret-management system.
Install the versioned source package through the Futuresens Ansible repository. Do not clone GuestOps from the target server and do not place Gitea credentials on it. The playbook should extract the package into a dedicated application directory. Copy `.env.example` to `.env`, set permissions to 600, and fill two different MongoDB passwords generated with `openssl rand -hex 32`. Use hex values so they are safe in the MongoDB URI. Store real values only on the server or in its secret-management system.
The MongoDB initialization script runs only on a new volume. Changing `.env` later does not rotate existing database users. Rotate those credentials through MongoDB administration and update application configuration together.
## 2. Load reviewed application images
## 2. Package and install with Ansible
CI builds API and worker images and packages them in a `guestops-linux-*` artifact. Download the successful artifact for the desired commit and transfer it to the sandbox through your normal authorized deployment process.
GuestOps follows the CMS/CMSFront deployment pattern: Gitea stores the application source, a specific committed version is compressed, and a version-selected Ansible playbook installs it. No Gitea Actions runner is required.
On the trusted packaging machine, run the automated checks, select the full commit SHA, and create the archive from that committed tree rather than from a working directory:
```sh
docker load -i guestops-images.tar.gz
git archive --format=tar.gz --prefix=GuestOps-0.2.0/ \
--output GuestOps-0.2.0.tar.gz FULL_40_CHARACTER_SHA
sha256sum GuestOps-0.2.0.tar.gz > GuestOps-0.2.0.tar.gz.sha256
```
Set `GUESTOPS_API_IMAGE=guestops-api:<commit-sha>` and `GUESTOPS_WORKER_IMAGE=guestops-worker:<commit-sha>` in `.env` using that exact build's SHA. Then run:
Store the archive and checksum under a versioned GuestOps files directory in the private Ansible repository. The GuestOps playbook and environment variables should select that version, copy and verify the archive, extract it into the application directory, preserve the private `.env` and provider configuration, and build images tagged with the full source commit:
```sh
docker build --target api -t guestops-api:FULL_40_CHARACTER_SHA .
docker build --target worker -t guestops-worker:FULL_40_CHARACTER_SHA .
```
Record the resulting immutable image IDs and bind them to the source archive with `deploy/release_record.py`. Retain the source archive, checksum, release record, its checksum, build output, commit and Ansible run result in the restricted release store. Set `GUESTOPS_API_IMAGE=guestops-api:FULL_40_CHARACTER_SHA` and `GUESTOPS_WORKER_IMAGE=guestops-worker:FULL_40_CHARACTER_SHA` in `.env`. Then the playbook runs:
```sh
docker compose config --quiet
@ -28,7 +39,7 @@ docker compose up -d --no-build
curl --fail http://127.0.0.1:8080/health
```
Do not run `docker compose config` without `--quiet` in shared logs: expanded configuration contains secrets. Local image builds are available with Compose for development, but CI builds avoid consuming sandbox resources.
Do not run `docker compose config` without `--quiet` in shared logs: expanded configuration contains secrets. Do not store `.env`, provider credentials, host inventory secrets, or restricted evidence in either application repository. Ansible must stop on a checksum, commit, version, build, or health-check mismatch.
## 3. Provision the first hotel owner
@ -86,4 +97,16 @@ The drill restarts MongoDB, API and worker, then force-recreates the stateless a
Record the host, operator, start/end time, release record checksum, resolved image IDs, preflight output and drill result in the deployment acceptance record. Also verify Docker starts at boot and perform a controlled Debian reboot before Gate A approval. After reboot, run the online preflight and inspect the Workspace health page; do not infer worker health solely from API readiness.
Keep the `debian-host` and `persistence` records in the restricted evidence store. Start from `deploy/debian-host-acceptance.example.json` and `deploy/persistence-acceptance.example.json`; the examples deliberately fail until every supervised scenario has passed. Bind both records to the expected release identifiers and validate them together:
```sh
python3 deploy/debian_acceptance.py \
/secure/acceptance/debian-host.json \
/secure/acceptance/persistence.json \
--expected-commit FULL_40_CHARACTER_SHA \
--expected-release-record-sha256 RELEASE_RECORD_SHA256
```
The validator requires matching archive and image identities, separate operator and reviewer names, the approved host capacity, only ports 80 and 443 recorded as publicly reachable, disabled unaccepted external writes, exact passed scenario sets and no unresolved critical findings. It validates record structure, not the restricted evidence itself. Retain the records, validator output and their checksums outside Git.
The supplied Docker `json-file` logs are size-capped to protect the small pilot disk, but container recreation removes that container's local log history. Before host acceptance, route GuestOps and Nginx logs to the site's durable restricted logging system, or use a reviewed Docker logging override backed by persistent systemd journal storage. Prove that operators can retrieve pre-recreation logs without exposing request credentials or OAuth callback query strings. Central retention and alerting are completed under milestone 11.

View File

@ -4,7 +4,7 @@ The owner-only **Workspace health** page reports database reachability, the work
## Release evidence and rollback
Every non-pull-request CI build packages the API and worker images under the full Git commit SHA. The accompanying `release-record.json` binds the archive checksum, application version, commit, image references and immutable Docker image IDs. Retain both files together in restricted off-host release storage; the CI artifact is a transfer mechanism, not the permanent archive.
Create the release source archive from an exact committed Gitea tree with `git archive`, then verify its SHA-256 before Ansible installs it. Ansible builds the API and worker images under the full Git commit SHA. The accompanying `release-record.json` binds the source-archive checksum, application version, commit, image references and immutable Docker image IDs. Retain the archive, checksum, record and Ansible result together in restricted off-host release storage.
Before deployment, verify the archive against its record without loading it:
@ -18,7 +18,28 @@ print(r['commit'], r['version'], r['images'])
PY
```
Load the archive, verify each loaded image ID matches the record, set `GUESTOPS_API_IMAGE` and `GUESTOPS_WORKER_IMAGE` to the recorded full-SHA references, and run the deployment preflight. Record the CI run, commit, checksum and operator in the change ticket. A release tag is an approval marker; do not move or reuse an existing tag. The application and web versions must match before the record can be created.
After Ansible has built the commit-tagged images, the repository verifier performs the same checks strictly, calculates the release-record checksum used by later acceptance records, and compares the record with the installed Docker images:
```sh
python3 deploy/verify_release.py \
--archive GuestOps-0.2.0.tar.gz \
--record release-record.json \
--commit FULL_40_CHARACTER_SHA \
--version 0.2.0 \
--output release-verification.json
python3 deploy/verify_release.py \
--archive GuestOps-0.2.0.tar.gz \
--record release-record.json \
--commit FULL_40_CHARACTER_SHA \
--version 0.2.0 \
--verify-loaded-images \
--output loaded-image-verification.json
```
Retain both verification summaries with the untouched source archive, its checksum, the release record, its SHA-256, and the exact Ansible run metadata. A package from an uncommitted working tree, a mismatched checksum, or a failed Ansible run is not release evidence. Do not silently replace an approved package or rebuild under the same release identity.
Verify each installed image ID matches the record, set `GUESTOPS_API_IMAGE` and `GUESTOPS_WORKER_IMAGE` to the recorded full-SHA references, and run the deployment preflight. Record the Ansible run, commit, archive checksum and operator in the change ticket. A release tag is an approval marker; do not move or reuse an existing tag. The application and web versions must match before the package is accepted.
For rollback, first disable worker-driven external writes and reconcile any sending, payment or PMS operation that may have completed since the prior release. Confirm the previous release archive and record are retained, verify its checksum and image IDs, take an encrypted backup, then select the previous recorded image references in `.env` and recreate only the API and worker. Do not roll back MongoDB or the key volume merely to change application images. Run the online preflight, readiness check and read-only smoke test before re-enabling the worker or provider writes. If a release introduced an incompatible data change, follow its release-specific recovery plan rather than starting an older image against newer data.
@ -91,6 +112,43 @@ systemctl list-timers guestops-backup.timer
The timer deliberately causes the same brief maintenance interruption as a manual backup. `Persistent=true` runs a missed event after downtime, so choose and communicate the maintenance window. A successful unit only stages an encrypted file locally. Configure an independently monitored off-host transfer, verify the destination checksum, alert on both unit and transfer failure, and test the alert route. Do not add automatic deletion until retention, legal hold and recovery requirements have named owners.
### Restricted off-host transfer
The optional transfer service uses rsync over pinned-host SSH. Create a dedicated upload-only account at the restricted store, disable interactive login, agent/port forwarding and access outside the GuestOps backup directory, and keep its private key only in `/etc/guestops` with mode 600. Pin the reviewed server host key; never use `StrictHostKeyChecking=no`.
Start from `deploy/systemd/backup-transfer.env.example` and store the completed file as `/etc/guestops/backup-transfer.env` with mode 600. The local and remote directories must already exist and remain private. Install and verify the service and its 15-minute retry timer:
```sh
sudo install -m 600 deploy/systemd/backup-transfer.env.example /etc/guestops/backup-transfer.env
sudoedit /etc/guestops/backup-transfer.env
sudo install -m 644 deploy/systemd/guestops-backup-transfer.service /etc/systemd/system/
sudo install -m 644 deploy/systemd/guestops-backup-transfer.timer /etc/systemd/system/
sudo systemd-analyze verify /etc/systemd/system/guestops-backup-transfer.service /etc/systemd/system/guestops-backup-transfer.timer
sudo systemctl daemon-reload
sudo systemctl start guestops-backup-transfer.service
sudo systemctl enable --now guestops-backup-transfer.timer
```
Only files named `guestops-YYYYMMDDTHHMMSSZ.tar.gpg` are eligible. The transfer writes a unique remote partial file, compares the remote and local SHA-256 values, atomically publishes a previously unused final name, verifies it again, and then writes a non-sensitive local marker. An existing remote name is accepted only when its checksum matches. Failed or mismatched transfers are never marked. The service removes local backups older than seven days only when the marker still matches the local checksum; untransferred or changed files are never pruned.
The restricted store is the durable copy. Configure its independently reviewed retention policy for 35 daily and 12 monthly recovery points. Legal hold must override expiry. The host tool does not delete remote data or enforce remote retention.
### Zabbix status boundary
Do not give the Zabbix agent access to Docker, MongoDB credentials or the application owner session. A root-owned systemd probe reads those local sources and atomically publishes aggregate status under `/run/guestops-monitor/status.json`; the agent reads that file only.
```sh
sudo install -m 644 deploy/systemd/guestops-monitor-status.service /etc/systemd/system/
sudo install -m 644 deploy/systemd/guestops-monitor-status.timer /etc/systemd/system/
sudo install -m 644 deploy/systemd/zabbix-agent-guestops.conf.example /etc/zabbix/zabbix_agentd.d/guestops.conf
sudo systemd-analyze verify /etc/systemd/system/guestops-monitor-status.service /etc/systemd/system/guestops-monitor-status.timer
sudo systemctl daemon-reload
sudo systemctl enable --now guestops-monitor-status.timer
sudo systemctl restart zabbix-agent
```
Create dependent Zabbix items from `guestops.status` for HTTPS readiness, certificate days remaining, container state/health, worker heartbeat age, backup age, verified-transfer age, free-disk percentage, persistent journal availability and probe error categories. Alert when the heartbeat is older than three minutes; backup or transfer is older than 30 hours; free disk falls below 25% (warning) or 15% (critical); the certificate has fewer than 30 days (warning) or 14 days (critical); any required container is absent/unhealthy; or the probe/timers fail. Route alerts to the monitoring owner and escalate unacknowledged critical events to the technical owner after 15 minutes. Exercise every trigger and its recovery notification with synthetic conditions.
Temporary plaintext files are held in private directories and removed on normal completion or exceptions. Process termination or power loss can leave temporary data, stopped services or TTL expiry disabled. After an interrupted run, inspect the dedicated project and remove only its identified abandoned temporary directory after securing any recovery material. Restore the recorded TTL setting (normally true) and restart the services:
```sh
@ -123,5 +181,21 @@ Restore into new isolated MongoDB and key volumes; preserve the damaged original
**A restored database can predate emails, invoices and PMS changes that providers already completed.** Review pending, sending and uncertain records against provider evidence before enabling any worker, including automatic FAQ rules. Do not replay an older approval merely because the restored record says it is pending. Reconcile external effects, validate account sessions and mailbox authorization, and explicitly approve the cutover only after these checks. Rotate credentials if compromise prompted the recovery. Keep the old deployment stopped when enabling the replacement.
CI exercises a synthetic encrypted backup and isolated restore drill, including actual key decryption and database comparison. A successful CI drill is separate from the required rehearsal on the Debian server with its actual deployment configuration.
Run the synthetic encrypted backup and isolated restore drill in a controlled test environment, including actual key decryption and database comparison. This automated check is separate from the required rehearsal on the Debian server with its actual deployment configuration.
## Milestone 11 acceptance record
Keep raw backups, restored data, remote paths, host keys, monitoring recipients, screenshots and logs outside Git. Copy `deploy/backup-restore-acceptance.example.json` to the restricted evidence store and replace every placeholder only after completing the supervised exercises. The example deliberately fails.
The accepted record uses a 24-hour RPO, four-hour RTO, seven-day verified local staging window, 35 daily and 12 monthly off-host recovery points, and the opaque evidence ID `backup-restore`. Bind it to the same release identifiers as the Debian-host and persistence records:
```sh
python3 deploy/backup_restore_acceptance.py \
/secure/acceptance/backup-restore.json \
--expected-commit FULL_40_CHARACTER_SHA \
--expected-release-record-sha256 RELEASE_RECORD_SHA256
sha256sum /secure/acceptance/backup-restore.json
```
The validator requires matching local/remote backup checksums, exact immutable image identities, a timed isolated restore, tested retention/legal hold and alert escalation, an image rollback that preserves persistent volumes, return to the approved candidate with external writes disabled, separate independent review, healthy monitoring and no unresolved critical findings. Structural validation does not inspect the restricted evidence or authorize a release by itself.

View File

@ -46,6 +46,6 @@ Only unsubmitted proposals can be cancelled in GuestOps. Invoice closure, refund
## Acceptance before live use
Automated tests use an in-process fake HTTP handler and never contact NMI. They cover tenant isolation, amount/currency/identity mismatches, partial status, concurrent approvals, lost create responses, pagination and merchant changes. CI checks the production configuration mount and disabled defaults.
Automated tests use an in-process fake HTTP handler and never contact NMI. They cover tenant isolation, amount/currency/identity mismatches, partial status, concurrent approvals, lost create responses, pagination and merchant changes. Run the production-configuration and disabled-default checks before packaging and again during the Ansible deployment verification.
Use a dedicated sandbox merchant and recipient to validate authentication, supported currency, exact request/response fields, preservation of `order_details.order_id`, customer invoice email and hosted checkout, partial/full payments and interrupted-request recovery. Live acceptance remains pending. Planet, direct payment links in GuestOps replies, automatic booking after payment, automated expiry/closure and background polling are follow-on work.

View File

@ -41,7 +41,7 @@ The example deliberately fails while daily reviews are `not-run`. A structurally
The go/no-go record must bind all evidence to the same release commit and release-record checksum. Record named owners, dates, evidence locations, findings and explicit dispositions for:
- default-branch CI and immutable release archive;
- exact-commit source package, checksum, recorded image IDs, and successful Ansible installation;
- Debian preflight, HTTPS, persistence and controlled reboot;
- encrypted off-host backup and timed isolated restore;
- Google mailbox and reviewed-send acceptance;

View File

@ -34,6 +34,6 @@ Current limitations: messages imported before reply headers were stored must be
## Verification
The test suite covers competing workers, send identity/thread headers, invalid recipients, header injection, uncertain outcomes, restart recovery, pre-send token failure, disabling hotel sending, cross-hotel access, Gmail reconciliation mismatches, AI source isolation, invalid citations, escalations and incomplete responses. CI also runs production container login/restart-persistence smoke checks. Live acceptance must additionally verify Google consent, actual threading, grant revocation, a representative AI draft evaluation set and provider error behaviour with the configured accounts.
The test suite covers competing workers, send identity/thread headers, invalid recipients, header injection, uncertain outcomes, restart recovery, pre-send token failure, disabling hotel sending, cross-hotel access, Gmail reconciliation mismatches, AI source isolation, invalid citations, escalations and incomplete responses. Run the production-container login and restart-persistence smoke checks before packaging and during Ansible deployment verification. Live acceptance must additionally verify Google consent, actual threading, grant revocation, a representative AI draft evaluation set and provider error behaviour with the configured accounts.
Implementation references: [OpenAI Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs), [Gmail sending](https://developers.google.com/workspace/gmail/api/guides/sending), [Gmail threads](https://developers.google.com/workspace/gmail/api/guides/threads).

View File

@ -0,0 +1,73 @@
using System.Net;
using System.Net.Mail;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Logging;
namespace GuestOps.Web;
public sealed record AccountMailPayload(string Recipient,string Subject,string Body,string Link);
public sealed class MailPreSubmissionException(string message):Exception(message);
public interface IAccountMailTransport { Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken); }
public sealed class SmtpAccountMailTransport(IConfiguration config):IAccountMailTransport
{
public async Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken)
{
if(!config.GetValue<bool>("Mail:Enabled"))throw new MailPreSubmissionException("Disabled");
var host=config["Mail:Host"];var username=config["Mail:Username"];var password=config["Mail:Password"];
var from=config["Mail:FromAddress"];var fromName=config["Mail:FromName"]??"GuestOps";
if(string.IsNullOrWhiteSpace(host)||string.IsNullOrWhiteSpace(username)||string.IsNullOrWhiteSpace(password)||!MailAddress.TryCreate(from,out var fromAddress))throw new MailPreSubmissionException("Configuration");
using var message=new MailMessage{From=new MailAddress(fromAddress.Address,fromName),Subject=payload.Subject,Body=payload.Body,IsBodyHtml=false};
message.To.Add(payload.Recipient);message.Headers.Add("Message-ID",messageId);
using var smtp=new SmtpClient(host,config.GetValue("Mail:Port",587)){EnableSsl=true,UseDefaultCredentials=false,Credentials=new NetworkCredential(username,password),DeliveryMethod=SmtpDeliveryMethod.Network};
await smtp.SendMailAsync(message,cancellationToken);
}
}
public sealed class AccountMailService(IStore store,IDataProtectionProvider protection,IConfiguration config)
{
readonly IDataProtector payloads=protection.CreateProtector("GuestOps.AccountMail.v1");
public bool Preview=>config.GetValue<bool>("Preview");
public async Task<AccountMail> Queue(StaffUser user,string purpose,string subject,string body,string link,DateTime expiresAt)
{
var id=Guid.NewGuid().ToString("N");var payload=new AccountMailPayload(user.Email,subject,body+"\n\n"+link,link);
var mail=new AccountMail{Id=id,HotelId=user.HotelId,UserId=user.Id,Purpose=purpose,ProtectedPayload=payloads.Protect(JsonSerializer.Serialize(payload)),MessageId=$"<{id}@account.guestops.invalid>",ExpiresAt=expiresAt};
await store.Insert(mail);return mail;
}
public Task Notify(StaffUser user,string purpose,string detail)
=>Queue(user,purpose,"GuestOps security notification",detail,"",DateTime.UtcNow.AddDays(7));
public AccountMailPayload Unprotect(AccountMail mail)=>JsonSerializer.Deserialize<AccountMailPayload>(payloads.Unprotect(mail.ProtectedPayload))??throw new InvalidOperationException("Invalid protected mail payload.");
}
public sealed class AccountMailProcessor(IStore store,AccountMailService mail,IAccountMailTransport transport,ILogger<AccountMailProcessor> log)
{
readonly string owner=Guid.NewGuid().ToString("N");
public async Task Process(AccountMail candidate,CancellationToken cancellationToken)
{
var item=await store.ClaimAccountMail(candidate.Id,owner);if(item==null)return;
var version=item.Version;
if(item.ExpiresAt<=DateTime.UtcNow){item.State="Expired";item.ErrorCategory="Expired";item.CompletedAt=DateTime.UtcNow;item.LeaseOwner="";item.LeaseUntil=null;item.Version++;await store.Replace(item.HotelId,item.Id,version,item);return;}
try
{
var payload=mail.Unprotect(item);await transport.Send(payload,item.MessageId,cancellationToken);
item.State="Sent";item.ErrorCategory="";item.CompletedAt=DateTime.UtcNow;
}
catch(MailPreSubmissionException)
{
item.AttemptCount++;item.ErrorCategory="PreSubmission";
if(item.AttemptCount>=5){item.State="Failed";item.CompletedAt=DateTime.UtcNow;}
else{item.State="Pending";item.NextAttemptAt=DateTime.UtcNow.AddMinutes(Math.Min(30,1<<item.AttemptCount));}
}
catch(OperationCanceledException) when(cancellationToken.IsCancellationRequested)
{
item.State="NeedsReview";item.ErrorCategory="Ambiguous";item.CompletedAt=DateTime.UtcNow;
}
catch(Exception ex)
{
item.State="NeedsReview";item.ErrorCategory="Ambiguous";item.CompletedAt=DateTime.UtcNow;
log.LogWarning("Account mail {MessageId} needs review after an ambiguous SMTP outcome ({Type})",item.MessageId,ex.GetType().Name);
}
item.LeaseOwner="";item.LeaseUntil=null;item.Version++;await store.Replace(item.HotelId,item.Id,version,item);
}
}

View File

@ -7,17 +7,17 @@ public static class AutoReplyEndpoints
{
public static void Map(RouteGroupBuilder api,bool preview)
{
var group=api.MapGroup("/auto-replies").RequireRateLimiting("pms");
var group=api.MapGroup("/auto-replies").RequireRateLimiting("pms").RequireAuthorization(Access.AutomationTest);
group.MapGet("/status",(AutoReplyWork work)=>Results.Ok(new{liveConfigured=!preview&&work.LiveConfigured,preview,questions=FaqMatcher.Questions,dailyLimit=20}));
group.MapGet("/rules",async(HttpContext c,IStore store)=>Results.Ok(await store.List<AutoReplyRule>(Session.Hotel(c))));
group.MapGet("/history",async(HttpContext c,IStore store)=>Results.Ok((await store.List<Conversation>(Session.Hotel(c))).Where(m=>m.AutoReplyCheckedAt!=null).OrderByDescending(m=>m.AutoReplyCheckedAt).Select(m=>new{m.Id,m.Subject,m.From,m.AutoReplyCheckedAt,m.AutoReplyMatched,m.AutoReplyDetail,delivery=m.Delivery?.State})));
group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{
if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body));
}).RequireAuthorization("Owner");
});
group.MapPost("/evaluate",async(AutoEvaluationInput input,HttpContext c,AutoReplyWork work)=>{
if(input.Cases is not {Length:>=1 and <=100} cases||cases.Select(x=>x.Id).Distinct(StringComparer.Ordinal).Count()!=cases.Length||cases.Any(x=>!Input.Text(x.Id,1,80)||!Input.Text(x.Subject,0,200)||!Input.Text(x.Body,1,2000)||x.ExpectedKnowledgeId.Length>80))return Results.BadRequest();
var results=await work.Evaluate(Session.Hotel(c),cases);return Results.Ok(new{total=results.Length,passed=results.Count(x=>x.Passed),falsePositives=results.Count(x=>!x.ExpectedMatch&&x.ActualMatch),falseNegatives=results.Count(x=>x.ExpectedMatch&&!x.ActualMatch),results});
}).RequireAuthorization("Owner");
});
group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{
if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest();
var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32];
@ -28,13 +28,14 @@ public static class AutoReplyEndpoints
if(exists){if(!await store.Replace(hotel,key,input.Version,rule))return Input.Conflict();}
else{try{await store.Insert(rule);}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return Input.Conflict();}}
await Session.Audit(store,c,"Reviewed FAQ automatic reply rule: "+rule.Question);return Results.Ok(rule);
}).RequireAuthorization("Owner");
});
group.MapPut("/mode",async(AutoModeInput input,HttpContext c,IStore store,AutoReplyWork work,GoogleMailbox google)=>{
if(input.Mode is not ("Off" or "Test" or "Live"))return Results.BadRequest();
if(input.Mode=="Live"&&!Access.Has(c.User,Access.AutomationLive))return Results.Forbid();
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();
if(input.Mode=="Live"&&(preview||!work.LiveConfigured||!google.SendingConfigured||!hotel.StaffSendingEnabled||!input.AcceptanceConfirmed))return Results.BadRequest(new{error="Live mode requires administrator enablement, Google sending, hotel sending and confirmed test-mode acceptance."});
hotel.AutoReplyMode=input.Mode;hotel.AutoReplyEpoch=Guid.NewGuid().ToString("N");hotel.AutoReplySince=DateTime.UtcNow;hotel.Version=input.Version+1;
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Set FAQ automation mode: "+input.Mode);return Results.Ok(hotel);
}).RequireAuthorization("Owner");
});
}
}

View File

@ -0,0 +1,73 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
namespace GuestOps.Web;
public sealed record MfaCodeInput(string Code);
public sealed record RecoveryInput(string Email);
public static class IdentityEndpoints
{
static async Task<StaffUser?> ChallengeUser(HttpContext c,IStore store,MfaChallenges challenges)
{
var challenge=challenges.Read(c);if(challenge==null)return null;
var user=await store.Get<StaffUser>(challenge.HotelId,challenge.UserId);
return user?.Active==true&&user.SecurityStamp==challenge.SecurityStamp?user:null;
}
public static void Map(WebApplication app,RouteGroupBuilder api)
{
app.MapPost("/api/auth/recovery",async(RecoveryInput input,HttpContext c,IStore store,TeamAccounts accounts,RecoveryRateLimits limits)=>
{
var email=(input.Email??"").Trim().ToLowerInvariant();var ip=c.Connection.RemoteIpAddress?.ToString()??"unknown";
if(email.Length<=254&&limits.Allow(ip,email)&&Input.Email(email))
{
var user=await store.FindLogin(email);
if(user?.Active==true&&user.PasswordHash.Length>0)
{
try{if(Access.NormalizeRole(user.Role)==Access.Owner)await accounts.RecoverOwner(user);else await accounts.ResetStaff(user,user.Version);}catch(AccountConflict){}catch(AccountInvalid){}
}
}
return Results.Accepted(value:new{accepted=true});
});
app.MapPost("/api/auth/mfa/enroll",async(HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=>
{
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var enrollment=await mfa.Begin(user);return Results.Ok(new{secret=enrollment.Secret,uri=enrollment.Uri});
}).RequireRateLimiting("accounts");
app.MapPost("/api/auth/mfa/enroll/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=>
{
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var codes=await mfa.Enable(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});
challenges.Clear(c);await Session.SignIn(c,user,true);await mail.Notify(user,"MfaEnabled","MFA was enabled for your GuestOps account.");return Results.Ok(new{recoveryCodes=codes});
}).RequireRateLimiting("accounts");
app.MapPost("/api/auth/mfa/verify",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa)=>
{
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);return Results.Ok();
}).RequireRateLimiting("accounts");
app.MapPost("/api/auth/mfa/recovery-code",async(MfaCodeInput input,HttpContext c,IStore store,MfaChallenges challenges,MfaService mfa,AccountMailService mail)=>
{
var user=await ChallengeUser(c,store,challenges);if(user==null)return Results.Unauthorized();var verified=await mfa.UseRecovery(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The recovery code is invalid or has already been used."});challenges.Clear(c);await Session.SignIn(c,verified,true);await mail.Notify(verified,"MfaRecovery","A recovery code was used to sign in to your GuestOps account.");return Results.Ok(new{remaining=verified.RecoveryCodeHashes.Length});
}).RequireRateLimiting("accounts");
api.MapGet("/auth/mfa/status",async(HttpContext c,IStore store)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);return user==null?Results.NotFound():Results.Ok(new{enabled=user.MfaEnabledAt!=null,recoveryCodesRemaining=user.RecoveryCodeHashes.Length,required=c.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("Identity:RequireMfa")});
});
api.MapPost("/auth/mfa/recovery-codes",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,AccountMailService mail)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var codes=await mfa.Regenerate(user,input.Code);if(codes==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});await c.SignOutAsync();await Session.SignIn(c,user,true);await mail.Notify(user,"RecoveryCodesRegenerated","Your GuestOps recovery codes were regenerated.");return Results.Ok(new{recoveryCodes=codes});
});
api.MapPost("/auth/mfa/disable",async(MfaCodeInput input,HttpContext c,IStore store,MfaService mfa,IConfiguration config,AccountMailService mail)=>
{
if(config.GetValue<bool>("Identity:RequireMfa"))return Results.Conflict(new{error="MFA cannot be disabled while enforcement is enabled."});var user=await store.Get<StaffUser>(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);if(user==null)return Results.NotFound();var verified=await mfa.Verify(user,input.Code);if(verified==null)return Results.BadRequest(new{error="The authenticator code is invalid or has already been used."});var version=verified.Version;MfaService.Reset(verified);verified.Version++;if(!await store.Replace(verified.HotelId,verified.Id,version,verified))return Input.Conflict();await c.SignOutAsync();await mail.Notify(verified,"MfaDisabled","MFA was disabled for your GuestOps account.");return Results.Ok();
});
api.MapGet("/me/preferences",async(HttpContext c,IStore store)=>
{
var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get<StaffUser>(Session.Hotel(c),id);var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));return user==null?Results.NotFound():Results.Ok(ViewPreferences(user,hotel));
});
api.MapPut("/me/preferences",async(PreferencesInput input,HttpContext c,IStore store)=>
{
if(!ValidFilter(input.DefaultInboxFilter))return Results.BadRequest(new{error="Choose a supported inbox filter."});if(input.DisplayTimeZone.Length>100)return Results.BadRequest(new{error="Choose a valid timezone."});if(input.DisplayTimeZone.Length>0)try{_=TimeZoneInfo.FindSystemTimeZoneById(input.DisplayTimeZone);}catch{return Results.BadRequest(new{error="Choose a valid timezone."});}
var id=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();user.DisplayTimeZone=input.DisplayTimeZone;user.DefaultInboxFilter=input.DefaultInboxFilter;user.Version=input.Version+1;if(!await store.Replace(user.HotelId,user.Id,input.Version,user))return Input.Conflict();var hotel=await store.Get<Hotel>(user.HotelId,user.HotelId);return Results.Ok(ViewPreferences(user,hotel));
});
}
public static bool ValidFilter(string value)=>value is "All" or "NeedsAttention" or "DraftReady" or "Completed";
public static object ViewPreferences(StaffUser user,Hotel? hotel)=>new{user.DisplayTimeZone,effectiveDisplayTimeZone=user.DisplayTimeZone.Length>0?user.DisplayTimeZone:hotel?.Timezone??"UTC",user.DefaultInboxFilter,user.Version};
}

View File

@ -0,0 +1,146 @@
using System.Globalization;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.DataProtection;
namespace GuestOps.Web;
public static class Access
{
public const string Owner="Owner", Manager="Manager", Agent="Agent", Auditor="Auditor";
public const string Inbox="Inbox", Send="Send", Knowledge="Knowledge", HotelSettings="HotelSettings",
Audit="Audit", Operations="Operations", Team="Team", Security="Security", Integrations="Integrations",
ExternalApproval="ExternalApproval", AutomationTest="AutomationTest", AutomationLive="AutomationLive",
ProviderLookup="ProviderLookup", ProviderProposal="ProviderProposal";
public static readonly string[] Roles=[Owner,Manager,Agent,Auditor];
static readonly IReadOnlyDictionary<string,string[]> Grants=new Dictionary<string,string[]>
{
[Owner]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,Security,Integrations,ExternalApproval,AutomationTest,AutomationLive,ProviderLookup,ProviderProposal],
[Manager]=[Inbox,Send,Knowledge,HotelSettings,Audit,Operations,Team,AutomationTest,ProviderLookup,ProviderProposal],
[Agent]=[Inbox,Send,ProviderLookup,ProviderProposal],
[Auditor]=[Audit,Operations]
};
public static string NormalizeRole(string? role)=>role=="Staff"?Agent:Roles.Contains(role,StringComparer.Ordinal)?role!:Agent;
public static IReadOnlyList<string> Permissions(string? role)=>Grants[NormalizeRole(role)];
public static bool Has(ClaimsPrincipal principal,string permission)=>Permissions(principal.FindFirstValue(ClaimTypes.Role)).Contains(permission,StringComparer.Ordinal);
public static bool CanManage(string actorRole,string targetRole)
{
actorRole=NormalizeRole(actorRole);targetRole=NormalizeRole(targetRole);
return actorRole==Owner?targetRole!=Owner:actorRole==Manager&&targetRole is Agent or Auditor;
}
}
public static class Totp
{
const string Alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
public static byte[] NewSecret()=>RandomNumberGenerator.GetBytes(20);
public static long Step(DateTime utcNow)=>new DateTimeOffset(utcNow.ToUniversalTime()).ToUnixTimeSeconds()/30;
public static string Code(byte[] secret,long step)
{
Span<byte> counter=stackalloc byte[8];System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(counter,step);
var hash=HMACSHA1.HashData(secret,counter);var offset=hash[^1]&15;
var value=((hash[offset]&127)<<24)|(hash[offset+1]<<16)|(hash[offset+2]<<8)|hash[offset+3];
return (value%1_000_000).ToString("D6",CultureInfo.InvariantCulture);
}
public static long? Verify(byte[] secret,string? code,DateTime utcNow,long? lastUsed)
{
if(code is null||code.Length!=6||!code.All(char.IsAsciiDigit))return null;
var now=Step(utcNow);
for(var delta=-1;delta<=1;delta++)
{
var candidate=now+delta;
if(candidate<=lastUsed)continue;
if(CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Code(secret,candidate)),Encoding.ASCII.GetBytes(code)))return candidate;
}
return null;
}
public static string Encode(byte[] data)
{
var output=new StringBuilder();var buffer=0;var bits=0;
foreach(var b in data){buffer=(buffer<<8)|b;bits+=8;while(bits>=5){bits-=5;output.Append(Alphabet[(buffer>>bits)&31]);}}
if(bits>0)output.Append(Alphabet[(buffer<<(5-bits))&31]);return output.ToString();
}
public static byte[] Decode(string value)
{
var bytes=new List<byte>();var buffer=0;var bits=0;
foreach(var c in value.Trim().TrimEnd('=').ToUpperInvariant()){var index=Alphabet.IndexOf(c);if(index<0)throw new FormatException("Invalid Base32 value.");buffer=(buffer<<5)|index;bits+=5;if(bits>=8){bits-=8;bytes.Add((byte)(buffer>>bits));buffer&=(1<<bits)-1;}}
return bytes.ToArray();
}
}
public sealed record MfaChallenge(string UserId,string HotelId,string SecurityStamp,long ExpiresUnix);
public sealed class MfaChallenges(IDataProtectionProvider protection,IConfiguration config)
{
const string Cookie="guestops.mfa";
readonly IDataProtector protector=protection.CreateProtector("GuestOps.MfaChallenge.v1");
public void Write(HttpContext c,StaffUser user)
{
var value=protector.Protect(JsonSerializer.Serialize(new MfaChallenge(user.Id,user.HotelId,user.SecurityStamp,DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds())));
c.Response.Cookies.Append(Cookie,value,new(){HttpOnly=true,Secure=!config.GetValue<bool>("Preview"),SameSite=SameSiteMode.Strict,MaxAge=TimeSpan.FromMinutes(5),Path="/api/auth/mfa"});
}
public MfaChallenge? Read(HttpContext c)
{
try{if(!c.Request.Cookies.TryGetValue(Cookie,out var value))return null;var result=JsonSerializer.Deserialize<MfaChallenge>(protector.Unprotect(value));return result?.ExpiresUnix>=DateTimeOffset.UtcNow.ToUnixTimeSeconds()?result:null;}catch(CryptographicException){return null;}catch(JsonException){return null;}
}
public void Clear(HttpContext c)=>c.Response.Cookies.Delete(Cookie,new(){Path="/api/auth/mfa"});
}
public sealed class MfaService(IStore store,IDataProtectionProvider protection)
{
readonly IDataProtector secrets=protection.CreateProtector("GuestOps.TotpSecret.v1");
static string RecoveryHash(StaffUser user,string code)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(user.Id+":"+NormalizeRecovery(code))));
static string NormalizeRecovery(string code)=>code.Replace("-","").Trim().ToUpperInvariant();
public async Task<(string Secret,string Uri)> Begin(StaffUser user)
{
if(user.MfaEnabledAt!=null)throw new AccountConflict("MFA is already enabled.");
byte[] secret;
if(user.MfaPendingSecretProtected.Length==0)
{
secret=Totp.NewSecret();var version=user.Version;user.MfaPendingSecretProtected=secrets.Protect(Convert.ToBase64String(secret));user.Version++;
if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Try again.");
}
else secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected));
var encoded=Totp.Encode(secret);var label=Uri.EscapeDataString("GuestOps:"+user.Email);var issuer=Uri.EscapeDataString("GuestOps");
return(encoded,$"otpauth://totp/{label}?secret={encoded}&issuer={issuer}&algorithm=SHA1&digits=6&period=30");
}
public async Task<string[]?> Enable(StaffUser user,string code,DateTime? now=null)
{
if(user.MfaEnabledAt!=null||user.MfaPendingSecretProtected.Length==0)return null;
var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaPendingSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null;
var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray();
var version=user.Version;user.MfaSecretProtected=user.MfaPendingSecretProtected;user.MfaPendingSecretProtected="";user.MfaEnabledAt=DateTime.UtcNow;user.LastTotpStep=step;user.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(user,x)).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
return await store.Replace(user.HotelId,user.Id,version,user)?raw:null;
}
public async Task<StaffUser?> Verify(StaffUser user,string code,DateTime? now=null)
{
if(user.MfaEnabledAt==null||user.MfaSecretProtected.Length==0)return null;
var secret=Convert.FromBase64String(secrets.Unprotect(user.MfaSecretProtected));var step=Totp.Verify(secret,code,now??DateTime.UtcNow,user.LastTotpStep);if(step==null)return null;
var version=user.Version;user.LastTotpStep=step;user.Version++;return await store.Replace(user.HotelId,user.Id,version,user)?user:null;
}
public async Task<StaffUser?> UseRecovery(StaffUser user,string code)
{
var hash=RecoveryHash(user,code);var index=Array.FindIndex(user.RecoveryCodeHashes,x=>CryptographicOperations.FixedTimeEquals(Convert.FromHexString(x),Convert.FromHexString(hash)));if(index<0)return null;
var version=user.Version;user.RecoveryCodeHashes=user.RecoveryCodeHashes.Where((_,i)=>i!=index).ToArray();user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
return await store.Replace(user.HotelId,user.Id,version,user)?user:null;
}
public async Task<string[]?> Regenerate(StaffUser user,string code)
{
var verified=await Verify(user,code);if(verified==null)return null;
var raw=Enumerable.Range(0,10).Select(_=>$"{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}-{Convert.ToHexString(RandomNumberGenerator.GetBytes(4))[..4]}").ToArray();
var version=verified.Version;verified.RecoveryCodeHashes=raw.Select(x=>RecoveryHash(verified,x)).ToArray();verified.SecurityStamp=Guid.NewGuid().ToString("N");verified.Version++;
return await store.Replace(verified.HotelId,verified.Id,version,verified)?raw:null;
}
public static void Reset(StaffUser user){user.MfaSecretProtected="";user.MfaPendingSecretProtected="";user.MfaEnabledAt=null;user.LastTotpStep=null;user.RecoveryCodeHashes=[];user.SecurityStamp=Guid.NewGuid().ToString("N");}
}
public sealed class RecoveryRateLimits
{
readonly System.Collections.Concurrent.ConcurrentDictionary<string,(DateTime Start,int Count)> windows=new();
bool Take(string key,int limit)
{
var now=DateTime.UtcNow;while(true){var old=windows.GetOrAdd(key,_=>(now,0));var next=now-old.Start>=TimeSpan.FromMinutes(15)?(now,1):(old.Start,old.Count+1);if(old.Count>=limit&&now-old.Start<TimeSpan.FromMinutes(15))return false;if(windows.TryUpdate(key,next,old))return true;}
}
public bool Allow(string ip,string email)=>Take("ip:"+ip,10)&&Take("address:"+Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(email))),5);
}

View File

@ -41,7 +41,7 @@ public static class MailboxManagement
if(action is not ("disconnect" or "retry"))return Results.NotFound();
if(!await Change(store,box,input.Version,action))return Results.Conflict(new{error="The mailbox changed, needs reconnection, or is waiting for its retry time. Refresh the status."});
await Session.Audit(store,c,action=="disconnect"?"Disconnected Google mailbox from GuestOps":"Requested a fresh mailbox import pass");return Results.Ok(View(box));
}).RequireAuthorization("Owner").RequireRateLimiting("accounts");
}).RequireAuthorization(Access.Integrations).RequireRateLimiting("accounts");
}
}

View File

@ -33,7 +33,32 @@ public class StaffUser : TenantDocument
public string Name { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string Role { get; set; } = "Owner";
public int RoleVersion { get; set; } = 1;
public bool Active { get; set; } = true;
public string MfaSecretProtected { get; set; } = "";
public string MfaPendingSecretProtected { get; set; } = "";
public DateTime? MfaEnabledAt { get; set; }
public long? LastTotpStep { get; set; }
public string[] RecoveryCodeHashes { get; set; } = [];
public DateTime? EmailVerifiedAt { get; set; }
public string DisplayTimeZone { get; set; } = "";
public string DefaultInboxFilter { get; set; } = "All";
}
public class AccountMail : TenantDocument
{
public long Version { get; set; }
public string UserId { get; set; } = "";
public string Purpose { get; set; } = "";
public string ProtectedPayload { get; set; } = "";
public string MessageId { get; set; } = "";
public string State { get; set; } = "Pending";
public int AttemptCount { get; set; }
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public string LeaseOwner { get; set; } = "";
public DateTime? LeaseUntil { get; set; }
public DateTime? CompletedAt { get; set; }
public string ErrorCategory { get; set; } = "";
}
public class KnowledgeEntry : TenantDocument
{
@ -108,6 +133,7 @@ public class WorkerLease
public DateTime Until { get; set; }
}
public record LoginInput(string Email, string Password);
public record PreferencesInput(string DisplayTimeZone, string DefaultInboxFilter, long Version);
public record SettingsInput(string Name, string Timezone, string Signature, long Version);
public record DraftInput(string Draft, long Version);
public record StatusInput(string Status, long Version);

View File

@ -22,8 +22,8 @@ public static class Operations
});
api.MapGet("/operations",async(HttpContext c,IStore store)=>
{
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var seen=await store.WorkerLastSeen();
return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")}});
}).RequireAuthorization("Owner");
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);var boxes=await store.List<Mailbox>(id);var messages=await store.List<Conversation>(id);var accountMail=await store.List<AccountMail>(id);var seen=await store.WorkerLastSeen();
return Results.Ok(new{checkedAt=DateTime.UtcNow,timeZone=hotel?.Timezone??"UTC",preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seen<DateTime.UtcNow.AddMinutes(-3)?"Stale":"Reporting"},mailboxes=new{total=boxes.Count,connected=boxes.Count(x=>x.Status=="Connected"),attention=boxes.Count(x=>x.Status!="Connected"||x.SyncError.Length>0)},replies=new{sampleSize=messages.Count,sampleLimit=500,pending=messages.Count(x=>x.Delivery?.State is "Pending" or "Sending"),uncertain=messages.Count(x=>x.Delivery?.State=="NeedsReview"),rejected=messages.Count(x=>x.Delivery?.State=="Rejected")},accountMail=new{pending=accountMail.Count(x=>x.State is "Pending" or "Sending"),needsReview=accountMail.Count(x=>x.State=="NeedsReview"),failed=accountMail.Count(x=>x.State is "Failed" or "Expired")}});
}).RequireAuthorization(Access.Operations);
}
}

View File

@ -4,29 +4,29 @@ public static class PaymentEndpoints
{
public static void Map(RouteGroupBuilder api,bool preview)
{
var group=api.MapGroup("/payments").RequireRateLimiting("pms");
var group=api.MapGroup("/payments").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup);
group.MapGet("/status",(HttpContext c,IConfiguration config)=>{var p=preview?null:NmiProfile.Read(config,Session.Hotel(c));return Results.Ok(new{configured=p!=null,createsConfigured=p?.CreatesEnabled==true,sandbox=p?.BaseUrl=="https://sandbox.nmi.com",preview});});
group.MapGet("/requests",async(HttpContext c,IStore store)=>Results.Ok((await store.List<PaymentRequest>(Session.Hotel(c))).OrderByDescending(p=>p.UpdatedAt).Select(p=>p.View())));
group.MapPost("/requests",async(PaymentInput input,HttpContext c,PaymentWork work,IStore store)=>{
if(preview)return Results.BadRequest(new{error="Real payment creation is disabled in preview."});
var p=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input);await Session.Audit(store,c,"Prepared payment request for review");return Results.Ok(p.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ProviderProposal);
group.MapPost("/requests/{id}/create",async(string id,PaymentApproval input,HttpContext c,PaymentWork work,IStore store)=>{
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Create(p,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.EmailAndAmountApproved,c.RequestAborted);await Session.Audit(store,c,"Payment creation result: "+result.State);return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPost("/requests/{id}/check",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Check(p,input.Version,c.RequestAborted);await Session.Audit(store,c,"Checked payment invoice: "+result.State);return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPost("/requests/{id}/cancel",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{
var p=await store.Get<PaymentRequest>(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Cancel(p,input.Version);await Session.Audit(store,c,"Cancelled unsubmitted payment proposal");return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>{
if(input.Enabled&&(preview||NmiProfile.Read(config,Session.Hotel(c))?.CreatesEnabled!=true))return Results.BadRequest(new{error="Administrator payment enablement and sandbox acceptance are required first."});
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PaymentsEnabled=input.Enabled;hotel.Version=input.Version+1;
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Updated payment creation control");return Results.Ok(hotel);
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Integrations);
}
}

View File

@ -5,7 +5,7 @@ public static class PmsEndpoints
{
public static void Map(RouteGroupBuilder api,bool preview)
{
var group=api.MapGroup("/pms").RequireRateLimiting("pms");
var group=api.MapGroup("/pms").RequireRateLimiting("pms").RequireAuthorization(Access.ProviderLookup);
group.MapGet("/status",(HttpContext c,IConfiguration config)=>
{
var profile=preview?null:OhipProfile.Read(config,Session.Hotel(c));
@ -23,29 +23,29 @@ public static class PmsEndpoints
if(preview)return Results.BadRequest();
var change=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,snapshot,input);
await Session.Audit(store,c,"Prepared a PMS change for review");return Results.Ok(change.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ProviderProposal);
group.MapPost("/changes/{id}/apply",async(string id,PmsApproveInput input,HttpContext c,PmsWork work,IStore store)=>
{
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Apply(change,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.AvailabilityAndPriceChecked,c.RequestAborted);
await Session.Audit(store,c,"PMS change result: "+result.State);return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPost("/changes/{id}/verify",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
{
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Verify(change,input.Version,c.RequestAborted);await Session.Audit(store,c,"Verified PMS state: "+result.State);return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPost("/changes/{id}/cancel",async(string id,VersionInput input,HttpContext c,PmsWork work,IStore store)=>
{
var change=await store.Get<PmsChange>(Session.Hotel(c),id);if(change==null)return Results.NotFound();if(preview)return Results.BadRequest();
var result=await work.Cancel(change,input.Version);await Session.Audit(store,c,"Cancelled an unapplied PMS proposal");return Results.Ok(result.View());
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.ExternalApproval);
group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>
{
if(input.Enabled&&(preview||OhipProfile.Read(config,Session.Hotel(c))?.WritesEnabled!=true))return Results.BadRequest(new{error="Server-side PMS writes must be enabled after sandbox acceptance first."});
var hotel=await store.Get<Hotel>(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PmsUpdatesEnabled=input.Enabled;hotel.Version=input.Version+1;
if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();
await Session.Audit(store,c,"Updated PMS write controls");return Results.Ok(hotel);
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Integrations);
}
}

View File

@ -13,8 +13,9 @@ using System.Net;
var bootstrap = args.Contains("--bootstrap");
var recoverOwner = args.Contains("--recover-owner");
var resetOwnerMfa = args.Contains("--reset-owner-mfa");
var backupProbe=args.Contains("--backup-probe");var verifyBackupProbe=args.Contains("--verify-backup-probe");
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray());
var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap" && arg != "--recover-owner" && arg != "--reset-owner-mfa" && arg != "--backup-probe" && arg != "--verify-backup-probe").ToArray());
if (builder.Configuration["Pms:ConfigFile"] is { Length: > 0 } pmsConfigFile) builder.Configuration.AddJsonFile(pmsConfigFile,optional:false,reloadOnChange:false);
if (builder.Configuration["Payments:ConfigFile"] is { Length: > 0 } paymentConfigFile) builder.Configuration.AddJsonFile(paymentConfigFile,optional:false,reloadOnChange:false);
builder.Logging.ClearProviders(); builder.Logging.AddConsole();
@ -38,6 +39,12 @@ builder.Services.AddHttpClient<NmiInvoices>(c=>c.Timeout=TimeSpan.FromSeconds(25
builder.Services.AddTransient<PaymentWork>();
builder.Services.AddTransient<AutoReplyWork>();
builder.Services.AddTransient<TeamAccounts>();
builder.Services.AddTransient<MfaService>();
builder.Services.AddSingleton<MfaChallenges>();
builder.Services.AddSingleton<RecoveryRateLimits>();
builder.Services.AddTransient<AccountMailService>();
builder.Services.AddTransient<IAccountMailTransport,SmtpAccountMailTransport>();
builder.Services.AddTransient<AccountMailProcessor>();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o =>
{
o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax;
@ -49,10 +56,17 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
{
var hotel = c.Principal?.FindFirstValue("hotel"); var id = c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier);
var user = hotel == null || id == null ? null : await c.HttpContext.RequestServices.GetRequiredService<IStore>().Get<StaffUser>(hotel, id);
if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || user.Role != c.Principal!.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
var normalized=user==null?null:Access.NormalizeRole(user.Role);var claimed=c.Principal?.FindFirstValue(ClaimTypes.Role);
var mfaRequired=c.HttpContext.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("Identity:RequireMfa");
if (user == null || !TeamAccounts.SessionValid(user,c.Principal?.FindFirstValue("security_stamp")) || normalized != claimed || mfaRequired&&c.Principal?.FindFirstValue("mfa")!="true") c.RejectPrincipal();
};
});
builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner")));
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("Owner",p=>p.RequireRole(Access.Owner));
foreach(var permission in new[]{Access.Inbox,Access.Send,Access.Knowledge,Access.HotelSettings,Access.Audit,Access.Operations,Access.Team,Access.Security,Access.Integrations,Access.ExternalApproval,Access.AutomationTest,Access.AutomationLive,Access.ProviderLookup,Access.ProviderProposal})
o.AddPolicy(permission,p=>p.RequireAssertion(c=>Access.Has(c.User,permission)));
});
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
// Trust scheme and client address only from the explicitly configured host proxy.
@ -90,6 +104,13 @@ if(recoverOwner)
var recovery=await app.Services.GetRequiredService<TeamAccounts>().RecoverOwner(user);
Console.WriteLine("Private single-use recovery link (expires in 30 minutes). Share only with the verified account owner:");Console.WriteLine(recovery.Link);return;
}
if(resetOwnerMfa)
{
var email=Environment.GetEnvironmentVariable("RECOVERY_EMAIL")?.Trim().ToLowerInvariant()??"";var user=await store.FindLogin(email);
if(user==null||!user.Active||Access.NormalizeRole(user.Role)!=Access.Owner)throw new InvalidOperationException("An active owner account is required.");
var version=user.Version;MfaService.Reset(user);user.Version++;if(!await store.Replace(user.HotelId,user.Id,version,user))throw new InvalidOperationException("The owner account changed; run the command again.");
await store.Insert(new Activity{HotelId=user.HotelId,UserName="Server administrator",Action="Reset owner MFA after identity verification"});Console.WriteLine("Owner MFA reset and all existing sessions invalidated.");return;
}
if (bootstrap)
{
var email = Environment.GetEnvironmentVariable("BOOTSTRAP_EMAIL")?.Trim().ToLowerInvariant() ?? "";
@ -133,11 +154,15 @@ app.Use(async (ctx, next) =>
await next();
});
app.MapGet("/health", () => Results.Ok(new { status = "ready" }));
app.MapGet("/api/session", (HttpContext c, IAntiforgery csrf) => Results.Ok(new
app.MapGet("/api/session", async (HttpContext c, IAntiforgery csrf) =>
{
preview, csrfToken = csrf.GetAndStoreTokens(c).RequestToken,
user = c.User.Identity?.IsAuthenticated == true ? new { id = c.User.FindFirstValue(ClaimTypes.NameIdentifier), name = c.User.Identity.Name, role = c.User.FindFirstValue(ClaimTypes.Role), hotelId = c.User.FindFirstValue("hotel") } : null
}));
object? view=null;if(c.User.Identity?.IsAuthenticated==true)
{
var hotelId=Session.Hotel(c);var user=await store.Get<StaffUser>(hotelId,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);var hotel=await store.Get<Hotel>(hotelId,hotelId);
if(user!=null)view=new{id=user.Id,name=user.Name,role=Access.NormalizeRole(user.Role),hotelId,permissions=Access.Permissions(user.Role),mfaEnabled=user.MfaEnabledAt!=null,preferences=IdentityEndpoints.ViewPreferences(user,hotel)};
}
return Results.Ok(new{preview,csrfToken=csrf.GetAndStoreTokens(c).RequestToken,user=view});
});
app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPasswordHasher<StaffUser> hasher) =>
{
if (input.Email == null || input.Password == null || input.Email.Length > 254 || input.Password.Length > 256) return Results.BadRequest(new { error = "Invalid credentials." });
@ -147,18 +172,23 @@ app.MapPost("/api/auth/login", async (LoginInput input, HttpContext c, IPassword
var hash = string.IsNullOrEmpty(user?.PasswordHash) ? Input.DummyHash : user.PasswordHash;
if (hasher.VerifyHashedPassword(checkUser, hash, input.Password) == PasswordVerificationResult.Failed || user?.Active != true)
return Results.Json(new { error = "Email or password is incorrect." }, statusCode: 401);
await Session.SignIn(c, user); return Results.Ok();
if(builder.Configuration.GetValue<bool>("Identity:RequireMfa"))
{
app.Services.GetRequiredService<MfaChallenges>().Write(c,user);return Results.Json(new{mfaRequired=true,enrollmentRequired=user.MfaEnabledAt==null},statusCode:202);
}
await Session.SignIn(c, user,false); return Results.Ok(new{mfaRequired=false});
}).RequireRateLimiting("login");
app.MapPost("/api/auth/logout", async (HttpContext c, CancellationToken _) => { await c.SignOutAsync(); return Results.Ok(); }).RequireAuthorization();
if (preview) app.MapPost("/api/preview/start", async (HttpContext c, CancellationToken _) => { var user = await Demo.Seed(store); await Session.SignIn(c, user); return Results.Ok(); }).RequireRateLimiting("login");
var api = app.MapGroup("/api").RequireAuthorization();
IdentityEndpoints.Map(app,api);
PmsEndpoints.Map(api,preview);
PaymentEndpoints.Map(api,preview);
AutoReplyEndpoints.Map(api,preview);
TeamEndpoints.Map(app,api,preview);
MailboxManagement.Map(api,preview);
Operations.Map(app,api,preview);
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c))));
api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get<Hotel>(Session.Hotel(c), Session.Hotel(c)))).RequireAuthorization(Access.Inbox);
api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
{
if (!Input.Text(input.Name, 2, 120) || !Input.Text(input.Signature, 0, 2000)) return Results.BadRequest(new { error = "Enter a hotel name and a signature under 2,000 characters." });
@ -167,10 +197,10 @@ api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
hotel.Name = input.Name.Trim(); hotel.Signature = input.Signature; hotel.Timezone = input.Timezone; hotel.Version = input.Version + 1;
if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict();
await Session.Audit(store, c, "Updated hotel settings"); return Results.Ok(hotel);
}).RequireAuthorization("Owner");
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt)));
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."}));
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound());
}).RequireAuthorization(Access.HotelSettings);
api.MapGet("/conversations", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Conversation>(Session.Hotel(c))).OrderByDescending(x => x.ReceivedAt))).RequireAuthorization(Access.Inbox);
api.MapGet("/conversations/page",async(string? cursor,HttpContext c)=>ConversationPaging.TryDecode(cursor,out var before,out var beforeId)?Results.Ok(await store.ConversationPage(Session.Hotel(c),cursor==null?null:before,beforeId,50)):Results.BadRequest(new{error="Invalid conversation cursor."})).RequireAuthorization(Access.Inbox);
api.MapGet("/conversations/{id}", async (string id, HttpContext c) => await store.Get<Conversation>(Session.Hotel(c), id) is { } item ? Results.Ok(item) : Results.NotFound()).RequireAuthorization(Access.Inbox);
api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, HttpContext c) =>
{
if (!Input.Text(input.Draft, 0, 20000)) return Results.BadRequest(new { error = "Draft must be under 20,000 characters." });
@ -179,7 +209,7 @@ api.MapPut("/conversations/{id}/draft", async (string id, DraftInput input, Http
item.Draft = input.Draft; item.Version = input.Version + 1; if (item.Status != "Completed") item.Status = input.Draft.Length > 0 ? "DraftReady" : "NeedsAttention";
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, "Saved a reply draft"); return Results.Ok(item);
});
}).RequireAuthorization(Access.Inbox);
api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, HttpContext c) =>
{
if (input.Status is not ("Completed" or "NeedsAttention")) return Results.BadRequest();
@ -188,14 +218,14 @@ api.MapPut("/conversations/{id}/status", async (string id, StatusInput input, Ht
item.Status = input.Status; item.Version = input.Version + 1;
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, input.Status == "Completed" ? "Resolved a conversation" : "Reopened a conversation"); return Results.Ok(item);
});
api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List<KnowledgeEntry>(Session.Hotel(c))));
}).RequireAuthorization(Access.Inbox);
api.MapGet("/knowledge", async (HttpContext c, CancellationToken _) => Results.Ok(await store.List<KnowledgeEntry>(Session.Hotel(c)))).RequireAuthorization(Access.Inbox);
api.MapPost("/knowledge", async (KnowledgeInput input, HttpContext c) =>
{
if (!Input.Knowledge(input)) return Results.BadRequest(new { error = "Enter a title and answer within the allowed lengths." });
var item = new KnowledgeEntry { HotelId = Session.Hotel(c), Title = input.Title, Category = input.Category, Answer = input.Answer, Keywords = input.Keywords, Approved = input.Approved };
await store.Insert(item); await Session.Audit(store, c, "Added a hotel knowledge entry"); return Results.Ok(item);
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Knowledge);
api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContext c) =>
{
if (!Input.Knowledge(input)) return Results.BadRequest();
@ -203,9 +233,9 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex
item.Title = input.Title; item.Category = input.Category; item.Answer = input.Answer; item.Keywords = input.Keywords; item.Approved = input.Approved; item.Version = input.Version + 1;
if (!await store.Replace(item.HotelId, id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, "Updated hotel knowledge"); return Results.Ok(item);
}).RequireAuthorization("Owner");
api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100)));
api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get<Hotel>(hotel,hotel))?.Timezone??"UTC", items = (await store.List<Mailbox>(hotel)).Select(MailboxManagement.View) }); });
}).RequireAuthorization(Access.Knowledge);
api.MapGet("/activity", async (HttpContext c, CancellationToken _) => Results.Ok((await store.List<Activity>(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))).RequireAuthorization(Access.Audit);
api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => { var hotel=Session.Hotel(c);return Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, timeZone=(await store.Get<Hotel>(hotel,hotel))?.Timezone??"UTC", items = (await store.List<Mailbox>(hotel)).Select(MailboxManagement.View) }); }).RequireAuthorization(Access.Inbox);
api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, GoogleMailbox google, AiDrafts ai) =>
{
if ((input.AiDraftsEnabled && (preview || !ai.Configured)) || (input.StaffSendingEnabled && (preview || !google.SendingConfigured))) return Results.BadRequest(new { error = "The administrator must configure this capability first." });
@ -213,7 +243,7 @@ api.MapPut("/reply-controls", async (ReplyControlsInput input, HttpContext c, Go
hotel.AiDraftsEnabled = input.AiDraftsEnabled; hotel.StaffSendingEnabled = input.StaffSendingEnabled; hotel.ReplyMode = input.StaffSendingEnabled ? "StaffApproved" : "DraftOnly"; hotel.Version = input.Version + 1;
if (!await store.Replace(hotel.HotelId, hotel.Id, input.Version, hotel)) return Input.Conflict();
await Session.Audit(store, c, "Updated AI and staff sending controls"); return Results.Ok(hotel);
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Integrations);
api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input, HttpContext c, AiDrafts ai) =>
{
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
@ -233,7 +263,7 @@ api.MapPost("/conversations/{id}/generate", async (string id, VersionInput input
item.Status = result.NeedsReview ? "NeedsAttention" : "DraftReady"; item.Version++;
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, result.NeedsReview ? "AI requested staff handling" : "Generated a draft for staff review"); return Results.Ok(item);
}).RequireRateLimiting("ai");
}).RequireAuthorization(Access.Inbox).RequireRateLimiting("ai");
api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpContext c, GoogleMailbox google) =>
{
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
@ -247,13 +277,13 @@ api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpC
item.Version++;
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, "Approved a saved reply for Gmail delivery"); return Results.Ok(item);
});
}).RequireAuthorization(Access.Send);
api.MapPost("/conversations/{id}/delivery/release",async(string id,VersionInput input,HttpContext c,AutoReplyWork work)=>
{
var item=await store.Get<Conversation>(Session.Hotel(c),id);if(item==null)return Results.NotFound();
if(preview||!await work.ReturnToStaff(item,input.Version))return Input.Conflict();
await Session.Audit(store,c,"Returned an unsubmitted automatic reply to staff review");return Results.Ok(item);
});
}).RequireAuthorization(Access.Integrations);
api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput input, HttpContext c) =>
{
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
@ -264,7 +294,7 @@ api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput
item.Delivery.State = "Pending"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Version++;
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, "Retried a reply that had not reached Gmail sending"); return Results.Ok(item);
});
}).RequireAuthorization(Access.Integrations);
api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInput input, HttpContext c, GoogleMailbox google) =>
{
var item = await store.Get<Conversation>(Session.Hotel(c), id); if (item == null) return Results.NotFound();
@ -275,13 +305,13 @@ api.MapPost("/conversations/{id}/delivery/verify", async (string id, VersionInpu
item.Delivery.ProviderId = found; item.Delivery.State = "Sent"; item.Delivery.Detail = "Verified in Gmail Sent"; item.Delivery.UpdatedAt = DateTime.UtcNow; item.Status = "Completed"; item.Version++;
if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict();
await Session.Audit(store, c, "Verified uncertain delivery in Gmail Sent"); return Results.Ok(item);
});
}).RequireAuthorization(Access.Integrations);
api.MapPost("/integrations/google/connect", async (HttpContext c, GoogleMailbox google) =>
{
if (preview || !google.Configured) return Results.BadRequest(new { error = "Google connection has not been configured by the administrator." });
var state = new OAuthRequest { Id = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)), HotelId = Session.Hotel(c), UserId = c.User.FindFirstValue(ClaimTypes.NameIdentifier)!, ExpiresAt = DateTime.UtcNow.AddMinutes(10) };
await store.Insert(state); return Results.Ok(new { url = google.AuthorizationUrl(state.Id) });
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Integrations);
api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox google) =>
{
if (preview) return Results.BadRequest();
@ -291,7 +321,7 @@ api.MapGet("/integrations/google/callback", async (HttpContext c, GoogleMailbox
try { await google.Connect(Session.Hotel(c), c.Request.Query["code"].ToString(),state.StartedAt,state.ExpectedEmail); await Session.Audit(store, c, "Connected Google mailbox"); }
catch { return Results.Redirect("/settings?google=failed"); }
return Results.Redirect("/settings?google=connected");
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.Integrations);
app.UseDefaultFiles(); app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
@ -301,7 +331,7 @@ namespace GuestOps.Web
public static class Session
{
public static string Hotel(HttpContext c) => c.User.FindFirstValue("hotel") ?? throw new InvalidOperationException("Missing hotel membership");
public static Task SignIn(HttpContext c, StaffUser u) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, u.Role), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp) }, CookieAuthenticationDefaults.AuthenticationScheme)));
public static Task SignIn(HttpContext c, StaffUser u,bool mfa=false) => c.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, u.Id), new Claim(ClaimTypes.Name, u.Name), new Claim(ClaimTypes.Role, Access.NormalizeRole(u.Role)), new Claim("hotel", u.HotelId), new Claim("security_stamp", u.SecurityStamp),new Claim("mfa",mfa?"true":"false") }, CookieAuthenticationDefaults.AuthenticationScheme)));
public static Task Audit(IStore store, HttpContext c, string action) => store.Insert(new Activity { HotelId = Hotel(c), UserName = c.User.Identity?.Name ?? "Staff", Action = action });
}
public static class Input

View File

@ -29,6 +29,8 @@ public interface IStore
Task ReleaseLease(string id, string owner);
Task Import(Conversation message);
Task<List<Conversation>> Deliveries();
Task<List<AccountMail>> AccountMails();
Task<AccountMail?> ClaimAccountMail(string id, string owner);
Task<bool> TryInsertPmsChange(PmsChange change);
Task<bool> TryInsertPayment(PaymentRequest payment);
Task<bool> TryAutoReplyClaim(AutoReplyClaim claim);
@ -40,6 +42,11 @@ public sealed class MongoStore : IStore
public async Task<DateTime?> WorkerLastSeen()=>(await db.GetCollection<WorkerHeartbeat>("workerheartbeat").Find(x=>x.Id=="worker").FirstOrDefaultAsync())?.At;
public async Task RecordWorkerHeartbeat()=>await db.GetCollection<WorkerHeartbeat>("workerheartbeat").ReplaceOneAsync(x=>x.Id=="worker",new WorkerHeartbeat(),new ReplaceOptions{IsUpsert=true});
public Task<List<Conversation>> Deliveries() => Collection<Conversation>().Find(x => x.Delivery != null && (x.Delivery.State == "Pending" || x.Delivery.State == "Sending")).SortBy(x => x.Delivery!.UpdatedAt).Limit(100).ToListAsync();
public Task<List<AccountMail>> AccountMails() => Collection<AccountMail>().Find(x => (x.State == "Pending" || x.State == "Sending") && x.NextAttemptAt <= DateTime.UtcNow).SortBy(x => x.NextAttemptAt).Limit(100).ToListAsync();
public async Task<AccountMail?> ClaimAccountMail(string id,string owner)=>await Collection<AccountMail>().FindOneAndUpdateAsync(
x=>x.Id==id&&(x.State=="Pending"||(x.State=="Sending"&&x.LeaseUntil<DateTime.UtcNow))&&x.NextAttemptAt<=DateTime.UtcNow,
Builders<AccountMail>.Update.Set(x=>x.State,"Sending").Set(x=>x.LeaseOwner,owner).Set(x=>x.LeaseUntil,DateTime.UtcNow.AddMinutes(2)).Inc(x=>x.Version,1),
new(){ReturnDocument=ReturnDocument.After});
private readonly IMongoDatabase db;
public MongoStore(IConfiguration config)
{
@ -57,6 +64,8 @@ public sealed class MongoStore : IStore
public async Task Initialize()
{
await Collection<StaffUser>().Indexes.CreateOneAsync(new CreateIndexModel<StaffUser>(Builders<StaffUser>.IndexKeys.Ascending(x=>x.AccountLinkHash)));
await Collection<AccountMail>().Indexes.CreateOneAsync(new CreateIndexModel<AccountMail>(Builders<AccountMail>.IndexKeys.Ascending(x=>x.State).Ascending(x=>x.NextAttemptAt)));
await Collection<AccountMail>().Indexes.CreateOneAsync(new CreateIndexModel<AccountMail>(Builders<AccountMail>.IndexKeys.Ascending(x=>x.MessageId),new(){Unique=true}));
foreach(var field in new[]{"ThreadKey","RecipientDay","DaySlot"})await Collection<AutoReplyClaim>().Indexes.CreateOneAsync(new CreateIndexModel<AutoReplyClaim>(Builders<AutoReplyClaim>.IndexKeys.Ascending(x=>x.HotelId).Ascending(field),new(){Unique=true}));
await Collection<Conversation>().Indexes.CreateOneAsync(new CreateIndexModel<Conversation>(Builders<Conversation>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.MailboxId).Ascending(x=>x.AutoReplyCheckedAt).Ascending(x=>x.ReceivedAt)));
await Collection<PaymentRequest>().Indexes.CreateOneAsync(new CreateIndexModel<PaymentRequest>(Builders<PaymentRequest>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true}));
@ -179,6 +188,16 @@ public sealed class PreviewStore : IStore
}
}
public Task<List<Conversation>> Deliveries() => Task.FromResult(rows.Where(x => x.Key.StartsWith("Conversation:")).Select(x => Clone<Conversation>(x.Value)).Where(x => x.Delivery?.State is "Pending" or "Sending").ToList());
public Task<List<AccountMail>> AccountMails()=>Task.FromResult(rows.Where(x=>x.Key.StartsWith("AccountMail:")).Select(x=>Clone<AccountMail>(x.Value)).Where(x=>x.State is "Pending" or "Sending"&&x.NextAttemptAt<=DateTime.UtcNow).ToList());
public Task<AccountMail?> ClaimAccountMail(string id,string owner)
{
lock(gate)
{
if(!rows.TryGetValue(Key<AccountMail>(id),out var raw))return Task.FromResult<AccountMail?>(null);
var mail=Clone<AccountMail>(raw);if(mail.NextAttemptAt>DateTime.UtcNow||mail.State!="Pending"&&!(mail.State=="Sending"&&mail.LeaseUntil<DateTime.UtcNow))return Task.FromResult<AccountMail?>(null);
mail.State="Sending";mail.LeaseOwner=owner;mail.LeaseUntil=DateTime.UtcNow.AddMinutes(2);mail.Version++;rows[Key<AccountMail>(id)]=Json(mail);return Task.FromResult<AccountMail?>(mail);
}
}
private readonly ConcurrentDictionary<string, string> rows = new();
private readonly object gate = new();
static string Key<T>(string id) => typeof(T).Name + ":" + id;

View File

@ -3,15 +3,15 @@ using System.Text;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Identity;
namespace GuestOps.Web;
public sealed record InviteInput(string Name,string Email);
public sealed record InviteInput(string Name,string Email,string Role="Agent");
public sealed record AccountTokenInput(string Token);
public sealed record AccountAcceptInput(string Token,string Password,string ConfirmPassword);
public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt);
public sealed record AccountLinkResult(string UserId,string Link,DateTime ExpiresAt,string DeliveryState="Preview");
public sealed class AccountInvalid(string message):Exception(message);
public sealed class AccountConflict(string message):Exception(message);
public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,IConfiguration config)
public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,IConfiguration config,AccountMailService? mail=null)
{
public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,user.Role,user.Active,user.Version,pending=user.PasswordHash.Length==0,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt};
public static object View(StaffUser user)=>new {user.Id,user.Name,user.Email,role=Access.NormalizeRole(user.Role),legacyRole=user.Role=="Staff",user.Active,user.Version,pending=user.PasswordHash.Length==0,mfaEnabled=user.MfaEnabledAt!=null,linkPurpose=user.AccountLinkPurpose,linkExpiresAt=user.AccountLinkExpiresAt};
public static bool SessionValid(StaffUser user,string? stamp)=>user.Active&&user.SecurityStamp==(stamp??"");
public static bool PasswordValid(string? password)=>password!=null&&password.Length>=14&&password.Length<=128;
static string Hash(string token)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
@ -27,19 +27,20 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
if(!Input.Text(input.Name,2,100)||!Input.Text(input.Email,3,254))throw new AccountInvalid("Enter a staff name and email address.");
var email=input.Email.Trim().ToLowerInvariant();if(!Input.Email(email)||email.Any(char.IsControl))throw new AccountInvalid("Enter one plain staff email address.");
_=BaseUrl();
var role=Access.NormalizeRole(input.Role);if(role==Access.Owner||!Access.Roles.Contains(role))throw new AccountInvalid("Choose Manager, Agent, or Auditor.");
var user=await store.FindLogin(email);
if(user!=null&&(user.HotelId!=hotel||user.Role!="Staff"||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator.");
if(user!=null&&(user.HotelId!=hotel||Access.NormalizeRole(user.Role)==Access.Owner||user.PasswordHash.Length>0))throw new AccountInvalid("This email is unavailable for invitation. Contact the administrator.");
if(user==null)
{
if((await store.List<StaffUser>(hotel)).Count>=50)throw new AccountInvalid("This hotel has reached the 50-account pilot limit. Contact the administrator.");
user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role="Staff",Active=false};
user=new StaffUser{HotelId=hotel,Name=input.Name.Trim(),Email=email,Role=role,RoleVersion=1,Active=false};
if(!await store.TryInsertStaff(user))throw new AccountConflict("The account changed elsewhere. Refresh the team list.");
}
user.Name=input.Name.Trim();return await Issue(user,"Invite",TimeSpan.FromHours(48));
user.Name=input.Name.Trim();user.Role=role;return await Issue(user,"Invite",TimeSpan.FromHours(48));
}
public Task<AccountLinkResult> ResetStaff(StaffUser user,long version)
{
if(user.Role!="Staff"||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current active staff account can receive a recovery link.");
if(Access.NormalizeRole(user.Role)==Access.Owner||!user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current active team account can receive a recovery link.");
return Issue(user,"Reset",TimeSpan.FromMinutes(30));
}
public Task<AccountLinkResult> RecoverOwner(StaffUser user)
@ -48,7 +49,7 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
}
public Task<AccountLinkResult> Restore(StaffUser user,long version)
{
if(user.Role!="Staff"||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only the current disabled staff account can be restored.");
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Active||user.Version!=version||user.PasswordHash.Length==0)throw new AccountConflict("Only a current disabled team account can be restored.");
return Issue(user,"Restore",TimeSpan.FromHours(48));
}
async Task<AccountLinkResult> Issue(StaffUser user,string purpose,TimeSpan lifetime)
@ -56,36 +57,55 @@ public sealed class TeamAccounts(IStore store,IPasswordHasher<StaffUser> hasher,
var root=BaseUrl();var token=Convert.ToHexString(RandomNumberGenerator.GetBytes(32));var version=user.Version;
user.AccountLinkHash=Hash(token);user.AccountLinkPurpose=purpose;user.AccountLinkExpiresAt=DateTime.UtcNow.Add(lifetime);user.Version++;
if(!await store.Replace(user.HotelId,user.Id,version,user))throw new AccountConflict("The account changed elsewhere. Refresh and issue a new link.");
return new(user.Id,root+"/account#token="+token,user.AccountLinkExpiresAt.Value);
var link=root+"/account#token="+token;
if(mail!=null&&!mail.Preview)
{
var subject=purpose=="Invite"?"Your GuestOps invitation":"Your GuestOps account recovery link";
await mail.Queue(user,purpose,subject,"This single-use link expires at "+user.AccountLinkExpiresAt.Value.ToString("O")+".",link,user.AccountLinkExpiresAt.Value);
return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Pending");
}
return new(user.Id,link,user.AccountLinkExpiresAt.Value,"Preview");
}
public async Task<StaffUser?> Inspect(string? token)
{
if(token==null||!Regex.IsMatch(token,"^[A-F0-9]{64}$"))return null;
var user=await store.FindAccountLink(Hash(token));
if(user==null||user.AccountLinkExpiresAt<=DateTime.UtcNow||user.AccountLinkExpiresAt==null)return null;
if(user.AccountLinkPurpose=="Invite"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length==0)return user;
if(user.AccountLinkPurpose=="Restore"&&user.Role=="Staff"&&!user.Active&&user.PasswordHash.Length>0)return user;
if(user.AccountLinkPurpose=="Invite"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length==0)return user;
if(user.AccountLinkPurpose=="Restore"&&Access.NormalizeRole(user.Role)!=Access.Owner&&!user.Active&&user.PasswordHash.Length>0)return user;
return user.AccountLinkPurpose=="Reset"&&user.Active&&user.PasswordHash.Length>0?user:null;
}
public async Task<bool> Accept(AccountAcceptInput input)
{
if(!PasswordValid(input.Password)||input.Password!=input.ConfirmPassword)throw new AccountInvalid("Use matching passwords of 14 to 128 characters.");
var user=await Inspect(input.Token);if(user==null)return false;
var hash=user.AccountLinkHash;var version=user.Version;
var hash=user.AccountLinkHash;var version=user.Version;var purpose=user.AccountLinkPurpose;
user.PasswordHash=hasher.HashPassword(user,input.Password);user.Active=true;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
if(purpose=="Invite")user.EmailVerifiedAt=DateTime.UtcNow;
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;
return await store.ConsumeAccountLink(user,version,hash);
var changed=await store.ConsumeAccountLink(user,version,hash);if(changed&&mail!=null)await mail.Notify(user,"PasswordChanged","Your GuestOps password was changed. Contact the hotel owner immediately if this was not you.");return changed;
}
public async Task<bool> Disable(StaffUser user,long version)
{
if(user.Role!="Staff"||user.Version!=version)return false;
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
user.Active=false;user.SecurityStamp=Guid.NewGuid().ToString("N");user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++;
return await store.Replace(user.HotelId,user.Id,version,user);
var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"AccountDisabled","Your GuestOps account was disabled.");return changed;
}
public async Task<bool> Revoke(StaffUser user,long version)
{
if(user.Role!="Staff"||user.Version!=version)return false;
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
user.AccountLinkHash="";user.AccountLinkPurpose="";user.AccountLinkExpiresAt=null;user.Version++;
return await store.Replace(user.HotelId,user.Id,version,user);
}
}
public async Task<bool> ChangeRole(StaffUser user,long version,string role)
{
role=Access.NormalizeRole(role);if(role==Access.Owner||!Access.Roles.Contains(role)||user.Version!=version||Access.NormalizeRole(user.Role)==Access.Owner)return false;
user.Role=role;user.RoleVersion=1;user.SecurityStamp=Guid.NewGuid().ToString("N");user.Version++;
var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"RoleChanged","Your GuestOps role changed to "+role+".");return changed;
}
public async Task<bool> ResetMfa(StaffUser user,long version)
{
if(Access.NormalizeRole(user.Role)==Access.Owner||user.Version!=version)return false;
MfaService.Reset(user);user.Version++;var changed=await store.Replace(user.HotelId,user.Id,version,user);if(changed&&mail!=null)await mail.Notify(user,"MfaReset","MFA was reset for your GuestOps account. You must enroll again before the next MFA-enforced sign-in.");return changed;
}
}

View File

@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
namespace GuestOps.Web;
public static class TeamEndpoints
{
static object Delivery(AccountLinkResult result,bool preview)=>preview?new{result.UserId,result.DeliveryState,result.ExpiresAt,result.Link}:(object)new{result.UserId,result.DeliveryState,result.ExpiresAt};
public static void Map(WebApplication app,RouteGroupBuilder api,bool preview)
{
app.MapPost("/api/account-links/inspect",async(AccountTokenInput input,TeamAccounts accounts,IStore store)=>
@ -17,25 +19,36 @@ public static class TeamEndpoints
await store.Insert(new Activity{HotelId=user.HotelId,UserName=user.Name,Action=user.AccountLinkPurpose=="Invite"?"Accepted staff invitation":"Changed account password"});
await c.SignOutAsync();return Results.Ok();
}).RequireRateLimiting("accounts");
var team=api.MapGroup("/team").RequireAuthorization("Owner");
var team=api.MapGroup("/team").RequireAuthorization(Access.Team);
team.MapGet("/",async(HttpContext c,IStore store)=>Results.Ok((await store.List<StaffUser>(Session.Hotel(c))).Select(TeamAccounts.View)));
team.MapPost("/invite",async(InviteInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var result=await accounts.Invite(Session.Hotel(c),input);await Session.Audit(store,c,"Issued a staff invitation link");return Results.Ok(result);
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
if(!Access.CanManage(actor,role))return Results.Forbid();
var result=await accounts.Invite(Session.Hotel(c),input with{Role=role});await Session.Audit(store,c,"Issued a team invitation");return Results.Ok(Delivery(result,preview));
}).RequireRateLimiting("accounts");
team.MapPost("/{id}/{action}",async(string id,string action,VersionInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();
if(user.Role!="Staff")return Results.BadRequest(new {error="Owner accounts are managed by the server administrator."});
var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";if(!Access.CanManage(actor,user.Role))return Results.Forbid();
if(action is "reset" or "restore")
{
var result=action=="reset"?await accounts.ResetStaff(user,input.Version):await accounts.Restore(user,input.Version);
await Session.Audit(store,c,action=="reset"?"Issued a staff password recovery link":"Issued a staff restoration link");return Results.Ok(result);
await Session.Audit(store,c,action=="reset"?"Issued a team password recovery":"Issued a team restoration");return Results.Ok(Delivery(result,preview));
}
if(action=="mfa-reset")
{
if(Access.NormalizeRole(actor)!=Access.Owner)return Results.Forbid();if(!await accounts.ResetMfa(user,input.Version))return Input.Conflict();await Session.Audit(store,c,"Reset team MFA after identity verification");return Results.Ok();
}
if(action is not ("disable" or "revoke"))return Results.NotFound();
if(!(action=="disable"?await accounts.Disable(user,input.Version):await accounts.Revoke(user,input.Version)))return Input.Conflict();
await Session.Audit(store,c,action=="disable"?"Disabled a staff account":"Revoked a staff account link");return Results.Ok();
await Session.Audit(store,c,action=="disable"?"Disabled a team account":"Revoked a team account link");return Results.Ok();
}).RequireRateLimiting("accounts");
team.MapPut("/{id}/role",async(string id,RoleInput input,HttpContext c,IStore store,TeamAccounts accounts)=>
{
var user=await store.Get<StaffUser>(Session.Hotel(c),id);if(user==null)return Results.NotFound();var actor=c.User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)??"";var role=Access.NormalizeRole(input.Role);
if(!Access.CanManage(actor,user.Role)||!Access.CanManage(actor,role))return Results.Forbid();if(!await accounts.ChangeRole(user,input.Version,role))return Input.Conflict();await Session.Audit(store,c,"Changed a team role to "+role);return Results.Ok(TeamAccounts.View(user));
});
api.MapGet("/onboarding",async(HttpContext c,IStore store)=>
{
var id=Session.Hotel(c);var hotel=await store.Get<Hotel>(id,id);
@ -44,10 +57,11 @@ public static class TeamEndpoints
new {title="Check your hotel details",detail="Review the hotel name, timezone and email signature.",path="/settings",complete=hotel!=null&&hotel.Name.Length>=2&&hotel.Signature.Length>0,optional=false},
new {title="Approve your guest answers",detail="Add current check-in, parking and breakfast information.",path="/knowledge",complete=knowledge.Any(x=>x.Approved),optional=false},
new {title="Connect the hotel mailbox",detail="Connect Google and check that guest messages appear in the inbox.",path="/settings",complete=mailboxes.Any(x=>x.Status=="Connected"&&x.LastSyncAt!=null),optional=false},
new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>x.Role=="Staff"&&x.Active),optional=true},
new {title="Invite your team",detail="Give each colleague their own account. Owners keep control of integrations and automation.",path="/team",complete=users.Any(x=>Access.NormalizeRole(x.Role)!=Access.Owner&&x.Active),optional=true},
new {title="Test FAQ automation",detail="Review test results before enabling live replies. This checklist does not enable sending.",path="/automation",complete=hotel?.AutoReplyMode=="Test"||hotel?.AutoReplyMode=="Live",optional=true}
}});
}).RequireAuthorization("Owner");
}).RequireAuthorization(Access.HotelSettings);
}
}
public sealed record RoleInput(string Role,long Version);

View File

@ -14,12 +14,36 @@ builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web").PersistK
builder.Services.AddHttpClient<GoogleMailbox>(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
builder.Services.AddTransient<ReplyDelivery>();
builder.Services.AddTransient<AutoReplyWork>();
builder.Services.AddTransient<AccountMailService>();
builder.Services.AddTransient<IAccountMailTransport,SmtpAccountMailTransport>();
builder.Services.AddTransient<AccountMailProcessor>();
builder.Services.AddHostedService<AccountMailWorker>();
builder.Services.AddHostedService<AutoReplyWorker>();
builder.Services.AddHostedService<DeliveryWorker>();
builder.Services.AddHostedService<MailboxWorker>();
builder.Services.AddHostedService<HeartbeatWorker>();
await builder.Build().RunAsync();
sealed class AccountMailWorker(IStore store,IServiceScopeFactory factory,ILogger<AccountMailWorker> log):BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while(!stoppingToken.IsCancellationRequested)
{
try
{
foreach(var item in await store.AccountMails())
{
using var scope=factory.CreateScope();using var deadline=CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);deadline.CancelAfter(TimeSpan.FromMinutes(1));
await scope.ServiceProvider.GetRequiredService<AccountMailProcessor>().Process(item,deadline.Token);
}
}
catch(Exception ex) when(!stoppingToken.IsCancellationRequested){log.LogWarning("Account mail cycle paused ({Type})",ex.GetType().Name);}
await Task.Delay(TimeSpan.FromSeconds(10),stoppingToken);
}
}
}
sealed class HeartbeatWorker(IStore store,ILogger<HeartbeatWorker> log):BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)

View File

@ -0,0 +1,49 @@
using GuestOps.Web;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using System.Net.Mail;
public static class IdentitySecurityTests
{
sealed class FakeTransport(Func<int,Exception?> outcome):IAccountMailTransport
{
public int Calls;
public Task Send(AccountMailPayload payload,string messageId,CancellationToken cancellationToken){Calls++;var error=outcome(Calls);return error==null?Task.CompletedTask:Task.FromException(error);}
}
public static async Task Run(Action<string,bool> check,IStore store)
{
var secret=System.Text.Encoding.ASCII.GetBytes("12345678901234567890");
check("TOTP matches RFC 6238 SHA1 vector truncated to six digits",Totp.Code(secret,1)=="287082"&&Totp.Code(secret,37037036)=="081804");
var now=DateTimeOffset.FromUnixTimeSeconds(1_234_567_890).UtcDateTime;var step=Totp.Step(now);var current=Totp.Code(secret,step);
check("TOTP accepts a current code",Totp.Verify(secret,current,now,null)==step);
check("TOTP accepts the approved one-step clock window",Totp.Verify(secret,Totp.Code(secret,step-1),now,null)==step-1&&Totp.Verify(secret,Totp.Code(secret,step+1),now,null)==step+1);
check("TOTP rejects malformed and out-of-window codes",Totp.Verify(secret,"12345x",now,null)==null&&Totp.Verify(secret,Totp.Code(secret,step+2),now,null)==null);
check("TOTP rejects replayed time steps",Totp.Verify(secret,current,now,step)==null);
check("Base32 secret round-trips",Totp.Decode(Totp.Encode(secret)).SequenceEqual(secret));
check("Legacy Staff role has Agent permissions",Access.NormalizeRole("Staff")==Access.Agent&&Access.Permissions("Staff").Contains(Access.Inbox)&&!Access.Permissions("Staff").Contains(Access.Team));
check("Auditor cannot access guest workflows",Access.Permissions(Access.Auditor).Contains(Access.Audit)&&!Access.Permissions(Access.Auditor).Contains(Access.Inbox));
check("Managers cannot manage Managers",Access.CanManage(Access.Manager,Access.Agent)&&Access.CanManage(Access.Manager,Access.Auditor)&&!Access.CanManage(Access.Manager,Access.Manager));
var protection=new EphemeralDataProtectionProvider();var hotel=Guid.NewGuid().ToString("N");var user=new StaffUser{HotelId=hotel,Email=hotel+"@example.invalid",Name="MFA user",SecurityStamp="initial",Role=Access.Agent};await store.Insert(user);
var mfa=new MfaService(store,protection);var enrollment=await mfa.Begin(user);var enrollmentSecret=Totp.Decode(enrollment.Secret);user=(await store.Get<StaffUser>(hotel,user.Id))!;var code=Totp.Code(enrollmentSecret,Totp.Step(DateTime.UtcNow));
var attempts=await Task.WhenAll(Enumerable.Range(0,4).Select(async _=>{var copy=(await store.Get<StaffUser>(hotel,user.Id))!;return await mfa.Enable(copy,code);}));
check("Concurrent MFA enrollment verification succeeds once",attempts.Count(x=>x!=null)==1);
var codes=attempts.Single(x=>x!=null)!;user=(await store.Get<StaffUser>(hotel,user.Id))!;
check("MFA stores protected secret and hashed recovery codes",user.MfaSecretProtected.Length>0&&!user.MfaSecretProtected.Contains(enrollment.Secret)&&user.RecoveryCodeHashes.Length==10&&codes.All(x=>!user.RecoveryCodeHashes.Contains(x)));
var used=await mfa.UseRecovery(user,codes[0]);check("Recovery code is consumed once",used!=null&&await mfa.UseRecovery((await store.Get<StaffUser>(hotel,user.Id))!,codes[0])==null);
var mailConfig=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?>{{"Preview","false"}}).Build();var mail=new AccountMailService(store,protection,mailConfig);
var queued=await mail.Queue(user,"Test","Security notice","Safe body","https://example.invalid/private-token",DateTime.UtcNow.AddMinutes(5));
check("Account mail protects address body and token at rest",!queued.ProtectedPayload.Contains(user.Email)&&!queued.ProtectedPayload.Contains("private-token")&&queued.MessageId.StartsWith('<'));
var success=new FakeTransport(_=>null);await new AccountMailProcessor(store,mail,success,NullLogger<AccountMailProcessor>.Instance).Process(queued,CancellationToken.None);
check("Account mail completes one claimed delivery",(await store.Get<AccountMail>(hotel,queued.Id))?.State=="Sent"&&success.Calls==1);
var ambiguous=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var uncertainTransport=new FakeTransport(_=>new SmtpException("ambiguous"));
await new AccountMailProcessor(store,mail,uncertainTransport,NullLogger<AccountMailProcessor>.Instance).Process(ambiguous,CancellationToken.None);
check("Ambiguous SMTP outcome is never automatically retried",(await store.Get<AccountMail>(hotel,ambiguous.Id))?.State=="NeedsReview");
var transient=await mail.Queue(user,"Test","Notice","Body","",DateTime.UtcNow.AddMinutes(5));var preSubmit=new FakeTransport(_=>new MailPreSubmissionException("not submitted"));
await new AccountMailProcessor(store,mail,preSubmit,NullLogger<AccountMailProcessor>.Instance).Process(transient,CancellationToken.None);var pending=await store.Get<AccountMail>(hotel,transient.Id);
check("Clearly pre-submission mail failure receives bounded retry",pending?.State=="Pending"&&pending.AttemptCount==1&&pending.NextAttemptAt>DateTime.UtcNow);
check("Preference filters are fixed presets",IdentityEndpoints.ValidFilter("DraftReady")&&!IdentityEndpoints.ValidFilter("OwnerOnly"));
}
}

View File

@ -23,7 +23,8 @@ try
await store.Ping();
if(uri!=null){await store.RecordWorkerHeartbeat();Check("Worker heartbeat persists in MongoDB",await store.WorkerLastSeen()>DateTime.UtcNow.AddMinutes(-1));}
await MailboxTests.Run(Check, store);
await TeamTests.Run(Check, store);
await TeamTests.Run(Check, store);
await IdentitySecurityTests.Run(Check, store);
await ReplyTests.Run(Check, store);
await PmsTests.Run(Check, store);
await PaymentTests.Run(Check, store);

View File

@ -14,7 +14,7 @@ public static class TeamTests
var hasher=new PasswordHasher<StaffUser>();var service=new TeamAccounts(store,hasher,config);
var link=await service.Invite(hotel,new("Test Colleague",email));var token=Token(link);
var user=(await store.Get<StaffUser>(hotel,link.UserId))!;
check("Invitation stores hash and creates inactive Staff only",user.Role=="Staff"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64);
check("Invitation stores hash and creates inactive Agent only",user.Role=="Agent"&&!user.Active&&user.PasswordHash==""&&user.AccountLinkHash!=token&&user.AccountLinkHash.Length==64);
check("Account link uses configured HTTPS origin and fragment",link.Link.StartsWith("https://hotel.example.invalid/account#token=")&&!link.Link.Contains('?'));
check("Token inspection rejects malformed token",await service.Inspect("bad")==null);
bool denied=false;try{await service.Invite("foreign",new("Other Colleague",email));}catch(AccountInvalid){denied=true;}check("Invitation cannot claim another hotel's account",denied);

View File

@ -1,4 +1,4 @@
"""Exercise disposable CI containers through the host's trusted proxy address.
"""Exercise disposable deployment containers through the host's trusted proxy address.
The forwarded HTTPS header simulates Nginx TLS termination; this is never run
against an existing hotel database. Cookie values and credentials are not logged.
@ -66,12 +66,12 @@ team = request("/api/team")
assert all("passwordHash" not in member and "securityStamp" not in member and "accountLinkHash" not in member for member in team)
if "--read" in sys.argv:
assert hotel["signature"] == "Persisted across container restart"
invited = next(member for member in team if member["email"] == "ci-staff@example.invalid")
invited = next(member for member in team if member["email"] == "deployment-staff@example.invalid")
assert invited["pending"] and not invited["active"] and invited["linkPurpose"] == "Invite"
else:
hotel["signature"] = "Persisted across container restart"
request("/api/hotel", "PUT", hotel)
invite = request("/api/team/invite", "POST", {"name": "CI Staff", "email": "ci-staff@example.invalid"})
invite = request("/api/team/invite", "POST", {"name": "Deployment Staff", "email": "deployment-staff@example.invalid"})
assert invite["link"].startswith("https://sandbox-guestops.futuresens.co.uk/account#token=")
request("/api/auth/logout", "POST")
request("/api/hotel", expected=401)

View File

@ -0,0 +1,30 @@
import copy
import importlib.util
import json
from pathlib import Path
import unittest
ROOT=Path(__file__).resolve().parents[1]
spec=importlib.util.spec_from_file_location("account_security_acceptance",ROOT/"deploy"/"account_security_acceptance.py")
validator=importlib.util.module_from_spec(spec);spec.loader.exec_module(validator)
def valid_record():
record=json.loads((ROOT/"deploy"/"account-security-acceptance.example.json").read_text(encoding="utf-8"))
record.update(operator="Operator Name",reviewedBy="Independent Reviewer",securityReviewedBy="Security Reviewer",securityReviewEvidence=["restricted/security-review-001"])
record["productionControls"]["enabledAfterSecurityReview"]=True
for scenario in record["scenarios"]:scenario.update(status="pass",evidence=["restricted/"+scenario["id"]])
return record
class AccountSecurityAcceptanceTests(unittest.TestCase):
def test_complete_record_passes(self): validator.validate(valid_record())
def test_missing_scenario_fails(self):
record=valid_record();record["scenarios"].pop()
with self.assertRaisesRegex(ValueError,"exact milestone 19 scenario"):validator.validate(record)
def test_review_must_be_independent(self):
record=valid_record();record["securityReviewedBy"]=record["operator"]
with self.assertRaisesRegex(ValueError,"different people"):validator.validate(record)
def test_evidence_rejects_contact_data(self):
record=valid_record();record["scenarios"][0]["evidence"]=["person@example.invalid"]
with self.assertRaisesRegex(ValueError,"opaque evidence"):validator.validate(record)
if __name__=="__main__":unittest.main()

View File

@ -0,0 +1,129 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"backup_restore_acceptance",
Path(__file__).resolve().parents[1] / "deploy" / "backup_restore_acceptance.py")
acceptance = importlib.util.module_from_spec(spec)
spec.loader.exec_module(acceptance)
COMMIT = "a" * 40
RELEASE_SHA = "b" * 64
def valid_record():
return {
"schemaVersion": 1,
"system": "guestops-backup-recovery",
"evidenceId": "backup-restore",
"dataClassification": "synthetic-only",
"releaseVersion": "0.2.0",
"releaseCommit": COMMIT,
"releaseRecordSha256": RELEASE_SHA,
"archiveSha256": "c" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk",
"hostIdentifier": "guestops-sandbox-01",
"images": {
"api": {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "d" * 64},
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "e" * 64},
"mongo": {"reference": "mongo:8.0", "id": "sha256:" + "f" * 64},
},
"owners": {
"backup": "Backup owner",
"monitoring": "Monitoring owner",
"recoveryOperator": "Recovery operator",
"technicalEscalation": "Technical owner",
"retention": "Retention owner",
"independentReviewer": "Independent reviewer",
},
"startedAt": "2026-10-01T09:00:00Z",
"endedAt": "2026-10-01T12:00:00Z",
"reviewedAt": "2026-10-01T13:00:00Z",
"recoveryObjectives": {
"targetRpoHours": 24, "observedRpoHours": 1,
"targetRtoMinutes": 240, "observedRtoMinutes": 180,
},
"retention": {
"localVerifiedDays": 7, "offHostDaily": 35,
"offHostMonthly": 12, "legalHoldOverrideTested": True,
},
"backup": {
"createdAt": "2026-10-01T08:00:00Z",
"sha256": "1" * 64,
"transferredSha256": "1" * 64,
"privateKeyPresentOnHost": False,
},
"rollback": {
"previousReleaseCommit": "2" * 40,
"previousArchiveSha256": "3" * 64,
"previousImages": {
"api": {"reference": "guestops-api:" + "2" * 40, "id": "sha256:" + "4" * 64},
"worker": {"reference": "guestops-worker:" + "2" * 40, "id": "sha256:" + "5" * 64},
},
"persistentVolumesReplaced": False,
"restoredReleaseCommit": COMMIT,
"unresolvedOperations": 0,
"finalControls": {
"googleSending": "disabled", "faqLiveMode": "disabled",
"pmsWrites": "disabled", "paymentCreation": "disabled",
},
},
"monitoringState": "healthy",
"unresolvedCriticalFindings": 0,
"scenarios": [
{"id": scenario, "status": "pass", "evidence": [f"restricted-{index}"]}
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
],
}
class BackupRestoreAcceptanceTests(unittest.TestCase):
def test_complete_record_passes(self):
acceptance.validate(valid_record(), COMMIT, RELEASE_SHA)
def test_release_identity_and_images_are_bound(self):
record = valid_record(); record["releaseCommit"] = "9" * 40
with self.assertRaisesRegex(ValueError, "approved candidate"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["images"]["api"]["reference"] = "guestops-api:latest"
with self.assertRaisesRegex(ValueError, "full approved commit"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["rollback"]["previousImages"]["worker"]["id"] = "mutable"
with self.assertRaisesRegex(ValueError, "retained previous release identity"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
def test_recovery_objectives_and_checksums_must_pass(self):
record = valid_record(); record["recoveryObjectives"]["observedRtoMinutes"] = 241
with self.assertRaisesRegex(ValueError, "four-hour"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["backup"]["transferredSha256"] = "9" * 64
with self.assertRaisesRegex(ValueError, "checksums must match"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["recoveryObjectives"]["observedRpoHours"] = 2
with self.assertRaisesRegex(ValueError, "match the backup"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
def test_independent_review_retention_and_final_state_are_required(self):
record = valid_record(); record["owners"]["independentReviewer"] = record["owners"]["backup"]
with self.assertRaisesRegex(ValueError, "different"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["retention"]["legalHoldOverrideTested"] = False
with self.assertRaisesRegex(ValueError, "Retention"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["rollback"]["persistentVolumesReplaced"] = True
with self.assertRaisesRegex(ValueError, "must not replace"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
def test_exact_passed_scenarios_and_monitoring_are_required(self):
record = valid_record(); record["scenarios"].pop()
with self.assertRaisesRegex(ValueError, "exact backup/recovery scenario"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
record = valid_record(); record["monitoringState"] = "degraded"
with self.assertRaisesRegex(ValueError, "Monitoring must be healthy"):
acceptance.validate(record, COMMIT, RELEASE_SHA)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,81 @@
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import time
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"backup_transfer", Path(__file__).resolve().parents[1] / "deploy" / "backup_transfer.py")
transfer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(transfer)
class BackupTransferTests(unittest.TestCase):
def backup(self, directory: Path, name="guestops-20261001T021700Z.tar.gpg") -> Path:
path = directory / name
path.write_bytes(b"encrypted-backup")
return path
def config(self, directory: Path):
return {
"directory": directory, "host": "store.example.invalid", "user": "guestops_upload",
"remote": "/restricted/guestops", "identity": Path("/safe/key"),
"known_hosts": Path("/safe/known_hosts"),
}
def test_existing_matching_remote_is_marked_without_upload(self):
with tempfile.TemporaryDirectory() as folder:
backup = self.backup(Path(folder)); checksum = transfer.digest(backup)
with patch.object(transfer, "remote_digest", return_value=checksum), \
patch.object(transfer, "run") as run:
transfer.transfer_one(self.config(Path(folder)), backup)
run.assert_not_called()
marker = json.loads(transfer.marker_path(backup).read_text(encoding="utf-8"))
self.assertEqual(marker["sha256"], checksum)
def test_existing_mismatched_remote_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as folder:
backup = self.backup(Path(folder))
with patch.object(transfer, "remote_digest", return_value="9" * 64), \
patch.object(transfer, "run") as run:
with self.assertRaisesRegex(RuntimeError, "different checksum"):
transfer.transfer_one(self.config(Path(folder)), backup)
run.assert_not_called()
self.assertFalse(transfer.marker_path(backup).exists())
def test_new_remote_is_uploaded_verified_and_marked(self):
with tempfile.TemporaryDirectory() as folder:
backup = self.backup(Path(folder)); checksum = transfer.digest(backup)
with patch.object(transfer, "remote_digest", side_effect=[None, checksum, checksum]), \
patch.object(transfer, "run", return_value=b"") as run, \
patch.object(transfer.subprocess, "run"):
transfer.transfer_one(self.config(Path(folder)), backup)
self.assertTrue(any(call.args[0][0] == "rsync" for call in run.call_args_list))
self.assertTrue(transfer.marker_path(backup).exists())
def test_prune_removes_only_old_checksum_verified_backups(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
verified = self.backup(directory)
checksum = transfer.digest(verified)
transfer.write_marker(verified, checksum)
unverified = self.backup(directory, "guestops-20261002T021700Z.tar.gpg")
old = time.time() - 8 * 86400
os.utime(verified, (old, old)); os.utime(unverified, (old, old))
removed = transfer.prune_verified(self.config(directory), 7, now=time.time())
self.assertEqual(removed, 1)
self.assertFalse(verified.exists())
self.assertTrue(unverified.exists())
def test_retention_bounds_are_enforced(self):
with tempfile.TemporaryDirectory() as folder:
with self.assertRaisesRegex(RuntimeError, "between 1 and 365"):
transfer.prune_verified(self.config(Path(folder)), 0)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,113 @@
import importlib.util
from pathlib import Path
import unittest
spec = importlib.util.spec_from_file_location(
"debian_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "debian_acceptance.py")
acceptance = importlib.util.module_from_spec(spec)
spec.loader.exec_module(acceptance)
COMMIT = "a" * 40
RELEASE_SHA = "b" * 64
def common(system):
return {
"schemaVersion": 1,
"system": system,
"evidenceId": acceptance.SYSTEMS[system]["evidenceId"],
"releaseVersion": "0.2.0",
"releaseCommit": COMMIT,
"releaseRecordSha256": RELEASE_SHA,
"archiveSha256": "c" * 64,
"environment": "https://sandbox-guestops.futuresens.co.uk",
"hostIdentifier": "guestops-sandbox-01",
"images": {
"api": {"reference": f"guestops-api:{COMMIT}", "id": "sha256:" + "d" * 64},
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": "sha256:" + "e" * 64},
},
"operator": "Deployment operator",
"reviewedBy": "Independent reviewer",
"startedAt": "2026-09-30T09:00:00Z",
"endedAt": "2026-09-30T10:00:00Z",
"reviewedAt": "2026-09-30T11:00:00Z",
"unresolvedCriticalFindings": 0,
"scenarios": [
{"id": scenario, "status": "pass", "evidence": [f"restricted-{index}"]}
for index, scenario in enumerate(sorted(acceptance.SYSTEMS[system]["scenarios"]), 1)
],
}
def valid_records():
host = common("guestops-debian-host")
host["hostFacts"] = {
"debianMajor": 12,
"cpuCores": 4,
"memoryBytes": 8_140_382_208,
"freeDiskBytes": 14_275_686_400,
"publicTcpPorts": [80, 443],
}
host["featureControls"] = {
"googleSending": "disabled",
"faqLiveMode": "disabled",
"pmsWrites": "disabled",
"paymentCreation": "disabled",
}
persistence = common("guestops-persistence")
persistence["drillCommand"] = "python3 deploy/ops.py persistence-drill --confirm-restart"
return host, persistence
class DebianAcceptanceTests(unittest.TestCase):
def test_complete_matching_records_pass(self):
acceptance.validate_pair(*valid_records(), COMMIT, RELEASE_SHA)
def test_expected_release_identity_is_required(self):
host, persistence = valid_records()
host["releaseCommit"] = "f" * 40
with self.assertRaisesRegex(ValueError, "approved candidate"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
host, persistence = valid_records()
persistence["releaseRecordSha256"] = "f" * 64
with self.assertRaisesRegex(ValueError, "retained release record"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
def test_exact_images_and_cross_record_identity_are_required(self):
host, persistence = valid_records()
host["images"]["api"]["reference"] = "guestops-api:latest"
with self.assertRaisesRegex(ValueError, "full approved commit"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
host, persistence = valid_records()
persistence["archiveSha256"] = "f" * 64
with self.assertRaisesRegex(ValueError, "same archiveSha256"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
def test_host_capacity_ports_and_disabled_controls_are_required(self):
host, persistence = valid_records()
host["hostFacts"]["publicTcpPorts"] = [80, 443, 8080]
with self.assertRaisesRegex(ValueError, "Only TCP ports"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
host, persistence = valid_records()
host["featureControls"]["googleSending"] = "enabled"
with self.assertRaisesRegex(ValueError, "must remain disabled"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
def test_independent_review_scenarios_and_findings_are_required(self):
host, persistence = valid_records()
host["reviewedBy"] = host["operator"]
with self.assertRaisesRegex(ValueError, "different people"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
host, persistence = valid_records()
persistence["scenarios"][0]["status"] = "not-run"
with self.assertRaisesRegex(ValueError, "has not passed"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
host, persistence = valid_records()
host["unresolvedCriticalFindings"] = 1
with self.assertRaisesRegex(ValueError, "critical findings"):
acceptance.validate_pair(host, persistence, COMMIT, RELEASE_SHA)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,75 @@
import datetime as dt
import importlib.util
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"monitor_status", Path(__file__).resolve().parents[1] / "deploy" / "monitor_status.py")
monitor = importlib.util.module_from_spec(spec)
spec.loader.exec_module(monitor)
class MonitorStatusTests(unittest.TestCase):
def test_compose_json_is_reduced_to_safe_state(self):
value = json.dumps([
{"Service": "api", "State": "running", "Health": "healthy", "Publishers": "secret"},
{"Service": "worker", "State": "running", "Health": ""},
{"Service": "mongo", "State": "running", "Health": "healthy"},
]).encode()
self.assertEqual(monitor.parse_compose(value)["api"], {"state": "running", "health": "healthy"})
self.assertNotIn("Publishers", monitor.parse_compose(value)["api"])
def test_age_ignores_symlinks_and_unexpected_files(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
backup = directory / "guestops-20261001T021700Z.tar.gpg"
backup.write_bytes(b"fixture")
moment = dt.datetime(2026, 10, 1, 3, 17, tzinfo=dt.timezone.utc)
os.utime(backup, (moment.timestamp() - 3600, moment.timestamp() - 3600))
(directory / "secret.txt").write_text("ignored", encoding="utf-8")
self.assertEqual(monitor.age_seconds(directory, monitor.BACKUP_NAME, moment), 3600)
def test_build_status_contains_only_aggregate_health(self):
now = dt.datetime(2026, 10, 1, 12, tzinfo=dt.timezone.utc)
compose = json.dumps([
{"Service": name, "State": "running", "Health": "healthy"}
for name in ("api", "worker", "mongo")
]).encode()
usage = type("Usage", (), {"total": 1000, "used": 500, "free": 500})()
with tempfile.TemporaryDirectory() as folder, \
patch.object(monitor, "https_status", return_value={"ready": True, "certificateDaysRemaining": 60}), \
patch.object(monitor, "run", return_value=compose), \
patch.object(monitor, "worker_heartbeat_age", return_value=30), \
patch.object(monitor.shutil, "disk_usage", return_value=usage):
status, errors = monitor.build_status(Path(folder), Path(folder), "https://example.invalid", now)
self.assertEqual(errors, [])
self.assertEqual(status["workerHeartbeatAgeSeconds"], 30)
self.assertEqual(status["diskFreePercent"], 50)
self.assertNotIn("credentials", json.dumps(status).lower())
def test_failed_probes_write_categories_not_exception_details(self):
now = dt.datetime(2026, 10, 1, 12, tzinfo=dt.timezone.utc)
usage = type("Usage", (), {"total": 1000, "used": 500, "free": 500})()
with tempfile.TemporaryDirectory() as folder, \
patch.object(monitor, "https_status", side_effect=RuntimeError("secret value")), \
patch.object(monitor, "run", side_effect=RuntimeError("secret value")), \
patch.object(monitor, "worker_heartbeat_age", side_effect=RuntimeError("secret value")), \
patch.object(monitor.shutil, "disk_usage", return_value=usage):
status, errors = monitor.build_status(Path(folder), Path(folder), "https://example.invalid", now)
self.assertGreaterEqual(len(errors), 3)
self.assertNotIn("secret value", json.dumps(status))
def test_status_output_is_atomic_json(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "status.json"
monitor.write_status(path, {"schemaVersion": 1, "errors": []})
self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["schemaVersion"], 1)
if __name__ == "__main__":
unittest.main()

View File

@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1]
class ReleaseRecordTests(unittest.TestCase):
def test_writes_versions_checksum_and_immutable_image_ids(self):
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "guestops-images.tar.gz"
artifact = Path(directory) / "GuestOps-0.2.0.tar.gz"
output = Path(directory) / "release-record.json"
artifact.write_bytes(b"reviewed image archive")

View File

@ -0,0 +1,87 @@
import hashlib
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("verify_release", ROOT / "deploy" / "verify_release.py")
verify_release = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verify_release)
COMMIT = "a" * 40
API_ID = "sha256:" + "b" * 64
WORKER_ID = "sha256:" + "c" * 64
class VerifyReleaseTests(unittest.TestCase):
def fixture(self, directory: str):
root = Path(directory)
archive = root / "GuestOps-0.2.0.tar.gz"
record = root / "release-record.json"
archive.write_bytes(b"reviewed image archive")
release = {
"schemaVersion": 1,
"version": "0.2.0",
"commit": COMMIT,
"artifact": {
"name": archive.name,
"size": archive.stat().st_size,
"sha256": hashlib.sha256(archive.read_bytes()).hexdigest(),
},
"images": {
"api": {"reference": f"guestops-api:{COMMIT}", "id": API_ID},
"worker": {"reference": f"guestops-worker:{COMMIT}", "id": WORKER_ID},
},
}
record.write_text(json.dumps(release), encoding="utf-8")
return archive, record, release
def test_verifies_candidate_archive_record_and_record_checksum(self):
with tempfile.TemporaryDirectory() as directory:
archive, record, _ = self.fixture(directory)
result = verify_release.validate(archive, record, COMMIT, "0.2.0")
self.assertTrue(result["verified"])
self.assertEqual(result["archive"]["sha256"], hashlib.sha256(archive.read_bytes()).hexdigest())
self.assertEqual(result["releaseRecord"]["sha256"], hashlib.sha256(record.read_bytes()).hexdigest())
self.assertFalse(result["loadedImageIdsVerified"])
def test_rejects_changed_archive(self):
with tempfile.TemporaryDirectory() as directory:
archive, record, _ = self.fixture(directory)
archive.write_bytes(b"changed")
with self.assertRaisesRegex(ValueError, "size does not match"):
verify_release.validate(archive, record, COMMIT, "0.2.0")
def test_rejects_wrong_commit_version_reference_and_mutable_id(self):
cases = [
(lambda value: value.update(commit="d" * 40), "commit does not match"),
(lambda value: value.update(version="0.3.0"), "version does not match"),
(lambda value: value["images"]["api"].update(reference="guestops-api:latest"), "full candidate SHA"),
(lambda value: value["images"]["worker"].update(id="worker-image"), "immutable SHA-256"),
]
for mutate, message in cases:
with self.subTest(message=message), tempfile.TemporaryDirectory() as directory:
archive, record, release = self.fixture(directory)
mutate(release)
record.write_text(json.dumps(release), encoding="utf-8")
with self.assertRaisesRegex(ValueError, message):
verify_release.validate(archive, record, COMMIT, "0.2.0")
def test_loaded_image_ids_must_match(self):
with tempfile.TemporaryDirectory() as directory:
archive, record, _ = self.fixture(directory)
with patch.object(verify_release, "loaded_image_id", side_effect=[API_ID, WORKER_ID]):
result = verify_release.validate(archive, record, COMMIT, "0.2.0", True)
self.assertTrue(result["loadedImageIdsVerified"])
with patch.object(verify_release, "loaded_image_id", return_value="sha256:" + "d" * 64):
with self.assertRaisesRegex(ValueError, "Loaded api image ID"):
verify_release.validate(archive, record, COMMIT, "0.2.0", True)
if __name__ == "__main__":
unittest.main()

View File

@ -6,8 +6,8 @@ type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimi
type Decision={matches:boolean;reason:string;body:string};
type Evaluation={total:number;passed:number;falsePositives:number;falseNegatives:number;results:{id:string;passed:boolean;reason:string}[]};
type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null};
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
type Props={hotel:Hotel;owner:boolean;canLive:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function AutomationPage({hotel,owner,canLive,busy,run,onHotel}:Props){
const [status,setStatus]=useState<Status|null>(null),[rules,setRules]=useState<Rule[]>([]),[knowledge,setKnowledge]=useState<Knowledge[]>([]),[history,setHistory]=useState<History[]>([]);
const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState<Decision|null>(null);
const [evaluationText,setEvaluationText]=useState('[\n {"id":"parking-exact","subject":"Parking","body":"Is parking available?","expectedMatch":true},\n {"id":"parking-extra-request","subject":"Parking","body":"Is parking available? Also cancel my booking.","expectedMatch":false}\n]'),[evaluation,setEvaluation]=useState<Evaluation|null>(null);
@ -18,7 +18,7 @@ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){
async function mode(value:string){await run(async()=>{if(value==='Live'&&!window.confirm('Enable automatic FAQ sending for new incoming messages? Confirm that you reviewed test-mode results and accepted Gmail delivery with a sandbox mailbox. Up to 20 replies per hotel per UTC day may be sent.'))return;onHotel(await api<Hotel>('/auto-replies/mode','PUT',{mode:value,version:hotel.version,acceptanceConfirmed:value==='Live'}));});}
async function save(e:React.FormEvent){e.preventDefault();await run(async()=>{const item=await api<Rule>(`/auto-replies/rules/${question}`,'PUT',{question:status!.questions[question],knowledgeId:answer,enabled,version:current?.version||0});setRules(old=>[item,...old.filter(r=>r.id!==item.id)]);setResult(null);});}
return <div className="page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Simple questions, thoughtful answers</span><h1>FAQ automation</h1><p>Start in test mode. Let approved answers handle a small set of straightforward questions.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh results</button></div>
<section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!owner||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section>
<section className="settings-card"><h2>Automation mode: {hotel.autoReplyMode||'Off'}</h2><p>Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.</p><div className="form-actions"><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Off')}>Turn off</button><button className="button secondary" disabled={busy||!owner} onClick={()=>mode('Test')}>Use test mode</button><button className="button primary" disabled={busy||!canLive||!status?.liveConfigured||!hotel.staffSendingEnabled} onClick={()=>mode('Live')}>Enable live replies</button></div><p className="small muted">Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.</p><p className="small muted">Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.</p>{status?.preview&&<p className="staff-note">Sample workspace: the question tester works here. Live sending is disabled.</p>}</section>
<div className="pms-columns"><section className="settings-card"><h2>Review a FAQ rule</h2><form onSubmit={save}><fieldset disabled={busy||!owner}><label>Complete guest question<select value={question} onChange={e=>setQuestion(Number(e.target.value))}>{status?.questions.map((q,i)=><option value={i} key={q}>{q}</option>)}</select></label><label>Approved hotel answer<select value={answer} required onChange={e=>setAnswer(e.target.value)}><option value="">Choose an answer</option>{knowledge.filter(k=>k.approved).map(k=><option value={k.id} key={k.id}>{k.title}</option>)}</select></label>{answer&&<p className="staff-note">{knowledge.find(k=>k.id===answer)?.answer}</p>}<label className="checkbox-label"><input type="checkbox" checked={enabled} onChange={e=>setEnabled(e.target.checked)}/>Enable this exact question and reviewed answer</label><button className="button secondary">Save reviewed rule</button></fieldset></form><p className="small muted">The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.</p></section>
<section className="settings-card"><h2>Try a question</h2><form onSubmit={e=>{e.preventDefault();run(async()=>setResult(await api<Decision>('/auto-replies/test','POST',{subject,body})));}}><fieldset disabled={busy||!owner}><label>Subject<input value={subject} maxLength={200} onChange={e=>setSubject(e.target.value)}/></label><label>Complete message<textarea rows={4} value={body} maxLength={2000} required onChange={e=>setBody(e.target.value)}/></label><button className="button secondary">Check match without sending</button></fieldset></form>{result&&<div role="status" className="staff-note"><strong>{result.matches?'Content matches a reviewed rule':'Keep with staff'}</strong><p>{result.reason}</p>{result.body&&<p>{result.body}</p>}</div>}<p className="small muted">This tester checks content only. Real messages must also pass sender, recipient, age, thread and delivery-limit checks.</p></section></div>
<section className="settings-card"><h2>Recent incoming-message results</h2>{history.length===0?<p>No incoming messages have been evaluated yet. Enable test mode after connecting your mailbox.</p>:<div className="pms-history">{history.map(h=><div className="pms-history-item" key={h.id}><strong>{h.subject}</strong><span>{h.autoReplyDetail}</span><span>{h.delivery?`Delivery: ${h.delivery} · `:''}{hotelTime(h.autoReplyCheckedAt,hotel.timezone)}</span></div>)}</div>}</section>

View File

@ -3,8 +3,8 @@ import { api, type Hotel } from './api';
import { hotelTime } from './time';
type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null};
type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean};
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
type Props={hotel:Hotel;owner:boolean;canPropose:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function PaymentsPage({hotel,owner,canPropose,busy,run,onHotel}:Props){
const [connection,setConnection]=useState<Connection|null>(null),[items,setItems]=useState<Payment[]>([]),[selected,setSelected]=useState<string|null>(null),[approved,setApproved]=useState(false);
const [reference,setReference]=useState(''),[email,setEmail]=useState(''),[description,setDescription]=useState(''),[amount,setAmount]=useState(''),[currency,setCurrency]=useState('GBP');
async function refresh(){const [c,p]=await Promise.all([api<Connection>('/payments/status'),api<Payment[]>('/payments/requests')]);setConnection(c);setItems(p);}
@ -18,7 +18,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
});}
return <div className="page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">A clear request, a clear record</span><h1>Payments</h1><p>Prepare a hosted invoice, review it, and check its status with NMI.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh history</button></div>
<section className="settings-card"><h2>Payment connection</h2><p>{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!canPropose||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}

View File

@ -4,8 +4,8 @@ import { hotelTime } from './time';
type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string};
type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null};
type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
type Props={hotel:Hotel;owner:boolean;canPropose:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
export function PmsPage({hotel,owner,canPropose,busy,run,onHotel}:Props){
const [connection,setConnection]=useState<Connection|null>(null),[changes,setChanges]=useState<Change[]>([]),[confirmation,setConfirmation]=useState(''),[snapshot,setSnapshot]=useState<Snapshot|null>(null);
const [kind,setKind]=useState('AddNote'),[arrival,setArrival]=useState(''),[departure,setDeparture]=useState(''),[note,setNote]=useState(''),[selected,setSelected]=useState<string|null>(null),[checked,setChecked]=useState(false);
async function refresh(){const [status,history]=await Promise.all([api<Connection>('/pms/status'),api<Change[]>('/pms/changes')]);setConnection(status);setChanges(history);}
@ -21,7 +21,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
return <div className="page pms-page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">Booking details, close at hand</span><h1>Reservations</h1><p>Look up a booking and review changes before applying them to your PMS.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh status</button></div>
<section className="settings-card"><h2>OHIP connection</h2><p>{connection?.configured?`Connected configuration for property ${connection.hotelCode}. Lookup will verify access.`:connection?.preview?'Sample workspace: real PMS lookup and updates are disabled.':'Your administrator needs to configure this hotel’s OHIP connection.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section>
<div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form>
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!canPropose}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {hotelTime(c.updatedAt,hotel.timezone)}</span></button>)}</div></section></div>
{current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>}
</div>;

View File

@ -1,18 +1,18 @@
import { useEffect, useState } from 'react';
import { api } from './api';
import { hotelTime } from './time';
type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
type Link={userId:string;link:string;expiresAt:string};
export function TeamPage({owner,timeZone}:{owner:boolean;timeZone:string}) {
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
type Member={id:string;name:string;email:string;role:string;legacyRole:boolean;active:boolean;pending:boolean;mfaEnabled:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
type Link={userId:string;link?:string;deliveryState:string;expiresAt:string};
export function TeamPage({owner,actorRole,timeZone}:{owner:boolean;actorRole:string;timeZone:string}) {
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[role,setRole]=useState('Agent'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
async function refresh(){setMembers(await api<Member[]>('/team'));}
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
async function act(path:string,body:unknown,method='POST'){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,method,body);if(result?.deliveryState)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {hotelTime(link.expiresAt,timeZone)}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section>
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
{link&&<section className="settings-card account-link" aria-label="Account delivery"><h2>{link.link?'Share this preview link privately':'Account email queued'}</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>Delivery state: {link.deliveryState}. Expires {hotelTime(link.expiresAt,timeZone)}.</p>{link.link&&<><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link||'');setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></>}</section>}
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Roles are enforced by the server. Managers may manage Agent and Auditor accounts only.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email,role});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label><label>Role<select value={role} onChange={e=>setRole(e.target.value)}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select></label></div><div className="form-actions"><button className="button primary">Send invitation</button></div></fieldset></form></section>
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=>{const manageable=m.role!=='Owner'&&(actorRole==='Owner'||m.role==='Agent'||m.role==='Auditor');return <article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role}{m.legacyRole?' (legacy Staff)':''} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'} · MFA {m.mfaEnabled?'on':'off'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{manageable&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email,role:m.role})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.active&&<select aria-label={`Role for ${m.name}`} value={m.role} onChange={e=>act(`/team/${m.id}/role`,{version:m.version,role:e.target.value},'PUT')}>{actorRole==='Owner'&&<option>Manager</option>}<option>Agent</option><option>Auditor</option></select>}{actorRole==='Owner'&&m.mfaEnabled&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Reset MFA for ${m.name} after completing identity verification?`))void act(`/team/${m.id}/mfa-reset`,{version:m.version});}}>Reset MFA</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>;})}</div><p className="small muted">New links invalidate earlier links. Password, role, disablement and MFA changes invalidate affected sessions. Owner MFA recovery is available only through the audited server command.</p></section>
</div>;
}
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};

View File

@ -1,5 +1,7 @@
export type User = { id: string; name: string; role: string; hotelId: string };
export type Preferences = { displayTimeZone: string; effectiveDisplayTimeZone: string; defaultInboxFilter: string; version: number };
export type User = { id: string; name: string; role: string; hotelId: string; permissions: string[]; mfaEnabled: boolean; preferences: Preferences };
export type Session = { preview: boolean; csrfToken: string; user: User | null };
export type LoginResult = { mfaRequired: boolean; enrollmentRequired?: boolean };
export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; autoReplyMode: string; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean };
export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; autoReplyDetail: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { automatic: boolean; state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null };
export type ConversationPage = { items: Conversation[]; nextCursor: string | null };
@ -12,7 +14,7 @@ export async function api<T>(path: string, method = 'GET', body?: unknown): Prom
const data = await response.json().catch(() => null);
if (!response.ok) {
if (response.status === 401 && path !== '/auth/login') window.dispatchEvent(new Event('session-expired'));
throw new Error(data?.error || (response.status === 429 ? 'Too many attempts. Please wait a minute.' : response.status === 403 ? 'Only the hotel owner can change this.' : 'We couldn’t complete that request. Please try again.'));
throw new Error(data?.error || (response.status === 429 ? 'Too many attempts. Please wait a minute.' : response.status === 403 ? 'Your role does not allow this action.' : 'We couldn’t complete that request. Please try again.'));
}
return data as T;
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
import { api, session, type Session, type LoginResult, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
import './style.css';
import { OperationsPage } from './OperationsPage';
import { MailboxPanel } from './MailboxPanel';
@ -24,48 +24,60 @@ function App() {
const [conversationCursor,setConversationCursor]=useState<string|null>(null);
const [loaded,setLoaded] = useState(false);
async function refresh() {
const [h,c,k,a,m] = await Promise.all([api<Hotel>('/hotel'),api<ConversationPage>('/conversations/page'),api<Knowledge[]>('/knowledge'),api<Activity[]>('/activity'),api<Mailboxes>('/mailboxes')]);
setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
const user=auth?.user;if(!user)return;const inbox=user.permissions.includes('Inbox'),audit=user.permissions.includes('Audit');
const [h,c,k,a,m]=await Promise.all([inbox?api<Hotel>('/hotel'):Promise.resolve(null),inbox?api<ConversationPage>('/conversations/page'):Promise.resolve(null),inbox?api<Knowledge[]>('/knowledge'):Promise.resolve([]),audit?api<Activity[]>('/activity'):Promise.resolve([]),inbox?api<Mailboxes>('/mailboxes'):Promise.resolve({configured:false,items:[]} as Mailboxes)]);
setHotel(h||{id:user.hotelId,name:'Hotel workspace',timezone:user.preferences.effectiveDisplayTimeZone,signature:'',replyMode:'DraftOnly',version:0,aiDraftsEnabled:false,staffSendingEnabled:false,autoReplyMode:'Off',paymentsEnabled:false,pmsUpdatesEnabled:false});
setConversations(c?.items||[]);setConversationCursor(c?.nextCursor||null);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
}
async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
useEffect(()=>{pageRef.current=page;},[page]);
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>{if(!window.dispatchEvent(new Event('workspace-navigate',{cancelable:true}))){history.pushState({},'',pageRef.current);return;}setPage(location.pathname==='/'?'/inbox':location.pathname);};window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
useEffect(()=>{if(auth?.user&&!auth.user.permissions.includes('Inbox')&&['/','/inbox','/reservations','/payments','/automation','/knowledge','/settings'].includes(page))setPage(auth.user.permissions.includes('Audit')?'/activity':'/health');},[auth?.user?.id,page]);
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
function go(path:string) { if (!window.dispatchEvent(new Event('workspace-navigate', { cancelable:true }))) return; setPage(path);history.pushState({},'',path);setError(''); }
async function login(email:string,password:string) { await run(async()=>{await api('/auth/login','POST',{email,password});setAuth(await session());}); }
async function login(email:string,password:string) { let result:LoginResult={mfaRequired:false};await run(async()=>{result=await api<LoginResult>('/auth/login','POST',{email,password});if(!result.mfaRequired)setAuth(await session());});return result; }
async function preview() { await run(async()=>{await api('/preview/start','POST');setAuth(await session());}); }
async function logout() { await run(async()=>{await api('/auth/logout','POST');setAuth(await session());setHotel(null);setLoaded(false);}); }
const errorBox=error?<div className="alert" role="alert"><CircleHelp size={18}/><span>{error}</span><button className="icon-button" onClick={()=>setError('')} aria-label="Dismiss error"><X size={17}/></button></div>:null;
if(!auth) return <div className="loading"><span className="brand-mark">g</span><p>Opening your workspace…</p>{errorBox}</div>;
if(page==="/account") return <AccountPage/>;
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onPreview={preview} busy={busy} error={errorBox}/>;
const count=conversations.filter(c=>c.status!=='Completed').length;
if(!auth.user) return <Login preview={auth.preview} onLogin={login} onAuthenticated={async()=>setAuth(await session())} onPreview={preview} busy={busy} error={errorBox}/>;
const count=conversations.filter(c=>c.status!=='Completed').length,can=(permission:string)=>auth.user!.permissions.includes(permission);
return <div className="app-shell">
<aside className="sidebar">
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
<div className="nav-label">WORKSPACE</div>
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/payments',label:'Payments',icon:Banknote},{path:'/automation',label:'FAQ automation',icon:ShieldCheck},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings},{path:'/team',label:'Your team',icon:ShieldCheck},{path:'/setup',label:'Hotel setup',icon:CheckCheck},{path:'/health',label:'Workspace health',icon:ActivityIcon}].filter(n=>auth.user?.role==='Owner'||!['/team','/setup','/health'].includes(n.path)).map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox,permission:'Inbox'},{path:'/reservations',label:'Reservations',icon:Building2,permission:'ProviderLookup'},{path:'/payments',label:'Payments',icon:Banknote,permission:'ProviderLookup'},{path:'/automation',label:'FAQ automation',icon:ShieldCheck,permission:'AutomationTest'},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen,permission:'Knowledge'},{path:'/activity',label:'Activity',icon:ActivityIcon,permission:'Audit'},{path:'/settings',label:'Settings',icon:Settings,permission:'HotelSettings'},{path:'/team',label:'Your team',icon:ShieldCheck,permission:'Team'},{path:'/setup',label:'Hotel setup',icon:CheckCheck,permission:'HotelSettings'},{path:'/health',label:'Workspace health',icon:ActivityIcon,permission:'Operations'},{path:'/profile',label:'My account',icon:ShieldCheck,permission:''}].filter(n=>!n.permission||can(n.permission)).map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Your team controls approved answers and reply automation.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
</aside>
<main className="main">
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={can('Operations')} go={go}/>:page==='/team'?<TeamPage owner={can('Team')} actorRole={auth.user.role} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={can('HotelSettings')} go={go}/>:page==='/profile'?<PreferencesPage value={auth.user.preferences} mfaEnabled={auth.user.mfaEnabled} onSaved={async()=>setAuth(await session())}/>:page==='/inbox'?<InboxPage hotel={hotel!} defaultFilter={auth.user.preferences.defaultInboxFilter} displayTimeZone={auth.user.preferences.effectiveDisplayTimeZone} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={can('AutomationTest')} canLive={can('AutomationLive')} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={can('ExternalApproval')} canPropose={can('ProviderProposal')} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={can('ExternalApproval')} canPropose={can('ProviderProposal')} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={can('Knowledge')} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,auth.user!.preferences.effectiveDisplayTimeZone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={can('HotelSettings')} integrations={can('Integrations')} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
</main>
</div>;
}
function PageHeading({eyebrow,title,text}:{eyebrow:string;title:string;text:string}) { return <div className="page-heading"><span className="eyebrow">{eyebrow}</span><h1>{title}</h1><p>{text}</p></div>; }
function Empty({title,text}:{title:string;text:string}) {return <div className="empty"><Mail size={34}/><h2>{title}</h2><p>{text}</p></div>;}
function Login({preview,onLogin,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
const [email,setEmail]=useState(''),[password,setPassword]=useState('');
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>Welcome back</h1><p>Sign in to take care of your guests.</p>{error}<form onSubmit={e=>{e.preventDefault();onLogin(email,password);}}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><p className="small muted">Need access or help signing in? Contact your hotel administrator.</p>{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</div></section></div>;
function Login({preview,onLogin,onAuthenticated,onPreview,busy,error}:{preview:boolean;onLogin:(e:string,p:string)=>Promise<LoginResult>;onAuthenticated:()=>Promise<void>;onPreview:()=>Promise<void>;busy:boolean;error:React.ReactNode}){
const [email,setEmail]=useState(''),[password,setPassword]=useState(''),[stage,setStage]=useState<'password'|'verify'|'enroll'|'codes'>('password'),[code,setCode]=useState(''),[recovery,setRecovery]=useState(false),[secret,setSecret]=useState(''),[uri,setUri]=useState(''),[codes,setCodes]=useState<string[]>([]),[localError,setLocalError]=useState(''),[recovering,setRecovering]=useState(false),[recoverySent,setRecoverySent]=useState(false);
async function passwordLogin(e:React.FormEvent){e.preventDefault();setLocalError('');try{const result=await onLogin(email,password);setPassword('');if(result.mfaRequired){if(result.enrollmentRequired){const enrollment=await api<{secret:string;uri:string}>('/auth/mfa/enroll','POST');setSecret(enrollment.secret);setUri(enrollment.uri);setStage('enroll');}else setStage('verify');}}catch(e){setLocalError(e instanceof Error?e.message:'Sign-in failed.');}}
async function verify(e:React.FormEvent){e.preventDefault();setLocalError('');try{if(stage==='enroll'){const result=await api<{recoveryCodes:string[]}>('/auth/mfa/enroll/verify','POST',{code});setCodes(result.recoveryCodes);setStage('codes');}else{await api(recovery?'/auth/mfa/recovery-code':'/auth/mfa/verify','POST',{code});await onAuthenticated();}}catch(e){setLocalError(e instanceof Error?e.message:'Verification failed.');}}
async function recover(){setLocalError('');try{await api('/auth/recovery','POST',{email});setRecoverySent(true);}catch(e){setLocalError(e instanceof Error?e.message:'Please try again.');}}
return <div className="login-layout"><section className="login-story"><div className="brand"><span className="brand-mark">g</span>guestops.</div><div><span className="eyebrow">A little more time for your guests</span><h1>Great hospitality.<br/>A calmer inbox.</h1><p>Your conversations, hotel knowledge and team.<br/>Together in one thoughtful workspace.</p><div className="login-detail"><Inbox size={24}/><span>Less time sorting emails.<br/><strong>More time making guests feel welcome.</strong></span></div></div><small>Built around the way hotels work.</small></section><section className="login-form"><div><span className="eyebrow">Your hotel workspace</span><h1>{stage==='password'?'Welcome back':stage==='enroll'?'Set up your authenticator':stage==='codes'?'Save your recovery codes':'Verify it’s you'}</h1>{error}{localError&&<div className="alert" role="alert">{localError}</div>}{stage==='password'?<><p>Sign in to take care of your guests.</p><form onSubmit={passwordLogin}><label>Email address<input type="email" autoComplete="username" value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@yourhotel.com" required/></label><label>Password<input type="password" autoComplete="current-password" value={password} onChange={e=>setPassword(e.target.value)} required/></label><button className="button primary wide" disabled={busy}>{busy?'Signing in…':'Sign in'}<ArrowUpRight size={18}/></button></form><button className="button text" onClick={()=>setRecovering(!recovering)}>Forgot your password?</button>{recovering&&<div className="preview-login"><p>Enter your email above. If an active account exists, GuestOps will send a recovery link.</p><button className="button secondary wide" disabled={!email||recoverySent} onClick={recover}>{recoverySent?'Check your email':'Send recovery link'}</button></div>}{preview&&<div className="preview-login"><span>Explore the interface with sample conversations.</span><button className="button secondary wide" onClick={onPreview} disabled={busy}>Open preview workspace<ArrowUpRight size={17}/></button><small>Preview changes are temporary. No real emails are sent.</small></div>}</>:stage==='codes'?<><p>Store these ten single-use codes somewhere secure. They will not be shown again.</p><pre className="staff-note">{codes.join('\n')}</pre><button className="button primary wide" onClick={onAuthenticated}>I saved the codes</button></>:<><p>{stage==='enroll'?<>Add this secret to an authenticator app, then enter its current six-digit code.<br/><strong>{secret}</strong></>:recovery?'Enter one unused recovery code.':'Enter the current six-digit code from your authenticator app.'}</p>{stage==='enroll'&&<details><summary>Authenticator setup URI</summary><code>{uri}</code></details>}<form onSubmit={verify}><label>{recovery?'Recovery code':'Authenticator code'}<input inputMode={recovery?'text':'numeric'} autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)} required/></label><button className="button primary wide">Verify</button></form>{stage==='verify'&&<button className="button text" onClick={()=>{setRecovery(!recovery);setCode('');}}>{recovery?'Use authenticator instead':'Use a recovery code'}</button>}</>}</div></section></div>;
}
function PreferencesPage({value,mfaEnabled,onSaved}:{value:{displayTimeZone:string;effectiveDisplayTimeZone:string;defaultInboxFilter:string;version:number};mfaEnabled:boolean;onSaved:()=>Promise<void>}){
const [form,setForm]=useState(value),[busy,setBusy]=useState(false),[error,setError]=useState(''),[code,setCode]=useState(''),[codes,setCodes]=useState<string[]>([]);
async function save(e:React.FormEvent){e.preventDefault();setBusy(true);setError('');try{await api('/me/preferences','PUT',form);await onSaved();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
async function regenerate(){setBusy(true);setError('');try{const result=await api<{recoveryCodes:string[]}>('/auth/mfa/recovery-codes','POST',{code});setCodes(result.recoveryCodes);setCode('');await onSaved();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
return <div className="page settings-page"><PageHeading eyebrow="Your workspace, your way" title="My account" text="Choose how dates and the inbox appear for you."/>{error&&<div className="alert" role="alert">{error}</div>}<section className="settings-card"><h2>Preferences</h2><form onSubmit={save}><div className="form-grid"><label>Display timezone<select value={form.displayTimeZone} onChange={e=>setForm({...form,displayTimeZone:e.target.value})}><option value="">Use hotel timezone ({form.effectiveDisplayTimeZone})</option>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label><label>Default inbox filter<select value={form.defaultInboxFilter} onChange={e=>setForm({...form,defaultInboxFilter:e.target.value})}>{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts ready'],['Completed','Completed']].map(([key,label])=><option value={key} key={key}>{label}</option>)}</select></label></div><button className="button primary" disabled={busy}>Save preferences</button></form></section><section className="settings-card"><h2>Multi-factor authentication</h2><p>{mfaEnabled?'Your account is protected by an authenticator and recovery codes.':'MFA is not enrolled. When enforcement is enabled, sign-in will guide you through enrollment.'}</p>{mfaEnabled&&<><label>Current authenticator code<input inputMode="numeric" autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)}/></label><button className="button secondary" disabled={busy||code.length!==6} onClick={regenerate}>Generate new recovery codes</button></>}{codes.length>0&&<><p>Store these codes securely. Earlier recovery codes are now invalid.</p><pre className="staff-note">{codes.join('\n')}</pre></>}</section></div>;
}
type Run=(a:()=>Promise<void>)=>Promise<void>;
function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
const [filter,setFilter]=useState('All'),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
function InboxPage({hotel,defaultFilter,displayTimeZone,mailboxes,conversations,hasMore,loadMore,knowledge,busy,run,onUpdate,notify,go}:{hotel:Hotel;defaultFilter:string;displayTimeZone:string;mailboxes:Mailboxes;conversations:Conversation[];hasMore:boolean;loadMore:()=>void;knowledge:Knowledge[];busy:boolean;run:Run;onUpdate:(c:Conversation)=>void;notify:(s:string)=>void;go:(s:string)=>void}){
const [filter,setFilter]=useState(defaultFilter),[search,setSearch]=useState(''),[selected,setSelected]=useState<string|null>(null),[draft,setDraft]=useState(''),[mobileDetail,setMobileDetail]=useState(false);
const filtered=conversations.filter(c=>(filter==='All'||c.status===filter)&&(c.subject+' '+c.from+' '+c.body).toLowerCase().includes(search.toLowerCase()));
const current=filtered.find(c=>c.id===selected)||filtered[0];
useEffect(()=>{setDraft(current?.draft||'');},[current?.id,current?.draft]);
@ -76,8 +88,8 @@ function InboxPage({hotel,mailboxes,conversations,hasMore,loadMore,knowledge,bus
async function resolve() {if(!current)return;await run(async()=>{onUpdate(await api<Conversation>(`/conversations/${current.id}/status`,'PUT',{status:current.status==='Completed'?'NeedsAttention':'Completed',version:current.version}));notify(current.status==='Completed'?'Conversation reopened.':'Conversation marked completed.');});}
return <div className="inbox-page"><div className="inbox-heading"><PageHeading eyebrow="A warm welcome starts here" title="Your guest inbox" text={needs?`${needs} conversations need your attention. Let's make their day.`:'A little space to focus on your guests.'}/><div className="mini-stats"><div><strong>{needs}</strong><span>Need attention</span></div><div><strong>{ready}</strong><span>Drafts ready</span></div></div></div>
<div className="inbox-toolbar"><div className="tabs" role="group" aria-label="Filter conversations">{[['All','All messages'],['NeedsAttention','Needs attention'],['DraftReady','Drafts'],['Completed','Completed']].map(([key,label])=><button key={key} className={filter===key?'tab selected':'tab'} onClick={()=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setFilter(key);}}>{label}{key==='NeedsAttention'&&needs>0&&<span>{needs}</span>}</button>)}</div><label className="search"><Search size={17}/><input aria-label="Search conversations" placeholder="Search loaded messages…" value={search} onChange={e=>{if(current&&draft!==current.draft&&!window.confirm('Discard your unsaved draft changes?'))return;setSearch(e.target.value);}}/></label></div>
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} loaded conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{timeZone:hotel.timezone,hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</button>}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&&current.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt,hotel.timezone)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
{!conversations.length?<div className="onboard-empty"><Empty title="A calmer inbox starts here" text="Connect your hotel's Google mailbox to bring recent guest conversations into GuestOps."/><button className="button primary" onClick={()=>go('/settings')}>Connect your mailbox<ArrowUpRight size={17}/></button></div>:<div className={'inbox-panels '+(mobileDetail?'show-detail':'')}><section className="message-list" aria-label="Conversations"><div className="list-title">{filtered.length} loaded conversation{filtered.length===1?'':'s'}<span>Newest first<ChevronDown size={13}/></span></div>{filtered.map((c,i)=><button key={c.id} className={'message-card '+(current?.id===c.id?'current':'')} onClick={()=>select(c)}><div className="message-top"><span className={'avatar tone-'+i%4}>{initials(sender(c.from))}</span><strong>{sender(c.from)}</strong><time>{new Date(c.receivedAt).toLocaleTimeString([],{timeZone:displayTimeZone,hour:'2-digit',minute:'2-digit'})}</time></div><h3>{c.subject}</h3><p>{c.body}</p><div className="message-bottom"><span className={'status '+c.status}>{c.status==='DraftReady'?<FileText size={12}/>:c.status==='Completed'?<CheckCheck size={12}/>:<Clock3 size={12}/>} {labels[c.status]}</span><span>{c.category}</span></div></button>)}{hasMore&&<button className="button secondary wide" disabled={busy} onClick={loadMore}>Load 50 older conversations</button>}{!filtered.length&&<Empty title="Nothing here just now" text="Try another filter or search."/>}</section>
<section className="conversation" aria-label="Selected conversation">{current?<><div className="conversation-tools"><button className="button text mobile-back" onClick={()=>setMobileDetail(false)}><ArrowLeft size={16}/>Back</button><span className="category-label">{current.category}</span><button className="button secondary compact" onClick={resolve} disabled={busy||!!current.delivery&&current.delivery.state!=='Sent'}><Check size={16}/>{current.status==='Completed'?'Reopen':'Mark complete'}</button></div><div className="conversation-body"><h2>{current.subject}</h2><div className="sender-line"><span className="avatar tone-0">{initials(sender(current.from))}</span><div><strong>{sender(current.from)}</strong><small>To your hotel · {date(current.receivedAt,displayTimeZone)}</small></div></div><div className="email-body">{current.body}</div>{current.note&&<div className="staff-note"><CircleHelp size={17}/>{current.note}</div>}<div className="reply-box"><div className="reply-header"><span><FileText size={17}/><strong>Your reply draft</strong></span><span className="small muted">Only visible to your team</span></div><label className="sr-only" htmlFor="draft">Reply draft</label><textarea id="draft" disabled={!!current.delivery||busy} value={draft} onChange={e=>setDraft(e.target.value)} placeholder="Write a thoughtful reply…"/><div className="knowledge-insert"><BookOpen size={15}/><select disabled={!!current.delivery||busy} aria-label="Insert an approved hotel answer" value="" onChange={e=>{const k=knowledge.find(x=>x.id===e.target.value);if(k)setDraft(d=>d+(d?'\n\n':'')+k.answer);}}><option value="">Insert an approved hotel answer</option>{knowledge.filter(k=>k.approved).map(k=><option key={k.id} value={k.id}>{k.title}</option>)}</select></div><div className="reply-footer"><span><ShieldCheck size={15}/>No automatic sending</span><button className="button primary" disabled={busy||!!current.delivery||draft===current.draft} onClick={save}><Save size={16}/>Save draft</button></div></div><ReplyActions message={current} hotel={hotel} mailboxes={mailboxes} knowledge={knowledge} dirty={draft!==current.draft} busy={busy} run={run} onUpdate={onUpdate}/><div className="below-draft"><span>Check the reply and recipient before sending.</span>{current.providerThreadId&&/^[a-zA-Z0-9]+$/.test(current.providerThreadId)&&<a href={'https://mail.google.com/mail/u/0/#inbox/'+encodeURIComponent(current.providerThreadId)} target="_blank" rel="noopener noreferrer">Open in Gmail<ArrowUpRight size={14}/></a>}</div></div></>:<Empty title="Choose a conversation" text="Read a message and prepare a reply here."/>}</section></div>}</div>;
}
function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge[];canEdit:boolean;busy:boolean;run:Run;onUpdate:(k:Knowledge)=>void;notify:(s:string)=>void}){
const [edit,setEdit]=useState<Knowledge|null>(null),[search,setSearch]=useState('');
@ -85,10 +97,10 @@ function KnowledgePage({items,canEdit,busy,run,onUpdate,notify}:{items:Knowledge
async function save(e:React.FormEvent) {e.preventDefault();if(!edit)return;await run(async()=>{const result=await api<Knowledge>('/knowledge'+(edit.id?'/'+edit.id:''),edit.id?'PUT':'POST',edit);onUpdate(result);setEdit(null);notify('Hotel knowledge saved.');});}
return <div className="page"><div className="heading-row"><PageHeading eyebrow="Answers your team can trust" title="Hotel knowledge" text="Keep your policies and helpful answers in one place."/>{canEdit&&<button className="button primary" onClick={()=>setEdit({id:'',title:'',answer:'',keywords:'',category:'General',approved:false,version:0})}><Plus size={17}/>Add an answer</button>}</div><div className="knowledge-summary"><BookOpen size={23}/><div><strong>Your hotel's source of truth</strong><p>Approve answers before your team uses them in a reply. Keep changing details up to date.</p></div><span>{items.filter(x=>x.approved).length} approved</span></div><label className="search knowledge-search"><Search size={17}/><input placeholder="Find an answer…" aria-label="Search hotel knowledge" value={search} onChange={e=>setSearch(e.target.value)}/></label><div className="knowledge-grid">{items.filter(k=>(k.title+' '+k.answer).toLowerCase().includes(search.toLowerCase())).map(k=><article className="knowledge-card" key={k.id}><div><span className="category-label">{k.category}</span><span className={'status '+(k.approved?'Completed':'NeedsAttention')}>{k.approved?'Approved':'Not approved'}</span></div><h2>{k.title}</h2><p>{k.answer}</p>{canEdit&&<button className="button text" onClick={()=>setEdit({...k})}>Edit answer<ArrowUpRight size={15}/></button>}</article>)}</div>{!items.length&&<Empty title="What should guests know?" text="Start with check-in times, parking and breakfast information."/>}{edit&&<div className="modal-backdrop"><section className="modal" role="dialog" aria-modal="true" aria-labelledby="knowledge-title"><div className="modal-heading"><h2 id="knowledge-title">{edit.id?'Edit answer':'Add an answer'}</h2><button className="icon-button" onClick={()=>setEdit(null)} aria-label="Close editor"><X size={20}/></button></div><form onSubmit={save}><label>Title<input autoFocus value={edit.title} maxLength={120} minLength={2} required onChange={e=>setEdit({...edit,title:e.target.value})}/></label><label>Category<input value={edit.category} maxLength={50} required onChange={e=>setEdit({...edit,category:e.target.value})}/></label><label>Answer<textarea value={edit.answer} rows={6} maxLength={5000} minLength={2} required onChange={e=>setEdit({...edit,answer:e.target.value})}/></label><label>Helpful keywords<input value={edit.keywords} maxLength={300} placeholder="parking, car, arrival" onChange={e=>setEdit({...edit,keywords:e.target.value})}/></label><label className="checkbox-label"><input type="checkbox" checked={edit.approved} onChange={e=>setEdit({...edit,approved:e.target.checked})}/>Approved for staff to use</label><div className="form-actions"><button type="button" className="button secondary" onClick={()=>setEdit(null)}>Cancel</button><button className="button primary" disabled={busy}>Save answer</button></div></form></section></div>}</div>;
}
function SettingsPage({hotel,mailboxes,preview,owner,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
function SettingsPage({hotel,mailboxes,preview,owner,integrations=false,busy,run,onSave,onMailboxes}:{hotel:Hotel;mailboxes:Mailboxes;preview:boolean;owner:boolean;integrations?:boolean;busy:boolean;run:Run;onSave:(h:Hotel)=>void;onMailboxes:(value:Mailboxes)=>void}){
const [form,setForm]=useState(hotel);useEffect(()=>setForm(hotel),[hotel]);
const result=new URLSearchParams(location.search).get('google');
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={owner} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={owner} busy={busy} run={run} onSave={onSave}/></div>;
return <div className="page settings-page"><PageHeading eyebrow="Make yourself at home" title="Hotel settings" text="The details that make this workspace yours."/>{result&&<div className={result==='connected'?'success-note':'alert'} role="status">{result==='connected'?'Google mailbox connected. Recent messages will appear after synchronization.':result==='cancelled'?'Google connection was cancelled.':'Google connection could not be completed. Try again or contact your administrator.'}</div>}<section className="settings-card"><div className="section-heading"><Building2 size={20}/><div><h2>Your hotel</h2><p>Shared with everyone in this workspace.</p></div></div><form onSubmit={e=>{e.preventDefault();run(async()=>onSave(await api<Hotel>('/hotel','PUT',form)));}}><fieldset disabled={!owner||busy}><div className="form-grid"><label>Hotel name<input value={form.name} minLength={2} maxLength={120} required onChange={e=>setForm({...form,name:e.target.value})}/></label><label>Timezone<select value={form.timezone} onChange={e=>setForm({...form,timezone:e.target.value})}>{['Europe/London','Europe/Paris','Europe/Berlin','America/New_York','America/Los_Angeles','Asia/Dubai','Asia/Singapore','Australia/Sydney','UTC'].map(t=><option key={t}>{t}</option>)}</select></label></div><label>Email signature<textarea rows={4} maxLength={2000} value={form.signature} onChange={e=>setForm({...form,signature:e.target.value})}/></label><div className="form-actions"><button className="button primary" disabled={busy}>Save hotel settings</button></div></fieldset></form></section><MailboxPanel data={mailboxes} owner={integrations} preview={preview} busy={busy} run={run} onChange={onMailboxes}/><ReplyControls hotel={hotel} mailboxes={mailboxes} owner={integrations} busy={busy} run={run} onSave={onSave}/></div>;
}
createRoot(document.getElementById('root')!).render(<App/>);