@objectstack/plugin-webhooks 16.1.0 → 17.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/{chunk-RBCYKJVL.js → chunk-6EPMRZ7I.js} +28 -2
  3. package/dist/chunk-6EPMRZ7I.js.map +1 -0
  4. package/dist/{chunk-PTMJ5BLL.cjs → chunk-QUTQSOQC.cjs} +28 -2
  5. package/dist/chunk-QUTQSOQC.cjs.map +1 -0
  6. package/dist/index.cjs +250 -33
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.d.cts +25 -0
  9. package/dist/index.d.ts +25 -0
  10. package/dist/index.js +231 -14
  11. package/dist/index.js.map +1 -1
  12. package/dist/schema.cjs +2 -2
  13. package/dist/schema.d.cts +515 -304
  14. package/dist/schema.d.ts +515 -304
  15. package/dist/schema.js +1 -1
  16. package/dist/translations-CBQRUIS5.cjs +383 -0
  17. package/dist/translations-CBQRUIS5.cjs.map +1 -0
  18. package/dist/translations-Y3CXWOTM.js +383 -0
  19. package/dist/translations-Y3CXWOTM.js.map +1 -0
  20. package/package.json +11 -5
  21. package/.turbo/turbo-build.log +0 -36
  22. package/dist/chunk-PTMJ5BLL.cjs.map +0 -1
  23. package/dist/chunk-RBCYKJVL.js.map +0 -1
  24. package/dist/translations-EPJYANXJ.js +0 -727
  25. package/dist/translations-EPJYANXJ.js.map +0 -1
  26. package/dist/translations-L2JAQDJB.cjs +0 -727
  27. package/dist/translations-L2JAQDJB.cjs.map +0 -1
  28. package/scripts/i18n-extract.config.ts +0 -32
  29. package/src/auto-enqueuer.test.ts +0 -431
  30. package/src/auto-enqueuer.ts +0 -383
  31. package/src/index.ts +0 -29
  32. package/src/schema.ts +0 -21
  33. package/src/sys-webhook.object.ts +0 -193
  34. package/src/translations/en.objects.generated.ts +0 -187
  35. package/src/translations/es-ES.objects.generated.ts +0 -187
  36. package/src/translations/index.ts +0 -23
  37. package/src/translations/ja-JP.objects.generated.ts +0 -187
  38. package/src/translations/zh-CN.objects.generated.ts +0 -187
  39. package/src/webhook-outbox-plugin.ts +0 -259
  40. package/tsconfig.json +0 -10
  41. package/tsup.config.ts +0 -14
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/webhook-outbox-plugin.ts"],"names":[],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<'create' | 'update' | 'delete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,\n );\n if (triggers.size === 0) {\n // No dispatchable triggers (or a manual-only webhook with none) —\n // skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n payload: {\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n ...payload,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: 'unauthenticated', message: 'Sign in to redeliver webhook deliveries.' },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: 'invalid_body', message: 'Request body must be JSON.' }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: 'missing_delivery_id', message: 'Body must include `deliveryId: string`.' },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'not_found') {\n return c.json({ success: false, error: 'not_found', message: err.message }, 404);\n }\n if (code === 'not_eligible') {\n return c.json({ success: false, error: 'not_eligible', message: err.message }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: 'internal_error', message: err?.message ?? String(err) },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","../src/auto-enqueuer.ts","../src/bootstrap-declared-webhooks.ts","../src/webhook-provenance.ts","../src/webhook-outbox-plugin.ts"],"names":["SYSTEM_CTX"],"mappings":"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","file":"/home/runner/work/objectstack/objectstack/packages/plugins/plugin-webhooks/dist/index.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\n\n/**\n * Enqueue callback into the shared `service-messaging` HTTP outbox (ADR-0018 M3).\n * The plugin supplies one bound to `messaging.enqueueHttp(...)`; webhooks no\n * longer own a delivery outbox/dispatcher — they share the generic substrate.\n */\nexport type HttpEnqueueFn = (input: EnqueueHttpInput) => Promise<string>;\n\n/**\n * Optional logger interface (subset of console / kernel logger).\n */\ninterface OptionalLogger {\n info?(msg: string, meta?: unknown): void;\n warn?(msg: string, meta?: unknown): void;\n debug?(msg: string, meta?: unknown): void;\n error?(msg: string, err?: unknown, meta?: unknown): void;\n}\n\n/**\n * Per-row subscription cached in memory. Mirrors a subset of the\n * `sys_webhook` object — only what the auto-enqueuer needs to match an\n * event and build an `EnqueueInput`.\n */\ninterface CachedSubscription {\n id: string;\n name: string;\n objectName: string | undefined; // empty = matches all objects\n triggers: Set<'create' | 'update' | 'delete'>;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n secret?: string;\n timeoutMs?: number;\n}\n\nexport interface AutoEnqueuerOptions {\n /**\n * Object name holding webhook subscriptions. Defaults to `sys_webhook`,\n * the platform-objects schema authored in apps.\n */\n subscriptionsObject?: string;\n\n /**\n * Periodic full-cache refresh interval (ms). Belt-and-braces in case\n * the subscription-change event is missed. Default 60s.\n */\n refreshIntervalMs?: number;\n\n logger?: OptionalLogger;\n}\n\n/**\n * Bridge between `IRealtimeService` (`data.record.*` events emitted by\n * the engine) and `IWebhookOutbox` (durable delivery rows the dispatcher\n * picks up).\n *\n * ## Why a separate class\n * Keeps `WebhookOutboxPlugin` lean: the plugin wires services, this\n * class owns the runtime fan-out logic + subscription cache.\n *\n * ## Hot path\n * Every `engine.insert/update/delete` fires a `data.record.*` event.\n * The handler:\n * 1. Looks up matching subscriptions in an in-memory `Map<object, sub[]>`\n * — O(1) per event, no DB hit on the write path.\n * 2. Calls `outbox.enqueue()` fire-and-forget for each match. The\n * enqueue itself is a single INSERT, which runs *after* the user's\n * request has already returned.\n *\n * Net cost on the write path: one synchronous Map lookup (~microseconds).\n *\n * ## Cache freshness\n * The cache is rebuilt:\n * 1. Once on `start()`.\n * 2. On every `data.record.{created,updated,deleted}` event whose\n * object is `sys_webhook` (self-healing — when a user toggles a\n * webhook, the handler refreshes the cache before returning).\n * 3. Periodically (default 60s) as belt-and-braces.\n *\n * For multi-node clusters this is *eventually consistent* — node B may\n * not see node A's edit for up to one cycle. That's acceptable for\n * webhook configuration changes (humans don't expect millisecond\n * propagation) and matches Hasura's behaviour.\n *\n * ## Determinism\n * `eventId` is computed from `${object}:${recordId}:${type}:${timestamp}`\n * so the outbox dedup index catches duplicates that could arise from\n * upstream replay or buggy producers — and is stable across nodes.\n */\nexport class AutoEnqueuer {\n private readonly subscriptions = new Map<string, CachedSubscription[]>();\n private readonly subscriptionsObject: string;\n private readonly refreshIntervalMs: number;\n private readonly logger: OptionalLogger;\n private subId: string | undefined;\n private subIdSelfHeal: string | undefined;\n private refreshTimer: ReturnType<typeof setInterval> | undefined;\n private running = false;\n private refreshing: Promise<void> | undefined;\n\n constructor(\n private readonly engine: IDataEngine,\n private readonly realtime: IRealtimeService,\n private readonly enqueue: HttpEnqueueFn,\n opts: AutoEnqueuerOptions = {},\n ) {\n this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook';\n this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000;\n this.logger = opts.logger ?? {};\n }\n\n /**\n * Load the subscription cache and start listening for events.\n */\n async start(): Promise<void> {\n if (this.running) return;\n this.running = true;\n\n await this.refresh();\n\n // Main subscription: every data event → match → enqueue.\n this.subId = await this.realtime.subscribe(\n 'webhook-auto-enqueuer',\n (event) => this.handleEvent(event),\n );\n\n // Self-healing: any change to sys_webhook refreshes the cache.\n this.subIdSelfHeal = await this.realtime.subscribe(\n 'webhook-auto-enqueuer-self-heal',\n (event) => this.handleSelfHealEvent(event),\n { object: this.subscriptionsObject },\n );\n\n if (this.refreshIntervalMs > 0) {\n this.refreshTimer = setInterval(() => {\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] periodic refresh failed', err),\n );\n }, this.refreshIntervalMs);\n // Don't keep the process alive solely for this timer.\n this.refreshTimer.unref?.();\n }\n }\n\n async stop(): Promise<void> {\n if (!this.running) return;\n this.running = false;\n if (this.subId) await this.realtime.unsubscribe(this.subId);\n if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal);\n if (this.refreshTimer) clearInterval(this.refreshTimer);\n this.subId = undefined;\n this.subIdSelfHeal = undefined;\n this.refreshTimer = undefined;\n }\n\n /**\n * Force-refresh the subscription cache from storage. Concurrent\n * callers share a single in-flight refresh.\n */\n async refresh(): Promise<void> {\n if (this.refreshing) return this.refreshing;\n this.refreshing = this.doRefresh().finally(() => {\n this.refreshing = undefined;\n });\n return this.refreshing;\n }\n\n private async doRefresh(): Promise<void> {\n let rows: any[];\n try {\n rows = await this.engine.find(this.subscriptionsObject, {\n where: { active: true },\n });\n } catch (err) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] failed to load ${this.subscriptionsObject}`,\n err,\n );\n return;\n }\n\n const next = new Map<string, CachedSubscription[]>();\n for (const row of rows) {\n const sub = this.parseRow(row);\n if (!sub) continue;\n // Empty objectName == \"any object\" → indexed under '*'.\n const key = sub.objectName ?? '*';\n const arr = next.get(key) ?? [];\n arr.push(sub);\n next.set(key, arr);\n }\n\n this.subscriptions.clear();\n for (const [k, v] of next) this.subscriptions.set(k, v);\n\n this.logger.debug?.('[webhook-auto-enqueuer] cache refreshed', {\n objects: this.subscriptions.size,\n rows: rows.length,\n });\n }\n\n private parseRow(row: any): CachedSubscription | null {\n if (!row?.id || !row?.url) return null;\n // `triggers` is now authored as a multi-select (stored as an array), but\n // legacy rows stored a comma-separated string (and some drivers hand a\n // JSON-encoded array back as a string). Accept all three shapes so a\n // schema change never silently drops a subscription's events.\n const rawTriggers = row.triggers;\n let triggerList: string[];\n if (Array.isArray(rawTriggers)) {\n triggerList = rawTriggers.map((t) => String(t));\n } else {\n const s = String(rawTriggers ?? '').trim();\n if (s.startsWith('[')) {\n try {\n const parsed = JSON.parse(s);\n triggerList = Array.isArray(parsed) ? parsed.map((t) => String(t)) : [s];\n } catch {\n triggerList = s.split(',');\n }\n } else {\n triggerList = s.split(',');\n }\n }\n const normalized = triggerList.map((t) => t.trim().toLowerCase()).filter(Boolean);\n // [#3196] Drop (and warn about) any trigger the enqueuer can't map to an\n // emitted record event — e.g. a legacy `sys_webhook` row authored with\n // the now-removed `undelete`/`api` values, which would otherwise sit in\n // the cache matching nothing. A loud drift-guard so a dead trigger can't\n // silently no-op again.\n const unknown = normalized.filter((t) => !DISPATCHABLE_WEBHOOK_TRIGGERS.has(t));\n if (unknown.length > 0) {\n this.logger.warn?.(\n `[webhook-auto-enqueuer] webhook '${(row.name as string) ?? row.id}' declares trigger(s) the engine never emits: ` +\n `${unknown.join(', ')} — ignored. Dispatchable triggers: create, update, delete.`,\n { id: row.id, unknown },\n );\n }\n const triggers = new Set(\n normalized.filter((t) => DISPATCHABLE_WEBHOOK_TRIGGERS.has(t)) as Array<'create' | 'update' | 'delete'>,\n );\n if (triggers.size === 0) {\n // No dispatchable triggers (or a manual-only webhook with none) —\n // skip auto-enqueue.\n return null;\n }\n\n // The \"definition_json\" field carries advanced config (headers,\n // secret, timeout); attempt a best-effort parse. Fall back to\n // top-level fields where present.\n let defn: Record<string, any> = {};\n if (typeof row.definition_json === 'string' && row.definition_json.length > 0) {\n try {\n defn = JSON.parse(row.definition_json) ?? {};\n } catch {\n defn = {};\n }\n }\n\n return {\n id: row.id as string,\n name: (row.name as string) ?? row.id,\n objectName: row.object_name ? String(row.object_name) : undefined,\n triggers,\n url: String(row.url),\n // Method is authored via a select whose option values are lowercased\n // (get/post/…); upper-case here so delivery uses a canonical HTTP\n // method regardless of whether the row was authored before or after\n // the select change (legacy rows stored 'POST').\n method: String(row.method ?? defn.method ?? 'POST').toUpperCase(),\n headers: defn.headers,\n secret: defn.secret,\n timeoutMs: defn.timeoutMs,\n };\n }\n\n /**\n * Handler for the firehose subscription.\n *\n * NOTE: we intentionally `void` the inner enqueue() so the realtime\n * publisher (and therefore the user's request) is never blocked on\n * webhook persistence.\n */\n private handleEvent(event: RealtimeEventPayload): void {\n if (!event.type?.startsWith('data.record.')) return;\n if (!event.object) return;\n if (event.object === this.subscriptionsObject) return; // self-heal handles its own\n\n const action = event.type.slice('data.record.'.length) as\n | 'created' | 'updated' | 'deleted' | string;\n const trigger = mapActionToTrigger(action);\n if (!trigger) return;\n\n const subs = [\n ...(this.subscriptions.get(event.object) ?? []),\n ...(this.subscriptions.get('*') ?? []),\n ];\n if (subs.length === 0) return;\n\n const payload = event.payload ?? {};\n const recordId =\n (payload as any).recordId ??\n (payload as any).id ??\n (payload as any).after?.id ??\n (payload as any).before?.id ??\n 'unknown';\n\n // Deterministic eventId — same input on any node → same id.\n // Includes timestamp so two distinct updates to the same record\n // don't accidentally dedup.\n const eventId = `${event.object}:${recordId}:${action}:${event.timestamp}`;\n\n for (const sub of subs) {\n if (!sub.triggers.has(trigger)) continue;\n\n // Fire-and-forget — never await on the hot path. Map the webhook\n // delivery onto the generic HTTP-outbox shape (ADR-0018 M3):\n // - source 'webhook' + dedupKey '<webhookId>:<eventId>' preserves\n // the old (event_id, webhook_id) at-most-once enqueue;\n // - refId = webhookId keeps per-webhook partition affinity / ordering;\n // - label = event type → X-Objectstack-Event header.\n void this.enqueue({\n source: 'webhook',\n refId: sub.id,\n dedupKey: `${sub.id}:${eventId}`,\n label: event.type,\n url: sub.url,\n method: sub.method,\n headers: sub.headers,\n signingSecret: sub.secret,\n timeoutMs: sub.timeoutMs,\n // [#3946] Envelope keys are written LAST so the event payload\n // cannot rewrite them. Behaviour-neutral for the engine's own\n // publishers — `data.record.*` payloads are\n // `{ recordId, after, changes }`, with record fields nested\n // under `after`, so none of these four keys collide today. It\n // is the shape that was wrong: a publisher that flattened\n // record fields into the payload (the `payload.id` fallback\n // above suggests some do) would have silently rewritten the\n // `object` / `action` / `timestamp` a subscriber receives.\n payload: {\n ...payload,\n object: event.object,\n recordId,\n action,\n timestamp: event.timestamp,\n },\n }).catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] enqueue failed', {\n webhook: sub.name,\n eventId,\n err: (err as Error)?.message ?? err,\n }),\n );\n }\n }\n\n private handleSelfHealEvent(event: RealtimeEventPayload): void {\n if (event.object !== this.subscriptionsObject) return;\n if (!event.type?.startsWith('data.record.')) return;\n this.refresh().catch((err) =>\n this.logger.warn?.('[webhook-auto-enqueuer] self-heal refresh failed', err),\n );\n }\n\n /** Test / admin accessor. */\n snapshot(): ReadonlyMap<string, ReadonlyArray<CachedSubscription>> {\n return this.subscriptions;\n }\n}\n\nfunction mapActionToTrigger(\n action: string,\n): 'create' | 'update' | 'delete' | null {\n switch (action) {\n case 'created':\n return 'create';\n case 'updated':\n return 'update';\n case 'deleted':\n return 'delete';\n default:\n return null;\n }\n}\n\n/** The trigger values the enqueuer can actually map from an emitted record event. */\nconst DISPATCHABLE_WEBHOOK_TRIGGERS: ReadonlySet<string> = new Set(['create', 'update', 'delete']);\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * bootstrapDeclaredWebhooks — materialize stack/connector-declared `webhooks`\n * into `sys_webhook` rows so the dispatcher can actually see them (closes #3461).\n *\n * ## The disconnect this closes\n * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`,\n * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and\n * is generically decomposed into the ObjectQL registry at boot as metadata\n * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads\n * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only\n * ever written by hand through the object's CRUD UI. Nothing bridged the two —\n * so authoring `webhooks:` on a stack produced metadata artifacts that never\n * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that\n * missing ingestion path.\n *\n * ## Shape translation (authoring → runtime row)\n * The spec shape diverges from the runtime column names; we map only at this\n * boundary and stash the full validated envelope in `definition_json` (whence\n * the enqueuer reads headers / secret / timeout):\n * - `object` → `object_name`\n * - `isActive` → `active`\n * - `triggers` / `url` / `method` / `label` / `description` → same-named columns\n * - the entire parsed {@link Webhook} → `definition_json` (JSON string)\n *\n * Each item is validated through `WebhookSchema.parse()` first — this gives the\n * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get\n * applied) and rejects malformed authoring with a warning instead of crashing\n * boot.\n *\n * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909)\n * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks\n * ship with the app/package, so they seed with `managed_by: 'package'`\n * provenance and re-seed on every boot — but a row an admin has created\n * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by\n * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly,\n * an admin's `active: false` on a noisy webhook survives redeploys.\n *\n * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache\n * refresh already sees the declared rows.\n */\n\nimport type { IDataEngine } from '@objectstack/spec/contracts';\nimport { WebhookSchema, type Webhook } from '@objectstack/spec/automation';\n\n/** System write context — the boot seeder is not an admin authoring action. */\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\ninterface Logger {\n info?: (msg: string, meta?: unknown) => void;\n warn?: (msg: string, meta?: unknown) => void;\n}\n\n/** Random id with a stable prefix — mirrors the sharing-rule seeder. */\nfunction uid(prefix: string): string {\n const g: any = globalThis as any;\n if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;\n return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;\n}\n\n/**\n * Read declared `webhook` items from the ObjectQL registry (where the manifest\n * decomposition parks `stack.webhooks`), falling back to the metadata service.\n * Items may be wrapped as `{ content }` — unwrap to the raw authoring object.\n */\nfunction readDeclared(engine: any, metadataService: any, type: string): any[] {\n try {\n const reg = engine?._registry;\n if (reg?.listItems) {\n const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean);\n if (items.length > 0) return items;\n }\n } catch {\n /* fall through to metadata service */\n }\n try {\n const listed = metadataService?.list?.(type);\n const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []);\n return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : [];\n } catch {\n return [];\n }\n}\n\nexport interface BootstrapDeclaredWebhooksResult {\n seeded: number;\n skipped: number;\n}\n\n/**\n * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run\n * on every boot.\n */\nexport async function bootstrapDeclaredWebhooks(\n engine: IDataEngine,\n metadataService: any,\n logger?: Logger,\n subscriptionsObject = 'sys_webhook',\n): Promise<BootstrapDeclaredWebhooksResult> {\n const declared = readDeclared(engine, metadataService, 'webhook');\n if (declared.length === 0) return { seeded: 0, skipped: 0 };\n\n const now = new Date().toISOString();\n let seeded = 0;\n let skipped = 0;\n\n for (const raw of declared) {\n // Validate + fill defaults through the canonical spec schema. A real\n // consumer at last — a malformed webhook warns and is skipped, never\n // crashing boot.\n let wh: Webhook;\n try {\n wh = WebhookSchema.parse(raw);\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook failed validation — skipped', {\n name: (raw as any)?.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n continue;\n }\n\n try {\n const existing = await engine.find(subscriptionsObject, {\n where: { name: wh.name },\n limit: 1,\n context: SYSTEM_CTX,\n } as any);\n const row: any = Array.isArray(existing) ? existing[0] : undefined;\n\n if (row) {\n // Admin owns a same-named row, or has edited this seeded one — never\n // clobber. `active: false` on a noisy webhook must survive redeploys.\n if (row.managed_by === 'admin') {\n logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', {\n name: wh.name,\n });\n skipped += 1;\n continue;\n }\n if (row.customized === true) {\n skipped += 1;\n continue;\n }\n const patch = {\n id: row.id,\n ...mapWebhookToRow(wh),\n // Adopt pristine/legacy (pre-provenance) rows so future boots\n // recognize them as package-managed.\n managed_by: 'package',\n updated_at: now,\n };\n await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any);\n seeded += 1;\n continue;\n }\n\n const newRow = {\n id: uid('whk'),\n ...mapWebhookToRow(wh),\n managed_by: 'package',\n customized: false,\n created_at: now,\n updated_at: now,\n };\n await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any);\n seeded += 1;\n } catch (err: any) {\n logger?.warn?.('[webhook] declared webhook seed failed', {\n name: wh.name,\n error: err?.message ?? String(err),\n });\n skipped += 1;\n }\n }\n\n logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', {\n seeded,\n skipped,\n total: declared.length,\n });\n return { seeded, skipped };\n}\n\n/**\n * Translate a validated {@link Webhook} into `sys_webhook` column values.\n * `object → object_name`, `isActive → active`; the full envelope is stashed in\n * `definition_json` for the enqueuer's advanced-config read (headers/secret/…).\n */\nfunction mapWebhookToRow(wh: Webhook): Record<string, unknown> {\n return {\n name: wh.name,\n label: wh.label ?? wh.name,\n object_name: wh.object ?? null,\n triggers: wh.triggers ?? [],\n url: wh.url,\n // Store lowercase to match the object's Field.select option values\n // (get/post/…); the enqueuer upper-cases before delivery either way.\n method: String(wh.method ?? 'POST').toLowerCase(),\n description: wh.description ?? null,\n active: wh.isActive !== false,\n definition_json: JSON.stringify(wh),\n };\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * [#3461] Provenance stamp for `sys_webhook`.\n *\n * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed\n * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such\n * as flipping a noisy webhook to `active: false` — is the authority. The seeder\n * skips rows marked `customized`, so this hook is the half that DETECTS the\n * admin edit: any non-system update touching a `package`/`platform`-seeded row\n * stamps `customized: true` onto the payload.\n *\n * Why a data hook (and not a write gate or the REST layer), verbatim to the\n * sys_sharing_rule rationale (#2909 T1):\n * - admins edit webhooks through several doors (Setup UI generic data door,\n * scripts, console) — an engine hook covers them all;\n * - there is deliberately NO write gate here: webhooks are a first-class admin\n * authoring surface, so edits are allowed — they just have to be remembered;\n * - both provenance columns are `readonly`, and the engine's readonly strip\n * exempts isSystem callers while snapshotting supplied keys BEFORE hooks run\n * — so a caller can never forge/clear `customized`, while this hook's stamp\n * survives.\n *\n * Known boundary: multi-row updates (no single `input.id`) are not stamped —\n * every webhook-editing UI path updates by id.\n */\n\ninterface MinimalEngine {\n find(object: string, opts?: any): Promise<any[]>;\n registerHook(event: string, handler: (ctx: any) => any, options?: Record<string, any>): void;\n unregisterHooksByPackage(packageId: string): number;\n}\n\ninterface MinimalLogger {\n info?: (msg: string, meta?: Record<string, any>) => void;\n warn?: (msg: string, meta?: Record<string, any>) => void;\n}\n\nexport const WEBHOOK_PROVENANCE_PACKAGE = 'plugin-webhooks:provenance';\n\nconst SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;\n\nexport function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: MinimalLogger): void {\n if (typeof engine?.registerHook !== 'function') return;\n engine.registerHook(\n 'beforeUpdate',\n async (ctx: any) => {\n // Seeder / boot reconcilers write with isSystem — the package door, not\n // an admin customization.\n if ((ctx?.session as any)?.isSystem) return;\n const id = ctx?.input?.id ?? (ctx?.input?.data as any)?.id;\n if (!id) return; // multi-row update — see boundary note above\n const data = ctx?.input?.data;\n if (!data || typeof data !== 'object') return;\n try {\n // `previous` is not resolved before beforeUpdate hooks run — read the\n // current row ourselves (system ctx: this is a provenance check, not\n // an authorization decision).\n const rows = await engine.find('sys_webhook', {\n where: { id },\n fields: ['id', 'managed_by', 'customized'],\n limit: 1,\n context: SYSTEM_CTX,\n });\n const row = Array.isArray(rows) ? rows[0] : undefined;\n if (!row) return;\n if ((row.managed_by === 'package' || row.managed_by === 'platform') && row.customized !== true) {\n (data as any).customized = true;\n }\n } catch (err: any) {\n logger?.warn?.('[webhook] provenance stamp failed (edit proceeds unstamped)', {\n id,\n error: err?.message,\n });\n }\n },\n { object: 'sys_webhook', packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 },\n );\n logger?.info?.('[webhook] provenance stamp hook bound');\n}\n\nexport function unbindWebhookProvenanceStamp(engine: MinimalEngine): void {\n if (typeof engine?.unregisterHooksByPackage === 'function') {\n engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);\n }\n}\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IDataEngine, IRealtimeService } from '@objectstack/spec/contracts';\nimport type { EnqueueHttpInput } from '@objectstack/service-messaging';\nimport { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';\nimport { SysWebhook } from './sys-webhook.object.js';\nimport { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js';\nimport { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js';\n\n/**\n * Structural view of `@objectstack/service-messaging`'s HTTP-outbox surface\n * (ADR-0018 M3) — declared locally so this plugin doesn't take a hard runtime\n * import on the service. Webhook deliveries are enqueued onto the shared\n * `sys_http_delivery` outbox and drained by the messaging `HttpDispatcher`.\n */\ninterface MessagingHttpSurface {\n isHttpDeliveryReady(): boolean;\n enqueueHttp(input: EnqueueHttpInput): Promise<string>;\n redeliverHttp(id: string): Promise<{ id: string; status: string }>;\n}\n\nexport interface WebhookOutboxPluginOptions {\n /**\n * Auto-enqueue config. When enabled (default `true` if the realtime + data\n * engine services are available), the plugin subscribes to `data.record.*`\n * events and enqueues a delivery onto the shared messaging HTTP outbox for\n * every matching `sys_webhook` row.\n *\n * Set `false` to disable and enqueue webhooks imperatively elsewhere.\n */\n autoEnqueue?: boolean | AutoEnqueuerOptions;\n}\n\n/**\n * Wires webhook fan-out on top of the shared outbound-HTTP delivery substrate\n * (ADR-0018 M3).\n *\n * Webhooks are no longer their own delivery engine: the durable outbox, the\n * cluster-coordinated dispatcher, the retry/backoff/dead-letter schedule, and\n * the retention sweep all live in `@objectstack/service-messaging`\n * (`sys_http_delivery` + `HttpDispatcher`). This plugin owns only the\n * webhook-specific concerns:\n * - the `sys_webhook` configuration object,\n * - the {@link AutoEnqueuer} that turns `data.record.*` events into outbox\n * rows (`source: 'webhook'`), and\n * - the redeliver admin endpoint.\n *\n * End-to-end flow:\n *\n * engine.insert('contact', {...})\n * → engine publishes data.record.created via IRealtimeService\n * → AutoEnqueuer matches active sys_webhook rows in O(1)\n * → messaging.enqueueHttp() runs fire-and-forget (off the write path)\n * → messaging HttpDispatcher claims and POSTs (cluster-coordinated, retried)\n *\n * **Requires** `MessagingServicePlugin` (`@objectstack/service-messaging`),\n * which is a foundational, always-on capability.\n */\nexport class WebhookOutboxPlugin implements Plugin {\n name = 'com.objectstack.plugin-webhook-outbox';\n version = '2.0.0';\n type = 'standard' as const;\n dependencies = ['com.objectstack.service.messaging'];\n /**\n * `init()` registers this plugin's schema through `manifest` with no\n * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens\n * to depend on ObjectQL, which provides `manifest` — so the guarantee would\n * have evaporated silently the day messaging stopped depending on the\n * engine, and the failure would have surfaced as an unrelated plugin's\n * init crash. Declaring the requirement directly makes the kernel check it\n * regardless of what messaging depends on.\n */\n requiresServices = ['manifest'];\n\n private autoEnqueuer: AutoEnqueuer | undefined;\n /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */\n private boundEngine: any;\n\n constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}\n\n async init(ctx: PluginContext): Promise<void> {\n // Register the webhook config object (ADR-0029 K2.a). The delivery\n // telemetry now lives in messaging's `sys_http_delivery`, so the nav's\n // \"Deliveries\" entry points there (filtered to source=webhook in views).\n const manifest = ctx.getService<{ register(m: any): void }>('manifest');\n if (manifest && typeof manifest.register === 'function') {\n manifest.register({\n id: 'com.objectstack.plugin-webhook-outbox.schema',\n namespace: 'sys',\n version: this.version,\n type: 'plugin',\n scope: 'system',\n name: 'Webhook Schemas',\n description: 'Registers sys_webhook (configuration). Deliveries use messaging\\'s sys_http_delivery outbox.',\n objects: [SysWebhook],\n navigationContributions: [\n {\n app: 'setup',\n group: 'group_integrations',\n priority: 100,\n items: [\n { id: 'nav_webhooks', type: 'object', label: 'Webhooks', objectName: 'sys_webhook', icon: 'webhook', requiresObject: 'sys_webhook' },\n { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries', objectName: 'sys_http_delivery', icon: 'send', requiresObject: 'sys_http_delivery' },\n ],\n },\n ],\n });\n } else {\n ctx.logger.warn?.(\n '[webhook-outbox] manifest service unavailable — sys_webhook will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',\n );\n }\n\n // ADR-0029 D8 — contribute object translations once i18n is up.\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n try {\n const i18n = ctx.getService<any>('i18n');\n if (i18n && typeof i18n.loadTranslations === 'function') {\n const { WebhooksTranslations } = await import('./translations/index.js');\n for (const [locale, data] of Object.entries(WebhooksTranslations)) {\n i18n.loadTranslations(locale, data as Record<string, unknown>);\n }\n }\n } catch { /* i18n optional */ }\n });\n }\n\n const autoEnqueueOpt = this.options.autoEnqueue ?? true;\n\n if (typeof (ctx as any).hook === 'function') {\n (ctx as any).hook('kernel:ready', async () => {\n // Materialize declared webhooks FIRST — this only needs the data\n // engine, so it must not be gated behind the auto-enqueue\n // dispatch prerequisites (realtime + messaging). Otherwise a\n // deployment without realtime would silently fail to materialize\n // declared webhooks — the very no-op this bridge closes (#3461).\n await this.bootDeclaredWebhooks(ctx);\n await this.bootAutoEnqueue(ctx, autoEnqueueOpt);\n this.registerAdminRoutes(ctx);\n });\n }\n\n ctx.logger.info?.('[webhook-outbox] initialised (delivery via shared messaging HTTP outbox)', {\n autoEnqueue: autoEnqueueOpt !== false,\n });\n }\n\n async dispose(): Promise<void> {\n await this.autoEnqueuer?.stop();\n if (this.boundEngine) {\n try { unbindWebhookProvenanceStamp(this.boundEngine); } catch { /* best effort */ }\n this.boundEngine = undefined;\n }\n }\n\n private getMessaging(ctx: PluginContext): MessagingHttpSurface | undefined {\n const svc = this.tryGetService<MessagingHttpSurface>(ctx, ['messaging']);\n return svc && typeof svc.enqueueHttp === 'function' ? svc : undefined;\n }\n\n /**\n * [#3461] Bridge the declarative authoring surface to the dispatcher:\n * materialize stack/connector-declared `webhook` metadata into `sys_webhook`\n * rows so the auto-enqueuer (and the Studio UI) can see them.\n *\n * Gated on the DATA ENGINE alone — deliberately independent of the\n * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing\n * rows is a pure write; a deployment that mounts the webhook plugin without\n * realtime must still get its declared webhooks into the table (and the\n * Setup UI), even if nothing dispatches them yet. Runs before\n * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.\n */\n private async bootDeclaredWebhooks(ctx: PluginContext): Promise<void> {\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n if (!engine) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available');\n return;\n }\n // Bind the provenance stamp so an admin edit freezes a seeded row.\n this.boundEngine = engine;\n bindWebhookProvenanceStamp(engine as any, ctx.logger as any);\n let metadataService: any;\n try { metadataService = ctx.getService('metadata'); } catch { /* optional */ }\n try {\n await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any);\n } catch (err: any) {\n ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', {\n error: err?.message ?? String(err),\n });\n }\n }\n\n private async bootAutoEnqueue(\n ctx: PluginContext,\n opt: boolean | AutoEnqueuerOptions,\n ): Promise<void> {\n if (opt === false) return;\n const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);\n const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);\n const messaging = this.getMessaging(ctx);\n if (!engine || !realtime || !messaging) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available',\n { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging },\n );\n return;\n }\n if (!messaging.isHttpDeliveryReady()) {\n ctx.logger.warn?.(\n '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable',\n );\n }\n\n const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;\n this.autoEnqueuer = new AutoEnqueuer(\n engine,\n realtime,\n (input) => messaging.enqueueHttp(input),\n { ...enqOpts, logger: ctx.logger },\n );\n await this.autoEnqueuer.start();\n ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);\n ctx.logger.info?.('[webhook-auto-enqueuer] started (enqueues source=webhook onto sys_http_delivery)');\n }\n\n private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {\n for (const n of names) {\n try {\n const svc = ctx.getService<T>(n);\n if (svc) return svc;\n } catch {\n // fall through\n }\n }\n return undefined;\n }\n\n /**\n * Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one is\n * available. Delegates to `messaging.redeliverHttp(deliveryId)`. Auth is the\n * better-auth session cookie — every authenticated user counts.\n */\n private registerAdminRoutes(ctx: PluginContext): void {\n const http = this.tryGetService<any>(ctx, ['http-server']);\n if (!http || typeof http.getRawApp !== 'function') {\n ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted');\n return;\n }\n const rawApp = http.getRawApp();\n const messaging = this.getMessaging(ctx);\n if (!rawApp || !messaging) return;\n\n rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {\n const userId = await this.resolveSessionUserId(ctx, c);\n if (!userId) {\n return c.json(\n { success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to redeliver webhook deliveries.' } },\n 401,\n );\n }\n let body: any;\n try {\n body = await c.req.json();\n } catch {\n return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }, 400);\n }\n const deliveryId = typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';\n if (!deliveryId) {\n return c.json(\n { success: false, error: { code: 'MISSING_REQUIRED_FIELD', message: 'Body must include `deliveryId: string`.' } },\n 400,\n );\n }\n try {\n const row = await messaging.redeliverHttp(deliveryId);\n ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId });\n return c.json({ success: true, data: { id: row.id, status: row.status } });\n } catch (err: any) {\n const code = err?.code;\n if (code === 'RESOURCE_NOT_FOUND') {\n return c.json({ success: false, error: { code, message: err.message } }, 404);\n }\n if (code === 'DELIVERY_NOT_ELIGIBLE') {\n return c.json({ success: false, error: { code, message: err.message } }, 409);\n }\n ctx.logger.error?.('[webhook-outbox] redeliver failed', err as Error);\n return c.json(\n { success: false, error: { code: 'INTERNAL_ERROR', message: err?.message ?? String(err) } },\n 500,\n );\n }\n });\n\n ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver');\n }\n\n private async resolveSessionUserId(ctx: PluginContext, c: any): Promise<string | undefined> {\n try {\n const authService: any = this.tryGetService<any>(ctx, ['auth']);\n if (!authService) return undefined;\n let api: any = authService.api;\n if (!api && typeof authService.getApi === 'function') {\n api = await authService.getApi();\n }\n if (!api?.getSession) return undefined;\n const session = await api.getSession({ headers: c.req.raw.headers });\n const uid = session?.user?.id;\n return typeof uid === 'string' && uid.length > 0 ? uid : undefined;\n } catch {\n return undefined;\n }\n }\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -167,11 +167,36 @@ declare class WebhookOutboxPlugin implements Plugin {
167
167
  version: string;
