Skip to content

LinkedIn Sync v1 — API Contract

LinkedIn Sync v1 — API Contract

Status: Draft — spec only. No implementation has landed. Spec bean: meshi-platform-jy0v Parent epic: meshi-platform-zal4 (LinkedIn Sync Pipeline v1) Date: 2026-05-04 Author: orchestrator


Purpose

This document is the authoritative API contract for the LinkedIn Sync v1 ingest surface. It defines the HTTP endpoints, Zod request/response schemas, auth model, error shapes, and polling contract that extension v3 and the frontend agent UI must implement against. All implementation work under meshi-platform-zal4 uses this document as its source of truth — implementation PRs that diverge from this contract must update this document first and get sign-off before merging.

LinkedIn Sync is a user-driven, recurring pipeline distinct from the admin-driven Person Import pipeline. The extension fetches contacts from LinkedIn’s Voyager API using the user’s active browser session, then POSTs a structured payload to Meshi. Because Voyager already supplies entityUrn, publicIdentifier, firstName, lastName, headline, profilePicture, and contactOrder, the pipeline can skip the linkedin_searching and linkedin_review stages that Person Import requires, collapsing to four stages (ingest → enrich → infer → synthesize). The UX is progressive: the user expects to see their network immediately (L0 ingest) while enrichment and trait inference trickle in over minutes to hours. Multi-tenant deduplication by entityUrn is a first-class concern: 70–90% of contacts across users share the same LinkedIn entity, allowing the platform to reuse enrichment results and slash per-user LLM cost.


Auth Model

Decision: Better Auth session cookie (primary) + API key Bearer token (secondary). meshi:write scope required.

The extension v3 is a browser extension operating in the user’s active LinkedIn session. It has access to the user’s Meshi session cookie (same-origin if the extension injects into my.meshi.io, or via a dedicated token exchange if running cross-origin). The frontend agent UI operates on the same session. Both surfaces must send one of:

  1. Session cookieCookie: better-auth.session_token=<token> via the standard Better Auth cookie set after sign-in. Cookie name is better-auth.session_token (verified against node_modules/.deno/better-auth@1.5.6/.../dist/cookies/index.mjs — default prefix better-auth
    • .session_token; no custom cookiePrefix is set in packages/api/src/auth.ts).
  2. Bearer API key or OAuth access tokenAuthorization: Bearer meshi_<hex> (personal API key) or Authorization: Bearer <oauth_access_token> — for extension contexts where cookie propagation is blocked by browser security policy (e.g., cross-origin fetch in a Manifest V3 service worker). Both token types are resolved by the existing resolveBearerToken middleware. Tokens must carry meshi:write scope — the auth middleware sets scopes: new Set(["meshi:read", "meshi:write"]) for session auth; API-key and OAuth tokens must be created/issued with meshi:write included.

The auth middleware in packages/api/src/middleware/auth.ts already supports all three paths (Bearer API key, OAuth access token, session cookie). No new auth primitives are required. The sync route sits behind the existing authMiddleware with an additional scope check for meshi:write.

Rationale for dual-mode: Manifest V3 service workers cannot send cookies cross-origin. An API key created at extension install time (one-time prompt) is the cleanest extension-side solution and requires no new infrastructure on the API side.


CORS / Cross-Origin Policy

Browser extensions calling https://api.meshi.io send Origin: chrome-extension://<EXTENSION_ID>. The existing getTrustedOrigins() function in packages/api/src/env.ts reflects only web, admin, and API host names plus MESHI_TRUSTED_ORIGINS. Without explicit allowlisting, a Manifest V3 service worker fetch will fail CORS preflight in production.

