Deploying the browser extension
Status: Operator runbook for the Tavrik browser extension’s MDM-managed deployment per ADR-0040 (architecture) + ADR-0041 (distribution + signing-key lifecycle). M4E.12 ships the structural runbook; M4E.13 close-out captures real screenshots from the first end-to-end MDM exercise.
This runbook walks an operator through deploying the Tavrik browser extension into a managed Chromium / Edge / Firefox fleet via group policy. Three tiers:
- Tier 1 — per-browser force-install runbooks. Full operational detail.
- Tier 2 — MDM platform reference. Vendor-doc links + the specific policy fields per platform; operators apply Tier 1 through their MDM of choice.
- Tier 3 — troubleshooting + diagnosis. Identity-gap mode, recognizer-bundle drift, JWT expiry, Web Store / AMO blocks.
Audience and assumptions
- Audience: the operator’s IT administrator (or equivalent) responsible for the managed-browser fleet.
- Assumptions:
- You have administrative access to your organisation’s MDM (Microsoft Intune, Jamf, Workspace ONE, Google Admin, Kandji, Mosyle, etc.) OR direct registry / plist / configuration file access on the target machines.
- Your tenant has been provisioned on the Tavrik gateway and you have a tenant
tenant_id(UUID) plus per-useruser_id(UUID) values for the users you intend to enroll. - You can obtain a per-user credential for each enrollment. Two operator-issued paths exist (ADR-0040 amendment (a); pilot blocker B1, shipped 2026-09-02), both on the admin console page
/tenants/{tenant_id}/extension(operator or root role) or the admin API directly:- MDM enrolment (preferred for managed fleets):
POST /v1/tenants/{tenant_id}/extensions/enrollmentswith{"user_id": "<uuid>"}returns{"jwt", "tenant_id", "user_id", "expires_at"}— the working credential, valid ≤ 24 h, shown once. Place it in thejwtfield of the managed config below. - Pairing code (self-install / popup path):
POST /v1/tenants/{tenant_id}/extensions/pairing-codeswith{"user_id": "<uuid>", "lifetime_seconds": 600}returns a short-lived code (≤ 10 min, shown once; single-use since B4: the gateway consumes the code on the first successful exchange and refuses a replay with the same generic401). The user pastes it into the extension popup; the extension exchanges it atPOST /v1/extension/enrollfor the working JWT. No managedjwtfield is needed on this path. - Both require the gateway and
gateway-adminto run with the sameGATEWAY_EXTENSION_JWT_KEK_REF(the on-prem VM runbook §4; unset ⇒ the admin endpoints and every/v1/extension/*route return 404) and the gateway to be reachable from workstations. Each issuance is audited (admin.extension_pairing_code_issued/admin.extension_enrollment_issued, categorical only — never the secret). - Lifetime caveat the fleet must plan for: a working JWT lives ≤ 24 h and refreshes itself only while still valid (
/v1/extension/enroll/refresh). A workstation idle longer than that (off over a weekend) drops into identity-gap mode and needs a fresh enrolment or pairing code. A longer-lived enrolment credential is a post-pilot ADR-0040 amendment.
- MDM enrolment (preferred for managed fleets):
Pre-deployment checklist
Before pushing any policy:
- Decide your packaging mode. Per ADR-0041 Decision 1, two modes ship: Chrome Web Store (Mode A; deferred — gated on demand) and operator self-hosted (Mode B; the M4E.12 ship default). Most healthcare / financial / sovereign-deployment buyers should use Mode B.
- For Mode B (self-hosted): generate your signing keypair under your own KMS / HSM custody. See § Signing-key generation below.
- Author the managed config. Per ADR-0041 Decision 3, the schema lives at
extension/src/managed/schema.json. Required fields:tenant_id,user_id,jwt,gateway_url,recognizer_bundle_url. Optional:enrollment_id. Reserved (do not populate in M4E.12 deployments):policy_bundle_url. Thejwtvalue comes fromPOST /v1/tenants/{tenant_id}/extensions/enrollments(console:/tenants/{tenant_id}/extension→ “Issue MDM enrolment”) issued for thatuser_id— see the assumptions above. - Validate field shapes. UUIDs must be RFC 4122 lowercase. URLs must be HTTPS. JWT must be three base64url-safe segments separated by dots. Schema-invalid managed config falls into identity-gap mode (per § Tier 3 Identity-gap mode diagnosis) — diagnose before pushing to a wide audience.
- Allowlist your extension id on the gateway. The gateway answers CORS for
/v1/extension/*only for the origins inGATEWAY_EXTENSION_ALLOWED_ORIGINS(ADR-0040 amendment (c)). The Tavrik-signed Chrome id is the default; a re-signed (Mode B) or Firefox build addschrome-extension://<your id>/moz-extension://<uuid>. Nothing is needed at your edge or in the manifest. - Decide the rollout cadence. Pilot (1–10 users) → cohort (100 users) → full rollout. The 60s alarm cadence means JWT-related issues surface within ~1 minute of activation; bulk rollout once the pilot cohort reports clean.
Signing-key generation
Self-hosted Chrome / Edge variants derive their extension ID from the SHA-256 of the operator’s public key. Rotating the signing key changes the extension ID, which breaks every existing install — see ADR-0041 Decision 4 for the rotation runbook covering routine, compromise-triggered, and operator-elective rotations.
Generate an RSA-2048 (or larger) keypair, store the private key under your existing KMS / HSM custody, and publish the corresponding extension ID in your group policy.
# Example — generate a fresh PEM-formatted RSA-2048 private key.# Real deployments should route key generation through the operator's# HSM / KMS rather than openssl on a workstation.openssl genrsa -out operator-signing.pem 2048
# Compute the extension ID from the public key (Chrome's CRX ID# derivation: SHA-256 of the SubjectPublicKeyInfo, take the first# 16 bytes, map each hex nibble to a-p alphabet).openssl rsa -in operator-signing.pem -pubout -outform DER 2>/dev/null \ | openssl dgst -sha256 -binary \ | head -c 16 \ | xxd -p \ | tr 0-9a-f a-pPack the extension into a .crx for self-hosted distribution:
cd extensionpnpm build:chromeCRX_PRIVATE_KEY_PATH=/secure/path/to/operator-signing.pem \CRX_OUTPUT_PATH=/srv/updates/tavrik.crx \ pnpm build:chrome-selfhostHost the .crx at an HTTPS URL reachable by your managed fleet. Author a CRX update XML manifest at a sibling URL declaring the version + .crx URL; this manifest is what Chrome’s update_url field points at.
<?xml version="1.0" encoding="UTF-8"?><gupdate xmlns="http://www.google.com/update2/response" protocol="2.0"> <app appid="<your-32-char-extension-id>"> <updatecheck codebase="https://updates.operator.example/tavrik.crx" version="0.1.0" /> </app></gupdate>Tier 1 — Per-browser force-install runbooks
Chrome Enterprise
Chrome Enterprise enforces extension installation via the ExtensionInstallForcelist policy. The format is the same across Windows / macOS / Linux; only the delivery mechanism differs.
Policy reference: Chrome Enterprise policy list — ExtensionInstallForcelist.
Windows (registry / GPO)
ADMX templates: chrome.admx + chrome.adml from the Chrome Enterprise download bundle. Drop into \\<domain>\sysvol\<domain>\Policies\PolicyDefinitions\.
Registry path:
HKEY_LOCAL_MACHINE\Software\Policies\Google\Chrome\ExtensionInstallForcelistAdd a string value named 1 (or the next sequential index) with content:
<32-char-extension-id>;https://updates.operator.example/tavrik-update.xmlExtensionSettings controls the managed-storage policy values. Author ExtensionSettings in the registry as a JSON string under the Software\Policies\Google\Chrome key:
{ "<32-char-extension-id>": { "installation_mode": "force_installed", "update_url": "https://updates.operator.example/tavrik-update.xml", "managed_storage": { "tenant_id": "00000000-0000-0000-0000-000000000001", "user_id": "00000000-0000-0000-0000-000000000abc", "jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>", "gateway_url": "https://gateway.operator.example", "recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json" } }}macOS (plist)
Configuration profile path: /Library/Managed Preferences/com.google.Chrome.plist.
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>ExtensionInstallForcelist</key> <array> <string><32-char-extension-id>;https://updates.operator.example/tavrik-update.xml</string> </array> <key>ExtensionSettings</key> <dict> <key><32-char-extension-id></key> <dict> <key>installation_mode</key> <string>force_installed</string> <key>update_url</key> <string>https://updates.operator.example/tavrik-update.xml</string> <key>managed_storage</key> <dict> <key>tenant_id</key> <string>00000000-0000-0000-0000-000000000001</string> <key>user_id</key> <string>00000000-0000-0000-0000-000000000abc</string> <key>jwt</key> <string><jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments></string> <key>gateway_url</key> <string>https://gateway.operator.example</string> <key>recognizer_bundle_url</key> <string>https://bundles.operator.example/recognizer-v1.json</string> </dict> </dict> </dict></dict></plist>Push via Jamf / Kandji / Mosyle / Workspace ONE — see Tier 2 for the per-vendor pointers.
Linux (JSON config)
Drop a JSON policy file at /etc/opt/chrome/policies/managed/tavrik.json:
{ "ExtensionInstallForcelist": [ "<32-char-extension-id>;https://updates.operator.example/tavrik-update.xml" ], "ExtensionSettings": { "<32-char-extension-id>": { "installation_mode": "force_installed", "update_url": "https://updates.operator.example/tavrik-update.xml", "managed_storage": { "tenant_id": "00000000-0000-0000-0000-000000000001", "user_id": "00000000-0000-0000-0000-000000000abc", "jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>", "gateway_url": "https://gateway.operator.example", "recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json" } } }}Permissions: chmod 644 tavrik.json; ownership root:root.
Verification — chrome://policy/
After Chrome restart, the extension installs silently. Operators verify via:
chrome://policy/Look for ExtensionInstallForcelist and ExtensionSettings rows; each should report Status=OK, Source=Platform (or Cloud, if Intune-pushed), and the value as authored. Reload (button at the top) forces a policy re-read without restart.
For managed-storage values, navigate to the extension’s options page (or the popup) — the popup status line reads Managed enrolment active (tenant <first-8-chars-of-tenant-id>…) when the schema validates.
Microsoft Edge
Edge re-uses Chrome’s policy schema verbatim. Substitute the registry / plist / JSON paths:
| Platform | Path |
|---|---|
| Windows | HKEY_LOCAL_MACHINE\Software\Policies\Microsoft\Edge\ExtensionInstallForcelist |
| macOS | /Library/Managed Preferences/com.microsoft.Edge.plist |
| Linux | /etc/opt/edge/policies/managed/tavrik.json |
Verification: edge://policy/ (the Edge equivalent of chrome://policy/).
The managed-storage ExtensionSettings JSON shape is identical — Edge accepts the Chrome-target manifest verbatim per ADR-0040 Decision 7. The same dist/chrome/ source bundle (or self-hosted .crx) deploys to both browsers.
Firefox
Firefox does not consume Chrome’s ExtensionInstallForcelist schema. Firefox uses a dedicated policies.json file with the Extensions.Install array.
Policy reference: Mozilla — Enterprise policies via policies.json.
Locations
| Platform | Path |
|---|---|
| Windows | <Firefox install dir>\distribution\policies.json (or via GPO ADMX) |
| macOS | /Applications/Firefox.app/Contents/Resources/distribution/policies.json |
| Linux | /etc/firefox/policies/policies.json (or <Firefox install dir>/distribution/policies.json) |
Policy content
{ "policies": { "ExtensionSettings": { "ai-esb@tavrik.ai": { "installation_mode": "force_installed", "install_url": "https://updates.operator.example/tavrik.xpi", "default_area": "navbar" } }, "3rdparty": { "Extensions": { "ai-esb@tavrik.ai": { "tenant_id": "00000000-0000-0000-0000-000000000001", "user_id": "00000000-0000-0000-0000-000000000abc", "jwt": "<jwt from POST /v1/tenants/{tenant_id}/extensions/enrollments>", "gateway_url": "https://gateway.operator.example", "recognizer_bundle_url": "https://bundles.operator.example/recognizer-v1.json" } } } }}Firefox’s 3rdparty.Extensions.<gecko-id> block is the equivalent of Chrome’s managed_storage — operator-authored values surfaced to the extension via browser.storage.managed. Firefox does NOT consume extension/src/managed/schema.json automatically; the schema is duplicated as the runtime regex set in extension/src/background/identity.ts (per ADR-0041 Decision 3) and operators must apply the same shape by hand.
Signed XPI requirement
Firefox requires force-installed extensions to be signed by Mozilla (AMO) or distributed via the Enterprise channel with a valid gecko.id. Self-hosted XPI distribution requires either:
- AMO listing — submit the XPI to addons.mozilla.org for signing; Mozilla returns a signed XPI you host. M4E.12 does not exercise the AMO submission flow (parallel to the deferred Chrome Web Store flow per ADR-0041 Decision 1).
- Self-signing via Mozilla’s enterprise Add-on Signing API — operator signs the XPI under their AMO developer account; signed XPI is hosted on the operator’s update server. The
update_urlfield inmanifest.firefox.jsonis unused in Firefox MV3 (the policy’sinstall_urlis what drives installation).
For ESR deployments, ensure your fleet runs Firefox ESR 128 or later — the manifest’s strict_min_version: "128.0" excludes older ESR releases. Operators on legacy ESR can request a build-time override; raise the request in your operator-success channel.
Firefox-specific signing-key advantage
Per ADR-0041 Decision 4, Firefox extension IDs are manifest-declared (gecko.id), not key-derived. Rotating the signing key on Firefox does not change the extension ID — existing installs accept the update without policy churn. This is a Firefox-channel advantage operators should consider when planning rotation cadence.
Verification — about:policies#active
After Firefox restart:
about:policies#activeConfirms the ExtensionSettings and 3rdparty blocks are applied. The extension popup status line should read Managed enrolment active (tenant <first-8-chars-of-tenant-id>…).
Tier 2 — MDM platform reference
This tier links to vendor docs for the most common managed-fleet platforms. Do not transcribe vendor UIs into this runbook — vendor screens change; the link is more durable. Every platform below ultimately authors the registry / plist / JSON / policies.json content from Tier 1; the platform is the delivery mechanism.
Microsoft Intune
- Intune — configure Chrome Enterprise via custom configuration profile.
- Policy field name:
ExtensionInstallForcelist(underGoogle Chrome \ Extensions). Apply the Tier 1 Windows shape via Intune’s custom OMA-URI or ADMX-backed configuration profile. - For Edge: Intune ships a built-in Edge configuration profile template; use the
Allow specific extensions to be installedsetting backed by the same<extension-id>;<update-url>syntax. - For Firefox: Intune does not ship a first-class Firefox template; deliver
policies.jsonvia a custom configuration profile that drops the file at the platform path from Tier 1.
Jamf (macOS)
- Jamf Pro — deploying Chrome Enterprise via configuration profiles.
- Policy field name:
ExtensionInstallForcelistinside an Application & Custom Settings payload targetingcom.google.Chrome(orcom.microsoft.Edge, or Firefox via a Custom Settings payload writing to/etc/firefox/policies/policies.json). - Apply the Tier 1 macOS plist content as the payload contents.
VMware Workspace ONE
- Workspace ONE — Chrome Enterprise integration.
- Policy field name:
ExtensionInstallForcelistvia the Chrome Configuration profile. - Per-OS profiles for Windows / macOS / Linux follow the Tier 1 shape.
Google Admin (Workspace)
- Google Workspace — Chrome Browser Cloud Management.
- Policy field name:
Force-installed Apps and ExtensionsunderChrome Browser ▸ User & Browser Settings. - Workspace-managed Chrome installations honour CBCM policies even on personal devices when the user signs in with a managed account; useful for hybrid fleets.
Kandji (macOS)
- Kandji — Chrome configuration via Custom Profile.
- Policy field name:
ExtensionInstallForcelistinside a Custom Profile payload targetingcom.google.Chrome.
Mosyle (macOS)
- Mosyle — Chrome Custom Profile deployment.
- Policy field name:
ExtensionInstallForcelist. Mosyle accepts an uploaded.mobileconfigprofile authored from the Tier 1 macOS plist content.
Tier 3 — Troubleshooting + diagnosis
Identity-gap mode diagnosis
The extension enters identity-gap mode in three cases per ADR-0041 Decision 3:
- No managed config + no paired identity. The MDM has not yet pushed config and the user has not enrolled via the popup. Popup string:
Not enrolled. Paste a pairing code below. - Managed config present but schema-invalid. One or more required fields missing or malformed (non-UUID
tenant_id, non-HTTPSgateway_url, two-segmentjwt, etc.). Popup string:Managed configuration is invalid. Contact your IT administrator. - Managed config valid but JWT expired. JWT validation is server-side; an expired JWT surfaces as a 401 on the next
/v1/extension/evaluatecall. The extension does not enter identity-gap mode automatically on JWT expiry — the worker queues observations locally and replays once the JWT refreshes. - Origin not allowlisted on the gateway. Every call fails at the browser with
Failed to fetch(popup:Enrol failed: Failed to fetch) and nothing reaches the gateway log — the preflightOPTIONS /v1/extension/*was refused. Check the gateway’s startup lineextension CORS allowlistagainst the id shown onchrome://extensions/; fixGATEWAY_EXTENSION_ALLOWED_ORIGINS.
Step-by-step diagnosis
-
Open the extension popup. Read the status string. The two cases above map to two different operator actions.
-
Open
chrome://policy/(Edge:edge://policy/; Firefox:about:policies#active). Confirm the policy blocks are applied at the browser layer. -
Inspect
chrome.storage.managedfrom the extension’s service-worker DevTools. Fromchrome://extensions/, click “service worker” under the Tavrik entry; in the DevTools console run:chrome.storage.managed.get(null).then(v => console.log(v));Operators see exactly what the browser exposes to the extension. Compare against the Tier 1 schema; mismatches localize the misconfiguration.
-
Re-author the managed config. Apply the corrected content via the MDM and force a policy refresh (
chrome://policy/?reload).
Revoking a device
Shipped 2026-09-02 (ADR-0040 amendment (b), pilot blocker B4). Every working credential has an enrolment row (GET /v1/tenants/{tenant_id}/extensions; console /tenants/{tenant_id}/extension → “Enrolled extensions”) keyed by the credential’s own id, with the user, how it was issued (pairing code, MDM enrolment, refresh), expiry, last activity and the tool last seen.
- Find the row — by the pseudonymous
user_idyou assigned at issuance, or by last activity / tool. - Revoke it — console Revoke (reason required: lost or stolen device, user left, credential compromised, other) or
POST /v1/tenants/{tenant_id}/extensions/{enrollment_id}/revoke {"reason": "lost_device"}(operator or root). Audited asadmin.extension_revokedwith the reason. - What happens next — the gateway refuses that credential on
POST /v1/extension/evaluate,POST /v1/extension/enroll/refreshandPOST /v1/extension/audit/replaywithin 60 seconds (401, codeextension_revoked; audited asextension.token_revoked). The extension drops into identity-gap mode on the next tick and shows the enrolment hint. A credential refreshed from the revoked one is refused too; revoking never cascades to other devices of the same user — revoke each row. The reverse is also true: a refresh does not revoke its predecessor — the older credential shows as replaced and stays valid until it expires, so for a lost device revoke the replaced row as well as the current one (the console offers Revoke on both). - Re-enrol the user with a new pairing code or MDM enrolment when appropriate.
Revocation is per credential, and credentials live ≤ 24 h, so a revoked row leaves the list once it has also expired.
Recognizer-bundle drift
Per ADR-0040 Decision 6, the extension stamps recognizer_bundle_version on every extension.observed audit event. The gateway compares against its server-side bundle version; a mismatch fires the extension.recognizer_drift audit event.
Diagnosis
- Query the operator audit dashboard for
extension.recognizer_driftevents scoped to the affectedtenant_id. The event attributes includeextension_version+expected_recognizer_bundle_version+observed_recognizer_bundle_version. - Drift typically resolves on the next 60s alarm tick (the extension refreshes the bundle on the periodic alarm). Persistent drift across >5 minutes signals a pinned-old-version client; investigate via the user’s
chrome://extensions/for the extension version.
Resolution
- Push the new extension version via your MDM update channel. The
chrome://policy/?reloadrefresh cycle picks up the newupdate_urlcontent. - For self-hosted deployments, host the new
.crxat the sameupdate_url; bump the version in the update XML manifest.
JWT expiry + refresh
Per ADR-0040 Decision 3, JWTs are short-lived (24h max). The extension’s 60s alarm refreshes the JWT when within 5 minutes of expiry via POST /v1/extension/enroll/refresh.
Refresh-failure symptoms:
- Popup shows
Managed enrolment active (tenant <8-chars>…)but tool integrations stop emitting audit events. - Gateway audit log shows no
extension.observedevents for the user despite confirmed prompt activity. - Service-worker DevTools console shows
policy-client: HTTP 401lines on the periodic alarm tick.
Resolution path:
- Verify the
gateway_urlfield is reachable from the user’s network (some sovereign-deployment fleets restrict HTTPS egress; the extension’sfetch()calls require connectivity to the configured gateway). - Confirm the JWT in
chrome.storage.managedis recent. JWTs older than 24h require an MDM re-push of a fresh JWT fromPOST /v1/tenants/{tenant_id}/extensions/enrollments(console: “Issue MDM enrolment”), or — on the self-install path — a new pairing code from…/extensions/pairing-codespasted into the popup. - If the fleet is on a restricted egress posture, ensure the gateway URL is on the egress allowlist alongside the
recognizer_bundle_urlhost.
Extension blocked by Web Store / AMO policy
If your operator profile uses the Chrome Web Store path (Mode A — deferred per ADR-0041 Decision 1) and the listing is removed or suspended by Google, the extension stops receiving updates fleet-wide. Mitigation:
- Switch to the self-hosted path (Mode B). The same source bundle in
dist/chrome/packs into a self-hosted.crxviapnpm build:chrome-selfhost. - Update group policy to point at the operator’s
update_urlinstead of the CWS update endpoint. - Operators on Firefox (where AMO is the analogous listing channel) follow the same fallback to operator-signed XPI distribution per Tier 1 Firefox.
Self-hosted is the M4E.12 ship default specifically because this scenario does not depend on a third-party listing channel.
Appendix: signing-key rotation runbook
For the three rotation triggers (routine cadence, compromise-triggered, operator-elective), follow the workflow in ADR-0041 Decision 4.
The TL;DR per trigger:
- Routine (planned, months of notice): generate new keypair, publish new-ID extension alongside old, MDM-push both IDs during a 2-week transition window, retire old after window closes.
- Compromise (urgent, hours): publish new-ID extension immediately; MDM-push new ID; mark old ID as
blocked(not deleted — forensic preservation); record incident in operator’s incident-response trail. - Operator-elective (planned, weeks): same staged path as routine; cutover timeline is operator-decided.
Firefox exemption: signing-key rotation does NOT change gecko.id on Firefox; existing Firefox installs accept the new XPI without policy churn. Operators get a free pass on Firefox key rotation.
Glossary
| Term | Meaning |
|---|---|
| CRX | Chrome eXtension. The signed binary format for Chromium-based browsers. CRX v3 is the current format. |
| MV3 | Manifest V3 — the current Chrome / Firefox extension manifest schema. Replaces MV2. |
| CWS | Chrome Web Store. Google’s first-party extension listing channel. |
| AMO | addons.mozilla.org. Mozilla’s first-party extension listing channel. |
| MDM | Mobile Device Management — the umbrella for managed-endpoint platforms (Intune, Jamf, Workspace ONE, etc.). |
| GPO | Group Policy Object — Windows’ policy mechanism for Chrome Enterprise / Edge configuration. |
| ESR | Extended Support Release. Firefox’s enterprise channel; Tavrik targets ESR 128 or later. |
| ExtensionInstallForcelist | Chrome / Edge’s policy field for force-install extensions. |
| gecko.id | Firefox’s manifest-declared extension ID. Stable across signing-key rotation. |
| identity-gap mode | Per ADR-0040 Decision 5: extension refuses to observe when no valid identity is present; popup surfaces enrollment flow or misconfiguration cue. |
Related documentation
- ADR-0040 — Browser extension architecture: the load-bearing privacy + lifecycle decisions.
- ADR-0041 — Extension distribution + signing-key lifecycle: the architectural source for this runbook.
extension/src/managed/schema.json: the canonical managed-config schema authored by operators.extension/scripts/pack-crx.ts: the operator-runnable CRX-pack script invoked bypnpm build:chrome-selfhost.