168
168
  type: "standard";
169
169
  dependencies: string[];
170
+ /**
171
+ * `init()` registers this plugin's schema through `manifest` with no
172
+ * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens
173
+ * to depend on ObjectQL, which provides `manifest` — so the guarantee would
174
+ * have evaporated silently the day messaging stopped depending on the
175
+ * engine, and the failure would have surfaced as an unrelated plugin's
176
+ * init crash. Declaring the requirement directly makes the kernel check it
177
+ * regardless of what messaging depends on.
178
+ */
179
+ requiresServices: string[];
170
180
  private autoEnqueuer;
181
+ /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */
182
+ private boundEngine;
171
183
  constructor(options?: WebhookOutboxPluginOptions);
172
184
  init(ctx: PluginContext): Promise<void>;
173
185
  dispose(): Promise<void>;
174
186
  private getMessaging;
187
+ /**
188
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
189
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
190
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
191
+ *
192
+ * Gated on the DATA ENGINE alone — deliberately independent of the
193
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
194
+ * rows is a pure write; a deployment that mounts the webhook plugin without
195
+ * realtime must still get its declared webhooks into the table (and the
196
+ * Setup UI), even if nothing dispatches them yet. Runs before
197
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
198
+ */
199
+ private bootDeclaredWebhooks;
175
200
  private bootAutoEnqueue;