Required configuration

  • A new environment variable MESHI_EXTENSION_ORIGINS (comma-separated) must be added to the API. Do not add extension origins to MESHI_TRUSTED_ORIGINS — that env is shared with web/admin and mixing extension origin churn into the web allowlist creates unnecessary noise.
  • The CORS middleware in app.ts must be updated to include origins from MESHI_EXTENSION_ORIGINS alongside getTrustedOrigins() on the /api/* path.
  • Format of extension origins: chrome-extension://<EXTENSION_ID> — one value per build channel (dev, canary, stable). Extension IDs are stable within a signing key; they change across signing keys.

Extension channel origins

ChannelHow to obtain ID
Dev (unpacked)Chrome chrome://extensions developer mode — changes per developer machine
Canary / betaFrom the signed CRX package; stable once published to Chrome Web Store
StableFrom Chrome Web Store listing; does not change after first publish

Set MESHI_EXTENSION_ORIGINS=chrome-extension://<stable_id> on the Fly.io production app. Add dev and canary IDs to the staging instance.

host_permissions in extension Manifest

The extension’s manifest.json must declare:

"host_permissions": [
"https://api.meshi.io/*",
"https://api.staging.meshi.io/*"
]

Without host_permissions, the browser will block the service worker’s fetch before it ever reaches CORS.

Preflight

The existing cors() middleware in app.ts handles OPTIONS preflight automatically. The extension must not suppress preflight (do not set mode: "no-cors").


Endpoints

POST /api/v0/sync/linkedin

Accept a batch of Voyager-sourced contacts from the extension. Validate, store, and enqueue for enrichment. Returns a sync_session_id and per-request job_id for readiness polling.

Auth: authMiddleware (session cookie or Bearer token; meshi:write scope required). Rate limit: 10 requests per 5 minutes per authenticated user (mirror of /me/contacts/* limit in app.ts).

Idempotency fields (two distinct concerns)

FieldWherePurpose
sync_session_idRequest bodyGroups multiple POSTs belonging to one user-initiated sync session. The server appends contacts to the existing session row. Re-using across POSTs is the intentional continuation signal — not an error.
Idempotency-KeyHTTP request headerSafe-retry key for a specific HTTP request. The server caches the exact response (status + body) keyed by (user_id, Idempotency-Key) for 24 hours. A retry with the same key returns the cached response byte-for-byte without re-processing.

Generate sync_session_id with crypto.randomUUID() once at the start of a sync session and persist it in chrome.storage.session. Generate a fresh Idempotency-Key per POST attempt; on network-timeout retry, re-use the same Idempotency-Key for that specific request.

Request Schema (Zod)

import { z } from "zod";
/** Voyager v1 per-contact shape.
*
* entityUrn format note: the extension always calls the Voyager connections
* API which returns "urn:li:fsd_profile:<id>" URNs. The server accepts any
* well-formed LinkedIn URN to guard against future Voyager API surface changes
* (e.g. "urn:li:fs_miniProfile:<id>", "urn:li:member:<id>"). Strict format
* enforcement is deferred to the ingestion adapter layer (see meshi-platform-12df). */
export const VoyagerContactV1Schema = z.object({
/** LinkedIn internal entity URN — the canonical dedup key.
* Accepted pattern: ^urn:li:[a-zA-Z_]+:[A-Za-z0-9_\-=]+$
* Required. Must be non-empty. */
entityUrn: z.string().regex(
/^urn:li:[a-zA-Z_]+:[A-Za-z0-9_\-=]+$/,
"entityUrn must be a valid LinkedIn URN",
),
/** LinkedIn public slug used to build profile URLs.
* May contain URL-encoded characters (®, ~, etc).
* Required. Must be non-empty. */
publicIdentifier: z.string().min(1),
/** Given name from Voyager. Required. */
firstName: z.string().min(1),
/** Family name from Voyager. Required. */
lastName: z.string().min(1),
/** Professional headline (e.g., "Staff Engineer at Acme"). Optional — Voyager
* omits it for restricted-visibility profiles. */
headline: z.string().optional().nullable(),
/** CDN URL for the profile picture. Optional — omitted for profiles with
* default avatar or restricted privacy settings.
* Note: LinkedIn CDN URLs may contain query params that fail strict URL
* validation. Loose string validation is intentional; the pipeline normalizes
* and validates URLs at ingestion time. */
profilePicture: z.string().min(1).max(2048).optional().nullable(),
/** 1-based position in the user's "My Network" connection list, as returned by
* Voyager's connections API with sortBy=RECENTLY_ADDED. Lower = more recently
* connected. Required — used for display ordering at L0. */
contactOrder: z.number().int().positive(),
});
export type VoyagerContactV1 = z.infer<typeof VoyagerContactV1Schema>;
/** Top-level sync request. */
export const LinkedInSyncRequestSchema = z.object({
/** Payload schema version. Allows the API to route to a version-specific
* parser adapter. Fixed at "voyager-v1" for this version. */
payload_version: z.literal("voyager-v1"),
/** Client-generated UUID grouping all POSTs in one user-initiated sync session.
* Generate once at session start; persist in chrome.storage.session; reuse for
* every batch POST in this session. Server appends contacts to the session row
* on each POST — not an error. See Idempotency section. */
sync_session_id: z.string().uuid(),
/** ISO 8601 timestamp of when the extension began fetching this batch from
* Voyager. Used for freshness tracking. Example: "2026-05-04T14:23:00Z". */
synced_at: z.string().datetime({ offset: true }),
/** Contacts in this batch. Minimum 1, maximum 1000 per request.
* For large syncs (>1000 contacts), split into sequential POSTs sharing the
* same sync_session_id. */
contacts: z.array(VoyagerContactV1Schema).min(1).max(1000),
/** Optional cooperative session-close signal. When true, the server marks
* the session as complete and stops accepting further POSTs for this
* sync_session_id. Omit or set false on intermediate batches. */
final: z.boolean().optional().default(false),
});
export type LinkedInSyncRequest = z.infer<typeof LinkedInSyncRequestSchema>;

