{
  "openapi": "3.0.3",
  "info": {
    "title": "PitchStation Share API",
    "version": "2026-08",
    "description": "Publish a document and get a secure, tracked /s/<slug> link with analytics, access control, expiry, password, and revoke. Auth: `Authorization: Bearer pst_...` (Personal Access Token, mint at /tokens.html). RULES FOR ASSISTANTS: (1) To UPDATE a published document use POST /api/share/{slug}/replace, never a second publish — republishing mints a new URL and splits analytics. (2) aclUsernames applies to access:'account' only. (3) recipients[] applies to access:'recipient' only — each gets a personal magic link, emailed automatically. (4) Never echo the share password back into chat unless asked. (5) Safe default is access:'account'; use access:'link' + expires_in + password for external sends. Human quickstart: https://www.pitchstation.ai/ai-skill-setup.html Compatibility: additive evolution only.",
    "contact": { "email": "hello@pitchstation.ai" }
  },
  "servers": [{ "url": "https://www.pitchstation.ai" }],
  "security": [{ "bearerAuth": [] }],
  "components": {
    "securitySchemes": {
      "bearerAuth": { "type": "http", "scheme": "bearer", "description": "Personal Access Token (pst_...)" }
    },
    "parameters": {
      "slug": { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "The share id from the /s/<slug> URL." }
    },
    "schemas": {
      "BundleFile": {
        "type": "object",
        "required": ["title"],
        "properties": {
          "title": { "type": "string" },
          "htmlContent": { "type": "string", "description": "Raw HTML string." },
          "pdfBase64": { "type": "string", "description": "Base64 PDF bytes (must start with %PDF-)." },
          "docxBase64": { "type": "string" },
          "pptxBase64": { "type": "string" },
          "markdown": { "type": "string" },
          "text": { "type": "string" },
          "imageBase64": { "type": "string" },
          "primary": { "type": "boolean", "description": "Exactly one file may be primary; defaults to the first." }
        }
      },
      "PublishRequest": {
        "type": "object",
        "description": "Exactly one content source: htmlContent | htmlBase64 | pdfBase64 | docxBase64 | pptxBase64 | files[] (multi-file bundle: one URL, sidebar viewer).",
        "properties": {
          "title": { "type": "string" },
          "htmlContent": { "type": "string", "description": "Raw HTML document. Most common." },
          "htmlBase64": { "type": "string" },
          "pdfBase64": { "type": "string" },
          "docxBase64": { "type": "string" },
          "pptxBase64": { "type": "string" },
          "files": { "type": "array", "items": { "$ref": "#/components/schemas/BundleFile" }, "description": "Multi-file bundle. One URL / password / expiry / analytics scope for all files." },
          "access": { "type": "string", "enum": ["account", "link", "recipient"], "default": "account", "description": "account = signed-in users (safe default, optionally narrowed by aclUsernames/teams); link = anyone with the URL (pair with expires_in + password); recipient = per-email magic links." },
          "aclUsernames": { "type": "array", "items": { "type": "string" }, "description": "account mode only: restrict to these usernames." },
          "teams": { "type": "array", "items": { "type": "string" }, "description": "account mode: team handles or '@owner/handle' refs whose members may view. An ambiguous bare handle is refused with TEAM_AMBIGUOUS + candidates — never guess; ask the user." },
          "teamIds": { "type": "array", "items": { "type": "integer" }, "description": "Exact team ids — never ambiguous, the safest form for an agent." },
          "notifyTeams": { "type": "boolean", "description": "Publishing to a team emails every member automatically; false publishes quietly. Default true." },
          "recipients": { "type": "array", "items": { "type": "string", "format": "email" }, "description": "recipient mode: each address gets its own magic link, emailed automatically." },
          "expires_in": { "type": "string", "description": "'24h', '7d', '10d', '30d', 'never', or an ISO timestamp." },
          "password": { "description": "true to auto-generate a memorable password (returned once in the response), or a custom string.", "oneOf": [{ "type": "boolean" }, { "type": "string" }] },
          "max_views": { "type": "integer", "description": "Auto-expire after N opens." },
          "watermark": { "type": "boolean", "description": "Stamp viewer identity + timestamp onto HTML pages." },
          "downloads": { "type": "boolean", "description": "Allow downloading the file. Default false." },
          "require_name": { "type": "boolean", "description": "link mode: viewers must give a name before viewing." },
          "allow_forward": { "type": "boolean", "description": "Viewers may mint their own tracked links; opens roll up to the origin." },
          "comments": { "type": "boolean", "description": "Enable inline comment threads on the document." }
        }
      },
      "PublishResponse": {
        "type": "object",
        "properties": {
          "success": { "type": "boolean" },
          "slug": { "type": "string" },
          "url": { "type": "string", "description": "The share link to give out. link-mode URLs carry the ?k= access key — always share the FULL url." },
          "password": { "type": "string", "description": "Present once when a password was set/generated. Relay it to the user beside the URL." },
          "recipients": { "type": "array", "items": { "type": "object", "properties": { "email": { "type": "string" }, "magic_link": { "type": "string" } } }, "description": "recipient mode: per-email personal links (also emailed automatically)." },
          "notified": { "type": "array", "items": { "type": "string" }, "description": "Addresses that were emailed." }
        }
      }
    }
  },
  "paths": {
    "/api/share": {
      "post": {
        "operationId": "publishShare",
        "summary": "Publish a document, get a tracked /s/<slug> link",
        "description": "Also accepts Content-Type: text/html with the raw document as the body and controls as query parameters (title, access, expires, password, max_views, watermark, downloads, recipients) — no JSON escaping needed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/PublishRequest" } },
            "text/html": { "schema": { "type": "string" } }
          }
        },
        "responses": {
          "201": { "description": "Created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PublishResponse" } } } },
          "400": { "description": "Invalid input (e.g. aclUsernames with access:'link')" },
          "413": { "description": "Payload over the size cap — strip embedded base64 images or publish as PDF" }
        }
      },
      "get": {
        "operationId": "listShares",
        "summary": "List shares owned by the caller",
        "responses": { "200": { "description": "OK — { shares: [{ slug, title, access, revoked, views, expires_at, url }] }" } }
      }
    },
    "/api/share/{slug}": {
      "get": {
        "operationId": "getShare",
        "summary": "Share detail: settings, recipients, teams, signature status",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "responses": { "200": { "description": "OK" }, "404": { "description": "Not found or not yours" } }
      },
      "patch": {
        "operationId": "editShare",
        "summary": "Change controls in place (same URL, analytics intact)",
        "description": "Settings only — password (string or null to clear), expires_in, max_views, watermark, downloads, require_name, comments. To change WHO can view, use PATCH /access. To change content, use /replace.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK — response lists any ignored fields" } }
      },
      "delete": {
        "operationId": "revokeShare",
        "summary": "Revoke — the link dies immediately",
        "description": "Only when the link should genuinely die. To fix content, use /replace instead.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "responses": { "200": { "description": "Revoked" } }
      }
    },
    "/api/share/{slug}/replace": {
      "post": {
        "operationId": "replaceShareContent",
        "summary": "Update the document behind an existing link",
        "description": "Keeps the URL, password, and analytics. Body: one content source (htmlContent | htmlBase64 | pdfBase64) plus optional { mode: 'announce', note: '...' } to show returning viewers an updated-since-you-read-it banner; omit mode for a silent fix.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "Replaced — version incremented" } }
      }
    },
    "/api/share/{slug}/access": {
      "patch": {
        "operationId": "editShareAccess",
        "summary": "Change who can view: add/remove recipients, viewers, teams",
        "description": "Body: { addRecipients: [emails], removeRecipients: [emails], addTeams: ['@owner/handle'] or addTeamIds: [ids], removeTeams / removeTeamIds, notify: true|false }. Adding a team is SILENT unless notify:true (it can hit many inboxes). Adding a team to a link/recipient-mode share PROMOTES it to account mode — the response's `promoted` object says so; relay it. Removing a recipient revokes their magic link.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/api/share/{slug}/recipients": {
      "get": {
        "operationId": "listShareRecipients",
        "summary": "Recipients with their personal magic links and view state",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "responses": { "200": { "description": "OK — { recipients: [{ email, url, registered, invitedAt, lastViewedAt }] }" } }
      }
    },
    "/api/share/{slug}/invite": {
      "post": {
        "operationId": "inviteToShare",
        "summary": "Email an invitation with the personal link",
        "description": "Body: { emails: [..], message: 'optional personal note shown as a quote', suggestSignup: true, requireAccount: true }. requireAccount makes the personal link identify-only: the document opens after they create a free account with that email and confirm it; re-sending the invite works as a remind-to-sign-up. Unregistered addresses get a signup nudge so the link doesn't read as spam. Suppressed (unsubscribed) addresses are skipped and reported.",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK — { notified: [..], skipped: [..] }" } }
      }
    },
    "/api/share/{slug}/notify": {
      "post": {
        "operationId": "notifyShare",
        "summary": "Re-announce a share to a named audience",
        "description": "Body: { to: ['members'|'teams'|'viewers'|'recipients'|'owner'] } — default ['members'] (teams + allowlist). There is deliberately no 'everyone'. Honors each person's email preferences; response reports notified[] and skipped[] with reasons. Passwords are never re-sent (stored hashed).",
        "parameters": [{ "$ref": "#/components/parameters/slug" }],
        "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK — { notified: [..], skipped: [{email, why}] }" } }
      }
    },
    "/api/teams": {
      "get": {
        "operationId": "listTeams",
        "summary": "Teams the caller owns or belongs to",
        "description": "Each team: id, handle, name, ref ('@owner/handle'), members, yourRole, archived, and `ambiguous` (another visible team shares the handle — use the ref or id, never the bare handle).",
        "responses": { "200": { "description": "OK — { teams: [...] }" } }
      },
      "post": {
        "operationId": "createTeam",
        "summary": "Create a team (you become owner + first member)",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["handle"], "properties": { "handle": { "type": "string", "description": "lowercase letters/digits/hyphens, unique per owner" }, "name": { "type": "string" }, "description": { "type": "string" } } } } } },
        "responses": { "201": { "description": "Created" }, "409": { "description": "HANDLE_TAKEN" } }
      }
    },
    "/api/teams/{teamId}": {
      "get": {
        "operationId": "getTeam",
        "summary": "Roster, granted documents, events, pending invites (admins)",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "OK — team with memberList[], shares[], events[], pendingInvites[] (admin only)" }, "404": { "description": "Not visible to you" } }
      },
      "patch": {
        "operationId": "updateTeam",
        "summary": "Rename, describe, archive or restore",
        "description": "Body: { name?, description?, archived? }. Handles are immutable. Archiving stops all access the team grants; teams are never deleted (archive instead).",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/api/teams/{teamId}/members": {
      "post": {
        "operationId": "addTeamMember",
        "summary": "Add a member (existing accounts only — see invites for others)",
        "description": "Body: { email | username | userId, role: 'member'|'admin' (default member), notify: true|false (default true — the new member is emailed what the team grants) }. Re-adding an existing member changes NOTHING (alreadyMember: true) — use the role endpoint to promote/demote. Response carries `granted` (documents they just gained) — relay that number.",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK — { granted, notified, alreadyMember?, note }" }, "404": { "description": "USER_NOT_FOUND — they need an account; use POST /invites instead" } }
      }
    },
    "/api/teams/{teamId}/members/{userId}": {
      "patch": {
        "operationId": "setTeamMemberRole",
        "summary": "Promote or demote a member",
        "description": "Body: { role: 'member'|'admin' }. Admins manage membership and settings; the owner is a permanent implicit admin.",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "userId", "in": "path", "required": true, "schema": { "type": "string" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
        "responses": { "200": { "description": "OK" } }
      },
      "delete": {
        "operationId": "removeTeamMember",
        "summary": "Remove a member — revokes access on EVERY document the team granted",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "userId", "in": "path", "required": true, "schema": { "type": "string" } }],
        "responses": { "200": { "description": "Removed" }, "409": { "description": "CANNOT_REMOVE_OWNER — archive the team instead" } }
      }
    },
    "/api/teams/{teamId}/invites": {
      "post": {
        "operationId": "inviteToTeam",
        "summary": "Invite someone WITHOUT an account by email",
        "description": "Body: { email, message? (personal note quoted in the email), role? }. If the address already belongs to a verified account they are ADDED directly (addedDirectly: true). Otherwise an invitation email goes out with a signup link; they join automatically when they sign up and CONFIRM that address (the inviter is emailed when it happens). A pending invite grants nothing. Re-inviting within 24h is throttled (429 INVITE_THROTTLED).",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["email"], "properties": { "email": { "type": "string" }, "message": { "type": "string" }, "role": { "type": "string" } } } } } },
        "responses": { "201": { "description": "Invitation sent — { invited, emailSent, expiresAt }" }, "200": { "description": "Added directly (account existed)" }, "429": { "description": "INVITE_THROTTLED" } }
      }
    },
    "/api/teams/{teamId}/invites/{inviteId}": {
      "delete": {
        "operationId": "revokeTeamInvite",
        "summary": "Revoke a pending invitation — the emailed link dies",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }, { "name": "inviteId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "Revoked" } }
      }
    },
    "/api/teams/{teamId}/leave": {
      "post": {
        "operationId": "leaveTeam",
        "summary": "Leave a team you are a member of (owners archive instead)",
        "parameters": [{ "name": "teamId", "in": "path", "required": true, "schema": { "type": "integer" } }],
        "responses": { "200": { "description": "Left — access through this team ended" } }
      }
    }
  }
}
