···7272 ON webhook_event_logs (owner_did, delivered_at DESC)
7373`
74747575+await db`
7676+ CREATE TABLE IF NOT EXISTS webhook_secrets (
7777+ did TEXT NOT NULL,
7878+ name TEXT NOT NULL,
7979+ token TEXT NOT NULL,
8080+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8181+ last_rotated_at TIMESTAMPTZ,
8282+ PRIMARY KEY (did, name)
8383+ )
8484+`
8585+7586/**
7687 * Find all webhook records whose scope AT-URI targets the given DID.
7788 * Matches exact DID scope (`at://did`) and collection/rkey sub-scopes (`at://did/...`).
···309320 for (const r of webhookDids) dids.add(r.did)
310321311322 return [...dids].sort()
323323+}
324324+325325+/** Look up the token for a server-managed signing secret by owner DID + name. */
326326+export async function getWebhookSecretToken(ownerDid: string, name: string): Promise<string | null> {
327327+ const rows = await db<Array<{ token: string }>>`
328328+ SELECT token FROM webhook_secrets WHERE did = ${ownerDid} AND name = ${name} LIMIT 1
329329+ `
330330+ return rows[0]?.token ?? null
312331}
313332314333/** Close all database connections gracefully. */
+6-2
apps/webhook-service/src/lib/delivery.ts
···22import { createLogger } from '@wispplace/observability'
33import { config } from '../config'
44import type { WebhookEntry } from './db'
55-import { insertEventLog } from './db'
55+import { getWebhookSecretToken, insertEventLog } from './db'
66import type { EventKind } from './matcher'
77import { publishWebhookEvent } from './redis'
88···7676 }
77777878 const body = JSON.stringify(payload)
7979- const signature = record.secret ? sign(record.secret, body) : undefined
7979+ let signingSecret: string | undefined = record.secret ?? undefined
8080+ if (!signingSecret && record.secretId) {
8181+ signingSecret = (await getWebhookSecretToken(ownerDid, record.secretId)) ?? undefined
8282+ }
8383+ const signature = signingSecret ? sign(signingSecret, body) : undefined
80848185 for (let attempt_n = 1; attempt_n <= config.deliveryMaxRetries; attempt_n++) {
8286 try {
+27-14
apps/webhook-service/src/lib/matcher.ts
···43434444/**
4545 * Recursively walk a parsed record object checking whether any string value
4646- * starts with `prefix` and has a collection segment matching `collectionRe`.
4646+ * starts with `prefix` and has a collection segment matching `collectionRe`,
4747+ * and optionally an rkey segment matching `rkey`.
4748 */
4848-function walkForReference(obj: unknown, prefix: string, collectionRe: RegExp | null, exact: string | null): boolean {
4949+function walkForReference(
5050+ obj: unknown,
5151+ prefix: string,
5252+ collectionRe: RegExp | null,
5353+ exact: string | null,
5454+ rkey: string | undefined,
5555+): boolean {
4956 if (typeof obj === 'string') {
5057 const idx = obj.indexOf(prefix)
5158 if (idx === -1) return false
5259 const rest = obj.slice(idx + prefix.length)
5360 if (collectionRe === null && exact === null) return true // at://did — any reference
5454- const end = rest.search(/[/"\\]/)
5555- const col = end === -1 ? rest : rest.slice(0, end)
6161+ const slashIdx = rest.search(/[/"\\]/)
6262+ const col = slashIdx === -1 ? rest : rest.slice(0, slashIdx)
5663 if (!col) return false
5757- return exact !== null ? col === exact : collectionRe!.test(col)
6464+ const colMatches = exact !== null ? col === exact : collectionRe!.test(col)
6565+ if (!colMatches) return false
6666+ if (!rkey) return true
6767+ if (slashIdx === -1) return false
6868+ const afterSlash = rest.slice(slashIdx + 1)
6969+ const rkeyEnd = afterSlash.search(/[/"\\]/)
7070+ const rkeySegment = rkeyEnd === -1 ? afterSlash : afterSlash.slice(0, rkeyEnd)
7171+ return rkeySegment === rkey
5872 }
5973 if (Array.isArray(obj)) {
6074 for (const v of obj) {
6161- if (walkForReference(v, prefix, collectionRe, exact)) return true
7575+ if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true
6276 }
6377 return false
6478 }
6579 if (obj !== null && typeof obj === 'object') {
6680 for (const v of Object.values(obj)) {
6767- if (walkForReference(v, prefix, collectionRe, exact)) return true
8181+ if (walkForReference(v, prefix, collectionRe, exact, rkey)) return true
6882 }
6983 }
7084 return false
7185}
72867387/**
7474- * Checks whether a record contains a reference to the given DID/collection.
8888+ * Checks whether a record contains a reference to the given DID/collection/rkey.
7589 * Uses a recursive walk and pre-compiled regex — no JSON.stringify.
7690 */
7777-function containsReference(record: unknown, did: string, collection?: string): boolean {
9191+function containsReference(record: unknown, did: string, collection?: string, rkey?: string): boolean {
7892 const prefix = `at://${did}/`
79938094 if (!collection) {
8181- // Any reference to this DID at all
8282- return walkForReference(record, `at://${did}`, null, null)
9595+ return walkForReference(record, `at://${did}`, null, null, undefined)
8396 }
84978598 const collectionRe = collection.includes('*') ? compileGlob(collection) : null
8699 const exact = collectionRe ? null : collection
8787- return walkForReference(record, prefix, collectionRe, exact)
100100+ return walkForReference(record, prefix, collectionRe, exact, rkey)
88101}
8910290103/**
···138151 continue
139152 }
140153141141- if (backlinks && eventDid !== scope.did && eventRecord != null) {
142142- if (containsReference(eventRecord, scope.did, scope.collection)) {
154154+ if (backlinks && eventRecord != null) {
155155+ if (containsReference(eventRecord, scope.did, scope.collection, scope.rkey)) {
143156 matched.push(entry)
144157 }
145158 }
+44
lexicons/secret-create-v2.json
···11+{
22+ "lexicon": 1,
33+ "id": "place.wisp.v2.secret.create",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Create a named webhook signing secret. The server generates a short random token returned once in the response. Reference the secret by name via secretId in place.wisp.v2.wh.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "required": ["name"],
1313+ "properties": {
1414+ "name": {
1515+ "type": "string",
1616+ "format": "record-key",
1717+ "description": "Unique name for this secret, scoped to the caller DID."
1818+ }
1919+ }
2020+ }
2121+ },
2222+ "output": {
2323+ "encoding": "application/json",
2424+ "schema": {
2525+ "type": "object",
2626+ "required": ["name", "token", "createdAt"],
2727+ "properties": {
2828+ "name": { "type": "string" },
2929+ "token": {
3030+ "type": "string",
3131+ "description": "The signing token. Only returned at creation time — store it now."
3232+ },
3333+ "createdAt": { "type": "string", "format": "datetime" }
3434+ }
3535+ }
3636+ },
3737+ "errors": [
3838+ { "name": "AuthenticationRequired" },
3939+ { "name": "InvalidRequest" },
4040+ { "name": "AlreadyExists" }
4141+ ]
4242+ }
4343+ }
4444+}
···11+{
22+ "lexicon": 1,
33+ "id": "place.wisp.v2.secret.rotate",
44+ "defs": {
55+ "main": {
66+ "type": "procedure",
77+ "description": "Rotate a webhook signing secret, generating a new token. The old token stops working immediately. The new token is only returned in this response.",
88+ "input": {
99+ "encoding": "application/json",
1010+ "schema": {
1111+ "type": "object",
1212+ "required": ["name"],
1313+ "properties": {
1414+ "name": { "type": "string", "format": "record-key" }
1515+ }
1616+ }
1717+ },
1818+ "output": {
1919+ "encoding": "application/json",
2020+ "schema": {
2121+ "type": "object",
2222+ "required": ["name", "token", "rotatedAt"],
2323+ "properties": {
2424+ "name": { "type": "string" },
2525+ "token": {
2626+ "type": "string",
2727+ "description": "The new signing token. Only returned here — store it now."
2828+ },
2929+ "rotatedAt": { "type": "string", "format": "datetime" }
3030+ }
3131+ }
3232+ },
3333+ "errors": [
3434+ { "name": "AuthenticationRequired" },
3535+ { "name": "NotFound" }
3636+ ]
3737+ }
3838+ }
3939+}
+6-1
lexicons/webhook-v1.json
···4141 "secret": {
4242 "type": "string",
4343 "maxLength": 256,
4444- "description": "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request."
4444+ "description": "Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records."
4545+ },
4646+ "secretId": {
4747+ "type": "string",
4848+ "format": "record-key",
4949+ "description": "Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present."
4550 },
4651 "enabled": {
4752 "type": "boolean",
···88export * as PlaceWispV2DomainGetList from "./types/place/wisp/v2/domain/getList.js";
99export * as PlaceWispV2DomainGetStatus from "./types/place/wisp/v2/domain/getStatus.js";
1010export * as PlaceWispV2Domains from "./types/place/wisp/v2/domains.js";
1111+export * as PlaceWispV2SecretCreate from "./types/place/wisp/v2/secret/create.js";
1212+export * as PlaceWispV2SecretDelete from "./types/place/wisp/v2/secret/delete.js";
1313+export * as PlaceWispV2SecretList from "./types/place/wisp/v2/secret/list.js";
1414+export * as PlaceWispV2SecretRotate from "./types/place/wisp/v2/secret/rotate.js";
1115export * as PlaceWispV2SiteDelete from "./types/place/wisp/v2/site/delete.js";
1216export * as PlaceWispV2SiteGetDomains from "./types/place/wisp/v2/site/getDomains.js";
1317export * as PlaceWispV2SiteGetList from "./types/place/wisp/v2/site/getList.js";
···4343 return atUriSchema;
4444 },
4545 /**
4646- * Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request.
4646+ * Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records.
4747 * @maxLength 256
4848 */
4949 secret: /*#__PURE__*/ v.optional(
···5151 /*#__PURE__*/ v.stringLength(0, 256),
5252 ]),
5353 ),
5454+ /**
5555+ * Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present.
5656+ */
5757+ secretId: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.recordKeyString()),
5458 /**
5559 * HTTPS endpoint to POST the webhook payload to.
5660 * @maxLength 2048
+62
packages/@wispplace/lexicons/src/index.ts
···1616import * as PlaceWispV2DomainDelete from './types/place/wisp/v2/domain/delete.js'
1717import * as PlaceWispV2DomainGetList from './types/place/wisp/v2/domain/getList.js'
1818import * as PlaceWispV2DomainGetStatus from './types/place/wisp/v2/domain/getStatus.js'
1919+import * as PlaceWispV2SecretCreate from './types/place/wisp/v2/secret/create.js'
2020+import * as PlaceWispV2SecretDelete from './types/place/wisp/v2/secret/delete.js'
2121+import * as PlaceWispV2SecretList from './types/place/wisp/v2/secret/list.js'
2222+import * as PlaceWispV2SecretRotate from './types/place/wisp/v2/secret/rotate.js'
1923import * as PlaceWispV2SiteDelete from './types/place/wisp/v2/site/delete.js'
2024import * as PlaceWispV2SiteGetDomains from './types/place/wisp/v2/site/getDomains.js'
2125import * as PlaceWispV2SiteGetList from './types/place/wisp/v2/site/getList.js'
···5761export class PlaceWispV2NS {
5862 _server: Server
5963 domain: PlaceWispV2DomainNS
6464+ secret: PlaceWispV2SecretNS
6065 site: PlaceWispV2SiteNS
61666267 constructor(server: Server) {
6368 this._server = server
6469 this.domain = new PlaceWispV2DomainNS(server)
7070+ this.secret = new PlaceWispV2SecretNS(server)
6571 this.site = new PlaceWispV2SiteNS(server)
6672 }
6773}
···142148 >,
143149 ) {
144150 const nsid = 'place.wisp.v2.domain.getStatus' // @ts-ignore
151151+ return this._server.xrpc.method(nsid, cfg)
152152+ }
153153+}
154154+155155+export class PlaceWispV2SecretNS {
156156+ _server: Server
157157+158158+ constructor(server: Server) {
159159+ this._server = server
160160+ }
161161+162162+ create<A extends Auth = void>(
163163+ cfg: MethodConfigOrHandler<
164164+ A,
165165+ PlaceWispV2SecretCreate.QueryParams,
166166+ PlaceWispV2SecretCreate.HandlerInput,
167167+ PlaceWispV2SecretCreate.HandlerOutput
168168+ >,
169169+ ) {
170170+ const nsid = 'place.wisp.v2.secret.create' // @ts-ignore
171171+ return this._server.xrpc.method(nsid, cfg)
172172+ }
173173+174174+ delete<A extends Auth = void>(
175175+ cfg: MethodConfigOrHandler<
176176+ A,
177177+ PlaceWispV2SecretDelete.QueryParams,
178178+ PlaceWispV2SecretDelete.HandlerInput,
179179+ PlaceWispV2SecretDelete.HandlerOutput
180180+ >,
181181+ ) {
182182+ const nsid = 'place.wisp.v2.secret.delete' // @ts-ignore
183183+ return this._server.xrpc.method(nsid, cfg)
184184+ }
185185+186186+ list<A extends Auth = void>(
187187+ cfg: MethodConfigOrHandler<
188188+ A,
189189+ PlaceWispV2SecretList.QueryParams,
190190+ PlaceWispV2SecretList.HandlerInput,
191191+ PlaceWispV2SecretList.HandlerOutput
192192+ >,
193193+ ) {
194194+ const nsid = 'place.wisp.v2.secret.list' // @ts-ignore
195195+ return this._server.xrpc.method(nsid, cfg)
196196+ }
197197+198198+ rotate<A extends Auth = void>(
199199+ cfg: MethodConfigOrHandler<
200200+ A,
201201+ PlaceWispV2SecretRotate.QueryParams,
202202+ PlaceWispV2SecretRotate.HandlerInput,
203203+ PlaceWispV2SecretRotate.HandlerOutput
204204+ >,
205205+ ) {
206206+ const nsid = 'place.wisp.v2.secret.rotate' // @ts-ignore
145207 return this._server.xrpc.method(nsid, cfg)
146208 }
147209}
+202-1
packages/@wispplace/lexicons/src/lexicons.ts
···667667 },
668668 },
669669 },
670670+ PlaceWispV2SecretCreate: {
671671+ lexicon: 1,
672672+ id: 'place.wisp.v2.secret.create',
673673+ defs: {
674674+ main: {
675675+ type: 'procedure',
676676+ description:
677677+ 'Create a named webhook signing secret. The server generates a short random token returned once in the response. Reference the secret by name via secretId in place.wisp.v2.wh.',
678678+ input: {
679679+ encoding: 'application/json',
680680+ schema: {
681681+ type: 'object',
682682+ required: ['name'],
683683+ properties: {
684684+ name: {
685685+ type: 'string',
686686+ format: 'record-key',
687687+ description:
688688+ 'Unique name for this secret, scoped to the caller DID.',
689689+ },
690690+ },
691691+ },
692692+ },
693693+ output: {
694694+ encoding: 'application/json',
695695+ schema: {
696696+ type: 'object',
697697+ required: ['name', 'token', 'createdAt'],
698698+ properties: {
699699+ name: {
700700+ type: 'string',
701701+ },
702702+ token: {
703703+ type: 'string',
704704+ description:
705705+ 'The signing token. Only returned at creation time — store it now.',
706706+ },
707707+ createdAt: {
708708+ type: 'string',
709709+ format: 'datetime',
710710+ },
711711+ },
712712+ },
713713+ },
714714+ errors: [
715715+ {
716716+ name: 'AuthenticationRequired',
717717+ },
718718+ {
719719+ name: 'InvalidRequest',
720720+ },
721721+ {
722722+ name: 'AlreadyExists',
723723+ },
724724+ ],
725725+ },
726726+ },
727727+ },
728728+ PlaceWispV2SecretDelete: {
729729+ lexicon: 1,
730730+ id: 'place.wisp.v2.secret.delete',
731731+ defs: {
732732+ main: {
733733+ type: 'procedure',
734734+ description: 'Delete a webhook signing secret by name.',
735735+ input: {
736736+ encoding: 'application/json',
737737+ schema: {
738738+ type: 'object',
739739+ required: ['name'],
740740+ properties: {
741741+ name: {
742742+ type: 'string',
743743+ format: 'record-key',
744744+ },
745745+ },
746746+ },
747747+ },
748748+ errors: [
749749+ {
750750+ name: 'AuthenticationRequired',
751751+ },
752752+ {
753753+ name: 'NotFound',
754754+ },
755755+ ],
756756+ },
757757+ },
758758+ },
759759+ PlaceWispV2SecretList: {
760760+ lexicon: 1,
761761+ id: 'place.wisp.v2.secret.list',
762762+ defs: {
763763+ main: {
764764+ type: 'query',
765765+ description:
766766+ 'List webhook signing secrets for the caller DID. Token values are never returned.',
767767+ output: {
768768+ encoding: 'application/json',
769769+ schema: {
770770+ type: 'object',
771771+ required: ['secrets'],
772772+ properties: {
773773+ secrets: {
774774+ type: 'array',
775775+ items: {
776776+ type: 'ref',
777777+ ref: 'lex:place.wisp.v2.secret.list#secretMeta',
778778+ },
779779+ },
780780+ },
781781+ },
782782+ },
783783+ errors: [
784784+ {
785785+ name: 'AuthenticationRequired',
786786+ },
787787+ ],
788788+ },
789789+ secretMeta: {
790790+ type: 'object',
791791+ required: ['name', 'createdAt'],
792792+ properties: {
793793+ name: {
794794+ type: 'string',
795795+ },
796796+ createdAt: {
797797+ type: 'string',
798798+ format: 'datetime',
799799+ },
800800+ lastRotatedAt: {
801801+ type: 'string',
802802+ format: 'datetime',
803803+ },
804804+ },
805805+ },
806806+ },
807807+ },
808808+ PlaceWispV2SecretRotate: {
809809+ lexicon: 1,
810810+ id: 'place.wisp.v2.secret.rotate',
811811+ defs: {
812812+ main: {
813813+ type: 'procedure',
814814+ description:
815815+ 'Rotate a webhook signing secret, generating a new token. The old token stops working immediately. The new token is only returned in this response.',
816816+ input: {
817817+ encoding: 'application/json',
818818+ schema: {
819819+ type: 'object',
820820+ required: ['name'],
821821+ properties: {
822822+ name: {
823823+ type: 'string',
824824+ format: 'record-key',
825825+ },
826826+ },
827827+ },
828828+ },
829829+ output: {
830830+ encoding: 'application/json',
831831+ schema: {
832832+ type: 'object',
833833+ required: ['name', 'token', 'rotatedAt'],
834834+ properties: {
835835+ name: {
836836+ type: 'string',
837837+ },
838838+ token: {
839839+ type: 'string',
840840+ description:
841841+ 'The new signing token. Only returned here — store it now.',
842842+ },
843843+ rotatedAt: {
844844+ type: 'string',
845845+ format: 'datetime',
846846+ },
847847+ },
848848+ },
849849+ },
850850+ errors: [
851851+ {
852852+ name: 'AuthenticationRequired',
853853+ },
854854+ {
855855+ name: 'NotFound',
856856+ },
857857+ ],
858858+ },
859859+ },
860860+ },
670861 PlaceWispSettings: {
671862 lexicon: 1,
672863 id: 'place.wisp.settings',
···11291320 type: 'string',
11301321 maxLength: 256,
11311322 description:
11321132- "Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request.",
13231323+ 'Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records.',
13241324+ },
13251325+ secretId: {
13261326+ type: 'string',
13271327+ format: 'record-key',
13281328+ description:
13291329+ 'Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present.',
11331330 },
11341331 enabled: {
11351332 type: 'boolean',
···12031400 PlaceWispV2DomainGetStatus: 'place.wisp.v2.domain.getStatus',
12041401 PlaceWispV2Domains: 'place.wisp.v2.domains',
12051402 PlaceWispFs: 'place.wisp.fs',
14031403+ PlaceWispV2SecretCreate: 'place.wisp.v2.secret.create',
14041404+ PlaceWispV2SecretDelete: 'place.wisp.v2.secret.delete',
14051405+ PlaceWispV2SecretList: 'place.wisp.v2.secret.list',
14061406+ PlaceWispV2SecretRotate: 'place.wisp.v2.secret.rotate',
12061407 PlaceWispSettings: 'place.wisp.settings',
12071408 PlaceWispV2SiteDelete: 'place.wisp.v2.site.delete',
12081409 PlaceWispV2SiteGetDomains: 'place.wisp.v2.site.getDomains',
···2121 url: string
2222 /** Which record events to trigger on. Defaults to all events if omitted. */
2323 events?: ('create' | 'update' | 'delete')[]
2424- /** Optional secret used to sign the webhook payload with HMAC-SHA256. The signature is included in the 'X-Webhook-Signature' header of the webhook request. */
2424+ /** Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. */
2525 secret?: string
2626+ /** Name of a server-managed signing secret created via place.wisp.v2.secret.create. Takes precedence over secret if both are present. */
2727+ secretId?: string
2628 /** Whether the webhook is active. Defaults to true if omitted. */
2729 enabled?: boolean
2830 /** Timestamp of when the webhook was created. */