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, served
        at the root and under the base path (api.easerix.com/notes/health). It
        also reports the weekly integrity check: an uptime monitor can
        alert on integrity other than ok. Status and counts only.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object
                required: [status, service, integrity, doubledPages]
                properties:
                  status:
                    type: string
                  service:
                    type: string
                  integrity:
                    type: string
                    enum: [unchecked, ok, doubled-pages, check-failed]
                    description: The last weekly check's result; unchecked until the first run (10 min after start).
                  doubledPages:
                    type: integer
                    description: Pages whose content is exactly doubled at the last check.
                  doubledCheckedAt:
                    type: string
                    format: date-time

  /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`.
                internal:
                  type: boolean
                  description: Internal spaces' pages can't be shared publicly without an owner/admin approval. Anyone may set true; false takes the space owner or a workspace owner/admin (403 internal_owner_only).
      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, or may not lift its internal flag (code internal_owner_only)
          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
        - name: source
          in: query
          description: Only pages published by this source (tool).
          schema:
            type: string
        - name: source_ref
          in: query
          description: Only the page with this id in its source (unique per space and source).
          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
                source:
                  type: string
                  description: The tool publishing this page from an outside source (e.g. notes-import).
                sourceRef:
                  type: string
                  description: The page's stable id in that source. Unique per space and source; requires source.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [page]
                properties:
                  page:
                    $ref: "#/components/schemas/Page"
        "409":
          description: Another page in this space already has this source and sourceRef; the body carries it so the tool can adopt it.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourceRefTaken"

  /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

        archived=true trashes the page and every live sub-page with the same
        timestamp; archived=false restores the page and exactly that batch
        (sub-pages trashed separately stay in the trash). A page restored
        under a parent that is still trashed comes back at the space root.

        `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.

        A move into a space with a wider audience — Private to a team or
        everyone, a team to everyone, or one team to another — exposes the
        page and its sub-pages to people who could not read them before. It
        needs the same authority as changing a space's audience: the owner
        of the space the page is leaving, or a workspace owner/admin (403
        `widening_move` otherwise). Callers who have that authority must
        also send `confirmWiden: true` (409 `confirm_widen` otherwise, with
        the target space so the client can name it in a prompt). Narrowing
        and same-audience moves need neither. Every cross-space move is
        written to the platform audit log as `notes.page.moved`.
      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
                internal:
                  type: boolean
                  description: Override the space's internal flag for this page. false takes the space owner or a workspace owner/admin (403 internal_owner_only).
                source:
                  type: string
                  description: Set ("" clears) the publishing source.
                sourceRef:
                  type: string
                  description: Set ("" clears) the page's id in its source. Unique per space and source; requires source.
                confirmWiden:
                  type: boolean
                  default: false
                  description: Confirms a move into a space with a wider audience. Ignored for any other change.
      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"
        "403":
          description: The move would widen the page's audience and the caller doesn't own its space and isn't a workspace owner or admin
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WideningMoveRefused"
        "409":
          description: |
            Another page in the space already has this source and sourceRef
            (`source_ref_taken`), or the move widens the page's audience and
            was not confirmed (`confirm_widen` — resend with `confirmWiden: true`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/SourceRefTaken"
                  - $ref: "#/components/schemas/ConfirmWiden"
    delete:
      operationId: deletePage
      tags: [pages]
      summary: Delete a trashed page permanently
      description: |
        Only a page that is already in the trash (archived) can be deleted
        forever; a live page returns 409. The page goes with its trashed
        sub-pages. A sub-page that is still live is kept and moved up to the
        deleted page's parent (or the space root if that parent is trashed).
      parameters:
        - $ref: "#/components/parameters/PageId"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "409":
          description: The page is not in the trash
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /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.

        A tool publishing from an outside source also sends publishedRev,
        the rev its last publish wrote (easerix#461). If the page has moved
        past it, people edited it in Notes since, and the save is refused
        with 409 code edited_since_publish unless force is true. A save that
        carries publishedRev or force is recorded as a publish
        (publishedRev/publishedAt on the page). Ordinary saves omit both and
        are unaffected.
      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
                publishedRev:
                  type: integer
                  format: int64
                  description: Publishing tools only. The rev the tool's last publish wrote (0 on first publish).
                force:
                  type: boolean
                  description: Publishing tools only. Publish even if the page was edited in Notes since.
      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
                  publishedRev:
                    type: integer
                    format: int64
                    description: Present when the save was a publish.
        "409":
          description: Rev mismatch (DocConflict, with the current server state) or, for a publish, edited_since_publish (PublishConflict)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/DocConflict"
                  - $ref: "#/components/schemas/PublishConflict"

  /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
                  description: A block on the same page
                position:
                  type: number
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [block]
                properties:
                  block:
                    $ref: "#/components/schemas/Block"
        "404":
          description: No such page, or parentId is not a block on this page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /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
                  description: A comment on the same page (makes this a reply)
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
        "404":
          description: No such page, or parentId is not a comment on this page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/comments:
    get:
      operationId: listCommentInbox
      tags: [collaboration]
      summary: Comments across every page the caller can read
      description: |
        The comment inbox, for people and agents (an MCP/OAuth client or API
        key acting for a user). Covers every page the caller can read; trashed
        pages are left out. Each comment is its own row with its parentId, so
        a thread's replies keep their own authors. Poll with since set to the
        previous answer's next to get only what was created, edited or
        resolved after it, oldest first. The cursor is a keyset on
        (updated_at, id), so comments sharing a timestamp (a bulk resolve)
        are never dropped at a page boundary.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [open, resolved, all]
            default: open
        - name: pageId
          in: query
          description: Only this page (404 if the caller can't read it).
          schema:
            type: string
            format: uuid
        - name: under
          in: query
          description: A page and every page below it (404 if the caller can't read it). Not with pageId.
          schema:
            type: string
            format: uuid
        - name: since
          in: query
          description: |
            The previous answer's next, a cursor on (updated_at, id) written
            "<RFC 3339 time>_<id>". A bare RFC 3339 time also works.
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        "200":
          description: Matching comments, oldest change first
          content:
            application/json:
              schema:
                type: object
                required: [comments, next]
                properties:
                  comments:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboxComment"
                  next:
                    type: string
                    description: Pass as since on the next poll (a cursor; don't parse it).
        "400":
          description: A malformed status, since or limit, or pageId with under
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: pageId is not a page the caller can read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /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)
      description: |
        Internal pages (every space by default) can only be shared by the
        space owner or a workspace owner/admin, with confirmInternal=true;
        every link expires. The public read and save re-check revocation,
        expiry, trash and, for internal pages, the approval on every request,
        and answer 404 for a dead link.

        Anonymous edit links are turned off unless the deployment sets
        NOTES_ALLOW_EDIT_LINKS=true: mode=edit is refused with 403
        edit_links_disabled, and existing edit links serve as view links
        (the public read reports mode=view and the public save returns 403).
      parameters:
        - $ref: "#/components/parameters/PageId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mode:
                  type: string
                  enum: [view, edit]
                  default: view
                confirmInternal:
                  type: boolean
                  description: Required (true) to publish an internal page; recorded as the approval.
                expiresInDays:
                  type: integer
                  minimum: 1
                  maximum: 365
                  description: Link lifetime; defaults to the deployment TTL (30 days).
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [share]
                properties:
                  share:
                    $ref: "#/components/schemas/Share"
        "409":
          description: The page is internal and the request lacks confirmInternal (code confirm_internal)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Edit links are turned off (code edit_links_disabled), or the page is internal and the caller is neither the space owner nor a workspace owner/admin (code internal_page)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /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: Stream an attachment (share link or bearer)
      description: |
        Streams the file for a caller holding a live share token for the
        attachment's page (share, used by share pages) or a bearer that can
        see the page. Anything else is 404, so a URL on its own (an id, or a
        retired exp+sig signed URL) opens nothing. Never redirects to a
        storage URL. Raster images (png, jpeg, gif, webp, avif) are served
        inline; anything else as application/octet-stream to download. Every
        response is Cache-Control private, no-store, Referrer-Policy
        no-referrer, X-Content-Type-Options nosniff. Streams are bounded: a
        deadline sized to the file, and a cap on concurrent downloads per
        caller and overall (429 past it).
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: share
          in: query
          description: A live share token for the attachment's page (share pages).
          schema:
            type: string
      responses:
        "200":
          description: The file bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "404":
          description: No such attachment, or no live share link or bearer for its page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many downloads at once for this caller or overall (Retry-After says when to retry)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/attachments/{id}/content:
    get:
      operationId: getAttachmentContent
      tags: [sharing]
      summary: Stream an attachment's bytes (members)
      description: |
        For an attachment on a page the caller can see, streams the file
        itself, with the same type and header rules as getAttachment. The
        editor shows images from it through blob: URLs, and exports bundle
        images from it.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The file bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        "404":
          description: No such attachment, or not on a page the caller can see
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Too many downloads at once for this caller or overall (Retry-After says when to retry)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /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. With
        rev_only=1 only the rev is returned (notes-sync polls open documents
        for writes made outside collab).
      security: []
      parameters:
        - $ref: "#/components/parameters/PageId"
        - $ref: "#/components/parameters/InternalKey"
        - name: rev_only
          in: query
          description: Pass "1" to return only the rev.
          schema:
            type: string
      responses:
        "200":
          description: CRDT state + projection (only rev when rev_only=1)
          content:
            application/json:
              schema:
                type: object
                required: [rev]
                properties:
                  ydoc:
                    type: string
                    description: |
                      Base64 Yjs state that belongs to the current doc (by content
                      hash); empty when none is stored or an outside write has
                      overtaken it.
                  staleYdoc:
                    type: string
                    description: |
                      Base64 Yjs state an outside write has overtaken (set only when
                      ydoc is empty). notes-sync loads it and brings it to doc with
                      ordinary edits, so a browser that kept that history merges
                      into it instead of getting a second copy.
                  staleYdocHash:
                    type: string
                    description: SHA-256 (hex) of staleYdoc's bytes; pass it as expect when storing the refreshed state.
                  seedPersist:
                    type: boolean
                    description: |
                      This build understands a seed store (PUT with seed). notes-sync
                      persists loaded state only when this is true.
                  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, except
        over writes made outside it: when baseRev is sent and the page's rev
        has moved past it (a REST save, version restore or copilot write
        while the page was open), the store is refused with 409 and the
        current doc, so notes-sync applies that write to the live document
        instead of overwriting it. Without baseRev the store is
        unconditional.
      security: []
      parameters:
        - $ref: "#/components/parameters/PageId"
        - $ref: "#/components/parameters/InternalKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ydoc]
              properties:
                ydoc:
                  type: string
                  description: Base64 Yjs state.
                doc:
                  type: object
                  description: Required for a normal store; never sent with seed.
                docText:
                  type: string
                links:
                  type: array
                  items:
                    type: string
                    format: uuid
                editedBy:
                  type: string
                  format: uuid
                baseRev:
                  type: integer
                  format: int64
                  description: The rev notes-sync last loaded or stored for this page.
                seed:
                  type: boolean
                  description: |
                    Store only the Yjs state the page was loaded with: no rev bump,
                    no version, no projection (doc is ignored). Requires baseRev
                    equal to the page's rev. With expect empty it only inserts;
                    otherwise it replaces the stored state only while that state
                    hashes to expect. A load that loses either race gets 409 with
                    the stored state as winner.
                expect:
                  type: string
                  description: With seed, the SHA-256 (hex) of the stored state being replaced; empty means "only if nothing is stored".
      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: |
            The page changed outside collab since baseRev; body carries the current
            rev and doc. With seed: the page moved past baseRev (rev only), or
            another load stored its state first (winner is that state).
          content:
            application/json:
              schema:
                type: object
                required: [error, rev]
                properties:
                  error:
                    type: string
                  rev:
                    type: integer
                    format: int64
                  doc:
                    type: object
                  winner:
                    type: string
                    description: With seed, the base64 Yjs state another load stored first; continue from it.

  /internal/sync/revocations:
    get:
      operationId: internalSyncRevocations
      tags: [system]
      summary: "Internal: share links revoked and pages trashed since a time (notes-sync only)"
      description: |
        Revocation feed for the notes-sync realtime server, gated by the
        X-Internal-Key shared secret (501 when unconfigured). notes-sync polls
        it every few seconds and closes the matching share-link connections
        at once, instead of at its per-connection re-check. Share
        links are reported as SHA-256 hashes (hex) of their tokens, never the
        tokens. since is a cursor: pass the previous answer's next. Rows come
        oldest first, at most 1000 of each kind; a full answer's next trails
        the clock by 30 s (so late commits are seen again), a capped answer's
        next is the last row's time (so a burst is read over several polls).
        The feed never looks back more than 10 minutes.
      security: []
      parameters:
        - $ref: "#/components/parameters/InternalKey"
        - name: since
          in: query
          description: The previous answer's next (RFC 3339). Omitted means 30 s ago.
          schema:
            type: string
            format: date-time
      responses:
        "200":
          description: What changed since the given time
          content:
            application/json:
              schema:
                type: object
                required: [next, revokedShareHashes, trashedPageIds]
                properties:
                  next:
                    type: string
                    format: date-time
                    description: The cursor to pass as since on the next poll.
                  revokedShareHashes:
                    type: array
                    items:
                      type: string
                      description: SHA-256 of the share token, lowercase hex.
                  trashedPageIds:
                    type: array
                    items:
                      type: string
                      format: uuid
        "400":
          description: since is not an RFC 3339 time
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

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
        internal:
          type: boolean
          nullable: true
          description: Explicit internal flag; null means the deployment default (internal unless NOTES_INTERNAL_DEFAULT=false).

    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
        source:
          type: string
          nullable: true
          description: Set when a tool publishes this page from an outside source.
        sourceRef:
          type: string
          nullable: true
          description: The page's stable id in its source (unique per space and source).
        publishedRev:
          type: integer
          format: int64
          nullable: true
          description: Doc rev written by the last publish. docRev above it means the page was edited in Notes since.
        publishedAt:
          type: string
          format: date-time
          nullable: true
        docRev:
          type: integer
          format: int64
        excerpt:
          type: string
          description: First ~140 characters of the page's text, whitespace collapsed (list rows).
        published:
          type: boolean
          description: The page has a live public link (the dashboard's PUBLISHED badge; otherwise DRAFT). Returned by listPages and getPage.
        openComments:
          type: integer
          description: How many open (unresolved) comments the page has, for the page tree. Returned by listPages.
        internalOverride:
          type: boolean
          nullable: true
          description: The page's own internal flag; null inherits the space.
        internal:
          type: boolean
          description: Effective internal flag (page, else space, else default). Returned by getPage only.

    SourceRefTaken:
      type: object
      required: [error, code, page]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [source_ref_taken]
        page:
          $ref: "#/components/schemas/Page"

    WideningMoveRefused:
      type: object
      required: [error, code]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [widening_move]

    ConfirmWiden:
      type: object
      required: [error, code, space]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [confirm_widen]
        space:
          $ref: "#/components/schemas/Workspace"

    PublishConflict:
      type: object
      required: [error, code, rev]
      properties:
        error:
          type: string
        code:
          type: string
          enum: [edited_since_publish]
        rev:
          type: integer
          format: int64
        publishedRev:
          type: integer
          format: int64
          nullable: true
        lastEditedBy:
          type: string
          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
        authorKind:
          type: string
          enum: [user, agent]
          description: |
            agent when the comment was written through a delegated credential
            (an MCP/OAuth client or an API key acting for authorId), else user.
            Treat unknown values as user; more kinds may be added.
        body:
          type: string
        resolvedAt:
          type: string
          format: date-time
          nullable: true
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time

    InboxComment:
      allOf:
        - $ref: "#/components/schemas/Comment"
        - type: object
          required: [page]
          properties:
            page:
              type: object
              required: [id, title]
              properties:
                id:
                  type: string
                  format: uuid
                title:
                  type: string

    Share:
      type: object
      required: [token, mode, created]
      properties:
        token:
          type: string
        mode:
          type: string
          enum: [view, edit]
        created:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
          nullable: true
        approved:
          type: boolean
          description: An owner/admin explicitly approved publishing this (internal) page.

    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
