openapi: 3.1.0
info:
  title: Beem File Hosting API
  version: 1.0.0
  license:
    name: Proprietary
  description: |-
    API for uploading, listing, and managing hosted HTML pages on public.beemdata.com.

    ## Authentication

    Most endpoints accept **either** of two authentication methods:

    1. **Bearer JWT** — Auth0 token in `Authorization: Bearer <token>` (audience: `https://public.beemdata.com/api`)
    2. **API Key** — per-user key in `X-API-Key: beem_...` header (create via `POST /api-keys`)

    API key management endpoints (`/api-keys`) require JWT only.

    ## Visibility levels

    Pages are organized by visibility:
    - **public** — accessible to anyone via `https://public.beemdata.com/public/{slug}`
    - **internal** — requires CloudFront signed cookies (issued via `/sign-cookies`)
    - **protected** — password-gated, served through `/protected/{slug}`
servers:
  - url: https://public.beemdata.com
    description: Production
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Auth0 JWT with:
        - Audience: `https://public.beemdata.com/api`
        - Issuer: `https://{AUTH0_DOMAIN}/`
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Per-user API key (format: `beem_` + 64 hex chars). Create and manage keys via the `/api-keys` endpoints.
        API keys cannot manage other API keys or edit page tags."
  schemas:
    PresignRequest:
      type: object
      description: Either provide `existingKey` (update mode) or `fileName` + `visibility` (new upload mode).
      properties:
        fileName:
          type: string
          description: Original filename. Will be slugified and given a random suffix. Required for new uploads.
          examples:
            - quarterly-report.html
        visibility:
          type: string
          enum:
            - public
            - internal
            - protected
          description: Determines access control for the uploaded page. Required for new uploads.
        contentType:
          type: string
          default: text/html
          description: MIME type of the file being uploaded.
        password:
          type: string
          maxLength: 256
          description: Required when visibility is `protected`. Used to gate access to the page.
        tags:
          type: array
          items:
            type: string
          maxItems: 20
          description: Optional tags to attach to the page on upload.
          examples:
            - - report
              - Q3
        existingKey:
          type: string
          description: S3 key of an existing page to update. When set, `fileName` and `visibility` are ignored and the upload
            overwrites the existing file content. Requires ownership or `edit:pages` permission.
          examples:
            - public/quarterly-report-a1b2c3.html
    PresignResponse:
      type: object
      properties:
        uploadUrl:
          type: string
          format: uri
          description: Pre-signed S3 PUT URL. Upload your file here within 15 minutes.
        fileUrl:
          type: string
          format: uri
          description: Permanent URL where the page will be accessible after upload.
        s3Key:
          type: string
          description: The S3 object key assigned to the upload (e.g. `public/quarterly-report-a1b2c3.html`).
    PageItem:
      type: object
      properties:
        key:
          type: string
          description: S3 object key (includes visibility prefix).
          examples:
            - public/quarterly-report-a1b2c3.html
        size:
          type: integer
          description: File size in bytes.
        uploadedBy:
          type: string
          format: email
          description: Email of the user who uploaded the page.
        uploadedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the page was uploaded.
        updatedAt:
          type:
            - string
            - "null"
          format: date-time
          description: ISO 8601 timestamp of the most recent content update, or null if never updated.
        updatedBy:
          type:
            - string
            - "null"
          format: email
          description: Email of the user who last updated the page content, or null if never updated.
        contentType:
          type: string
          description: MIME type of the file.
        lastModified:
          type: string
          format: date-time
          description: Most recent modification time — equals `updatedAt` if the page has been updated, otherwise `uploadedAt`.
        thumbnail:
          type:
            - string
            - "null"
          description: Path to the rendered PNG preview (e.g. `/thumbnails/public/quarterly-report-a1b2c3.html.png`), or null if
            not yet generated. Internal/protected thumbnails require signed cookies; protected previews are blurred.
        thumbnailAt:
          type:
            - string
            - "null"
          format: date-time
          description: ISO 8601 timestamp of when the thumbnail was last rendered, or null if none. Useful as a cache-busting
            version.
        tags:
          type: array
          items:
            type: string
          description: Tags attached to the page.
    PagesResponse:
      type: object
      properties:
        pages:
          type: array
          items:
            $ref: "#/components/schemas/PageItem"
        total:
          type: integer
          description: Total number of pages matching the query.
        page:
          type: integer
          description: Current page number.
        pageSize:
          type: integer
          description: Number of items per page.
        totalPages:
          type: integer
          description: Total number of pages available.
    DeleteResponse:
      type: object
      properties:
        deleted:
          type: string
          description: The S3 key of the deleted page.
    UpdateTagsRequest:
      type: object
      required:
        - tags
      properties:
        tags:
          type: array
          items:
            type: string
            maxLength: 50
          maxItems: 20
          description: New set of tags for the page. Pass an empty array to remove all tags.
    UpdateTagsResponse:
      type: object
      properties:
        key:
          type: string
          description: The S3 key of the updated page.
        tags:
          type: array
          items:
            type: string
          description: The updated tags.
    TagsListResponse:
      type: object
      properties:
        tags:
          type: array
          items:
            type: string
          description: All unique tags across all pages, sorted alphabetically.
    SignCookiesResponse:
      type: object
      properties:
        expires:
          type: integer
          description: Unix epoch timestamp when the signed cookies expire (8 hours from issuance).
    PasswordRequest:
      type: object
      required:
        - password
      properties:
        password:
          type: string
          format: password
          maxLength: 256
          description: Password for the protected page.
    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable error message.