176
201
  private tryGetService;
177
202
  /**
package/dist/index.d.ts CHANGED
@@ -167,11 +167,36 @@ declare class WebhookOutboxPlugin implements Plugin {
167
167
  version: string;
168
168
  type: "standard";
169
169
  dependencies: string[];
170
+ /**
171
+ * `init()` registers this plugin's schema through `manifest` with no
172
+ * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens
173
+ * to depend on ObjectQL, which provides `manifest` — so the guarantee would
174
+ * have evaporated silently the day messaging stopped depending on the
175
+ * engine, and the failure would have surfaced as an unrelated plugin's
176
+ * init crash. Declaring the requirement directly makes the kernel check it
177
+ * regardless of what messaging depends on.
178
+ */
179
+ requiresServices: string[];
170
180
  private autoEnqueuer;
181
+ /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */
182
+ private boundEngine;
171
183
  constructor(options?: WebhookOutboxPluginOptions);
172
184
  init(ctx: PluginContext): Promise<void>;
173
185
  dispose(): Promise<void>;
174
186
  private getMessaging;
187
+ /**
188
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
189
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
190
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
191
+ *
192
+ * Gated on the DATA ENGINE alone — deliberately independent of the
193
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
194
+ * rows is a pure write; a deployment that mounts the webhook plugin without
195
+ * realtime must still get its declared webhooks into the table (and the
196
+ * Setup UI), even if nothing dispatches them yet. Runs before
197
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
198
+ */
199
+ private bootDeclaredWebhooks;
175
200
  private bootAutoEnqueue;
