Appearance
BrowserActions — Specs
Describes the system as it is now. Update this file in the same change as any code change.
REST API (BrowserActions)
ASP.NET Core controllers: Program.cs only registers services and maps controllers; endpoints are in Controllers/ (Actions, Contexts, Queues, Producers, Health), logic in Services/ (Browserbase, RepairlineSyncDatesProducer, RepairLine/); ServiceBusQueue and the QueueConsumer base class come from CommonServiceBus, and the key check in Middleware/ApiKeyMiddleware.
Callers (n8n) send X-Api-Key: <Api:Key> on every request except /health; missing or wrong key → 401. Controllers use [ApiController], so a body that doesn't bind (malformed JSON, missing required field) returns 400 as RFC 9110 problem details before the endpoint's own checks run.
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /health | — | ok (no auth) |
| POST | /contexts | — | { contextId } — new Browserbase context |
| DELETE | /contexts/{contextId} | — | 204 — deletes the context (404 route if not a GUID) |
| POST | /actions/get-page | { "url": "https://...", "contextId"?, "persistContext"? } | { title, text } — page title and visible body text. 400 if url isn't absolute http(s) or contextId isn't a GUID. |
| POST | /queues/{queue}/messages | [{ "messageId", "body", "subject"? }] | { sent } — sends each item to Service Bus queue {queue}. body is a JSON object or a JSON string (sent as-is, ContentType: application/json). 400 if any item lacks messageId/body, 404 if the queue doesn't exist. Sent in chunks of 100; a failure mid-way can leave earlier chunks sent — re-posting is safe because duplicate detection drops repeats. |
| POST | /actions/repairline/sync-dates | { "primeJobId", "dryRun"? } | { outcome, reason, detail, sessionId } — runs one RepairLine sync-dates job synchronously (up to ~15 min; waits for any running RepairLine job). See RepairLine sync-dates below. 400 if primeJobId isn't a GUID. |
| POST | /producers/repairline-sync-dates | — | { queued } — runs spRepairlineGetSyncDatesJobTasks and sends every row to queue ServiceBus:Queues:RepairlineSyncDates. queued = rows found; repeats inside the queue's 1-day duplicate-detection window are dropped by Service Bus, so calling it often is safe. Needs ConnectionStrings:Default and the proc deployed. |
Service Bus
Producers send through the API so n8n holds only the API key, never database or Service Bus credentials. Normal flow: n8n schedule → POST /producers/<name> (the API runs the proc and queues the rows). POST /queues/<queue>/messages takes rows from anywhere else (manual sends, other producers).
- Auth, either: (a)
ServiceBus:ConnectionString— a SAS connection string from the namespace-level shared access policyBrowserActionsWebAPIonsb-prod-australiasoutheast-001(Namespace → Shared access policies; Send, plus Listen once a consumer runs here, never Manage); used when set. One string covers every queue in the namespace, so new queues need no config change. A queue-level policy's string would carryEntityPath=<queue>and only reach that queue — any other{queue}in the URL returns404. (b)ServiceBus:Namespace(e.g.<ns>.servicebus.windows.net) withDefaultAzureCredential— the host's managed identity (App Service → Identity → System assigned: On; no user sign-in involved),az login/ Visual Studio locally — which needs Azure Service Bus Data Sender on the namespace (consumers: Data Receiver). Neither set →500ServiceBus:ConnectionString or ServiceBus:Namespace not set. Standard tier or above (duplicate detection). - Queue
sbq-repairlinedates-prod-australiasoutheast-001(RepairLine sync-dates): duplicate detection on, window 1 day (can only be set at creation); TTL 1 day (the producer re-adds still-stale jobs every 3 h); lock 5 min (the consumer auto-renews); max delivery count 10; dead-letter on expiration, partitioning, sessions and auto-delete off. - Producer
spRepairlineGetSyncDatesJobTasks(run byPOST /producers/repairline-sync-dates,Services/RepairlineSyncDatesProducer) →messageId = sync-dates:<primeJobId>,subject = sync-dates,body = { primeJobId, primeJobNumber }. - No consumer exists yet; messages wait in the queue until they expire.
Create the queue (once per environment):
az servicebus queue create -g <rg> --namespace-name <ns> -n sbq-repairlinedates-prod-australiasoutheast-001 \
--enable-duplicate-detection true --duplicate-detection-history-time-window P1D \
--default-message-time-to-live P1D --lock-duration PT5M --max-delivery-count 10
az role assignment create --role "Azure Service Bus Data Sender" --assignee <api identity or your user> \
--scope $(az servicebus namespace show -g <rg> -n <ns> --query id -o tsv)Contexts
A Browserbase context holds cookies, local storage and cache across sessions — e.g. stay logged in to a site. Every action request accepts optional contextId (from POST /contexts) and persistContext (default true: changes made during the session are saved back to the context when it closes; false = load it read-only). Without contextId the session starts clean.
The caller (n8n) owns context IDs — this service doesn't store them. Browserbase saves a persisted context shortly after the session closes, so allow a few seconds before the next session uses it. Avoid running two persisting sessions on the same context at once; the last one to close wins.
Browser sessions
RunAsync = one session per call. OpenAsync returns a BrowserbaseSession the caller keeps open across actions (disposing it ends the session; a failed connect releases it at once) — used by RepairLineBrowser. Both take timeoutSeconds (session cap); RunAsync also takes onSession (receives the Browserbase session id as soon as it exists, for outcomes and logs). Each action call creates one Browserbase session (POST https://api.browserbase.com/v1/sessions) routed through a Browserbase residential proxy exiting in Australia (proxies: [{ type: "browserbase", geolocation: { country: "AU" } }]; requires the Developer plan or above), with Browserbase's default automatic captcha solving left on. The browser runs in region ap-southeast-1 (Singapore), the closest Browserbase region to AU. Browserbase sets the browser timezone to match the proxy (Australia/Sydney); navigator.geolocation is granted and pinned to Sydney CBD (-33.8688, 151.2093). It connects Playwright over CDP, uses the session's default context/page (so the Browserbase recording captures it), and closes the browser afterwards, which ends the session. Nothing is pooled or reused. RunAsync takes optional userMetadata, sent as the session's Browserbase userMetadata (e.g. { portal: "repairline" }) so sessions can be filtered in the dashboard/API. Browserbase contexts can't be named — their ids are Browserbase-generated UUIDs, so the readable name is the config key holding the id (e.g. Portals:RepairLine:ContextId).
RepairLine sync-dates (consumer)
Writes Prime's changed job dates onto the job's Key Dates in Sedgwick's RepairLine portal, verifies them after a fresh load, and records them in dbo.primeJobDateHistory. Port of stagehand-automations/apps/new-repair-line-dates-and-schedules; the rules and their history are in docs/actions/repairline-sync-dates.md.
Code: Services/RepairLine/ — RepairLineSyncDates (one job → one outcome), RepairLineBrowser (the shared long-lived session), RepairLinePortal (Playwright steps), PrimeJobDates (Prime record, date rules, history row), MfaCodes (WebAPI MFA store), RepairLineSyncDatesWorker (queue consumer). Manual entry: POST /actions/repairline/sync-dates (RepairLineController).
Flow for one job (primeJobId):
dbo.spPrimeGetJobDates(BlazorApps-owned). No record →attempt/no_job_dates_record; noClientReference→attempt/no_job_reference. Targets = fields flagged changed with a Prime value, except Date Completed (portal-managed). None → history from Prime's values,complete/nothing_to_write.- The shared RepairLine session (see below), signed in. Search tab → reference → the one
IncompleteRepair Authorityrow (none →complete/no_active_authority; several →complete/duplicate_authority_rows; no rows →attempt/job_not_found), confirm the page is an Incomplete Repair Authority (attempt/wrong_job). - Read the six Key Dates rows. Estimated Commencement empty and not a target →
complete/missing_required_date(the portal won't save without it; Prime has no source). Unreadable target row →attempt/row_unreadable. All targets already there → history,complete/already_correct.dryRunstops here withattempt/dry_runand the planned writes — nothing typed, saved or posted. - Write at most 2 differing rows (rest →
attempt/rows_remainingafter verifying the first two). No Save & Exit after 9 min (attempt/deadline_exceeded). Save refused ("You need to add … before submitting") →complete/save_refused. - Witness: fresh load + search, re-read; a written row not holding its date →
attempt/date_not_persisted. dbo.spPrimeUpsertJobDateHistorywith what the portal holds (Prime's own value where the dates match, so the producer stops offering the job) →complete/reconciled.
Any other exception → attempt/portal_step_failed. complete/no_active_authority/duplicate_authority_rows/missing_required_date/save_refused post no history, so the producer offers the job again later.
Date rules: contract milestones (primeMilestoneItem.value, nvarchar UTC like 2026-09-21 18:25:41) are converted to the Australia/Melbourne calendar date for the portal; history gets the original value back. primeJobs dates are calendar dates.
Portal facts (captured 2026-10-11, Blazor Server + Telerik):
- Search: tab "Search", box
input[name=SearchQuery], button "Search"; results grid "Data table", rowstr.k-master-row, columns located by heading (Status, Request Type, Sedgwick Ref). Clicking a row opens/job/<guid>. The tab click is retried — clicks before the Blazor connection is live do nothing. - Key Dates rows are
<tr>with a<strong>Label:</strong>. Editable rows hold a Telerik date input whose live value isdd/MM/yyyy, or the literal maskdd/MM/yyyywhen empty. Date Completed is text. Commencement Date, once changed, becomes textdd/MM/yyyy *(Original Date: …) Reason for the date change: …plus a "Modify original start date" button, which opens "New date" + "Reason for the date change" (we enterDate change from Prime). - Typing a date: click, Ctrl+A, eight digits 600 ms apart, Tab, settle 1.5 s, read the value back. Never
FillAsync. - Navigation waits for DOMContentLoaded (the full load through the proxy can exceed 30 s).
Shared session (RepairLineBrowser): RepairLine binds its login to the client IP and the login cookie .RepairLine.Auth.Application.v2 is session-only, so on Browserbase's rotating proxy every new session must sign in with an emailed code — neither the persisted context nor restored cookies keep the login (tested 11 Oct 2026). So one Browserbase session (60 min cap, Portals:RepairLine:ContextId, tagged { portal: "repairline" }) stays open and every job runs in it, one at a time: one code per session, not per job. A job never starts with < 16 min of session left (the session is closed and a new one opened); a disconnected browser is replaced; the session is closed after 10 min idle (billing) and on app shutdown. Each job starts by navigating home, so a failed step in a live browser doesn't poison the next job.
Sign-in (only when the page isn't signed in): clear cookies, type email/password, read the MFA store's latest updatedAt (the floor), press "Contractor Login", wait for /LoginWith2faEmail, poll WebAPI GET Mfa/Sedgwick every 5 s for up to 150 s for a newer code, type it, "Log in", wait to leave the code page. Failures: attempt/send_budget_exhausted (in-process cap of 2 sign-ins/hour), mfa_code_stale (no new code — also what the portal's own send limit looks like), mfa_code_rejected, sign_in_failed.
Low profile: portals must not see automation — human-paced typing (TypeAsync: per-key delay 70–140 ms) and pauses between steps, one session per account, one job at a time (RepairLineBrowser, shared by worker and endpoint), Browserbase AU residential proxy and default fingerprint, nothing identifying sent to the portal.
Worker (RepairLineSyncDatesWorker, a CommonServiceBus.QueueConsumer subclass that only parses the payload and maps the outcome): off unless RepairLine:SyncDates:ConsumerEnabled is true. ServiceBusProcessor on ServiceBus:Queues:RepairlineSyncDates, MaxConcurrentCalls = 1, no prefetch, lock auto-renewed up to 20 min. complete → complete; attempt → complete and re-send a copy scheduled 10/30/90/120 min later with MessageId = <original>:<n> and app properties attempt, baseMessageId, lastReason; after 5 attempts, or reject/bad payload → dead-letter with the reason. Needs Listen and Send on the Service Bus policy, and exactly one replica.
Data access (CommonDataAccess)
ISQLDataAccess / SQLDataAccess from CommonDataAccess — Dapper over stored procedures only, connection string ConnectionStrings:Default, registered as a singleton in this app (it holds only IConfiguration, so that is safe). Calls pass CommandType.StoredProcedure; LoadSingleData<T?, U> returns null when the proc returns no row.
Used by RepairlineSyncDatesProducer (spRepairlineGetSyncDatesJobTasks) and RepairLineSyncDates (spPrimeGetJobDates, spPrimeUpsertJobDateHistory — BlazorApps-owned, called not copied). The App Service should share WebAPI's plan (same outbound IPs), so the database firewall is already open to it.
Planned portal actions (not built) are specced in BrowserActions-Actions.md; the A&G Crunchworks create-job design (AB#2132) is BrowserActions-ag-create-job.md.
Database (BlazorApps DbMigrations)
Target: the existing Ethos core database (Azure SQL, sqldb-coredata-*). Its current schema is exported nightly to the read-only sql-ontology repo (schemas/<type>/dbo.<Name>.json) — read it there, don't query prod.
- Run by BlazorApps' own
DbMigrations(DbUp, journaldbo.SchemaVersions); there is no separate runner. The old runner'sdbo.BrowserActionsSchemaVersionstable is dropped by migration0055.
Objects this app depends on (all in DbMigrations):
dbo.spRepairlineGetSyncDatesJobTasks(Scripts/Programmable/Procedures/) — RepairLine sync-dates producer. Same eligibility as BlazorApps'spEthRepairlineJobDatesQueueTasks(Sedgwick - AllianzJ…jobs in an active status whose contract sent/returned or expected end date differs fromprimeJobDateHistory, or with no history), butSELECTs one row per job (messageId, subject, body) instead of enqueueing into the SQL work queue. The BlazorApps proc is untouched; keep the two eligibility rules in step until it is retired.
Logging (API)
NLog (NLog.Web.AspNetCore), same setup as BlazorApps WebAPI. BrowserActions/nlog.config (copied to the output folder) replaces the default providers, so every ILogger<T> writes through it:
- File: one log per logger (class short name) at
${HOME}/LogFiles/app/<Logger>.log—HOMEis App Service'sD:\home//home, so logs land in its standard log folder. Archived daily toarchives/, 30 kept. Locally,HOMEunset (e.g. Visual Studio) →\LogFiles\appon the current drive. - Console: same layout.
- Level:
Infoand above for everything. TheLoggingsection inappsettings*.jsondoesn't filter NLog — change levels innlog.config. - Startup failures are logged before the process exits;
LogManager.Shutdown()flushes on stop.
Configuration
| Key | Used by | |
|---|---|---|
Browserbase:ApiKey, Browserbase:ProjectId | API | Browserbase credentials. Missing or empty → action fails with <key> not set |
Api:Key | API | Shared secret for X-Api-Key |
ConnectionStrings:Default | API | Azure SQL connection string |
Portals:RepairLine:LoginUrl | API | https://repairline.com.au/Account/Login?ReturnUrl=%2F (appsettings.json) |
Portals:RepairLine:Username, Portals:RepairLine:Password | API | RepairLine portal account — secrets, never in appsettings |
Portals:RepairLine:MfaServiceProviderName | API | Sedgwick (appsettings.json) — the MFA store provider holding RepairLine's emailed codes, read via WebAPI GET api/Mfa/Sedgwick |
WebApi:BaseUrl | API | BlazorApps WebAPI, prod (appsettings.json) |
WebApi:Username, WebApi:Password | API | WebAPI Basic auth — secrets |
ServiceBus:ConnectionString | API | SAS connection string (namespace-level policy BrowserActionsWebAPI) — secret; takes precedence over ServiceBus:Namespace |
ServiceBus:Namespace | API | Fully qualified Service Bus namespace, used with DefaultAzureCredential when no connection string is set (see Service Bus) |
RepairLine:SyncDates:ConsumerEnabled | API | false (appsettings.json). true starts the sync-dates queue consumer — set only on the one production host |
Portals:RepairLine:ContextId | API | Browserbase context for RepairLine sessions — secret/user-secrets |
ServiceBus:Queues:RepairlineSyncDates | API | Queue the sync-dates producer sends to: sbq-repairlinedates-prod-australiasoutheast-001 (appsettings.json) |
Locally via user-secrets; in production via env vars (__ separator, e.g. ConnectionStrings__Default).

