openapi: 3.1.0
info:
  title: FuzeFront App Registry API
  version: 1.6.2
  description: >-
    REST API for the FuzeFront **app registry** — the contract by which a
    federated (or standalone) application is registered, activated, and surfaced
    in the host shell's application menu. This contract is frozen OVER the
    existing applications-service (`backend/applications/`); it does not replace
    the legacy `/api/apps` routes but supersedes them under the versioned path
    `/api/v1/app-registry`.

    This file is the single source of truth for `@fuzefront/app-registry-client`.
    Changing the API means amending this file first (see the `api-contract-first`
    skill), then regenerating the client (`openapi-typescript`).

    ### The App Manifest
    The heart of this contract is the **App Manifest** (`AppManifest`): a single
    declarative document, submitted at registration and served back on read, that
    fully describes how the host mounts and chromes an app. It covers four
    capabilities:
      1. registration + activation lifecycle (`registered → activated → suspended`);
      2. **menu substitution** (`chrome.menu = host | substitute`);
      3. **standalone / non-portal** mode (`mode = portal | standalone`);
      4. built-in apps (`builtin: true`, e.g. the seeded `clock` app).

    ### Async surface (events)
    Beyond HTTP, the registry emits Kafka events whose Zod schemas live in
    `shared/src/kafka/schemas/`:
      * `app.registered`  — `appRegisteredSchemaV1`
      * `app.activated`   — `appActivatedSchemaV1`
      * `app.suspended`   — `appSuspendedSchemaV1`
      * `app.heartbeat`   — `appHeartbeatSchemaV1`
    These are the durable, cross-service system-of-record successors to the
    legacy Socket.io `app-registered` / `app-status-changed` emits (which the host
    may keep purely as a live UI push).

    ### Changelog
      * **1.6.2** — non-breaking format correction, completing the 1.6.1 fix.
        `App.organizationId` and `RegisterAppRequest.organizationId` are
        wire-typed TypeID-or-UUID strings, not `format: uuid` — with
        `fuzefront.identity.prefixed-ids` ON at 100% in prod these fields carry
        `org_…` TypeIDs, which are not syntactic UUIDs and failed the old
        constraint (exactly the `PublicationQueueItem.organizationId` bug fixed in
        1.6.1). Loosening a `format` is additive/non-breaking.
      * **1.6.1** — corrections to 1.6.0 before release (non-breaking):
        `PublicationQueueItem.organizationId` is a wire-typed TypeID-or-UUID
        string, not `format: uuid` (a prefixed `org_…` id is not a syntactic
        UUID); the review-queue sort is documented per status (pending →
        `submittedAt`, rejected → `rejectedAt`, approved → `approvedAt` with a
        creation-time fallback), since a reject clears `marketplace_submitted_at`;
        `PublicationQueueItem.icon` documented as always-present (server
        placeholder when the manifest has none); and the personal-vs-platform
        ownership wording on `organizationId` corrected.
      * **1.6.0** — additive, non-breaking. Completes the app-builder design
        frames (`design/frames/app-builder/`, `commissionedByApproval`): adds
        the optional `AppCreator.contactEmail` (frame (e1) "Contact creator"
        row) and the platform-admin-only, cursor-paginated collection
        `GET /publication-requests` that backs the marketplace review queue
        (frame (g)). Both are gated by their existing feature flags
        (`fuzefront.apps.creator-ownership`, `fuzefront.apps.marketplace-publishing`).
      * **1.5.0** — build-with-agent sessions, creator ownership, marketplace
        publishing (flag-gated OFF).
  license:
    name: UNLICENSED

servers:
  - url: '{baseUrl}/api/v1/app-registry'
    description: applications-service, app-registry surface mounted under /api/v1/app-registry
    variables:
      baseUrl:
        default: http://fuzefront-applications:3003
        description: Cluster-internal base URL of the applications-service.

tags:
  - name: apps
    description: Application registry — list/read/register apps.
  - name: lifecycle
    description: Activation / suspension lifecycle transitions.
  - name: onboarding
    description: >-
      Self-service onboarding surfaces an app calls at deploy time (authz policy,
      billing profile) so registering a product requires no platform-side edit.
  - name: heartbeat
    description: App liveness heartbeat (called by the app, not by users).
  - name: builds
    description: >-
      "Build your application" — start a FuzeAgent session that develops and
      deploys an app into the caller's personal account or an organization.
      Gated by the release flag `fuzefront.apps.build-with-agent` (default OFF):
      while OFF every operation answers `503 feature_disabled`.
  - name: publishing
    description: >-
      Publishing tiers — private -> organization -> marketplace. The marketplace
      tier goes through a submit / approve|reject review. Gated by the release
      flag `fuzefront.apps.marketplace-publishing` (default OFF): while OFF every
      operation answers `503 feature_disabled`.
  - name: health
    description: Service liveness / readiness.

security:
  - sessionAuth: []