176
201
  private tryGetService;
177
202
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  SysWebhook
3
- } from "./chunk-RBCYKJVL.js";
3
+ } from "./chunk-6EPMRZ7I.js";
4
4
 
5
5
  // src/auto-enqueuer.ts
6
6
  var AutoEnqueuer = class {
@@ -180,12 +180,21 @@ var AutoEnqueuer = class {
180
180
  headers: sub.headers,
181
181
  signingSecret: sub.secret,
182
182
  timeoutMs: sub.timeoutMs,
183
+ // [#3946] Envelope keys are written LAST so the event payload
184
+ // cannot rewrite them. Behaviour-neutral for the engine's own
185
+ // publishers — `data.record.*` payloads are
186
+ // `{ recordId, after, changes }`, with record fields nested
187
+ // under `after`, so none of these four keys collide today. It
188
+ // is the shape that was wrong: a publisher that flattened
189
+ // record fields into the payload (the `payload.id` fallback
190
+ // above suggests some do) would have silently rewritten the
191
+ // `object` / `action` / `timestamp` a subscriber receives.
183
192
  payload: {
193
+ ...payload,
184
194
  object: event.object,
185
195
  recordId,
186
196
  action,
187
- timestamp: event.timestamp,
188
- ...payload
197
+ timestamp: event.timestamp
189
198
  }
190
199
  }).catch(
191
200
  (err) => this.logger.warn?.("[webhook-auto-enqueuer] enqueue failed", {
@@ -222,6 +231,163 @@ function mapActionToTrigger(action) {
222
231
  }
223
232
  var DISPATCHABLE_WEBHOOK_TRIGGERS = /* @__PURE__ */ new Set(["create", "update", "delete"]);
224
233
 
234
+ // src/bootstrap-declared-webhooks.ts
235
+ import { WebhookSchema } from "@objectstack/spec/automation";
236
+ var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
237
+ function uid(prefix) {
238
+ const g = globalThis;
239
+ if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
240
+ return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
241
+ }
242
+ function readDeclared(engine, metadataService, type) {
243
+ try {
244
+ const reg = engine?._registry;
245
+ if (reg?.listItems) {
246
+ const items = (reg.listItems(type) ?? []).map((i) => i?.content ?? i).filter(Boolean);
247
+ if (items.length > 0) return items;
248
+ }
249
+ } catch {
250
+ }
251
+ try {
252
+ const listed = metadataService?.list?.(type);
253
+ const arr = typeof listed?.then === "function" ? [] : listed ?? [];
254
+ return Array.isArray(arr) ? arr.map((i) => i?.content ?? i).filter(Boolean) : [];
255
+ } catch {
256
+ return [];
257
+ }
258
+ }
259
+ async function bootstrapDeclaredWebhooks(engine, metadataService, logger, subscriptionsObject = "sys_webhook") {
260
+ const declared = readDeclared(engine, metadataService, "webhook");
261
+ if (declared.length === 0) return { seeded: 0, skipped: 0 };
262
+ const now = (/* @__PURE__ */ new Date()).toISOString();
263
+ let seeded = 0;
264
+ let skipped = 0;
265
+ for (const raw of declared) {
266
+ let wh;
267
+ try {
268
+ wh = WebhookSchema.parse(raw);
269
+ } catch (err) {
270
+ logger?.warn?.("[webhook] declared webhook failed validation \u2014 skipped", {
271
+ name: raw?.name,
272
+ error: err?.message ?? String(err)
273
+ });
274
+ skipped += 1;
275
+ continue;
276
+ }
277
+ try {
278
+ const existing = await engine.find(subscriptionsObject, {
279
+ where: { name: wh.name },
280
+ limit: 1,
281
+ context: SYSTEM_CTX
282
+ });
283
+ const row = Array.isArray(existing) ? existing[0] : void 0;
284
+ if (row) {
285
+ if (row.managed_by === "admin") {
286
+ logger?.warn?.("[webhook] declared name collides with an admin-authored row \u2014 seed skipped", {
287
+ name: wh.name
288
+ });
289
+ skipped += 1;
290
+ continue;
291
+ }
292
+ if (row.customized === true) {
293
+ skipped += 1;
294
+ continue;
295
+ }
296
+ const patch = {
297
+ id: row.id,
298
+ ...mapWebhookToRow(wh),
299
+ // Adopt pristine/legacy (pre-provenance) rows so future boots
300
+ // recognize them as package-managed.
301
+ managed_by: "package",
302
+ updated_at: now
303
+ };
304
+ await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX });
305
+ seeded += 1;
306
+ continue;
307
+ }
308
+ const newRow = {
309
+ id: uid("whk"),
310
+ ...mapWebhookToRow(wh),
311
+ managed_by: "package",
312
+ customized: false,
313
+ created_at: now,
314
+ updated_at: now
315
+ };
316
+ await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX });
317
+ seeded += 1;
318
+ } catch (err) {
319
+ logger?.warn?.("[webhook] declared webhook seed failed", {
320
+ name: wh.name,
321
+ error: err?.message ?? String(err)
322
+ });
323
+ skipped += 1;
324
+ }
325
+ }
326
+ logger?.info?.("[webhook] declared webhooks materialized into sys_webhook", {
327
+ seeded,
328
+ skipped,
329
+ total: declared.length
330
+ });
331
+ return { seeded, skipped };
332
+ }
333
+ function mapWebhookToRow(wh) {
334
+ return {
335
+ name: wh.name,
336
+ label: wh.label ?? wh.name,
337
+ object_name: wh.object ?? null,
338
+ triggers: wh.triggers ?? [],
339
+ url: wh.url,
340
+ // Store lowercase to match the object's Field.select option values
341
+ // (get/post/…); the enqueuer upper-cases before delivery either way.
342
+ method: String(wh.method ?? "POST").toLowerCase(),
343
+ description: wh.description ?? null,
344
+ active: wh.isActive !== false,
345
+ definition_json: JSON.stringify(wh)
346
+ };
347
+ }
348
+
349
+ // src/webhook-provenance.ts
350
+ var WEBHOOK_PROVENANCE_PACKAGE = "plugin-webhooks:provenance";
351
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
352
+ function bindWebhookProvenanceStamp(engine, logger) {
353
+ if (typeof engine?.registerHook !== "function") return;
354
+ engine.registerHook(
355
+ "beforeUpdate",
356
+ async (ctx) => {
357
+ if (ctx?.session?.isSystem) return;
358
+ const id = ctx?.input?.id ?? ctx?.input?.data?.id;
359
+ if (!id) return;
360
+ const data = ctx?.input?.data;
361
+ if (!data || typeof data !== "object") return;
362
+ try {
363
+ const rows = await engine.find("sys_webhook", {
364
+ where: { id },
365
+ fields: ["id", "managed_by", "customized"],
366
+ limit: 1,
367
+ context: SYSTEM_CTX2
368
+ });
369
+ const row = Array.isArray(rows) ? rows[0] : void 0;
370
+ if (!row) return;
371
+ if ((row.managed_by === "package" || row.managed_by === "platform") && row.customized !== true) {
372
+ data.customized = true;
373
+ }
374
+ } catch (err) {
375
+ logger?.warn?.("[webhook] provenance stamp failed (edit proceeds unstamped)", {
376
+ id,
377
+ error: err?.message
378
+ });
379
+ }
380
+ },
381
+ { object: "sys_webhook", packageId: WEBHOOK_PROVENANCE_PACKAGE, priority: 150 }
382
+ );
383
+ logger?.info?.("[webhook] provenance stamp hook bound");
384
+ }
385
+ function unbindWebhookProvenanceStamp(engine) {
386
+ if (typeof engine?.unregisterHooksByPackage === "function") {
387
+ engine.unregisterHooksByPackage(WEBHOOK_PROVENANCE_PACKAGE);
388
+ }
389
+ }
390
+
225
391
  // src/webhook-outbox-plugin.ts
