openapi: 3.1.0
info:
  title: SeaMeet Media/STT Worker API
  version: 0.4.0
  description: |
    Media/STT Worker-only contract derived from docs/api/openapi.yaml.
servers:
  - url: https://seameet-stt-proxy.seameet.workers.dev
    description: PROD -- Cloudflare Worker
  - url: https://seameet-stt-proxy-dev.seameet.workers.dev
    description: DEV -- Cloudflare Worker
security:
  - supabaseJwt: []
components:
  securitySchemes:
    supabaseJwt:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        Advanced first-party app session auth. Normal public API clients should
        use X-Api-Key instead.
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: >
        Personal API key for machine/agent access (create at
        https://app.seameet.ai/account). Format `smk_<40 hex>`. This is the
        only header normal API clients need. Scopes: read-only keys may call
        entitlement, account-state, usage, delta, get-sections, get-asset,
        download-url(s), and share-list; write scope unlocks the rest of the
        normal surface -- including the Meeting Memory (wiki-*) ops, whose
        reads are NOT on the read-scope list and so need a write-scoped key or
        a user JWT. Key management ops (api-key-*) always require a user JWT,
        as do the irreversible ops (purge-*, empty-trash) and the
        identity-bound ops (team-*, affiliate-*, referral-*), which no key may
        reach at any scope.
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        detail: { type: string }
        reason: { type: string }
    QuotaExceeded:
      type: object
      description: Returned with HTTP 413 when an upload would exceed the storage quota.
      required: [error, usedBytes, quotaBytes, remainingBytes]
      properties:
        error: { const: quota_exceeded }
        usedBytes: { type: integer }
        quotaBytes: { type: integer }
        remainingBytes: { type: integer }
        reservedBytes:
          type: integer
          description: >
            Present only on a web import's multipart-create refusal: the declared
            size of the account's other in-flight web imports, counted against the
            cap so two concurrent imports can't both be admitted over it.
            remainingBytes is net of it.
    Asset:
      type: object
      description: >
        Manifest row (also readable via PostgREST /rest/v1/assets under RLS).
        delta additionally decorates rows with `shared`, `capped`, and -- for
        shared rows -- `ownerDisplayName`.
      required: [id, uid, kind, client_ref, display_name, r2_key, size_bytes,
                 duration_ms, has_video, hash, parent_asset_id, rel_path,
                 link_access, upload_state, trashed_at, trash_expires_at,
                 deleted_at, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        uid: { type: string, format: uuid, description: Owner uid }
        kind: { type: string, enum: [audio, video, audio_for_stt, screenshot, bundle, note, wiki] }
        client_ref: { type: string, description: Sanitized-basename sync identity }
        display_name: { type: [string, 'null'] }
        r2_key: { type: [string, 'null'] }
        size_bytes: { type: integer }
        duration_ms: { type: [integer, 'null'] }
        has_video: { type: boolean }
        hash: { type: [string, 'null'], description: sha256 hex }
        parent_asset_id: { type: [string, 'null'], description: audio_for_stt -> its source recording }
        rel_path: { type: [string, 'null'], description: workspace-relative path (note/wiki) }
        link_access: { type: string, enum: [none, viewer] }
        upload_state: { type: string, enum: [pending, uploading, complete] }
        trashed_at: { type: [string, 'null'], format: date-time }
        trash_expires_at: { type: [string, 'null'], format: date-time }
        deleted_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        shared: { type: boolean, description: 'delta only: true when the row reaches the caller via a grant, not ownership' }
        capped: { type: boolean, description: 'delta only: server applied the reveal cap and content beyond it is hidden' }
        ownerDisplayName: { type: [string, 'null'], description: 'delta only, shared rows: owner display name for "Shared by ..."' }
    SectionVersion:
      type: object
      description: One per-section text row (transcript, summary, notes, ...).
      required: [section, version, content, device_id, updated_at]
      properties:
        asset_id: { type: string, format: uuid, description: Present in delta responses; absent in get-sections. }
        section: { type: string }
        version: { type: integer }
        content: { type: string, description: May be truncated at the reveal cap for non-Pro viewers. }
        device_id: { type: [string, 'null'] }
        updated_at: { type: string, format: date-time }
    AccountBlock:
      type: object
      description: >
        Allowance/meter block consumed by the client walls and meters
        (composeAccount in supabase/functions/_shared/allowance.ts).
      required: [linked, revealCapMs, freeTranscriptionMsLeft, freeOcrLeft,
                 importSecondsThisPeriod, importCapSeconds, ocrImportThisPeriod,
                 ocrCaptureThisPeriod, ocrImportCap, currentPeriodEnd, storageQuotaBytes]
      properties:
        linked: { type: boolean, description: false for anonymous (not-yet-linked) users }
        revealCapMs:
          type: integer
          description: >
            Client-side VIEW mask: transcript content past this offset is
            blurred for free viewers. Sync Pro gets Number.MAX_SAFE_INTEGER
            as a "no mask" sentinel.
        freeTranscriptionMsLeft: { type: integer, description: Remaining ms of the free-tier lifetime generation pool }
        freeOcrLeft: { type: integer }
        importSecondsThisPeriod: { type: integer }
        importCapSeconds: { type: integer }
        ocrImportThisPeriod: { type: integer }
        ocrCaptureThisPeriod: { type: integer }
        ocrImportCap: { type: integer }
        currentPeriodEnd: { type: [string, 'null'], format: date-time }
        storageQuotaBytes: { type: integer }
    Entitlement:
      type: object
      required: [entitled, reason, plan, account]
      properties:
        entitled: { type: boolean }
        reason: { type: string, enum: [ok, no_plan, expired, disabled] }
        plan: { type: string }
        account: { $ref: '#/components/schemas/AccountBlock' }
    AssetMeta:
      type: object
      description: canRead-gated viewer metadata DTO returned by get-asset.
      required: [id, displayName, ownerDisplayName, durationMs, kind, linkAccess, shared, isOwner, createdAt]
      properties:
        id: { type: string, format: uuid }
        displayName: { type: string }
        ownerDisplayName: { type: string }
        durationMs: { type: [integer, 'null'] }
        kind: { type: string }
        linkAccess: { type: string, enum: [none, viewer] }
        shared: { type: boolean, description: Caller already holds a durable grant (grantee/saver). Always false for the owner. }
        isOwner: { type: boolean }
        createdAt: { type: [string, 'null'], format: date-time, description: When the asset was created (a guest file's 7-day retention anchor). }
        transcriptionJob:
          type: object
          description: >
            Owner only, and only when the asset had a file transcription (app.seameet.ai
            upload). Tells a file that is still transcribing, or failed, apart from one
            with no transcript. Omitted for viewers.
          required: [state, error, updatedAt, finishedAt]
          properties:
            state: { type: string, enum: [queued, submitted, polling, persisting, done, failed] }
            error: { type: [string, 'null'], description: "Outcome code: a failure reason when failed (e.g. guest_too_long, poll_timeout, or provider_error when the transcription provider failed), no_speech for a silent file that finished, else null." }
            updatedAt: { type: [string, 'null'], format: date-time }
            finishedAt: { type: [string, 'null'], format: date-time }
        summaryRun:
          type: object
          description: >
            Owner only: the newest summary regeneration requested through an API
            key in the last 30 minutes (summarize-asset). A run still `running`
            after 6 minutes is reported as `failed` with error `timeout`. Omitted
            for viewers and when there is none.
          required: [state, error, startedAt, finishedAt]
          properties:
            state: { type: string, enum: [running, done, failed] }
            error: { type: [string, 'null'], enum: [no_summary, provider_error, internal, timeout, null] }
            startedAt: { type: [string, 'null'], format: date-time }
            finishedAt: { type: [string, 'null'], format: date-time }
    ShareEntry:
      type: object
      description: One access-list row returned by share-list.
      required: [id, granteeUid, granteeEmail, granteeName, role, createdAt, resolvedAt, viaLink]
      properties:
        id: { type: string, format: uuid }
        granteeUid: { type: [string, 'null'], format: uuid }
        granteeEmail: { type: [string, 'null'], description: 'Resolved account email, or the pending invite email.' }
        granteeName: { type: [string, 'null'] }
        role: { type: string, enum: [viewer], description: 'v1: roles reserved but always viewer' }
        createdAt: { type: string, format: date-time }
        resolvedAt: { type: [string, 'null'], format: date-time }
        viaLink: { type: boolean, description: 'Grant was self-saved via the share link, not owner-created.' }
paths:
  /v1/file:
    post:
      operationId: fileStt
      tags: [Media/STT Worker]
      summary: Managed file transcription against the synced copy (Worker)
      security:
        - supabaseJwt: []
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      x-codeSamples:
        - lang: cURL
          label: Transcribe a synced file
          source: |
            curl -sS "https://seameet-stt-proxy.seameet.workers.dev/v1/file" \
              -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
              -H "Content-Type: application/json" \
              -d '{"assetId":"00000000-0000-4000-8000-000000000000","language":"en"}'
      description: >
        Prefers the kind=audio_for_stt child for video assets. 409
        not_synced when the asset isn't complete in R2 -- upload first via
        the sync ops. Soniox/Deepgram chosen by server-side routing.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [assetId]
              properties:
                assetId: { type: string, format: uuid }
                language: { type: string, description: ISO-639-1 hint }
            examples:
              transcribeEnglish:
                summary: Transcribe an English recording
                value:
                  assetId: 00000000-0000-4000-8000-000000000000
                  language: en
      responses:
        '200': { description: '{segments: TranscriptSegment[], provider, durationMs}' }
        '409': { description: not_synced }
  /v1/file/jobs:
    post:
      operationId: fileJobCreate
      tags: [Media/STT Worker]
      summary: Durable file transcription job for a synced upload (Worker; free-allowed, guests included)
      security:
        - supabaseJwt: []
        - apiKey: []
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      description: >
        Inserts (or, for a failed job, resets and restarts) the asset's
        stt_jobs row and answers 202 at once; the client follows the row over
        Realtime. One job per asset: a re-POST for a running job answers the
        same id. A guest (anonymous session) passes extra gates first, in this
        order, all failing closed: the guest config (`guest_disabled`), the
        Turnstile challenge on `turnstileToken` when the config requires it
        (`guest_challenge`), then admission against the per-file length, the
        per-IP daily file count and the global daily guest budget. A Turnstile
        or configuration outage answers 503 `guest_unavailable`, never a
        challenge failure. Signed-in users skip the guest gates entirely.
        A write-scoped personal API key (`X-Api-Key`) works in place of the
        session: it is checked first, a key that does not resolve answers 401
        (never falling back to a Bearer), a read-only key answers 403
        insufficient_scope, and a key user is never treated as a guest. The
        local @seameet/mcp client uses this for `seameet_transcribe_file`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [assetId]
              properties:
                assetId: { type: string, format: uuid, description: The uploaded asset (the audio_for_stt child is preferred for video) }
                language: { type: string, description: 'BCP-47 / ISO-639-1 hint (at most 16 chars kept). Omit on a Retry to keep the stored one.' }
                estimatedDurationMs: { type: number, description: 'Browser-measured length, used only to admit the job (the charge is the provider''s). Over 5 h -> 413 session_too_long.' }
                retranscribe: { type: boolean, description: 'Re-run an existing finished job in a different language (needs `language`); never creates a job.' }
                meta: { type: object, description: 'Landing attribution (origin, locale, page, src, audioOffsetMs); allowlisted keys only, never a reason to refuse.' }
                options: { type: object, description: Per-job ASR options (sanitized and byte-capped). }
                turnstileToken: { type: string, description: 'Guest only: the Cloudflare Turnstile token, required when GET /v1/file/guest-config reports turnstileRequired. Ignored for signed-in users.' }
      responses:
        '202':
          description: Queued (new or restarted job) or the existing job's current state.
          content:
            application/json:
              schema:
                type: object
                required: [jobId, state]
                properties:
                  jobId: { type: string, format: uuid }
                  state: { type: string, description: stt_jobs.state (queued / submitted / polling / persisting / done / failed) }
        '400': { description: 'bad_request -- missing/invalid assetId, or retranscribe without language', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { description: 'unauthorized -- no session, or an unknown/revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403':
          description: >
            insufficient_scope (a read-only API key), not_entitled, or the import verdict's reason (e.g.
            import_cap_reached); for a guest: guest_disabled (guest uploads
            are switched off) or guest_challenge (the Turnstile token was
            missing or rejected).
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404': { description: not_found -- no such asset for the caller, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'not_synced (with uploadState) -- the upload is not complete; retranscribe_refused -- no eligible job to re-run', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '413':
          description: 'session_too_long (estimate over 5 h); for a guest: guest_too_long -- the file is over the per-file limit, with `maxMinutes`'
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string, enum: [session_too_long, guest_too_long] }
                  maxMinutes: { type: integer, description: guest_too_long only -- the per-file limit in minutes }
        '429':
          description: >
            daily_import_limit (free daily file count) or queue_full; for a
            guest: guest_ip_cap -- this network already used today's guest
            files, with `limit` -- or guest_budget -- today's shared guest
            budget is spent.
          content:
            application/json:
              schema:
                type: object
                required: [error]
                properties:
                  error: { type: string, enum: [daily_import_limit, queue_full, guest_ip_cap, guest_budget] }
                  limit: { type: integer, description: guest_ip_cap only -- files per network per UTC day }
        '500': { description: internal, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '503': { description: 'no_route or budget_unavailable (try again); auth_unavailable -- the API key could not be checked (try again); for a guest: guest_unavailable -- the guest gate could not decide (config, IP, Turnstile outage) and failed closed', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /v1/file/budget:
    get:
      operationId: fileBudget
      tags: [Media/STT Worker]
      summary: The caller's file-transcription allowance (advisory; the job admission decides)
      security:
        - supabaseJwt: []
        - apiKey: []
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      description: >
        A session or a personal API key with read or write scope. The upload
        clients check it before uploading so a file that cannot be transcribed
        is refused before its bytes move.
      responses:
        '200':
          description: The allowance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  limitMs: { type: [integer, 'null'] }
                  usedMs: { type: integer }
                  reservedMs: { type: integer }
                  availableMs: { type: [integer, 'null'] }
                  dailyRemaining: { type: [integer, 'null'], description: 'Free plan: files left today; null when unlimited' }
                  maxFileMs: { type: integer, description: 'Per-file cap (5 h)' }
                  maxFileBytes: { type: integer, description: 'Per-file size cap (512 MiB)' }
                  queueRemaining: { type: integer }
        '401': { description: 'unauthorized -- no session, or an unknown/revoked API key', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'insufficient_scope (a key with no scope) or not_entitled', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '503': { description: 'auth_unavailable or budget_unavailable (try again)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /v1/file/guest-config:
    get:
      operationId: fileGuestConfig
      tags: [Media/STT Worker]
      summary: Whether guest (signed-out) file uploads are accepting right now (unauthenticated, cacheable)
      security: []
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      description: >
        Public read for the signed-out upload page: the guest config numbers
        and whether today's shared guest budget still accepts files. Carries
        no user or per-IP data. Always 200: any read failure answers
        `enabled:false, accepting:false, reason:'unavailable'` with the
        default numbers and a shorter cache. CORS is open.
      responses:
        '200':
          description: Guest upload availability.
          headers:
            Cache-Control:
              description: "`public, max-age=60`; `public, max-age=10` on the fail-closed (reason: unavailable) answer"
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [enabled, accepting, reason, maxFileMinutes, ipFilesPerDay, turnstileRequired, revealMinutes, retentionDays]
                properties:
                  enabled: { type: boolean, description: The master switch (false on the fail-closed answer). }
                  accepting: { type: boolean, description: enabled and today's budget (settled + in-flight minutes) is not spent. }
                  reason: { type: [string, 'null'], enum: [disabled, budget, unavailable, null], description: Why not accepting; null when accepting. }
                  maxFileMinutes: { type: integer, description: Per-file length limit. }
                  ipFilesPerDay: { type: integer, description: Guest files per network per UTC day. }
                  turnstileRequired: { type: boolean, description: POST /v1/file/jobs needs a turnstileToken. }
                  revealMinutes: { type: integer, description: Minutes of transcript a guest can read before signing in. }
                  retentionDays: { type: integer, description: Days an unclaimed guest file is kept. }
              examples:
                accepting:
                  value: { enabled: true, accepting: true, reason: null, maxFileMinutes: 120, ipFilesPerDay: 3, turnstileRequired: true, revealMinutes: 15, retentionDays: 7 }
  /v1/gemini-push:
    post:
      operationId: geminiPush
      tags: [Media/STT Worker]
      summary: Server-side R2 -> Gemini File API push (kills the second client upload for deep analysis)
      security:
        - supabaseJwt: []
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [assetId], properties: { assetId: { type: string, format: uuid } } }
      responses:
        '200': { description: '{fileUri, fileName, mimeType, sizeBytes}' }
        '409': { description: not_synced }
        '413': { description: 'too_large -- over the push ceiling; client falls back to direct upload' }
        '504': { description: push_timeout -- client falls back to direct upload }
  /v1/health:
    get:
      operationId: health
      tags: [Media/STT Worker]
      summary: Live STT session counts per provider (unauthenticated, counts only)
      servers:
        - url: https://seameet-stt-proxy.seameet.workers.dev
          description: PROD -- Cloudflare Worker
        - url: https://seameet-stt-proxy-dev.seameet.workers.dev
          description: DEV -- Cloudflare Worker
      security: []
      responses:
        '200': { description: '{sessions: {provider: count}}' }

# ---------------------------------------------------------------------------
# Outbound webhooks (OpenAPI 3.1 `webhooks:`). These describe the POSTs SeaMeet
# sends TO an endpoint you registered via webhook-create -- your server is the
# receiver. Register/list/update/delete the endpoints via the /sync-api#webhook-*
# operations above.
#
# Every delivery carries these headers:
#   X-SeaMeet-Event:     the event name (recording.synced | ai.ready)
#   X-SeaMeet-Delivery:  a unique uuid per delivery attempt
#   X-SeaMeet-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "{t}.{rawBody}")>
# Verify by recomputing v1 over `${t}.${rawBody}` with your endpoint secret and
# rejecting a stale t (recommended tolerance: 5 minutes). Respond 2xx to ack;
# non-2xx / timeout counts as a failure (endpoint auto-disables after 20 in a row).
# ---------------------------------------------------------------------------