paths:
  /presign:
    post:
      operationId: createPresignedUrl
      summary: Get pre-signed upload URL
      description: |
        Generates a pre-signed S3 PUT URL for uploading an HTML page. The URL expires
        after 15 minutes. After uploading, the page is immediately available at `fileUrl`.

        **New upload mode:** Provide `fileName` and `visibility`. The filename is slugified
        and appended with a random 6-character hex suffix to prevent collisions.

        **Update mode:** Provide `existingKey` to replace the content of an existing page
        while keeping the same URL. Requires ownership or `edit:pages` permission.
        Original metadata (uploader, upload date, tags) is preserved.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PresignRequest"
            examples:
              public:
                summary: Upload a public page
                value:
                  fileName: quarterly-report.html
                  visibility: public
                  contentType: text/html
              withTags:
                summary: Upload with tags
                value:
                  fileName: quarterly-report.html
                  visibility: public
                  contentType: text/html
                  tags:
                    - report
                    - Q3
              protected:
                summary: Upload a password-protected page
                value:
                  fileName: confidential-memo.html
                  visibility: protected
                  contentType: text/html
                  password: s3cur3-p4ss
              update:
                summary: Update an existing page
                value:
                  existingKey: public/quarterly-report-a1b2c3.html
                  contentType: text/html
      responses:
        "200":
          description: Pre-signed URL generated successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PresignResponse"
              example:
                uploadUrl: https://s3.amazonaws.com/beem-hosting/public/quarterly-report-a1b2c3.html?X-Amz-...
                fileUrl: https://public.beemdata.com/public/quarterly-report-a1b2c3.html
                s3Key: public/quarterly-report-a1b2c3.html
        "400":
          description: Invalid request (missing fields, invalid visibility, or missing password for protected).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid credentials (no valid JWT or API key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Not authorized to update this page (update mode only).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Page not found (update mode only).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /pages:
    get:
      operationId: listPages
      summary: List hosted pages
      description: |
        Returns a paginated list of hosted pages with metadata. By default returns
        only the current user's pages, sorted newest-first.

        Set `uploadedBy=*` to list all users' pages.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      parameters:
        - name: visibility
          in: query
          description: Comma-separated list of visibilities to include.
          schema:
            type: string
            default: public,internal,protected
          examples:
            all:
              value: public,internal,protected
            publicOnly:
              value: public
        - name: uploadedBy
          in: query
          description: Filter by uploader email. Defaults to the current user. Use `*` for all users.
          schema:
            type: string
          examples:
            all:
              value: "*"
            specific:
              value: alice@beemdata.com
        - name: dateFrom
          in: query
          description: Inclusive start date (ISO 8601 date, e.g. `2025-01-01`).
          schema:
            type: string
            format: date
        - name: dateTo
          in: query
          description: Inclusive end date (ISO 8601 date, e.g. `2025-12-31`). Time is set to 23:59:59.999Z.
          schema:
            type: string
            format: date
        - name: page
          in: query
          description: Page number (1-indexed).
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: pageSize
          in: query
          description: Number of items per page.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: search
          in: query
          description: Case-insensitive substring search on the page key (URL path). Filters results in-memory.
          schema:
            type: string
          examples:
            example:
              value: quarterly
        - name: tags
          in: query
          description: Comma-separated list of tags. Only pages matching ALL specified tags are returned (AND logic).
          schema:
            type: string
          examples:
            single:
              value: report
            multiple:
              value: report,Q3
        - name: company
          in: query
          description: Filter by HubSpot company ID. Only pages associated with this company are returned.
          schema:
            type: string
          examples:
            example:
              value: "12345"
        - name: deal
          in: query
          description: Filter by HubSpot deal ID. Only pages associated with this deal are returned.
          schema:
            type: string
          examples:
            example:
              value: "67890"
      responses:
        "200":
          description: Paginated list of pages.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PagesResponse"
              example:
                pages:
                  - key: public/quarterly-report-a1b2c3.html
                    size: 45230
                    uploadedBy: alice@beemdata.com
                    uploadedAt: 2025-03-15T10:30:00.000Z
                    contentType: text/html
                    lastModified: 2025-03-15T10:30:00.000Z
                total: 42
                page: 1
                pageSize: 25
                totalPages: 2
        "400":
          description: Invalid visibility parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid credentials (no valid JWT or API key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /pages/{key}:
    delete:
      operationId: deletePage
      summary: Delete a page
      description: |
        Deletes a page from S3. The DynamoDB index record is removed automatically
        by the S3 event-driven indexer.

        **Authorization:**
        - Users with the `delete:pages` permission (Admin role) can delete any page.
        - Other users can only delete pages they uploaded.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      parameters:
        - name: key
          in: path
          required: true
          description: |
            Full S3 object key including visibility prefix (e.g. `public/my-page-a1b2c3.html`).
            The key may contain slashes.
          schema:
            type: string
          examples:
            public:
              value: public/quarterly-report-a1b2c3.html
            internal:
              value: internal/team-update-d4e5f6.html
      responses:
        "200":
          description: Page deleted successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DeleteResponse"
              example:
                deleted: public/quarterly-report-a1b2c3.html
        "400":
          description: Missing key or key not under a valid prefix.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid credentials (no valid JWT or API key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: User does not own this page and lacks `delete:pages` permission.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Page not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    patch:
      operationId: updatePage
      summary: Update page metadata (tags and/or company)
      description: |
        Updates tags and/or HubSpot company association on a page. At least one of
        `tags` or `hubspotCompany` must be provided.

        - **tags:** Pass an array to replace tags, or an empty array to remove all tags.
        - **hubspotCompany:** Pass an object with `id` and `name` to associate, or `null` to clear.

        When a company is associated, a note is created on the HubSpot company timeline.
        Requires ownership of the page or `edit:pages` permission.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      parameters:
        - name: key
          in: path
          required: true
          description: S3 key of the page (e.g. `public/quarterly-report-a1b2c3.html`).
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tags:
                  type: array
                  items:
                    type: string
                  description: Replacement tags for the page.
                hubspotCompany:
                  nullable: true
                  type: object
                  properties:
                    id:
                      type: string
                      description: HubSpot company ID
                    name:
                      type: string
                      description: Company display name
                  required:
                    - id
                    - name
                  description: HubSpot company to associate, or null to clear.
            examples:
              tags:
                summary: Update tags only
                value:
                  tags:
                    - report
                    - Q3
                    - finance
              company:
                summary: Associate a company
                value:
                  hubspotCompany:
                    id: "12345"
                    name: Acme Corp
              both:
                summary: Update both
                value:
                  tags:
                    - report
                  hubspotCompany:
                    id: "12345"
                    name: Acme Corp
              clearCompany:
                summary: Remove company association
                value:
                  hubspotCompany: null
      responses:
        "200":
          description: Page updated successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: string
                  tags:
                    type: array
                    items:
                      type: string
                  hubspotCompany:
                    nullable: true
                    type: object
                    properties:
                      id:
                        type: string
                      name:
                        type: string
              example:
                key: public/quarterly-report-a1b2c3.html
                tags:
                  - report
                  - Q3
                  - finance
                hubspotCompany:
                  id: "12345"
                  name: Acme Corp
        "400":
          description: Invalid request (missing fields or invalid tags/company).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing or invalid credentials.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Not authorized to edit this page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Page not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /tags:
    get:
      operationId: listTags
      summary: List all unique tags
      description: |
        Returns all unique tags used across all pages, sorted alphabetically.
        Results are cached for 60 seconds.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: List of tags.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TagsListResponse"
              example:
                tags:
                  - finance
                  - Q3
                  - report
        "401":
          description: Missing or invalid JWT.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /sign-cookies:
    get:
      operationId: signCookies
      summary: Get CloudFront signed cookies
      description: |
        Issues CloudFront signed cookies that grant access to restricted paths for
        8 hours. The cookies are set via `Set-Cookie` response headers.

        Scoped `Path=/` over a `/*` resource, so one call covers both internal
        pages and the gated thumbnails under `/thumbnails/internal/*` and
        `/thumbnails/protected/*`. CloudFront only enforces the policy on
        behaviors that use a trusted key group, so public paths stay open.

        After obtaining cookies, internal pages can be accessed directly at
        `https://public.beemdata.com/internal/{slug}`.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      responses:
        "200":
          description: Signed cookies issued successfully.
          headers:
            Set-Cookie:
              description: |
                Three CloudFront cookies are set: `CloudFront-Policy`, `CloudFront-Signature`,
                and `CloudFront-Key-Pair-Id`. All scoped to `Path=/; Secure; HttpOnly; SameSite=Lax`.
                They authorize both `/internal/*` pages and gated `/thumbnails/*` previews.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SignCookiesResponse"
              example:
                expires: 1710576000
        "401":
          description: Missing or invalid credentials (no valid JWT or API key).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /protected/{proxy}:
    get:
      operationId: getProtectedPage
      summary: View password-protected page
      security: []
      description: |
        Serves a password-protected page. If the user has a valid `beem-protected`
        cookie, the page HTML is returned directly. Otherwise, a password prompt
        form is displayed.

        No JWT is required — access is controlled by the page password.
      parameters:
        - name: proxy
          in: path
          required: true
          description: Page slug (e.g. `confidential-memo-a1b2c3.html`).
          schema:
            type: string
      responses:
        "200":
          description: Password prompt form or page content (if authenticated via cookie).
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Missing or invalid page path.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Page not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    post:
      operationId: validateProtectedPassword
      summary: Validate password for protected page
      security: []
      description: |
        Validates the password for a protected page. On success, returns the page
        HTML and sets a `beem-protected` cookie valid for 8 hours.
      parameters:
        - name: proxy
          in: path
          required: true
          description: Page slug (e.g. `confidential-memo-a1b2c3.html`).
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasswordRequest"
      responses:
        "200":
          description: Password correct — page HTML returned with auth cookie.
          headers:
            Set-Cookie:
              description: "`beem-protected` cookie valid for 8 hours."
              schema:
                type: string
          content:
            text/html:
              schema:
                type: string
        "400":
          description: Missing or invalid password.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: Incorrect password.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Page not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /api-keys:
    post:
      operationId: createApiKey
      summary: Create a new API key
      description: |
        Creates a new API key for the authenticated user. The plaintext key is returned
        once — store it securely. Subsequent requests show only the prefix.
        Requires JWT authentication (API keys cannot create other API keys).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  maxLength: 64
                  description: Human-readable label for the key.
                  examples:
                    - CI Pipeline
      responses:
        "201":
          description: Key created. The `key` field is shown only in this response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    type: string
                    description: Plaintext API key (shown once).
                  keyId:
                    type: string
                    description: Short identifier for revocation.
                  name:
                    type: string
                  prefix:
                    type: string
                    description: First 12 characters of the key.
                  createdAt:
                    type: string
                    format: date-time
        "400":
          description: Invalid request (missing or too-long name).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: JWT required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
    get:
      operationId: listApiKeys
      summary: List your API keys
      description: |
        Returns all API keys for the authenticated user. Only metadata is returned —
        the plaintext key is never retrievable after creation.
        Requires JWT authentication.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: List of the user's API keys.
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        keyId:
                          type: string
                          description: Short identifier for revocation.
                        name:
                          type: string
                        prefix:
                          type: string
                          description: First 12 characters of the key.
                        createdAt:
                          type: string
                          format: date-time
        "401":
          description: JWT required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /api-keys/{keyId}:
    delete:
      operationId: revokeApiKey
      summary: Revoke an API key
      description: |
        Permanently revokes an API key. The key must belong to the authenticated user.
        Requires JWT authentication (API keys cannot revoke other keys).
      security:
        - bearerAuth: []
      parameters:
        - name: keyId
          in: path
          required: true
          schema:
            type: string
          description: The short identifier of the key to revoke.
      responses:
        "200":
          description: Key revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: string
                    description: The keyId that was revoked.
        "401":
          description: JWT required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Key not found or doesn't belong to user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /hubspot/companies:
    get:
      operationId: searchHubspotCompanies
      summary: Search HubSpot companies
      description: |
        Proxies a company name search to the HubSpot CRM API. Returns up to 10 matching
        companies with their ID, name, and domain. Used by the upload SPA for the company
        autocomplete field.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      parameters:
        - name: q
          in: query
          required: true
          description: Search query (minimum 2 characters).
          schema:
            type: string
            minLength: 2
      responses:
        "200":
          description: Matching companies.
          content:
            application/json:
              schema:
                type: object
                properties:
                  companies:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        name:
                          type: string
                        domain:
                          type: string
              example:
                companies:
                  - id: "12345"
                    name: Acme Corp
                    domain: acme.com
        "400":
          description: Search query too short.
        "502":
          description: HubSpot API error.
        "503":
          description: HubSpot integration not configured.
  /hubspot/deals:
    get:
      operationId: searchHubspotDeals
      summary: Search HubSpot deals for a company
      description: |
        Returns deals associated with a given HubSpot company. Used by the upload SPA
        to let users select which deal to attach a page URL to.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      parameters:
        - name: companyId
          in: query
          required: true
          description: HubSpot company ID to find associated deals for.
          schema:
            type: string
      responses:
        "200":
          description: Deals associated with the company.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deals:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        name:
                          type: string
                        stage:
                          type: string
        "400":
          description: Missing companyId.
        "502":
          description: HubSpot API error.
        "503":
          description: HubSpot integration not configured.
tags: []
