{
  "openapi": "3.1.0",
  "info": {
    "title": "TaleArt API",
    "version": "1.0.0",
    "description": "The TaleArt Management API under `/api/v1`, for the dashboard, scripts and the command-line tool, and the Delivery API under `/delivery/v1`, for SDKs and host pages."
  },
  "servers": [
    {
      "url": "/api/v1"
    }
  ],
  "security": [
    {
      "session": []
    },
    {
      "token": []
    }
  ],
  "tags": [
    {
      "name": "Reference",
      "description": "Whether the API is up, and this document."
    },
    {
      "name": "Projects",
      "description": "Projects, their site key and invitations."
    },
    {
      "name": "Zones",
      "description": "Zones, their display (a frame of a registered type) and where host pages load the SDK."
    },
    {
      "name": "Tale groups",
      "description": "Tale groups, their place in zones, status, schedule and cover."
    },
    {
      "name": "Tales",
      "description": "Tales, their media, documents and responses."
    },
    {
      "name": "Media",
      "description": "The project's media library."
    },
    {
      "name": "Theme and fonts",
      "description": "The project's theme and fonts."
    },
    {
      "name": "Targeting",
      "description": "Tags and user lists that decide who sees a tale group."
    },
    {
      "name": "API tokens",
      "description": "Personal API tokens for scripts. A passkey session only; a token cannot manage tokens."
    },
    {
      "name": "Delivery",
      "description": "The Delivery API for SDKs and host pages, with the project's site key, and public media."
    },
    {
      "name": "Sign-in",
      "description": "Passkey setup, sign-in, sign-out, username and password sign-in and invitations, and the signed-in user's own sign-in methods. Cookies only; any request with an `Authorization` header is refused."
    },
    {
      "name": "Users",
      "description": "The install's users for admins (the Users page). A passkey session only; any request with an `Authorization` header is refused."
    },
    {
      "name": "Sign-in methods",
      "description": "Which sign-in methods are on, the external identity providers, and admin-issued password reset links. Admins only, with a session cookie; any request with an `Authorization` header is refused."
    }
  ],
  "paths": {
    "/openapi.json": {
      "servers": [
        {
          "url": "/api",
          "description": "This document, served by the API."
        }
      ],
      "get": {
        "tags": [
          "Reference"
        ],
        "operationId": "getOpenApiDocument",
        "summary": "This OpenAPI document, as JSON.",
        "description": "Served at `/api/openapi.json`, with no credentials (any are ignored), `Cache-Control: public, max-age=300` and an ETag (304 for a matching `If-None-Match`). Its relative servers resolve against the API's origin.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "304": {
            "description": "The client's copy, named by `If-None-Match`, is still current."
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Reference"
        ],
        "operationId": "getHealth",
        "summary": "Report that the API is up.",
        "security": [],
        "responses": {
          "200": {
            "description": "The API is up.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                }
              }
            }
          }
        }
      }
    },
    "/auth/state": {
      "get": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "getAuthState",
        "summary": "Whether setup is still needed, who is signed in, and the sign-in methods on.",
        "description": "Works with or without a session. `user` is null when the request has no valid session. `methods` lists the sign-in methods an admin has turned on and that are usable, in the order the sign-in page shows them: passkey, password, then the providers. On an empty install with `TALEART_SETUP_PASSWORD=true`, `password` is listed as well, since the first admin can set up with it. It carries no configuration and no secrets.",
        "security": [],
        "responses": {
          "200": {
            "description": "The auth state.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthState"
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/setup/options": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "startSetup",
        "summary": "Start registering the first admin's passkey.",
        "description": "Only while no user exists; afterwards 409 `setup_closed`. Answers WebAuthn creation options (user verification and a resident key required) and sets the http-only `taleart_challenge` cookie. The challenge is single-use and expires after 5 minutes.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetupRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "WebAuthn creation options for the browser.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PasskeyCreationOptions"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/setup/verify": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "finishSetup",
        "summary": "Register the first admin's passkey and sign them in.",
        "description": "Checks the authenticator response against the challenge found through the `taleart_challenge` cookie, creates the admin and their passkey, and sets the `taleart_session` cookie. Bad or expired challenge, or failed verification: 400 `passkey_failed`. A user already exists: 409 `setup_closed`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasskeyResponse"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The admin, now signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/sign-in/options": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "startSignIn",
        "summary": "Start signing in with a passkey.",
        "description": "Answers WebAuthn request options without `allowCredentials` (the passkey is discoverable, so no email is needed) and sets the http-only `taleart_challenge` cookie. The challenge is single-use and expires after 5 minutes.",
        "security": [],
        "responses": {
          "200": {
            "description": "WebAuthn request options for the browser.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PasskeyRequestOptions"
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/sign-in/verify": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "finishSignIn",
        "summary": "Sign in with a stored passkey.",
        "description": "Checks the authenticator response and the signature counter, then sets the `taleart_session` cookie (7 days). Unknown passkey, bad or expired challenge, or failed verification: 401 `passkey_failed`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasskeyResponse"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The signed-in user.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/sign-out": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "signOut",
        "summary": "End the current session.",
        "description": "Deletes the session and clears the `taleart_session` cookie. Works without a session too.",
        "security": [],
        "responses": {
          "204": {
            "description": "Signed out."
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/invitations/{token}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/InvitationToken"
        }
      ],
      "get": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "getInvitation",
        "summary": "Read an invitation before joining.",
        "description": "Works without a session. An unknown, used or expired invitation answers 410 `invitation_invalid`.",
        "security": [],
        "responses": {
          "200": {
            "description": "The project the invitation is for, and when it expires.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvitationPreview"
                }
              }
            }
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/invitations/{token}/options": {
      "parameters": [
        {
          "$ref": "#/components/parameters/InvitationToken"
        }
      ],
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "startInvitation",
        "summary": "Start registering an invitee's passkey.",
        "description": "Answers WebAuthn creation options and sets the http-only `taleart_challenge` cookie (single-use, 5 minutes). Unknown, used or expired invitation: 410 `invitation_invalid`. An email that already belongs to a user: 409 `email_taken`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RedeemInvitationRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "WebAuthn creation options for the browser.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PasskeyCreationOptions"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/invitations/{token}/verify": {
      "parameters": [
        {
          "$ref": "#/components/parameters/InvitationToken"
        }
      ],
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "finishInvitation",
        "summary": "Register the invitee's passkey, use the invitation and sign them in.",
        "description": "Creates the user as a `content_manager`, their passkey and their membership of the invitation's project, marks the invitation used and sets the `taleart_session` cookie, all in one transaction. Bad or expired challenge, or failed verification: 400 `passkey_failed`. The invitation was used meanwhile or expired: 410 `invitation_invalid`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasskeyResponse"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new content manager, now signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/password/sign-in": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "signInWithPassword",
        "summary": "Sign in with a username and password.",
        "description": "Only while the password method is on (an admin turns it on under Sign-in methods). Sets the `taleart_session` cookie (7 days) and, like a passkey sign-in, ends the user's other sessions only when they change their password. An unknown username, a wrong password and a turned-off method all answer the same 401 `sign_in_failed`. Failures are throttled per address (a burst of 10, then 10 a minute) and per username: after 5 failures the username waits 30 s, doubling per further failure up to 15 minutes; while it waits the answer is 429 `rate_limited` with `Retry-After`. A success clears the username's failures.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordSignInRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The signed-in user.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/setup/password": {
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "setupWithPassword",
        "summary": "Create the first admin with a username and password, and sign them in.",
        "description": "The password variant of setup. Only while no user exists (409 `setup_closed`). Refused with 403 `sign_in_method_disabled` unless the server runs with `TALEART_SETUP_PASSWORD=true`; then the password method is turned on in the same step, so the new admin can sign in with it. The password must meet the password policy (400 `password_rejected`) and the username is taken at most once (409 `username_taken`). Sets the `taleart_session` cookie.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordRegistrationRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The admin, now signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/invitations/{token}/password": {
      "parameters": [
        {
          "$ref": "#/components/parameters/InvitationToken"
        }
      ],
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "redeemInvitationWithPassword",
        "summary": "Join through an invitation with a username and password, and sign in.",
        "description": "The password variant of redeeming an invitation, for when the password method is on. Creates the content manager with the password, their membership of the invitation's project, marks the invitation used and sets the `taleart_session` cookie, in one transaction. Unknown, used or expired invitation: 410 `invitation_invalid`; the password method off: 403 `sign_in_method_disabled`; an email that already belongs to a user: 409 `email_taken`; a username taken: 409 `username_taken`; a password that breaks the policy: 400 `password_rejected`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordRegistrationRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new content manager, now signed in.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/password-resets/{token}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PasswordResetToken"
        }
      ],
      "get": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "getPasswordReset",
        "summary": "Read a password reset link before using it.",
        "description": "Works without a session. Says whose password the link sets and whether the form must ask for a username (the user has no password yet). An unknown, used or expired link, or one while the password method is off, answers 410 `password_reset_invalid`.",
        "security": [],
        "responses": {
          "200": {
            "description": "Whose password the link sets.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PasswordResetPreview"
                }
              }
            }
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/password-resets/{token}/redeem": {
      "parameters": [
        {
          "$ref": "#/components/parameters/PasswordResetToken"
        }
      ],
      "post": {
        "tags": [
          "Sign-in"
        ],
        "operationId": "redeemPasswordReset",
        "summary": "Set a new password through a reset link, and sign the user in.",
        "description": "Sets the password (and the username, when the user has none yet), marks the link used, ends every other session of the user and sets the `taleart_session` cookie for a new one, all in one transaction. The link is single-use and expires after 24 hours. The password must meet the policy (400 `password_rejected`) and a username given for a user who already has one must match it (400 `validation_failed`). An unknown, used or expired link, or one while the password method is off: 410 `password_reset_invalid`.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RedeemPasswordResetRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The user, now signed in with a new session.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignedIn"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/account/methods": {
      "get": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "getAccountMethods",
        "summary": "The signed-in user's sign-in methods.",
        "description": "Lists the user's passkeys, whether they have a password (its username, never the password), and their linked provider identities. A session cookie only; a request with an `Authorization` header is refused.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The user's sign-in methods.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccountMethods"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/account/password": {
      "put": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "setAccountPassword",
        "summary": "Set the signed-in user's password, or change it.",
        "description": "A user without a password sets one: `username` is then required, and `currentPassword` is not. A user with one must send `currentPassword` (403 `password_incorrect` when it is wrong, counted against the username's failures) and may not change `username` here. The password must meet the policy (400 `password_rejected`). Every other session of the user ends; the session making the request stays signed in.",
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetPasswordRequest"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "The password is set."
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "removeAccountPassword",
        "summary": "Remove the signed-in user's password.",
        "description": "Refused with 409 `last_sign_in_method` unless another method the user holds stays usable: a passkey, or a provider identity whose provider is on. Without a password: 404 `not_found`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "The password is removed."
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/account/passkeys/{passkeyId}": {
      "parameters": [
        {
          "name": "passkeyId",
          "in": "path",
          "required": true,
          "description": "The passkey's id, from `GET /auth/account/methods`.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "removeAccountPasskey",
        "summary": "Remove one of the signed-in user's passkeys.",
        "description": "Refused with 409 `last_sign_in_method` unless another method the user holds stays usable (see the password removal). Another user's passkey or an unknown one: 404 `not_found`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "The passkey is removed."
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/account/identities/{identityId}": {
      "parameters": [
        {
          "name": "identityId",
          "in": "path",
          "required": true,
          "description": "The linked identity's id, from `GET /auth/account/methods`.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "delete": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "removeAccountIdentity",
        "summary": "Unlink one of the signed-in user's provider identities.",
        "description": "Refused with 409 `last_sign_in_method` unless another method the user holds stays usable. Another user's identity or an unknown one: 404 `not_found`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "The identity is unlinked."
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/settings": {
      "get": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "getAuthSettings",
        "summary": "Which sign-in methods are on.",
        "description": "Passkey and password on or off, and whether the server has the key that encrypts provider secrets (`secretKeyConfigured`). Admins only.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The sign-in method settings.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthSettings"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "setAuthSettings",
        "summary": "Turn the passkey and password methods on or off.",
        "description": "Admins only. At least one method must stay on: none on answers 409 `last_sign_in_method`. A change that would leave no method on that every admin can use (every admin needs a passkey, a password, or an identity of a provider that is on) answers 409 `sign_in_lockout`. A fresh install has the passkey on and the password off.",
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateAuthSettingsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The settings now in force.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthSettings"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/providers": {
      "get": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "listAuthProviders",
        "summary": "The external identity providers an admin has added.",
        "description": "Admins only. Never returns a secret: `hasSecret` says whether one is stored. `usable` is true when the provider is on and, if it needs a secret, that secret is stored and the server has its key. `redirectUri` is the URL to register at the provider.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The providers, by slug.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AuthProvider"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/providers/{slug}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProviderSlug"
        }
      ],
      "put": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "saveAuthProvider",
        "summary": "Add a provider, or replace its settings.",
        "description": "Admins only. The slug names the provider: `google` and `apple` are the presets, any other slug is a generic OpenID Connect provider (method `oidc:<slug>`). The secret is write-only: send it in `secret` to store it (encrypted), `null` to remove it, or leave the field out to keep the stored one. The secret is refused with 503 `secret_key_missing` when the server has no valid `TALEART_SECRET_KEY`. Turning a provider off that the sign-in methods depend on answers 409 `sign_in_lockout` or `last_sign_in_method`.",
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveAuthProviderRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The provider as saved.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthProvider"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "503": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "deleteAuthProvider",
        "summary": "Remove a provider and its secret.",
        "description": "Admins only. The identities users linked to it stay in place but cannot sign in while it is gone. Refused with 409 `sign_in_lockout` or `last_sign_in_method` when that would lock every admin out or leave no method on.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "The provider is removed."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/users/{userId}/password-reset": {
      "parameters": [
        {
          "name": "userId",
          "in": "path",
          "required": true,
          "description": "The user the link is for.",
          "schema": {
            "type": "string",
            "format": "uuid"
          }
        }
      ],
      "post": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "issuePasswordReset",
        "summary": "Issue a one-time password reset link for a user.",
        "description": "Admins only. The link works once, for 24 hours, and is shown only in this response; only its hash is stored. Issuing a link replaces the user's earlier unused links. It also adds a password to a user who has none. Unknown user: 404 `not_found`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "201": {
            "description": "The link, to share once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PasswordResetLink"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/providers/{slug}/start": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProviderSlug"
        }
      ],
      "get": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "startProviderSignIn",
        "summary": "Start a sign-in with a provider (OpenID Connect, Google or Apple).",
        "description": "Works without a session, except `purpose=link`, which needs one. Sets the `taleart_oauth` cookie (http-only, SameSite=Lax, path `/api/v1/auth`, 10 minutes), then redirects the browser to the provider (302). With `format=json` it answers `{ authorizationUrl }` instead. The provider must be enabled, have its secret stored and the server must have its key; otherwise, and for an unknown slug, the same refusal: 401 `sign_in_failed`. `purpose`: `sign-in` (the identity must already be linked), `setup` (only while no user exists), `invitation` (with `token` and the invitee's `email`; the verified email must equal it), `link` (attaches the provider to the signed-in user). A failed start redirects to the dashboard with `?auth_error=<code>` unless `format=json`. The redirect URI is `<PUBLIC_API_URL>/api/v1/auth/callback/<slug>`.",
        "security": [],
        "parameters": [
          {
            "name": "purpose",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "sign-in",
                "setup",
                "invitation",
                "link"
              ]
            }
          },
          {
            "name": "token",
            "in": "query",
            "required": false,
            "description": "The invitation token (purpose `invitation`).",
            "schema": {
              "type": "string",
              "maxLength": 200
            }
          },
          {
            "name": "email",
            "in": "query",
            "required": false,
            "description": "The invitee's email (purpose `invitation`); the verified provider email must equal it.",
            "schema": {
              "type": "string",
              "format": "email",
              "maxLength": 320
            }
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "With `format=json`, the URL to send the browser to.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "authorizationUrl"
                  ],
                  "properties": {
                    "authorizationUrl": {
                      "type": "string",
                      "format": "uri"
                    }
                  }
                }
              }
            }
          },
          "302": {
            "description": "The provider's authorization page, or the dashboard with `?auth_error=<code>`."
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "410": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/callback/{slug}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProviderSlug"
        }
      ],
      "get": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "finishProviderSignIn",
        "summary": "The redirect back from a provider.",
        "description": "Works without a session (the `taleart_oauth` cookie must be sent, and the state must match it). The challenge is single use. Success sets the session cookie (for sign-in, setup and invitation) and redirects to the dashboard. A refusal redirects to the dashboard with `?auth_error=<code>`, one of the provider codes; the provider's own error text is never passed on. Responses are not cached.",
        "security": [],
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "The dashboard, with `?auth_error=<code>` on a refusal."
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "postProviderSignInCallback",
        "summary": "The form-posted redirect back from a provider (Apple, `response_mode=form_post`).",
        "description": "Content type `application/x-www-form-urlencoded`, at most 16 KB; any other content type, or a larger body, redirects to the dashboard with `?auth_error=sign_in_failed`. Otherwise as the GET callback. Browsers send the `SameSite=Lax` cookie on a cross-site POST only where they allow it.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "code": {
                    "type": "string"
                  },
                  "state": {
                    "type": "string"
                  },
                  "id_token": {
                    "type": "string"
                  },
                  "user": {
                    "type": "string"
                  },
                  "error": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "302": {
            "description": "The dashboard, with `?auth_error=<code>` on a refusal."
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/auth/providers/{slug}/test": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProviderSlug"
        }
      ],
      "post": {
        "tags": [
          "Sign-in methods"
        ],
        "operationId": "testAuthProvider",
        "summary": "Run the provider's discovery and report what it found.",
        "description": "Admins only. Runs the discovery of the provider's issuer (with the same checks as a sign-in: exact issuer, https except localhost, 5 s, 256 KB, no redirects). Answers 200 either way: `ok: true` with the issuer and endpoints, or `ok: false` with a short `code`. The secret is not used.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The discovery result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProviderTestResult"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api-tokens": {
      "get": {
        "tags": [
          "API tokens"
        ],
        "operationId": "listApiTokens",
        "summary": "The signed-in user's API tokens that are not revoked, newest first.",
        "description": "Personal API tokens for scripts. Each user sees only their own. Never returns a token or its hash, only its prefix. An expired token is listed until it is revoked. A passkey session only: an API token gets 403 `forbidden` here, so a token can never list, create or revoke tokens.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's tokens.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ApiToken"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "API tokens"
        ],
        "operationId": "createApiToken",
        "summary": "Create an API token for the signed-in user (shown only once).",
        "description": "The token (`tat_` and 32 random bytes in base64url) appears only in this response, sent with `Cache-Control: no-store`; only its SHA-256 hash and its prefix are stored. It acts as the user, limited to `projectIds` (or, when null, to every project the user can access at the time of each request) and, when `readOnly`, to GET requests. The user's own access is read on every request, so a project the user loses is lost to the token too. Every listed project must exist (else 400 `project_not_found`) and be one the user can access (else 403 `forbidden`). A user holds at most 50 active tokens, expired ones not counted (409 `api_token_limit_reached`). A passkey session only: an API token gets 403 `forbidden`.",
        "security": [
          {
            "session": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateApiTokenRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new token, shown only once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IssuedApiToken"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api-tokens/current": {
      "get": {
        "tags": [
          "API tokens"
        ],
        "operationId": "getCurrentApiToken",
        "summary": "The API token making the request, with its user (an API token only).",
        "description": "What `taleart whoami` shows: the token's name, prefix and scope (`allProjects`, `projectIds`, `readOnly`, `expiresAt`) and the user it acts as, with their role. Never the token or its hash. Read-only tokens may read it. A passkey session has no token and gets 403 `forbidden`.",
        "security": [
          {
            "token": []
          }
        ],
        "responses": {
          "200": {
            "description": "The token and its user, sent with `Cache-Control: no-store`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CurrentApiToken"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api-tokens/{tokenId}": {
      "parameters": [
        {
          "name": "tokenId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "API tokens"
        ],
        "operationId": "revokeApiToken",
        "summary": "Revoke one of the signed-in user's API tokens.",
        "description": "The token stops working at once: its next request answers 401 `unauthenticated`. Another user's token, an unknown or an already revoked one answers 404 `api_token_not_found`. A passkey session only: an API token gets 403 `forbidden`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "204": {
            "description": "The token is revoked."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/users": {
      "get": {
        "tags": [
          "Users"
        ],
        "operationId": "listUsers",
        "summary": "List the install's users with their sign-in methods (admins only).",
        "description": "Admins only, with a session cookie: a content manager gets 403 `forbidden`, and any request with an `Authorization` header (an API token included) gets 403 `forbidden`. Each user shows whether they hold a passkey and a password, the external providers they link (their ids, once each) and how many projects they are a member of. Never a password hash, a secret or a username. Ordered by creation, then id, and at most 500 users: `truncated` is true when more exist.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "The users.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AdminUserList"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/users/{userId}/api-tokens": {
      "parameters": [
        {
          "name": "userId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "API tokens"
        ],
        "operationId": "revokeUserApiTokens",
        "summary": "Revoke every API token of a user (admins only).",
        "description": "For a lost laptop or a person leaving: every active token of the user stops working at once. A content manager gets 403 `forbidden`; an unknown user 404 `not_found`. A passkey session only: an API token gets 403 `forbidden`.",
        "security": [
          {
            "session": []
          }
        ],
        "responses": {
          "200": {
            "description": "How many tokens were revoked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RevokedApiTokens"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects": {
      "get": {
        "tags": [
          "Projects"
        ],
        "operationId": "listProjects",
        "summary": "List the projects the caller can access, oldest first.",
        "description": "Admins see every project; content managers see the projects they are members of.",
        "responses": {
          "200": {
            "description": "The accessible projects.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Projects"
        ],
        "operationId": "createProject",
        "summary": "Create a project (admins only).",
        "description": "A content manager gets 403 `forbidden`. An admin's API token may create a project only when it covers all of the admin's projects and is read-write; any other token gets 403 `forbidden`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateProjectRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created project.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/invitations": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Projects"
        ],
        "operationId": "createInvitation",
        "summary": "Invite someone to a project (admins only).",
        "security": [
          {
            "session": []
          }
        ],
        "description": "Creates a one-time invitation, valid for 7 days. The invitee opens `url`, registers a passkey and becomes a content manager of the project. The token in `url` is shown only in this response; only its SHA-256 hash is stored. A content manager gets 403 `forbidden`; an unknown project 404 `project_not_found`. An API token gets 403 `forbidden`: invitations need a passkey session.",
        "responses": {
          "201": {
            "description": "The invitation, with its one-time link.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Invitation"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/site-key": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Projects"
        ],
        "operationId": "getSiteKey",
        "summary": "Whether the project has an active site key, with its prefix and issue date.",
        "description": "Answers `null` when the project has no active site key. Never returns the key or its hash. Anyone who can access the project may read it. An unknown project answers 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The active site key's info, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/SiteKeyInfo"
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Projects"
        ],
        "operationId": "issueSiteKey",
        "summary": "Issue the project's site key, or rotate it (admins only).",
        "description": "A project has at most one active site key, which places every zone of the project on a page with the zone's slug. When the project already has an active site key, that key is revoked in the same transaction (rotation), which affects every zone of the project. `key` appears only in this response; only its SHA-256 hash is stored, and the response is sent with `Cache-Control: no-store`. A content manager gets 403 `forbidden`; an unknown project 404 `project_not_found`. With `ifNone: true` (the dashboard's \"Issue site key\") nothing is rotated: when the project already has an active site key the answer is 409 `site_key_exists` and that key keeps working. Without it (or `false`) the request rotates (the dashboard's \"Rotate\").",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/IssueSiteKeyRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new site key, shown only once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IssuedSiteKey"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Projects"
        ],
        "operationId": "revokeSiteKey",
        "summary": "Revoke the project's active site key (admins only).",
        "description": "The key stops working on the Delivery API at once, for every zone of the project. A project without an active site key answers 404 `site_key_not_found`.",
        "responses": {
          "204": {
            "description": "The site key is revoked."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/tale-groups": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "listProjectTaleGroups",
        "summary": "List every tale group of a project by name, with its tale count and zones.",
        "description": "Tale groups belong to the project; a group placed in no zone is listed with an empty `zoneIds`. Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's tale groups, by name ignoring case.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectTaleGroup"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "createProjectTaleGroup",
        "summary": "Create a tale group in a project, placed in no zone.",
        "description": "The group starts Off (`draft`) with no tales and no placement; it can be shown in a zone later. Unknown project 404 `project_not_found`. A project holds at most 500 tale groups that are not archived: 409 `tale_group_limit_reached`, and nothing is created.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTaleGroupRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created tale group, with no tales and no zones.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectTaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/theme": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "getProjectTheme",
        "summary": "The project's theme.",
        "description": "One theme per project styles every zone's frames and the viewer. `theme` holds the fields the project sets; `resolved` fills the rest with TaleArt's default theme. Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's theme.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectTheme"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "setProjectTheme",
        "summary": "Replace the project's theme.",
        "description": "The body replaces the stored theme; a field left out takes TaleArt's default (`{}` resets the theme). A value outside its pattern or range, or an unknown field, answers 400 `invalid_theme` naming the field, and nothing is stored. Low contrast is not refused; the dashboard warns.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ThemeSettings"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The project's new theme.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectTheme"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/media": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Media"
        ],
        "operationId": "listProjectMedia",
        "summary": "The project's media, which Tale Studio's image and video elements pick from.",
        "description": "The project's uploads (newest first), then its tales' media, then media its tales' documents use (such as a copied group's), each key once. Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's media.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectMedia"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Media"
        ],
        "operationId": "uploadProjectMedia",
        "summary": "Upload an image (GIF included) or a video to the project's media.",
        "description": "The body is the file's bytes, with its type declared as one of the tale media types. The server checks the bytes' magic number against the declared type and stores the file through the storage port without transcoding it, streaming it to storage. Images are at most 10 MB, videos 100 MB; a project holds at most 500 files and 2 GB (PROJECT_MEDIA_MAX_FILES, PROJECT_MEDIA_MAX_BYTES). Admins and content managers upload. Checked in this order, all but the last three before the body is read (with `Connection: close`): too many uploads at once in this process 429 `upload_busy`; unknown project 404 `project_not_found`; another declared type 415 `unsupported_media_type`; a compressed body 415; no valid `Content-Length` 411 `length_required`; empty 400 `validation_failed`; over the type's limit 413 `media_too_large`; a full project 409 `media_limit_reached` or 413 `media_storage_full`; then, while it streams, first bytes of another type 415, and a body other than its declared length 400; the caps again when the row is inserted.",
        "requestBody": {
          "required": true,
          "content": {
            "image/png": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/png"
              }
            },
            "image/jpeg": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/jpeg"
              }
            },
            "image/gif": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/gif"
              }
            },
            "image/webp": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/webp"
              }
            },
            "video/mp4": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/mp4"
              }
            },
            "video/webm": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/webm"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The uploaded media.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectMedia"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "411": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "415": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/media/file": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "key",
          "in": "query",
          "required": true,
          "description": "The media's storage key, as the list names it.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Media"
        ],
        "operationId": "getProjectMediaFile",
        "summary": "A file of the project's media, with a session (for Tale Studio).",
        "description": "Answers one byte range (206) for videos. A key that is not the project's media answers 404 `media_not_found`.",
        "responses": {
          "200": {
            "description": "The file.",
            "content": {
              "image/*": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "video/*": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "206": {
            "description": "One byte range of the file."
          },
          "304": {
            "description": "Not modified."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/fonts": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "listProjectFonts",
        "summary": "The project's fonts, by family, weight and style.",
        "description": "Every face uploaded to the project, with whether a tale's draft or published document uses it. Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's fonts.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ProjectFont"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "uploadProjectFont",
        "summary": "Upload a font face to the project.",
        "description": "The body is the font file's bytes, uncompressed (no `Content-Encoding`), sent as `font/woff2` or `application/octet-stream`, types a form cannot send without CORS preflight. Only WOFF2 is accepted, judged from the bytes: the `wOF2` signature, a header whose length is the file's, a TrueType, CFF or collection flavour, a reserved field of 0, 1 to 256 tables, a declared decoded size of at most 30 MB, and table and collection directories, metadata and compressed data that lie inside the file. Nothing is decompressed. At most 500 KB; a project holds at most 50 faces and 10 MB of font files. The query names the face (the family stored with its spaces collapsed) and carries the uploader's confirmation that they may use and distribute the font, which is stored with their id and name and the time. Admins and content managers upload. Checked in this order: unknown project 404 `project_not_found`; another type 415 `unsupported_media_type`; an invalid `family`, `weight`, `style` or `fallback` 400 `validation_failed`; no licence confirmation 400 `validation_failed`; too large 413 `media_too_large` (refused before the body is read when its declared length is too large, with `Connection: close`); a compressed body 415; a body that did not arrive whole 400; empty 400 `validation_failed`; not WOFF2 415 `unsupported_media_type`; 50 faces already 409 `font_limit_reached`; over 10 MB together 413 `font_storage_full`; a face with the same family (in any case), weight and style 409 `font_exists`.",
        "parameters": [
          {
            "name": "family",
            "in": "query",
            "required": true,
            "description": "One font name, the label people read: letters, digits, spaces and hyphens, starting with a letter, at most 100 characters, and no CSS-wide keyword (the theme's font-list rules). It never reaches CSS: the SDK draws the font under an alias made of its id.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "weight",
            "in": "query",
            "required": true,
            "schema": {
              "type": "integer",
              "enum": [
                100,
                200,
                300,
                400,
                500,
                600,
                700,
                800,
                900
              ]
            }
          },
          {
            "name": "style",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "normal",
                "italic"
              ]
            }
          },
          {
            "name": "fallback",
            "in": "query",
            "required": true,
            "description": "The system stack text shows until, or when, the font is not available.",
            "schema": {
              "type": "string",
              "enum": [
                "sans",
                "serif",
                "rounded",
                "monospace"
              ]
            }
          },
          {
            "name": "licence",
            "in": "query",
            "required": true,
            "description": "`confirmed`: the uploader may use and distribute this font.",
            "schema": {
              "type": "string",
              "enum": [
                "confirmed"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "font/woff2": {
              "schema": {
                "type": "string",
                "contentMediaType": "font/woff2"
              }
            },
            "application/octet-stream": {
              "schema": {
                "type": "string",
                "contentMediaType": "font/woff2"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new font face.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProjectFont"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "415": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/fonts/{fontId}": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "fontId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "delete": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "deleteProjectFont",
        "summary": "Delete a font face and its file.",
        "description": "A font that any tale's draft or published document uses cannot be deleted: 409 `font_in_use`. Unknown font, or a font of another project: 404 `font_not_found`.",
        "responses": {
          "204": {
            "description": "The font and its file are gone."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/fonts/{fontId}/file": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "fontId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Theme and fonts"
        ],
        "operationId": "getProjectFontFile",
        "summary": "Read a font's file with a session, whether a published tale uses it or not.",
        "description": "What Tale Studio and the font list load. `font/woff2`, cached privately with an opaque ETag (304 when it matches). Unknown font, or a font of another project: 404 `font_not_found`.",
        "responses": {
          "200": {
            "description": "The font file.",
            "content": {
              "font/woff2": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "font/woff2"
                }
              }
            }
          },
          "304": {
            "description": "The client's copy, named by `If-None-Match`, is still current."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/tags": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Targeting"
        ],
        "operationId": "listTags",
        "summary": "List a project's tags by name.",
        "description": "Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's tags, by name ignoring case.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Tag"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Targeting"
        ],
        "operationId": "createTag",
        "summary": "Create a tag in a project.",
        "description": "The name is trimmed and holds 1 to 40 characters and no control characters (400 `validation_failed` otherwise). Names are unique per project ignoring case; a taken name answers 409 `tag_exists`. Unknown project 404 `project_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTagRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created tag.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tag"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/user-lists": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Targeting"
        ],
        "operationId": "listUserLists",
        "summary": "A project's user lists, by name.",
        "description": "Each list with its name, how many external user IDs it holds and when it last changed. The IDs themselves are never returned: they are stored only as keyed hashes. Unknown project 404 `project_not_found`.",
        "responses": {
          "200": {
            "description": "The project's user lists, by name ignoring case.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/UserList"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Targeting"
        ],
        "operationId": "createUserList",
        "summary": "Create an empty user list in a project.",
        "description": "Admins and content managers create lists. The name is trimmed and holds 1 to 100 characters and no control characters (400 `validation_failed`); names are unique per project ignoring case (409 `user_list_exists`); a project holds at most 100 lists (409 `user_list_limit_reached`). Unknown project 404 `project_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateUserListRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created list, holding no ID.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/user-lists/{userListId}": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "userListId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "tags": [
          "Targeting"
        ],
        "operationId": "renameUserList",
        "summary": "Rename a user list.",
        "description": "Same name rules as creating one. A list of another project, or none, 404 `user_list_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameUserListRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The renamed list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserList"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Targeting"
        ],
        "operationId": "deleteUserList",
        "summary": "Delete a user list and every ID it holds, for good.",
        "description": "A hard delete in one transaction: the list and all its hashes go, with no soft delete. When it was the project's last list, the project's hashing secret goes too, so the next list gets a new one. Admins and content managers delete. 404 `user_list_not_found` for a list of another project, or none.",
        "responses": {
          "204": {
            "description": "Deleted."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/projects/{projectId}/user-lists/{userListId}/ids": {
      "parameters": [
        {
          "name": "projectId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "userListId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "header",
          "in": "query",
          "required": false,
          "description": "`true` when the first row is a header, which is skipped; any other value reads every row.",
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Targeting"
        ],
        "operationId": "addUserListIds",
        "summary": "Add the external user IDs of a single-column CSV to a user list.",
        "description": "The body is the CSV, UTF-8 (a byte order mark is skipped), one ID per row, read as a stream and not kept. Each non-empty row, trimmed (and unquoted when it is a quoted field), is one ID of at most 256 ASCII characters with no control characters; matching is exact (case-sensitive, no Unicode normalization). Each ID is stored only as HMAC-SHA-256 under the project's secret; no ID is ever returned, logged or kept. Rows already in the list, or earlier in the file, count as already present; refused rows are counted and the first 10 named by line number and reason. The upload is all or nothing. Admins and content managers upload. An upload that sends no byte for 30 s, or takes more than 280 s in all (a 50 MB file needs about 190 KB/s), is ended with nothing added: the API resets the connection, so the client usually sees a reset or closed connection and no answer (when an answer does arrive, it is 408 `validation_failed`). Checked before the body is read (with `Connection: close`): too many uploads at once 429 `upload_busy`; unknown project 404 `project_not_found`; unknown list 404 `user_list_not_found`; a type other than `text/csv` or `text/plain` 415 `unsupported_media_type`; a compressed body 415; no valid `Content-Length` 411 `length_required`; empty 400 `validation_failed`; over 50 MB 413 `user_list_file_too_large`. While it streams: over 1,000,000 rows 413 `user_list_file_too_large`, not UTF-8 or a body other than its declared length 400 `validation_failed`; nothing is added in either case.",
        "requestBody": {
          "required": true,
          "content": {
            "text/csv": {
              "schema": {
                "type": "string"
              }
            },
            "text/plain": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "What the upload did.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UserListUploadReport"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "408": {
            "$ref": "#/components/responses/Error"
          },
          "411": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "415": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones": {
      "get": {
        "tags": [
          "Zones"
        ],
        "operationId": "listZones",
        "summary": "List the zones of the projects the caller can access, oldest first.",
        "responses": {
          "200": {
            "description": "The accessible zones.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Zone"
                  }
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Zones"
        ],
        "operationId": "createZone",
        "summary": "Create a zone in a project, from a template or without a display.",
        "description": "`template` is optional: a template with a display gives the new zone that display with its preset settings in the same transaction, so a refused display leaves no zone; its key is recorded as the zone's `createdFromTemplate` note. A key not in the template registry answers 400 `unknown_template` and nothing is created. `slug` is optional: when missing, it is made from the name (lowercase ASCII letters and digits joined by hyphens; `zone` when the name has none, as a Hebrew name has none) and made unique in the project with `-2`, `-3`. A given slug must be lowercase letters and digits, words joined by single hyphens, at most 64 characters (400 `validation_failed`), and free in the project (409 `slug_taken`). A blank name answers 400 `validation_failed`; a project that does not exist 400 `project_not_found`; a project the caller cannot access 403 `forbidden`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateZoneRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created zone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Zone"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/install": {
      "get": {
        "tags": [
          "Zones"
        ],
        "operationId": "getInstallInfo",
        "summary": "Where a host page loads the Web SDK and where the test page is.",
        "description": "The API's public origin (`PUBLIC_API_URL`), the URL of the Web SDK bundle the API serves (`<apiUrl>/sdk/v1/taleart.js`, an ES module) and the URL of the TaleArt-served test page (`<apiUrl>/sdk/v1/test-page`), for the zone page's Install tab. Any signed-in user.",
        "responses": {
          "200": {
            "description": "The install URLs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InstallInfo"
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/frame-types": {
      "get": {
        "tags": [
          "Zones"
        ],
        "operationId": "listFrameTypes",
        "summary": "List the frame types in the registry.",
        "responses": {
          "200": {
            "description": "Every registered frame type.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FrameType"
                  }
                }
              }
            }
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "tags": [
          "Zones"
        ],
        "operationId": "updateZone",
        "summary": "Rename a zone or change its slug.",
        "description": "`name` is trimmed and must not be blank. `slug` must be lowercase letters and digits, words joined by single hyphens, at most 64 characters (400 `validation_failed`), and free in the zone's project (409 `slug_taken`). A page names the zone by its slug, so changing it changes what such a page must say. Unknown zone: 404 `zone_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateZoneRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The zone as it is now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Zone"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/frame": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Zones"
        ],
        "operationId": "setZoneFrame",
        "summary": "Give a zone its display, a frame of a registered type.",
        "description": "A zone has at most one frame, its display. `settings` is validated against the JSON Schema of the frame type (400 `invalid_frame_settings`); a missing `settings` is the type's defaults. An unknown type answers 400 `unknown_frame_type`. A zone that already has a display answers 409 `display_exists`; change it with PATCH. Unknown zone: 404 `zone_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetFrameRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The zone's display.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Frame"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "tags": [
          "Zones"
        ],
        "operationId": "updateZoneFrame",
        "summary": "Change the type or the settings of a zone's display.",
        "description": "A new `type` changes the display's frame type and resets its settings to that type's defaults, unless `settings` is given too. `settings` replaces the settings and is validated against the JSON Schema of the display's (new) type: an invalid value answers 400 `invalid_frame_settings` naming the field, and nothing is stored. A body with neither answers 400 `validation_failed`. The display keeps its id, so events keep their `frame_id`. Unknown zone: 404 `zone_not_found`; a zone without a display: 404 `display_not_found`. When another change switches the display's type meanwhile, the change is checked again against the new type once, then refused with 409 `concurrent_change`; nothing is stored and it can be tried again.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateFrameRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The display as it is now.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Frame"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/key": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Zones"
        ],
        "operationId": "getZoneKey",
        "summary": "Whether the zone has an active key, with its prefix and issue date.",
        "description": "Answers `null` when the zone has no active key. Never returns the key or its hash. Anyone who can access the zone's project may read it.",
        "responses": {
          "200": {
            "description": "The active key's info, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ZoneKeyInfo"
                    },
                    {
                      "type": "null"
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Zones"
        ],
        "operationId": "issueZoneKey",
        "summary": "Issue a zone key, or rotate it (admins only).",
        "description": "Creates a new key for the zone. When the zone already has an active key, that key is revoked in the same transaction (rotation). `key` appears only in this response; only its SHA-256 hash is stored. A content manager gets 403 `forbidden`; an unknown zone 404 `zone_not_found`.",
        "responses": {
          "201": {
            "description": "The new key, shown only once.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/IssuedZoneKey"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Zones"
        ],
        "operationId": "revokeZoneKey",
        "summary": "Revoke the zone's active key (admins only).",
        "description": "The key stops working on the Delivery API at once. A zone without an active key answers 404 `zone_key_not_found`.",
        "responses": {
          "204": {
            "description": "The key is revoked."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/analytics": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Zones"
        ],
        "operationId": "getZoneAnalytics",
        "summary": "A zone's analytics (totals, a daily series, rows per tale group and per tale).",
        "description": "The published metric definitions (the docs' `/reference/analytics` page says the same). Periods are the last 7, 30 or 90 UTC days, today included: the series has one entry per day of the period, and days without events are zero. Impressions: the number of `impression` events. Reach: the distinct anonymous visitors with at least one impression in the period. Opens: the number of `open` events. Tale views: the number of `view` events. Clicks: the number of `click` events. Engagement rate: the engaged visitors, those with at least one impression and at least one open in the period, as a percentage of Reach, one decimal; 0 when Reach is 0. Engaged visitors are part of Reach, so the rate never exceeds 100 percent. CTR: Clicks as a percentage of Tale views, one decimal; 0 when there are no views. Reach is counted distinct per row (per total, per group, per tale), so the Reach of a total is not the sum of its rows. Tale rows carry the tale views and clicks only: impressions and opens name a group, not a tale, so their tale fields are null. Only events of the zone count. Any member with access to the zone's project may read it. A zone of a project the caller cannot access answers 404 `zone_not_found`, as does an unknown zone. Answers are `Cache-Control: no-store`.",
        "parameters": [
          {
            "name": "period",
            "in": "query",
            "required": false,
            "description": "The period: last 7, 30 or 90 days. Defaults to 30d.",
            "schema": {
              "$ref": "#/components/schemas/AnalyticsPeriod"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The zone's analytics for the period.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneAnalytics"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zone": {
      "servers": [
        {
          "url": "/delivery/v1",
          "description": "Delivery API, for SDKs. Accepts zone keys and site keys."
        }
      ],
      "get": {
        "tags": [
          "Delivery"
        ],
        "operationId": "getDeliveryZone",
        "summary": "A zone, named by site key and slug, with its display and tale groups (Delivery API).",
        "description": "Served under `/delivery/v1`. Accepts only a delivery key in `Authorization: Bearer <key>`, never a session. A zone key (`tak_...`) names its zone alone: the `zone` query parameter is then ignored. With any other bearer key, the key is the project's site key (`tas_...`) and the response is the project's zone whose slug the `zone` query parameter names. Every refusal (a missing, unknown or revoked key, any other key, a missing slug, or a slug no zone of the key's project has) answers the same 401 `invalid_site_key` body, so a caller cannot tell an unknown slug from a refused key. A 401 is sent with `Cache-Control: no-store`. Every Delivery API request counts against its visitor's token bucket (by default 600 at once, 20 per second), the visitor named by the `X-TaleArt-Visitor` header; a request without a valid visitor id counts against a bucket of the same size for its address (an IPv4 address or an IPv6 /48). Every request also counts against its address's backstop, ten times that size, so a client inventing ids gains no more than ten visitors' allowance. Over either the API answers 429 `rate_limited` with `Retry-After`. CORS is open to any origin, without credentials, and allows the `X-TaleArt-Visitor` and `X-TaleArt-External-User-Id` headers. Tale groups and their tales come in dashboard order; only `live` groups and tales whose start and end times, and their group's, contain the current time are listed; each tale's media URL is absolute and public. A group targeted to user lists is listed only when the external user ID header names an ID in one of them. Without that header the zone is cached as `private, no-cache`, varies by `Authorization` and `X-TaleArt-External-User-Id` and carries an ETag, so a browser revalidates it on every load (304 while unchanged) and sees a change at once. With the header (even one that reads as no ID) the answer is `private, no-store`, with no ETag and never a 304, so no cache keeps a per-viewer answer.",
        "security": [
          {
            "siteKey": []
          },
          {
            "zoneKey": []
          }
        ],
        "parameters": [
          {
            "name": "zone",
            "in": "query",
            "required": false,
            "description": "The zone's slug (lowercase letters, digits and hyphens), sent with the project's site key, as a host page names it in `zone=\"...\"`. Not read with a zone key.",
            "schema": {
              "type": "string"
            },
            "example": "home-top"
          },
          {
            "name": "X-TaleArt-Visitor",
            "in": "header",
            "required": false,
            "description": "The visitor id: a random UUID v4 the SDK generates once per device and keeps there (the Web SDK in localStorage, the native SDK in its device store), with no personal data. Written all in lowercase or all in uppercase; anything else counts as no id. It is used only to rate limit per visitor.",
            "schema": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})$"
            }
          },
          {
            "name": "X-TaleArt-External-User-Id",
            "in": "header",
            "required": false,
            "description": "The viewer's external user ID, set by the host page (`setExternalUserId`), for user-list targeting: base64url (RFC 4648 section 5, no padding) of the ID's UTF-8 bytes, as `encodeExternalUserIdHeader` in the contract writes it. Never sent in the URL. The API decodes it, hashes it with the project's secret for the lookup and drops it: it is not logged, stored, put in analytics, or linked to the visitor id. A value that is not canonical base64url of UTF-8, or decodes to an ID a user list could not hold (blank, over 256 characters, a control character), reads as no ID.",
            "schema": {
              "type": "string",
              "pattern": "^[A-Za-z0-9_-]{1,1366}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The zone, its display, and its tale groups by position.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeliveryZone"
                }
              }
            }
          },
          "304": {
            "description": "The client's copy, named by `If-None-Match`, is still current."
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/media/{key}": {
      "servers": [
        {
          "url": "/",
          "description": "Public media. No session and no site key."
        }
      ],
      "parameters": [
        {
          "name": "key",
          "in": "path",
          "required": true,
          "description": "The storage key of a published tale's media, a published tale group's cover or a project font a published tale uses, such as `tales/<uuid>.png`, `covers/<uuid>.webm` or `fonts/<uuid>.woff2`. It contains a slash, sent as is.",
          "schema": {
            "type": "string"
          },
          "allowReserved": true
        }
      ],
      "get": {
        "tags": [
          "Delivery"
        ],
        "operationId": "getPublicMedia",
        "summary": "Read a published tale's media file, with no credentials.",
        "description": "Serves only keys that a published tale (a `live` tale of a `live` group, each inside its start and end time) currently references; any other key, including the key of replaced media or of draft, paused, archived, scheduled or expired content, answers 404 `media_not_found`. A tale group's cover is served while the group is delivered: `live`, inside its window, with at least one published tale. A project font (`fonts/<uuid>.woff2`) is served as `font/woff2` while a published tale of a delivered group (`live`, in its window, placed in a zone) has a published document with a visible text that uses it; any other font key answers 404. The response carries the stored `Content-Type`, supports one byte range (206, or 416 when unsatisfiable) and is open to any origin. It is cached as `public, no-cache` with the key as ETag, so a client revalidates (304 while published) and sees the 404 as soon as the content is unpublished.",
        "security": [],
        "responses": {
          "200": {
            "description": "The media file.",
            "content": {
              "image/jpeg": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/jpeg"
                }
              },
              "image/png": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/png"
                }
              },
              "image/webp": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/webp"
                }
              },
              "image/gif": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/gif"
                }
              },
              "video/mp4": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/mp4"
                }
              },
              "video/webm": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/webm"
                }
              },
              "font/woff2": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "font/woff2"
                }
              }
            }
          },
          "206": {
            "description": "The requested byte range of the media file."
          },
          "304": {
            "description": "The client's copy, named by `If-None-Match`, is still current."
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "416": {
            "description": "The requested range lies outside the file."
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/tale-groups": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "listZoneTaleGroups",
        "summary": "List the tale groups placed in a zone by position, each with its tales by position.",
        "responses": {
          "200": {
            "description": "The zone's tale groups, each with its position in this zone.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ZoneTaleGroup"
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "createZoneTaleGroup",
        "summary": "Create a tale group and place it in a zone, after the zone's existing groups.",
        "description": "The tale group belongs to the zone's project and gets one placement, at the end of this zone. A project holds at most 500 tale groups that are not archived: 409 `tale_group_limit_reached`, and nothing is created.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTaleGroupRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created tale group, with no tales.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneTaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/tale-groups/order": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "orderZoneTaleGroups",
        "summary": "Set the order of the tale groups placed in a zone.",
        "description": "The list names every tale group placed in the zone exactly once; positions in this zone are rewritten to 0..n-1 in that order, and other zones keep their own order. A list that omits, repeats or adds an id answers 409 `order_mismatch`. Unknown zone: 404 `zone_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderTaleGroupsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The zone's tale groups in the new order, each with its tales.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ZoneTaleGroup"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/zones/{zoneId}/tale-groups/{taleGroupId}": {
      "parameters": [
        {
          "name": "zoneId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        },
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "placeZoneTaleGroup",
        "summary": "Place an existing tale group of the zone's project in the zone, after its groups.",
        "description": "The group keeps its tales, tags and media; edits show in every zone it is placed in. A group already placed here answers 200 unchanged. A group that does not exist or belongs to another project answers 400 `tale_group_not_found`. An archived group not placed here yet answers 409 `tale_group_archived`: restore it first. Unknown zone: 404 `zone_not_found`.",
        "responses": {
          "200": {
            "description": "The group was already placed here; nothing changed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneTaleGroup"
                }
              }
            }
          },
          "201": {
            "description": "The placed tale group, with its position in this zone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneTaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "removeZoneTaleGroup",
        "summary": "Remove a tale group from a zone.",
        "description": "Deletes only this placement: the group, its tales and its other placements stay. The zone's remaining positions become 0..n-1. A group not placed here answers 404 `tale_group_not_found`; an unknown zone 404 `zone_not_found`.",
        "responses": {
          "204": {
            "description": "The group is no longer placed in the zone."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "getTaleGroup",
        "summary": "Read one tale group with its tales.",
        "description": "The group with its tales in order, its tags, the zones it is placed in, and the visibility of the group and each tale in any of those zones, placed in a zone or not. The dashboard reads it to open a group that no zone lists, such as one placed in no zone. Unknown tale group: 404 `tale_group_not_found`.",
        "responses": {
          "200": {
            "description": "The tale group.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "renameTaleGroup",
        "summary": "Rename a tale group.",
        "description": "The name is trimmed and must not be blank (400 `validation_failed`). The group is the same in every zone it is placed in, so every zone shows the new name. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameTaleGroupRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The renamed tale group.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/copy": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "copyTaleGroup",
        "summary": "Copy a tale group into a zone, as an independent group.",
        "description": "Creates a new tale group in the target zone's project, with the same name, placed after the zone's groups. Its tales are new rows with the same titles, positions and media; media files are not duplicated, the copy shares their storage keys. For each of the source's tags, a tag of the target project with the same name, ignoring case, is reused, else created; the copy keeps the source's `tagMatch`. Copy and source are independent afterwards. The caller needs access to the source group's project and to the target zone's project (403 `forbidden`). Unknown tale group: 404 `tale_group_not_found`; missing `zoneId`: 400 `validation_failed`; unknown zone: 404 `zone_not_found`. The copy counts against the target zone's project, which holds at most 500 tale groups that are not archived: 409 `tale_group_limit_reached`, and nothing is copied.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopyTaleGroupRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The copy, as placed in the target zone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ZoneTaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/status": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "setTaleGroupStatus",
        "summary": "Publish, pause, archive or restore a tale group.",
        "description": "Sets the group's stored status key. Allowed: `live` from `draft` or `paused` (publish); `paused` from `live` (pause) or from `archived` (restore); `archived` from `draft`, `live` or `paused` (archive). Setting the current status again changes nothing. Any other change: 409 `invalid_status_transition`. A restore in a project that already has 500 tale groups that are not archived: 409 `tale_group_limit_reached`. A status other than `live`, `paused` or `archived`: 400 `validation_failed`. Unknown tale group: 404 `tale_group_not_found`. Only `live` groups reach the Delivery API; the `live` tales of a `draft` or `paused` group read `blocked-by-group`. The dashboard shows `live` as On and `draft` or `paused` as Off (`groupShownStatus`); its switch sets `live` (On) or `paused` (Off).",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetStatusRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its new status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/schedule": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "setTaleGroupSchedule",
        "summary": "Set the start and end time of a tale group.",
        "description": "Sets or clears the start and end time. Live content is delivered only while the current time is at or after `startsAt` and before `endsAt`; before it, it reads `scheduled`, after it `expired`; nothing stores those keys. A bound that is not an ISO 8601 date-time with a time zone, a missing field, or an `endsAt` not later than `startsAt`: 400 `validation_failed`, nothing changes. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its new window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/cover": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "getTaleGroupCover",
        "summary": "Read a tale group's cover file.",
        "description": "The stored bytes, with the `Content-Type` they were uploaded with. No cover: 404 `media_not_found`. Unknown tale group: 404 `tale_group_not_found`.",
        "responses": {
          "200": {
            "description": "The cover file.",
            "content": {
              "image/jpeg": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/jpeg"
                }
              },
              "image/png": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/png"
                }
              },
              "image/webp": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/webp"
                }
              },
              "image/gif": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/gif"
                }
              },
              "video/mp4": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/mp4"
                }
              },
              "video/webm": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/webm"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "putTaleGroupCover",
        "summary": "Set or replace a tale group's cover, still or animated.",
        "description": "The body is the file's bytes and `Content-Type` its type, one of the tale media types. The dashboard makes the file in the browser (an image, a video frame, or a short clip); the server does not transcode. The server decides from the bytes whether the cover is animated: every video, every GIF, an animated WebP and an APNG. An animated cover is at most 300 KB (307 200 bytes, `MAX_ANIMATED_COVER_BYTES`), a still image at most 10 MB; larger: 413 `media_too_large`. Other or mismatched type: 415 `unsupported_media_type`; empty body: 400 `validation_failed`; unknown tale group: 404 `tale_group_not_found`. The previous cover's file is deleted unless a tale or another cover still references it.",
        "requestBody": {
          "required": true,
          "content": {
            "image/jpeg": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/jpeg"
              }
            },
            "image/png": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/png"
              }
            },
            "image/webp": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/webp"
              }
            },
            "image/gif": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/gif"
              }
            },
            "video/mp4": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/mp4"
              }
            },
            "video/webm": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/webm"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its new cover.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "415": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "removeTaleGroupCover",
        "summary": "Remove a tale group's cover.",
        "description": "The group goes back to the default cover, its first published tale's media. The file is deleted unless a tale or another cover still references it. Removing a cover the group does not have changes nothing. Unknown tale group: 404 `tale_group_not_found`.",
        "responses": {
          "200": {
            "description": "The tale group, with no cover.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/tales": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "createTale",
        "summary": "Add a tale to a tale group, after its existing tales.",
        "description": "The tale takes its project from the tale group. A new tale is stored `live` (Shown): it shows once its group is On and it has a published document, which uploading its media creates. A tale group holds at most 100 tales; adding to a full group answers 409 `tale_group_full`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateTaleRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created tale.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/tales/order": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tale groups"
        ],
        "operationId": "orderTales",
        "summary": "Set the order of a tale group's tales.",
        "description": "The list names every tale of the group exactly once; positions are rewritten to 0..n-1 in that order. A list that omits, repeats or adds an id answers 409 `order_mismatch`. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OrderTalesRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The group's tales in the new order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Tale"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/tags": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Targeting"
        ],
        "operationId": "setTaleGroupTags",
        "summary": "Replace the tags of a tale group.",
        "description": "The list becomes the group's full set of tags; an empty list clears them, and repeated ids count once. Every id must name a tag of the group's own project; otherwise nothing changes and the answer is 400 `tag_not_found`. `tagMatch`, when given, sets how the tags combine (`any` or `all`); a value that is neither is 400 `validation_failed`. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetTaleGroupTagsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its new tags.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/audience": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Targeting"
        ],
        "operationId": "setTaleGroupAudience",
        "summary": "Set or clear the audience rule of a tale group.",
        "description": "The body is `{ \"audience\": AudienceRule }` to set the rule, or `{ \"audience\": null }` to clear it (the group then has no audience rule). The rule is checked on save (match, 1 to 10 conditions, operators, value types and limits); a rule that breaks one is 400 `validation_failed` with the reason as its message, and nothing changes. Any project member who may change the group's tags may change its audience (the same check, 403 `forbidden` otherwise). The rule is public like tag names: keep private content out of its values. A viewer must pass the tags, the user lists and the audience rule to see the group. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetTaleGroupAudienceRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its audience rule.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tale-groups/{taleGroupId}/user-lists": {
      "parameters": [
        {
          "name": "taleGroupId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Targeting"
        ],
        "operationId": "setTaleGroupUserLists",
        "summary": "Replace the user lists a tale group is targeted to.",
        "description": "The list becomes the group's full set of user lists; an empty list clears them, and repeated ids count once. Every id must name a user list of the group's own project; otherwise nothing changes and the answer is 404 `user_list_not_found`. Only admins and content managers change it (403 `forbidden`). A group with user lists is delivered only to viewers whose external user ID is in one of them; one whose lists all hold no ID is not visible (`audience-empty`). Saving it (as saving the group's tags) ends `audience-removed`. Unknown tale group: 404 `tale_group_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetTaleGroupUserListsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale group with its new user lists.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleGroup"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tales"
        ],
        "operationId": "getTale",
        "summary": "Read one tale.",
        "description": "The tale with its status as read and its visibility in any zone its group is placed in, as Tale Studio's status line shows it. Unknown tale: 404 `tale_not_found`.",
        "responses": {
          "200": {
            "description": "The tale.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Tales"
        ],
        "operationId": "deleteTale",
        "summary": "Delete a tale.",
        "description": "Deletes the tale for good: its draft and published documents, and its references to media and project fonts. The group's remaining tales keep their order, numbered from the start again. A file the tale used is deleted only when nothing references it any more: a copy of the tale, another tale, a tale group cover or the project's uploaded media keeps it. Project fonts stay in the project. Once deleted, the tale is no longer delivered and its media's public URL answers 404 unless a delivered tale or cover still uses that file. Events already recorded keep the tale's id; the visitors' responses to its interactive components are deleted with it. Any member with access to the tale's project may delete it (403 `forbidden` otherwise). Unknown tale: 404 `tale_not_found`.",
        "responses": {
          "204": {
            "description": "The tale was deleted."
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/status": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tales"
        ],
        "operationId": "setTaleStatus",
        "summary": "Publish, pause, archive or restore a tale.",
        "description": "Sets the tale's stored status key, with the same allowed changes as a tale group's: 409 `invalid_status_transition` for any other, 400 `validation_failed` for a status other than `live`, `paused` or `archived`. Unknown tale: 404 `tale_not_found`. Only `live` tales of `live` groups reach the Delivery API, and only their media is public. Setting `live` on a tale that has nothing published yet (from any status) also publishes its draft document in the same transaction, after checking it again; a tale that already has a published document keeps it. When the draft no longer passes the check, the status still changes, the draft is not published, and the answer says so with `draftNotPublished: true` and why in `draftNotPublishedReason`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetStatusRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale with its new status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleStatusResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/schedule": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "put": {
        "tags": [
          "Tales"
        ],
        "operationId": "setTaleSchedule",
        "summary": "Set the start and end time of a tale.",
        "description": "Sets or clears the start and end time. Live content is delivered only while the current time is at or after `startsAt` and before `endsAt`; before it, it reads `scheduled`, after it `expired`; nothing stores those keys. A bound that is not an ISO 8601 date-time with a time zone, a missing field, or an `endsAt` not later than `startsAt`: 400 `validation_failed`, nothing changes. Unknown tale: 404 `tale_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetScheduleRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale with its new window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/media": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tales"
        ],
        "operationId": "getTaleMedia",
        "summary": "Read a tale's media file.",
        "description": "The stored bytes, with the `Content-Type` they were uploaded with.",
        "responses": {
          "200": {
            "description": "The media file.",
            "content": {
              "image/jpeg": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/jpeg"
                }
              },
              "image/png": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/png"
                }
              },
              "image/webp": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/webp"
                }
              },
              "image/gif": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "image/gif"
                }
              },
              "video/mp4": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/mp4"
                }
              },
              "video/webm": {
                "schema": {
                  "type": "string",
                  "contentMediaType": "video/webm"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "tags": [
          "Tales"
        ],
        "operationId": "putTaleMedia",
        "summary": "Upload or replace a tale's image, GIF or video.",
        "description": "The body is the file's bytes and `Content-Type` its type. Images (`image/jpeg`, `image/png`, `image/webp`, `image/gif`) may be up to 10 MB and videos (`video/mp4`, `video/webm`) up to 100 MB. The declared type must match the file's leading bytes. Unknown tale: 404 `tale_not_found`; other or mismatched type: 415 `unsupported_media_type`; too large: 413 `media_too_large`; empty body: 400 `validation_failed`. Replacing media removes the previous file.",
        "requestBody": {
          "required": true,
          "content": {
            "image/jpeg": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/jpeg"
              }
            },
            "image/png": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/png"
              }
            },
            "image/webp": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/webp"
              }
            },
            "image/gif": {
              "schema": {
                "type": "string",
                "contentMediaType": "image/gif"
              }
            },
            "video/mp4": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/mp4"
              }
            },
            "video/webm": {
              "schema": {
                "type": "string",
                "contentMediaType": "video/webm"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The tale, with its new media.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "415": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/document": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tales"
        ],
        "operationId": "getTaleDocument",
        "summary": "Read a tale's draft document, with its published one.",
        "description": "The tale's draft document, the one Tale Studio edits, with its revision, and the published document with its own revision, the one the Delivery API serves. A tale without a draft answers `document: null` and revision 0; without a published document, `published: { document: null, revision: 0 }`, and it shows its media only. Unknown tale: 404 `tale_not_found`.",
        "responses": {
          "200": {
            "description": "The document and its revision.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleDocumentRevision"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "put": {
        "tags": [
          "Tales"
        ],
        "operationId": "saveTaleDocument",
        "summary": "Save a tale's draft document.",
        "description": "Replaces the draft document when `revision` is the draft's current revision, and answers it with the next revision and the published document. The published document, and so what visitors see, does not change. A write based on an older revision changes nothing and answers 409 `stale_revision`, so two editors never overwrite each other silently. The document is checked against `tale-document/v1.schema.json` (no additional properties), each element's `props` against the schema of its registered type, and the tale document rules: at most 60 elements, 1,000 characters per text, unique ids, numbers in range, no control characters except a line feed in text. A refused document answers 400 `invalid_tale_document` with `pointer`, the JSON pointer of the field it refused (from the document's root); a document over 128 KB answers 413 `tale_document_too_large`. A body without a document or a revision (an integer from 0 to 2147483647): 400 `validation_failed`. Unknown tale: 404 `tale_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveTaleDocumentRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The saved document and its new revision.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleDocumentRevision"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/document/publish": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tales"
        ],
        "operationId": "publishTaleDocument",
        "summary": "Publish a tale's draft document.",
        "description": "Copies the draft document to the published one, which the Delivery API serves, raising the published revision, when `revision` is the draft's current revision and `publishedRevision` the published one's (compare-and-set on both, in one statement); otherwise nothing changes and it answers 409 `stale_revision`. A tale with no draft answers 409 `nothing_to_publish`; publishing never empties the published document. The draft is checked again first (400 `invalid_tale_document` with `pointer`), with the publish rule too: every visible image and video needs alt text (`a11y.label`) or `a11y.decorative`, refused with a message naming the element. The tale's status is not changed: a tale that is not `live` stays unseen. A body without integer revisions: 400 `validation_failed`. Unknown tale: 404 `tale_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaleDocumentRevisionsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The draft and the newly published document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleDocumentRevision"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/document/discard": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tales"
        ],
        "operationId": "discardTaleDraft",
        "summary": "Discard a tale's draft, back to the published document.",
        "description": "Replaces the draft with the published document (null when nothing is published), raising the draft's revision, when `revision` and `publishedRevision` are the current ones (compare-and-set on both); otherwise nothing changes and it answers 409 `stale_revision`. A body without integer revisions: 400 `validation_failed`. Unknown tale: 404 `tale_not_found`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TaleDocumentRevisionsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The draft, now the published document, and the published document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleDocumentRevision"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/document/copy": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tales"
        ],
        "operationId": "copyTaleDocument",
        "summary": "Keep a document as a new tale in the tale's group.",
        "description": "For a save refused with 409 `stale_revision`: keeps the editor's own version as a new tale instead of dropping it. In one transaction, creates a `draft` tale at the end of the source tale's group, titled after the source with \" (copy)\", sharing the source's media storage key, and holding `document` as its draft at revision 1, with nothing published (revision 0). The document is checked as a save checks it: 400 `invalid_tale_document` with `pointer`, 413 `tale_document_too_large`. A tale group holds at most 100 tales: 409 `tale_group_full`, and nothing is created. The caller needs access to the source tale's project (403 `forbidden`). Unknown tale: 404 `tale_not_found`; a body without a document: 400 `validation_failed`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CopyTaleDocumentRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new tale, a draft, holding the document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/duplicate": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "post": {
        "tags": [
          "Tales"
        ],
        "operationId": "duplicateTale",
        "summary": "Duplicate a tale with its documents into a tale group of its project.",
        "description": "Creates a new tale in `taleGroupId` (default: the tale's own group), which must be a group of the tale's project: an unknown group or one of another project answers 400 `tale_group_not_found`, as placing a group does. The copy is stored `paused` (Hidden), so it does not show twice on the site until someone shows it. In the tale's own group it is placed right after the original (the later tales move one on); in another group, last. Its title is the original's with \" (copy)\"; it keeps the start and end time. It holds the original's published document as its published document and the original's draft as its draft, each at revision 1 (0 when there is none), so a pending change is still pending on the copy; every element gets a new id. Media and project fonts are not copied: the copy shares the original's storage keys, and a file stays while any tale uses it, so changing or archiving the original never breaks the copy. A tale group holds at most 100 tales: 409 `tale_group_full`, and nothing is created. The caller needs access to the tale's project (403 `forbidden`). Unknown tale: 404 `tale_not_found`; a `taleGroupId` that is not a string: 400 `validation_failed`.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DuplicateTaleRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The copy, Hidden, as its group lists it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Tale"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/tales/{taleId}/responses": {
      "parameters": [
        {
          "name": "taleId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "get": {
        "tags": [
          "Tales"
        ],
        "operationId": "getTaleResponses",
        "summary": "Read the responses to a tale's interactive components.",
        "description": "The visitors' responses to the tale's interactive components, as Tale Studio's inspector shows them: per component, the number of responses in all and per stored value (a poll's option id, a quiz's answer id, a Rating's value from 0 to 100, a Reaction's emoji), whatever the tale's documents hold now. A component without a response is not listed. Counts include only responses from the site; the studio preview records none. Any member with access to the tale's project may read them (403 `forbidden` otherwise). Unknown tale: 404 `tale_not_found`.",
        "responses": {
          "200": {
            "description": "The counts per component.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TaleResponses"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/responses": {
      "servers": [
        {
          "url": "/delivery/v1",
          "description": "Delivery API, for SDKs. Accepts site keys only."
        }
      ],
      "post": {
        "tags": [
          "Delivery"
        ],
        "operationId": "postDeliveryResponse",
        "summary": "Record a visitor's response to an interactive component (Delivery API).",
        "description": "Served under `/delivery/v1`. Accepts the project's site key and the zone's slug exactly as `GET /zone` does, with the same 401 `invalid_site_key` for every refused key or slug, and the same per-visitor rate limit, plus a stricter limit of its own for writes (by default 30 at once and 1 per second per visitor). The `X-TaleArt-Visitor` header is required here: a missing or invalid visitor id answers 400 `visitor_id_required`. The response is kept only when the tale is delivered in that zone now (the visibility rule: its group is placed there and on, the tale shown and updated, inside their windows, the zone set up), and its published document holds a visible element with that id of an interactive type; otherwise 404 `response_target_not_found`, the same for a tale of another project, a hidden tale and an unknown element. The value must be valid for the element (a poll's option id; a quiz's answer id; a Rating's whole number from 0 to 100, written plainly, such as `80`; a Reaction's emoji, one of its `emojis`), else 422 `validation_failed`. Each visitor keeps one response per tale, component and interaction kind, and the first is kept (C9): a new response answers 201, a repeat 200 with the stored value and `recorded: false`. Either way the answer carries the current result (a poll's or a quiz's counts and percentages per option or answer, a Rating's count and average, a Reaction's count per emoji). A quiz's correct answer is not in the answer: it is in the published document (C10). The row records the project, zone, display, tale group and tale ids, the element's id and type, the value, the visitor id and the client's and the server's times; no user data. Deleting the tale deletes its responses. Answers are `Cache-Control: no-store`.",
        "security": [
          {
            "siteKey": []
          },
          {
            "zoneKey": []
          }
        ],
        "parameters": [
          {
            "name": "zone",
            "in": "query",
            "required": true,
            "description": "The zone's slug, as for `GET /zone`.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "X-TaleArt-Visitor",
            "in": "header",
            "required": true,
            "description": "The visitor id: a random UUID v4 the SDK keeps on the device, all in lowercase or all in uppercase. Responses are kept per visitor id.",
            "schema": {
              "type": "string",
              "format": "uuid",
              "pattern": "^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/InteractiveResponseRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A repeat; the visitor's first response, kept, with the current result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InteractiveResponse"
                }
              }
            }
          },
          "201": {
            "description": "The response was recorded; the current result.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InteractiveResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/events": {
      "servers": [
        {
          "url": "/delivery/v1",
          "description": "Delivery API, for SDKs. Accepts zone keys only."
        }
      ],
      "post": {
        "tags": [
          "Delivery"
        ],
        "operationId": "postDeliveryEvents",
        "summary": "Record the analytics events of a zone's viewers (Delivery API).",
        "description": "Served under `/delivery/v1`. Accepts only a zone key (`tak_...`), in `Authorization: Bearer <key>`, or, for `navigator.sendBeacon` (which cannot set headers), in the `key` field of the JSON body; a site key is refused. A refused key, revoked or unknown, answers the same 401 `invalid_site_key` as every Delivery request. The visitor id is the `X-TaleArt-Visitor` header; a beacon, which cannot set it, sends it in the `visitor` field of the body. A missing or invalid visitor id answers 400 `visitor_id_required`. Each request carries at most 50 events (more answers 400 `validation_failed`). An event is kept when its type is one of `impression`, `open`, `view` and `click`, its `groupId` (and, for `view` and `click`, its `taleId`, which the other types must not carry) names a tale group placed in the key's zone (and a tale of that group), and its `at` is an ISO 8601 UTC time (ending in `Z`) not older than 7 days nor more than 1 hour ahead. Any other event is dropped and counted. Only the visitor's id as a hash, the zone and the ids, the type and the time are stored; no IP address, user agent or external user ID. Answers 202 with the counts. Rate limited per visitor, more strictly than reads (by default 30 at once and 0.5 per second), with the same 429 `rate_limited`.",
        "security": [
          {
            "zoneKey": []
          }
        ],
        "parameters": [
          {
            "name": "X-TaleArt-Visitor",
            "in": "header",
            "required": false,
            "description": "The visitor id: a random UUID v4 the SDK keeps on the device, all in lowercase or all in uppercase. Required unless the body carries `visitor`.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AnalyticsEventsRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The events were handled; the counts of kept and dropped events.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AnalyticsAccepted"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "413": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          },
          "default": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "session": {
        "type": "apiKey",
        "in": "cookie",
        "name": "taleart_session",
        "description": "Server-side session started by a passkey sign-in. Without a valid session every route except `/health` and `/auth/*` answers 401 `unauthenticated`. A signed-in user who cannot access the project of a resource gets 403 `forbidden`; a resource that does not exist still answers 404. When a request carries an `Authorization` header, the cookie is not read (see `token`)."
      },
      "token": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "tat_",
        "description": "A personal API token (`tat_...`) created in the dashboard under Settings > API tokens, sent as `Authorization: Bearer <token>`. It acts as the user who created it, within the token's scope: its projects (or all of the user's projects) intersected with the user's access read on every request, and, for a read-only token, GET and HEAD only (anything else answers 403 `forbidden`). Admin-only routes stay admin-only. A token follows its user's role as it is now: a token for all projects reaches every project once its user is made an admin (and fewer when the user loses projects or admin), and an admin's read-write token can do what the admin can within its projects, including issuing, rotating and revoking site keys, which affects every zone of the project. An admin can revoke every token of a user (`DELETE /users/{userId}/api-tokens`). At most 50 tokens per user are active (expired and revoked ones do not count). A missing, unknown, revoked or expired token, or any other credential in the header (such as a `tas_` site key), answers 401 `unauthenticated`; when a request carries an `Authorization` header the session cookie is ignored, even if the header is refused. Failed attempts count against the caller's address (by default 10 at once, refilled at 10 a minute); past that, a failed attempt answers 429 `rate_limited` with `Retry-After` instead of 401, while a valid token keeps working. A token can never create, list or revoke tokens, use `/auth/*` (sign-in, sign-out, setup, invitations: 403 `forbidden`), manage passkeys or sessions, or create invitations; a token limited to some projects cannot create projects. Only the Management API accepts it; the Delivery API refuses it."
      },
      "siteKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "The project's site key (`tas_...`) issued in the dashboard, sent as `Authorization: Bearer <key>` with the zone's slug in the `zone` query parameter. Only the Delivery API accepts it."
      },
      "zoneKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "A zone key (`tak_...`) issued in the dashboard, sent as `Authorization: Bearer <key>`. Only the Delivery API accepts it."
      }
    },
    "parameters": {
      "InvitationToken": {
        "name": "token",
        "in": "path",
        "required": true,
        "description": "The one-time token from the invitation link.",
        "schema": {
          "type": "string"
        }
      },
      "PasswordResetToken": {
        "name": "token",
        "in": "path",
        "required": true,
        "description": "The one-time token from the password reset link.",
        "schema": {
          "type": "string"
        }
      },
      "ProviderSlug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "The provider's slug: `google`, `apple`, or the slug of a generic OpenID Connect provider (lower case letters, digits and hyphens, starting with a letter or digit, at most 40 characters).",
        "schema": {
          "type": "string",
          "pattern": "^[a-z0-9][a-z0-9-]{0,39}$"
        }
      }
    },
    "responses": {
      "Error": {
        "description": "The request failed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorBody"
            }
          }
        }
      }
    },
    "schemas": {
      "Health": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "const": "ok"
          }
        }
      },
      "Project": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "CreateProjectRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          }
        }
      },
      "Zone": {
        "type": "object",
        "required": [
          "id",
          "name",
          "slug",
          "projectId",
          "createdFromTemplate",
          "frame",
          "visibility",
          "firstVisibleAt"
        ],
        "properties": {
          "firstVisibleAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the zone was first read as visible on the site, or null while it never was. Set once, by the API, the first time it returns the zone with `visibility.visible` true, and never cleared: the dashboard hides the zone's setup checklist from then on, for every user, even when the zone later stops being visible."
          },
          "visibility": {
            "$ref": "#/components/schemas/Visibility",
            "description": "Whether the zone shows anything on the site now: it has a display, it is installed, and a tale group placed in it is delivered. Computed by the rule the Delivery API uses."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "maxLength": 64,
            "description": "Unique in the zone's project: lowercase letters and digits, words joined by single hyphens. The value a host page names in `zone=\"…\"`."
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "createdFromTemplate": {
            "type": [
              "string",
              "null"
            ],
            "description": "The key of the template the zone was created from, from the template registry in `libs/contract`; null when none. Only a \"created from template\" note: the template set the display once and nothing links the zone to it afterwards. A zone the migration made from a stage keeps its original zone's note."
          },
          "frame": {
            "description": "The zone's display: at most one frame, or null when the zone has none. A zone without a display delivers nothing.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/Frame"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "CreateZoneRequest": {
        "type": "object",
        "required": [
          "name",
          "projectId"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "slug": {
            "type": "string",
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "maxLength": 64,
            "description": "The zone's slug. When missing, it is made from the name and made unique in the project with a numeric suffix (`home-2`). A slug another zone of the project has answers 409 `slug_taken`."
          },
          "template": {
            "type": "string",
            "enum": [
              "stories-bar",
              "promo-banner",
              "blank"
            ],
            "description": "A template from the template registry in `libs/contract`. It sets the zone's display and its settings once, in the same transaction, and is recorded as the zone's `createdFromTemplate` note. `blank` sets no display. When missing, the zone has no display and no note."
          }
        }
      },
      "UpdateZoneRequest": {
        "type": "object",
        "description": "At least one of `name` and `slug`.",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "slug": {
            "type": "string",
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "maxLength": 64
          }
        }
      },
      "FrameType": {
        "type": "object",
        "required": [
          "key",
          "label",
          "schema"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "Registry key stored on each frame as `type`."
          },
          "label": {
            "type": "string"
          },
          "schema": {
            "type": "object",
            "additionalProperties": true,
            "description": "The JSON Schema (draft 2020-12) of the type's `settings`, from `libs/contract/frame-types/`. Each field's `title`, `description` and `default` drive the dashboard's settings form."
          }
        }
      },
      "Frame": {
        "type": "object",
        "required": [
          "id",
          "zoneId",
          "projectId",
          "type",
          "settings"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "zoneId": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "description": "A frame type key from the registry."
          },
          "settings": {
            "type": "object",
            "additionalProperties": true,
            "description": "Settings valid against the frame type's JSON Schema."
          }
        }
      },
      "SetFrameRequest": {
        "type": "object",
        "required": [
          "type"
        ],
        "properties": {
          "type": {
            "type": "string",
            "minLength": 1
          },
          "settings": {
            "type": "object",
            "additionalProperties": true
          }
        }
      },
      "UpdateFrameRequest": {
        "type": "object",
        "description": "At least one of `type` and `settings`.",
        "properties": {
          "type": {
            "type": "string",
            "minLength": 1,
            "description": "A new frame type; without `settings`, the settings reset to its defaults."
          },
          "settings": {
            "type": "object",
            "additionalProperties": true,
            "description": "The display's new settings, valid against its (new) type's JSON Schema."
          }
        }
      },
      "TaleGroup": {
        "type": "object",
        "description": "A tale group belongs to its project. Zones place it, each in its own order; its tales, tags and media are the same in every zone.",
        "required": [
          "id",
          "projectId",
          "name",
          "status",
          "storedStatus",
          "startsAt",
          "endsAt",
          "cover",
          "zoneIds",
          "tales",
          "tags",
          "tagMatch",
          "userLists",
          "audience",
          "visibility"
        ],
        "properties": {
          "visibility": {
            "$ref": "#/components/schemas/Visibility",
            "description": "Whether the group is visible on the site now. As a zone lists it (`ZoneTaleGroup`), in that zone: the visibility of this placement. Elsewhere, in any zone it is placed in."
          },
          "cover": {
            "description": "The group's cover, still or animated, or null when it has none; then the bar shows its first published tale's media.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/TaleGroupCover"
              },
              {
                "type": "null"
              }
            ]
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The group's status as read: `scheduled` or `expired` when `storedStatus` is `live` and the current time is before `startsAt` or at or after `endsAt`, else `storedStatus`."
          },
          "storedStatus": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The status key a person set: `draft` for a new group, then `live`, `paused` or `archived`."
          },
          "startsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Start of the window, ISO 8601 UTC, or null for no start. Before it, live content reads `scheduled` and is not delivered."
          },
          "endsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "End of the window, ISO 8601 UTC, or null for no end; later than `startsAt`. From it on, live content reads `expired` and is not delivered."
          },
          "zoneIds": {
            "type": "array",
            "description": "Every zone the group is placed in, in the order it was placed; empty when none.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "tales": {
            "type": "array",
            "maxItems": 100,
            "description": "The group's tales, by position.",
            "items": {
              "$ref": "#/components/schemas/Tale"
            }
          },
          "tags": {
            "type": "array",
            "description": "The group's tags, by name ignoring case.",
            "items": {
              "$ref": "#/components/schemas/TaleGroupTag"
            }
          },
          "tagMatch": {
            "$ref": "#/components/schemas/TagMatch"
          },
          "userLists": {
            "type": "array",
            "description": "The user lists the group is targeted to, by name ignoring case; empty when none. With any, the group shows only to viewers whose external user ID is in one of them (and whose tags pass its tag rule).",
            "items": {
              "$ref": "#/components/schemas/TaleGroupUserList"
            }
          },
          "audience": {
            "description": "The group's audience rule (viewer properties, see `AudienceRule`), or null when it has none. Every viewer gets the same rule; the SDK evaluates it with `audienceRulePasses` from the contract, on top of the tags and user lists.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/AudienceRule"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "ZoneTaleGroup": {
        "description": "A tale group as a zone lists it, with its position in that zone.",
        "allOf": [
          {
            "$ref": "#/components/schemas/TaleGroup"
          },
          {
            "type": "object",
            "required": [
              "position"
            ],
            "properties": {
              "position": {
                "type": "integer",
                "minimum": 0,
                "description": "Order within this zone; the zone's lists are sorted by it."
              }
            }
          }
        ]
      },
      "ProjectTaleGroup": {
        "type": "object",
        "description": "A tale group as the project's list shows it: the group's own fields, cover, tags and visibility in any zone it is placed in, with its tale counts instead of its tales, so the list stays small.",
        "required": [
          "id",
          "projectId",
          "name",
          "status",
          "storedStatus",
          "startsAt",
          "endsAt",
          "cover",
          "zoneIds",
          "tags",
          "tagMatch",
          "userLists",
          "audience",
          "visibility",
          "taleCount",
          "shownTaleCount"
        ],
        "properties": {
          "visibility": {
            "$ref": "#/components/schemas/Visibility",
            "description": "Whether the group is visible on the site now, in any zone it is placed in."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The group's status as read, as on `TaleGroup`."
          },
          "storedStatus": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The status key a person set."
          },
          "startsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Start of the window, ISO 8601 UTC, or null for no start."
          },
          "endsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "End of the window, ISO 8601 UTC, or null for no end."
          },
          "cover": {
            "description": "The group's cover, or null when it has none, as on `TaleGroup`.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/TaleGroupCover"
              },
              {
                "type": "null"
              }
            ]
          },
          "zoneIds": {
            "type": "array",
            "description": "Every zone the group is placed in, in the order it was placed; empty when none.",
            "items": {
              "type": "string",
              "format": "uuid"
            }
          },
          "tags": {
            "type": "array",
            "description": "The group's tags, by name ignoring case.",
            "items": {
              "$ref": "#/components/schemas/TaleGroupTag"
            }
          },
          "tagMatch": {
            "$ref": "#/components/schemas/TagMatch"
          },
          "userLists": {
            "type": "array",
            "description": "The user lists the group is targeted to, by name ignoring case; empty when none. With any, the group shows only to viewers whose external user ID is in one of them (and whose tags pass its tag rule).",
            "items": {
              "$ref": "#/components/schemas/TaleGroupUserList"
            }
          },
          "audience": {
            "description": "The group's audience rule (viewer properties, see `AudienceRule`), or null when it has none. Every viewer gets the same rule; the SDK evaluates it with `audienceRulePasses` from the contract, on top of the tags and user lists.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/AudienceRule"
              },
              {
                "type": "null"
              }
            ]
          },
          "taleCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "shownTaleCount": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "The group's tales that are Shown (stored `live`)."
          }
        }
      },
      "Tag": {
        "type": "object",
        "required": [
          "id",
          "projectId",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40
          }
        }
      },
      "TaleGroupTag": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "description": "A tag as a tale group carries it.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "TaleGroupUserList": {
        "type": "object",
        "required": [
          "id",
          "name",
          "idCount"
        ],
        "description": "A user list as a tale group is targeted to it, with how many IDs it holds (never which).",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "idCount": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "UserListTaleGroup": {
        "type": "object",
        "required": [
          "id",
          "name"
        ],
        "description": "A tale group a user list targets.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "CreateTagRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 40,
            "description": "Trimmed before it is checked and stored; unique in the project ignoring case."
          }
        }
      },
      "UserList": {
        "type": "object",
        "required": [
          "id",
          "projectId",
          "name",
          "idCount",
          "taleGroups",
          "createdAt",
          "updatedAt"
        ],
        "description": "A project's list of external user IDs. It tells how many IDs it holds, never which: they are stored only as keyed hashes, and the tale groups it targets.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "idCount": {
            "type": "integer",
            "minimum": 0,
            "description": "How many distinct external user IDs the list holds."
          },
          "taleGroups": {
            "type": "array",
            "description": "The tale groups targeted to the list, by name ignoring case.",
            "items": {
              "$ref": "#/components/schemas/UserListTaleGroup"
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the list was created, renamed or last had IDs added."
          }
        }
      },
      "CreateUserListRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed before it is checked and stored; unique in the project ignoring case."
          }
        }
      },
      "RenameUserListRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed before it is checked and stored; unique in the project ignoring case."
          }
        }
      },
      "UserListRefusedRow": {
        "type": "object",
        "required": [
          "line",
          "reason"
        ],
        "description": "A refused row, by its line number in the file (from 1), never by its value.",
        "properties": {
          "line": {
            "type": "integer",
            "minimum": 1
          },
          "reason": {
            "$ref": "#/components/schemas/UserListRefusal"
          }
        }
      },
      "UserListRefusal": {
        "type": "string",
        "enum": [
          "too-long",
          "control-character",
          "not-one-column"
        ],
        "description": "Why a row was refused: longer than 256 characters (code points), holding a control character, or more than one field (an unquoted comma or quote, or a broken quoted field). Any other Unicode text is an ID: the SDKs send it base64url-encoded (`X-TaleArt-External-User-Id`), and it matches exactly, case included, with no Unicode normalization."
      },
      "UserListUploadReport": {
        "type": "object",
        "required": [
          "added",
          "alreadyPresent",
          "refused",
          "refusedRows",
          "emptyRows",
          "emailLike",
          "phoneLike",
          "idCount"
        ],
        "description": "What an upload did, in counts only. It never holds an uploaded value.",
        "properties": {
          "added": {
            "type": "integer",
            "minimum": 0,
            "description": "IDs added to the list."
          },
          "alreadyPresent": {
            "type": "integer",
            "minimum": 0,
            "description": "Rows whose ID the list already held, or an earlier row of the file held."
          },
          "refused": {
            "type": "integer",
            "minimum": 0
          },
          "refusedRows": {
            "type": "array",
            "maxItems": 10,
            "description": "The first refused rows, by line number.",
            "items": {
              "$ref": "#/components/schemas/UserListRefusedRow"
            }
          },
          "emptyRows": {
            "type": "integer",
            "minimum": 0,
            "description": "Blank rows, skipped."
          },
          "emailLike": {
            "type": "integer",
            "minimum": 0,
            "description": "Accepted rows that look like an email address."
          },
          "phoneLike": {
            "type": "integer",
            "minimum": 0,
            "description": "Accepted rows that look like a phone number."
          },
          "idCount": {
            "type": "integer",
            "minimum": 0,
            "description": "How many IDs the list holds now."
          }
        }
      },
      "SetTaleGroupTagsRequest": {
        "type": "object",
        "required": [
          "tagIds"
        ],
        "properties": {
          "tagIds": {
            "type": "array",
            "description": "The ids of the group's new tags, all of the group's project. Repeats count once.",
            "items": {
              "type": "string"
            }
          },
          "tagMatch": {
            "$ref": "#/components/schemas/TagMatch",
            "description": "How the tags combine; left out, the group keeps its match mode."
          }
        }
      },
      "SetTaleGroupUserListsRequest": {
        "type": "object",
        "required": [
          "userListIds"
        ],
        "properties": {
          "userListIds": {
            "type": "array",
            "maxItems": 100,
            "description": "The ids of the group's new user lists, all of the group's project. Repeats count once; empty clears them.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "TagMatch": {
        "type": "string",
        "enum": [
          "any",
          "all"
        ],
        "description": "How a tale group's targeting tags combine: `any` (the default) shows the group to a viewer with at least one of its tags, `all` only to a viewer with every one of them. A group with no tags, or a viewer whose page passed no tags, always passes. The SDK decides, with `tagRulePasses` from the contract."
      },
      "AudienceOperator": {
        "type": "string",
        "enum": [
          "eq",
          "neq",
          "in",
          "notIn",
          "gt",
          "gte",
          "lt",
          "lte",
          "contains",
          "exists",
          "notExists"
        ],
        "description": "How a condition compares the viewer's property with its value. `eq` and `neq` compare strings ignoring case (Unicode NFC, trimmed), and numbers and booleans exactly. `in` and `notIn` take a list of strings. `gt`, `gte`, `lt` and `lte` compare numbers only. `contains` finds a substring of a string (ignoring case) or an item of a list of strings. `exists` and `notExists` take no value. A missing property is false, except for `neq`, `notIn` and `notExists`, which are true. A value of another type than the property's is false."
      },
      "AudienceCondition": {
        "type": "object",
        "description": "One test on one viewer property.",
        "required": [
          "property",
          "operator"
        ],
        "additionalProperties": false,
        "properties": {
          "property": {
            "type": "string",
            "pattern": "^[A-Za-z][A-Za-z0-9_.-]{0,63}$",
            "description": "The viewer property's name, as the host page passes it (case-sensitive)."
          },
          "operator": {
            "$ref": "#/components/schemas/AudienceOperator"
          },
          "value": {
            "description": "The value compared with. Required for every operator but `exists` and `notExists`, which take none. A string is at most 256 characters; a list holds 1 to 50 strings of at most 256.",
            "oneOf": [
              {
                "type": "string",
                "maxLength": 256
              },
              {
                "type": "number"
              },
              {
                "type": "boolean"
              },
              {
                "type": "array",
                "minItems": 1,
                "maxItems": 50,
                "items": {
                  "type": "string",
                  "maxLength": 256
                }
              }
            ]
          }
        }
      },
      "AudienceRule": {
        "type": "object",
        "description": "A tale group's audience rule: all or any of its conditions must hold. It is evaluated in the browser or app with `audienceRulePasses` from the contract, against the viewer properties the host page passed; those never reach the server. The limits and the test table are in the contract (`audience-rule.cases.json`).",
        "required": [
          "match",
          "conditions"
        ],
        "additionalProperties": false,
        "properties": {
          "match": {
            "type": "string",
            "enum": [
              "all",
              "any"
            ],
            "description": "`all` needs every condition to hold, `any` one of them."
          },
          "conditions": {
            "type": "array",
            "minItems": 1,
            "maxItems": 10,
            "description": "The conditions, 1 to 10. Rules do not nest.",
            "items": {
              "$ref": "#/components/schemas/AudienceCondition"
            }
          }
        }
      },
      "SetTaleGroupAudienceRequest": {
        "type": "object",
        "description": "The audience rule to set, or null to clear it. One shape for both: a client never clears a rule by sending an empty one by accident.",
        "required": [
          "audience"
        ],
        "additionalProperties": false,
        "properties": {
          "audience": {
            "description": "The new rule, or null to clear it.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/AudienceRule"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "CreateTaleGroupRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed before it is checked and stored; at most 100 characters."
          }
        }
      },
      "RenameTaleGroupRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100,
            "description": "Trimmed before it is checked and stored."
          }
        }
      },
      "CopyTaleGroupRequest": {
        "type": "object",
        "required": [
          "zoneId"
        ],
        "properties": {
          "zoneId": {
            "type": "string",
            "description": "The zone to place the copy in; its project may differ from the source's."
          }
        }
      },
      "DuplicateTaleRequest": {
        "type": "object",
        "properties": {
          "taleGroupId": {
            "type": "string",
            "description": "The tale group of the tale's project to put the copy in; absent, the tale's own group."
          }
        }
      },
      "Tale": {
        "type": "object",
        "required": [
          "id",
          "taleGroupId",
          "projectId",
          "title",
          "position",
          "status",
          "storedStatus",
          "startsAt",
          "endsAt",
          "media",
          "visibility"
        ],
        "properties": {
          "visibility": {
            "$ref": "#/components/schemas/Visibility",
            "description": "Whether the tale is visible on the site now. Inside a zone's list of tale groups, in that zone; elsewhere, in any zone its group is placed in."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "taleGroupId": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "position": {
            "type": "integer",
            "minimum": 0,
            "description": "Order within the tale group; lists are sorted by it."
          },
          "status": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The tale's status as read: `storedStatus` unless it is `live`; then `blocked-by-group` when its group is `draft` or `paused`, else `scheduled` before `startsAt`, `expired` at or after `endsAt`, else `live`. The group's window does not change the tale's status, but the Delivery API needs both windows to contain the current time."
          },
          "storedStatus": {
            "$ref": "#/components/schemas/ContentStatus",
            "description": "The status key a person set: `live` for a new tale (Shown, so a group turned On shows it), then `paused` (Hidden), `live` (Shown) or `archived`. `draft` (Hidden) remains on older tales and on kept copies. The dashboard shows `live` as Shown and `draft` or `paused` as Hidden (`taleShownStatus`). A tale is delivered only when it also has a published document."
          },
          "startsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Start of the window, ISO 8601 UTC, or null for no start. Before it, live content reads `scheduled` and is not delivered."
          },
          "endsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "End of the window, ISO 8601 UTC, or null for no end; later than `startsAt`. From it on, live content reads `expired` and is not delivered."
          },
          "media": {
            "description": "The tale's image, GIF or video, or null when it has none. Kept for SDKs from before documents: the document holds the same media as its `taleMedia` element, and current SDKs render only the document.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/TaleMedia"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "TaleMedia": {
        "type": "object",
        "required": [
          "kind",
          "contentType",
          "url"
        ],
        "properties": {
          "kind": {
            "$ref": "#/components/schemas/MediaKind"
          },
          "contentType": {
            "$ref": "#/components/schemas/MediaContentType"
          },
          "url": {
            "type": "string",
            "description": "Where the Management API serves the file, such as `/api/v1/tales/{taleId}/media`."
          }
        }
      },
      "TaleGroupCover": {
        "type": "object",
        "required": [
          "kind",
          "contentType",
          "animated",
          "url"
        ],
        "properties": {
          "kind": {
            "$ref": "#/components/schemas/MediaKind"
          },
          "contentType": {
            "$ref": "#/components/schemas/MediaContentType"
          },
          "animated": {
            "type": "boolean",
            "description": "True for a video, a GIF, an animated WebP or an APNG: the bar animates it unless the visitor prefers reduced motion."
          },
          "url": {
            "type": "string",
            "description": "Where the Management API serves the file, such as `/api/v1/tale-groups/{taleGroupId}/cover`."
          }
        }
      },
      "Visibility": {
        "type": "object",
        "description": "Whether content is visible on the site now, and why not. Computed from the same rule the Delivery API filters by, so the two cannot disagree: `visible` is true exactly when `reasons` is empty.",
        "required": [
          "visible",
          "reasons"
        ],
        "properties": {
          "visible": {
            "type": "boolean"
          },
          "reasons": {
            "type": "array",
            "description": "Every reason the content is not visible, most useful first (`VISIBILITY_REASON_ORDER` in `libs/contract`); empty when it is visible.",
            "items": {
              "$ref": "#/components/schemas/VisibilityReason"
            }
          }
        }
      },
      "VisibilityReason": {
        "type": "object",
        "required": [
          "code",
          "subject"
        ],
        "properties": {
          "code": {
            "$ref": "#/components/schemas/VisibilityReasonCode"
          },
          "subject": {
            "type": "string",
            "enum": [
              "zone",
              "group",
              "tale"
            ],
            "description": "What the reason is about, such as the tale's group for `group-off` on a tale."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "For `starts-at` and `ended-at`, the start or end of the window, ISO 8601 UTC."
          }
        }
      },
      "VisibilityReasonCode": {
        "type": "string",
        "description": "Why content is not visible. `group-off`: the group is stored `draft` or `paused`. `tale-hidden`: the tale is. `group-blocked`: a process set the group's key (`processing`, `invalid-media`, `quota-reached`). `archived`. `no-shown-tales`: a group has no delivered tale. `no-visible-groups`: a zone has tale groups but delivers none. `no-tale-groups`: a zone has none. `not-in-any-zone`. `starts-at` and `ended-at`: outside the window. `zone-no-display`: the zone has no frame. `zone-not-installed`: the zone has no active key. `media-processing`: the tale is stored `processing`. `media-problem`: the tale is stored `invalid-media` or `quota-reached`. `not-updated-yet`: nothing published to deliver. `audience-empty`: the group is targeted only to user lists and none of them holds an ID, so no viewer can match. `audience-removed`: the group's user lists were taken away without a person choosing a new audience (its last list was deleted, or it was copied to another project, where its lists do not exist); it stays hidden until someone saves its Audience, so it never turns public by accident.",
        "enum": [
          "group-off",
          "tale-hidden",
          "group-blocked",
          "archived",
          "no-shown-tales",
          "not-in-any-zone",
          "starts-at",
          "ended-at",
          "zone-no-display",
          "zone-not-installed",
          "media-processing",
          "media-problem",
          "not-updated-yet",
          "no-visible-groups",
          "no-tale-groups",
          "audience-empty",
          "audience-removed"
        ]
      },
      "ContentStatus": {
        "type": "string",
        "description": "A content status key. Each key has one HTTP code, defined in `libs/contract` (`CONTENT_STATUS_HTTP_CODES`): draft 204, live 200, scheduled 202, processing 102, invalid-media 422, paused 403, blocked-by-group 423, expired 410, quota-reached 429, archived 404. `scheduled`, `expired` and `blocked-by-group` are computed when content is read and never stored.",
        "enum": [
          "draft",
          "live",
          "scheduled",
          "processing",
          "invalid-media",
          "paused",
          "blocked-by-group",
          "expired",
          "quota-reached",
          "archived"
        ]
      },
      "SetStatusRequest": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "live",
              "paused",
              "archived"
            ],
            "description": "`live` publishes, `paused` pauses (or restores an archived item), `archived` archives."
          }
        }
      },
      "SetScheduleRequest": {
        "type": "object",
        "required": [
          "startsAt",
          "endsAt"
        ],
        "description": "The window in which live content is delivered. Each bound is an ISO 8601 date-time with `Z` or an offset, stored and returned in UTC, or null for an open bound. `endsAt` must be later than `startsAt` when both are set.",
        "properties": {
          "startsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "endsAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "MediaKind": {
        "type": "string",
        "enum": [
          "image",
          "video"
        ]
      },
      "MediaContentType": {
        "type": "string",
        "enum": [
          "image/jpeg",
          "image/png",
          "image/webp",
          "image/gif",
          "video/mp4",
          "video/webm"
        ]
      },
      "CreateTaleRequest": {
        "type": "object",
        "required": [
          "title"
        ],
        "properties": {
          "title": {
            "type": "string",
            "minLength": 1
          }
        }
      },
      "OrderTaleGroupsRequest": {
        "type": "object",
        "required": [
          "taleGroupIds"
        ],
        "properties": {
          "taleGroupIds": {
            "type": "array",
            "description": "Every tale group id placed in the zone, once each, in the new order.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "OrderTalesRequest": {
        "type": "object",
        "required": [
          "taleIds"
        ],
        "properties": {
          "taleIds": {
            "type": "array",
            "description": "Every tale id of the group, once each, in the new order.",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "InstallInfo": {
        "type": "object",
        "required": [
          "apiUrl",
          "sdkUrl",
          "sdkPinnedUrl",
          "sdkIntegrity",
          "sdkBuildsKept",
          "testPageUrl"
        ],
        "properties": {
          "apiUrl": {
            "type": "string",
            "description": "The API's public origin, the value of `api-url` on a host page."
          },
          "sdkUrl": {
            "type": "string",
            "description": "The Web SDK bundle (an ES module) for a host page's script tag, at a stable URL: a deploy replaces it there (cached five minutes)."
          },
          "sdkPinnedUrl": {
            "type": [
              "string",
              "null"
            ],
            "description": "The same bundle at a URL pinned to its content (`<apiUrl>/sdk/v1/taleart.<hash>.js`, cached a year, immutable), for a page that also pins it with `integrity`. The API keeps serving it while it is one of the last `sdkBuildsKept` SDK builds it served. Null when the API has no bundle."
          },
          "sdkIntegrity": {
            "type": [
              "string",
              "null"
            ],
            "description": "The bundle's Subresource Integrity value (`sha384-…`); null when the API has no bundle."
          },
          "sdkBuildsKept": {
            "type": "integer",
            "minimum": 1,
            "description": "How many SDK builds this server keeps at their pinned URLs, the current one included (SDK_BUILDS_KEPT, default 5). 1 when the server keeps no earlier builds (SDK_BUILDS_DIR=off): a pinned URL then stops working at the next SDK build."
          },
          "testPageUrl": {
            "type": "string",
            "description": "The test page. It takes the zone's slug as `?zone=<slug>` and a site key in the URL fragment, `#siteKey=<key>`, or pasted on the page."
          }
        }
      },
      "ZoneKeyInfo": {
        "type": "object",
        "required": [
          "prefix",
          "createdAt"
        ],
        "properties": {
          "prefix": {
            "type": "string",
            "description": "The start of the key, `tak_` and the next 6 characters, to recognise it."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SiteKeyInfo": {
        "type": "object",
        "required": [
          "prefix",
          "createdAt"
        ],
        "properties": {
          "prefix": {
            "type": "string",
            "description": "The start of the site key, `tas_` and the next 6 characters, to recognise it."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "IssueSiteKeyRequest": {
        "type": "object",
        "properties": {
          "ifNone": {
            "type": "boolean",
            "description": "Issue only when the project has no active site key; otherwise 409 `site_key_exists` and nothing changes. Leave it out (or false) to rotate."
          }
        }
      },
      "IssuedZoneKey": {
        "type": "object",
        "required": [
          "key",
          "prefix",
          "createdAt"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The secret zone key, `tak_` and 32 random bytes in base64url. Shown only once."
          },
          "prefix": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "IssuedSiteKey": {
        "type": "object",
        "required": [
          "key",
          "prefix",
          "createdAt"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The secret site key, `tas_` and 32 random bytes in base64url. Shown only once."
          },
          "prefix": {
            "type": "string"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ApiToken": {
        "type": "object",
        "description": "A personal API token as its user sees it, never the token or its hash.",
        "required": [
          "id",
          "name",
          "prefix",
          "projectIds",
          "readOnly",
          "createdAt",
          "expiresAt",
          "lastUsedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "What the token is for, 1 to 80 characters."
          },
          "prefix": {
            "type": "string",
            "description": "The start of the token, `tat_` and the next 6 characters, to recognise it."
          },
          "projectIds": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The projects the token is limited to; null for every project its user can access. Either way the user's own access is applied too."
          },
          "readOnly": {
            "type": "boolean",
            "description": "When true the token may only read (GET and HEAD)."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "expiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the token stops working; null when it does not expire."
          },
          "lastUsedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the token was last used, to within a minute (it is written at most once a minute); null when it was never used."
          }
        }
      },
      "CreateApiTokenRequest": {
        "type": "object",
        "required": [
          "name",
          "projectIds",
          "readOnly"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "projectIds": {
            "type": [
              "array",
              "null"
            ],
            "minItems": 1,
            "maxItems": 100,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "format": "uuid"
            },
            "description": "The projects to limit the token to, or null for every project the user can access."
          },
          "readOnly": {
            "type": "boolean"
          },
          "expiresAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the token stops working, in the future; null or left out for never."
          }
        }
      },
      "CurrentApiToken": {
        "description": "The API token making the request and the user it acts as (GET /api-tokens/current).",
        "allOf": [
          {
            "$ref": "#/components/schemas/ApiToken"
          },
          {
            "type": "object",
            "required": [
              "allProjects",
              "user"
            ],
            "properties": {
              "allProjects": {
                "type": "boolean",
                "description": "True when the token reaches every project its user can access (`projectIds` is null)."
              },
              "user": {
                "$ref": "#/components/schemas/User"
              }
            }
          }
        ]
      },
      "AdminUser": {
        "type": "object",
        "required": [
          "id",
          "name",
          "email",
          "role",
          "createdAt",
          "methods",
          "projects"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "role": {
            "$ref": "#/components/schemas/UserRole"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "methods": {
            "type": "object",
            "required": [
              "passkey",
              "password",
              "identities"
            ],
            "properties": {
              "passkey": {
                "type": "boolean",
                "description": "True when the user has at least one passkey."
              },
              "password": {
                "type": "boolean",
                "description": "True when the user has a username and password."
              },
              "identities": {
                "type": "array",
                "description": "The external providers the user links (`google`, `apple`, `oidc:<slug>`), each once.",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "projects": {
            "type": "integer",
            "minimum": 0,
            "description": "How many projects the user is a member of."
          }
        }
      },
      "AdminUserList": {
        "type": "object",
        "required": [
          "users",
          "truncated"
        ],
        "properties": {
          "users": {
            "type": "array",
            "maxItems": 500,
            "items": {
              "$ref": "#/components/schemas/AdminUser"
            }
          },
          "truncated": {
            "type": "boolean",
            "description": "True when the install holds more than 500 users; only the first 500 are listed."
          }
        }
      },
      "RevokedApiTokens": {
        "type": "object",
        "required": [
          "revoked"
        ],
        "properties": {
          "revoked": {
            "type": "integer",
            "minimum": 0,
            "description": "How many active tokens were revoked."
          }
        }
      },
      "IssuedApiToken": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ApiToken"
          },
          {
            "type": "object",
            "required": [
              "token"
            ],
            "properties": {
              "token": {
                "type": "string",
                "description": "The secret token, `tat_` and 32 random bytes in base64url. Shown only once."
              }
            }
          }
        ]
      },
      "ThemeColor": {
        "type": "string",
        "pattern": "^#[0-9a-fA-F]{6}$",
        "description": "A colour as `#rrggbb`; nothing else is accepted, so no value can inject CSS."
      },
      "RingStyle": {
        "type": "string",
        "enum": [
          "gradient",
          "solid",
          "none"
        ]
      },
      "ColorScheme": {
        "type": "string",
        "enum": [
          "auto",
          "light",
          "dark"
        ],
        "description": "Which colour scheme the SDK draws: `auto` follows the host page's `prefers-color-scheme`, `light` and `dark` always draw that one."
      },
      "ThemeSettings": {
        "type": "object",
        "description": "The fields of a theme a project sets; a field left out takes TaleArt's default, which keeps the look host pages had before themes: a transparent background, and the title colour, font and corner radius of the host page or the frame (`null`). Validated by `themeSettingsError` in `libs/contract`.",
        "additionalProperties": false,
        "properties": {
          "ringStyle": {
            "$ref": "#/components/schemas/RingStyle",
            "default": "gradient"
          },
          "ringColors": {
            "type": "array",
            "description": "The unseen ring's colours; a gradient runs through them, `solid` uses the first.",
            "minItems": 1,
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/ThemeColor"
            },
            "default": [
              "#ea580c",
              "#db2777",
              "#7c3aed"
            ]
          },
          "seenRingColor": {
            "$ref": "#/components/schemas/ThemeColor",
            "default": "#6b7280"
          },
          "ringWidth": {
            "type": "integer",
            "minimum": 1,
            "maximum": 4,
            "default": 2,
            "description": "The ring's width in px."
          },
          "cornerRadius": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 32,
            "default": null,
            "description": "Corner radius in px of rounded-square bar items and banner slides; null keeps the frame's own (a quarter of the item size, 12 px on a banner)."
          },
          "fontFamily": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 200,
            "pattern": "^(?!.*(?:^|[\\s,\"'])(?:[iI][nN][hH][eE][rR][iI][tT]|[iI][nN][iI][tT][iI][aA][lL]|[uU][nN][sS][eE][tT]|[rR][eE][vV][eE][rR][tT]|[rR][eE][vV][eE][rR][tT]-[lL][aA][yY][eE][rR]|[dD][eE][fF][aA][uU][lL][tT])(?=$|[\\s,\"']))(?!.*(?:^|[\\s,\"'])[tT][aA][lL][eE][aA][rR][tT]-[fF][oO][nN][tT]-)\\s*(?:[A-Za-z][A-Za-z0-9 -]*|\"[A-Za-z0-9 -]+\"|'[A-Za-z0-9 -]+')\\s*(?:,\\s*(?:[A-Za-z][A-Za-z0-9 -]*|\"[A-Za-z0-9 -]+\"|'[A-Za-z0-9 -]+')\\s*)*$",
            "default": null,
            "description": "A CSS font-family list (the host page loads the font): names of letters, digits, spaces and hyphens, bare (starting with a letter) or quoted, never a CSS-wide keyword or `default`. Null inherits the host page's font."
          },
          "titleColor": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^#[0-9a-fA-F]{6}$",
            "default": null,
            "description": "Titles and banner dots as `#rrggbb`; null inherits the host page's text colour."
          },
          "backgroundColor": {
            "type": "string",
            "pattern": "^(?:#[0-9a-fA-F]{6}|transparent)$",
            "default": "transparent",
            "description": "Behind the bar and the banner, as `#rrggbb` or `transparent` (the host page shows through)."
          },
          "accentColor": {
            "$ref": "#/components/schemas/ThemeColor",
            "default": "#ffffff",
            "description": "The viewer's progress and button icons."
          },
          "colorScheme": {
            "$ref": "#/components/schemas/ColorScheme",
            "default": "auto"
          },
          "liveColor": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^#[0-9a-fA-F]{6}$",
            "default": null,
            "description": "The ring of a live tale as `#rrggbb`; null takes the last of `ringColors`."
          },
          "successColor": {
            "$ref": "#/components/schemas/ThemeColor",
            "default": "#047857",
            "description": "The text and mark of a correct quiz answer. Needs 4.5:1 on light and dark backgrounds."
          },
          "errorColor": {
            "$ref": "#/components/schemas/ThemeColor",
            "default": "#b91c1c",
            "description": "The text and mark of a wrong quiz answer. Needs 4.5:1 on light and dark backgrounds."
          }
        }
      },
      "Theme": {
        "type": "object",
        "description": "A complete theme, every field present (TaleArt's default where the project sets none); `null` takes the host page's or the frame's own value.",
        "required": [
          "ringStyle",
          "ringColors",
          "seenRingColor",
          "ringWidth",
          "cornerRadius",
          "fontFamily",
          "titleColor",
          "backgroundColor",
          "accentColor",
          "colorScheme",
          "liveColor",
          "successColor",
          "errorColor"
        ],
        "additionalProperties": false,
        "properties": {
          "ringStyle": {
            "$ref": "#/components/schemas/RingStyle"
          },
          "ringColors": {
            "type": "array",
            "minItems": 1,
            "maxItems": 3,
            "items": {
              "$ref": "#/components/schemas/ThemeColor"
            }
          },
          "seenRingColor": {
            "$ref": "#/components/schemas/ThemeColor"
          },
          "ringWidth": {
            "type": "integer",
            "minimum": 1,
            "maximum": 4
          },
          "cornerRadius": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 32
          },
          "fontFamily": {
            "type": [
              "string",
              "null"
            ]
          },
          "titleColor": {
            "type": [
              "string",
              "null"
            ]
          },
          "backgroundColor": {
            "type": "string"
          },
          "accentColor": {
            "$ref": "#/components/schemas/ThemeColor"
          },
          "colorScheme": {
            "$ref": "#/components/schemas/ColorScheme"
          },
          "liveColor": {
            "type": [
              "string",
              "null"
            ],
            "pattern": "^#[0-9a-fA-F]{6}$"
          },
          "successColor": {
            "$ref": "#/components/schemas/ThemeColor"
          },
          "errorColor": {
            "$ref": "#/components/schemas/ThemeColor"
          }
        }
      },
      "ProjectTheme": {
        "type": "object",
        "required": [
          "projectId",
          "theme",
          "resolved"
        ],
        "properties": {
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "theme": {
            "$ref": "#/components/schemas/ThemeSettings"
          },
          "resolved": {
            "$ref": "#/components/schemas/Theme"
          }
        }
      },
      "DeliveryFrame": {
        "type": "object",
        "required": [
          "id",
          "type",
          "settings"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "description": "A frame type key; clients skip types they don't know."
          },
          "settings": {
            "type": "object",
            "additionalProperties": true,
            "description": "The frame's settings, with every field the stored settings leave out filled with its default from the type's JSON Schema."
          }
        }
      },
      "DeliveryZone": {
        "type": "object",
        "required": [
          "id",
          "name",
          "slug",
          "theme",
          "frame",
          "taleGroups"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "The zone's slug."
          },
          "theme": {
            "$ref": "#/components/schemas/Theme",
            "description": "The zone's project theme, every field filled with TaleArt's default where the project sets none."
          },
          "frame": {
            "description": "The zone's display, or null when it has none; a zone without a display lists no tale group.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/DeliveryFrame"
              },
              {
                "type": "null"
              }
            ]
          },
          "taleGroups": {
            "type": "array",
            "description": "The published tale groups placed in the zone, in this zone's order; the display shows all of them.",
            "items": {
              "$ref": "#/components/schemas/DeliveryTaleGroup"
            }
          }
        }
      },
      "DeliveryTaleGroup": {
        "type": "object",
        "required": [
          "id",
          "name",
          "cover",
          "tales",
          "tags",
          "tagMatch",
          "audience"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "description": "The names of the group's targeting tags, ignoring case by name; empty when the group has none. The SDK compares them with the viewer's tags; every viewer gets the same list.",
            "items": {
              "type": "string"
            }
          },
          "tagMatch": {
            "$ref": "#/components/schemas/TagMatch"
          },
          "audience": {
            "description": "The group's audience rule (viewer properties), or null when it has none. Every viewer gets the same rule; the SDK evaluates it against the properties the host page passed, which are never sent to the server.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/AudienceRule"
              },
              {
                "type": "null"
              }
            ]
          },
          "userListMatched": {
            "type": "boolean",
            "description": "True when the group is targeted to user lists and the viewer's external user ID (header `X-TaleArt-External-User-Id`) is in one of them; left out otherwise. A group targeted to lists the viewer is in none of is not in the response at all, so the SDK never needs the lists."
          },
          "cover": {
            "description": "The group's cover, or null when it has none; the bar then shows its first tale's lowest media element (the document's media background, else its first image or video element), or, when that tale has no media, its canvas colour.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/DeliveryCover"
              },
              {
                "type": "null"
              }
            ]
          },
          "tales": {
            "type": "array",
            "description": "The group's published tales by position.",
            "items": {
              "$ref": "#/components/schemas/DeliveryTale"
            }
          }
        }
      },
      "DeliveryTale": {
        "type": "object",
        "required": [
          "id",
          "title",
          "media",
          "document"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "title": {
            "type": "string"
          },
          "media": {
            "description": "The tale's image, GIF or video, or null when it has none. Kept for SDKs from before documents: the document holds the same media as its `taleMedia` element, and current SDKs render only the document.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/DeliveryMedia"
              },
              {
                "type": "null"
              }
            ]
          },
          "document": {
            "description": "The tale's document as SDKs render it. Every delivered tale has one: a tale whose media is not yet in its published document gets it composed in, and a tale with neither gets an empty one. Null only from older servers.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/DeliveryTaleDocument"
              },
              {
                "type": "null"
              }
            ]
          },
          "fonts": {
            "type": "array",
            "description": "The project fonts the document's visible text uses, left out when it uses none. SDKs fetch them only when the tale opens.",
            "items": {
              "$ref": "#/components/schemas/DeliveryFont"
            }
          }
        }
      },
      "DeliveryFont": {
        "type": "object",
        "required": [
          "id",
          "family",
          "url",
          "weight",
          "style"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid",
            "description": "The font id a text's `font.fontId` names."
          },
          "family": {
            "type": "string",
            "description": "The face's family name as the project stores it (spaces collapsed). The Web SDK draws the face under its id's alias and does not use it; native SDKs, which do not download project fonts, draw the text in a font the host app installed under this family name, else its fallback stack."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The public `font/woff2` URL of the file, open to any origin."
          },
          "weight": {
            "type": "integer"
          },
          "style": {
            "type": "string",
            "enum": [
              "normal",
              "italic"
            ]
          }
        }
      },
      "DeliveryTaleDocument": {
        "type": "object",
        "description": "A tale document as delivered: hidden elements are removed, and so are the editor-only fields `hidden` and `locked`. Clients ignore fields they do not know and skip element types they do not know.",
        "required": [
          "version",
          "canvas",
          "elements"
        ],
        "properties": {
          "version": {
            "type": "integer"
          },
          "canvas": {
            "$ref": "#/components/schemas/DeliveryCanvas"
          },
          "durationMs": {
            "type": "integer"
          },
          "placeholder": {
            "type": "boolean",
            "description": "True only on the document the Delivery API makes for a tale with neither media nor a document: viewers paint its colour across the whole stage, as such a tale always showed."
          },
          "elements": {
            "type": "array",
            "description": "The visible elements, first at the bottom of the z-order.",
            "items": {
              "$ref": "#/components/schemas/DeliveryTaleElement"
            }
          }
        }
      },
      "DeliveryTaleElement": {
        "type": "object",
        "required": [
          "id",
          "type",
          "box",
          "a11y",
          "props"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "description": "A key of the element-type registry, such as `text`, `image`, `video`, `shape`, `emoji`, `sticker`, `button`, `image-button`, `swipe-up`, `poll`, `rating`, `quiz`, `countdown`, `promo-code` or `reaction`."
          },
          "box": {
            "$ref": "#/components/schemas/Box"
          },
          "a11y": {
            "$ref": "#/components/schemas/A11y"
          },
          "props": {
            "type": "object",
            "description": "The type's props, as its schema in `tale-document/element-types/` defines them, except that an image's, video's, image sticker's or image button's `mediaKey` is replaced by `url`, its public URL. A link's destination stays `href`."
          }
        }
      },
      "TaleDocument": {
        "$ref": "#/components/schemas/v1.schema"
      },
      "TextElementProps": {
        "$ref": "#/components/schemas/text.schema"
      },
      "ImageElementProps": {
        "$ref": "#/components/schemas/image.schema"
      },
      "VideoElementProps": {
        "$ref": "#/components/schemas/video.schema"
      },
      "ShapeElementProps": {
        "$ref": "#/components/schemas/shape.schema"
      },
      "EmojiElementProps": {
        "$ref": "#/components/schemas/emoji.schema"
      },
      "StickerElementProps": {
        "$ref": "#/components/schemas/sticker.schema"
      },
      "ButtonElementProps": {
        "$ref": "#/components/schemas/button.schema"
      },
      "ImageButtonElementProps": {
        "$ref": "#/components/schemas/image-button.schema"
      },
      "SwipeUpElementProps": {
        "$ref": "#/components/schemas/swipe-up.schema"
      },
      "PollElementProps": {
        "$ref": "#/components/schemas/poll.schema"
      },
      "RatingElementProps": {
        "$ref": "#/components/schemas/rating.schema"
      },
      "QuizElementProps": {
        "$ref": "#/components/schemas/quiz.schema"
      },
      "CountdownElementProps": {
        "$ref": "#/components/schemas/countdown.schema"
      },
      "PromoCodeElementProps": {
        "$ref": "#/components/schemas/promo-code.schema"
      },
      "ReactionElementProps": {
        "$ref": "#/components/schemas/reaction.schema"
      },
      "ChoiceCount": {
        "type": "object",
        "required": [
          "id",
          "count",
          "percent"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "The choice's id (a poll's option id, a quiz's answer id)."
          },
          "count": {
            "type": "integer",
            "minimum": 0
          },
          "percent": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "The choice's share of the votes as a whole percentage; the shares add up to 100 when there is a vote, and are 0 without one."
          }
        }
      },
      "ChoiceResult": {
        "type": "object",
        "description": "A choice component's result, over the choices its published document holds now, in their order.",
        "required": [
          "total",
          "choices"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "minimum": 0
          },
          "choices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChoiceCount"
            }
          }
        }
      },
      "RatingResult": {
        "type": "object",
        "description": "A Rating's result: how many visitors rated it, and the average of their ratings.",
        "required": [
          "total",
          "average"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "minimum": 0
          },
          "average": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100,
            "description": "The ratings' mean on the 0 to 100 scale, rounded to a whole number (half up); 0 without a rating."
          }
        }
      },
      "ReactionResult": {
        "type": "object",
        "description": "A Reaction's result: how many visitors tapped it, and each emoji's count, over the emoji its published document holds now, in their order.",
        "required": [
          "total",
          "reactions"
        ],
        "properties": {
          "total": {
            "type": "integer",
            "minimum": 0
          },
          "reactions": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "emoji",
                "count"
              ],
              "properties": {
                "emoji": {
                  "type": "string"
                },
                "count": {
                  "type": "integer",
                  "minimum": 0
                }
              }
            }
          }
        }
      },
      "InteractiveResponseRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "taleId",
          "elementId",
          "value"
        ],
        "properties": {
          "taleId": {
            "type": "string",
            "format": "uuid"
          },
          "elementId": {
            "type": "string",
            "format": "uuid"
          },
          "value": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "The response, such as a poll's option id, a quiz's answer id, a Rating's whole number from 0 to 100 (`80`) or a Reaction's emoji (`❤️`)."
          },
          "clientAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the visitor responded, by the device's clock."
          }
        }
      },
      "InteractiveResponse": {
        "type": "object",
        "required": [
          "elementId",
          "value",
          "recorded",
          "result"
        ],
        "properties": {
          "elementId": {
            "type": "string",
            "format": "uuid"
          },
          "value": {
            "type": "string",
            "description": "The visitor's stored response; on a repeat, the first one."
          },
          "recorded": {
            "type": "boolean",
            "description": "True when this request recorded it; false for a repeat."
          },
          "result": {
            "description": "The component's result now: a poll's or a quiz's counts and percentages per choice, a Rating's count and average, or a Reaction's count per emoji.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/ChoiceResult"
              },
              {
                "$ref": "#/components/schemas/RatingResult"
              },
              {
                "$ref": "#/components/schemas/ReactionResult"
              }
            ]
          }
        }
      },
      "ElementResponseCounts": {
        "type": "object",
        "required": [
          "elementId",
          "elementType",
          "total",
          "values"
        ],
        "properties": {
          "elementId": {
            "type": "string",
            "format": "uuid"
          },
          "elementType": {
            "type": "string",
            "description": "The element's type when its responses were recorded, such as `poll`."
          },
          "total": {
            "type": "integer",
            "minimum": 0
          },
          "values": {
            "type": "array",
            "description": "The number of responses per stored value, most first.",
            "items": {
              "type": "object",
              "required": [
                "value",
                "count"
              ],
              "properties": {
                "value": {
                  "type": "string"
                },
                "count": {
                  "type": "integer",
                  "minimum": 1
                }
              }
            }
          }
        }
      },
      "TaleResponses": {
        "type": "object",
        "required": [
          "elements"
        ],
        "properties": {
          "elements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ElementResponseCounts"
            }
          }
        }
      },
      "AnalyticsPeriod": {
        "type": "string",
        "enum": [
          "7d",
          "30d",
          "90d"
        ]
      },
      "AnalyticsEventType": {
        "type": "string",
        "enum": [
          "impression",
          "open",
          "view",
          "click"
        ]
      },
      "AnalyticsEventsRequest": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "events"
        ],
        "properties": {
          "events": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/AnalyticsEvent"
            }
          },
          "key": {
            "type": "string",
            "description": "The zone key, for a beacon that cannot send the Authorization header. Accepted only here."
          },
          "visitor": {
            "type": "string",
            "format": "uuid",
            "description": "The visitor id, for a beacon that cannot send the X-TaleArt-Visitor header."
          }
        }
      },
      "AnalyticsEvent": {
        "type": "object",
        "required": [
          "type",
          "groupId",
          "at"
        ],
        "properties": {
          "type": {
            "$ref": "#/components/schemas/AnalyticsEventType"
          },
          "groupId": {
            "type": "string",
            "format": "uuid"
          },
          "taleId": {
            "type": "string",
            "format": "uuid",
            "description": "Required for `view` and `click`; not sent with `impression` or `open`."
          },
          "at": {
            "type": "string",
            "format": "date-time",
            "description": "When the event happened, an ISO 8601 UTC time ending in `Z`."
          }
        }
      },
      "AnalyticsAccepted": {
        "type": "object",
        "required": [
          "accepted",
          "dropped"
        ],
        "properties": {
          "accepted": {
            "type": "integer",
            "minimum": 0
          },
          "dropped": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "AnalyticsTotals": {
        "type": "object",
        "required": [
          "impressions",
          "reach",
          "opens",
          "taleViews",
          "clicks",
          "engagementRate",
          "ctr"
        ],
        "properties": {
          "impressions": {
            "type": "integer",
            "minimum": 0
          },
          "reach": {
            "type": "integer",
            "minimum": 0,
            "description": "Distinct visitors with at least one impression, counted per row."
          },
          "opens": {
            "type": "integer",
            "minimum": 0
          },
          "taleViews": {
            "type": "integer",
            "minimum": 0
          },
          "clicks": {
            "type": "integer",
            "minimum": 0
          },
          "engagementRate": {
            "type": "number",
            "minimum": 0,
            "description": "Visitors with at least one impression and at least one open, as a percentage of Reach, one decimal; 0 when Reach is 0. Never above 100."
          },
          "ctr": {
            "type": "number",
            "minimum": 0,
            "description": "Clicks as a percentage of tale views, one decimal; 0 when there are no views."
          }
        }
      },
      "AnalyticsDay": {
        "type": "object",
        "required": [
          "date",
          "impressions",
          "clicks",
          "opens",
          "taleViews"
        ],
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "A UTC day."
          },
          "impressions": {
            "type": "integer",
            "minimum": 0
          },
          "clicks": {
            "type": "integer",
            "minimum": 0
          },
          "opens": {
            "type": "integer",
            "minimum": 0
          },
          "taleViews": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "AnalyticsGroupRow": {
        "type": "object",
        "description": "A tale group's metrics, the totals' definitions per group.",
        "required": [
          "groupId",
          "name",
          "impressions",
          "reach",
          "opens",
          "taleViews",
          "clicks",
          "engagementRate",
          "ctr"
        ],
        "properties": {
          "groupId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "impressions": {
            "type": "integer",
            "minimum": 0
          },
          "reach": {
            "type": "integer",
            "minimum": 0
          },
          "opens": {
            "type": "integer",
            "minimum": 0
          },
          "taleViews": {
            "type": "integer",
            "minimum": 0
          },
          "clicks": {
            "type": "integer",
            "minimum": 0
          },
          "engagementRate": {
            "type": "number",
            "minimum": 0
          },
          "ctr": {
            "type": "number",
            "minimum": 0
          }
        }
      },
      "AnalyticsTaleRow": {
        "type": "object",
        "description": "A tale's metrics. Impressions, reach, opens and the engagement rate name a group, not a tale, so they are null here.",
        "required": [
          "taleId",
          "name",
          "groupId",
          "impressions",
          "reach",
          "opens",
          "taleViews",
          "clicks",
          "engagementRate",
          "ctr"
        ],
        "properties": {
          "taleId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "groupId": {
            "type": "string",
            "format": "uuid"
          },
          "impressions": {
            "type": "null"
          },
          "reach": {
            "type": "null"
          },
          "opens": {
            "type": "null"
          },
          "taleViews": {
            "type": "integer",
            "minimum": 0
          },
          "clicks": {
            "type": "integer",
            "minimum": 0
          },
          "engagementRate": {
            "type": "null"
          },
          "ctr": {
            "type": "number",
            "minimum": 0
          }
        }
      },
      "ZoneAnalytics": {
        "type": "object",
        "required": [
          "period",
          "totals",
          "daily",
          "groups",
          "tales"
        ],
        "properties": {
          "period": {
            "$ref": "#/components/schemas/AnalyticsPeriod"
          },
          "totals": {
            "$ref": "#/components/schemas/AnalyticsTotals"
          },
          "daily": {
            "type": "array",
            "description": "One entry per UTC day of the period, oldest first.",
            "items": {
              "$ref": "#/components/schemas/AnalyticsDay"
            }
          },
          "groups": {
            "type": "array",
            "description": "The tale groups with events in the period, most impressions first.",
            "items": {
              "$ref": "#/components/schemas/AnalyticsGroupRow"
            }
          },
          "tales": {
            "type": "array",
            "description": "The tales with views or clicks in the period, most views first.",
            "items": {
              "$ref": "#/components/schemas/AnalyticsTaleRow"
            }
          }
        }
      },
      "MediaBackground": {
        "description": "An image (GIF included) of the tale's project filling the canvas, by its storage key. It is decorative. `color` fills the bands `contain` leaves, and the canvas until the image shows.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "type",
          "mediaKey",
          "fit",
          "color"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "media"
          },
          "mediaKey": {
            "description": "The storage key of an image of the tale's project, as the server made it.",
            "type": "string",
            "maxLength": 200,
            "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|jpg|gif|webp|mp4|webm)$"
          },
          "fit": {
            "type": "string",
            "enum": [
              "cover",
              "contain"
            ]
          },
          "color": {
            "$ref": "#/components/schemas/Color"
          }
        }
      },
      "ColorBackground": {
        "description": "A solid background colour.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "type",
          "color"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "color"
          },
          "color": {
            "$ref": "#/components/schemas/Color"
          }
        }
      },
      "DeliveryCanvas": {
        "type": "object",
        "description": "The canvas as delivered: a colour background as stored; a media background with its storage key replaced by the public `url`.",
        "required": [
          "background"
        ],
        "properties": {
          "background": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ColorBackground"
              },
              {
                "$ref": "#/components/schemas/DeliveryMediaBackground"
              }
            ]
          }
        }
      },
      "DeliveryMediaBackground": {
        "type": "object",
        "required": [
          "type",
          "url",
          "fit",
          "color"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "media"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The public URL of the image."
          },
          "fit": {
            "type": "string",
            "enum": [
              "cover",
              "contain"
            ]
          },
          "color": {
            "type": "string",
            "description": "The band colour, as `#RRGGBB` or `#RRGGBBAA`."
          }
        }
      },
      "ProjectMedia": {
        "type": "object",
        "description": "An image or video of a project: one of its uploads, a tale's media, or media its tales' documents use. A tale document references it by `key`.",
        "required": [
          "key",
          "kind",
          "contentType",
          "url",
          "source"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The storage key a document's `mediaKey` names."
          },
          "kind": {
            "$ref": "#/components/schemas/MediaKind"
          },
          "contentType": {
            "$ref": "#/components/schemas/MediaContentType"
          },
          "url": {
            "type": "string",
            "description": "Where the Management API serves the file, with a session."
          },
          "source": {
            "type": "string",
            "enum": [
              "upload",
              "tale",
              "document"
            ],
            "description": "`upload` for a file uploaded to the project's media, `tale` for a tale's media, `document` for media only documents use."
          },
          "taleTitle": {
            "type": "string",
            "description": "For `tale`, the title of a tale holding it."
          },
          "sizeBytes": {
            "type": "integer",
            "description": "For `upload`, the file's size."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "For `upload`, when it was uploaded."
          }
        }
      },
      "ProjectFont": {
        "type": "object",
        "description": "A font face uploaded to a project.",
        "required": [
          "id",
          "projectId",
          "family",
          "weight",
          "style",
          "fallback",
          "sizeBytes",
          "licenceConfirmedBy",
          "licenceConfirmedAt",
          "inUse"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "family": {
            "type": "string",
            "description": "The family name people read; never used as a CSS name."
          },
          "weight": {
            "type": "integer",
            "minimum": 100,
            "maximum": 900
          },
          "style": {
            "type": "string",
            "enum": [
              "normal",
              "italic"
            ]
          },
          "fallback": {
            "type": "string",
            "enum": [
              "sans",
              "serif",
              "rounded",
              "monospace"
            ]
          },
          "sizeBytes": {
            "type": "integer"
          },
          "licenceConfirmedBy": {
            "type": "object",
            "description": "Who uploaded the font and confirmed they may use and distribute it.",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "licenceConfirmedAt": {
            "type": "string",
            "format": "date-time"
          },
          "inUse": {
            "type": "boolean",
            "description": "True while a tale's draft or published document uses the font, which then cannot be deleted."
          }
        }
      },
      "TaleDocumentRevision": {
        "type": "object",
        "required": [
          "document",
          "revision",
          "published"
        ],
        "properties": {
          "document": {
            "description": "The tale's draft document, the one Tale Studio edits, or null when it has none.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/v1.schema"
              },
              {
                "type": "null"
              }
            ]
          },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "description": "The draft's revision, raised by one on every save; 0 before the first save."
          },
          "published": {
            "$ref": "#/components/schemas/PublishedTaleDocument"
          }
        }
      },
      "TaleStatusResult": {
        "description": "A tale as a status change answers it.",
        "allOf": [
          {
            "$ref": "#/components/schemas/Tale"
          },
          {
            "type": "object",
            "properties": {
              "draftNotPublished": {
                "type": "boolean",
                "description": "Present and true when the tale went live with nothing published yet and its draft document no longer passes the checks, so the draft was not published."
              },
              "draftNotPublishedReason": {
                "type": "string",
                "description": "With `draftNotPublished`, why the draft was not published, such as an image without alt text, named as Tale Studio names it."
              }
            }
          }
        ]
      },
      "PublishedTaleDocument": {
        "type": "object",
        "required": [
          "document",
          "revision"
        ],
        "properties": {
          "document": {
            "description": "The published document, the one the Delivery API serves, or null when none is published.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/v1.schema"
              },
              {
                "type": "null"
              }
            ]
          },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "description": "Raised by one on every publish; 0 before the first."
          }
        }
      },
      "TaleDocumentRevisionsRequest": {
        "type": "object",
        "required": [
          "revision",
          "publishedRevision"
        ],
        "properties": {
          "revision": {
            "type": "integer",
            "minimum": 0,
            "maximum": 2147483647,
            "description": "The draft revision the request is based on."
          },
          "publishedRevision": {
            "type": "integer",
            "minimum": 0,
            "maximum": 2147483647,
            "description": "The published revision the request is based on."
          }
        }
      },
      "SaveTaleDocumentRequest": {
        "type": "object",
        "required": [
          "document",
          "revision"
        ],
        "properties": {
          "document": {
            "$ref": "#/components/schemas/v1.schema"
          },
          "revision": {
            "type": "integer",
            "minimum": 0,
            "maximum": 2147483647,
            "description": "The revision this document was based on, as the last read or save returned it."
          },
          "sawTaleMedia": {
            "type": "boolean",
            "description": "True when the document the editor loaded held the tale's own media (its `taleMedia` element). The server then takes the saved document as the person's choice: when it lacks that element the person removed it, and it is not composed back. False or absent from an editor that never saw it (one opened before the media was put in the documents): the media stays composed in."
          }
        }
      },
      "CopyTaleDocumentRequest": {
        "type": "object",
        "required": [
          "document"
        ],
        "properties": {
          "document": {
            "$ref": "#/components/schemas/v1.schema"
          },
          "sawTaleMedia": {
            "type": "boolean",
            "description": "True when the document the editor loaded held the tale's own media (its `taleMedia` element). The server then takes the saved document as the person's choice: when it lacks that element the person removed it, and it is not composed back. False or absent from an editor that never saw it (one opened before the media was put in the documents): the media stays composed in."
          }
        }
      },
      "DeliveryMedia": {
        "type": "object",
        "required": [
          "kind",
          "contentType",
          "url"
        ],
        "properties": {
          "kind": {
            "$ref": "#/components/schemas/MediaKind"
          },
          "contentType": {
            "$ref": "#/components/schemas/MediaContentType"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute, public and unguessable URL of the file, such as `https://api.example.com/media/tales/<uuid>.png`. Needs no key."
          }
        }
      },
      "DeliveryCover": {
        "type": "object",
        "required": [
          "kind",
          "contentType",
          "animated",
          "url"
        ],
        "properties": {
          "kind": {
            "$ref": "#/components/schemas/MediaKind"
          },
          "contentType": {
            "$ref": "#/components/schemas/MediaContentType"
          },
          "animated": {
            "type": "boolean",
            "description": "True when the cover moves (a video, a GIF, an animated WebP or an APNG); clients show it still when the visitor prefers reduced motion."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Absolute, public and unguessable URL of the file, such as `https://api.example.com/media/covers/<uuid>.webm`. Needs no key."
          }
        }
      },
      "UserRole": {
        "type": "string",
        "enum": [
          "admin",
          "content_manager"
        ]
      },
      "User": {
        "type": "object",
        "required": [
          "id",
          "name",
          "email",
          "role"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "role": {
            "$ref": "#/components/schemas/UserRole"
          }
        }
      },
      "AuthState": {
        "type": "object",
        "required": [
          "setupRequired",
          "user",
          "methods"
        ],
        "properties": {
          "setupRequired": {
            "type": "boolean",
            "description": "True while no user exists; the dashboard then shows the setup screen."
          },
          "user": {
            "description": "The signed-in user, or null without a valid session.",
            "oneOf": [
              {
                "$ref": "#/components/schemas/User"
              },
              {
                "type": "null"
              }
            ]
          },
          "methods": {
            "type": "array",
            "description": "The sign-in methods on and usable, for the sign-in page.",
            "items": {
              "$ref": "#/components/schemas/AuthMethod"
            }
          }
        }
      },
      "AuthMethodKind": {
        "type": "string",
        "enum": [
          "passkey",
          "password",
          "oidc",
          "google",
          "apple"
        ]
      },
      "AuthMethod": {
        "type": "object",
        "description": "A sign-in method on for the sign-in page. Carries no configuration and no secret.",
        "required": [
          "id",
          "label",
          "kind"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "`passkey`, `password`, `google`, `apple`, or `oidc:<slug>`."
          },
          "label": {
            "type": "string",
            "description": "The button or heading the sign-in page shows."
          },
          "kind": {
            "$ref": "#/components/schemas/AuthMethodKind"
          }
        }
      },
      "PasswordSignInRequest": {
        "type": "object",
        "required": [
          "username",
          "password"
        ],
        "properties": {
          "username": {
            "type": "string",
            "minLength": 1
          },
          "password": {
            "type": "string",
            "minLength": 1,
            "maxLength": 128
          }
        }
      },
      "PasswordRegistrationRequest": {
        "type": "object",
        "required": [
          "name",
          "email",
          "username",
          "password"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "username": {
            "type": "string",
            "description": "3 to 40 characters: letters, digits, `.`, `_` and `-`. Stored in lower case."
          },
          "password": {
            "type": "string",
            "description": "12 to 128 characters, not the username or the email's local part, not a common password."
          }
        }
      },
      "PasswordResetPreview": {
        "type": "object",
        "required": [
          "name",
          "usernameRequired",
          "expiresAt"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "The name of the user the link is for."
          },
          "usernameRequired": {
            "type": "boolean",
            "description": "True when the user has no password yet, so the form asks for a username."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "RedeemPasswordResetRequest": {
        "type": "object",
        "required": [
          "password"
        ],
        "properties": {
          "username": {
            "type": "string",
            "description": "Required when the user has no password yet; otherwise it must match the current username."
          },
          "password": {
            "type": "string",
            "description": "12 to 128 characters, not the username or the email's local part, not a common password."
          }
        }
      },
      "SetPasswordRequest": {
        "type": "object",
        "required": [
          "password"
        ],
        "properties": {
          "username": {
            "type": "string",
            "description": "Required when the user has no password yet. Not changeable once set."
          },
          "currentPassword": {
            "type": "string",
            "description": "Required when the user has a password."
          },
          "password": {
            "type": "string",
            "description": "12 to 128 characters, not the username or the email's local part, not a common password."
          }
        }
      },
      "AccountPasskey": {
        "type": "object",
        "required": [
          "id",
          "createdAt",
          "lastUsedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastUsedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "AccountIdentity": {
        "type": "object",
        "required": [
          "id",
          "provider",
          "email",
          "createdAt",
          "lastUsedAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "provider": {
            "type": "string",
            "description": "`google`, `apple`, or `oidc:<slug>`."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "The email the provider reported, when it reported a verified one."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastUsedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "AccountMethods": {
        "type": "object",
        "required": [
          "passkeys",
          "password",
          "identities"
        ],
        "properties": {
          "passkeys": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AccountPasskey"
            }
          },
          "password": {
            "description": "The user's password, without the password itself; null when they have none.",
            "oneOf": [
              {
                "type": "object",
                "required": [
                  "username",
                  "updatedAt"
                ],
                "properties": {
                  "username": {
                    "type": "string"
                  },
                  "updatedAt": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              },
              {
                "type": "null"
              }
            ]
          },
          "identities": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AccountIdentity"
            }
          }
        }
      },
      "AuthSettings": {
        "type": "object",
        "required": [
          "passkeyEnabled",
          "passwordEnabled",
          "secretKeyConfigured"
        ],
        "properties": {
          "passkeyEnabled": {
            "type": "boolean"
          },
          "passwordEnabled": {
            "type": "boolean"
          },
          "secretKeyConfigured": {
            "type": "boolean",
            "description": "True when the server has a valid `TALEART_SECRET_KEY`, so provider secrets can be saved."
          }
        }
      },
      "UpdateAuthSettingsRequest": {
        "type": "object",
        "required": [
          "passkeyEnabled",
          "passwordEnabled"
        ],
        "properties": {
          "passkeyEnabled": {
            "type": "boolean"
          },
          "passwordEnabled": {
            "type": "boolean"
          }
        }
      },
      "AuthProviderKind": {
        "type": "string",
        "enum": [
          "oidc",
          "google",
          "apple"
        ]
      },
      "AuthProviderConfig": {
        "type": "object",
        "description": "The settings that are not secret. Only the fields of the provider's kind are allowed: `oidc` takes `issuer`, `clientId` and `scopes`; `google` takes `clientId` and `scopes`; `apple` takes `clientId`, `teamId` and `keyId`.",
        "additionalProperties": false,
        "properties": {
          "issuer": {
            "type": "string",
            "format": "uri",
            "description": "The issuer URL, https (http only for localhost outside production). Discovery must answer with exactly this issuer."
          },
          "clientId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "scopes": {
            "type": "string",
            "maxLength": 200,
            "description": "Space-separated scopes; must include `openid`. Defaults to `openid email profile` (Apple `name email`)."
          },
          "teamId": {
            "type": "string",
            "description": "The Apple developer team id, 10 letters and digits."
          },
          "keyId": {
            "type": "string",
            "description": "The Apple key id, 10 letters and digits."
          }
        }
      },
      "AuthProvider": {
        "type": "object",
        "required": [
          "slug",
          "kind",
          "id",
          "displayName",
          "enabled",
          "usable",
          "config",
          "hasSecret",
          "redirectUri",
          "updatedAt"
        ],
        "properties": {
          "slug": {
            "type": "string"
          },
          "kind": {
            "$ref": "#/components/schemas/AuthProviderKind"
          },
          "id": {
            "type": "string",
            "description": "The sign-in method id: `google`, `apple` or `oidc:<slug>`."
          },
          "displayName": {
            "type": "string"
          },
          "enabled": {
            "type": "boolean"
          },
          "usable": {
            "type": "boolean",
            "description": "True when enabled, and the secret it needs is stored and the server has its key."
          },
          "config": {
            "$ref": "#/components/schemas/AuthProviderConfig"
          },
          "hasSecret": {
            "type": "boolean",
            "description": "True when a secret is stored. The secret itself is never returned."
          },
          "redirectUri": {
            "type": "string",
            "format": "uri",
            "description": "The redirect URI to register at the provider, `<PUBLIC_API_URL>/api/v1/auth/callback/<slug>`."
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ProviderTestResult": {
        "type": "object",
        "required": [
          "ok"
        ],
        "properties": {
          "ok": {
            "type": "boolean"
          },
          "issuer": {
            "type": "string",
            "description": "The issuer the discovery document names, when ok is true."
          },
          "endpoints": {
            "type": "object",
            "required": [
              "authorization",
              "token",
              "jwks"
            ],
            "properties": {
              "authorization": {
                "type": "string",
                "format": "uri"
              },
              "token": {
                "type": "string",
                "format": "uri"
              },
              "jwks": {
                "type": "string",
                "format": "uri"
              }
            }
          },
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          }
        }
      },
      "SaveAuthProviderRequest": {
        "type": "object",
        "required": [
          "displayName",
          "enabled",
          "config"
        ],
        "properties": {
          "displayName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "enabled": {
            "type": "boolean"
          },
          "config": {
            "$ref": "#/components/schemas/AuthProviderConfig"
          },
          "secret": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 8192,
            "description": "The client secret, or for Apple the `.p8` private key as PEM. Write-only: `null` removes the stored one, and leaving it out keeps it."
          }
        }
      },
      "PasswordResetLink": {
        "type": "object",
        "required": [
          "id",
          "userId",
          "url",
          "expiresAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "userId": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "description": "The one-time link to share, `<dashboard origin>/password-reset/<token>`. Shown only once."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SetupRequest": {
        "type": "object",
        "required": [
          "name",
          "email"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": "string",
            "format": "email"
          }
        }
      },
      "RedeemInvitationRequest": {
        "type": "object",
        "required": [
          "name",
          "email"
        ],
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1
          },
          "email": {
            "type": "string",
            "format": "email"
          }
        }
      },
      "Invitation": {
        "type": "object",
        "required": [
          "id",
          "projectId",
          "url",
          "expiresAt"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "projectId": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "description": "The one-time link to share, `<dashboard origin>/invite/<token>`. Shown only once."
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "InvitationPreview": {
        "type": "object",
        "required": [
          "projectName",
          "expiresAt"
        ],
        "properties": {
          "projectName": {
            "type": "string"
          },
          "expiresAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SignedIn": {
        "type": "object",
        "required": [
          "user"
        ],
        "properties": {
          "user": {
            "$ref": "#/components/schemas/User"
          }
        }
      },
      "PasskeyCreationOptions": {
        "type": "object",
        "required": [
          "challenge"
        ],
        "additionalProperties": true,
        "description": "WebAuthn `PublicKeyCredentialCreationOptionsJSON`, passed to the browser as is.",
        "properties": {
          "challenge": {
            "type": "string"
          }
        }
      },
      "PasskeyRequestOptions": {
        "type": "object",
        "required": [
          "challenge"
        ],
        "additionalProperties": true,
        "description": "WebAuthn `PublicKeyCredentialRequestOptionsJSON`, passed to the browser as is.",
        "properties": {
          "challenge": {
            "type": "string"
          }
        }
      },
      "PasskeyResponse": {
        "type": "object",
        "required": [
          "id"
        ],
        "additionalProperties": true,
        "description": "The authenticator's answer as WebAuthn JSON (`RegistrationResponseJSON` or `AuthenticationResponseJSON`), sent as the browser produced it.",
        "properties": {
          "id": {
            "type": "string"
          }
        }
      },
      "ErrorCode": {
        "type": "string",
        "enum": [
          "validation_failed",
          "project_not_found",
          "not_found",
          "zone_not_found",
          "unknown_frame_type",
          "invalid_frame_settings",
          "invalid_theme",
          "display_not_found",
          "display_exists",
          "slug_taken",
          "tale_group_not_found",
          "tale_group_full",
          "tale_group_limit_reached",
          "tale_not_found",
          "order_mismatch",
          "media_not_found",
          "unsupported_media_type",
          "media_too_large",
          "unauthenticated",
          "setup_closed",
          "passkey_failed",
          "forbidden",
          "invitation_invalid",
          "email_taken",
          "tag_exists",
          "tag_not_found",
          "zone_key_not_found",
          "site_key_not_found",
          "site_key_exists",
          "invalid_site_key",
          "api_token_not_found",
          "api_token_limit_reached",
          "rate_limited",
          "invalid_status_transition",
          "tale_group_archived",
          "unknown_template",
          "concurrent_change",
          "invalid_tale_document",
          "tale_document_too_large",
          "stale_revision",
          "nothing_to_publish",
          "font_not_found",
          "font_exists",
          "font_in_use",
          "font_limit_reached",
          "font_storage_full",
          "media_limit_reached",
          "media_storage_full",
          "upload_busy",
          "length_required",
          "response_target_not_found",
          "visitor_id_required",
          "user_list_not_found",
          "user_list_exists",
          "user_list_limit_reached",
          "user_list_file_too_large",
          "sign_in_failed",
          "password_rejected",
          "username_taken",
          "password_incorrect",
          "password_reset_invalid",
          "sign_in_method_disabled",
          "last_sign_in_method",
          "sign_in_lockout",
          "secret_key_missing",
          "provider_not_found",
          "identity_in_use",
          "email_not_verified",
          "email_mismatch",
          "session_required",
          "discovery_failed",
          "provider_unavailable",
          "internal_error"
        ]
      },
      "ErrorBody": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "$ref": "#/components/schemas/ErrorCode"
              },
              "message": {
                "type": "string"
              },
              "pointer": {
                "type": "string",
                "description": "For `invalid_tale_document`: the JSON pointer (RFC 6901) of the document field that was refused, such as `/elements/0/box/x`."
              }
            }
          }
        }
      },
      "Color": {
        "description": "A colour as #RRGGBB or #RRGGBBAA.",
        "type": "string",
        "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
      },
      "Box": {
        "description": "Where an element sits, as fractions (0 to 1) of the canvas width (x, w) and height (y, h), and its rotation in degrees about its centre.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "x",
          "y",
          "w",
          "h",
          "rotation"
        ],
        "properties": {
          "x": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "y": {
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "w": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 1
          },
          "h": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 1
          },
          "rotation": {
            "type": "number",
            "minimum": -180,
            "maximum": 180
          }
        }
      },
      "A11y": {
        "description": "What assistive technology is told about the element.",
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "label": {
            "type": "string",
            "maxLength": 300
          },
          "decorative": {
            "type": "boolean"
          }
        }
      },
      "ElementId": {
        "description": "A UUID, unique in the document.",
        "type": "string",
        "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
      },
      "ElementType": {
        "description": "A key of the element-type registry.",
        "type": "string",
        "pattern": "^[a-z][a-z0-9-]{0,63}$"
      },
      "Canvas": {
        "description": "The 9:16 stage of the tale.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "background"
        ],
        "properties": {
          "background": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ColorBackground"
              },
              {
                "$ref": "#/components/schemas/MediaBackground"
              }
            ]
          }
        }
      },
      "Element": {
        "description": "One element. `hidden` and `locked` are editor state and are never delivered.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "id",
          "type",
          "box",
          "hidden",
          "locked",
          "a11y",
          "props"
        ],
        "properties": {
          "id": {
            "$ref": "#/components/schemas/ElementId"
          },
          "type": {
            "$ref": "#/components/schemas/ElementType"
          },
          "box": {
            "$ref": "#/components/schemas/Box"
          },
          "hidden": {
            "type": "boolean"
          },
          "locked": {
            "type": "boolean"
          },
          "a11y": {
            "$ref": "#/components/schemas/A11y"
          },
          "props": {
            "type": "object"
          }
        }
      },
      "v1.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "TaleDocument",
        "description": "A tale document, version 1: the canvas and the elements of one tale. Each element's `props` are checked against the schema its `type` names in `element-types/registry.json`; an unknown type is refused on write and skipped by clients.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "version",
          "canvas",
          "elements"
        ],
        "properties": {
          "version": {
            "description": "The document version. Additive changes keep it; a breaking change raises it and ships a forward migration.",
            "type": "integer",
            "const": 1
          },
          "canvas": {
            "$ref": "#/components/schemas/Canvas"
          },
          "durationMs": {
            "description": "How long the tale shows, in milliseconds, when it has no video; 7000 when left out. With a video element the tale plays for the video's length.",
            "type": "integer",
            "minimum": 3000,
            "maximum": 15000
          },
          "elements": {
            "description": "The elements, first at the bottom of the z-order.",
            "type": "array",
            "maxItems": 60,
            "items": {
              "$ref": "#/components/schemas/Element"
            }
          }
        },
        "$defs": {
          "Color": {
            "description": "A colour as #RRGGBB or #RRGGBBAA.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "Canvas": {
            "description": "The 9:16 stage of the tale.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "background"
            ],
            "properties": {
              "background": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/ColorBackground"
                  },
                  {
                    "$ref": "#/components/schemas/MediaBackground"
                  }
                ]
              }
            }
          },
          "ColorBackground": {
            "description": "A solid background colour.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "type",
              "color"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "color"
              },
              "color": {
                "$ref": "#/components/schemas/Color"
              }
            }
          },
          "MediaBackground": {
            "description": "An image (GIF included) of the tale's project filling the canvas, by its storage key. It is decorative. `color` fills the bands `contain` leaves, and the canvas until the image shows.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "type",
              "mediaKey",
              "fit",
              "color"
            ],
            "properties": {
              "type": {
                "type": "string",
                "const": "media"
              },
              "mediaKey": {
                "description": "The storage key of an image of the tale's project, as the server made it.",
                "type": "string",
                "maxLength": 200,
                "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|jpg|gif|webp|mp4|webm)$"
              },
              "fit": {
                "type": "string",
                "enum": [
                  "cover",
                  "contain"
                ]
              },
              "color": {
                "$ref": "#/components/schemas/Color"
              }
            }
          },
          "Box": {
            "description": "Where an element sits, as fractions (0 to 1) of the canvas width (x, w) and height (y, h), and its rotation in degrees about its centre.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "x",
              "y",
              "w",
              "h",
              "rotation"
            ],
            "properties": {
              "x": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "y": {
                "type": "number",
                "minimum": 0,
                "maximum": 1
              },
              "w": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 1
              },
              "h": {
                "type": "number",
                "exclusiveMinimum": 0,
                "maximum": 1
              },
              "rotation": {
                "type": "number",
                "minimum": -180,
                "maximum": 180
              }
            }
          },
          "A11y": {
            "description": "What assistive technology is told about the element.",
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 300
              },
              "decorative": {
                "type": "boolean"
              }
            }
          },
          "ElementType": {
            "description": "A key of the element-type registry.",
            "type": "string",
            "pattern": "^[a-z][a-z0-9-]{0,63}$"
          },
          "ElementId": {
            "description": "A UUID, unique in the document.",
            "type": "string",
            "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
          },
          "Element": {
            "description": "One element. `hidden` and `locked` are editor state and are never delivered.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "type",
              "box",
              "hidden",
              "locked",
              "a11y",
              "props"
            ],
            "properties": {
              "id": {
                "$ref": "#/components/schemas/ElementId"
              },
              "type": {
                "$ref": "#/components/schemas/ElementType"
              },
              "box": {
                "$ref": "#/components/schemas/Box"
              },
              "hidden": {
                "type": "boolean"
              },
              "locked": {
                "type": "boolean"
              },
              "a11y": {
                "$ref": "#/components/schemas/A11y"
              },
              "props": {
                "type": "object"
              }
            }
          }
        }
      },
      "text.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "TextElementProps",
        "description": "The props of a text element: plain Unicode text, never HTML or Markdown, wrapped inside the element's box. A line feed starts a new line. Every style prop is optional (an additive change); one left out takes its default from the contract (`DEFAULT_TEXT_STYLE`), which is how text looked before styling. Styles are enumerated or range-checked values, never CSS text.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "text"
        ],
        "properties": {
          "text": {
            "type": "string",
            "maxLength": 1000
          },
          "font": {
            "description": "Where the font comes from: a system stack of the font registry, the project theme's font, a font-family list the host page loads (the theme's font-list rules, `isFontFamily`), or a font uploaded to the tale's project, by its id.",
            "type": "object",
            "additionalProperties": false,
            "required": [
              "source"
            ],
            "properties": {
              "source": {
                "type": "string",
                "enum": [
                  "system",
                  "theme",
                  "name",
                  "project"
                ]
              },
              "stack": {
                "type": "string",
                "enum": [
                  "sans",
                  "serif",
                  "rounded",
                  "monospace"
                ]
              },
              "family": {
                "type": "string",
                "maxLength": 200,
                "pattern": "^(?!.*(?:^|[\\s,\"'])(?:[iI][nN][hH][eE][rR][iI][tT]|[iI][nN][iI][tT][iI][aA][lL]|[uU][nN][sS][eE][tT]|[rR][eE][vV][eE][rR][tT]|[rR][eE][vV][eE][rR][tT]-[lL][aA][yY][eE][rR]|[dD][eE][fF][aA][uU][lL][tT])(?=$|[\\s,\"']))(?!.*(?:^|[\\s,\"'])[tT][aA][lL][eE][aA][rR][tT]-[fF][oO][nN][tT]-)\\s*(?:[A-Za-z][A-Za-z0-9 -]*|\"[A-Za-z0-9 -]+\"|'[A-Za-z0-9 -]+')\\s*(?:,\\s*(?:[A-Za-z][A-Za-z0-9 -]*|\"[A-Za-z0-9 -]+\"|'[A-Za-z0-9 -]+')\\s*)*$"
              },
              "fontId": {
                "description": "The id of a font of the tale's project (`project_fonts`).",
                "type": "string",
                "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
              }
            },
            "allOf": [
              {
                "if": {
                  "properties": {
                    "source": {
                      "const": "system"
                    }
                  }
                },
                "then": {
                  "required": [
                    "stack"
                  ],
                  "properties": {
                    "family": false,
                    "fontId": false
                  }
                }
              },
              {
                "if": {
                  "properties": {
                    "source": {
                      "const": "theme"
                    }
                  }
                },
                "then": {
                  "properties": {
                    "stack": false,
                    "family": false,
                    "fontId": false
                  }
                }
              },
              {
                "if": {
                  "properties": {
                    "source": {
                      "const": "name"
                    }
                  }
                },
                "then": {
                  "required": [
                    "family"
                  ],
                  "properties": {
                    "stack": false,
                    "fontId": false
                  }
                }
              },
              {
                "if": {
                  "properties": {
                    "source": {
                      "const": "project"
                    }
                  }
                },
                "then": {
                  "required": [
                    "fontId"
                  ],
                  "properties": {
                    "stack": false,
                    "family": false
                  }
                }
              }
            ]
          },
          "fallback": {
            "description": "The system stack used until, or when, the font is not available.",
            "type": "string",
            "enum": [
              "sans",
              "serif",
              "rounded",
              "monospace"
            ]
          },
          "size": {
            "description": "The font size as a fraction of the canvas height.",
            "type": "number",
            "minimum": 0.01,
            "maximum": 0.2
          },
          "weight": {
            "type": "string",
            "enum": [
              "regular",
              "medium",
              "bold"
            ]
          },
          "italic": {
            "type": "boolean"
          },
          "color": {
            "description": "The text colour as #RRGGBB or #RRGGBBAA.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "align": {
            "description": "Where lines sit in the box; `start` and `end` follow the text direction.",
            "type": "string",
            "enum": [
              "start",
              "center",
              "end"
            ]
          },
          "direction": {
            "description": "The text direction: `auto` takes it from the first strong character.",
            "type": "string",
            "enum": [
              "auto",
              "rtl",
              "ltr"
            ]
          },
          "lineHeight": {
            "description": "The line height as a multiple of the font size.",
            "type": "number",
            "minimum": 0.8,
            "maximum": 3
          },
          "background": {
            "description": "Behind the text: nothing, a solid box filling the element, or a highlight behind each line.",
            "type": "string",
            "enum": [
              "none",
              "box",
              "highlight"
            ]
          },
          "backgroundColor": {
            "description": "The box or highlight colour as #RRGGBB or #RRGGBBAA.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          }
        }
      },
      "image.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ImageElementProps",
        "description": "The props of an image element: an image (GIF included) of the tale's project, by its storage key, never a URL. Its alt text is the element's `a11y.label`; publishing needs it, or `a11y.decorative`.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "mediaKey",
          "fit"
        ],
        "properties": {
          "mediaKey": {
            "description": "The storage key of an image of the tale's project, as the server made it.",
            "type": "string",
            "maxLength": 200,
            "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|jpg|gif|webp|mp4|webm)$"
          },
          "fit": {
            "description": "How the media fills its box: `cover` crops it to fill the box, `contain` shows all of it.",
            "type": "string",
            "enum": [
              "cover",
              "contain"
            ]
          },
          "radius": {
            "description": "The corner radius as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.5
          },
          "opacity": {
            "description": "From 0 (transparent) to 1 (opaque); 1 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "taleMedia": {
            "description": "True on the tale's own media: the element the server keeps in step with the tale's media, which a tale from before documents, a dashboard upload and an import create. While it is the bottom element with the full-canvas box, unrotated, unrounded and opaque, viewers draw it full-bleed behind the canvas, as tale media always was. A document holds at most one.",
            "type": "boolean"
          }
        }
      },
      "video.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "VideoElementProps",
        "description": "The props of a video element: an MP4 or WebM video of the tale's project, by its storage key, never a URL. It plays muted and inline; a tale holds at most one, and then plays for the video's length. Its alt text is the element's `a11y.label`; publishing needs it, or `a11y.decorative`.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "mediaKey",
          "fit"
        ],
        "properties": {
          "mediaKey": {
            "description": "The storage key of a video of the tale's project, as the server made it.",
            "type": "string",
            "maxLength": 200,
            "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|jpg|gif|webp|mp4|webm)$"
          },
          "fit": {
            "description": "How the media fills its box: `cover` crops it to fill the box, `contain` shows all of it.",
            "type": "string",
            "enum": [
              "cover",
              "contain"
            ]
          },
          "radius": {
            "description": "The corner radius as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.5
          },
          "opacity": {
            "description": "From 0 (transparent) to 1 (opaque); 1 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 1
          },
          "taleMedia": {
            "description": "True on the tale's own media: the element the server keeps in step with the tale's media, which a tale from before documents, a dashboard upload and an import create. While it is the bottom element with the full-canvas box, unrotated, unrounded and opaque, viewers draw it full-bleed behind the canvas, as tale media always was. A video so marked runs the tale for its whole length, with sound where the browser allows it, and does not count toward the one-video limit. A document holds at most one.",
            "type": "boolean"
          }
        }
      },
      "shape.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ShapeElementProps",
        "description": "The props of a shape element: a rectangle (with a corner radius), an ellipse or a line, built by clients from these values only, never from SVG or CSS text. Lengths are fractions of the canvas height. A line runs across the middle of its box, in its stroke colour and width, and turns with the box's rotation; it has no fill. A shape without a label is decorative.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "shape"
        ],
        "properties": {
          "shape": {
            "type": "string",
            "enum": [
              "rectangle",
              "ellipse",
              "line"
            ]
          },
          "fill": {
            "description": "The fill colour as #RRGGBB or #RRGGBBAA; no fill when left out.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "stroke": {
            "description": "The stroke colour as #RRGGBB or #RRGGBBAA; no stroke when left out.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "strokeWidth": {
            "description": "The stroke width as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.05
          },
          "radius": {
            "description": "A rectangle's corner radius as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.5
          },
          "opacity": {
            "description": "From 0 (transparent) to 1 (opaque); 1 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 1
          }
        },
        "allOf": [
          {
            "if": {
              "properties": {
                "shape": {
                  "const": "ellipse"
                }
              },
              "required": [
                "shape"
              ]
            },
            "then": {
              "not": {
                "required": [
                  "radius"
                ]
              }
            }
          },
          {
            "if": {
              "properties": {
                "shape": {
                  "const": "line"
                }
              },
              "required": [
                "shape"
              ]
            },
            "then": {
              "required": [
                "stroke",
                "strokeWidth"
              ],
              "properties": {
                "strokeWidth": {
                  "exclusiveMinimum": 0
                }
              },
              "allOf": [
                {
                  "not": {
                    "required": [
                      "fill"
                    ]
                  }
                },
                {
                  "not": {
                    "required": [
                      "radius"
                    ]
                  }
                }
              ]
            }
          }
        ]
      },
      "emoji.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "EmojiElementProps",
        "description": "The props of an emoji sticker: one emoji, as Unicode text drawn in the platform's emoji font, never an image. The server accepts exactly one RGI emoji (`isEmojiGlyph`). Its accessible name is the element's `a11y.label`, which Tale Studio fills from the emoji's Unicode name and the author may edit.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "emoji"
        ],
        "properties": {
          "emoji": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          }
        }
      },
      "sticker.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "StickerElementProps",
        "description": "The props of an image sticker: a PNG, WebP or GIF of the tale's project, by its storage key, never a URL, shown whole inside its box. Its alt text is the element's `a11y.label`; publishing needs it, or `a11y.decorative`.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "mediaKey"
        ],
        "properties": {
          "mediaKey": {
            "description": "The storage key of a PNG, WebP or GIF image of the tale's project, as the server made it.",
            "type": "string",
            "maxLength": 200,
            "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|gif|webp)$"
          },
          "opacity": {
            "description": "From 0 (transparent) to 1 (opaque); 1 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 1
          }
        }
      },
      "button.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ButtonElementProps",
        "description": "The props of a button: a call to action with a label, a style (filled or outline), colours and a corner radius, opening its destination in the same tab, or in a new tab without opener access. Clients render it as a link (`<a>`), named by its label. A draft may hold a button without a destination; publishing needs one.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "variant",
          "color",
          "fill"
        ],
        "properties": {
          "label": {
            "description": "The text on the button: plain text on one line, its accessible name.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "href": {
            "description": "The destination: an absolute http or https URL of at most 2,048 characters, without credentials, spaces or control characters. Servers check it in full; clients check it again before rendering.",
            "type": "string",
            "maxLength": 2048,
            "pattern": "^[hH][tT][tT][pP][sS]?://"
          },
          "newTab": {
            "description": "Opens the destination in a new tab, with `noopener`; the same tab when left out.",
            "type": "boolean"
          },
          "variant": {
            "description": "`filled` paints the button in `fill`; `outline` draws only its edge in `fill`.",
            "type": "string",
            "enum": [
              "filled",
              "outline"
            ]
          },
          "color": {
            "description": "The label colour as #RRGGBB or #RRGGBBAA.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "fill": {
            "description": "The button colour as #RRGGBB or #RRGGBBAA: its fill, or its edge for an outline button.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "radius": {
            "description": "The corner radius as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.5
          }
        }
      },
      "image-button.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ImageButtonElementProps",
        "description": "The props of a custom image button: an image of the tale's project, by its storage key, never a URL, that opens its destination. Its alt text is the element's `a11y.label`, the link's accessible name; publishing needs it (the decorative mark does not apply to a link) and a destination.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "mediaKey"
        ],
        "properties": {
          "mediaKey": {
            "description": "The storage key of an image (PNG, JPEG, GIF or WebP) of the tale's project, as the server made it.",
            "type": "string",
            "maxLength": 200,
            "pattern": "^(tales|media)/[a-z0-9][a-z0-9-]{0,99}\\.(png|jpg|gif|webp)$"
          },
          "href": {
            "description": "The destination: an absolute http or https URL of at most 2,048 characters, without credentials, spaces or control characters.",
            "type": "string",
            "maxLength": 2048,
            "pattern": "^[hH][tT][tT][pP][sS]?://"
          },
          "newTab": {
            "description": "Opens the destination in a new tab, with `noopener`; the same tab when left out.",
            "type": "boolean"
          },
          "radius": {
            "description": "The corner radius as a fraction of the canvas height; 0 when left out.",
            "type": "number",
            "minimum": 0,
            "maximum": 0.5
          }
        }
      },
      "swipe-up.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "SwipeUpElementProps",
        "description": "The props of a swipe-up link: a label pinned to the bottom safe area that opens its destination on a swipe up, a click, or Enter on the focused label. A tale holds at most one, and its box is the contract's `SWIPE_UP_BOX`. Clients render it as a link (`<a>`), named by its label. Publishing needs a destination.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "color",
          "background"
        ],
        "properties": {
          "label": {
            "description": "The text of the link: plain text on one line, its accessible name.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80
          },
          "href": {
            "description": "The destination: an absolute http or https URL of at most 2,048 characters, without credentials, spaces or control characters.",
            "type": "string",
            "maxLength": 2048,
            "pattern": "^[hH][tT][tT][pP][sS]?://"
          },
          "newTab": {
            "description": "Opens the destination in a new tab, with `noopener`; the same tab when left out.",
            "type": "boolean"
          },
          "color": {
            "description": "The label and arrow colour as #RRGGBB or #RRGGBBAA.",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          },
          "background": {
            "description": "The colour of the pill behind the label as #RRGGBB or #RRGGBBAA (#RRGGBB00 for none).",
            "type": "string",
            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$"
          }
        }
      },
      "poll.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "PollElementProps",
        "description": "The props of a poll: an optional title, a question and two options, each with an id that stays the same when its label is edited, so its count survives the edit, and a light or dark theme. All text is plain, on one line, without control characters. Clients render the options as buttons; a visitor's vote is posted to the Delivery API and the poll then shows each option's percentage. A tale holds at most one poll. The `view` picks how the poll is drawn (sticker, embed, this-or-that, grid or inline); each option's optional `color` is used only by this-or-that and grid, and is content data (the app's own category colour), not part of the theme.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "question",
          "options",
          "theme"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the question; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "question": {
            "description": "The question visitors answer.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "options": {
            "description": "The two answers, in the order they show.",
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "label"
              ],
              "properties": {
                "id": {
                  "description": "The option's id, unique in the poll and kept when its label changes: votes are counted by it.",
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]{1,64}$"
                },
                "label": {
                  "description": "The answer as visitors read it: its accessible name.",
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 60,
                  "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
                },
                "color": {
                  "description": "Optional colour of the option as #rrggbb; used only by the this-or-that and grid views, as content data (not theme).",
                  "type": "string",
                  "pattern": "^#[0-9a-fA-F]{6}$"
                }
              }
            }
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          },
          "view": {
            "description": "How the poll is drawn: sticker, a small card on the tale; embed, a card that holds its own results; this-or-that, two large halves to pick between; grid, the options as tiles; inline, the options as plain rows in the flow of the page. Left out, sticker.",
            "type": "string",
            "enum": [
              "sticker",
              "embed",
              "this-or-that",
              "grid",
              "inline"
            ],
            "default": "sticker"
          }
        }
      },
      "rating.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "RatingElementProps",
        "description": "The props of a Rating: an optional title, a question and one emoji that a visitor slides along a scale from 0 to 100, and a light or dark theme. All text is plain, on one line, without control characters; the server accepts exactly one RGI emoji (`isEmojiGlyph`), as for the `emoji` type. Clients render a slider with the emoji as its thumb; a visitor's rating is posted to the Delivery API on release or on Enter, and the Rating then shows the count and average of all ratings. A tale holds at most one Rating.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "question",
          "emoji",
          "theme"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the question; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "question": {
            "description": "The question visitors answer; the slider's accessible name.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "emoji": {
            "description": "The emoji on the slider's thumb: one RGI emoji, drawn as text.",
            "type": "string",
            "minLength": 1,
            "maxLength": 64
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          }
        }
      },
      "quiz.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "QuizElementProps",
        "description": "The props of a quiz: an optional title, a question, two to four answers, each with an id that stays the same when its text is edited or it moves, so its count survives the edit, an optional correct answer and a light or dark theme. All text is plain, on one line, without control characters. The correct answer is delivered with the published document, so clients mark it at once (C10): a quiz is not a secure test. Clients render the answers as buttons; a visitor's answer is posted to the Delivery API and the quiz then shows whether it was right (when a correct answer is set), the correct answer and each answer's percentage. Without a correct answer it works as a survey and shows only the percentages. A tale holds at most one quiz.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "question",
          "answers",
          "theme"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the question; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "question": {
            "description": "The question visitors answer.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "answers": {
            "description": "The two to four answers, in the order they show.",
            "type": "array",
            "minItems": 2,
            "maxItems": 4,
            "items": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "id",
                "label"
              ],
              "properties": {
                "id": {
                  "description": "The answer's id, unique in the quiz and kept when its text changes or it moves: answers are counted by it.",
                  "type": "string",
                  "pattern": "^[A-Za-z0-9_-]{1,64}$"
                },
                "label": {
                  "description": "The answer as visitors read it: its accessible name.",
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 60,
                  "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
                }
              }
            }
          },
          "correctAnswerId": {
            "description": "The id of the correct answer, one of `answers`; when left out the quiz works as a survey.",
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,64}$"
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          }
        }
      },
      "countdown.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "CountdownElementProps",
        "description": "The props of a Countdown: an optional title, the instant it ends in UTC, the text it shows from then on, and a light or dark theme. It shows days, hours, minutes and seconds left by the visitor's clock, ticking each second, and at and after `endsAt` the `endedText`. All text is plain, on one line, without control characters. A tale may hold several Countdowns.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "endsAt"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the countdown; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "endsAt": {
            "description": "The instant the countdown ends, as ISO 8601 in UTC with a `Z`, such as `2026-12-31T21:00:00Z`. The server also checks that it is a real date and time.",
            "type": "string",
            "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?Z$",
            "maxLength": 40
          },
          "endedText": {
            "description": "The text shown at and after `endsAt`; the SDK's \"ended\" string when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card. Light when left out.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          }
        }
      },
      "promo-code.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "PromoCodeElementProps",
        "description": "The props of a Promo code: an optional title, the code itself, an optional note and a light or dark theme. The code shows in a chip with a Copy button, and it is visible to every visitor, so it is not a secret. All text is plain, on one line, without control characters. A tale may hold several Promo codes.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "code"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the code; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "code": {
            "description": "The code visitors copy: 1 to 32 letters, digits, underscores or hyphens (`A-Z`, `a-z`, `0-9`, `_`, `-`).",
            "type": "string",
            "minLength": 1,
            "maxLength": 32,
            "pattern": "^[A-Za-z0-9_-]+$"
          },
          "note": {
            "description": "A short line under the code, such as its terms; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 120,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card. Light when left out.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          }
        }
      },
      "reaction.schema": {
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "title": "ReactionElementProps",
        "description": "The props of a Reaction: an optional title, a question, two to six distinct emoji a visitor taps one of, and a light or dark theme. All text is plain, on one line, without control characters; the server accepts exactly one RGI emoji for each entry (`isEmojiGlyph`), as for the `emoji` type, and no emoji twice. Clients render a row of emoji buttons; a visitor's tap is posted to the Delivery API as the emoji, and the Reaction then shows each emoji's count. A tale holds at most one Reaction.",
        "type": "object",
        "additionalProperties": false,
        "required": [
          "question",
          "emojis"
        ],
        "properties": {
          "title": {
            "description": "A short heading above the question; none when left out.",
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "question": {
            "description": "The question visitors answer with an emoji; the row's accessible group name.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200,
            "pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\u2066-\\u2069]*$"
          },
          "emojis": {
            "description": "The emoji a visitor can tap, in the order they show: two to six, each one RGI emoji drawn as text, all different. A tap is counted by its emoji, so an emoji kept keeps its count.",
            "type": "array",
            "minItems": 2,
            "maxItems": 6,
            "uniqueItems": true,
            "items": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64
            }
          },
          "theme": {
            "description": "`light` draws dark text on a light card; `dark` light text on a dark card. Light when left out.",
            "type": "string",
            "enum": [
              "light",
              "dark"
            ]
          }
        }
      }
    }
  }
}
