diff --git a/.gitignore b/.gitignore index 0a99240..3b36fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ guestops-backup-*/ guestops-restore-*/ *.tar.gpg -__pycache__/ +__pycache__/ + +.fake diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..570bf30 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,36 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "GuestOps API (Preview)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-api", + "program": "${workspaceFolder}/src/GuestOps.Api/bin/Debug/net10.0/GuestOps.Api.dll", + "cwd": "${workspaceFolder}/src/GuestOps.Api", + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "Preview": "true", + "ASPNETCORE_URLS": "http://127.0.0.1:5180" + }, + "stopAtEntry": false + }, + { + "name": "GuestOps Web (Vite)", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}/web" + } + ], + "compounds": [ + { + "name": "GuestOps: API + Web", + "configurations": [ + "GuestOps API (Preview)", + "GuestOps Web (Vite)" + ], + "stopAll": true + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5b2742e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-api", + "type": "process", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/src/GuestOps.Api/GuestOps.Api.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/MILESTONES.md b/MILESTONES.md index 900e35d..cd73854 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -40,8 +40,8 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i | 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 | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. | -| 16 | Identity, preferences, and privacy | B/C | Planned | Finish operational identity controls, privacy/retention decisions, hotel preferences, audit review, and proxy-aware login rate limiting. | -| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Complete hotel-timezone rendering and agreed desktop-parity acceptance. | +| 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. | +| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. | | 18 | Pilot, capacity, and release approval | B/C | Planned | Run the supervised pilot, exercise support and incident procedures, validate capacity, resolve pilot findings, and capture explicit go/no-go approval for wider rollout. | ## Delivery sequence diff --git a/docs/deployment.md b/docs/deployment.md index ce0df35..014c559 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -54,7 +54,7 @@ Merge `deploy/nginx.conf` into the existing host configuration, check with `ngin Compose reserves the private bridge `172.30.87.0/24`, with gateway `172.30.87.1`. The API trusts the host gateway's `X-Forwarded-Proto` header so Nginx's HTTPS connections receive secure session and CSRF cookies. Check for an existing network using that range. If it conflicts, set both `GUESTOPS_SUBNET` and `GUESTOPS_GATEWAY` in `.env` to a free matching subnet and gateway. Do not replace this with unrestricted forwarded-header trust. -The initial rate limiter keys off the direct peer address. Behind this loopback reverse proxy it is shared across users (10 login attempts/minute), which is conservative for a pilot. Before scaling, configure explicitly trusted forwarded headers and per-account/IP rate limits; never trust arbitrary client-supplied forwarding headers. +The login rate limiter uses the client address forwarded by the explicitly configured host proxy (10 attempts per client address per minute). ASP.NET accepts one forwarding hop only from `Proxy__KnownAddress`; arbitrary client-supplied forwarding headers are not trusted. Keep the API port loopback-only and update the known address together with any reviewed Compose subnet change. ## 5. Configure Google diff --git a/src/GuestOps.Api/Operations.cs b/src/GuestOps.Api/Operations.cs index 1f726d7..372ffd2 100644 --- a/src/GuestOps.Api/Operations.cs +++ b/src/GuestOps.Api/Operations.cs @@ -22,8 +22,8 @@ public static class Operations }); api.MapGet("/operations",async(HttpContext c,IStore store)=> { - var id=Session.Hotel(c);var boxes=await store.List(id);var messages=await store.List(id);var seen=await store.WorkerLastSeen(); - return Results.Ok(new{checkedAt=DateTime.UtcNow,preview,database="Reachable",worker=new{lastSeenAt=seen,state=preview?"Preview":seen==null?"NotSeen":seenx.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")}}); + var id=Session.Hotel(c);var hotel=await store.Get(id,id);var boxes=await store.List(id);var messages=await store.List(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":seenx.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"); } } diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 09a40f9..16938e4 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -55,9 +55,9 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc builder.Services.AddAuthorization(o => o.AddPolicy("Owner", p => p.RequireRole("Owner"))); builder.Services.Configure(o => { - // Trust only HTTPS information from the configured host reverse proxy. - // Client IP forwarding remains disabled until per-client limits are introduced. - o.ForwardedHeaders = ForwardedHeaders.XForwardedProto; + // Trust scheme and client address only from the explicitly configured host proxy. + // The rewritten RemoteIpAddress is used by login throttling below. + o.ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor; o.ForwardLimit = 1; if (builder.Configuration["Proxy:KnownAddress"] is { Length: > 0 } address) o.KnownProxies.Add(IPAddress.Parse(address)); @@ -205,7 +205,7 @@ api.MapPut("/knowledge/{id}", async (string id, KnowledgeInput input, HttpContex 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(Session.Hotel(c))).OrderByDescending(x => x.At).Take(100))); -api.MapGet("/mailboxes", async (HttpContext c, GoogleMailbox google, AiDrafts ai) => Results.Ok(new { configured = !preview && google.Configured, sendingConfigured = !preview && google.SendingConfigured, aiConfigured = !preview && ai.Configured, items = (await store.List(Session.Hotel(c))).Select(MailboxManagement.View) })); +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))?.Timezone??"UTC", items = (await store.List(hotel)).Select(MailboxManagement.View) }); }); 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." }); diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index b2ac3ff..f73237b 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -93,14 +93,14 @@ try var h1=await Read(one,"/api/hotel");var h2=await Read(two,"/api/hotel"); Check("Different preview sessions have different hotels", h1.GetProperty("id").GetString()!=h2.GetProperty("id").GetString()); var health=await Read(one,"/api/operations"); - Check("Health overview is hotel scoped and labels preview worker",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"); + Check("Health overview is hotel scoped and carries hotel timezone",health.GetProperty("mailboxes").GetProperty("total").GetInt32()==1&&health.GetProperty("worker").GetProperty("state").GetString()=="Preview"&&health.GetProperty("timeZone").GetString()=="Europe/London"); var messages=await Read(one,"/api/conversations"); var id=messages[0].GetProperty("id").GetString(); Check("Guessed message ID cannot be edited across hotels", (await two.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="stolen",version=0})).StatusCode==HttpStatusCode.NotFound); Check("Draft save succeeds", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="Hello guest",version=0})).IsSuccessStatusCode); Check("Stale API draft save returns conflict", (await one.PutAsJsonAsync($"/api/conversations/{id}/draft",new {draft="old",version=0})).StatusCode==HttpStatusCode.Conflict); Check("Preview cannot connect real Gmail", !(await one.PostAsJsonAsync("/api/integrations/google/connect",new {})).IsSuccessStatusCode); var boxes=await Read(one,"/api/mailboxes");var boxId=boxes.GetProperty("items")[0].GetProperty("id").GetString(); - Check("Mailbox health includes recovery state without secrets",boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken")); + Check("Mailbox health includes timezone and recovery state without secrets",boxes.GetProperty("timeZone").GetString()=="Europe/London"&&boxes.GetProperty("items")[0].GetProperty("syncErrorCode").GetString()=="ReconnectRequired"&&!boxes.GetRawText().Contains("protectedRefreshToken")&&!boxes.GetRawText().Contains("pageToken")); foreach(var action in new[]{"disconnect","reconnect","retry"})Check("Other hotel cannot "+action+" a mailbox",(await two.PostAsJsonAsync($"/api/mailboxes/{boxId}/{action}",new{version=0})).StatusCode==HttpStatusCode.NotFound); Check("Preview recovery cannot change real Google state",(await one.PostAsJsonAsync($"/api/mailboxes/{boxId}/reconnect",new{version=0})).StatusCode==HttpStatusCode.BadRequest); Check("Preview cannot enable paid AI", (await one.PutAsJsonAsync("/api/reply-controls",new {version=0,aiDraftsEnabled=true,staffSendingEnabled=false})).StatusCode==HttpStatusCode.BadRequest); diff --git a/web/src/AutomationPage.tsx b/web/src/AutomationPage.tsx index 8fc80aa..73cf013 100644 --- a/web/src/AutomationPage.tsx +++ b/web/src/AutomationPage.tsx @@ -1,5 +1,6 @@ import {useEffect,useState} from 'react'; import {api,type Hotel,type Knowledge} from './api'; +import {hotelTime} from './time'; type Rule={id:string;question:string;knowledgeId:string;knowledgeVersion:number;enabled:boolean;version:number}; type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimit:number}; type Decision={matches:boolean;reason:string;body:string}; @@ -20,7 +21,7 @@ export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){

Automation mode: {hotel.autoReplyMode||'Off'}

Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.

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.

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.

{status?.preview&&

Sample workspace: the question tester works here. Live sending is disabled.

}

Review a FAQ rule

{answer&&

{knowledge.find(k=>k.id===answer)?.answer}

}

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.

Try a question

{e.preventDefault();run(async()=>setResult(await api('/auto-replies/test','POST',{subject,body})));}}>