From d2248424a706032dfbff07abcad843b89249c989 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 7 Jun 2026 11:29:02 +0200 Subject: [PATCH] docs(api): add OpenAPI 3.1 spec for the HTTP server --- openapi.yaml | 449 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 openapi.yaml diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..4aba93c --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,449 @@ +openapi: 3.1.0 +info: + title: Remanso Jetstream API + version: 0.1.0 + description: | + HTTP API for Remanso, a blogging platform on the AT Protocol. Exposes + paginated read access to `space.remanso.note` records, full-text fuzzy + search backed by OpenSearch, and per-user webhook subscriptions that fire + on note create/update/delete. + + Authentication uses AT Protocol PDS-issued bearer JWTs. The token's `sub` + must equal the path `:did` for owner-scoped endpoints; admin endpoints + additionally require the verified DID to be in the server-side + `ADMIN_DIDS` allowlist. + +servers: + - url: https://api.remanso.space + description: Production + - url: http://localhost:8080 + description: Local development + +tags: + - name: notes + description: Read access to stored notes + - name: search + description: Full-text fuzzy search via OpenSearch + - name: webhooks + description: Per-user delivery hooks for note events + - name: admin + description: Endpoints restricted to ADMIN_DIDS + - name: auth + description: OAuth helpers + - name: meta + +paths: + /: + get: + tags: [meta] + summary: Hello world + responses: + "200": + description: Plain text greeting + content: + text/plain: + schema: { type: string, example: "Hello world" } + + /health: + get: + tags: [meta] + summary: Health probe + responses: + "200": + description: Service is up + content: + application/json: + schema: + type: object + properties: + status: { type: string, example: ok } + + /auth/github: + get: + tags: [auth] + summary: GitHub OAuth proxy + description: | + Exchanges a GitHub OAuth `code` for a token, or refreshes an existing + one. The server adds its own client_id/client_secret and forwards to + `https://github.com/login/oauth/access_token`. Response body and + status are passed through from GitHub. + parameters: + - in: query + name: code + required: true + schema: { type: string } + description: GitHub OAuth code, or the refresh token when `type=refresh`. + - in: query + name: type + required: false + schema: { type: string, enum: [refresh] } + description: When `refresh`, performs a refresh-token grant instead. + responses: + "200": + description: GitHub access-token response (shape determined by GitHub). + content: + application/json: + schema: { type: object, additionalProperties: true } + "400": + $ref: "#/components/responses/BadRequest" + + /notes: + get: + tags: [notes] + summary: List discoverable notes across all users + parameters: + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of notes ordered by rkey descending. + content: + application/json: + schema: { $ref: "#/components/schemas/NotePage" } + + /{did}/notes: + get: + tags: [notes] + summary: List discoverable notes for a single DID + parameters: + - $ref: "#/components/parameters/Did" + - $ref: "#/components/parameters/Cursor" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of notes for the given DID. + content: + application/json: + schema: { $ref: "#/components/schemas/NotePage" } + + /notes/feed: + post: + tags: [notes] + summary: Multi-DID feed + description: Paginated discoverable notes across an arbitrary set of DIDs. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [dids] + properties: + dids: + type: array + minItems: 1 + items: { type: string, example: "did:plc:abc123..." } + cursor: + type: string + description: rkey returned as `cursor` from a previous page. + limit: + type: integer + minimum: 1 + default: 20 + responses: + "200": + description: A page of notes from the requested DIDs. + content: + application/json: + schema: { $ref: "#/components/schemas/NotePage" } + "400": + $ref: "#/components/responses/BadRequest" + + /search: + get: + tags: [search] + summary: Public full-text fuzzy search + description: | + Searches every discoverable note's title and content. Uses + OpenSearch `multi_match` with `fuzziness: AUTO`, so small typos still + match. Each hit includes a sentence-bounded `snippet` from the match + with query terms wrapped in `` tags. + parameters: + - in: query + name: q + required: true + schema: { type: string, minLength: 1 } + description: Query string. + - in: query + name: cursor + required: false + schema: { type: string } + description: Opaque base64 cursor returned from a previous call. + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of search hits. + content: + application/json: + schema: { $ref: "#/components/schemas/SearchPage" } + "400": + $ref: "#/components/responses/BadRequest" + + /{did}/search: + get: + tags: [search] + summary: Owner-scoped full-text fuzzy search + description: | + Same as `/search` but limited to a single DID and including + non-discoverable notes (drafts). Requires a bearer JWT whose `sub` + equals the path `:did`. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/Did" + - in: query + name: q + required: true + schema: { type: string, minLength: 1 } + - in: query + name: cursor + required: false + schema: { type: string } + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: A page of search hits for the caller's DID. + content: + application/json: + schema: { $ref: "#/components/schemas/SearchPage" } + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /{did}/webhooks: + get: + tags: [webhooks] + summary: List a DID's webhook subscriptions + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/Did" + responses: + "200": + description: All subscriptions owned by the caller. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WebhookSubscription" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + post: + tags: [webhooks] + summary: Add one or more webhook subscriptions + description: | + When `verb` is omitted, a pair of subscriptions is created — one for + `create`, one for `delete`. When `verb` is provided, only that + subscription is created. The response array contains every + subscription that was inserted. + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/Did" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [method, url] + properties: + method: + type: string + example: POST + url: + type: string + format: uri + token: + type: string + description: Optional bearer token sent to the webhook URL. + verb: + type: string + enum: [create, delete, bulk-create] + responses: + "201": + description: One subscription per requested verb. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WebhookSubscription" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + delete: + tags: [webhooks] + summary: Delete every webhook subscription for the caller + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/Did" + responses: + "204": { description: All subscriptions deleted. } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /{did}/webhooks/{id}: + delete: + tags: [webhooks] + summary: Delete a single webhook subscription by id + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/Did" + - in: path + name: id + required: true + schema: { type: integer, minimum: 1 } + responses: + "204": { description: Subscription deleted. } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": + description: No subscription with that id owned by this DID. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /admin/webhooks: + get: + tags: [admin] + summary: List every webhook subscription + description: Requires the verified DID to be in `ADMIN_DIDS`. + security: + - bearerAuth: [] + responses: + "200": + description: All subscriptions across all DIDs. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/WebhookSubscription" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + AT Protocol PDS session JWT. The server resolves the DID from the + token's `sub`, looks up the PDS endpoint via plc.directory, and + verifies the session by calling `com.atproto.server.getSession` + against that PDS. + + parameters: + Did: + in: path + name: did + required: true + schema: { type: string, example: "did:plc:abc123..." } + description: AT Protocol DID. + Cursor: + in: query + name: cursor + required: false + schema: { type: string } + description: rkey returned as `cursor` from the previous page. + Limit: + in: query + name: limit + required: false + schema: { type: integer, minimum: 1, default: 20 } + description: Maximum results per page. Search endpoints cap at 100. + + responses: + BadRequest: + description: Malformed input. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Missing or invalid bearer token. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: Token is valid but the caller is not authorized for this resource. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + Error: + type: object + required: [error] + properties: + error: { type: string } + + Note: + type: object + required: [did, rkey, title, publishedAt, createdAt] + properties: + did: { type: string } + rkey: { type: string } + title: { type: string } + publishedAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + language: + type: string + description: ISO 639-3 code if known. + + NotePage: + type: object + required: [notes] + properties: + notes: + type: array + items: { $ref: "#/components/schemas/Note" } + cursor: + type: string + nullable: true + description: Present only when more pages exist; pass back as `cursor`. + + SearchHit: + type: object + required: [did, rkey, title, snippet, score] + properties: + did: { type: string } + rkey: { type: string } + title: { type: string } + snippet: + type: string + description: Sentence-bounded fragment with `` around matches. + score: { type: number } + publishedAt: { type: string, format: date-time } + + SearchPage: + type: object + required: [results] + properties: + results: + type: array + items: { $ref: "#/components/schemas/SearchHit" } + cursor: + type: string + nullable: true + description: Opaque base64 cursor; pass back as `cursor`. + + WebhookSubscription: + type: object + required: [id, did, method, url, verb] + description: | + `token` is write-only — it is accepted by `POST /:did/webhooks` but + never returned in responses. + properties: + id: { type: integer, minimum: 1 } + did: { type: string } + method: { type: string, example: POST } + url: { type: string, format: uri } + verb: { type: string, enum: [create, delete, bulk-create] }