Required request header:

Idempotency-Key: <uuid> (per-request, generated fresh each attempt; reused on retry of same attempt)

If Idempotency-Key is absent the server processes the request normally (no caching). Clients should always supply it for safety.

Response Schema (Zod) — 202 Accepted

/** Per-contact rejection (validation or dedup failure). */
export const SyncRejectionSchema = z.object({
/** 0-based index within the submitted contacts array. */
index: z.number().int().nonnegative(),
/** Human-readable rejection reason. Not for end-user display — for
* extension diagnostics and support. */
reason: z.string(),
});
export const LinkedInSyncResponseSchema = z.object({
/** Session identifier — echoes sync_session_id from the request body.
* Use for readiness polling and the optional explicit-complete endpoint. */
sync_session_id: z.string().uuid(),
/** Per-request job identifier assigned by the server. Stable for the
* lifetime of the session. UUIDv4. */
job_id: z.string().uuid(),
/** Number of contacts accepted into the pipeline in this batch. */
accepted: z.number().int().nonnegative(),
/** Contacts rejected at validation time (missing required fields, malformed
* entityUrn, etc.). Empty array when all contacts were accepted. */
rejected: z.array(SyncRejectionSchema),
/** Meshi entity IDs assigned to each accepted contact, in the same order as
* the accepted contacts (with no entry for rejected indices). Use to poll
* L0–L4 readiness per contact via the readiness endpoint. */
entity_ids: z.array(z.string().uuid()),
});
export type LinkedInSyncResponse = z.infer<typeof LinkedInSyncResponseSchema>;

HTTP status: 202 Accepted (pipeline is async; 200 is reserved for synchronous completion, which is never guaranteed here).

Session completion

A sync session is considered complete via three mechanisms (whichever fires first):

  1. Cooperative signal — last POST body includes final: true.
  2. Explicit close endpointPOST /api/v0/sync/linkedin/:sync_session_id/complete (idempotent; see below).
  3. Server-side inactivity timeout — session auto-marked complete after 10 minutes with no new POST for the same sync_session_id.

Once a session is marked complete, further POSTs with that sync_session_id return 409 Conflict (see Error Shapes).

Worked example — multi-batch sync with retry

# Batch 1 (contacts 1–1000), first attempt
POST /api/v0/sync/linkedin
Idempotency-Key: 11111111-0000-0000-0000-000000000001
Body: { sync_session_id: "aaaa...", payload_version: "voyager-v1", final: false, contacts: [...1000] }
→ 202 { sync_session_id: "aaaa...", job_id: "bbbb...", accepted: 1000, ... }
# Batch 1 — network timeout, exact retry (same Idempotency-Key)
POST /api/v0/sync/linkedin
Idempotency-Key: 11111111-0000-0000-0000-000000000001 ← same key
Body: { sync_session_id: "aaaa...", ... } ← same body
→ 202 { sync_session_id: "aaaa...", job_id: "bbbb...", ... } ← cached response, not re-processed
# Batch 2 (contacts 1001–1847, final batch), new Idempotency-Key
POST /api/v0/sync/linkedin
Idempotency-Key: 11111111-0000-0000-0000-000000000002 ← new key
Body: { sync_session_id: "aaaa...", final: true, contacts: [...847] }
→ 202 { sync_session_id: "aaaa...", job_id: "bbbb...", accepted: 847, ... }
# Session is now marked complete server-side.
# Stale extension retry after session complete
POST /api/v0/sync/linkedin
Body: { sync_session_id: "aaaa...", final: false, contacts: [...] }
→ 409 { error: "Sync session already complete", code: "SESSION_COMPLETE",
sync_session_id: "aaaa...", job_id: "bbbb..." }

