alf: the atproto Latency Fabric alf.fly.dev/
at main 694 lines 20 kB view raw view rendered
1# ALF API Reference 2 3All endpoints require a valid ATProto Bearer token unless noted otherwise. 4 5XRPC endpoints follow the ATProto convention: 6- **Queries** (`type: query`) → `GET /xrpc/<method-id>?param=value` 7- **Procedures** (`type: procedure`) → `POST /xrpc/<method-id>` with `Content-Type: application/json` 8 9--- 10 11## Write interface (proxied ATProto methods) 12 13These endpoints mirror the standard ATProto repo write API. Instead of writing to the PDS, ALF stores the record as a draft. 14 15### `com.atproto.repo.createRecord` 16 17Create a draft record. If `x-scheduled-at` is provided, the draft is immediately scheduled via a `once` recurrence schedule. If `x-trigger: webhook` is provided, a one-time secret URL is returned that publishes the draft on demand. 18 19**Request headers:** 20 21| Header | Required | Description | 22|--------|----------|-------------| 23| `Authorization` | Yes | `Bearer <access-token>` | 24| `x-scheduled-at` | No | ISO 8601 datetime. Creates a `once` schedule; draft gets a `scheduleId`. | 25| `x-trigger` | No | Set to `webhook` to generate a one-time trigger URL instead of a fixed schedule. | 26 27**Request body:** 28 29```jsonc 30{ 31 "repo": "did:plc:alice", // Must match authenticated user 32 "collection": "app.bsky.feed.post", 33 "rkey": "3kw9mts3abc", // Optional; auto-generated TID if omitted 34 "record": { ... } // The ATProto record 35} 36``` 37 38**Response (200):** 39 40```jsonc 41{ 42 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc", 43 "cid": "bafyreib...", 44 "validationStatus": "unknown", 45 "triggerUrl": "https://alf.example.com/triggers/..." // Only when x-trigger: webhook 46} 47``` 48 49**Errors:** 50 51| Code | Description | 52|------|-------------| 53| `InvalidRequest` (400) | Missing `collection` or `record`; `repo` doesn't match authenticated user | 54| `DuplicateDraft` (400) | A draft with this URI already exists and is not cancelled/published | 55| `AuthRequired` (401) | Missing or invalid Bearer token | 56 57--- 58 59### `com.atproto.repo.putRecord` 60 61Create a draft for a `putRecord` (create-or-update) operation. 62 63**Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`). 64 65**Request body:** 66 67```jsonc 68{ 69 "repo": "did:plc:alice", 70 "collection": "app.bsky.actor.profile", 71 "rkey": "self", 72 "record": { ... } 73} 74``` 75 76**Response (200):** Same shape as `createRecord`. 77 78--- 79 80### `com.atproto.repo.deleteRecord` 81 82Create a draft for a `deleteRecord` operation. No record content is needed. 83 84**Request headers:** Same as `createRecord` (including `x-scheduled-at` and `x-trigger`). 85 86**Request body:** 87 88```jsonc 89{ 90 "repo": "did:plc:alice", 91 "collection": "app.bsky.feed.post", 92 "rkey": "3kw9mts3abc" 93} 94``` 95 96**Response (200):** 97 98```jsonc 99{} 100``` 101 102--- 103 104## Draft management methods 105 106### `town.roundabout.scheduledPosts.listPosts` 107 108List drafts for a user. Users can only list their own drafts. 109 110**Query parameters:** 111 112| Parameter | Required | Description | 113|-----------|----------|-------------| 114| `repo` | Yes | DID of the user. Must match the authenticated user. | 115| `status` | No | Filter by status: `draft`, `scheduled`, `publishing`, `published`, `failed`, `cancelled` | 116| `limit` | No | Number of results (1–100, default 50) | 117| `cursor` | No | Pagination cursor from a previous response | 118 119**Response (200):** 120 121```jsonc 122{ 123 "posts": [ 124 { /* DraftView */ }, 125 { /* DraftView */ } 126 ], 127 "cursor": "..." // Present if more results exist 128} 129``` 130 131--- 132 133### `town.roundabout.scheduledPosts.getPost` 134 135Get a single draft by AT-URI. 136 137**Query parameters:** 138 139| Parameter | Required | Description | 140|-----------|----------|-------------| 141| `uri` | Yes | AT-URI of the draft | 142 143**Response (200):** A `DraftView` object. 144 145**Errors:** 146 147| Code | Description | 148|------|-------------| 149| `NotFound` (400) | No draft with this URI | 150| `AuthRequired` (401) | URI belongs to a different user | 151 152--- 153 154### `town.roundabout.scheduledPosts.schedulePost` 155 156Set or change the publish time for a draft. The draft must be in `draft` or `scheduled` status. 157 158**Request body:** 159 160```jsonc 161{ 162 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc", 163 "publishAt": "2025-06-01T09:00:00.000Z" // ISO 8601 164} 165``` 166 167**Response (200):** The updated `DraftView`. 168 169**Errors:** 170 171| Code | Description | 172|------|-------------| 173| `InvalidRequest` (400) | `publishAt` is not a valid datetime | 174| `NotFound` (400) | Draft not found or not in a schedulable state (e.g., already published) | 175| `AuthRequired` (401) | Draft belongs to a different user | 176 177--- 178 179### `town.roundabout.scheduledPosts.publishPost` 180 181Immediately publish a draft to the user's PDS. This is a synchronous operation — the response reflects the final published state. 182 183**Request body:** 184 185```jsonc 186{ 187 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc" 188} 189``` 190 191**Response (200):** The updated `DraftView` (status will be `published` on success, or `failed` if the PDS write failed). 192 193**Errors:** 194 195| Code | Description | 196|------|-------------| 197| `NotFound` (400) | Draft not found | 198| `AuthRequired` (401) | Draft belongs to a different user | 199 200--- 201 202### `town.roundabout.scheduledPosts.updatePost` 203 204Update the record content and/or schedule of a draft. Both `record` and `scheduledAt` are optional — only the fields provided are updated. The CID is recomputed if `record` changes. 205 206The draft must be in `draft` or `scheduled` status. 207 208**Request body:** 209 210```jsonc 211{ 212 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc", 213 "record": { ... }, // Optional: new record content 214 "scheduledAt": "2025-06-01T09:00:00.000Z" // Optional: new publish time 215} 216``` 217 218**Response (200):** The updated `DraftView`. 219 220**Errors:** 221 222| Code | Description | 223|------|-------------| 224| `InvalidRequest` (400) | `scheduledAt` is not a valid datetime | 225| `NotFound` (400) | Draft not found or not in an updatable state | 226| `AuthRequired` (401) | Draft belongs to a different user | 227 228--- 229 230### `town.roundabout.scheduledPosts.deletePost` 231 232Cancel and discard a draft. Sets status to `cancelled`. This is permanent. 233 234**Request body:** 235 236```jsonc 237{ 238 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc" 239} 240``` 241 242**Response (200):** 243 244```jsonc 245{} 246``` 247 248**Errors:** 249 250| Code | Description | 251|------|-------------| 252| `AuthRequired` (401) | Draft belongs to a different user | 253 254--- 255 256## Schedule management methods 257 258Recurring schedules fire on a recurrence rule and automatically create a new draft for each occurrence. The draft is published at the scheduled time, then the next draft is queued. 259 260### `town.roundabout.scheduledPosts.createSchedule` 261 262Create a recurring schedule. ALF computes the first occurrence immediately and creates a draft for it. 263 264**Request body:** 265 266```jsonc 267{ 268 "collection": "app.bsky.feed.post", 269 "recurrenceRule": { /* RecurrenceRule */ }, 270 "timezone": "America/New_York", 271 "record": { ... }, // Static post content (mutually exclusive with contentUrl) 272 "contentUrl": "https://..." // Dynamic content URL (mutually exclusive with record) 273} 274``` 275 276**Response (200):** 277 278```jsonc 279{ 280 "schedule": { /* ScheduleView */ } 281} 282``` 283 284**Errors:** 285 286| Code | Description | 287|------|-------------| 288| `InvalidRequest` (400) | `record` and `contentUrl` both provided; rule produces no future occurrences | 289| `AuthRequired` (401) | Missing or invalid Bearer token | 290 291--- 292 293### `town.roundabout.scheduledPosts.listSchedules` 294 295List schedules for a user. 296 297**Query parameters:** 298 299| Parameter | Required | Description | 300|-----------|----------|-------------| 301| `repo` | Yes | DID of the user. Must match the authenticated user. | 302| `status` | No | Filter by status: `active`, `paused`, `cancelled`, `completed`, `error` | 303| `limit` | No | Number of results (1–100, default 50) | 304| `cursor` | No | Pagination cursor from a previous response | 305 306**Response (200):** 307 308```jsonc 309{ 310 "schedules": [ { /* ScheduleView */ } ], 311 "cursor": "..." 312} 313``` 314 315--- 316 317### `town.roundabout.scheduledPosts.getSchedule` 318 319Get a single schedule by ID. 320 321**Query parameters:** 322 323| Parameter | Required | Description | 324|-----------|----------|-------------| 325| `id` | Yes | Schedule UUID | 326 327**Response (200):** A `ScheduleView` object. 328 329**Errors:** 330 331| Code | Description | 332|------|-------------| 333| `NotFound` (400) | No schedule with this ID | 334| `AuthRequired` (401) | Schedule belongs to a different user | 335 336--- 337 338### `town.roundabout.scheduledPosts.updateSchedule` 339 340Pause or resume a schedule. Pausing cancels the pending next draft; resuming immediately computes and queues the next occurrence. 341 342**Request body:** 343 344```jsonc 345{ 346 "id": "550e8400-e29b-41d4-a716-446655440000", 347 "status": "paused" // "paused" or "active" 348} 349``` 350 351**Response (200):** The updated `ScheduleView`. 352 353**Errors:** 354 355| Code | Description | 356|------|-------------| 357| `NotFound` (400) | Schedule not found | 358| `AuthRequired` (401) | Schedule belongs to a different user | 359 360--- 361 362### `town.roundabout.scheduledPosts.deleteSchedule` 363 364Delete a schedule and cancel its pending draft. This is permanent. 365 366**Request body:** 367 368```jsonc 369{ 370 "id": "550e8400-e29b-41d4-a716-446655440000" 371} 372``` 373 374**Response (200):** 375 376```jsonc 377{} 378``` 379 380**Errors:** 381 382| Code | Description | 383|------|-------------| 384| `NotFound` (400) | Schedule not found | 385| `AuthRequired` (401) | Schedule belongs to a different user | 386 387--- 388 389## REST endpoints 390 391### `POST /blob` 392 393Upload a blob (image) for use in a scheduled post. ALF stores the raw bytes and returns the CID. Use the CID when constructing the record's blob references. 394 395The blob is stored until the draft is published, at which point it is re-uploaded to the user's PDS. 396 397**Request headers:** 398 399| Header | Required | Description | 400|--------|----------|-------------| 401| `Authorization` | Yes | `Bearer <access-token>` | 402| `Content-Type` | Yes | MIME type of the blob (e.g., `image/jpeg`, `image/png`) | 403 404**Request body:** Raw image bytes (max 10MB). 405 406**Response (200):** 407 408```jsonc 409{ 410 "cid": "bafkreihdwdcefgh...", 411 "mimeType": "image/jpeg", 412 "size": 204800 413} 414``` 415 416Use `cid` in blob references in your record: 417 418```jsonc 419{ 420 "$type": "blob", 421 "ref": { "$link": "bafkreihdwdcefgh..." }, 422 "mimeType": "image/jpeg", 423 "size": 204800 424} 425``` 426 427**Errors:** 428 429| Code | Description | 430|------|-------------| 431| `InvalidRequest` (400) | Empty request body | 432| `AuthRequired` (401) | Missing or invalid Bearer token | 433 434--- 435 436### `POST /triggers/:key` 437 438Fire a webhook trigger draft immediately. No authentication required — the URL itself is the secret. 439 440**Response (200):** 441 442```jsonc 443{ 444 "published": true, 445 "uri": "at://did:plc:alice/app.bsky.feed.post/3kw9mts3abc" 446} 447``` 448 449**Errors:** 450 451| Code | Description | 452|------|-------------| 453| `NotFound` (404) | Trigger key not found | 454| `TriggerAlreadyFired` (409) | Draft already published, failed, or cancelled | 455 456--- 457 458### `GET /oauth/status` 459 460Check whether the authenticated user has authorized ALF to publish on their behalf. 461 462**Request headers:** 463 464| Header | Required | Description | 465|--------|----------|-------------| 466| `Authorization` | Yes | `Bearer <access-token>` | 467 468**Response (200):** 469 470```jsonc 471{ 472 "authorized": true, 473 "authType": "oauth" // "oauth" | null 474} 475``` 476 477If the token is invalid or the user has not authorized ALF, returns `{ "authorized": false, "authType": null }`. 478 479--- 480 481### `GET /oauth/authorize` 482 483Initiate the OAuth authorization flow for a user. 484 485**Query parameters:** 486 487| Parameter | Required | Description | 488|-----------|----------|-------------| 489| `handle` | Yes | ATProto handle (e.g., `alice.bsky.social`) or DID | 490 491Redirects the user to their PDS for authorization. After approval, the PDS redirects back to `/oauth/callback`. 492 493--- 494 495### `GET /health` 496 497Basic health check. No authentication required. 498 499**Response (200):** 500 501```jsonc 502{ "status": "ok", "service": "alf" } 503``` 504 505--- 506 507## DraftView object 508 509All draft management endpoints return a `DraftView`: 510 511```typescript 512{ 513 uri: string; // AT-URI: "at://did:plc:.../collection/rkey" 514 cid?: string; // Pre-computed DAG-CBOR CID; absent for deleteRecord drafts 515 collection: string; // NSID, e.g. "app.bsky.feed.post" 516 rkey: string; // Record key 517 action: "create" | "put" | "delete"; 518 status: "draft" | "scheduled" | "publishing" | "published" | "failed" | "cancelled"; 519 scheduledAt?: string; // ISO 8601 datetime; absent if unscheduled 520 createdAt: string; // ISO 8601 datetime 521 failureReason?: string; // Present only when status is "failed" 522 record?: object; // Record content; absent for deleteRecord drafts 523 scheduleId?: string; // UUID of the parent schedule, if this draft was created by one 524 triggerUrl?: string; // One-time webhook URL; only present on drafts with x-trigger: webhook 525} 526``` 527 528### Draft statuses 529 530| Status | Description | 531|--------|-------------| 532| `draft` | Saved but not scheduled. Will not be published automatically. | 533| `scheduled` | Has a publish time. Will be published by the scheduler. | 534| `publishing` | Currently being published (claimed by scheduler). | 535| `published` | Successfully written to the PDS. | 536| `failed` | Failed to publish after all retry attempts. | 537| `cancelled` | Cancelled by the user via `deletePost`. | 538 539--- 540 541## ScheduleView object 542 543Schedule management endpoints return a `ScheduleView`: 544 545```typescript 546{ 547 id: string; // UUID 548 collection: string; // NSID, e.g. "app.bsky.feed.post" 549 status: "active" | "paused" | "cancelled" | "completed" | "error"; 550 recurrenceRule: RecurrenceRule; // Full rule object (see below) 551 timezone: string; // IANA timezone 552 fireCount: number; // Number of times this schedule has fired 553 createdAt: string; // ISO 8601 554 lastFiredAt?: string; // ISO 8601; present after first firing 555 nextDraftUri?: string; // AT-URI of the pending next draft 556 record?: object; // Static post content, if applicable 557 contentUrl?: string; // Dynamic content URL, if applicable 558} 559``` 560 561### Schedule statuses 562 563| Status | Description | 564|--------|-------------| 565| `active` | Running normally; a pending draft exists. | 566| `paused` | Paused by the user; no pending draft. | 567| `cancelled` | Deleted by the user. | 568| `completed` | Series naturally exhausted (e.g., a `once` schedule that has fired). | 569| `error` | An unrecoverable error occurred during chaining or publishing. | 570 571--- 572 573## RecurrenceRule object 574 575A `RecurrenceRule` is a JSON object passed to `createSchedule`. It contains a core rule plus optional bounds and exception lists. 576 577```typescript 578{ 579 rule: RecurrenceRuleCore; // The core firing pattern (see below) 580 startDate?: string; // YYYY-MM-DD: first occurrence must be on or after this date 581 endDate?: string; // YYYY-MM-DD: no occurrences after this date 582 count?: number; // Maximum number of total firings 583 revisions?: RecurrenceRevision[]; // Time-spec changes taking effect from a given date 584 exceptions?: RecurrenceException[]; // Per-occurrence overrides (cancel, move, override_time, override_payload) 585} 586``` 587 588### Core rule types 589 590| Type | Description | Extra fields | 591|------|-------------|--------------| 592| `once` | Fires exactly once at the given UTC datetime. | `datetime: string` (ISO 8601 UTC) | 593| `daily` | Every N days at the given time. | `interval?: number`, `time: TimeSpec` | 594| `weekly` | Every N weeks on the specified days of the week. | `interval?: number`, `daysOfWeek: number[]` (0=Sun–6=Sat), `time: TimeSpec` | 595| `monthly_on_day` | Nth day of every N-th month; clamps to last day if month is shorter. | `interval?: number`, `dayOfMonth: number` (1–31), `time: TimeSpec` | 596| `monthly_nth_weekday` | Nth weekday of every N-th month (nth=-1 means last). | `interval?: number`, `nth: number` (1–4 or -1), `weekday: number` (0=Sun–6=Sat), `time: TimeSpec` | 597| `monthly_last_business_day` | Last Mon–Fri of every N-th month. | `interval?: number`, `time: TimeSpec` | 598| `yearly_on_month_day` | Specific month and day each year; clamps Feb 29 in non-leap years. | `interval?: number`, `month: number` (1–12), `dayOfMonth: number` (1–31), `time: TimeSpec` | 599| `yearly_nth_weekday` | Nth weekday of a specific month each year. | `interval?: number`, `month: number`, `nth: number`, `weekday: number`, `time: TimeSpec` | 600| `quarterly_last_weekday` | Last occurrence of a weekday in each quarter-end month (Mar, Jun, Sep, Dec). | `interval?: number` (quarters between fires, default 1), `weekday: number`, `time: TimeSpec` | 601 602### TimeSpec 603 604All repeating rule types include a `time` field that is one of: 605 606```typescript 607// Wall-clock time in a named timezone (DST-aware) 608{ type: "wall_time", hour: number, minute: number, second?: number, timezone: string } 609 610// Fixed UTC offset (does not adjust for DST) 611{ type: "fixed_instant", utcOffsetMinutes: number, hour: number, minute: number, second?: number } 612``` 613 614### Exception types 615 616Exceptions are matched by their `date` field (a `YYYY-MM-DD` string in the rule's timezone). Multiple exceptions for different dates can coexist. 617 618| Type | Effect | Fields | 619|------|--------|--------| 620| `cancel` | Skip this occurrence entirely. | `date: string` | 621| `move` | Publish at a different UTC datetime instead. | `date: string`, `newDatetime: string` (ISO 8601 UTC) | 622| `override_time` | Use a different time spec for this occurrence only. | `date: string`, `time: TimeSpec` | 623| `override_payload` | Publish a different record for this occurrence. Resolved at publish time, not schedule time. | `date: string`, `record: object` | 624 625### Example: daily post at 9 AM ET, skipping a holiday 626 627```jsonc 628{ 629 "rule": { 630 "type": "daily", 631 "time": { "type": "wall_time", "hour": 9, "minute": 0, "timezone": "America/New_York" } 632 }, 633 "exceptions": [ 634 { "type": "cancel", "date": "2025-07-04" }, 635 { "type": "override_payload", "date": "2025-12-25", "record": { 636 "$type": "app.bsky.feed.post", "text": "Happy holidays!", "createdAt": "2025-12-25T00:00:00Z" 637 }} 638 ] 639} 640``` 641 642### Example: once schedule 643 644```jsonc 645{ 646 "rule": { "type": "once", "datetime": "2025-06-01T14:00:00Z" } 647} 648``` 649 650> **Note:** Using `x-scheduled-at` on `createRecord` or `putRecord` automatically creates a `once` schedule behind the scenes. The returned draft will have a `scheduleId` pointing to it. 651 652### Dynamic content schedules 653 654If `contentUrl` is provided instead of `record`, ALF fetches the URL at publish time with two query parameters: 655 656| Parameter | Description | 657|-----------|-------------| 658| `fireCount` | 1-based count of how many times this schedule has fired (including this firing) | 659| `scheduledAt` | ISO 8601 datetime of the scheduled occurrence | 660 661The response must be a JSON object that is the record to publish. 662 663--- 664 665## Client utilities (`@newpublic/recurrence`) 666 667The recurrence package exported by the monorepo includes two utilities for working with `RecurrenceRule` objects on the client side. 668 669### `parseRecurrenceRule(input, defaultTimezone?)` 670 671Parses a plain-English string into a `RecurrenceRule`. Returns `null` if the input doesn't match any known pattern. 672 673```typescript 674import { parseRecurrenceRule } from '@newpublic/recurrence'; 675 676parseRecurrenceRule('every Monday and Friday at 9am ET'); 677parseRecurrenceRule('last business day of each month at 5pm UTC'); 678parseRecurrenceRule('every year on January 1st at midnight', 'America/New_York'); 679``` 680 681`defaultTimezone` is an IANA timezone used when no timezone is detected in the input; defaults to `'UTC'`. See the [package README](../packages/recurrence/README.md) for the full list of supported phrasings. 682 683### `formatRecurrenceRule(rule)` 684 685Formats a `RecurrenceRule` as a human-readable English string. Useful for displaying schedule summaries in UI. 686 687```typescript 688import { formatRecurrenceRule } from '@newpublic/recurrence'; 689 690formatRecurrenceRule(rule); 691// e.g. "every Monday and Friday at 9am (ET)" 692// e.g. "last business day of every month at 5pm (UTC)" 693// e.g. "every year on January 1st at midnight (UTC), starting 2026-01-01" 694```