{"openapi":"3.0.1","info":{"title":"Pagegoat API","description":"Programmatic access to Pagegoat sites. Authenticate with a Bearer access token: exchange your API key at `POST {IdP}/api/keys/token`, then send the returned JWT as `Authorization: Bearer <token>`.","version":"0.1.0"},"servers":[{"url":"https://api.pagegoat.com","description":"Generated server url"}],"security":[{"bearer-jwt":[]}],"paths":{"/api/sites/{slug}/visitors/{visitorId}/blocked":{"put":{"tags":["site-controller"],"summary":"Block or unblock one captured address","description":"A blocked address is refused at the gate and keeps its row, so the same person cannot walk back in and be captured again as new. Admin only.","operationId":"setVisitorBlocked","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"visitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"blocked","in":"query","required":true,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Visitor"}}}}}}},"/api/sites/{slug}/visibility":{"put":{"tags":["site-controller"],"summary":"Set a site's link-access mode","description":"Private, password protected, email capture, or public. Switching to `password` requires a `password` unless the site already has one; `capture` is refused unless the deployment has CAPTURE_EMAILS switched on. Setting or changing the password — and any change of mode — invalidates every gate token already issued, so everyone currently viewing has to clear the gate again. Leaving `capture` never deletes captured addresses; it only stops collecting.","operationId":"setVisibility","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetVisibilityRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Site"}}}}}}},"/api/sites/{slug}/content":{"get":{"tags":["site-controller"],"operationId":"content","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"string","format":"byte"}}}}}},"put":{"tags":["site-controller"],"summary":"Replace a site's files with a new version","description":"Multipart. Comments and access are preserved. The quota charged is the OWNER's, not the uploader's.","operationId":"reupload","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"required":["file"],"type":"object","properties":{"file":{"type":"string","format":"binary"}}}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Site"}}}}}}},"/api/workspace/members":{"get":{"tags":["workspace-controller"],"summary":"The caller's teammates and pending invitations","operationId":"list","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Workspace"}}}}}},"post":{"tags":["workspace-controller"],"summary":"Seat a teammate","description":"Requires the Pro plan and a free seat.","operationId":"add","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddWorkspaceMemberRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WorkspaceMember"}}}}}}},"/api/workspace/invites":{"post":{"tags":["workspace-controller"],"summary":"Invite an email to be a teammate","description":"The fallback when the address has no account yet.","operationId":"invite","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInviteRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WorkspaceInvite"}}}}}}},"/api/sites":{"get":{"tags":["site-controller"],"summary":"List every site the caller can access","description":"Unpaginated and unbounded — cost grows with how many sites exist. Prefer `/api/sites/page` for anything user-facing.","operationId":"list_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Site"}}}}}}},"post":{"tags":["site-controller"],"summary":"Create a site from an uploaded file or ZIP","description":"Multipart. The caller becomes owner and the bytes count against their plan — unless they are publishing into a workspace they are a teammate of, in which case the workspace owner pays.","operationId":"upload","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadSiteForm"}}}},"responses":{"413":{"description":"Larger than the per-upload cap."},"201":{"description":"The created site.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Site"}}}},"402":{"description":"No paid plan, or out of quota."}}}},"/api/sites/{slug}/visitors/{visitorId}/resend":{"post":{"tags":["site-controller"],"summary":"Mail one captured address a fresh code","description":"Admin only.","operationId":"resendVisitorCode","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"visitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Visitor"}}}}}}},"/api/sites/{slug}/invites":{"post":{"tags":["site-controller"],"summary":"Invite an email with no account yet","description":"Becomes a real grant on that person's first verified sign-in.","operationId":"invite_1","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSiteInviteRequest"}}},"required":true},"responses":{"201":{"description":"The pending invitation.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SiteInvite"}}}},"400":{"description":"Missing email, or an unknown role."},"404":{"description":"No such site, or not an admin on it."}}}},"/api/sites/{slug}/gate":{"get":{"tags":["site-controller"],"summary":"What a visitor must clear to open this site","description":"Answers only for a gated site. Everything here is shown to the visitor at the wall, so nothing on it is private.","operationId":"gate","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The gate's rules.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/GateChallenge"}}}},"404":{"description":"No such site, or it isn't gated."}}},"post":{"tags":["site-controller"],"summary":"Unlock a password-protected site","description":"Returns a pass to send as the `X-Pagegoat-Gate` header on later requests. Wrong passwords are rate limited per site and client address.","operationId":"unlock","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UnlockRequest"}}},"required":true},"responses":{"401":{"description":"Wrong password."},"200":{"description":"Correct — here is the pass.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Gate"}}}},"404":{"description":"No such site, or it isn't password protected."},"429":{"description":"Too many wrong tries; wait it out."}}}},"/api/sites/{slug}/comments":{"get":{"tags":["site-controller"],"summary":"List a site's comments","description":"The whole thread, roots and replies, oldest first. Any role that can view the site can read them, including an anonymous visitor to a public site.","operationId":"listComments","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The thread, oldest first.","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Comment"}}}}}}},"post":{"tags":["site-controller"],"summary":"Create a comment or a reply","operationId":"comment","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCommentRequest"}}}},"responses":{"400":{"description":"Blank body, or a reply to a reply."},"201":{"description":"The created comment.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Comment"}}}},"423":{"description":"Commenting is disabled on this site.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"403":{"description":"Commenter role required."}}}},"/api/sites/{slug}/comments/{commentId}/resolve":{"post":{"tags":["site-controller"],"summary":"Resolve a thread","description":"Moves it open -> resolved and records you as the resolver. Any commenter may resolve any thread, not only its author.","operationId":"resolveComment","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"404":{"description":"No such comment on this site."},"200":{"description":"The resolved comment.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Comment"}}}}}}},"/api/sites/{slug}/comments/{commentId}/reopen":{"post":{"tags":["site-controller"],"summary":"Reopen a resolved thread","description":"Moves it resolved -> open and clears the resolver.","operationId":"reopenComment","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"The reopened comment.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Comment"}}}},"404":{"description":"No such comment on this site."}}}},"/api/sites/{slug}/capture/verify":{"post":{"tags":["site-controller"],"summary":"Confirm a code and get the pass","description":"Returns a pass to send as the `X-Pagegoat-Gate` header on later requests. Its lifetime is the site's re-verify window.","operationId":"captureVerify","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureVerifyRequest"}}},"required":true},"responses":{"401":{"description":"Wrong, expired or already-used code."},"200":{"description":"Correct — here is the pass.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Gate"}}}},"429":{"description":"Too many tries; wait it out."},"403":{"description":"This address is blocked."},"404":{"description":"No such site, or it isn't capturing emails."}}}},"/api/sites/{slug}/capture/request":{"post":{"tags":["site-controller"],"summary":"Ask for a code at a site's email gate","description":"Mails a six-digit code. On a site set to \"email only, no code\" it returns the gate pass directly and there is no second step.","operationId":"captureRequest","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CaptureRequest"}}},"required":true},"responses":{"429":{"description":"Too many requests; wait it out."},"200":{"description":"A code is on its way — or, with no code required, here is the pass.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CaptureChallenge"}}}},"403":{"description":"Blocked, or a domain the site doesn't accept."},"400":{"description":"That isn't an email address."},"404":{"description":"No such site, or it isn't capturing emails."}}}},"/api/sites/{slug}/access":{"get":{"tags":["site-controller"],"summary":"Who can reach this site","description":"Admins only.","operationId":"listAccess","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SiteAccess"}}}}}},"post":{"tags":["site-controller"],"summary":"Grant a user, org or group a role on this site","operationId":"addAccess","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddAccessRequest"}}},"required":true},"responses":{"400":{"description":"Missing principalType/principalId, or an unknown role."},"201":{"description":"The created grant.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Grant"}}}},"404":{"description":"No such site, or not an admin on it."}}}},"/api/projects":{"get":{"tags":["project-controller"],"summary":"List every project visible to the caller","description":"A project is visible if you own it or can access at least one of its member pages. Unpaginated — prefer `/api/projects/page`.","operationId":"list_2","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Project"}}}}}}},"post":{"tags":["project-controller"],"summary":"Create a project","description":"Actor-gated: this consumes the CALLER's plan, unlike acting on a site someone shared with them.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNameRequest"}}}},"responses":{"201":{"description":"The created project.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Project"}}}},"402":{"description":"No paid plan, or the plan's project ceiling is already reached."}}}},"/api/sites/{slug}":{"get":{"tags":["site-controller"],"summary":"Site detail","description":"Readable by anyone with viewer access, including an anonymous visitor to a public site.","operationId":"get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"404":{"description":"No such site, or no access to it."},"200":{"description":"The site.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Site"}}}}}},"delete":{"tags":["site-controller"],"summary":"Delete a site","description":"Admins only. A suspended site refuses until it is unsuspended.","operationId":"delete","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Deleted."},"404":{"description":"No such site, or not an admin on it."},"451":{"description":"Suspended pending review."}}},"patch":{"tags":["site-controller"],"summary":"Update a site's editable metadata","operationId":"update","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSiteRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Site"}}}}}}},"/api/sites/{slug}/comments/{commentId}":{"delete":{"tags":["site-controller"],"summary":"Delete a comment","description":"Your own, or any comment on the site if you are a site admin. Deleting a thread root deletes its replies.","operationId":"deleteComment","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"404":{"description":"No such comment on this site."},"403":{"description":"Not the author, and not an admin."},"204":{"description":"Deleted."}}},"patch":{"tags":["site-controller"],"summary":"Edit your own comment","description":"Authors only. The anchor is not editable.","operationId":"editComment","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCommentRequest"}}},"required":true},"responses":{"404":{"description":"No such comment on this site."},"200":{"description":"The updated comment.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Comment"}}}},"403":{"description":"Not the author."}}}},"/api/projects/{id}":{"get":{"tags":["project-controller"],"summary":"Project detail plus the member pages the caller can reach","operationId":"get_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProjectDetail"}}}}}},"delete":{"tags":["project-controller"],"summary":"Delete a project","description":"Owner only. Member pages are unfiled, never deleted.","operationId":"delete_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"403":{"description":"Not the owner."},"404":{"description":"No such project."}}},"patch":{"tags":["project-controller"],"summary":"Rename a project","description":"Owner only.","operationId":"update_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectNameRequest"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Project"}}}}}}},"/health":{"get":{"tags":["me-controller"],"summary":"Liveness check","operationId":"health","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/api/workspace/memberships":{"get":{"tags":["workspace-controller"],"summary":"Workspaces the caller may publish into","operationId":"memberships","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Membership"}}}}}}}},"/api/usage":{"get":{"tags":["usage-controller"],"summary":"The caller's consumption against their plan limits","operationId":"usage","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Usage"}}}}}}},"/api/sites/{slug}/visitors":{"get":{"tags":["site-controller"],"summary":"Captured email addresses for this site","description":"Paged, searchable and filterable. Admin only.","operationId":"visitors","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"query","in":"query","description":"Substring of the address. Matches the domain too, since an address contains its own domain.","required":false,"schema":{"type":"string"}},{"name":"status","in":"query","description":"pending | verified | blocked. Omit for all.","required":false,"schema":{"type":"string"}},{"name":"sort","in":"query","description":"newest | visits | email","required":false,"schema":{"type":"string","default":"newest"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":25}}],"responses":{"200":{"description":"One page, plus the list's totals.","content":{"*/*":{"schema":{"$ref":"#/components/schemas/VisitorPage"}}}},"404":{"description":"No such site, or not an admin on it."}}},"delete":{"tags":["site-controller"],"summary":"Forget every captured address on this site","description":"The only bulk erase. Changing the link-access mode never does this. Admin only.","operationId":"deleteAllVisitors","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/sites/{slug}/visitors.csv":{"get":{"tags":["site-controller"],"summary":"Export the captured list as CSV","description":"Admin only.","operationId":"visitorsCsv","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"text/csv":{"schema":{"type":"string"}}}}}}},"/api/sites/{slug}/versions":{"get":{"tags":["site-controller"],"summary":"A site's deploy history","description":"A log, not restorable snapshots — only the current version's bytes are kept on disk.","operationId":"versions","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SiteVersion"}}}}}}}},"/api/sites/slug-available":{"get":{"tags":["site-controller"],"summary":"Check whether a slug is free","description":"Requires a token: slugs are public URLs, but an open endpoint here would be a free \"does this site exist?\" oracle.","operationId":"slugAvailable","parameters":[{"name":"slug","in":"query","required":true,"schema":{"type":"string"}},{"name":"for","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SlugAvailability"}}}}}}},"/api/sites/page":{"get":{"tags":["site-controller"],"summary":"One page of the caller's sites","description":"Seek-paginated and searched server-side. Echo `nextCursor` back as `cursor` for the following page.","operationId":"page","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":12}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"recent"}},{"name":"tab","in":"query","required":false,"schema":{"type":"string","default":"all"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SitePage"}}}}}}},"/api/sites/counts":{"get":{"tags":["site-controller"],"summary":"Totals for the caller's sites","operationId":"counts","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SiteCounts"}}}}}}},"/api/projects/page":{"get":{"tags":["project-controller"],"summary":"One page of the caller's projects","operationId":"page_1","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":12}},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}},{"name":"q","in":"query","required":false,"schema":{"type":"string"}},{"name":"tab","in":"query","required":false,"schema":{"type":"string","default":"all"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProjectPage"}}}}}}},"/api/projects/counts":{"get":{"tags":["project-controller"],"summary":"Totals for the caller's projects","operationId":"counts_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ProjectCounts"}}}}}}},"/api/me":{"get":{"tags":["me-controller"],"summary":"Echo the caller's verified token claims","description":"The quickest way to check that a key exchange produced a token this API actually accepts, and that the issuer matches.","operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/Me"}}}}}}},"/api/workspace/members/{id}":{"delete":{"tags":["workspace-controller"],"summary":"Remove a teammate","description":"Sites they created stay with the workspace — they were never the owner.","operationId":"remove","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/workspace/invites/{id}":{"delete":{"tags":["workspace-controller"],"summary":"Withdraw a pending invitation, freeing its seat","operationId":"revokeInvite","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/sites/{slug}/visitors/{visitorId}":{"delete":{"tags":["site-controller"],"summary":"Forget one captured address","description":"Admin only.","operationId":"deleteVisitor","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"visitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK"}}}},"/api/sites/{slug}/invites/{inviteId}":{"delete":{"tags":["site-controller"],"summary":"Withdraw a pending site invitation","operationId":"revokeInvite_1","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"inviteId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"404":{"description":"No such invitation, or no such site."},"204":{"description":"Withdrawn."}}}},"/api/sites/{slug}/access/{grantId}":{"delete":{"tags":["site-controller"],"summary":"Revoke a grant on this site","operationId":"revokeAccess","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"grantId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Revoked."},"404":{"description":"No such site, or not an admin on it."}}}},"/api/sites/{slug}/assets/{path}":{"get":{"tags":["site-controller"],"summary":"Serve one file of a multi-file site","description":"The path after `/assets/` is the file's path relative to the version root, so it may contain slashes: `/api/sites/my-site/assets/styles/main.css`. Viewer role or better; public sites serve without a token.","operationId":"asset","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"path","in":"path","description":"The file's path relative to the version root. May contain slashes: `styles/main.css` is served at `/api/sites/{slug}/assets/styles/main.css`.","required":true,"schema":{"type":"string"},"example":"styles/main.css"}],"responses":{"404":{"description":"No such site, no access to it, or no such file in the current version."},"200":{"description":"The file, typed by its extension.","content":{"*/*":{"schema":{"type":"string","format":"byte"}}}}}}}},"components":{"schemas":{"Visitor":{"required":["email","firstSeenAt","id","status"],"type":"object","properties":{"id":{"type":"string","description":"Row id, for the row actions.","format":"uuid"},"email":{"type":"string","description":"The address, lowercased.","example":"dana.k@acme.io"},"status":{"type":"string","description":"`verified` got in; `pending` was sent a code and never used it; `blocked` was refused by an admin.","example":"verified","enum":["pending","verified","blocked"]},"firstSeenAt":{"type":"string","description":"First time this address was offered at the gate.","format":"date-time"}},"description":"An email address captured at a site's email gate."},"SetVisibilityRequest":{"required":["visibility"],"type":"object","properties":{"visibility":{"type":"string","description":"Required.","example":"password","enum":["private","password","capture","public"]},"publicRole":{"type":"string","description":"Role for anyone who gets in via the link. Meaningful for password, capture and public; ignored (and cleared) for private.","nullable":true,"example":"viewer","enum":["viewer","commenter"]},"password":{"type":"string","description":"New link password. Required when switching to password mode and the site has none; omit to keep the current one. Setting it signs out everyone currently viewing. Max 128 characters.","nullable":true,"example":"mR7k-qP2w-vT9x"},"captureOtp":{"type":"boolean","description":"Whether a capture visitor must confirm a six-digit code. False is \"email only, no code\". Only read when visibility is `capture`; absent keeps the current setting.","nullable":true,"example":true},"captureReverifyDays":{"type":"integer","description":"How many days a confirmed visitor stays confirmed. 0 means every visit re-verifies. Only read when visibility is `capture`.","format":"int32","nullable":true,"example":30},"captureDomains":{"type":"string","description":"Comma- or space-separated domains a visitor's address must be at; empty for any domain. Subdomains of a listed domain are accepted. Only read when visibility is `capture`.","nullable":true,"example":"acme.com, northloop.co"}},"description":"Set a site's link-access mode: private, password protected, email capture, or public."},"Site":{"required":["captureOtp","captureReverifyDays","commentsEnabled","currentVersion","hiddenFromSearch","name","passwordSet","reason","role","slug","updatedAt","visibility","visitorCount"],"type":"object","properties":{"slug":{"type":"string","description":"URL-safe identifier; the site's public path.","example":"myfirstproject-11"},"name":{"type":"string","description":"Display name.","example":"My First Project"},"role":{"type":"string","description":"The caller's effective role on this site.","example":"admin","enum":["viewer","commenter","admin"]},"reason":{"type":"string","description":"Why the caller has that role — ownership, a grant, or public access.","example":"owner"},"commentsEnabled":{"type":"boolean","description":"Whether new comments may be created.","example":true},"visibility":{"type":"string","description":"Link-access mode. `password` means a visitor must enter the site's password before anything is served; `capture` means they must confirm an email address.","example":"public","enum":["private","password","capture","public"]},"publicRole":{"type":"string","description":"Role granted to anyone who gets in via the link — public, or password or capture once cleared.","nullable":true,"enum":["viewer","commenter"]},"passwordSet":{"type":"boolean","description":"Whether a link password is set. The password itself is never returned.","example":false},"hiddenFromSearch":{"type":"boolean","description":"Advisory `noindex` on the viewer page. NOT access control.","example":false},"captureOtp":{"type":"boolean","description":"Capture mode: whether visitors must confirm a six-digit code.","example":true},"captureReverifyDays":{"type":"integer","description":"Capture mode: days a confirmed visitor stays confirmed; 0 means every visit.","format":"int32","example":30},"captureDomains":{"type":"string","description":"Capture mode: comma-separated domains the gate accepts, or null for any.","nullable":true,"example":"acme.com,northloop.co"},"visitorCount":{"type":"integer","description":"Addresses captured on this site, ever. The Visitors tab exists for as long as this is above zero, whatever the current mode.","format":"int64","example":128},"currentVersion":{"type":"integer","description":"Version currently served. Incremented by each re-upload.","format":"int32","example":2},"updatedAt":{"type":"string","description":"When the site was last changed (ISO-8601, UTC).","format":"date-time"},"projectId":{"type":"string","description":"Owning project, or null when unfiled.","format":"uuid","nullable":true},"suspendedAt":{"type":"string","description":"When an operator suspended this site, or null. A suspended site is not served to visitors and cannot be edited or deleted until it is restored.","format":"date-time","nullable":true},"suspensionReason":{"type":"string","description":"Why it was suspended, in the operator's words. Only returned to a site admin — the owner is who this is written for.","nullable":true},"owned":{"type":"boolean","description":"True when the caller owns this site. Only on list endpoints.","nullable":true},"commentCount":{"type":"integer","description":"Total comments on the site. Only on list endpoints.","format":"int64","nullable":true},"collaborators":{"type":"array","description":"Owner plus direct user grants, deduped and capped at 5, for avatar chips. Only on list endpoints.","nullable":true,"items":{"type":"string","description":"A token `sub`."}}},"description":"A site, with the calling user's effective role on it."},"AddWorkspaceMemberRequest":{"required":["memberSub"],"type":"object","properties":{"memberSub":{"type":"string","description":"Token `sub` of the teammate. Resolve an email to a sub against the auth server first; use `POST /api/workspace/invites` when there is no account yet."}},"description":"Seat a teammate by their token `sub`."},"WorkspaceMember":{"required":["id","memberSub"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"memberSub":{"type":"string","description":"Token `sub` of the teammate. Matches how site grants identify people."},"createdAt":{"type":"string","description":"ISO-8601 instant, or null on rows written before this was recorded.","nullable":true}},"description":"Someone allowed to publish into this workspace."},"CreateWorkspaceInviteRequest":{"required":["email"],"type":"object","properties":{"email":{"type":"string","example":"teammate@example.com"}},"description":"Invite an email to be a teammate."},"WorkspaceInvite":{"required":["email","id"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","example":"teammate@example.com"},"expiresAt":{"type":"string","description":"ISO-8601 instant.","nullable":true}},"description":"A pending teammate invitation."},"UploadSiteForm":{"required":["file","title"],"type":"object","properties":{"title":{"type":"string","description":"Display name. Required.","example":"My First Project"},"slug":{"type":"string","description":"Preferred slug. Derived from the title when omitted, and de-duplicated if taken.","nullable":true,"example":"my-first-project"},"visibility":{"type":"string","description":"Defaults to private. Password protection is turned on afterwards, from the site's Sharing settings.","nullable":true,"enum":["public","private"]},"publicRole":{"type":"string","description":"Role for anyone with the link. Only meaningful when going public.","nullable":true,"enum":["viewer","commenter"]},"file":{"type":"string","description":"A single HTML file, or a ZIP of a multi-file site.","format":"binary"}},"description":"Multipart form for creating a site."},"CreateSiteInviteRequest":{"required":["email","role"],"type":"object","properties":{"email":{"type":"string","example":"reviewer@example.com"},"role":{"type":"string","example":"commenter","enum":["viewer","commenter","admin"]}},"description":"Invite an email to a site."},"SiteInvite":{"required":["email","expiresAt","id","role"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"email":{"type":"string","description":"Invited email, stored lowercased.","example":"reviewer@example.com"},"role":{"type":"string","example":"commenter","enum":["viewer","commenter","admin"]},"expiresAt":{"type":"string","description":"Claimable only before this instant; afterwards it is dropped.","format":"date-time"}},"description":"A pending share to someone without an account yet."},"UnlockRequest":{"required":["password"],"type":"object","properties":{"password":{"type":"string","description":"Required.","example":"mR7k-qP2w-vT9x"}},"description":"Try a password-protected site's link password."},"Gate":{"required":["expiresAt","token"],"type":"object","properties":{"token":{"type":"string","description":"Present as the X-Pagegoat-Gate header."},"expiresAt":{"type":"string","description":"When the pass stops working.","format":"date-time","example":"2026-08-13T04:00:00Z"}},"description":"A short-lived pass earned by entering a site's password."},"CommentAnchor":{"required":["text"],"type":"object","properties":{"text":{"maxLength":300,"type":"string","description":"The selected text, whitespace-normalized. The primary selector.","example":"Grow Faster With Smarter Marketing"},"prefix":{"type":"string","description":"Roughly 48 characters of page text immediately before the selection, used to tell repeated quotes apart.","nullable":true,"example":"Welcome to NovaFlow. "},"suffix":{"type":"string","description":"Roughly 48 characters immediately after the selection.","nullable":true,"example":" Launch campaigns, reach more customers"},"start":{"type":"string","description":"CSS path to the element holding the selection's first character.","nullable":true,"example":"body > section:nth-of-type(2) > div:nth-of-type(2) > p:nth-of-type(1)"},"startOffset":{"type":"integer","description":"Offset of that character within the start element's own text.","format":"int32","nullable":true,"example":36},"end":{"type":"string","description":"CSS path to the element holding the selection's last character.","nullable":true,"example":"body > section:nth-of-type(2) > div:nth-of-type(2) > p:nth-of-type(1)"},"endOffset":{"type":"integer","description":"Offset one past that character, within the end element's own text.","format":"int32","nullable":true,"example":50},"page":{"type":"string","description":"Page within a multi-file site: \"/\" for the entrypoint, otherwise a path relative to the site root.","nullable":true,"example":"/"},"version":{"type":"integer","description":"Site version the selection was made against, so a stale anchor can say which version it described.","format":"int32","nullable":true,"example":1}},"description":"Where on the page a comment was left. Several redundant descriptions of one spot, because each survives a different kind of edit. Resolved in the browser, never by the API. All fields are optional; `text` alone is a usable anchor.","nullable":true},"CreateCommentRequest":{"required":["anchor","body"],"type":"object","properties":{"body":{"type":"string","description":"The comment text. Required; blank is rejected with 400.","example":"Can we soften this headline?"},"anchorText":{"type":"string","description":"The selected text, when you have no full anchor to send. Ignored if `anchor.text` is present.","nullable":true,"example":"Grow Faster With Smarter Marketing"},"anchor":{"$ref":"#/components/schemas/CommentAnchor"},"parentId":{"type":"string","description":"Thread root, to post a reply. Omit for a top-level comment. Replying to a reply is rejected with 400.","format":"uuid","nullable":true}},"description":"A new comment, or a reply when `parentId` is set. Supply `anchor` to record where on the page the comment belongs; `anchorText` alone is accepted for compatibility and is treated as a quote-only anchor."},"Comment":{"required":["anchor","authorSub","body","createdAt","edited","id","status","updatedAt"],"type":"object","properties":{"id":{"type":"string","description":"Comment id.","format":"uuid","example":"3f2a9c10-5b6d-4e21-9f80-1c2d3e4f5a6b"},"parentId":{"type":"string","description":"Thread root for a reply, or null for a top-level comment.","format":"uuid","nullable":true},"anchorText":{"type":"string","description":"The selected text this comment is anchored to. Duplicated from `anchor.text` as a flat convenience field, and the only anchor data that exists on comments created before anchoring was added.","nullable":true,"example":"Grow Faster With Smarter Marketing"},"anchor":{"$ref":"#/components/schemas/CommentAnchor"},"body":{"type":"string","description":"The comment text.","example":"Can we soften this headline?"},"authorSub":{"type":"string","description":"Subject claim of the author's token.","example":"user_01H8XG..."},"status":{"type":"string","description":"Thread status.","example":"open","enum":["open","resolved"]},"resolvedBy":{"type":"string","description":"Subject of whoever resolved the thread, or null while it is open.","nullable":true},"edited":{"type":"boolean","description":"True once the author has edited the body.","example":false},"createdAt":{"type":"string","description":"When the comment was created (ISO-8601, UTC).","format":"date-time","example":"2026-08-09T15:04:05.123456Z"},"updatedAt":{"type":"string","description":"When it was last changed (ISO-8601, UTC).","format":"date-time","example":"2026-08-09T15:04:05.123456Z"}},"description":"A review comment on a site. A reply is a comment whose `parentId` points at the thread root; replies are one level deep and never anchored."},"ApiError":{"required":["error"],"type":"object","properties":{"error":{"type":"string","description":"Human-readable reason.","example":"Commenting is disabled on this site"}},"description":"A failure the API reports with an explanation."},"CaptureVerifyRequest":{"required":["code","email"],"type":"object","properties":{"email":{"type":"string","description":"The address the code was sent to.","example":"dana.k@acme.io"},"code":{"type":"string","description":"The six digits from the email.","example":"472913"}},"description":"Confirm a code mailed to a visitor."},"CaptureRequest":{"required":["email"],"type":"object","properties":{"email":{"type":"string","description":"Required.","example":"dana.k@acme.io"}},"description":"Ask for a code (or, on a site that requires none, for entry) at an email gate."},"CaptureChallenge":{"required":["codeSent","expiresInMinutes"],"type":"object","properties":{"codeSent":{"type":"boolean","description":"True when a six-digit code was mailed and `POST /capture/verify` is the next step.","example":true},"token":{"type":"string","description":"Present only when `codeSent` is false: the gate pass, ready to send as the X-Pagegoat-Gate header.","nullable":true},"expiresAt":{"type":"string","description":"When that pass stops working. Null alongside a null token.","format":"date-time","nullable":true},"expiresInMinutes":{"type":"integer","description":"Minutes the mailed code stays valid, so the visitor can be told.","format":"int64","example":10}},"description":"Either a code is on its way, or — on a site that asks for none — the pass."},"AddAccessRequest":{"required":["principalId","principalType","role"],"type":"object","properties":{"principalType":{"type":"string","example":"user","enum":["user","org","group"]},"principalId":{"type":"string","description":"A token `sub`, an organization id, or a group name, per `principalType`."},"role":{"type":"string","example":"commenter","enum":["viewer","commenter","admin"]}},"description":"Grant someone a role on a site."},"Grant":{"required":["id","principalId","principalType","role"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"principalType":{"type":"string","description":"What kind of principal `principalId` names.","example":"user","enum":["user","org","group"]},"principalId":{"type":"string","description":"A token `sub`, an organization id, or a group name, per `principalType`."},"role":{"type":"string","example":"commenter","enum":["viewer","commenter","admin"]}},"description":"A role granted to a user, org or group on a site."},"ProjectNameRequest":{"type":"object","properties":{"name":{"type":"string","description":"Blank or omitted falls back to a generated default on create.","nullable":true,"example":"Q3 Launch"}},"description":"A project's name."},"Project":{"required":["id","memberCount","name","owned","ownerSub","siteCount","updatedAt"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string","example":"Q3 Launch"},"ownerSub":{"type":"string","description":"Token `sub` of the project owner."},"owned":{"type":"boolean","description":"True when the caller is that owner."},"siteCount":{"type":"integer","description":"Member pages the CALLER can access, not the project's total.","format":"int32","example":4},"memberCount":{"type":"integer","description":"Distinct people involved: the project owner plus every member page's owner.","format":"int32","example":2},"updatedAt":{"type":"string","format":"date-time"}},"description":"A project, as listed."},"UpdateSiteRequest":{"type":"object","properties":{"name":{"type":"string","description":"New display name.","nullable":true,"example":"My First Project"},"slug":{"type":"string","description":"New slug. Rejected if taken or malformed.","nullable":true,"example":"my-first-project"},"commentsEnabled":{"type":"boolean","description":"Allow new comments.","nullable":true},"hiddenFromSearch":{"type":"boolean","description":"Emit `noindex` on the viewer page. Advisory, not access control.","nullable":true},"projectId":{"type":"string","description":"Project to file this site under. Send null to unfile it; omit the field entirely to leave it where it is.","format":"uuid","nullable":true}},"description":"Editable site metadata. Omit any field you are not changing."},"UpdateCommentRequest":{"required":["body"],"type":"object","properties":{"body":{"type":"string","description":"The replacement text. Required; blank is rejected with 400.","example":"Can we soften this headline a little?"}},"description":"New text for a comment you authored."},"Health":{"required":["status"],"type":"object","properties":{"status":{"type":"string","example":"ok"}},"description":"Liveness check."},"Membership":{"required":["id","ownerSub"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"ownerSub":{"type":"string","description":"Token `sub` of the account that pays for what the caller publishes."}},"description":"A workspace the caller can publish into."},"Workspace":{"required":["enabled","invites","limit","members","used"],"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether the caller's plan includes teammates at all."},"limit":{"type":"integer","description":"Seats the plan allows. -1 means unlimited.","format":"int32","example":5},"used":{"type":"integer","description":"Seats spent: members PLUS pending invites, since an outstanding invite has already promised a seat away.","format":"int32","example":3},"members":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMember"}},"invites":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvite"}}},"description":"Teammates and pending invitations, with seat usage."},"Usage":{"required":["projects","projectsLimit","sites","storageBytes","storageLimitBytes","uploadLimitBytes"],"type":"object","properties":{"storageBytes":{"type":"integer","description":"Measured bytes across every site the caller owns.","format":"int64"},"storageLimitBytes":{"type":"integer","description":"Storage the plan allows, or -1 for unlimited.","format":"int64"},"uploadLimitBytes":{"type":"integer","description":"Largest single upload the plan accepts, or -1 for unlimited.","format":"int64"},"sites":{"type":"integer","description":"Sites the caller owns.","format":"int64"},"projects":{"type":"integer","description":"Projects the caller owns.","format":"int64"},"projectsLimit":{"type":"integer","description":"Projects the plan allows, or -1 for unlimited.","format":"int32"}},"description":"Measured consumption against plan limits. Limits use -1 for unlimited."},"VisitorPage":{"required":["items","lastSevenDays","matching","page","pending","size","total","verified"],"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Visitor"}},"page":{"type":"integer","description":"Zero-based page index.","format":"int32","example":0},"size":{"type":"integer","description":"Rows per page.","format":"int32","example":25},"matching":{"type":"integer","description":"Rows matching the current filters, across all pages.","format":"int64","example":128},"total":{"type":"integer","description":"Every address captured on this site, ignoring the filters.","format":"int64","example":128},"verified":{"type":"integer","description":"How many have confirmed a code at least once.","format":"int64","example":121},"pending":{"type":"integer","description":"Sent a code, never used it.","format":"int64","example":7},"lastSevenDays":{"type":"integer","description":"First seen in the last seven days.","format":"int64","example":19}},"description":"A page of captured visitors and the list's totals."},"SiteVersion":{"required":["createdAt","uploadedBy","version"],"type":"object","properties":{"version":{"type":"integer","description":"Version number, starting at 1.","format":"int32","example":2},"uploadedBy":{"type":"string","description":"Token `sub` of whoever uploaded it."},"createdAt":{"type":"string","description":"When it was deployed.","format":"date-time"}},"description":"One upload in a site's deploy history."},"GateChallenge":{"required":["allowedDomains","mode","name","otpRequired","reverifyDays"],"type":"object","properties":{"mode":{"type":"string","description":"Which wall to draw.","example":"capture","enum":["password","capture"]},"name":{"type":"string","description":"The site's display name, for the wall's copy.","example":"Q3 Board Deck"},"otpRequired":{"type":"boolean","description":"Capture only: whether a six-digit code follows the address.","example":true},"allowedDomains":{"type":"array","description":"Capture only: domains the gate accepts, or empty for any. Shown to the visitor so a refusal is predictable rather than a surprise.","items":{"type":"string","description":"Capture only: domains the gate accepts, or empty for any. Shown to the visitor so a refusal is predictable rather than a surprise."}},"reverifyDays":{"type":"integer","description":"Capture only: days a confirmed visitor stays confirmed. 0 means every visit re-verifies.","format":"int32","example":30}},"description":"What a visitor must clear to open a gated site."},"SiteAccess":{"required":["grants","invites","ownerSub","passwordSet","slug","visibility"],"type":"object","properties":{"slug":{"type":"string"},"ownerSub":{"type":"string","description":"Token `sub` of the owner. Always an admin, never listed as a grant."},"visibility":{"type":"string","description":"Link-access mode governing everyone who has the URL. Named grants below apply on top of it and always win.","example":"private","enum":["private","password","capture","public"]},"publicRole":{"type":"string","description":"Role anyone who gets in via the link receives.","nullable":true,"enum":["viewer","commenter"]},"passwordSet":{"type":"boolean","description":"Whether a link password is set. The password itself is never returned.","example":false},"grants":{"type":"array","items":{"$ref":"#/components/schemas/Grant"}},"invites":{"type":"array","items":{"$ref":"#/components/schemas/SiteInvite"}}},"description":"The full sharing state of a site."},"SlugAvailability":{"required":["available","slug","valid"],"type":"object","properties":{"slug":{"type":"string","description":"The slug as normalised by the server.","example":"my-first-project"},"valid":{"type":"boolean","description":"False when the slug breaks the format rules, regardless of who holds it.","example":true},"available":{"type":"boolean","description":"True when no other site holds it. A site's own slug reads as available to its admins, so renaming to the current value is not reported as a clash.","example":false},"suggestion":{"type":"string","description":"A free alternative, when the requested one is taken.","nullable":true,"example":"my-first-project-2"}},"description":"Whether a slug is well-formed and unclaimed, with a suggestion when it is not."},"SitePage":{"required":["items"],"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Site"}},"nextCursor":{"type":"string","description":"Opaque `<sortValue>|<id>` cursor. Null when there are no more pages.","nullable":true,"example":"2026-08-09T15:04:05.123456Z|3f2a9c10-5b6d-4e21-9f80-1c2d3e4f5a6b"}},"description":"A page of sites plus the cursor for the next one."},"SiteCounts":{"required":["owned","shared","total"],"type":"object","properties":{"owned":{"type":"integer","description":"Sites the caller owns.","format":"int64","example":12},"shared":{"type":"integer","description":"Sites shared with the caller by someone else.","format":"int64","example":3},"total":{"type":"integer","description":"owned + shared.","format":"int64","example":15}},"description":"How many sites the caller owns and has shared with them."},"ProjectDetail":{"required":["createdAt","id","memberCount","name","owned","ownerSub","siteCount","sites","updatedAt"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"ownerSub":{"type":"string"},"owned":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"siteCount":{"type":"integer","format":"int32"},"memberCount":{"type":"integer","format":"int32"},"sites":{"type":"array","items":{"$ref":"#/components/schemas/ProjectSite"}}},"description":"A project and its accessible member pages."},"ProjectSite":{"required":["currentVersion","name","ownerSub","role","slug","updatedAt","visibility"],"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"ownerSub":{"type":"string"},"role":{"type":"string","description":"The caller's effective role on this page.","enum":["viewer","commenter","admin"]},"visibility":{"type":"string","enum":["private","password","capture","public"]},"currentVersion":{"type":"integer","format":"int32"},"updatedAt":{"type":"string","format":"date-time"}},"description":"A member page, as listed on a project's detail page."},"ProjectCard":{"required":["collaborators","commentCount","createdAt","id","lastActivityAt","memberCount","name","owned","ownerSub","recentSites","siteCount","updatedAt"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"ownerSub":{"type":"string"},"owned":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"siteCount":{"type":"integer","description":"Member pages the caller can access. The true total, so the card can say \"+N more pages\" beyond the few in `recentSites`.","format":"int32"},"memberCount":{"type":"integer","format":"int32"},"commentCount":{"type":"integer","description":"Comments summed across every member page the caller can see.","format":"int64"},"lastActivityAt":{"type":"string","description":"Newest member page the caller can see, falling back to the project's own timestamp when they can see none.","format":"date-time"},"collaborators":{"type":"array","description":"Owner plus direct user grants across every visible member page, deduped and capped at 8.","items":{"type":"string","description":"Owner plus direct user grants across every visible member page, deduped and capped at 8."}},"recentSites":{"type":"array","description":"Up to 3 member pages, newest first.","items":{"$ref":"#/components/schemas/ProjectMember"}}},"description":"A project plus the member data its card renders."},"ProjectMember":{"required":["name","slug","updatedAt","visibility"],"type":"object","properties":{"slug":{"type":"string"},"name":{"type":"string"},"visibility":{"type":"string","enum":["private","password","capture","public"]},"updatedAt":{"type":"string","format":"date-time"}},"description":"A member page, summarised for a project card."},"ProjectPage":{"required":["items"],"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/ProjectCard"}},"nextCursor":{"type":"string","description":"Opaque `<createdAt>|<id>` cursor. Null on the last page.","nullable":true}},"description":"A page of projects plus the cursor for the next one."},"ProjectCounts":{"required":["groupedPages","owned","shared","total"],"type":"object","properties":{"owned":{"type":"integer","format":"int64"},"shared":{"type":"integer","format":"int64"},"total":{"type":"integer","description":"owned + shared.","format":"int64"},"groupedPages":{"type":"integer","description":"Pages the caller can reach that are filed under some project.","format":"int64"}},"description":"How many projects the caller owns and can see."},"Me":{"required":["audience","issuer","sub"],"type":"object","properties":{"sub":{"type":"string","description":"Subject: who the token is for. This is the id everything else keys on."},"issuer":{"type":"string","description":"Issuer. Must match what the API is configured to trust, or every request 401s with invalid_issuer."},"audience":{"type":"array","description":"Audience. `pagegoat-api` for a programmatic key.","items":{"type":"string","description":"Audience. `pagegoat-api` for a programmatic key."}},"scope":{"type":"string","description":"Space-separated scopes.","nullable":true,"example":"sites.read sites.write"},"clientId":{"type":"string","description":"OAuth client the token was minted for.","nullable":true}},"description":"The claims this API read out of your access token."}},"securitySchemes":{"bearer-jwt":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}}}}