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] }