openapi: 3.0.3
info:
  title: Easerix Notes API
  description: |
    Docs & wiki with a rich (Tiptap/ProseMirror) document model
    (backend/services/products/notes). Workspaces are org-scoped: any member
    of the active org shares the org's workspaces. Pages store one JSON doc
    plus a plain-text projection that powers ranked full-text search.
    Responds in camelCase (same recorded drift as links — see contracts/README.md).
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/notes
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: workspaces
    description: Org-scoped workspaces and members
  - name: pages
    description: Page tree and rich documents
  - name: blocks
    description: Legacy per-block editing surface (superseded by the doc endpoints)
  - name: collaboration
    description: Versions, comments, presence, links
  - name: sharing
    description: Public share links and attachments
  - name: search
    description: Ranked full-text search

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /v1/members:
    get:
      operationId: listMembers
      tags: [workspaces]
      summary: List known members
      description: Members seen by this service (upserted from JWTs), for name lookups.
      responses:
        "200":
          description: Members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: "#/components/schemas/Member"

  /v1/workspaces:
    get:
      operationId: listWorkspaces
      tags: [workspaces]
      summary: List the active org's workspaces
      description: |
        Lists workspaces for the caller's active org, adopting legacy pre-org
        rows into a personal org and provisioning the org's first workspace
        when none exists.
      responses:
        "200":
          description: Workspaces
          content:
            application/json:
              schema:
                type: object
                required: [workspaces]
                properties:
                  workspaces:
                    type: array
                    items:
                      $ref: "#/components/schemas/Workspace"
    post:
      operationId: createWorkspace
      tags: [workspaces]
      summary: Create a space
      description: |
        kind follows the platform scoping standard: 'org' (default, every org
        member), 'team' (requires teamId, caller must belong to the team), or
        'personal' (owner only).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                kind:
                  type: string
                  enum: [org, team, personal]
                teamId:
                  type: string
                  format: uuid
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [workspace]
                properties:
                  workspace:
                    $ref: "#/components/schemas/Workspace"

  /v1/workspaces/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          format: uuid
    patch:
      operationId: updateWorkspace
      tags: [workspaces]
      summary: Rename a space and/or change its audience
      description: |
        Any member with access to the space may rename it (`name` alone).

        Sending `kind` (and `teamId` for a team space) re-targets the space's
        audience — which subject it belongs to, and therefore who can see every
        page inside it. Those rules are the platform's, enforced by
        `authkit.AuthorizeRetarget` (docs/platform/subjects-and-containers.md):

        - the space's creator may re-target their own space; anyone else needs
          `org.data.audience_manage` (owner/admin) — 403 otherwise
        - a personal space is never reclassified (400) — move its pages out
          with `updatePage.workspaceId` instead
        - only the creator may make a space `personal` (403 otherwise), so a
          space can't vanish into another person's private area
        - a `team` target must be a team the caller belongs to (403 otherwise)

        Re-submitting the current audience is a no-op and needs no permission.
        Audience changes are written to the platform audit log as
        `notes.space.audience_changed`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name:
                  type: string
                  description: New name. Omit to leave the name unchanged.
                kind:
                  type: string
                  enum: [org, team, personal]
                  description: New audience. Omit to leave the audience unchanged.
                teamId:
                  type: string
                  format: uuid
                  nullable: true
                  description: Target team, required when `kind` is `team`.
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [workspace]
                properties:
                  workspace:
                    $ref: "#/components/schemas/Workspace"
        "400":
          description: Blank name, unknown audience, or a personal space being reclassified
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: The caller may not change this space's audience
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: deleteWorkspace
      tags: [workspaces]
      summary: Delete an EMPTY space
      description: |
        Refused (409) while any pages exist — including trashed ones — so
        content is never cascade-deleted through this endpoint. Personal
        spaces can't be deleted (400).
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "409":
          description: The space still contains pages
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/pages:
    get:
      operationId: listPages
      tags: [pages]
      summary: List pages in a workspace
      parameters:
        - name: workspace_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
        - name: archived
          in: query
          description: Pass "1" to include archived (trashed) pages.
          schema:
            type: string
      responses:
        "200":
          description: Pages
          content:
            application/json:
              schema:
                type: object
                required: [pages]
                properties:
                  pages:
                    type: array
                    items:
                      $ref: "#/components/schemas/Page"
    post:
      operationId: createPage
      tags: [pages]
      summary: Create a page
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspaceId]
              properties:
                workspaceId:
                  type: string
                  format: uuid
                title:
                  type: string
                icon:
                  type: string
                parentId:
                  type: string
                  format: uuid
                  nullable: true
                position:
                  type: number
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [page]
                properties:
                  page:
                    $ref: "#/components/schemas/Page"

  /v1/pages/{id}:
    get:
      operationId: getPage
      tags: [pages]
      summary: Get a page with its legacy blocks
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Page + blocks
          content:
            application/json:
              schema:
                type: object
                required: [page, blocks]
                properties:
                  page:
                    $ref: "#/components/schemas/Page"
                  blocks:
                    type: array
                    items:
                      $ref: "#/components/schemas/Block"
    patch:
      operationId: updatePage
      tags: [pages]
      summary: Update page metadata (title, icon, move, archive, change space)
      description: |
        Setting workspaceId moves the page to another space the caller can
        access — the entire subtree moves with it and the page becomes a
        root of the target space unless the same request also names a parent
        inside it.

        A sidebar drag sends all three move fields at once (space, parent,
        position), so parentId is validated:

        - the parent must be a page the caller can see (404 otherwise —
          never a confirmation that an id exists in another org)
        - the parent must be in the space the page ends up in, so a page is
          never nested under a parent that space's readers cannot open (400)
        - the parent must not be the page itself or one of its own
          descendants (400) — a cycle detaches the whole branch from every
          root and it stops appearing in the tree

        `parentId: ""` clears the parent, putting the page at the top level
        of its space. `position` is a float, so a page can be dropped between
        two siblings by writing one row instead of renumbering the list.
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                icon:
                  type: string
                parentId:
                  type: string
                  nullable: true
                  description: Parent page id, or "" for the top level of the space.
                position:
                  type: number
                  description: Sort key among siblings; fractional values order a drop between two of them.
                archived:
                  type: boolean
                workspaceId:
                  type: string
                  format: uuid
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [page]
                properties:
                  page:
                    $ref: "#/components/schemas/Page"
        "400":
          description: The parent is in another space, or would nest the page inside itself
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    delete:
      operationId: deletePage
      tags: [pages]
      summary: Delete a page permanently
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/pages/{id}/doc:
    get:
      operationId: getDoc
      tags: [pages]
      summary: Get the rich document
      description: Returns the Tiptap JSON doc, converting legacy blocks on first read.
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: The document
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocPayload"
    put:
      operationId: putDoc
      tags: [pages]
      summary: Save the rich document (optimistic concurrency)
      description: |
        Saves the doc with the revision the client loaded. A rev mismatch
        returns 409 with the current server doc so the editor can merge
        instead of clobbering another writer. Optionally syncs outgoing
        wiki-link edges in the same save.
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [doc, rev]
              properties:
                doc:
                  type: object
                  description: Tiptap/ProseMirror document JSON.
                docText:
                  type: string
                  description: Plain-text projection for search.
                rev:
                  type: integer
                  format: int64
                links:
                  type: array
                  description: Outgoing page-link ids (replaces the edge set).
                  items:
                    type: string
                    format: uuid
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                required: [rev, updated]
                properties:
                  rev:
                    type: integer
                    format: int64
                  updated:
                    type: string
                    format: date-time
        "409":
          description: Rev mismatch — body carries the current server state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocConflict"

  /v1/pages/{id}/versions:
    get:
      operationId: listVersions
      tags: [collaboration]
      summary: List version snapshots (newest first)
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Versions
          content:
            application/json:
              schema:
                type: object
                required: [versions]
                properties:
                  versions:
                    type: array
                    items:
                      $ref: "#/components/schemas/VersionMeta"

  /v1/pages/{id}/versions/{versionId}:
    get:
      operationId: getVersion
      tags: [collaboration]
      summary: Get one snapshot including its doc
      parameters:
        - $ref: "#/components/parameters/PageId"
        - name: versionId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/VersionDetail"

  /v1/pages/{id}/versions/{versionId}/restore:
    post:
      operationId: restoreVersion
      tags: [collaboration]
      summary: Restore a snapshot (current doc is snapshotted first)
      parameters:
        - $ref: "#/components/parameters/PageId"
        - name: versionId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Restored
          content:
            application/json:
              schema:
                type: object
                required: [rev, doc, updated]
                properties:
                  rev:
                    type: integer
                    format: int64
                  doc:
                    type: object
                  updated:
                    type: string
                    format: date-time

  /v1/pages/{id}/blocks:
    get:
      operationId: listBlocks
      tags: [blocks]
      summary: List a page's legacy blocks
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          $ref: "#/components/responses/Blocks"
    post:
      operationId: createBlock
      tags: [blocks]
      summary: Create a block
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                content:
                  type: string
                properties:
                  type: object
                parentId:
                  type: string
                  nullable: true
                position:
                  type: number
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [block]
                properties:
                  block:
                    $ref: "#/components/schemas/Block"

  /v1/pages/{id}/blocks/reorder:
    post:
      operationId: reorderBlocks
      tags: [blocks]
      summary: Reorder a page's blocks
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [orderedIds]
              properties:
                orderedIds:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        "200":
          $ref: "#/components/responses/Blocks"

  /v1/blocks/{id}:
    patch:
      operationId: updateBlock
      tags: [blocks]
      summary: Update a block
      parameters:
        - $ref: "#/components/parameters/BlockId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                content:
                  type: string
                properties:
                  type: object
                position:
                  type: number
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [block]
                properties:
                  block:
                    $ref: "#/components/schemas/Block"
    delete:
      operationId: deleteBlock
      tags: [blocks]
      summary: Delete a block
      parameters:
        - $ref: "#/components/parameters/BlockId"
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/pages/{id}/backlinks:
    get:
      operationId: getBacklinks
      tags: [collaboration]
      summary: Pages that link to this page
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Backlinks
          content:
            application/json:
              schema:
                type: object
                required: [backlinks]
                properties:
                  backlinks:
                    type: array
                    items:
                      $ref: "#/components/schemas/PageRef"

  /v1/pages/{id}/links:
    put:
      operationId: setLinks
      tags: [collaboration]
      summary: Replace this page's outgoing links
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [targetPageIds]
              properties:
                targetPageIds:
                  type: array
                  items:
                    type: string
                    format: uuid
      responses:
        "200":
          description: The stored edge set
          content:
            application/json:
              schema:
                type: object
                required: [targetPageIds]
                properties:
                  targetPageIds:
                    type: array
                    items:
                      type: string
                      format: uuid

  /v1/search:
    get:
      operationId: search
      tags: [search]
      summary: Ranked full-text search
      description: |
        Postgres FTS over titles + doc text (websearch syntax) with an ILIKE
        title fallback. Snippets wrap match runs in **…** markers.
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
        - name: workspace_id
          in: query
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Ranked hits
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      $ref: "#/components/schemas/SearchHit"

  /v1/templates:
    get:
      operationId: listTemplates
      tags: [pages]
      summary: List a workspace's templates
      parameters:
        - name: workspace_id
          in: query
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Templates
          content:
            application/json:
              schema:
                type: object
                required: [templates]
                properties:
                  templates:
                    type: array
                    items:
                      $ref: "#/components/schemas/Template"
    post:
      operationId: createTemplate
      tags: [pages]
      summary: Create a template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [workspaceId, name]
              properties:
                workspaceId:
                  type: string
                  format: uuid
                name:
                  type: string
                icon:
                  type: string
                doc:
                  type: object
                docText:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [template]
                properties:
                  template:
                    $ref: "#/components/schemas/Template"

  /v1/templates/{id}:
    delete:
      operationId: deleteTemplate
      tags: [pages]
      summary: Delete a template
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/favorites:
    get:
      operationId: listFavorites
      tags: [pages]
      summary: The caller's favorite pages
      responses:
        "200":
          description: Favorites
          content:
            application/json:
              schema:
                type: object
                required: [favorites]
                properties:
                  favorites:
                    type: array
                    items:
                      $ref: "#/components/schemas/PageRef"

  /v1/pages/{id}/favorite:
    put:
      operationId: addFavorite
      tags: [pages]
      summary: Favorite a page
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          $ref: "#/components/responses/Message"
    delete:
      operationId: removeFavorite
      tags: [pages]
      summary: Unfavorite a page
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/pages/{id}/comments:
    get:
      operationId: listComments
      tags: [collaboration]
      summary: List a page's comments
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Comments
          content:
            application/json:
              schema:
                type: object
                required: [comments]
                properties:
                  comments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Comment"
    post:
      operationId: createComment
      tags: [collaboration]
      summary: Comment on a page
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                parentId:
                  type: string
                  nullable: true
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"

  /v1/comments/{id}:
    patch:
      operationId: updateComment
      tags: [collaboration]
      summary: Edit (author-only) or resolve a comment
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                body:
                  type: string
                resolved:
                  type: boolean
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
    delete:
      operationId: deleteComment
      tags: [collaboration]
      summary: Delete a comment (author-only)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/pages/{id}/shares:
    get:
      operationId: listShares
      tags: [sharing]
      summary: List a page's active public links
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Shares
          content:
            application/json:
              schema:
                type: object
                required: [shares]
                properties:
                  shares:
                    type: array
                    items:
                      $ref: "#/components/schemas/Share"
    post:
      operationId: createShare
      tags: [sharing]
      summary: Create a public link (view or edit mode)
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [view, edit]
                  default: view
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [share]
                properties:
                  share:
                    $ref: "#/components/schemas/Share"

  /v1/shares/{token}:
    delete:
      operationId: revokeShare
      tags: [sharing]
      summary: Revoke a public link
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/Message"

  /v1/public/pages/{token}:
    get:
      operationId: publicPage
      tags: [sharing]
      summary: Read a shared page (no auth)
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The shared page
          content:
            application/json:
              schema:
                type: object
                required: [pageId, mode, title, icon, doc, rev, updated]
                properties:
                  pageId:
                    type: string
                    format: uuid
                  mode:
                    type: string
                    enum: [view, edit]
                  title:
                    type: string
                  icon:
                    type: string
                  doc:
                    type: object
                  rev:
                    type: integer
                    format: int64
                  updated:
                    type: string
                    format: date-time

  /v1/public/pages/{token}/doc:
    put:
      operationId: publicPutDoc
      tags: [sharing]
      summary: Save through an edit-mode share link (no auth)
      description: |
        Anonymous save path for edit-mode share links: same optimistic
        rev contract as the authenticated save (409 carries the current
        server doc). View-only links get 403. Wiki-link syncing is skipped
        and last_edited_by is left untouched.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [doc, rev]
              properties:
                doc:
                  type: object
                docText:
                  type: string
                rev:
                  type: integer
                  format: int64
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                required: [rev, updated]
                properties:
                  rev:
                    type: integer
                    format: int64
                  updated:
                    type: string
                    format: date-time
        "403":
          description: The link is view-only
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Rev mismatch — body carries the current server state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DocConflict"

  /v1/pages/{id}/attachments:
    get:
      operationId: listAttachments
      tags: [sharing]
      summary: List a page's attachments
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Attachments
          content:
            application/json:
              schema:
                type: object
                required: [attachments]
                properties:
                  attachments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Attachment"
    post:
      operationId: createAttachment
      tags: [sharing]
      summary: Upload a file (multipart, "file" field, ≤15MB)
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Uploaded
          content:
            application/json:
              schema:
                type: object
                required: [attachment]
                properties:
                  attachment:
                    $ref: "#/components/schemas/Attachment"

  /v1/attachments/{id}:
    get:
      operationId: getAttachment
      tags: [sharing]
      summary: Fetch an attachment (no auth — presigned redirect)
      description: Redirects (302) to a presigned R2 URL so it works in an <img src>.
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "302":
          description: Redirect to the presigned file URL

  /v1/pages/{id}/presence:
    post:
      operationId: heartbeat
      tags: [collaboration]
      summary: Presence heartbeat
      description: Marks the caller as viewing the page; returns other live viewers and the current doc rev (used for soft-realtime refresh).
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          description: Viewers + current rev
          content:
            application/json:
              schema:
                type: object
                required: [viewers, rev, updated]
                properties:
                  viewers:
                    type: array
                    items:
                      $ref: "#/components/schemas/PresenceViewer"
                  rev:
                    type: integer
                    format: int64
                  updated:
                    type: string
                    format: date-time

  /internal/pages/{id}/sync:
    get:
      operationId: internalGetSyncState
      tags: [system]
      summary: "Internal: read CRDT state (notes-sync only)"
      description: |
        Service-to-service surface for the notes-sync realtime server, gated
        by the X-Internal-Key shared secret (501 when unconfigured). Returns
        the stored Yjs binary state (base64, empty when none) plus the current
        JSON projection and rev so a fresh Y.Doc can be seeded.
      security: []
      parameters:
        - $ref: "#/components/parameters/PageId"
        - $ref: "#/components/parameters/InternalKey"
      responses:
        "200":
          description: CRDT state + projection
          content:
            application/json:
              schema:
                type: object
                required: [ydoc, doc, rev]
                properties:
                  ydoc:
                    type: string
                    description: Base64 Yjs state; empty when none stored yet.
                  doc:
                    type: object
                  rev:
                    type: integer
                    format: int64
    put:
      operationId: internalPutSyncState
      tags: [system]
      summary: "Internal: persist CRDT state (notes-sync only)"
      description: |
        Stores the Yjs binary and projects the JSON doc through the standard
        save path (rev bump, snapshot throttling, wiki-link edges, search
        text). The sync server is authoritative during co-editing — no
        optimistic rev check.
      security: []
      parameters:
        - $ref: "#/components/parameters/PageId"
        - $ref: "#/components/parameters/InternalKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ydoc, doc]
              properties:
                ydoc:
                  type: string
                  description: Base64 Yjs state.
                doc:
                  type: object
                docText:
                  type: string
                links:
                  type: array
                  items:
                    type: string
                    format: uuid
                editedBy:
                  type: string
                  format: uuid
      responses:
        "200":
          description: Saved
          content:
            application/json:
              schema:
                type: object
                required: [rev, updated]
                properties:
                  rev:
                    type: integer
                    format: int64
                  updated:
                    type: string
                    format: date-time

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    PageId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    InternalKey:
      name: X-Internal-Key
      in: header
      required: true
      description: Shared service secret (INTERNAL_API_KEY).
      schema:
        type: string
    BlockId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid

  responses:
    Message:
      description: Confirmation
      content:
        application/json:
          schema:
            type: object
            required: [message]
            properties:
              message:
                type: string
    Blocks:
      description: The page's blocks in order
      content:
        application/json:
          schema:
            type: object
            required: [blocks]
            properties:
              blocks:
                type: array
                items:
                  $ref: "#/components/schemas/Block"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }

    Member:
      type: object
      required: [id, userId, name, email, created]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
        created:
          type: string
          format: date-time

    Workspace:
      type: object
      required: [id, name, ownerId, kind, created]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        ownerId:
          type: string
          format: uuid
        kind:
          type: string
          enum: [org, team, personal]
        teamId:
          type: string
          format: uuid
          nullable: true
        created:
          type: string
          format: date-time

    Page:
      type: object
      required: [id, workspaceId, title, icon, position, createdBy, created, updated]
      properties:
        id:
          type: string
          format: uuid
        workspaceId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          nullable: true
        title:
          type: string
        icon:
          type: string
        position:
          type: number
        createdBy:
          type: string
          format: uuid
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time
        archivedAt:
          type: string
          format: date-time
          nullable: true

    PageRef:
      type: object
      required: [id, title, icon, workspaceId]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        icon:
          type: string
        workspaceId:
          type: string
          format: uuid

    Block:
      type: object
      required: [id, pageId, type, content, properties, position, created, updated]
      properties:
        id:
          type: string
          format: uuid
        pageId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          nullable: true
        type:
          type: string
        content:
          type: string
        properties:
          type: object
        position:
          type: number
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    DocPayload:
      type: object
      required: [doc, rev, updated]
      properties:
        doc:
          type: object
          description: Tiptap/ProseMirror document JSON.
        rev:
          type: integer
          format: int64
        updated:
          type: string
          format: date-time
        lastEditedBy:
          type: string
          format: uuid
          nullable: true

    DocConflict:
      type: object
      required: [error, rev, doc]
      properties:
        error:
          type: string
        rev:
          type: integer
          format: int64
        doc:
          type: object
        lastEditedBy:
          type: string
          format: uuid
          nullable: true

    VersionMeta:
      type: object
      required: [id, title, created]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        savedBy:
          type: string
          format: uuid
          nullable: true
        created:
          type: string
          format: date-time

    VersionDetail:
      allOf:
        - $ref: "#/components/schemas/VersionMeta"
        - type: object
          required: [doc]
          properties:
            doc:
              type: object

    Template:
      type: object
      required: [id, workspaceId, name, icon, doc, created]
      properties:
        id:
          type: string
          format: uuid
        workspaceId:
          type: string
          format: uuid
        name:
          type: string
        icon:
          type: string
        doc:
          type: object
        createdBy:
          type: string
          format: uuid
          nullable: true
        created:
          type: string
          format: date-time

    Comment:
      type: object
      required: [id, pageId, authorId, body, created, updated]
      properties:
        id:
          type: string
          format: uuid
        pageId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          nullable: true
        authorId:
          type: string
          format: uuid
        body:
          type: string
        resolvedAt:
          type: string
          format: date-time
          nullable: true
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    Share:
      type: object
      required: [token, mode, created]
      properties:
        token:
          type: string
        mode:
          type: string
          enum: [view, edit]
        created:
          type: string
          format: date-time

    Attachment:
      type: object
      required: [id, filename, contentType, size, url, created]
      properties:
        id:
          type: string
          format: uuid
        pageId:
          type: string
          format: uuid
          nullable: true
        filename:
          type: string
        contentType:
          type: string
        size:
          type: integer
          format: int64
        url:
          type: string
        created:
          type: string
          format: date-time

    PresenceViewer:
      type: object
      required: [userId, name, seen]
      properties:
        userId:
          type: string
          format: uuid
        name:
          type: string
        seen:
          type: string
          format: date-time

    SearchHit:
      type: object
      required: [id, title, icon, workspaceId, snippet, updated]
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        icon:
          type: string
        workspaceId:
          type: string
          format: uuid
        snippet:
          type: string
          description: Match runs are wrapped in **…** markers (ts_headline).
        updated:
          type: string
          format: date-time
