Skip to main content

SWE Enclave Errors and Error Reporting

The SWE Enclave (the browser-side surface that runs Tide flows in the user's tab) used to surface failures as throw new Error("...") strings shown verbatim in the UI. That made flow errors hard to triage on the operator side and impossible for users to report safely — the natural "copy/paste this stacktrace" path would have leaked SessionKey private components, DPoP keys, raw Dokens, and decrypted vendor responses.

The SWE error-reporting work replaces that model with three coordinated pieces:

  • A single structured error type — TideError — used everywhere on the client side, with a closed enumeration of TIDE-TIDEJS-* codes.
  • A structured "Something went wrong" page rendered by renderSomethingWentWrong in the Enclave, with copy / retry / report controls.
  • A report flow (modal → POST /Diagnostics/ErrorReport) whose payload is constrained by an explicit allowlist with a unit-test canary, then a second pass of DTO-based redaction on the server.

This page documents all three. It is a sibling of:

  • ORK Errors — the server-side TIDE-ORK-* taxonomy. ORK error codes can be carried verbatim on a TideError when an ORK ProblemDetails response is the source of the failure.
  • ORK Metrics — the OpenTelemetry metric dictionary.

TideError — the structured error type

TideError lives in tide-js/Errors/TideError.ts and is exported from @tideorg/js. Every error thrown across Cryptide / TideKey / AES / DH / Serialization / Doken / TideMemory / Models / Tools is now a TideError; raw throw new Error("...") is gone from those paths.

Shape

class TideError extends Error {
readonly code: string; // canonical code, e.g. "TIDE-TIDEJS-CRYPTO-ED25519_BAD_POINT"
readonly displayMessage: string; // UI-safe human-readable message
readonly messageKey?: string | null;
readonly messageParams?: Record<string, unknown> | null;
readonly traceId?: string; // W3C traceparent, propagated verbatim from upstream
readonly source?: string; // e.g. "Ork/Voucher/Verifier.cs:142" or the tide-js call-site
readonly httpStatus?: number;
readonly problemType?: string; // RFC 9457 Problem Details "type" URI
readonly url?: string; // full request URL when knowable
readonly endpoint?: string; // path-only portion of url
readonly method?: string;
readonly details?: TideErrorDetail[]; // per-attempt failures for aggregate errors
}

The code, displayMessage, and (where present) traceId form a cross-project public contract — keycloak-IGA's getTideErrorInfo and the SWE error UI both read these field names directly. Renaming any of them requires coordinating across every consumer.

TideError.isTideError(value) is a structural type guard (checks name === "TideError" plus the two required string fields) — used by external consumers that cannot rely on instanceof across module/bundling boundaries.

Throwing

import { TideError, TideJsErrorCodes } from "@tideorg/js";
throw new TideError({
code: TideJsErrorCodes.CRYPTO_ED25519_BAD_POINT,
displayMessage: "Ed25519 point failed on-curve validation.",
source: "Cryptide/Ed25519Components.ts:add",
cause: underlyingError,
});

The initialiser is always a single object so call-sites are self-documenting and field order can never drift.

Pass-through of upstream codes

When tide-js receives an application/problem+json body from an upstream component (ORK, tidecloak-idp-extensions, midgard), the upstream code is preserved verbatim on the resulting TideError. It is not mapped onto a TIDE-TIDEJS-* code. A consumer switching on error.code will therefore see e.g. TIDE-ORK-SIG-VERIFY_FAILED for an ORK-originated signing failure, exactly as the ORK emitted it.

The synthetic codes in TideJsErrorCodes are only used for failures that originate inside tide-js itself.

Why this replaced raw throw Error(...)

Raw Error instances lose every structured field a triage flow needs: there is no machine-readable code to switch on, no traceId for log correlation, no per-attempt details for aggregate failures, no consistent source location. They also serialise differently across realms (Node, browser, web worker) and forced the SWE error page to render [object Object] whenever a flow threw a literal. TideError standardises all of that and gives the error UI a single shape to render.

Code reference

These are the codes tide-js itself originates. They are defined as a frozen object literal in tide-js/Errors/codes.ts and exported as TideJsErrorCodes. The canonical form is TIDE-TIDEJS-<CATEGORY>-<NAME>.

These strings are a stable contract. tide-js consumers (the SWE, keycloak-IGA, idp-extensions) compare against these exact values to drive UI / triage flows — treat them like a public API surface.

Network

CodeWhen emitted
TIDE-TIDEJS-NET-FETCH_FAILEDUnderlying fetch rejected (DNS, connection refused, TLS, CORS, ...)
TIDE-TIDEJS-NET-TIMEOUTThe request was aborted by tide-js's internal timeout AbortController
TIDE-TIDEJS-NET-ABORTEDThe request was aborted by a caller-supplied AbortSignal
TIDE-TIDEJS-NET-NON_OK_STATUSresponse.ok === false and the body did not carry a recognisable error envelope
TIDE-TIDEJS-NET-THRESHOLD_FAILUREFan-out to multiple ORKs completed but fewer succeeded than the threshold required. Carries details[] with each per-ORK underlying failure
TIDE-TIDEJS-NET-UNKNOWNA non-TideError value was thrown from the fetch pipeline — caught and tagged so it is still visible in the recent-requests buffer

Parsing

CodeWhen emitted
TIDE-TIDEJS-PARSE-PROBLEM_JSON_INVALIDServer sent application/problem+json but the body did not parse or lacked required fields
TIDE-TIDEJS-PARSE-UNKNOWN_FORMATBody shape is not understood (e.g. legacy --FAILED--: envelope, or unknown format)
TIDE-TIDEJS-PARSE-NODECLIENT_RESPONSE_SHAPEA NodeClient response did not contain the expected index field (used by WaitForNumberofORKs cleanup)
TIDE-TIDEJS-PARSE-INSUFFICIENT_DATATideMemory buffer is too small to read the requested segment (truncated / malformed input)
TIDE-TIDEJS-PARSE-INDEX_OUT_OF_RANGETideMemory segment index requested is past the end of the buffer's encoded segments
TIDE-TIDEJS-PARSE-BUFFER_OVERFLOWTideMemory allocation/write would exceed the destination buffer's capacity

Validation

CodeWhen emitted
TIDE-TIDEJS-VAL-MISSING_SESSION_KEYA client method required a session key but AddBearerAuthorization was never called
TIDE-TIDEJS-VAL-INPUT_SHAPEA flow input failed a shape/type validation (wrong type, wrong array length, missing required field)
TIDE-TIDEJS-VAL-UID_FORBIDDENThe supplied username (uid) is not allowed (empty reserver list returned by the network)
TIDE-TIDEJS-VAL-INVALID_ACCOUNTThe supplied account could not be located on the network (e.g. simulator invalid-account sentinel)

Cryptography

Low-level cryptographic primitive failures. Typically thrown from Cryptide/. These are generally not retryable — they indicate a malformed input, an on-curve check failure, or an unsupported key/scheme combination.

CodeWhen emitted
TIDE-TIDEJS-CRYPTO-SESSION_KEY_MISMATCHThe session key the caller supplied does not match the session key bound into the Doken
TIDE-TIDEJS-CRYPTO-GRJ_SJ_LENGTH_MISMATCHGRj and Sj arrays produced by a signing flow had differing lengths (should be impossible)
TIDE-TIDEJS-CRYPTO-ORK_ARRAY_LENGTH_MISMATCHPer-ORK response arrays had differing lengths during PreSign / Sign aggregation
TIDE-TIDEJS-CRYPTO-NOT_IMPLEMENTEDA BaseComponent abstract method (Add / Multiply / Scheme) was invoked but not implemented on the concrete subclass
TIDE-TIDEJS-CRYPTO-COMPONENT_MISMATCHTwo components were combined whose schemes / component-types do not match
TIDE-TIDEJS-CRYPTO-UNKNOWN_COMPONENT_TYPEScheme / component-type registry lookup failed (unknown scheme or component type)
TIDE-TIDEJS-CRYPTO-DESERIALIZE_FAILEDA serialized component could not be parsed into bytes (neither hex nor base64)
TIDE-TIDEJS-CRYPTO-AES_UNSUPPORTED_KEY_TYPEAES encrypt/decrypt called with a key of an unsupported JS type
TIDE-TIDEJS-CRYPTO-DH_UNSUPPORTED_PRIV_TYPEDH computeSharedKey called with a private value of an unsupported JS type
TIDE-TIDEJS-CRYPTO-ED25519_BAD_POINTAn Ed25519 Point failed an on-curve / equality / non-ZERO sanity check
TIDE-TIDEJS-CRYPTO-INVERSE_NOT_EXISTModular inverse does not exist (gcd != 1, or invert of 0 / non-positive modulus)
TIDE-TIDEJS-CRYPTO-INVALID_BIGINT_INPUTA low-level crypto primitive received a value of an unexpected JS type (e.g. invert expected a bigint)
TIDE-TIDEJS-CRYPTO-HASH_TO_POINT_INVALID_INPUTHash-to-Point (RFC 9380 expand_message_xmd / i2osp) received an out-of-range input

Signature

CodeWhen emitted
TIDE-TIDEJS-SIG-VERIFY_FAILEDNon-blind signature verification failed (e.g. Ed25519Scheme.verifyingFunc)
TIDE-TIDEJS-SIG-BLIND_VERIFY_FAILEDLocal blind-signature verification failed against the expected challenge

Serialization

Helpers under tide-js/Serialization/ — length-tagged blobs, hex / base64 transcoding, type guards.

CodeWhen emitted
TIDE-TIDEJS-SERIAL-LENGTH_OUT_OF_RANGEA numeric value cannot be represented in the requested width (e.g. > Int64 / > 255 byte)
TIDE-TIDEJS-SERIAL-INVALID_TYPEThe supplied argument was not of the expected JS type (e.g. expected Uint8Array, got something else)
TIDE-TIDEJS-SERIAL-INDEX_OOBA serialization helper found data already present where an empty slot was expected
TIDE-TIDEJS-SERIAL-BUFFER_OVERFLOWA serialization write would have exceeded the destination buffer's capacity
TIDE-TIDEJS-SERIAL-INVALID_LENGTHA length-tagged input did not match the expected length (e.g. TIDE_KEY blob != 32 bytes)
TIDE-TIDEJS-SERIAL-UNEXPECTED_HEADERA header / magic value did not match the expected token (e.g. tidexxxkey prefix mismatch)
TIDE-TIDEJS-SERIAL-INVALID_HEXA hex string failed regex validation
TIDE-TIDEJS-SERIAL-INVALID_BASE64A base64 string failed validation or decoding
TIDE-TIDEJS-SERIAL-LENGTH_MISMATCHTwo operand arrays had unequal lengths where equal lengths were required (e.g. XOR)

Models

Construction / parsing guards on tide-js/Models/ — Doken, AuthRequest, TideKey, Policy.

CodeWhen emitted
TIDE-TIDEJS-MODEL-INVALID_FIELDA model field (Doken / AuthRequest / TideKey) failed a shape/type guard during construction or parsing
TIDE-TIDEJS-MODEL-UNEXPECTED_HEADERA model header value (e.g. Doken alg / typ) did not match the expected value
TIDE-TIDEJS-MODEL-INVALID_SHAPEA model expected a specific shape (e.g. Doken = 3 parts) and the input did not conform
TIDE-TIDEJS-MODEL-INVALID_KEYA TideKey was constructed/derived from a component that does not satisfy the required interface
TIDE-TIDEJS-MODEL-VALUE_OUT_OF_RANGEA model field's value was not in the allowed range (e.g. VRK expiry too close to now)
TIDE-TIDEJS-MODEL-UNKNOWN_MODELModelRegistry could not resolve a sign-request name:version to a builder (unknown model id)
TIDE-TIDEJS-MODEL-UNKNOWN_PARAM_TYPEA PolicyParameters entry carries an unrecognised type tag (not str / num / bnum / bln / byt)
TIDE-TIDEJS-MODEL-PARAM_NOT_FOUNDPolicy.getParameter was asked for a parameter key that does not exist on the policy
TIDE-TIDEJS-MODEL-DEV_ERRORA developer-only invariant was violated inside a Policy version handler (should be unreachable in production)
TIDE-TIDEJS-MODEL-REQUEST_NOT_INITIALIZEDA request (e.g. BaseTideRequest) was used before a required field (authorizer / authorization / cert) had been added
TIDE-TIDEJS-MODEL-VERSION_MISMATCHA serialized model header carried an unsupported version tag (Policy / SerializedField)

TideMemory

Bounds-checked memory helpers in tide-js/Tools/TideMemory.ts. These guards prevent malformed or hostile input from over-reading or over-writing a TideMemory buffer.

CodeWhen emitted
TIDE-TIDEJS-MEM-NEGATIVE_INDEXCaller supplied a negative index to a TideMemory helper
TIDE-TIDEJS-MEM-INDEX_ZERO_RESERVEDCaller attempted to overwrite the zero-index slot via WriteValue (must use Create)
TIDE-TIDEJS-MEM-BUFFER_OVERFLOWTideMemory write would exceed the destination buffer's capacity
TIDE-TIDEJS-MEM-INDEX_OUT_OF_RANGETideMemory read sought past the encoded segments of the buffer
TIDE-TIDEJS-MEM-INSUFFICIENT_DATATideMemory buffer is too small to hold even the version header
TIDE-TIDEJS-MEM-INDEX_ALREADY_WRITTENTideMemory write attempted at an index already populated with data

Proxy / pass-through

CodeWhen emitted
TIDE-TIDEJS-PROXY-UPSTREAM_ERRORtide-js is wrapping an upstream failure in a way that adds semantics (e.g. "all ORKs failed", retry exhaustion). For straight pass-through of an upstream ProblemDetails body, the upstream code is preserved verbatim instead

SWE-side synthetic code

CodeWhen emitted
TIDE-SWE-UNHANDLEDSynthesised by the SWE error page (convertToError and buildReportPayload) for any thrown value that is not a TideError and not a duck-typed {code, displayMessage} literal. Covers plain Error instances, strings, and other thrown values from third-party / pre-TideError call sites. Renders the original message as the displayMessage

Error reporting flow

The journey from a thrown error to an operator-side report is:

throw TideError global handler / explicit catch
│ │
▼ ▼
convertToError(err) ────► renderSomethingWentWrong(info)
├── copy / copy-all / retry buttons
└── "Report this error" ─► showErrorReportModal(info)
├── buildReportPayload (allowlist)
JSON preview
POST /Diagnostics/ErrorReport
├── 202 ─► toast: report id
└── 5xx / network fail ─► mailto: fallback

Each step:

  1. convertToError(err) (in Ork/Ork/Enclave/js/index.js ~line 3507) normalises any thrown value — TideError, plain Error, duck-typed {code, displayMessage} literal, string, or other — into a uniform info object with all the fields the page renderer reads. Plain values become TIDE-SWE-UNHANDLED. messageKey is resolved against the loaded i18n bundle; missing translations fall back to "<code>: <displayMessage>" so the user can always quote the code.
  2. renderSomethingWentWrong(info, opts) (~line 3632) hides the active .page, populates the structured error page (code, traceId, source, httpStatus, endpoint, method, and the per-attempt details[] list), and wires the copy / retry / report buttons.
  3. showErrorReportModal(info) (~line 3900) calls buildReportPayload to produce the sanitised initial payload + a set of "user field chips" (non-password user inputs from the visible page). The user can remove any chip with an X button before sending.
  4. JSON preview gate. A <details> element shows the exact payload that will be sent. The Send button is disabled until the user expands that preview at least once. This is an explicit informed-consent step — the user cannot send anything they have not had the option to inspect.
  5. POST /Diagnostics/ErrorReport with Content-Type: application/json. On 2xx the modal closes and a transient toast shows the server-issued reportId.
  6. mailto: fallback. On any non-2xx response (fetch returning res.ok === false) or a network-level failure (fetch throwing), the modal falls back to opening a mailto:errors@tide.org with a minimal subset — code, traceId, source, displayMessage, endpoint, HTTP status, and the optional user note. No automatic retry is performed; nothing else is auto-submitted.

Security boundary — what gets sent and what doesn't

The buildReportPayload function in Ork/Ork/Enclave/js/Reporting/buildReportPayload.js is the wall. It is intentionally pure (no DOM access, no fetch, no globals) so it can be unit-tested in isolation. The "shape" of the report is defined by a strict allowlist; anything not in the allowlist is silently dropped, even if a caller hands the function an object that contains private fields.

Threat model

  • The Enclave keeps highly-sensitive material in memory: SessionKey private components, _clientDPoPKey, gPass, networkSessionKey, decrypted vendor responses, raw Dokens, prismAuthi, prkECDHi. None of this can ever cross the wire as part of an error report, even with explicit user consent.
  • Page inputs may contain passwords. Password inputs are always dropped — regardless of user consent and regardless of what the user typed into the chips area. Other inputs are collected into userFieldsRaw so the modal can let the user opt-in or remove them on a per-field basis before the payload is finalised.

What the allowlist permits

  • TideError structured fields: code, displayMessage, messageKey, traceId, source, httpStatus, problemType, method, endpoint, url, details[], stack.
  • SWE state: ?type=... parameter, voucherURL, visible page name, buildSha.
  • tide-js version: the tideJsVersion constant from @tideorg/js.
  • Environment: userAgent, language, platform, screen (WIDTHxHEIGHT), viewport (WIDTHxHEIGHT).
  • ORK topology (PUBLIC IDs/URLs only): sessionKeyPublic (the public component only — enclave.SessionKey.get_public_component().Serialize().ToString()), activeOrksCount, threshold, bitwise.
  • Recent requests (from RecentRequestsBuffer.snapshot()): one entry per recent HTTP request observed by ClientBase, with timestamp, sanitized url, endpoint, method, httpStatus, durationMs, and the TideError code if the request failed. Bodies are never included.
  • timeSkew (from localStorage, informational only).
  • User fields added by the modal after the user accepts each chip (userFields) and an optional free-form userNote.

URL sanitisation

  • The uid query parameter is stripped, case-insensitive (uid / UID / Uid are all dropped) — guards against an attacker probing for an exact-match strip.
  • vvkid is truncated to the first 12 characters (it is a public id but long; truncating shrinks the bundle and reduces fingerprinting surface).
  • voucherURL is kept verbatim (it is public-by-design).
  • For URLs that fail new URL(...) parsing, only the path portion before the first ? is kept — the query string is never re-included from an unparseable input.

Stack-trace handling

  • Stack traces are capped at 8 KB.
  • Absolute filesystem paths in stack frames are best-effort stripped to the file basename (/home/alice/project/ork/Ork/Ork/Enclave/js/index.js becomes index.js), removing personally-identifying directory layouts.

Banned-token canary test

buildReportPayload.test.js constructs a fake Enclave / fake localStorage / fake active page that contain every category of sensitive material, calls buildReportPayload, then serialises the resulting payload to JSON and asserts that none of the following 26 tokens appear anywhere in the serialised output:

PRIVATE_SCALAR_SECRET_DO_NOT_LEAK
PRIVATE_RAW_BYTES_SECRET
PRIVATE_SEED_SECRET
PRIVATE_RB_SECRET
DPOP_PRIVATE_KEY_SECRET
GPASS_SECRET
NETWORK_SESS_KEY_PRIVATE_SECRET
DECRYPTED_RESPONSE_SECRET
DECRYPTED_CHALLENGE_SECRET
DEC_PRISM_REQUEST_SECRET
DOKEN_RAW_STRING_SECRET
VENDOR_ENC_DATA_SECRET
PRISMAUTHI_SECRET
PRKECDHI_SECRET
TIDE_DEVICE_KEY_PRIVATE_SECRET
TIDE_SESSION_KEY_PRIVATE_SECRET
TIDE_ENTRY_PRIVATE_BYTES_SECRET
HUNTER2_PASSWORD_SECRET
CSRF_TOKEN_SECRET
VRK_SECRET_DATA
USER_UID_PRIVATE_SECRET
ANOTHER_UID_SECRET
DETAIL_UID_SECRET
USER_UID_IN_BUFFER_SECRET
CAUSE_PRIVATE_SECRET
REQUEST_BODY_PRIVATE_SECRET
RESPONSE_BODY_PRIVATE_SECRET

Each token represents a distinct class of material the allowlist must keep out: SessionKey private scalar / raw bytes / seed / rB, DPoP key, gPass, network session key, every decrypted response / challenge / Prism request, raw Dokens, vendor-encrypted data, PrismAuthi, PrkECDHi, Tide_Device_Key / Tide_SessionKey / Tide_Entry in localStorage, password inputs, hidden CSRF inputs, data-tide-sensitive inputs, every flavour of uid query parameter (URL, error.url, detail URLs, recent-request URLs), cause objects on error details, and request / response bodies in recent-request entries.

When extending the allowlist or adding a new piece of state to the payload, add a matching banned token to this test for any sensitive material the new state might risk surfacing. The test exits non-zero on the first failure and is intentionally dependency-free so it can run without a test framework.

Server-side second pass

The Diagnostics endpoint does not trust the inbound payload shape. The body is deserialised into a strongly-typed ErrorReportDto (with only the allowlist fields as [JsonPropertyName(...)] properties) and System.Text.Json silently drops everything else. The on-wire / on-disk shape is then re-serialised from the DTO, never echoed from the raw inbound bytes. A bug in the client-side allowlist therefore cannot leak material the DTO does not have a slot for.

DiagnosticsController — the server-side endpoint

Ork/Ork/Controllers/Diagnostics/DiagnosticsController.cs.

Endpoint

POST /Diagnostics/ErrorReport with Content-Type: application/json.

Response:

  • 202 Accepted with a JSON body { "reportId": "<guid>", "traceId": "<from-error>" }. The reportId is also written to a Serilog LogInformation entry with structured fields tide.error.report.id, tide.error.code, tide.error.report.trace_id, tide.error.report.source, and tide.error.report.payload (full sanitised JSON). That log entry is the authoritative record — email is best-effort.
  • 400 (TIDE-ORK-DIAGNOSTICS-INVALID_REPORT) when the body is not valid JSON or is missing required fields (schemaVersion, error.code, error.displayMessage).
  • 413 (TIDE-ORK-DIAGNOSTICS-PAYLOAD_TOO_LARGE) when the body exceeds Tide:Diagnostics:MaxBodyBytes (default 256 KiB). The body-size check fires before JSON parsing, so an oversized junk payload cannot trigger an expensive parse.
  • 429 (TIDE-ORK-DIAGNOSTICS-RATELIMITED) when the per-IP rate limit is exceeded.
  • 503 (TIDE-ORK-DIAGNOSTICS-DISABLED) when Tide:Diagnostics:ErrorReportEnabled is false (kill switch).

Rate limiting

A per-client-IP token bucket sized by Tide:Diagnostics:RateLimitPerMinute (default 5 reports per IP per minute). The bucket dictionary is a static ConcurrentDictionary so it survives request scopes; it lives per replica, not per cluster — for stricter cross-replica limits deploy ThrottleMiddleware in front of the controller. The dictionary opportunistically evicts stale buckets every 50 calls (any bucket whose window is older than 5 minutes is dropped) and has a hard cap of 10,000 entries to defend against IP-spraying attacks.

Email destination + SMTP config

Reports are forwarded to the destination resolved from Tide:Diagnostics:ErrorReportEmailTo (default errors@tide.org).

SMTP transport reuses the shared Ork.Shared.EmailSettings registered at startup regardless of node mode (Authenticator / Payer / Master). The runtime SMTP host + credentials (Azure Communication Services) come in via --Email:* command-line args and override the placeholders in appsettings.json. The send is fire-and-forget on a background task so a stuck SMTP server cannot keep the request thread alive — the 202 response is returned before the email is dispatched, and the Serilog entry is authoritative if SMTP fails. If EmailSettings.SmtpClient or EmailSettings.NetworkCredentials.Password is missing, the email send is skipped (with a LogWarning) rather than throwing on SmtpClient construction — a misconfigured deploy degrades gracefully.

Overriding the destination

Tide:Diagnostics:ErrorReportEmailTo can be set via the standard ASP.NET Core configuration chain — including as the environment variable Tide__Diagnostics__ErrorReportEmailTo (double underscore separators) on the node.

The known reason an operator might need this override is the M365 same-tenant routing parking lot: when Azure Communication Services sends to a destination that lives in the same M365 tenant as the sending domain, the message can be silently dropped by intra-tenant routing rather than delivered. Pointing the override at a destination outside that tenant (Gmail, an aliased mailbox) restores delivery. This is a transport-level workaround — the structured Serilog entry remains authoritative either way.

Subject-line header injection guard

SanitizeEmailHeader strips control characters (CR, LF) and DEL from Error.Code and Error.TraceId before interpolating them into the email Subject header. Without this, an attacker controlling either value could otherwise inject additional headers or a second message body via CRLF.

Vendor-iframe defense

The "Report this error" button is hidden when window.top !== window.self:

const inIframe = (function () { try { return window.top !== window.self; } catch { return true; } })();
if (inIframe) reportBtn.hidden = true;

This composes with the pre-existing EnclaveBase._init clickjacking page-replacement (which fully replaces the SWE document body when loaded inside a frame), giving three independent defences against a malicious vendor coercing a user into surfacing diagnostic state:

  1. The EnclaveBase._init clickjacking replacement removes the Tide DOM entirely when the page is iframed.
  2. If that defence is bypassed by build-time or load-order quirks, the Report button is hidden in the renderer.
  3. Even if both defences fail and a malicious page constructs the report manually, buildReportPayload's allowlist still constrains what can be in the payload, and DiagnosticsController's DTO still constrains what the server will accept.

The Report flow therefore only fires for top-level Tide sessions — which is correct, because the user-consent step (the "expand the preview, then Send" UX) is only meaningful when the user can actually see and trust the surrounding chrome.