Skip to content

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.

MethodPathBodyReturns
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 policy BrowserActionsWebAPI on sb-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 carry EntityPath=<queue> and only reach that queue — any other {queue} in the URL returns 404. (b) ServiceBus:Namespace (e.g. <ns>.servicebus.windows.net) with DefaultAzureCredential — 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 → 500 ServiceBus: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 by POST /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):

  1. dbo.spPrimeGetJobDates (BlazorApps-owned). No record → attempt/no_job_dates_record; no ClientReference → 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.
  2. The shared RepairLine session (see below), signed in. Search tab → reference → the one Incomplete Repair Authority row (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).
  3. 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. dryRun stops here with attempt/dry_run and the planned writes — nothing typed, saved or posted.
  4. Write at most 2 differing rows (rest → attempt/rows_remaining after verifying the first two). No Save & Exit after 9 min (attempt/deadline_exceeded). Save refused ("You need to add … before submitting") → complete/save_refused.
  5. Witness: fresh load + search, re-read; a written row not holding its date → attempt/date_not_persisted.
  6. dbo.spPrimeUpsertJobDateHistory with 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", rows tr.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 is dd/MM/yyyy, or the literal mask dd/MM/yyyy when empty. Date Completed is text. Commencement Date, once changed, becomes text dd/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 enter Date 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, journal dbo.SchemaVersions); there is no separate runner. The old runner's dbo.BrowserActionsSchemaVersions table is dropped by migration 0055.

Objects this app depends on (all in DbMigrations):

  • dbo.spRepairlineGetSyncDatesJobTasks (Scripts/Programmable/Procedures/) — RepairLine sync-dates producer. Same eligibility as BlazorApps' spEthRepairlineJobDatesQueueTasks (Sedgwick - Allianz J… jobs in an active status whose contract sent/returned or expected end date differs from primeJobDateHistory, or with no history), but SELECTs 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 — HOME is App Service's D:\home / /home, so logs land in its standard log folder. Archived daily to archives/, 30 kept. Locally, HOME unset (e.g. Visual Studio) → \LogFiles\app on the current drive.
  • Console: same layout.
  • Level: Info and above for everything. The Logging section in appsettings*.json doesn't filter NLog — change levels in nlog.config.
  • Startup failures are logged before the process exits; LogManager.Shutdown() flushes on stop.

Configuration ​

KeyUsed by
Browserbase:ApiKey, Browserbase:ProjectIdAPIBrowserbase credentials. Missing or empty → action fails with <key> not set
Api:KeyAPIShared secret for X-Api-Key
ConnectionStrings:DefaultAPIAzure SQL connection string
Portals:RepairLine:LoginUrlAPIhttps://repairline.com.au/Account/Login?ReturnUrl=%2F (appsettings.json)
Portals:RepairLine:Username, Portals:RepairLine:PasswordAPIRepairLine portal account — secrets, never in appsettings
Portals:RepairLine:MfaServiceProviderNameAPISedgwick (appsettings.json) — the MFA store provider holding RepairLine's emailed codes, read via WebAPI GET api/Mfa/Sedgwick
WebApi:BaseUrlAPIBlazorApps WebAPI, prod (appsettings.json)
WebApi:Username, WebApi:PasswordAPIWebAPI Basic auth — secrets
ServiceBus:ConnectionStringAPISAS connection string (namespace-level policy BrowserActionsWebAPI) — secret; takes precedence over ServiceBus:Namespace
ServiceBus:NamespaceAPIFully qualified Service Bus namespace, used with DefaultAzureCredential when no connection string is set (see Service Bus)
RepairLine:SyncDates:ConsumerEnabledAPIfalse (appsettings.json). true starts the sync-dates queue consumer — set only on the one production host
Portals:RepairLine:ContextIdAPIBrowserbase context for RepairLine sessions — secret/user-secrets
ServiceBus:Queues:RepairlineSyncDatesAPIQueue 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).