POST /api/v0/sync/linkedin/:sync_session_id/complete

Explicitly mark a session as complete. Idempotent — calling it on an already-complete session returns 200 with the session state, not an error. No request body required.

Auth: same as POST sync — session cookie or Bearer token with meshi:write scope. Ownership: sync_session_id must belong to the authenticated user; non-owner → 404.

Response — 200 OK:

{
"sync_session_id": "aaaa...",
"job_id": "bbbb...",
"status": "complete",
"total_accepted": 1847
}

GET /api/v0/sync/linkedin/:job_id/readiness

Poll the enrichment progress for a previously submitted sync job. The frontend agent UI uses this endpoint to drive the progressive UX: show contact cards at L0, add detail as higher levels land.

Auth: same as POST — session cookie or Bearer token with meshi:write scope. The authenticated user must own the job_id (ownership checked against linkedin_sync_job.auth_user_id). Non-owner → 404 (not 403, to avoid job ID enumeration).

Recommended polling cadence: 2 s while any contact is below L2; 30 s once all contacts are ≥ L2 (enrichment is complete; traits/brief are slower). Stop polling when all contacts reach L4 or expires_at is past.

Readiness Levels (L0–L4)

LevelNameMeaning
L0ingestedContact row written to DB. Basic fields available (name, headline, profilePicture from Voyager). Entity ID assigned. Card can render.
L1enrichedLinkedIn profile enriched: full work history, education, skills, location, industry from the enrichment service.
L2inferredTrait claims written (personality, communication style, professional strengths). The 30-second traits visible in the contact card.
L3synthesizedFull canonical brief generated (the “Brief” tab in the contact card).
L4indexedEmbedding vector written. Contact is searchable and participates in matchmaking.

These levels map to the A1 entity_deliverable_readiness domain primitive (Q1 dependency). Until A1 ships, the readiness endpoint can return a stub { level: 0, name: "ingested" } for all contacts.

Response Schema (Zod) — 200 OK

export const ReadinessLevelEnum = z.enum([
"ingested", // L0
"enriched", // L1
"inferred", // L2
"synthesized", // L3
"indexed", // L4
]);
export type ReadinessLevel = z.infer<typeof ReadinessLevelEnum>;
export const ContactReadinessSchema = z.object({
/** Meshi entity ID. Matches the entity_ids array from the POST response. */
entity_id: z.string().uuid(),
/** Current highest completed readiness level for this contact. */
level: z.number().int().min(0).max(4),
/** String name of the current level. */
level_name: ReadinessLevelEnum,
/** True when level = 4 (indexed) — the contact is fully processed. */
complete: z.boolean(),
});
export const LinkedInSyncReadinessResponseSchema = z.object({
job_id: z.string().uuid(),
/** ISO 8601 timestamp after which this job record may be GC'd. Clients
* should stop polling after this time. Typically job creation + 48 hours. */
expires_at: z.string().datetime({ offset: true }),
/** Per-contact readiness. Ordered by contactOrder ascending. */
contacts: z.array(ContactReadinessSchema),
/** Count of contacts at each level (convenience for progress bars). */
summary: z.object({
total: z.number().int(),
l0: z.number().int(),
l1: z.number().int(),
l2: z.number().int(),
l3: z.number().int(),
l4: z.number().int(),
}),
});
export type LinkedInSyncReadinessResponse = z.infer<
typeof LinkedInSyncReadinessResponseSchema
>;

Error Shapes

Note — 401 shape: The existing authMiddleware returns { error: "Unauthorized" } (no code field) — a bare envelope. This is inconsistent with the structured { error, code } shape used by 400/409/429/503. For v1 this spec aligns to the existing behavior to avoid a breaking change. A follow-up bean (meshi-platform-nkq2) tracks standardizing the error envelope across all routes.

All other error responses follow { error: string, code: string } with additional fields per status:

400 Bad Request — Validation error

{
"error": "contacts[3].entityUrn: Invalid LinkedIn URN",
"code": "VALIDATION_ERROR",
"field": "contacts[3].entityUrn"
}

Returned when the Zod schema parse fails. The field is the dot-path of the first failing validation. Mirrors the ValidationError handler in app.ts.

401 Unauthorized

{ "error": "Unauthorized" }

Bare envelope — matches current authMiddleware output. No code field in v1.

409 Conflict — Session already complete

{
"error": "Sync session already complete",
"code": "SESSION_COMPLETE",
"sync_session_id": "aaaa-...",
"job_id": "bbbb-..."
}

