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:
- Session cookie —
Cookie: better-auth.session_token=<token>via the standard Better Auth cookie set after sign-in. Cookie name isbetter-auth.session_token(verified againstnode_modules/.deno/better-auth@1.5.6/.../dist/cookies/index.mjs— default prefixbetter-auth.session_token; no customcookiePrefixis set inpackages/api/src/auth.ts).
- Bearer API key or OAuth access token —
Authorization: Bearer meshi_<hex>(personal API key) orAuthorization: Bearer <oauth_access_token>— for extension contexts where cookie propagation is blocked by browser security policy (e.g., cross-originfetchin a Manifest V3 service worker). Both token types are resolved by the existingresolveBearerTokenmiddleware. Tokens must carrymeshi:writescope — the auth middleware setsscopes: new Set(["meshi:read", "meshi:write"])for session auth; API-key and OAuth tokens must be created/issued withmeshi:writeincluded.
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 toMESHI_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.tsmust be updated to include origins fromMESHI_EXTENSION_ORIGINSalongsidegetTrustedOrigins()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
| Channel | How to obtain ID |
|---|---|
| Dev (unpacked) | Chrome chrome://extensions developer mode — changes per developer machine |
| Canary / beta | From the signed CRX package; stable once published to Chrome Web Store |
| Stable | From 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)
| Field | Where | Purpose |
|---|---|---|
sync_session_id | Request body | Groups 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-Key | HTTP request header | Safe-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):
- Cooperative signal — last POST body includes
final: true. - Explicit close endpoint —
POST /api/v0/sync/linkedin/:sync_session_id/complete(idempotent; see below). - 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 attemptPOST /api/v0/sync/linkedinIdempotency-Key: 11111111-0000-0000-0000-000000000001Body: { 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/linkedinIdempotency-Key: 11111111-0000-0000-0000-000000000001 ← same keyBody: { 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-KeyPOST /api/v0/sync/linkedinIdempotency-Key: 11111111-0000-0000-0000-000000000002 ← new keyBody: { 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 completePOST /api/v0/sync/linkedinBody: { 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)
| Level | Name | Meaning |
|---|---|---|
| L0 | ingested | Contact row written to DB. Basic fields available (name, headline, profilePicture from Voyager). Entity ID assigned. Card can render. |
| L1 | enriched | LinkedIn profile enriched: full work history, education, skills, location, industry from the enrichment service. |
| L2 | inferred | Trait claims written (personality, communication style, professional strengths). The 30-second traits visible in the contact card. |
| L3 | synthesized | Full canonical brief generated (the “Brief” tab in the contact card). |
| L4 | indexed | Embedding 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 viaentity_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_atcolumn, 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:
- Add
linkedin_urn TEXT UNIQUEto theentitytable (or equivalent lookup table). - Add
last_enriched_at TIMESTAMPTZtoentityor a newentity_enrichment_statetable to support the 90-day freshness window. - Add a
linkedin_sync_jobtable with columns:id UUID PRIMARY KEY DEFAULT gen_random_uuid()auth_user_id TEXT NOT NULL REFERENCES "user"(id)sync_session_id UUID NOT NULLbatch_id UUID NOT NULL— server-assigned per-request job identifierpayload_version TEXT NOT NULL DEFAULT 'voyager-v1'contact_count INT NOT NULLaccepted_count INT NOT NULLstatus TEXT NOT NULL DEFAULT 'queued'— enum:queued,processing,complete,failedlast_batch_at TIMESTAMPTZ— for inactivity timeout enforcementcreated_at TIMESTAMPTZ NOT NULL DEFAULT now()expires_at TIMESTAMPTZ NOT NULLUNIQUE(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:
- Store the
job_idreturned from the POST response (pass it through from the extension viapostMessageor a shared API call if the UI is the one making the POST). - Poll
GET /api/v0/sync/linkedin/:job_id/readinessusing the recommended cadence above. - Update the contact list progressively as
levelincrements 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-zal4dependency 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:
- Extension origin policy: use a dedicated
MESHI_EXTENSION_ORIGINSenv var (recommended — keeps extension origin churn separate from web/admin allowlist) vs. add extension origins to the existingMESHI_TRUSTED_ORIGINS? - OAuth access token scope enforcement: the spec recommends requiring
meshi:writescope for all tokens on this route. Does the scope-check middleware need to be applied explicitly per-route, or is the existingscopeFromMethod()middleware sufficient (it sets required scope from HTTP method: POST → write)? - 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 ofmeshi-platform-yed5. - 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)?