paths:
  /apps:
    get:
      operationId: listApps
      tags: [apps]
      summary: List registered apps
      description: >-
        Returns the apps visible to the caller, filtered by the caller's
        org/roles and the requested `status`/`mode`. The host launcher and side
        menu call this to render the application menu. Replaces the legacy
        unfiltered `GET /api/apps`.
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by lifecycle status. Omit for all non-suspended.
          schema:
            $ref: '#/components/schemas/AppStatus'
        - name: mode
          in: query
          required: false
          description: Filter by app mode (portal | standalone).
          schema:
            $ref: '#/components/schemas/AppMode'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          description: Opaque pagination cursor returned as `nextCursor`.
          schema: { type: string }
      responses:
        '200':
          description: The page of apps visible to the caller.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [apps]
                properties:
                  apps:
                    type: array
                    items: { $ref: '#/components/schemas/App' }
                  nextCursor:
                    type: [string, 'null']
                    description: Cursor for the next page, or null if last page.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: registerApp
      tags: [apps]
      summary: Register an app
      description: >-
        Registers a new app from its **manifest**. The app starts in `registered`
        status (NOT visible) — it must be activated to appear in the menu. The
        `slug` must be unique. Requires the `apps:register` scope (platform-admin
        or org-admin per visibility). Emits `app.registered`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterAppRequest' }
      responses:
        '201':
          description: App registered (status = registered).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/App' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: An app with this slug already exists.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      operationId: getApp
      tags: [apps]
      summary: Get an app by slug
      description: Returns the full app record (including its manifest).
      responses:
        '200':
          description: The app.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/App' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      operationId: updateApp
      tags: [apps]
      summary: Replace an app's manifest
      description: >-
        Replaces the manifest of an existing app. `slug`, `builtin`, and
        `manifestVersion` are immutable and must match. Requires `apps:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AppManifest' }
      responses:
        '200':
          description: Updated app.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/App' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      operationId: deleteApp
      tags: [apps]
      summary: Delete a registered app
      description: >-
        Permanently removes a registered app. **Built-in apps cannot be
        deleted** (only suspended) — returns 403. Requires `apps:write`.
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden, or the app is built-in and cannot be deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}/activate:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: activateApp
      tags: [lifecycle]
      summary: Activate an app
      description: >-
        Transitions an app to `activated` so it appears in the application menu /
        launcher. Idempotent: activating an already-activated app is a no-op 200.
        Requires `apps:activate`. Emits `app.activated`.
      responses:
        '200':
          description: App activated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/App' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}/suspend:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: suspendApp
      tags: [lifecycle]
      summary: Suspend an app
      description: >-
        Transitions an app to `suspended` (hidden from the menu, record retained).
        Works for built-in apps too. Idempotent. Requires `apps:activate`.
        Emits `app.suspended`.
      responses:
        '200':
          description: App suspended.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/App' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}/policy:
    parameters:
      - $ref: '#/components/parameters/Slug'
    put:
      operationId: putAppPolicy
      tags: [onboarding]
      summary: Submit the app's authorization policy
      description: >-
        Declares the product's OWN Permit resources/actions/roles using BARE keys
        (`Listing`, `seller`). The platform namespaces them by the app's slug
        (`fuzemarket_Listing`) and merges them into the base platform schema before
        syncing to the policy provider, so two products may both define a `Listing`
        or an `admin` without colliding.


        This is the declarative replacement for hand-editing a product policy into
        the platform source tree and adding it to a hardcoded sync list — a step that
        was invisible to the product team and silently skipped the provider when
        forgotten. Idempotent (full replace). Requires `apps:write`.


        ACCEPTANCE IS NOT PROPAGATION. A 200 means the policy was validated and
        STORED. It is pushed to the policy provider by the platform's schema sync,
        which runs on FuzeFront backend boot and as a post-install/post-upgrade Helm
        job — so a policy submitted between those events is live in the registry but
        not yet in the provider, and its roles deny until the next sync. Check
        `GET /health` on the platform backend (the `permit` block) to see which
        registered policies the last sync actually applied, and which it rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductPolicy' }
      responses:
        '200':
          description: >-
            Policy validated and stored. NOT yet pushed to the policy provider — see
            the propagation note above.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [slug, resources, roles]
                properties:
                  slug: { $ref: '#/components/schemas/Slug' }
                  resources:
                    type: integer
                    description: Count of resources accepted in the submitted policy.
                  roles:
                    type: integer
                    description: Count of roles accepted in the submitted policy.
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}/billing-profile:
    parameters:
      - $ref: '#/components/parameters/Slug'
    put:
      operationId: putAppBillingProfile
      tags: [onboarding]
      summary: Register the app's billing profile
      description: >-
        Registers the app's billing `productKey` so the billing service accepts
        checkout for it. Replaces hand-editing the `BILLING_PRODUCT_KEYS` env
        allowlist in platform deploy values, which coupled onboarding a product to a
        platform redeploy. Idempotent. Requires `apps:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BillingProfile' }
      responses:
        '200':
          description: Billing profile registered.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingProfile' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /apps/{slug}/heartbeat:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: heartbeatApp
      tags: [heartbeat]
      summary: Report app liveness
      description: >-
        Called by a running app (or its SDK) to report liveness. Authenticated by
        a per-app heartbeat token, not a user session. Updates `lastSeenAt` and
        emits `app.heartbeat`. Mirrors the legacy `POST /api/apps/:id/heartbeat`.
      security:
        - appHeartbeatToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HeartbeatRequest' }
      responses:
        '200':
          description: Heartbeat accepted.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [accepted, at]
                properties:
                  accepted: { type: boolean }
                  at: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500': { $ref: '#/components/responses/InternalError' }

  /build-sessions:
    post:
      operationId: createBuildSession
      tags: [builds]
      summary: Start a build-with-FuzeAgent session
      description: >-
        Starts a FuzeAgent session that develops an app and deploys it into the
        caller's **personal account** (`context=personal` — the caller's personal
        organization) or an **organization** (`context=organization` — the caller
        needs an active membership and the `apps:register` scope on that org).
        The owner of record of the resulting app is that organization.


        The session id is **server-minted** (`front_abs_…`); the body never
        carries an id. If the builder is not configured on this server the call
        answers `503 builder_unavailable` and persists nothing. If the agent
        launch fails the session is recorded as `failed` (`errorCode:
        launch_failed`) and the call answers `502`.


        Feature flag: `fuzefront.apps.build-with-agent` (release, default OFF) —
        `503 feature_disabled` while OFF. Rate limited.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateBuildSessionRequest' }
      responses:
        '201':
          description: Session created and launched (status = building).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BuildSession' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404':
          description: The named organization does not exist (platform-admin callers only).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: '`context=personal` but the caller has no personal account (`no_personal_org`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '502':
          description: The FuzeAgent launch failed; the session is recorded as `failed` (`launch_failed`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
    get:
      operationId: listBuildSessions
      tags: [builds]
      summary: List build sessions
      description: >-
        The caller's own build sessions, newest first. When `organizationId` is
        given **and** the caller manages that organization (owner/admin, or
        platform admin) every session of that organization is returned.


        Cursor-paginated (`limit` default 50, max 200 — larger values are
        clamped; `cursor` is opaque; a malformed cursor is a 400). Feature flag:
        `fuzefront.apps.build-with-agent` (release, default OFF).
      parameters:
        - name: organizationId
          in: query
          required: false
          description: Restrict to one organization (TypeID `org_…` or uuid).
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from `page.nextCursor`.
          schema: { type: string }
      responses:
        '200':
          description: A page of build sessions.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [items, page]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/BuildSession' }
                  page:
                    type: object
                    additionalProperties: false
                    required: [nextCursor, hasMore]
                    properties:
                      nextCursor: { type: [string, 'null'] }
                      hasMore: { type: boolean }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /build-sessions/{id}:
    parameters:
      - $ref: '#/components/parameters/BuildSessionId'
    get:
      operationId: getBuildSession
      tags: [builds]
      summary: Get a build session
      description: >-
        Visible to the requester, the owning organization's managers
        (owner/admin) and platform admins. Anyone else — and any unknown or
        wrongly-typed id — gets **404, never 403**. Feature flag:
        `fuzefront.apps.build-with-agent` (release, default OFF).
      responses:
        '200':
          description: The build session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BuildSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /build-sessions/{id}/cancel:
    parameters:
      - $ref: '#/components/parameters/BuildSessionId'
    post:
      operationId: cancelBuildSession
      tags: [builds]
      summary: Cancel a build session
      description: >-
        Requester or organization manager. Only from `requested`, `launching`,
        `building` or `deploying` -> `cancelled`; a terminal session answers 409.
        Cancels the platform-side session only (the agent is not signalled) — a
        late agent status callback is then rejected 409. Feature flag:
        `fuzefront.apps.build-with-agent` (release, default OFF).
      responses:
        '200':
          description: The cancelled session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BuildSession' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The session is already in a terminal state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /build-sessions/{id}/status:
    parameters:
      - $ref: '#/components/parameters/BuildSessionId'
    post:
      operationId: reportBuildSessionStatus
      tags: [builds]
      summary: FuzeAgent status callback
      description: >-
        Called by the FuzeAgent builder (platform service account / platform
        admin ONLY — the session's own requester is rejected 403, otherwise they
        could mark an arbitrary app "deployed"). Transitions are **forward-only**
        (`launching -> building -> deploying -> deployed`; `failed` from any
        non-terminal state); `deployed`, `failed` and `cancelled` are terminal and
        immutable (409).


        On `deployed`: `appSlug` is required; the app must exist (422) and must
        belong to the **same organization** as the session (409 `ORG_MISMATCH` — an
        agent can never deploy into a different tenant). The app is linked to the
        session and, when `fuzefront.apps.creator-ownership` is ON and the app has
        no creator yet, the session's requester becomes the creator of record
        (immutable) and receives the initial `creator` Permit role.


        Feature flag: `fuzefront.apps.build-with-agent` (release, default OFF).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BuildSessionStatusReport' }
      responses:
        '200':
          description: The updated session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BuildSession' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Illegal transition (backwards/repeat/terminal) or cross-tenant deploy (`ORG_MISMATCH`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: '`deployed` named an `appSlug` that is not registered (`APP_NOT_FOUND`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /apps/{slug}/publication:
    parameters:
      - $ref: '#/components/parameters/Slug'
    get:
      operationId: getAppPublication
      x-pagination: exempt
      tags: [publishing]
      summary: Get an app's publication state
      description: >-
        Current tier and marketplace review state. Visible to the app's managers
        and platform admins; anyone who can merely read the app gets 404.
        Feature flag: `fuzefront.apps.marketplace-publishing` (release, default OFF).
      responses:
        '200':
          description: The publication state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppPublication' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /apps/{slug}/publication-requests:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: requestAppPublication
      tags: [publishing]
      summary: Submit an app to the marketplace
      description: >-
        Requires permission to mutate the app (`apps:write`). Sets
        `marketplace_submitted_at`, leaves `is_marketplace_approved=false` and
        records `notes`/`requestedBy` in `marketplace_metadata.publication`. 409 if
        a request is already pending (`ALREADY_PENDING`) or the app is already
        published (`ALREADY_PUBLISHED`). Feature flag:
        `fuzefront.apps.marketplace-publishing` (release, default OFF).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicationRequest' }
      responses:
        '201':
          description: Request recorded (status = pending).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppPublication' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: A request is already pending, or the app is already published.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /apps/{slug}/publication-requests/approve:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: approveAppPublication
      tags: [publishing]
      summary: Approve a marketplace publication request
      description: >-
        **Platform admins only** (403 otherwise). Sets `visibility=marketplace`,
        `is_marketplace_approved=true`, `marketplace_approved_at` and
        `approved_by`. 409 `NOT_PENDING` when there is no pending request.
        Feature flag: `fuzefront.apps.marketplace-publishing` (release, default OFF).
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicationApproval' }
      responses:
        '200':
          description: Approved; the app is now in the marketplace tier.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppPublication' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: No pending publication request (`NOT_PENDING`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /apps/{slug}/publication-requests/reject:
    parameters:
      - $ref: '#/components/parameters/Slug'
    post:
      operationId: rejectAppPublication
      tags: [publishing]
      summary: Reject a marketplace publication request
      description: >-
        **Platform admins only** (403 otherwise). Clears `marketplace_submitted_at`
        and records `{reason, rejectedAt, rejectedBy}` in
        `marketplace_metadata.publication.rejection`; the app may be resubmitted.
        409 `NOT_PENDING` when there is no pending request. Feature flag:
        `fuzefront.apps.marketplace-publishing` (release, default OFF).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PublicationRejection' }
      responses:
        '200':
          description: Rejected.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AppPublication' }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: No pending publication request (`NOT_PENDING`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /publication-requests:
    get:
      operationId: listPublicationRequests
      tags: [publishing]
      summary: List marketplace publication requests (admin review queue)
      description: >-
        The cross-app review queue that backs frame (g). Returns one row per app
        whose publication is in the requested `status` (default `pending`),
        **oldest first** so the longest-waiting item leads. "Oldest" is measured
        by the instant that defines the requested status, because a rejected
        request clears `marketplace_submitted_at`:
          * `status=pending` — ascending by `submittedAt`;
          * `status=rejected` — ascending by `rejection.rejectedAt`;
          * `status=approved` — ascending by `approvedAt`, falling back to the
            app's creation time when an approved app has no recorded
            `approvedAt` (legacy/pre-attribution rows).
        Each row carries the app's publication state plus the minimal display
        context a reviewer needs — app display name, icon, owning
        `organizationId`, and the creator contact (subject to the same
        former-member rule as `App.creator`).

        **Platform admins / reviewers only (403 otherwise).** As everywhere in
        this contract, the authorization decision is Permit's, not this flag's
        and not the knowledge of any id — admin/reviewer is enforced server-side
        via Permit.

        All data returned is owned by the app-registry service itself (apps,
        their publication state, and the in-org creator contact), so this is a
        single-service collection, **not** a client-side or cross-service join —
        the frames deliberately do not scan `GET /apps`.

        Cursor-paginated (`limit` default 50, max 200 — larger values are
        clamped; `cursor` is opaque; a malformed cursor is a 400). Feature flag:
        `fuzefront.apps.marketplace-publishing` (release, default OFF).
      parameters:
        - name: status
          in: query
          required: false
          description: >-
            Which publication requests to list. Defaults to `pending` (the
            review queue). `none` is not a request state and is rejected 400.
          schema:
            type: string
            enum: [pending, approved, rejected]
            default: pending
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from `page.nextCursor`.
          schema: { type: string }
      responses:
        '200':
          description: A page of publication requests, oldest first (per-status instant; see operation description).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [items, page]
                properties:
                  items:
                    type: array
                    items: { $ref: '#/components/schemas/PublicationQueueItem' }
                  page:
                    type: object
                    additionalProperties: false
                    required: [nextCursor, hasMore]
                    properties:
                      nextCursor: { type: [string, 'null'] }
                      hasMore: { type: boolean }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /health:
    get:
      operationId: getHealth
      tags: [health]
      summary: Liveness / readiness
      description: Unauthenticated liveness/readiness probe for the applications-service.
      security: []
      responses:
        '200':
          description: Service healthy.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [status]
                properties:
                  status: { type: string, enum: [ok] }
        '500': { $ref: '#/components/responses/InternalError' }

components:
  securitySchemes:
    sessionAuth:
      type: http
      scheme: bearer
      description: >-
        Platform user session (Authentik OIDC bearer). The applications-service
        authorizes via Permit. Scopes referenced in this contract:
        `apps:register`, `apps:write`, `apps:activate`.
    appHeartbeatToken:
      type: http
      scheme: bearer
      description: >-
        Per-app heartbeat token issued at registration. Authenticates the app
        process (not a user) for the heartbeat endpoint only.

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: The app's immutable url-safe slug (e.g. `clock`).
      schema: { $ref: '#/components/schemas/Slug' }

    BuildSessionId:
      name: id
      in: path
      required: true
      description: Build session TypeID (`front_abs_…`).
      schema: { type: string, pattern: '^front_abs_[0-9a-z]+$' }

  responses:
    TooManyRequests:
      description: Rate limit exceeded.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ServiceUnavailable:
      description: >-
        The operation's release flag is OFF (`feature_disabled`), or — for
        `createBuildSession` — the builder is not configured (`builder_unavailable`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid session / token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: The caller lacks the required scope for this object.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: App not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationError:
      description: Request body / manifest failed validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationErrorBody' }
    InternalError:
      description: Unexpected server error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Slug:
      type: string
      description: URL-safe unique identifier, immutable once registered.
      pattern: '^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$'
      examples: ['clock', 'market']

    AppMode:
      type: string
      description: >-
        A surface the app can be served on. NOT mutually exclusive — see
        `AppManifest.modes`, which is the multi-valued form.
        `portal` — mounted inside the host shell at `/app/:slug` with chrome.
        `standalone` — rendered without portal chrome on its own host/path
        (`routing.host`). This is the surface a mobile TWA/APK wraps, and the
        only one that can be, because an app store needs a URL that stands on
        its own.
        `embed` — rendered inside a THIRD-PARTY page via an SDK, with neither
        portal chrome nor FuzeFront navigation. An embed-only product is not a
        portal destination and may not register a menu entry at all.
      enum: [portal, standalone, embed]

    AppStatus:
      type: string
      description: Lifecycle status. registered → activated → suspended.
      enum: [registered, activated, suspended]

    IntegrationType:
      type: string
      enum: [module-federation, iframe, web-component, spa]

    Visibility:
      type: string
      enum: [private, organization, public, marketplace]

    Icon:
      type: object
      additionalProperties: false
      required: [kind, value]
      description: App icon — either an emoji glyph or an image URL.
      properties:
        kind: { type: string, enum: [emoji, url] }
        value:
          type: string
          description: The emoji character, or an https URL to the icon image.

    Integration:
      type: object
      additionalProperties: false
      required: [type]
      description: How the host loads/mounts the app.
      properties:
        type: { $ref: '#/components/schemas/IntegrationType' }
        remoteEntry:
          type: string
          format: uri-reference
          pattern: '^(https?://[^\s]+|/(?![/\\])[^\s?#\\]*)$'
          description: >-
            FULL Module-Federation remote entry — the complete entry, NOT a base
            (this freezes the legacy base-vs-entry ambiguity). Required when
            `type = module-federation`.

            Two accepted shapes:

            * **Same-origin absolute path** (PREFERRED for apps deployed in the
              same cluster as the host), e.g.
              `/apps/fuzequality/assets/remoteEntry.js`. The browser resolves it
              against the host shell's origin and the host ingress proxies
              `/apps/<slug>/*` to the app's in-cluster Service, so the asset is
              served without a second public/CDN round-trip. Host-agnostic: the
              same manifest works on the prod host, a tenant wildcard host, and
              localhost.
            * **Absolute `http(s)` URL**, e.g.
              `https://clock.example.com/remoteEntry.js`, for remotes hosted
              outside the cluster.

            Protocol-relative values (`//host/x.js`) are REJECTED: a browser
            treats them as cross-origin, defeating the purpose of the
            same-origin shape.
        scope:
          type: string
          description: >-
            MF remote scope/name (required for module-federation), OR, for
            `web-component`, the custom-element tag name the shell
            instantiates (e.g. `my-widget`). See
            docs/guides/WEB_COMPONENT_INTEGRATION.md for the latter.
        module:
          type: string
          description: Exposed module path, e.g. `./ClockApp`. Required for module-federation.
        url:
          type: string
          format: uri-reference
          pattern: '^(https?://[^\s]+|/(?![/\\])[^\s?#\\]*)$'
          description: >-
            Navigable URL for `iframe` and `standalone`/`spa` integrations,
            OR, for `web-component`, the script URL the shell injects to
            define the custom element named by `scope` (see
            docs/guides/WEB_COMPONENT_INTEGRATION.md). Accepts the same two
            shapes as `remoteEntry`: a same-origin absolute path such as
            `/apps/fuzesocial/` (preferred for in-cluster apps — the host
            ingress proxies it, so the iframe/script does not re-enter the
            public edge) or an absolute `http(s)` URL for externally hosted
            apps.

    MenuItem:
      type: object
      additionalProperties: false
      required: [id, label]
      description: An app-declared menu item (for chrome substitution or app section).
      properties:
        id: { type: string }
        label: { type: string }
        icon: { type: string, description: 'icon name or emoji' }
        route: { type: string, description: 'in-app route the item navigates to' }
        order: { type: integer }

    NavSection:
      type: string
      description: >-
        The app's place in the company's day-to-day lifecycle. The host groups the
        side menu by this, in the order declared here:
        steer -> plan -> build -> sell -> serve -> measure -> operate.
        Apps that do not declare one fall into `platform` (last).
      enum: [executive, plan, build, revenue, customer, insight, platform]
      default: platform

    Nav:
      type: object
      additionalProperties: false
      description: >-
        Where the app sits in the host's side menu. The app DECLARES its own
        placement here rather than the platform holding a central ordering table,
        so onboarding a product never requires a FuzeFront edit.
      properties:
        section: { $ref: '#/components/schemas/NavSection' }
        order:
          type: integer
          minimum: 0
          maximum: 9999
          default: 999
          description: >-
            Rank WITHIN the section — or within the suite, when `suite` is declared.
            Ascending. Ties break on `slug` so ordering is always total and stable.
            Leave unset (999) to sort after apps that care.
        suite: { $ref: '#/components/schemas/Suite' }

    Suite:
      type: object
      additionalProperties: false
      required: [id, label]
      description: >-
        Groups sibling apps that are surfaces of ONE product into a single named
        menu group. A repo that ships several independently-mountable surfaces —
        FuzeHub's talent / recruiter / ventures / marketplace / admin remotes — must
        register one app per surface, because `roles`, `visibility`, `integration`
        and `nav` are all per-surface and a single row cannot hold five of each.
        Without this key those five register as five unrelated top-level entries and
        the product disappears as a concept.

        Each sibling carries its own copy rather than the platform holding a central
        suite table, for the same reason `nav.section` is self-declared: onboarding a
        product must never require a FuzeFront edit. Siblings are expected to agree;
        the host resolves disagreement deterministically (lowest `order` wins, ties
        on the alphabetically-first `slug`) instead of picking arbitrarily.
      properties:
        id:
          allOf:
            - $ref: '#/components/schemas/Slug'
          description: >-
            Stable group key, shared verbatim by every sibling. Apps with the same
            `id` in the same `section` render as one group.
        label:
          type: string
          maxLength: 40
          description: Display name of the group (e.g. "FuzeHub").
        order:
          type: integer
          minimum: 0
          maximum: 9999
          default: 999
          description: >-
            Rank of the GROUP within its section — distinct from `nav.order`, which
            ranks this app within the group. Sorting is section → suite.order →
            nav.order.

    Chrome:
      type: object
      additionalProperties: false
      description: >-
        How the host's chrome behaves while this app is active. Ignored for
        `mode = standalone` (standalone apps render with no portal chrome).
      properties:
        menu:
          type: string
          enum: [host, substitute]
          default: host
          description: >-
            `host` — keep the portal side menu (app may still push runtime items).
            `substitute` — the app provides its OWN full side menu; the host yields
            its portal menu and renders `items` instead. The host always retains a
            minimal "return to portal" affordance so the user is never trapped.
        topbar:
          type: string
          enum: [host, hidden]
          default: host
        items:
          type: array
          description: App-declared menu items, used when `menu = substitute`.
          items: { $ref: '#/components/schemas/MenuItem' }

    Routing:
      type: object
      additionalProperties: false
      description: Routing for the app. `host` applies to standalone only.
      properties:
        path:
          type: string
          pattern: '^/[a-zA-Z0-9/_-]*$'
          description: >-
            Path the app is reachable at. For portal apps this is informational
            (host mounts at `/app/:slug`); for standalone it is the routed path.
        host:
          type: string
          description: >-
            Standalone-only: a dedicated host (e.g. `clock.fuzefront.com`). When
            omitted, standalone apps are path-based under the main app host.

    Auth:
      type: object
      additionalProperties: false
      description: >-
        Identity the app needs the platform to provision for it. Today an Authentik
        application + OIDC provider is created by hand-writing a blueprint under
        FuzeFront's `deploy/helm/fuzefront/authentik/blueprints/`, which means
        onboarding a product's identity requires an edit INSIDE FuzeFront — the same
        coupling that `nav` removed for the side menu and `policy` removed for authz.
        Declaring it here lets the product own it.
      properties:
        oidc: { $ref: '#/components/schemas/AuthOidc' }

    AuthOidc:
      type: object
      additionalProperties: false
      required: [redirectUris]
      description: >-
        An OIDC client for this app. Describes only what the PRODUCT knows; every
        secret stays on the platform side.
      properties:
        applicationSlug:
          allOf:
            - $ref: '#/components/schemas/Slug'
          description: >-
            Authentik application slug. Defaults to the app's own `slug` when omitted.
            The server MUST reject a value claimed by a different app — a manifest is
            self-declared at registration, so an unchecked slug would let one product
            bind itself to another product's identity.
        clientType:
          type: string
          enum: [confidential, public]
          default: confidential
          description: >-
            `public` (PKCE, no secret) is for clients that cannot hold one — a
            browser-only SPA or a mobile/TWA build. Anything with a server keeps
            `confidential`.
        redirectUris:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            format: uri
            pattern: '^(https://|http://localhost([:/]|$)|http://127\.0\.0\.1([:/]|$))[^*\s]*$'
            maxLength: 2048
          description: >-
            Exact callback URIs. HTTPS only, except loopback for local dev. NO
            wildcards: a redirect URI is where the authorization code is delivered, so
            a permissive entry is a token-theft primitive, not a convenience. The
            server SHOULD additionally require each host to be one the app already
            declares (`routing.host`, or its integration URL).
        postLogoutRedirectUris:
          type: array
          uniqueItems: true
          items:
            type: string
            format: uri
            pattern: '^(https://|http://localhost([:/]|$)|http://127\.0\.0\.1([:/]|$))[^*\s]*$'
            maxLength: 2048
        scopes:
          type: array
          uniqueItems: true
          default: [openid, email, profile]
          items: { type: string, maxLength: 64 }
          description: Scopes the client requests. `openid` is implied and always granted.
        claims:
          type: object
          additionalProperties: { type: string, maxLength: 64 }
          description: >-
            Maps an OIDC claim to the field the app expects, e.g.
            `{ "sub": "userId", "groups": "roles" }`.
        secretRef:
          type: object
          additionalProperties: false
          required: [name, key]
          description: >-
            Where the platform finds this client's secret — a (Sealed)Secret reference,
            NEVER the value. The manifest is committed to a product repo AND served back
            on read, so a literal secret here would be published twice over. There is
            deliberately no field to put one in.
          properties:
            name: { type: string, maxLength: 253 }
            key: { type: string, maxLength: 253 }

    Infra:
      type: object
      additionalProperties: false
      description: >-
        Platform infrastructure the (typically standalone) app opts into. Portal
        apps implicitly get auth + api; this block is mainly meaningful for
        standalone apps that selectively use FuzeFront infra.
      properties:
        auth: { type: boolean, default: true, description: 'Authentik identity/SSO' }
        billing: { type: boolean, default: false, description: 'Stripe billing surface' }
        api: { type: boolean, default: true, description: 'Same-origin platform API' }
        deployOnFuzeInfra:
          type: boolean
          default: true
          description: 'Deployed via the FuzeInfra GitOps pipeline'

    Distribution:
      type: object
      additionalProperties: false
      description: >-
        Public installation destinations for a product's native mobile build.
        Omit a field until that exact store listing or APK is publicly available;
        clients must never synthesize download URLs from a product name.
      properties:
        appleAppStoreUrl:
          type: string
          format: uri
          pattern: '^https://'
          description: Public Apple App Store product URL.
        googlePlayUrl:
          type: string
          format: uri
          pattern: '^https://'
          description: Public Google Play Store product URL.
        apkUrl:
          type: string
          format: uri
          pattern: '^https://'
          description: Direct public Android APK URL, normally the product's latest GitHub Release asset.

    AppManifest:
      type: object
      additionalProperties: false
      required: [manifestVersion, slug, name, menuLabel, mode, integration]
      description: >-
        The declarative document describing how the host mounts and chromes an
        app. Stored on the app row, served back on read, and the single shared
        type across UI / backend / tests.
      properties:
        manifestVersion: { type: string, enum: ['1'] }
        slug: { $ref: '#/components/schemas/Slug' }
        name: { type: string, maxLength: 120 }
        menuLabel:
          type: string
          maxLength: 40
          description: Label shown in the application menu / launcher (e.g. "Market").
        description: { type: string, maxLength: 1024 }
        icon: { $ref: '#/components/schemas/Icon' }
        mode:
          allOf:
            - $ref: '#/components/schemas/AppMode'
          deprecated: true
          description: >-
            DEPRECATED — single-surface form, superseded by `modes`. A product can
            genuinely serve more than one surface (portal in the browser, standalone
            for its own host and for a mobile wrapper), which this scalar cannot say.
            Still REQUIRED so existing manifests keep validating; when both are
            present `modes` wins and `mode` MUST be its first entry.
        modes:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: '#/components/schemas/AppMode' }
          description: >-
            Every surface this app supports, in preference order — the first entry
            is how the host serves it by default. `["portal", "standalone"]` is the
            common case: mounted in the shell on desktop/web, and separately
            reachable on its own host so a mobile build has something to wrap.
            Omit to fall back to `[mode]`.
        builtin:
          type: boolean
          default: false
          description: Shipped with the platform; cannot be deleted, only suspended.
        integration: { $ref: '#/components/schemas/Integration' }
        chrome: { $ref: '#/components/schemas/Chrome' }
        nav: { $ref: '#/components/schemas/Nav' }
        auth: { $ref: '#/components/schemas/Auth' }
        routing: { $ref: '#/components/schemas/Routing' }
        infra:
          $ref: '#/components/schemas/Infra'
        distribution:
          $ref: '#/components/schemas/Distribution'
        visibility:
          $ref: '#/components/schemas/Visibility'
        scopeLevel:
          type: string
          enum: [personal, organization, both]
          description: Where the app may be installed (personal, organization, or either).
        requiresOrgContext:
          type: boolean
          description: Whether the app strictly requires an active organization context to run.
        installMode:
          type: string
          enum: [self, everyone, both]
          description: Allowed installation modes (self, everyone, or both). 'everyone' enforces whole-organization only installs.
        organizationInstallMode:
          type: string
          enum: [self, everyone, both]
          description: Optional organization-specific installation mode. Use 'everyone' with scopeLevel 'both' to allow personal installs while requiring organization installs to cover the whole organization.
        orgLevelOnly:
          type: boolean
          description: Whether installation is restricted to the whole organization only (cannot be installed to a single user in an org).
        roles:
          type: array
          description: Roles permitted to see/activate the app. Empty = all members.
          items: { type: string }

    App:
      type: object
      additionalProperties: false
      required: [slug, status, mode, builtin, manifest, createdAt, updatedAt]
      description: A registered app — its server-managed state plus its manifest.
      properties:
        slug: { $ref: '#/components/schemas/Slug' }
        status: { $ref: '#/components/schemas/AppStatus' }
        mode: { $ref: '#/components/schemas/AppMode' }
        builtin: { type: boolean }
        organizationId:
          type: [string, 'null']
          description: >-
            Owning org as a wire-typed reference — a TypeID (`org_…`, the value
            `fuzefront.identity.prefixed-ids` returns in prod) or a bare UUID
            (legacy/unprefixed). NOT `format: uuid`: a prefixed TypeID is not a
            syntactic UUID and would fail that constraint. `null` is reserved for
            a genuinely platform-global app with no owning org. A reference/read,
            never an identity or a capability (authority is Permit + org role).
        manifest: { $ref: '#/components/schemas/AppManifest' }
        isHealthy:
          type: [boolean, 'null']
          description: Latest heartbeat-derived health, or null if unknown.
        lastSeenAt:
          type: [string, 'null']
          format: date-time
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy:
          type: [string, 'null']
          description: >-
            Creator/publisher of record (`usr_…`), or null. **Present only when
            the release flag `fuzefront.apps.creator-ownership` is ON.**
            Server-set from the authenticated principal, informational and
            immutable — never accepted in a request body, and never a source of
            authority (authority is Permit + org role; the owner of record is
            `organizationId`).
        creator:
          description: >-
            The creator as an in-org contact, or null. **Present only when
            `fuzefront.apps.creator-ownership` is ON.**
          oneOf:
            - { $ref: '#/components/schemas/AppCreator' }
            - { type: 'null' }

    AppCreator:
      type: object
      additionalProperties: false
      required: [userId, isCurrentMember]
      description: >-
        The app's creator. `displayName` and `contactEmail` are returned ONLY
        while the creator is still an active member of the owning organization;
        a former member is reduced to `isCurrentMember: false` (no name/contact)
        — the app stays with the organization.
      properties:
        userId: { type: string, description: 'Creator user TypeID (`usr_…`).' }
        isCurrentMember: { type: boolean }
        displayName: { type: string }
        contactEmail:
          type: string
          format: email
          description: >-
            The creator's contact address, backing frame (e1)'s "Contact
            creator" row. Optional and informational — returned under the same
            rule as `displayName` (only while `isCurrentMember` is true; absent
            for a former member). A reference/read, never an identity or a
            capability; authority is Permit + org role. The UI renders the
            Contact row only when this is present, never a guessed address.

    RegisterAppRequest:
      type: object
      additionalProperties: false
      required: [manifest]
      properties:
        manifest: { $ref: '#/components/schemas/AppManifest' }
        organizationId:
          type: [string, 'null']
          description: >-
            Owning org for org-scoped apps as a wire-typed reference — a TypeID
            (`org_…`, the value `fuzefront.identity.prefixed-ids` returns in prod)
            or a bare UUID (legacy/unprefixed). NOT `format: uuid`: a prefixed
            TypeID is not a syntactic UUID and would fail that constraint. Omit
            (or send `null`) for a platform-global app. A reference to an org that
            already exists, never an identity minted here.

    ProductResourceDecl:
      type: object
      additionalProperties: false
      required: [key, name, actions]
      description: A resource the product owns, declared with a BARE key.
      properties:
        key:
          type: string
          pattern: '^[A-Za-z][A-Za-z0-9-]*$'
          description: >-
            Bare resource key, e.g. `Listing`. Namespaced to `<slug>_Listing`.
            May NOT contain `_` — that is the namespace separator and the key must
            split cleanly at the first one.
        name: { type: string }
        relations:
          type: object
          description: Map of bare relation names to bare resources in this same product.
          additionalProperties:
            { type: string, pattern: '^[A-Za-z][A-Za-z0-9-]*$' }
        roles:
          type: object
          description: Instance-scoped roles; these do not create tenant-wide grants.
          additionalProperties:
            { $ref: '#/components/schemas/ProductInstanceRoleDecl' }
        actions:
          type: object
          description: 'Map of action key -> { name }, e.g. { create: { name: Create } }.'
          additionalProperties:
            type: object
            additionalProperties: false
            required: [name]
            properties:
              name: { type: string }

    ProductInstanceRoleDecl:
      type: object
      additionalProperties: false
      required: [name, permissions]
      properties:
        name: { type: string }
        permissions:
          type: array
          description: Bare actions of the containing resource; grants apply to one instance only.
          items: { type: string }
        granted_to:
          type: object
          additionalProperties: false
          required: [users_with_role]
          properties:
            users_with_role:
              type: array
              items:
                type: object
                additionalProperties: false
                required: [role, on_resource, linked_by_relation]
                properties:
                  role: { type: string, pattern: '^[A-Za-z][A-Za-z0-9-]*$' }
                  on_resource:
                    { type: string, pattern: '^[A-Za-z][A-Za-z0-9-]*$' }
                  linked_by_relation:
                    { type: string, pattern: '^[A-Za-z][A-Za-z0-9-]*$' }

    ProductRoleDecl:
      type: object
      additionalProperties: false
      required: [key, name, permissions]
      description: A role the product owns, declared with a BARE key.
      properties:
        key:
          type: string
          pattern: '^[A-Za-z][A-Za-z0-9-]*$'
          description: 'Bare role key, e.g. `seller`. Namespaced to `<slug>_seller`.'
        name: { type: string }
        permissions:
          type: array
          description: >-
            Permission strings against this product's BARE resource keys, e.g.
            `Listing:create`. Rewritten to `<slug>_Listing:create` on merge. Every
            referenced resource/action must exist in this same document.
          items:
            type: string
            pattern: '^[A-Za-z][A-Za-z0-9-]*:[A-Za-z][A-Za-z0-9_-]*$'

    ProductPolicy:
      type: object
      additionalProperties: false
      required: [resources, roles]
      description: >-
        A product's self-declared authorization policy. `product` is implied by the
        path slug and rejected in the body if it disagrees.
      properties:
        product:
          $ref: '#/components/schemas/Slug'
        name:
          type: string
          description: Human-readable product name, used to prefix display names.
        resources:
          type: array
          items: { $ref: '#/components/schemas/ProductResourceDecl' }
        roles:
          type: array
          items: { $ref: '#/components/schemas/ProductRoleDecl' }

    BillingProfile:
      type: object
      additionalProperties: false
      required: [productKey]
      description: >-
        The app's billing identity. Registering this is what allows the billing
        service to accept checkout sessions carrying this `productKey`.
      properties:
        productKey:
          type: string
          minLength: 1
          maxLength: 100
          pattern: '^[a-z0-9][a-z0-9-]*$'
          description: >-
            The key stamped on checkout sessions and echoed on billing events, e.g.
            `mendys-datasets`. Conventionally the app slug unless the product already
            ships a different one.
        currencies:
          type: array
          description: 'ISO-4217 lowercase currency codes the product accepts. Empty = platform default.'
          items:
            type: string
            pattern: '^[a-z]{3}$'
        maxTotalCents:
          type: integer
          minimum: 1
          description: >-
            Upper bound on a single checkout total, in minor units. A guard against a
            mispriced line item billing a customer catastrophically; omit for the
            platform default.
        meteredUsage:
          type: boolean
          default: false
          description: Whether the product reports metered usage for billing.

    HeartbeatRequest:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status: { type: string, enum: [online, degraded] , default: online }
        metadata:
          type: object
          additionalProperties: true
          description: Free-form app-reported metadata (version, build, etc).

    Error:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error: { type: string, description: 'Stable machine-readable error code' }
        message: { type: string, description: 'Human-readable detail' }
        code:
          type: string
          description: Optional finer-grained code (e.g. `ORG_MISMATCH`, `NOT_PENDING`).

    ValidationErrorBody:
      type: object
      additionalProperties: false
      required: [error, fields]
      properties:
        error: { type: string, enum: [validation_error] }
        message: { type: string }
        fields:
          type: array
          items:
            type: object
            additionalProperties: false
            required: [path, message]
            properties:
              path: { type: string }
              message: { type: string }

    BuildContext:
      type: string
      enum: [personal, organization]

    BuildStatus:
      type: string
      description: >-
        `requested`, `launching`, `building`, `deploying` are in-flight;
        `deployed`, `failed`, `cancelled` are terminal and immutable.
      enum: [requested, launching, building, deploying, deployed, failed, cancelled]

    CreateBuildSessionRequest:
      type: object
      additionalProperties: false
      required: [context, name, brief]
      description: >-
        No `id` is accepted — the session id is server-minted.
        `organizationId` is REQUIRED when `context=organization` and REJECTED when
        `context=personal` (the personal account is resolved from the caller).
      properties:
        context: { $ref: '#/components/schemas/BuildContext' }
        organizationId:
          type: string
          description: Organization TypeID (`org_…`) or uuid.
        name: { type: string, minLength: 1, maxLength: 120 }
        brief:
          type: string
          minLength: 1
          maxLength: 4000
          description: What the app should do — the brief handed to FuzeAgent.

    BuildSession:
      type: object
      additionalProperties: false
      required: [id, organizationId, context, name, brief, status, createdAt, updatedAt]
      properties:
        id: { type: string, description: 'Build session TypeID (`front_abs_…`).' }
        organizationId:
          type: string
          description: Owning organization (`org_…`) — the personal org for personal builds.
        requestedBy:
          type: [string, 'null']
          description: Requesting user (`usr_…`); null if the user was since deleted.
        context: { $ref: '#/components/schemas/BuildContext' }
        name: { type: string }
        brief: { type: string }
        status: { $ref: '#/components/schemas/BuildStatus' }
        appId:
          type: [string, 'null']
          description: The deployed app (`app_…`) once `status=deployed`.
        appSlug:
          type: [string, 'null']
          description: The deployed app's slug once `status=deployed`.
        agentSessionUrl:
          type: [string, 'null']
          description: Link to follow the FuzeAgent session, when the agent provides one.
        errorCode: { type: [string, 'null'] }
        errorMessage: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    BuildSessionStatusReport:
      type: object
      additionalProperties: false
      required: [status]
      properties:
        status:
          type: string
          enum: [building, deploying, deployed, failed]
        appSlug:
          type: string
          description: REQUIRED when `status=deployed`; invalid otherwise.
        errorCode:
          type: string
          pattern: '^[A-Za-z0-9_.-]{1,64}$'
          description: Only valid when `status=failed`.
        errorMessage:
          type: string
          maxLength: 2000
          description: Only valid when `status=failed`.

    PublicationRequest:
      type: object
      additionalProperties: false
      required: [target]
      properties:
        target: { type: string, enum: [marketplace] }
        notes: { type: string, maxLength: 2000 }

    PublicationApproval:
      type: object
      additionalProperties: false
      properties:
        notes: { type: string, maxLength: 2000 }

    PublicationRejection:
      type: object
      additionalProperties: false
      required: [reason]
      properties:
        reason: { type: string, minLength: 1, maxLength: 2000 }

    AppPublication:
      type: object
      additionalProperties: false
      required: [slug, tier, status, target]
      properties:
        slug: { $ref: '#/components/schemas/Slug' }
        tier:
          $ref: '#/components/schemas/Visibility'
        status:
          type: string
          enum: [none, pending, approved, rejected]
        target: { type: string, enum: [marketplace] }
        submittedAt: { type: [string, 'null'], format: date-time }
        approvedAt: { type: [string, 'null'], format: date-time }
        approvedBy: { type: [string, 'null'], description: 'Approving platform admin (`usr_…`).' }
        requestedBy: { type: [string, 'null'], description: 'Requesting user (`usr_…`).' }
        notes: { type: [string, 'null'] }
        rejection:
          type: [object, 'null']
          additionalProperties: false
          required: [reason, rejectedAt]
          properties:
            reason: { type: string }
            rejectedAt: { type: string, format: date-time }
            rejectedBy: { type: [string, 'null'] }

    PublicationQueueItem:
      type: object
      additionalProperties: false
      required: [slug, name, icon, publication]
      description: >-
        One row of the marketplace review queue (`GET /publication-requests`):
        an app's publication state together with the minimal app display context
        a reviewer needs to triage it without a second call. Every field is
        owned by the app-registry service — this is not a cross-service join.
        `icon` is REQUIRED here even though `AppManifest.icon` is optional: the
        queue row always renders an icon cell, so the server substitutes a
        placeholder (`{ kind: emoji, value: "📦" }`) when the app's manifest has
        none. Consumers can therefore rely on `icon` being present and non-null.
      properties:
        slug: { $ref: '#/components/schemas/Slug' }
        name:
          type: string
          description: App display name (`manifest.name`), for the "App" column.
        icon:
          $ref: '#/components/schemas/Icon'
          description: >-
            Always present — the app's manifest icon, or a server-provided
            placeholder (`📦` emoji) when the manifest declares none.
        organizationId:
          type: [string, 'null']
          description: >-
            Owning org (the publisher) as a wire-typed reference — a TypeID
            (`org_…`, the value prefixed-ids returns in prod) or a bare UUID
            (legacy/unprefixed). NOT `format: uuid`: a prefixed TypeID is not a
            syntactic UUID and would fail that constraint. A personal app is
            owned by the caller's personal org and still carries that org's id;
            `null` is reserved for a genuinely platform-global app with no owning
            org. The org's display name is resolved by the caller, not embedded.
        creator:
          description: >-
            The creator as an in-org contact, or null — rendered in the
            publisher/contact column. Subject to the SAME former-member rule as
            `App.creator` (a former member is reduced to `isCurrentMember:false`
            with no name/contact, per frame (e)). Present only when
            `fuzefront.apps.creator-ownership` is ON.
          oneOf:
            - { $ref: '#/components/schemas/AppCreator' }
            - { type: 'null' }
        publication: { $ref: '#/components/schemas/AppPublication' }