Returned when a POST arrives for a sync_session_id that has been marked complete (via final: true, the explicit-complete endpoint, or the 10-minute inactivity timeout). The extension should treat this as a terminal state and stop sending batches for this session.

429 Too Many Requests — Rate limit

{ "error": "Too many requests. Please try again later.", "code": "RATE_LIMITED" }

Headers returned:

  • Retry-After: <seconds> — how long to wait before retrying.
  • X-RateLimit-Limit: 10 — requests allowed per window.
  • X-RateLimit-Remaining: 0 — requests remaining.
  • X-RateLimit-Reset: <unix-timestamp> — when the window resets.

The extension must respect Retry-After and back off; do not retry immediately.

503 Service Unavailable — Pipeline saturated

{
"error": "Sync pipeline temporarily saturated. Try again in a few minutes.",
"code": "PIPELINE_SATURATED"
}

Returned when the Inngest queue depth exceeds a configured high-water mark. Clients should retry with exponential backoff. Retry-After header is set.


Idempotency

Two separate mechanisms for two separate concerns.

Per-request idempotency (Idempotency-Key header)

The Idempotency-Key HTTP header provides safe-retry semantics for a single HTTP request. The server caches the exact response (HTTP status + body) keyed by (auth_user_id, Idempotency-Key) for 24 hours. A repeat request with the same key returns the cached response byte-for-byte without re-processing, regardless of body changes between attempts.

An Idempotency-Key is scoped per user. Two different users can use the same UUID value without collision.

This pattern is intended to be implemented via the B3 central idempotency layer (Q2 dependency, meshi-platform-zal4). Until B3 ships, the endpoint can implement a simpler in-memory or DB-backed cache of (auth_user_id, idempotency_key) → response_body.

Per-session grouping (sync_session_id body field)

The sync_session_id groups multiple POSTs into one logical sync session. The server appends contacts from each POST to the session row and returns the same job_id for all batches in the session. Re-using sync_session_id is the intentional continuation signal — it is not an error and does not trigger caching.


Multi-Tenant Deduplication Notes

This section is design-time guidance. It is not part of the API surface (consumers need not implement it).

entityUrn from Voyager is LinkedIn’s stable, opaque identifier for a profile. It is the canonical dedup key for multi-tenant deduplication:

  • When two users’ sync batches contain the same entityUrn, the platform creates a single entity record and links both users to it via entity_relationship.
  • Enrichment results (L1) from any user’s sync are shared across all users who have the same contact in their network — once enriched, re-enrichment is skipped until the 90-day freshness window elapses (last_enriched_at column, see child bean for migration).
  • LLM inference (L2–L3) is similarly shared: traits and brief are entity-level, not per-user-per-contact.

The dedup lookup key is entity.linkedin_urn = :entityUrn. See the Schema Gap section for the required migration.


Schema Gap: linkedin_urn Column Missing

The linkedin_urn (entityUrn) column does not currently exist on the entity or source_record tables. The existing schema stores LinkedIn identity in person_external_identity with identity_type IN ('linkedin_url', 'linkedin_id'), but there is no direct entityUrn column that supports O(1) lookup by Voyager URN.

Before the POST endpoint can land, a migration must:

  1. Add linkedin_urn TEXT UNIQUE to the entity table (or equivalent lookup table).
  2. Add last_enriched_at TIMESTAMPTZ to entity or a new entity_enrichment_state table to support the 90-day freshness window.
  3. Add a linkedin_sync_job table with columns:
    • id UUID PRIMARY KEY DEFAULT gen_random_uuid()
    • auth_user_id TEXT NOT NULL REFERENCES "user"(id)
    • sync_session_id UUID NOT NULL
    • batch_id UUID NOT NULL — server-assigned per-request job identifier
    • payload_version TEXT NOT NULL DEFAULT 'voyager-v1'
    • contact_count INT NOT NULL
    • accepted_count INT NOT NULL
    • status TEXT NOT NULL DEFAULT 'queued' — enum: queued, processing, complete, failed
    • last_batch_at TIMESTAMPTZ — for inactivity timeout enforcement
    • created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    • expires_at TIMESTAMPTZ NOT NULL
    • UNIQUE(auth_user_id, sync_session_id) — one session per user

Child bean for this migration: meshi-platform-6ks8


Coordination Notes

Legacy extension URL cutover

meshi-extension/lib/config.js currently points to:

