The PDS endpoint URL is /xrpc/com.atproto.server.getSession —
those are dots before getSession, not slashes, so the previous
includes("/getSession") filter never matched and the Authorization
header assertion silently short-circuited via optional chaining.
Uses @std/testing/time FakeTime to deterministically advance the
500ms debounce. Covers accumulation, timer restart on each push,
the bulk-create verb + payload shape, and per-did buffer
isolation with cleared state after flush.
Tests can't override values captured at module load because ES
imports hoist before any Deno.env.set call. Reading env on each
helper invocation makes the module trivially testable and has
no production cost (env doesn't change at runtime).
setup-deno's built-in cache stores fetched JSR/npm modules and
compiled dependencies across runs. Keying on the lockfile hash
invalidates the cache only when dependencies change.
The webhook CRUD test combined an explicit did:plc:user webhook
with seedWebhook calls that defaulted to did:plc:test, so
listWebhooksByDid("did:plc:user") only returned the one
explicit row instead of three.
withTestDb sets up an in-memory SQLite handle with migrations
applied, swaps the production singleton via _setDbForTest, and
restores in a finally block. installFetchStub captures every
outbound request and throws if restored twice. requestApp wraps
Oak's app.handle so route tests can run without listen().
stubAuthenticate flips the server's authenticator indirection.
setTimeout returns an opaque Timeout type in newer Deno
releases, so the previous timer: number annotation fails type
checking in CI. Using ReturnType<typeof setTimeout> is portable
across Deno versions and runtimes.
encodeCursor/decodeCursor/firstSnippet (opensearch) and
decodeJwtPayload (verify) become public exports so tests can
hit them without round-tripping through fetch.
dispatchAll, fireWebhooks, and the bulk-create debounce buffer
move out of jetstream.ts into src/jetstream/{webhooks,bulk}.ts.
fireWebhooks and the bulk helpers now take their DB lookup and
dispatch as parameters so tests can inject stubs. Top-level
Jetstream construction, event handlers, ensureIndex call, and
the cursor-saving interval are gated behind import.meta.main so
importing jetstream.ts no longer opens a WebSocket.
createApp() builds the Application without listening, so tests
can call app.handle() directly. The listen call is gated behind
import.meta.main so importing server.ts no longer boots a port.
ADMIN_DIDS is read inside requireAdmin on each call (was frozen
at module load), and a mutable authenticator indirection lets
tests stub authenticateRequest via _setAuthenticatorForTest.
The Database handle is now constructed on first use and held in
a module-level slot. New _setDbForTest and _resetDbForTest
exports let tests swap in a :memory: handle without rewriting
callers. closeDb() replaces the previous db.close() pattern used
by short-lived scripts. Adds listDistinctNoteDids() to keep the
backfill script from poking the raw connection.
Pull in @opensearch-project/opensearch only for its
Search_RequestBody type so the inline search body is
type-checked. Also drops the unsupported
highlight.content.boundary_scanner_locale option.
Backfill must read the SQLite volume that lives inside the api/jetstream
containers, so it has to be runnable via `deno task backfill:search`
from a Coolify exec shell.
Single-node cluster with the security plugin disabled (in-cluster HTTP
only). jetstream and api now depend on it being healthy and receive
OPENSEARCH_URL via env.
Iterates distinct DIDs in SQLite, resolves each one's PDS, and reindexes
every space.remanso.note record into OpenSearch via
com.atproto.repo.listRecords. Needed because note content is not
persisted to SQLite, so the index would otherwise only cover notes
created after deployment.
GET /search is public and filters to discoverable=true. GET /:did/search
requires a JWT for that DID and includes non-discoverable notes (drafts).
Both reuse the existing PAGINATION default and { results, cursor }
envelope. The requireDidOwnership 403 message is generalized since it
now guards more than webhooks.
Wires create/update/delete events to indexNote/removeNote alongside the
existing webhook dispatch via Promise.allSettled, so search-index
failures cannot break webhook delivery. Calls ensureIndex once at
startup behind a try/catch so a transient OpenSearch outage does not
crash the listener.
Exposes ensureIndex, indexNote, removeNote, and searchNotes. searchNotes
runs a fuzzy multi_match across title+content, returns sentence-bounded
highlights, and paginates via base64-encoded search_after cursors. All
operations no-op when OPENSEARCH_URL is unset so callers stay safe.
The image previously inherited everything from a `COPY . .`, including
.env (secrets), local notes.db copies, and admin scripts that should
not run in prod containers.
Hits GET /admin/webhooks instead of opening the local SQLite directly,
so the task can be run from a developer laptop without ssh or file
access to the server. Drops the FFI/read/write task permissions in
favour of net/env.
Adds GET /admin/webhooks gated by an ADMIN_DIDS env-var allowlist of
verified AT Proto DIDs. Fail-closed: if ADMIN_DIDS is unset, the route
always returns 403 — no accidental exposure on deploys that forget it.
`deno task webhooks list` prints the authenticated DID's subscriptions
(id, url, method, verb). `deno task webhooks delete --id <id>` removes
a single one — pair with `list` to pick which to drop instead of
nuking everything via delete-all.
- GET /:did/webhooks lists subscriptions for the authenticated owner
(token field excluded — write-only as elsewhere).
- DELETE /:did/webhooks/:id deletes a single subscription. The query
scopes on (did, id) so a verified caller cannot delete rows that
belong to a different DID even with a valid id.
Also extracts the auth gate into requireDidOwnership now that three
endpoints share it.
Inputs are now just ATPROTO_HANDLE, ATPROTO_APP_PASSWORD, REMANSO_API
(or matching --handle, --app-password, --api). The script resolves the
handle to a DID via public.api.bsky.app, then resolves the DID to a
PDS via plc.directory — same path the server's verifier uses. Works
for any AT Protocol PDS (Bluesky, eurosky, self-hosted) without the
caller having to know the PDS URL.
Wraps the createSession + Authorization: Bearer flow so callers don't
need to assemble curl by hand. Reads BSKY_IDENTIFIER / BSKY_PASSWORD /
BSKY_PDS / REMANSO_API from env (or matching flags). Defaults to
bsky.social so non-bsky-hosted accounts must set BSKY_PDS explicitly,
e.g. BSKY_PDS=https://eurosky.social.
Both POST /:did/webhooks and DELETE /:did/webhooks were unauthenticated:
anyone could register a webhook for someone else's DID (privacy leak)
or wipe a DID's webhook list (DoS on legitimate subscribers). Now both
endpoints require a Bluesky session bearer token, verified end-to-end
against the DID's PDS via the existing authenticateRequest helper, and
the verified DID must match the URL :did.
Subscriptions now declare a `verb` (create | delete | bulk-create).
POST /:did/webhooks defaults to inserting both create and delete rows
when no verb is given, preserving existing all-events behavior. Update
events fold into the create verb. The new bulk-create verb debounces
creates per DID over 400 ms and delivers a `records` array.
Migration adds the verb column with default 'create' and clones every
existing row for the delete verb so legacy subscriptions keep firing
on both events.
Set busy_timeout before journal_mode=WAL in db.ts so SQLite retries
for 10s instead of failing immediately with the default 0ms timeout.
Extract migration into a dedicated Docker Compose service so both
jetstream and api wait for it to complete before opening the database.