openapi: 3.1.0
info:
  title: SeaMeet Sync API
  version: 0.4.0
  description: |
    Sync API-only contract derived from docs/api/openapi.yaml.
    Do not call /sync-api#... paths directly. They are documentation anchors;
    always POST to /sync-api with the documented op value in the JSON body.
servers:
  - url: https://api.seameet.ai/functions/v1
    description: PROD -- Supabase Edge Functions
  - url: https://ifcxzlaynrdykqmcwgbh.supabase.co/functions/v1
    description: DEV -- Supabase Edge Functions
security:
  - supabaseJwt: []
  - apiKey: []
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 mutation ops.
        Key management ops (api-key-*) always require a user JWT.
  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 }
    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]
      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 }
    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:
  /sync-api#entitlement:
    post:
      operationId: entitlement
      tags: [Account and Entitlement]
      summary: Server-truth plan gate + allowance block (answers 403-free for non-entitled users)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: entitlement } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/Entitlement' } } }
  /sync-api#account-state:
    post:
      operationId: accountState
      tags: [Account and Entitlement]
      summary: Allowance/meter block only (free-allowed -- powers walls + meters without the entitlement wrapper)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: account-state } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [account]
                properties:
                  account: { $ref: '#/components/schemas/AccountBlock' }
  /sync-api#set-locale:
    post:
      operationId: setLocale
      tags: [Account and Entitlement]
      summary: Report the client UI language so account emails localize (free-allowed; caller can only set their own row)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, locale]
              properties:
                op: { const: set-locale }
                locale: { type: string, maxLength: 16, description: 'i18next language tag; stored raw, normalized at email send time' }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true }, locale: { type: string } } } } }
        '400': { description: locale missing/empty, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#track_event:
    post:
      operationId: trackEvent
      tags: [Account and Entitlement]
      summary: Funnel telemetry (free-allowed; best-effort -- always 200, DB errors swallowed)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, event]
              properties:
                op: { const: track_event }
                event:
                  type: object
                  required: [name]
                  properties:
                    name: { type: string }
                  additionalProperties: true
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true } } } } }
  /sync-api#resolve-grants:
    post:
      operationId: resolveGrants
      tags: [Account and Entitlement]
      summary: Claim pending email-invite shares addressed to the caller's verified email (free-allowed; atomic; no-op for anonymous/unverified -> claimed 0)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: resolve-grants } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { claimed: { type: integer } } } } }
  # ---------------------------------------------------------------------------
  # Manifest + upload ops (owner-scoped; free-allowed for owner self-writes)
  # ---------------------------------------------------------------------------
  /sync-api#upsert-asset:
    post:
      operationId: upsertAsset
      tags: [Upload and Media]
      summary: Register/refresh a manifest row (idempotent on uid+client_ref+kind; hash dedupe)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, clientRef, kind]
              properties:
                op: { const: upsert-asset }
                clientRef: { type: string, maxLength: 120 }
                kind: { type: string, enum: [audio, video, audio_for_stt, screenshot, bundle, note, wiki] }
                displayName: { type: string }
                ext: { type: string, pattern: '^[A-Za-z0-9]{1,8}$', description: Required for media kinds; omit for text-only kinds (note/wiki/bundle) }
                sizeBytes: { type: integer }
                hash: { type: string, description: sha256 hex; enables dedup short-circuit }
                durationMs: { type: integer }
                hasVideo: { type: boolean }
                originDeviceId: { type: string, maxLength: 80 }
                parentClientRef: { type: string, description: audio_for_stt -> its video }
                relPath: { type: string, maxLength: 512, description: workspace-relative path (note/wiki) }
                localPresent: { type: boolean, description: Media bytes exist on this device's disk (tailors free-tier expiry nudges only) }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  assetId: { type: string, format: uuid }
                  r2Key: { type: [string, 'null'] }
                  dedup: { type: boolean }
                  uploadState: { type: string, enum: [pending, uploading, complete] }
        '400': { description: 'clientRef/kind missing, or ext missing for a media kind', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#upload-url:
    post:
      operationId: uploadUrl
      tags: [Upload and Media]
      summary: Presigned single PUT (1 h expiry, <=100 MB objects). Quota-gated (413 quota_exceeded).
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: upload-url }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
                  r2Key: { type: string }
                  expiresIn: { type: integer, description: seconds (3600) }
        '400': { description: text-only asset (no R2 object), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '413': { description: Storage quota exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#multipart-create:
    post:
      operationId: multipartCreate
      tags: [Upload and Media]
      summary: Start a multipart upload. Quota-gated (413 quota_exceeded).
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: multipart-create }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { uploadId: { type: string } } } } }
        '400': { description: text-only asset, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '413': { description: Storage quota exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/QuotaExceeded' } } } }
  /sync-api#multipart-sign:
    post:
      operationId: multipartSign
      tags: [Upload and Media]
      summary: Presign one part PUT (parts >=5 MB except the last; partNumber 1-10000)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId, partNumber]
              properties:
                op: { const: multipart-sign }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
                partNumber: { type: integer, minimum: 1, maximum: 10000 }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, partNumber: { type: integer }, expiresIn: { type: integer } } } } }
        '400': { description: uploadId/partNumber invalid, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#multipart-complete:
    post:
      operationId: multipartComplete
      tags: [Upload and Media]
      summary: Finish a multipart upload -> upload_state 'complete'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId, parts]
              properties:
                op: { const: multipart-complete }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
                parts:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [partNumber, etag]
                    properties:
                      partNumber: { type: integer }
                      etag: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { const: complete } } } } }
        '400': { description: uploadId/parts missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#multipart-abort:
    post:
      operationId: multipartAbort
      tags: [Upload and Media]
      summary: Abort a multipart upload -> upload_state back to 'pending'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, uploadId]
              properties:
                op: { const: multipart-abort }
                assetId: { type: string, format: uuid }
                uploadId: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { const: pending } } } } }
        '400': { description: uploadId missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#complete-upload:
    post:
      operationId: completeUpload
      tags: [Upload and Media]
      summary: Mark a single-PUT upload complete
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: complete-upload }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, uploadState: { const: complete } } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#rename-asset:
    post:
      operationId: renameAsset
      tags: [Upload and Media]
      summary: Re-point an asset's sync identity after a rename (409 on clientRef clash; R2 object follows via copy+delete)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, clientRef]
              properties:
                op: { const: rename-asset }
                assetId: { type: string, format: uuid }
                clientRef: { type: string, maxLength: 120 }
                displayName: { type: string }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, clientRef: { type: string }, r2Key: { type: [string, 'null'] } } } } }
        '400': { description: clientRef missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
        '409': { description: clientRef already in use for this kind, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # Read ops (canRead-gated: owner | grantee | link viewer; free-allowed)
  # ---------------------------------------------------------------------------
  /sync-api#download-url:
    post:
      operationId: downloadUrl
      tags: [Upload and Media]
      summary: Presigned GET (15 min). disposition 'inline' for streaming/thumbnails, 'attachment' (default) for downloads with a readable filename.
      description: canRead-gated (owner | grantee | link viewer). Trashed assets are readable by the owner only.
      x-codeSamples:
        - lang: cURL
          label: Get a streaming URL
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"download-url","assetId":"00000000-0000-4000-8000-000000000000","disposition":"inline"}'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId]
              properties:
                op: { const: download-url }
                assetId: { type: string, format: uuid }
                disposition: { type: string, enum: [inline, attachment], default: attachment }
            examples:
              inlineStreamingUrl:
                summary: Get a URL suitable for playback
                value:
                  op: download-url
                  assetId: 00000000-0000-4000-8000-000000000000
                  disposition: inline
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { url: { type: string }, expiresIn: { type: integer, description: seconds (900) } } } } }
        '400': { description: text-only asset (no R2 object), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign/purged asset (existence-hiding -- non-viewers always get 404) }
  /sync-api#download-urls:
    post:
      operationId: downloadUrls
      tags: [Upload and Media]
      summary: Batched presigns (<=50 ids, owner-only) -- one round-trip per thumbnail screenful
      description: >
        Owner-only (unlike download-url). Malformed/foreign/incomplete/deleted
        ids are silently omitted from the result map rather than failing the
        batch.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetIds]
              properties:
                op: { const: download-urls }
                assetIds: { type: array, maxItems: 50, items: { type: string, format: uuid } }
                disposition: { type: string, enum: [inline, attachment] }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { urls: { type: object, additionalProperties: { type: string } }, expiresIn: { type: integer } } } } }
        '400': { description: assetIds missing/empty after UUID-shape filtering, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#get-asset:
    post:
      operationId: getAsset
      tags: [Library Reads]
      summary: Viewer metadata for one asset (canRead-gated; the only metadata read path for a link viewer)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: get-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { $ref: '#/components/schemas/AssetMeta' } } }
        '404': { description: Unknown/foreign/purged asset (existence-hiding) }
  /sync-api#get-sections:
    post:
      operationId: getSections
      tags: [Library Reads]
      summary: Read all per-section text for one asset (canRead-gated; the ONLY sanctioned section read path -- reveal cap applied server-side for every caller)
      description: >
        The reveal cap is always the OWNER's: a Pro owner's content passes
        through unmasked; a free owner's content is truncated at the cap for
        every viewer, owner included. `capped` is true iff the server actually
        hid content (cap below the recording length).
      x-codeSamples:
        - lang: cURL
          label: Read transcript and AI sections
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"get-sections","assetId":"00000000-0000-4000-8000-000000000000"}'
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: get-sections }, assetId: { type: string, format: uuid } } }
            examples:
              readTextSections:
                summary: Read transcript, summary, chapters, and notes
                value:
                  op: get-sections
                  assetId: 00000000-0000-4000-8000-000000000000
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  sections:
                    type: array
                    items: { $ref: '#/components/schemas/SectionVersion' }
                  capped: { type: boolean }
                  capMs: { type: integer, description: The applied reveal cap in ms (Number.MAX_SAFE_INTEGER sentinel for Pro owners) }
        '404': { description: Unknown/foreign/purged asset (existence-hiding) }
  /sync-api#delta:
    post:
      operationId: delta
      tags: [Library Reads]
      summary: Incremental manifest pull (owned + shared assets, plus text sections, since a cursor; capped 500 each)
      description: >
        Owned rows include trashed/deleted state (cross-device trash
        propagation); shared rows exclude the owner's trashed/deleted assets.
        Section content is reveal-cap masked -- shared rows by the OWNER's
        entitlement, owned rows by the CALLER's.
      x-codeSamples:
        - lang: cURL
          label: First library sync
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"delta","since":null}'
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, since], properties: { op: { const: delta }, since: { type: string, format: date-time } } }
            examples:
              firstSync:
                summary: First sync
                value:
                  op: delta
                  since: null
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  assets: { type: array, items: { $ref: '#/components/schemas/Asset' } }
                  versions: { type: array, items: { $ref: '#/components/schemas/SectionVersion' } }
                  hasMore: { type: boolean }
        '400': { description: since is not an ISO timestamp, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#put-sections:
    post:
      operationId: putSections
      tags: [Library Reads]
      summary: Write per-section text (LWW; losers archived to conflict_events)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, sections]
              properties:
                op: { const: put-sections }
                assetId: { type: string, format: uuid }
                deviceId: { type: string }
                sections:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [section, content]
                    properties:
                      section: { type: string }
                      content: { type: string }
                      baseVersion: { type: integer }
                      updatedAt: { type: string, format: date-time }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  conflicts:
                    type: array
                    items:
                      type: object
                      properties:
                        section: { type: string }
                        winner: { type: string, enum: [incoming, stored] }
                        version: { type: integer }
        '400': { description: 'sections[] missing or a section lacks {section, content}', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  # ---------------------------------------------------------------------------
  # Sharing ops (owner-gated except save-to-library; free-allowed)
  # ---------------------------------------------------------------------------
  /sync-api#share-create:
    post:
      operationId: shareCreate
      tags: [Sharing]
      summary: Grant a user access to an asset (owner-only; email invite or direct uid; idempotent -- duplicate grants return the existing row)
      description: >
        An invite email addressed to an existing non-anonymous account becomes
        an immediate named grant (granteeUid set); otherwise a pending email
        invite is created and resolved on the invitee's first sign-in
        (resolve-grants). Fires a best-effort invite email.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId]
              properties:
                op: { const: share-create }
                assetId: { type: string, format: uuid }
                email: { type: string, format: email, description: Required unless grantUid is given }
                grantUid: { type: string, format: uuid, description: Direct grant to a known uid }
                role: { type: string, enum: [viewer], description: 'Reserved -- always viewer in v1' }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string, format: uuid }
                  granteeEmail: { type: [string, 'null'] }
                  granteeUid: { type: [string, 'null'] }
                  pending: { type: boolean, description: true = email invite awaiting the invitee's first sign-in }
                  duplicate: { type: boolean, description: Present (true) when the grant already existed }
        '400': { description: email/grantUid missing or invalid email, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#save-to-library:
    post:
      operationId: saveToLibrary
      tags: [Sharing]
      summary: Self-grant -- a permitted non-owner viewer saves a shared recording into their own library (canRead-gated; idempotent)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: save-to-library }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shareId: { type: string, format: uuid }
                  granteeUid: { type: string, format: uuid }
                  pending: { const: false }
                  duplicate: { type: boolean, description: Present (true) when already saved / already a grantee }
        '404': { description: Unknown/foreign/purged/owner-trashed asset (existence-hiding) }
  /sync-api#share-list:
    post:
      operationId: shareList
      tags: [Sharing]
      summary: List an asset's access grants + link access (owner-only; first 200 grants; grantee identities resolved server-side)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: share-list }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  shares:
                    type: array
                    maxItems: 200
                    items: { $ref: '#/components/schemas/ShareEntry' }
                  linkAccess: { type: string, enum: [none, viewer] }
        '404': { description: Unknown/foreign asset }
  /sync-api#share-revoke:
    post:
      operationId: shareRevoke
      tags: [Sharing]
      summary: Delete a grant (owner-only; 404 for a non-owner -- never reveals a shareId exists)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, shareId], properties: { op: { const: share-revoke }, shareId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { revoked: { const: true } } } } }
        '400': { description: shareId missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: "Unknown share, or the caller does not own the share's asset" }
  /sync-api#set-link-access:
    post:
      operationId: setLinkAccess
      tags: [Sharing]
      summary: Toggle anyone-with-the-link viewing for an asset (owner-only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, assetId, linkAccess]
              properties:
                op: { const: set-link-access }
                assetId: { type: string, format: uuid }
                linkAccess: { type: string, enum: [none, viewer] }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { linkAccess: { type: string, enum: [none, viewer] } } } } }
        '400': { description: linkAccess must be 'none' or 'viewer', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown/foreign asset }
  /sync-api#send-digest:
    post:
      operationId: sendDigest
      tags: [Sharing]
      summary: Re-trigger the summary-digest email for the caller's own asset (owner-only; safe to call anytime -- a missing summary makes it a no-op)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: send-digest }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { ok: { const: true }, sent: { type: [integer, 'null'], description: Emails sent (null if the sender did not report a count) } } } } }
        '404': { description: Unknown/foreign asset }
        '500': { description: not_configured -- email secret missing on the server, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '502': { description: send_failed -- the share-emails function errored, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # Webhook endpoint management
  # ---------------------------------------------------------------------------
  /sync-api#webhook-create:
    post:
      operationId: webhookCreate
      tags: [Webhooks]
      summary: Register an outbound webhook endpoint (JWT or write-scoped API key; public https URLs only -- internal/loopback/link-local hosts rejected; max 10; requires verified account + Sync Pro)
      description: >
        Register an endpoint you host; SeaMeet POSTs event deliveries to it (see
        the top-level `webhooks:` section for the payload + signature contract).
        Callable with a session JWT or a **write-scoped** API key -- a key-created
        endpoint records provenance and is cascade-deleted when that key is
        revoked. Deliveries carry headers X-SeaMeet-Event, X-SeaMeet-Delivery
        (uuid), and X-SeaMeet-Signature: `t=<unix seconds>,v1=<hex
        HMAC-SHA256(secret, "{t}.{rawBody}")>`. Verify by recomputing v1 and
        rejecting stale t (recommended tolerance 5 minutes). Endpoints deactivate
        after 20 consecutive delivery failures; any success resets the counter,
        and webhook-update can re-enable a disabled one.
      x-codeSamples:
        - lang: cURL
          label: Subscribe to recording and AI events
          source: |
            curl -sS "https://api.seameet.ai/functions/v1/sync-api" \
              -H "X-Api-Key: $SEAMEET_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"op":"webhook-create","url":"https://example.com/seameet/webhook","events":["recording.synced","ai.ready"]}'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, url]
              properties:
                op: { const: webhook-create }
                url: { type: string, format: uri, description: 'https:// only' }
                events:
                  type: array
                  items: { type: string, enum: [recording.synced, ai.ready] }
                  description: Defaults to both events.
            examples:
              subscribeToBothEvents:
                summary: Subscribe to both webhook events
                value:
                  op: webhook-create
                  url: https://example.com/seameet/webhook
                  events: [recording.synced, ai.ready]
      responses:
        '200':
          description: Created -- `secret` signs deliveries (also retrievable via webhook-list).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  active: { type: boolean }
                  created_by_key_id: { type: ['string', 'null'], format: uuid, description: 'API key that created it, or null if via JWT/web' }
                  created_at: { type: string, format: date-time }
                  secret: { type: string, example: 'whsec_0123...' }
        '400': { description: 'Bad url/events, unverified account, or endpoint limit', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: 'Not entitled, or called with a read-only API key (write scope required)', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#webhook-list:
    post:
      operationId: webhookList
      tags: [Webhooks]
      summary: List webhook endpoints incl. signing secrets and delivery health (reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: webhook-list } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  endpoints:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string }
                        secret: { type: string }
                        events: { type: array, items: { type: string } }
                        active: { type: boolean }
                        failure_count: { type: integer }
                        last_delivery_at: { type: ['string', 'null'], format: date-time }
                        last_status: { type: ['integer', 'null'] }
                        created_by_key_id: { type: ['string', 'null'], format: uuid }
                        created_at: { type: string, format: date-time }
  /sync-api#webhook-update:
    post:
      operationId: webhookUpdate
      tags: [Webhooks]
      summary: Update a webhook endpoint -- re-enable a disabled one and/or change its url/events (JWT or write-scoped API key; reachable after subscription lapse)
      description: >
        Pass any of `active`, `url`, `events`. `active: true` re-enables an
        endpoint auto-disabled by delivery failures and resets its failure
        counter. Owner-scoped.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, endpointId]
              properties:
                op: { const: webhook-update }
                endpointId: { type: string, format: uuid }
                active: { type: boolean, description: 'true re-enables + clears the failure counter' }
                url: { type: string, format: uri, description: 'https:// only' }
                events:
                  type: array
                  items: { type: string, enum: [recording.synced, ai.ready] }
      responses:
        '200':
          description: Updated endpoint (without the secret).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string }
                  events: { type: array, items: { type: string } }
                  active: { type: boolean }
                  failure_count: { type: integer }
                  last_delivery_at: { type: ['string', 'null'], format: date-time }
                  last_status: { type: ['integer', 'null'] }
                  created_by_key_id: { type: ['string', 'null'], format: uuid }
                  created_at: { type: string, format: date-time }
        '400': { description: 'Bad url/events or nothing to update', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: Unknown endpoint, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#webhook-delete:
    post:
      operationId: webhookDelete
      tags: [Webhooks]
      summary: Delete a webhook endpoint (JWT or write-scoped API key; reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, endpointId], properties: { op: { const: webhook-delete }, endpointId: { type: string, format: uuid } } }
      responses:
        '200': { description: Deleted, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: string } } } } } }
        '404': { description: Unknown endpoint, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#api-key-create:
    post:
      operationId: apiKeyCreate
      tags: [API Keys]
      summary: Create a personal API key (JWT-only; plaintext key returned ONCE; max 20 active keys; requires verified account + Sync Pro)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [op, name]
              properties:
                op: { const: api-key-create }
                name: { type: string, maxLength: 80 }
                scopes:
                  type: array
                  items: { type: string, enum: [read, write] }
                  default: [read]
      responses:
        '200':
          description: Created -- `key` is the plaintext credential, shown only here.
          content:
            application/json:
              schema:
                type: object
                required: [id, name, key_prefix, scopes, created_at, key]
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  key_prefix: { type: string, example: smk_ab12cd34 }
                  scopes: { type: array, items: { type: string } }
                  created_at: { type: string, format: date-time }
                  key: { type: string, example: smk_0123abcd... }
        '400': { description: Missing name / unverified account / key limit reached, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '403': { description: Not entitled (Sync Pro required) or called with an API key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#api-key-list:
    post:
      operationId: apiKeyList
      tags: [API Keys]
      summary: List the caller's API keys (metadata only -- never plaintext; reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: api-key-list } } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        key_prefix: { type: string }
                        scopes: { type: array, items: { type: string } }
                        created_at: { type: string, format: date-time }
                        last_used_at: { type: ['string', 'null'], format: date-time }
                        revoked_at: { type: ['string', 'null'], format: date-time }
  /sync-api#api-key-revoke:
    post:
      operationId: apiKeyRevoke
      tags: [API Keys]
      summary: Revoke an API key immediately (reachable after subscription lapse)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, keyId], properties: { op: { const: api-key-revoke }, keyId: { type: string, format: uuid } } }
      responses:
        '200': { description: Revoked, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: string } } } } } }
        '404': { description: Unknown or already-revoked key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#usage:
    post:
      operationId: usage
      tags: [Trash and Account Data]
      summary: Storage usage vs quota (trashed assets count for 7 days)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: usage } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { usedBytes: { type: integer }, quotaBytes: { type: integer } } } } }
  /sync-api#trash-asset:
    post:
      operationId: trashAsset
      tags: [Trash and Account Data]
      summary: Move an asset family to the 30-day Trash (cascades to audio_for_stt children; counts against quota for 7 days)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: trash-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string, description: Family-root asset id (may differ from the requested child id) }, trashedAt: { type: string, format: date-time } } } } }
        '404': { description: Unknown/foreign/already-purged asset }
  /sync-api#restore-asset:
    post:
      operationId: restoreAsset
      tags: [Trash and Account Data]
      summary: Un-trash an asset family
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: restore-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, restored: { const: true } } } } }
        '404': { description: Unknown/foreign/already-purged asset }
  /sync-api#purge-asset:
    post:
      operationId: purgeAsset
      tags: [Trash and Account Data]
      summary: Delete an asset family forever (R2 objects first, tombstone row last; idempotent on already-purged)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, assetId], properties: { op: { const: purge-asset }, assetId: { type: string, format: uuid } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { assetId: { type: string }, purged: { const: true } } } } }
        '404': { description: Unknown/foreign asset }
        '500': { description: purge_failed -- retryable (R2 deletes are re-runnable), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  /sync-api#empty-trash:
    post:
      operationId: emptyTrash
      tags: [Trash and Account Data]
      summary: Purge everything in Trash (per-item failures are skipped and logged)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op], properties: { op: { const: empty-trash } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { purged: { type: integer, description: Number of asset families purged } } } } }
  /sync-api#purge-account:
    post:
      operationId: purgeAccount
      tags: [Trash and Account Data]
      summary: Delete ALL cloud data (requires confirm "DELETE"; R2 prefix wipe + rows)
      requestBody:
        content:
          application/json:
            schema: { type: object, required: [op, confirm], properties: { op: { const: purge-account }, confirm: { const: DELETE } } }
      responses:
        '200':
          description: OK
          content: { application/json: { schema: { type: object, properties: { purged: { const: true }, objectsDeleted: { type: integer } } } } }
        '400': { description: confirm_required -- body.confirm must be 'DELETE', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
  # ---------------------------------------------------------------------------
  # STT Worker endpoints (Cloudflare Worker servers)
  # ---------------------------------------------------------------------------
