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.
- Migration: CREATE TABLE webhook_subscription (id, did, method, url) with index on did
- db.ts: addWebhookSubscription and deleteWebhooksByDid helpers
- server.ts: POST /:did/webhooks (201) and DELETE /:did/webhooks (204)
Jetstream was running backgrounded in the same container as the API server,
so crashes went undetected and Docker never restarted it. Now each process
runs as a separate docker-compose service with independent restart policies.
Also adds cursor persistence to SQLite (saved every 5s) so restarts resume
from where they left off, moves event destructuring inside try/catch blocks,
and adds global unhandled error/rejection handlers for crash visibility.
Verify the caller owns the DID by resolving their PDS via plc.directory
and validating the session token before allowing note deletion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>