226
392
  var WebhookOutboxPlugin = class {
227
393
  constructor(options = {}) {
@@ -230,6 +396,16 @@ var WebhookOutboxPlugin = class {
230
396
  this.version = "2.0.0";
231
397
  this.type = "standard";
232
398
  this.dependencies = ["com.objectstack.service.messaging"];
399
+ /**
400
+ * `init()` registers this plugin's schema through `manifest` with no
401
+ * fallback. Until #4187 that was safe only TRANSITIVELY — messaging happens
402
+ * to depend on ObjectQL, which provides `manifest` — so the guarantee would
403
+ * have evaporated silently the day messaging stopped depending on the
404
+ * engine, and the failure would have surfaced as an unrelated plugin's
405
+ * init crash. Declaring the requirement directly makes the kernel check it
406
+ * regardless of what messaging depends on.
407
+ */
408
+ this.requiresServices = ["manifest"];
233
409
  }
234
410
  async init(ctx) {
235
411
  const manifest = ctx.getService("manifest");
@@ -265,7 +441,7 @@ var WebhookOutboxPlugin = class {
265
441
  try {
266
442
  const i18n = ctx.getService("i18n");
267
443
  if (i18n && typeof i18n.loadTranslations === "function") {
268
- const { WebhooksTranslations } = await import("./translations-EPJYANXJ.js");
444
+ const { WebhooksTranslations } = await import("./translations-Y3CXWOTM.js");
269
445
  for (const [locale, data] of Object.entries(WebhooksTranslations)) {
270
446
  i18n.loadTranslations(locale, data);
271
447
  }
@@ -277,6 +453,7 @@ var WebhookOutboxPlugin = class {
277
453
  const autoEnqueueOpt = this.options.autoEnqueue ?? true;
278
454
  if (typeof ctx.hook === "function") {
279
455
  ctx.hook("kernel:ready", async () => {
456
+ await this.bootDeclaredWebhooks(ctx);
280
457
  await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
281
458
  this.registerAdminRoutes(ctx);
282
459
  });
@@ -287,11 +464,51 @@ var WebhookOutboxPlugin = class {
287
464
  }
288
465
  async dispose() {
289
466
  await this.autoEnqueuer?.stop();
467
+ if (this.boundEngine) {
468
+ try {
469
+ unbindWebhookProvenanceStamp(this.boundEngine);
470
+ } catch {
471
+ }
472
+ this.boundEngine = void 0;
473
+ }
290
474
  }
291
475
  getMessaging(ctx) {
292
476
  const svc = this.tryGetService(ctx, ["messaging"]);
293
477
  return svc && typeof svc.enqueueHttp === "function" ? svc : void 0;
294
478
  }
479
+ /**
480
+ * [#3461] Bridge the declarative authoring surface to the dispatcher:
481
+ * materialize stack/connector-declared `webhook` metadata into `sys_webhook`
482
+ * rows so the auto-enqueuer (and the Studio UI) can see them.
483
+ *
484
+ * Gated on the DATA ENGINE alone — deliberately independent of the
485
+ * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing
486
+ * rows is a pure write; a deployment that mounts the webhook plugin without
487
+ * realtime must still get its declared webhooks into the table (and the
488
+ * Setup UI), even if nothing dispatches them yet. Runs before
489
+ * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows.
490
+ */
491
+ async bootDeclaredWebhooks(ctx) {
492
+ const engine = this.tryGetService(ctx, ["objectql", "data"]);
493
+ if (!engine) {
494
+ ctx.logger.warn?.("[webhook] declared-webhook bootstrap skipped \u2014 no data engine available");
495
+ return;
496
+ }
497
+ this.boundEngine = engine;
498
+ bindWebhookProvenanceStamp(engine, ctx.logger);
499
+ let metadataService;
500
+ try {
501
+ metadataService = ctx.getService("metadata");
502
+ } catch {
503
+ }
504
+ try {
505
+ await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger);
506
+ } catch (err) {
507
+ ctx.logger.warn?.("[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)", {
508
+ error: err?.message ?? String(err)
509
+ });
510
+ }
511
+ }
295
512
  async bootAutoEnqueue(ctx, opt) {
296
513
  if (opt === false) return;
297
514
  const engine = this.tryGetService(ctx, ["objectql", "data"]);
@@ -348,7 +565,7 @@ var WebhookOutboxPlugin = class {
348
565
  const userId = await this.resolveSessionUserId(ctx, c);
349
566
  if (!userId) {
350
567
  return c.json(
351
- { success: false, error: "unauthenticated", message: "Sign in to redeliver webhook deliveries." },
568
+ { success: false, error: { code: "UNAUTHENTICATED", message: "Sign in to redeliver webhook deliveries." } },
352
569
  401
353
570
  );
354
571
  }
@@ -356,12 +573,12 @@ var WebhookOutboxPlugin = class {
356
573
  try {
357
574
  body = await c.req.json();
358
575
  } catch {
359
- return c.json({ success: false, error: "invalid_body", message: "Request body must be JSON." }, 400);
576
+ return c.json({ success: false, error: { code: "INVALID_REQUEST", message: "Request body must be JSON." } }, 400);
360
577
  }
361
578
  const deliveryId = typeof body?.deliveryId === "string" ? body.deliveryId.trim() : "";
362
579
  if (!deliveryId) {
363
580
  return c.json(
364
- { success: false, error: "missing_delivery_id", message: "Body must include `deliveryId: string`." },
581
+ { success: false, error: { code: "MISSING_REQUIRED_FIELD", message: "Body must include `deliveryId: string`." } },
365
582
  400
366
583
  );
367
584
  }
@@ -371,15 +588,15 @@ var WebhookOutboxPlugin = class {
371
588
  return c.json({ success: true, data: { id: row.id, status: row.status } });
372
589
  } catch (err) {
373
590
  const code = err?.code;
374
- if (code === "not_found") {
375
- return c.json({ success: false, error: "not_found", message: err.message }, 404);
591
+ if (code === "RESOURCE_NOT_FOUND") {
592
+ return c.json({ success: false, error: { code, message: err.message } }, 404);
376
593
  }
377
- if (code === "not_eligible") {
378
- return c.json({ success: false, error: "not_eligible", message: err.message }, 409);
594
+ if (code === "DELIVERY_NOT_ELIGIBLE") {
595
+ return c.json({ success: false, error: { code, message: err.message } }, 409);
379
596
  }
380
597
  ctx.logger.error?.("[webhook-outbox] redeliver failed", err);
381
598
  return c.json(
382
- { success: false, error: "internal_error", message: err?.message ?? String(err) },
599
+ { success: false, error: { code: "INTERNAL_ERROR", message: err?.message ?? String(err) } },
383
600
  500
384
601
  );
385
602
  }
@@ -396,8 +613,8 @@ var WebhookOutboxPlugin = class {
396
613
  }
397
614
  if (!api?.getSession) return void 0;
398
615
  const session = await api.getSession({ headers: c.req.raw.headers });
399
- const uid = session?.user?.id;
400
- return typeof uid === "string" && uid.length > 0 ? uid : void 0;
616
+ const uid2 = session?.user?.id;
617
+ return typeof uid2 === "string" && uid2.length > 0 ? uid2 : void 0;
401
618
  } catch {
402
619
  return void 0;
403
620
  }