production: 'https://temporal-fastapi-43044211660.us-central1.run.app'

Extension v3 must change this to:

production: 'https://api.meshi.io'
staging: 'https://api.staging.meshi.io'
development: 'http://localhost:38100'

The legacy endpoint (/meshi/sync) on the old FastAPI server does not exist in the Hono API. The new endpoint is POST /api/v0/sync/linkedin. The extension must update both the base URL and the path in the same cutover. A feature-flag (MESHI_LINKEDIN_SYNC_V1=1) on the API server gates the route so the old extension version continues to 404 (graceful degradation) until v3 is distributed.

Before stable channel cutover: verify that the extension’s Chrome extension ID has been added to MESHI_EXTENSION_ORIGINS on the staging Fly instance and CORS preflight succeeds against api.staging.meshi.io.

Frontend agent UI

The frontend agent UI consumes this contract for the “did sync land?” status indicator. It must:

  1. Store the job_id returned from the POST response (pass it through from the extension via postMessage or a shared API call if the UI is the one making the POST).
  2. Poll GET /api/v0/sync/linkedin/:job_id/readiness using the recommended cadence above.
  3. Update the contact list progressively as level increments for each contact.

The entity_ids array in the POST response allows the UI to begin rendering L0 contact cards immediately without waiting for the first poll cycle.


Out of Scope

The following concerns are explicitly excluded from this contract and tracked as separate work:

  • Pipeline implementation (the 4-stage state machine, Inngest functions, enrichment service integration) — separate child bean.
  • Dedup engine implementation (entityUrn lookup, freshness window enforcement, cost attribution stub) — separate child bean or subphase of pipeline bean.
  • Billing attribution — cost tracking per entity per user is a Q3 concern (bean meshi-platform-zal4 dependency C1).
  • Plan B fallback (official LinkedIn OAuth API or CSV export upload) — separate investigation bean; not required for v1 launch.
  • Cookie expiry handling — the extension detects Voyager auth failures and surfaces “log into LinkedIn again” prompt. The API need only return 401 if the extension passes a stale Meshi token. Extension-side cookie resume logic is a separate extension child bean.
  • Voyager fetch rate limiting (extension-side, ~1–2 pages/min organic) — extension implementation concern, not API surface.
  • Voyager ToS / detection mitigation — tracked separately in meshi-platform-zal4.
  • API error envelope standardization — tracked in meshi-platform-nkq2.

Legacy Payload Reference

For extension v3 authors: the current (v2) extension sends the following reduced shape to /meshi/sync:

{
"user_id": "<meshiid from chrome.storage>",
"linkedin_contacts": [
{
"linkedin_url": "https://www.linkedin.com/in/<publicIdentifier>",
"avatar_url": "<profilePicture CDN URL>",
"contact_order": 1
}
],
"batch_number": 1,
"total_batches": 3
}

The v1 contract replaces user_id (legacy user ID from the old system) with the Meshi auth session (cookie or API key). It replaces linkedin_url + avatar_url with the full Voyager fields. The batch_number / total_batches fields are superseded by sync_session_id (grouping) and Idempotency-Key (safe retry). All Voyager source fields that the v2 extension discarded (firstName, lastName, headline, entityUrn, publicIdentifier) must be forwarded verbatim in v3.


Open Questions

The following questions must be resolved before implementation begins:

  1. Extension origin policy: use a dedicated MESHI_EXTENSION_ORIGINS env var (recommended — keeps extension origin churn separate from web/admin allowlist) vs. add extension origins to the existing MESHI_TRUSTED_ORIGINS?
  2. OAuth access token scope enforcement: the spec recommends requiring meshi:write scope for all tokens on this route. Does the scope-check middleware need to be applied explicitly per-route, or is the existing scopeFromMethod() middleware sufficient (it sets required scope from HTTP method: POST → write)?
  3. API key acquisition UX in extension: should the extension prompt for an API key at install time (user copies from my.meshi.io/settings), or should there be a one-click “authorize extension” OAuth PKCE flow? Affects scope of meshi-platform-yed5.
  4. L0 latency SLA: the parent epic acceptance criterion is “L0 readiness within 5 seconds of extension POST.” The readiness endpoint is polling-based (minimum 2 s cadence). If pipeline enqueue takes >3 s, the SLA cannot be met without a push mechanism (SSE/WebSocket). Should v1 commit to this SLA (requiring synchronous DB write before 202 response) or relax to “within one poll cycle after POST” (~2–4 s typical)?