terp-cap-webhooks 0.1.0__tar.gz

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.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-webhooks
3
+ Version: 0.1.0
4
+ Summary: Terp webhooks capability — reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: cryptography>=48.0.1
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,6 @@
1
+ {
2
+ "arch-allow-mutations-emit-audit": 2,
3
+ "arch-allow-no-destructive-migrations": 1,
4
+ "arch-allow-no-internal-imports": 1,
5
+ "arch-allow-table-models-use-base-table": 1
6
+ }
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-webhooks"
7
+ version = "0.1.0"
8
+ description = "Terp webhooks capability — reliable, signed, SSRF-guarded outbound webhooks on the jobs/outbox seam."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ # The outbound HTTP client lives ONLY in this capability (the delivery job handler);
14
+ # an app module never imports it. Background delivery + retry ride the jobs/outbox seam.
15
+ "httpx>=0.27",
16
+ # Seals the subscription signing secret at rest (ADR 0076) — Fernet keyed from
17
+ # SECRET_KEY with a webhooks-specific HKDF label, mirroring terp.core.secrets.
18
+ "cryptography>=48.0.1",
19
+ ]
20
+
21
+ # Self-registering: the kernel discovers this ModuleSpec via the entry point, mounting the
22
+ # admin webhooks router at `/api/v1/webhooks` without any composition-root edit.
23
+ [project.entry-points."terp.capabilities"]
24
+ webhooks = "terp.capabilities.webhooks:module"
25
+
26
+ # Owns the `webhook_subscription` + append-only `webhook_delivery` tables, so it ships an
27
+ # independent, linear Alembic history (its own `alembic_version_webhooks` table). `terp
28
+ # migrate` discovers this via the `terp.migrations` group (ADR 0027).
29
+ [project.entry-points."terp.migrations"]
30
+ webhooks = "terp.capabilities.webhooks"
31
+
32
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.webhooks`.
33
+ [tool.hatch.build.targets.wheel]
34
+ sources = ["src"]
35
+ only-include = ["src/terp/capabilities/webhooks"]
@@ -0,0 +1,104 @@
1
+ """terp.capabilities.webhooks — reliable, signed, SSRF-guarded outbound webhooks.
2
+
3
+ Built **only** on the shipped ports: the jobs seam (:func:`terp.core.enqueue` + a typed
4
+ :class:`~terp.core.JobDefinition`) and the durable outbox (for retry / dead-letter). It adds
5
+ no engine and changes no ``terp.core``.
6
+
7
+ * A consumer registers an owner-scoped :class:`WebhookSubscription` through the discovered,
8
+ admin-only router at ``/api/v1/webhooks``; the signing ``secret`` is supplied on create and
9
+ is never serialized back out.
10
+ * An app wires :func:`enqueue_webhook_deliveries` to a catalog event with the eventbus
11
+ ``@subscribe`` decorator, so when the event fires the matching deliveries are enqueued **on
12
+ the producer's session** — atomically with the business write (no dual-write).
13
+ * The :data:`WEBHOOK_DELIVER` job (drained by the outbox worker, ``terp jobs worker``) signs
14
+ the payload (HMAC-SHA256), re-checks the target against the SSRF denylist, POSTs with a
15
+ strict timeout and no redirect following, records a :class:`WebhookDelivery`, and lets a
16
+ failure propagate so the outbox retries with backoff and dead-letters.
17
+
18
+ It depends only on ``terp-core`` and ``httpx`` — never a sibling capability or a broker
19
+ engine; the app composes the durable ``OutboxJobQueue`` at ``create_app``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from terp.capabilities.webhooks.delivery import (
25
+ WEBHOOK_DELIVER,
26
+ WebhookDeliveryError,
27
+ WebhookDeliveryPayload,
28
+ WebhookResponse,
29
+ WebhookSender,
30
+ active_webhook_sender,
31
+ deliver_webhook,
32
+ reset_webhook_sender,
33
+ set_webhook_sender,
34
+ )
35
+ from terp.capabilities.webhooks.models import (
36
+ OUTCOME_BLOCKED,
37
+ OUTCOME_DELIVERED,
38
+ OUTCOME_FAILED,
39
+ OUTCOME_SKIPPED,
40
+ WebhookDelivery,
41
+ WebhookSubscription,
42
+ )
43
+ from terp.capabilities.webhooks.router import module, router
44
+ from terp.capabilities.webhooks.sealing import (
45
+ WebhookSecretError,
46
+ is_sealed_secret,
47
+ seal_secret,
48
+ unseal_secret,
49
+ )
50
+ from terp.capabilities.webhooks.schemas import (
51
+ WebhookDeliveryRead,
52
+ WebhookSubscriptionCreate,
53
+ WebhookSubscriptionRead,
54
+ WebhookSubscriptionUpdate,
55
+ )
56
+ from terp.capabilities.webhooks.service import WebhookSubscriptionService, list_deliveries
57
+ from terp.capabilities.webhooks.ssrf import (
58
+ CLOUD_METADATA_ADDRESS,
59
+ PinnedTarget,
60
+ WebhookTargetError,
61
+ is_denied_address,
62
+ resolve_pinned_target,
63
+ validate_webhook_target,
64
+ )
65
+ from terp.capabilities.webhooks.store import record_delivery
66
+ from terp.capabilities.webhooks.triggers import enqueue_webhook_deliveries
67
+
68
+ __all__ = [
69
+ "CLOUD_METADATA_ADDRESS",
70
+ "OUTCOME_BLOCKED",
71
+ "OUTCOME_DELIVERED",
72
+ "OUTCOME_FAILED",
73
+ "OUTCOME_SKIPPED",
74
+ "WEBHOOK_DELIVER",
75
+ "PinnedTarget",
76
+ "WebhookDelivery",
77
+ "WebhookDeliveryError",
78
+ "WebhookDeliveryPayload",
79
+ "WebhookDeliveryRead",
80
+ "WebhookResponse",
81
+ "WebhookSecretError",
82
+ "WebhookSender",
83
+ "WebhookSubscription",
84
+ "WebhookSubscriptionCreate",
85
+ "WebhookSubscriptionRead",
86
+ "WebhookSubscriptionService",
87
+ "WebhookSubscriptionUpdate",
88
+ "WebhookTargetError",
89
+ "active_webhook_sender",
90
+ "deliver_webhook",
91
+ "enqueue_webhook_deliveries",
92
+ "is_denied_address",
93
+ "is_sealed_secret",
94
+ "list_deliveries",
95
+ "module",
96
+ "record_delivery",
97
+ "reset_webhook_sender",
98
+ "resolve_pinned_target",
99
+ "router",
100
+ "seal_secret",
101
+ "set_webhook_sender",
102
+ "unseal_secret",
103
+ "validate_webhook_target",
104
+ ]
@@ -0,0 +1,315 @@
1
+ """The ``WEBHOOK_DELIVER`` job: sign + POST one delivery, record the attempt, retry on failure.
2
+
3
+ This is the worker half of the webhook seam (the jobs design's §6 / §7): the external HTTP
4
+ call lives **here**, in a job handler that runs **post-commit, on a worker**, drained from
5
+ the durable outbox — never in an ``_after_write`` hook (which would dual-write the business
6
+ row and a remote call). The trigger (an event subscriber) only *enqueues* this job, atomically
7
+ with the business write; this handler delivers it off-request, and a failure **propagates** so
8
+ the :class:`~terp.core.RetryPolicy` + outbox worker retry with exponential backoff and
9
+ dead-letter after the attempt budget is spent.
10
+
11
+ Security controls applied on every attempt:
12
+
13
+ * an **SSRF re-check** of the target immediately before the request (DNS-rebinding defense,
14
+ :mod:`terp.capabilities.webhooks.ssrf`);
15
+ * an **HMAC-SHA256** signature over the exact JSON body, keyed by the subscription's stored
16
+ ``secret`` — which never leaves the server;
17
+ * a strict outbound **timeout**, a bounded outbound **payload size**, and **no redirect
18
+ following** (a 3xx is recorded as a failure, never chased to a possibly-disallowed host).
19
+
20
+ The outbound HTTP client is the injectable :data:`WebhookSender` seam (default: ``httpx``),
21
+ so tests drive the handler with no real network I/O — and ``httpx`` is imported only here, in
22
+ this capability, never by an app module.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import hmac
29
+ import json
30
+ import uuid
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass
33
+ from datetime import UTC, datetime
34
+ from typing import Any, Final
35
+
36
+ import httpx
37
+ from sqlmodel import Field
38
+
39
+ from terp.core import (
40
+ AppError,
41
+ BaseSchema,
42
+ JobContext,
43
+ JobDefinition,
44
+ JobVisibility,
45
+ NotFoundError,
46
+ RetryPolicy,
47
+ )
48
+
49
+ from terp.capabilities.webhooks.models import (
50
+ OUTCOME_BLOCKED,
51
+ OUTCOME_DELIVERED,
52
+ OUTCOME_FAILED,
53
+ OUTCOME_SKIPPED,
54
+ )
55
+ from terp.capabilities.webhooks.sealing import WebhookSecretError, unseal_secret
56
+ from terp.capabilities.webhooks.service import WebhookSubscriptionService
57
+ from terp.capabilities.webhooks.ssrf import (
58
+ PinnedTarget,
59
+ WebhookTargetError,
60
+ resolve_pinned_target,
61
+ )
62
+ from terp.capabilities.webhooks.store import record_delivery
63
+
64
+ # Conservative secure outbound limits (Tier-B-style knobs with safe defaults).
65
+ _TIMEOUT_SECONDS: Final[float] = 10.0
66
+ _MAX_PAYLOAD_BYTES: Final[int] = 256 * 1024 # 256 KiB cap on the signed body
67
+ _SIGNATURE_HEADER: Final[str] = "X-Terp-Signature"
68
+ _TIMESTAMP_HEADER: Final[str] = "X-Terp-Webhook-Timestamp"
69
+ _EVENT_HEADER: Final[str] = "X-Terp-Event"
70
+ _DELIVERY_HEADER: Final[str] = "X-Terp-Delivery-Id"
71
+
72
+
73
+ def _utc_now() -> datetime:
74
+ """UTC ``now`` provider for the signature timestamp (private so tests can patch it)."""
75
+ return datetime.now(UTC)
76
+
77
+
78
+ class WebhookDeliveryError(AppError):
79
+ """502 — a webhook delivery attempt failed; propagated so the outbox retries / dead-letters."""
80
+
81
+ status_code = 502
82
+ code = "webhook_delivery_failed"
83
+ default_message = "The webhook endpoint did not accept the delivery."
84
+
85
+
86
+ class WebhookDeliveryPayload(BaseSchema):
87
+ """The JSON-serializable payload carried by a ``WEBHOOK_DELIVER`` job (ids, not entities).
88
+
89
+ It carries the subscription id (the handler re-loads the live row to read the current
90
+ target / secret — neither rides the wire), a per-attempt-stable ``delivery_id`` the
91
+ subscriber can dedupe on, the originating event name, and the event's public ``data``.
92
+ """
93
+
94
+ subscription_id: uuid.UUID
95
+ delivery_id: uuid.UUID
96
+ event: str = Field(max_length=128)
97
+ data: dict[str, Any] = Field(default_factory=dict)
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class WebhookResponse:
102
+ """The minimal result the handler needs from an outbound POST (the status code)."""
103
+
104
+ status_code: int
105
+
106
+
107
+ # A sender performs the actual POST and returns a :class:`WebhookResponse`. It is injectable
108
+ # so tests drive the handler without real network I/O (mirroring the audit-sink / throttle-
109
+ # store seams); the default implementation uses ``httpx``.
110
+ WebhookSender = Callable[[PinnedTarget, bytes, dict[str, str]], WebhookResponse]
111
+
112
+
113
+ def _httpx_sender(
114
+ target: PinnedTarget, body: bytes, headers: dict[str, str]
115
+ ) -> WebhookResponse:
116
+ """Default sender: POST to *target*, pinned to its pre-validated IP, no redirect chasing.
117
+
118
+ The request is built from the original ``https`` URL (so the ``Host`` header + path are
119
+ correct and TLS is verified against the hostname via the ``sni_hostname`` extension), then
120
+ the connection is **repointed to the validated IP** — so a DNS-rebinding attacker cannot
121
+ make the socket land on a private address after the SSRF check (closing the TOCTOU). A
122
+ strict timeout bounds the request; redirects are never followed.
123
+ """
124
+ with httpx.Client(timeout=_TIMEOUT_SECONDS, follow_redirects=False) as client:
125
+ request = client.build_request(
126
+ "POST",
127
+ target.url,
128
+ content=body,
129
+ headers=headers,
130
+ extensions={"sni_hostname": target.host},
131
+ )
132
+ request.url = request.url.copy_with(host=target.ip)
133
+ response = client.send(request)
134
+ return WebhookResponse(status_code=response.status_code)
135
+
136
+
137
+ _active_sender: WebhookSender = _httpx_sender
138
+
139
+
140
+ def set_webhook_sender(sender: WebhookSender) -> None:
141
+ """Install the outbound HTTP *sender* (the seam tests replace with a fake)."""
142
+ global _active_sender
143
+ _active_sender = sender
144
+
145
+
146
+ def reset_webhook_sender() -> None:
147
+ """Restore the default ``httpx`` sender (the test-isolation reset)."""
148
+ global _active_sender
149
+ _active_sender = _httpx_sender
150
+
151
+
152
+ def active_webhook_sender() -> WebhookSender:
153
+ """The sender the delivery handler currently posts through."""
154
+ return _active_sender
155
+
156
+
157
+ def _sign(secret: str, timestamp: str, body: bytes) -> str:
158
+ """``sha256=<hex>`` HMAC-SHA256 over ``timestamp.body``, keyed by the subscription *secret*.
159
+
160
+ Binding the timestamp into the signature lets a receiver reject a **replay** of a captured
161
+ delivery: it recomputes the HMAC over the ``X-Terp-Webhook-Timestamp`` header value and the
162
+ raw body and bounds the age, so a valid signature is not indefinitely reusable.
163
+ """
164
+ signed = timestamp.encode("utf-8") + b"." + body
165
+ digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
166
+ return f"sha256={digest}"
167
+
168
+
169
+ _service = WebhookSubscriptionService()
170
+
171
+
172
+ def deliver_webhook(ctx: JobContext, payload: WebhookDeliveryPayload) -> None:
173
+ """Deliver one webhook: load the subscription, sign, POST, record the attempt.
174
+
175
+ Runs in its own audited unit on a worker (``run_job``). A non-2xx response or a network
176
+ error records a ``failed`` attempt and then **raises** :class:`WebhookDeliveryError`, so
177
+ the outbox retries with backoff and dead-letters after the job's ``RetryPolicy`` budget.
178
+ A removed / inactive subscription, an SSRF-blocked target, or an oversized payload records
179
+ a terminal attempt and returns without raising (a retry could not help).
180
+ """
181
+ try:
182
+ subscription = _service.get(ctx.session, payload.subscription_id)
183
+ except NotFoundError:
184
+ record_delivery(
185
+ ctx.session,
186
+ subscription_id=payload.subscription_id,
187
+ event=payload.event,
188
+ outcome=OUTCOME_SKIPPED,
189
+ attempt=ctx.attempt,
190
+ last_error="subscription no longer exists",
191
+ )
192
+ return
193
+ if not subscription.active:
194
+ record_delivery(
195
+ ctx.session,
196
+ subscription_id=subscription.id,
197
+ event=payload.event,
198
+ outcome=OUTCOME_SKIPPED,
199
+ attempt=ctx.attempt,
200
+ last_error="subscription is inactive",
201
+ )
202
+ return
203
+
204
+ body = json.dumps(payload.data, separators=(",", ":"), sort_keys=True).encode("utf-8")
205
+ if len(body) > _MAX_PAYLOAD_BYTES:
206
+ record_delivery(
207
+ ctx.session,
208
+ subscription_id=subscription.id,
209
+ event=payload.event,
210
+ outcome=OUTCOME_FAILED,
211
+ attempt=ctx.attempt,
212
+ last_error=f"payload exceeds the {_MAX_PAYLOAD_BYTES}-byte cap",
213
+ )
214
+ return # an oversized payload is deterministic — do not waste retries on it
215
+
216
+ try:
217
+ pinned = resolve_pinned_target(subscription.target_url)
218
+ except WebhookTargetError as exc:
219
+ record_delivery(
220
+ ctx.session,
221
+ subscription_id=subscription.id,
222
+ event=payload.event,
223
+ outcome=OUTCOME_BLOCKED,
224
+ attempt=ctx.attempt,
225
+ last_error=exc.message,
226
+ )
227
+ return # an SSRF-blocked target is deterministic — do not retry it
228
+
229
+ try:
230
+ # Sealed at rest (ADR 0076): the plaintext exists only here, at signing time.
231
+ signing_secret = unseal_secret(subscription.secret)
232
+ except WebhookSecretError as exc:
233
+ record_delivery(
234
+ ctx.session,
235
+ subscription_id=subscription.id,
236
+ event=payload.event,
237
+ outcome=OUTCOME_FAILED,
238
+ attempt=ctx.attempt,
239
+ last_error=exc.message,
240
+ )
241
+ return # a secret that no longer unseals is deterministic — do not retry it
242
+
243
+ timestamp = str(int(_utc_now().timestamp()))
244
+ headers = {
245
+ "Content-Type": "application/json",
246
+ _TIMESTAMP_HEADER: timestamp,
247
+ _SIGNATURE_HEADER: _sign(signing_secret, timestamp, body),
248
+ _EVENT_HEADER: payload.event,
249
+ _DELIVERY_HEADER: str(payload.delivery_id),
250
+ }
251
+ try:
252
+ response = active_webhook_sender()(pinned, body, headers)
253
+ except Exception as exc: # noqa: BLE001 - any transport error becomes a recorded, retried failure
254
+ record_delivery(
255
+ ctx.session,
256
+ subscription_id=subscription.id,
257
+ event=payload.event,
258
+ outcome=OUTCOME_FAILED,
259
+ response_code=None,
260
+ attempt=ctx.attempt,
261
+ last_error=f"request error: {exc}",
262
+ )
263
+ raise WebhookDeliveryError(
264
+ f"webhook request to subscription {subscription.id} failed"
265
+ ) from exc
266
+
267
+ if 200 <= response.status_code < 300:
268
+ record_delivery(
269
+ ctx.session,
270
+ subscription_id=subscription.id,
271
+ event=payload.event,
272
+ outcome=OUTCOME_DELIVERED,
273
+ response_code=response.status_code,
274
+ attempt=ctx.attempt,
275
+ )
276
+ return
277
+ record_delivery(
278
+ ctx.session,
279
+ subscription_id=subscription.id,
280
+ event=payload.event,
281
+ outcome=OUTCOME_FAILED,
282
+ response_code=response.status_code,
283
+ attempt=ctx.attempt,
284
+ last_error=f"endpoint returned HTTP {response.status_code}",
285
+ )
286
+ raise WebhookDeliveryError(
287
+ f"webhook delivery to subscription {subscription.id} failed with "
288
+ f"HTTP {response.status_code}"
289
+ )
290
+
291
+
292
+ # The typed job contract a consumer wires into its control-plane ``JobCatalog`` so the
293
+ # trigger can enqueue it (and the outbox worker resolve its handler by name). Retries lean
294
+ # on the outbox: five attempts with exponential backoff, then dead-letter.
295
+ WEBHOOK_DELIVER = JobDefinition(
296
+ name="webhooks.delivery.send",
297
+ payload_schema=WebhookDeliveryPayload,
298
+ handler=deliver_webhook,
299
+ retry=RetryPolicy(max_attempts=5, backoff_seconds=10.0),
300
+ queue="webhooks",
301
+ visibility=JobVisibility.INTERNAL,
302
+ )
303
+
304
+
305
+ __all__ = [
306
+ "WEBHOOK_DELIVER",
307
+ "WebhookDeliveryError",
308
+ "WebhookDeliveryPayload",
309
+ "WebhookResponse",
310
+ "WebhookSender",
311
+ "active_webhook_sender",
312
+ "deliver_webhook",
313
+ "reset_webhook_sender",
314
+ "set_webhook_sender",
315
+ ]
@@ -0,0 +1,80 @@
1
+ """create webhooks tables
2
+
3
+ Revision ID: 717754ab63f6
4
+ Revises:
5
+ Create Date: 2026-06-30 22:16:37.385448
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ import sqlmodel
15
+
16
+
17
+ # revision identifiers, used by Alembic.
18
+ revision: str = '717754ab63f6'
19
+ down_revision: str | None = None
20
+ branch_labels: str | Sequence[str] | None = None
21
+ depends_on: str | Sequence[str] | None = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # ### commands auto generated by Alembic - please adjust! ###
26
+ op.create_table('webhook_delivery',
27
+ sa.Column('id', sa.Uuid(), nullable=False),
28
+ sa.Column('subscription_id', sa.Uuid(), nullable=False),
29
+ sa.Column('event', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
30
+ sa.Column('outcome', sqlmodel.sql.sqltypes.AutoString(length=16), nullable=False),
31
+ sa.Column('response_code', sa.Integer(), nullable=True),
32
+ sa.Column('attempt', sa.Integer(), nullable=False),
33
+ sa.Column('last_error', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True),
34
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
35
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_webhook_delivery'))
36
+ )
37
+ with op.batch_alter_table('webhook_delivery', schema=None) as batch_op:
38
+ batch_op.create_index(batch_op.f('ix_webhook_delivery_created_at'), ['created_at'], unique=False)
39
+ batch_op.create_index(batch_op.f('ix_webhook_delivery_event'), ['event'], unique=False)
40
+ batch_op.create_index(batch_op.f('ix_webhook_delivery_outcome'), ['outcome'], unique=False)
41
+ batch_op.create_index(batch_op.f('ix_webhook_delivery_subscription_id'), ['subscription_id'], unique=False)
42
+
43
+ op.create_table('webhook_subscription',
44
+ sa.Column('owner_id', sa.Uuid(), nullable=True),
45
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
46
+ sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
47
+ sa.Column('id', sa.Uuid(), nullable=False),
48
+ sa.Column('version', sa.Integer(), nullable=False),
49
+ sa.Column('target_url', sqlmodel.sql.sqltypes.AutoString(length=2048), nullable=False),
50
+ sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(length=256), nullable=False),
51
+ sa.Column('event', sqlmodel.sql.sqltypes.AutoString(length=128), nullable=False),
52
+ sa.Column('active', sa.Boolean(), nullable=False),
53
+ sa.PrimaryKeyConstraint('id', name=op.f('pk_webhook_subscription'))
54
+ )
55
+ with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
56
+ batch_op.create_index(batch_op.f('ix_webhook_subscription_active'), ['active'], unique=False)
57
+ batch_op.create_index(batch_op.f('ix_webhook_subscription_event'), ['event'], unique=False)
58
+ batch_op.create_index(batch_op.f('ix_webhook_subscription_owner_id'), ['owner_id'], unique=False)
59
+ batch_op.create_index(batch_op.f('ix_webhook_subscription_target_url'), ['target_url'], unique=False)
60
+
61
+ # ### end Alembic commands ###
62
+
63
+
64
+ def downgrade() -> None:
65
+ # ### commands auto generated by Alembic - please adjust! ###
66
+ with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
67
+ batch_op.drop_index(batch_op.f('ix_webhook_subscription_target_url'))
68
+ batch_op.drop_index(batch_op.f('ix_webhook_subscription_owner_id'))
69
+ batch_op.drop_index(batch_op.f('ix_webhook_subscription_event'))
70
+ batch_op.drop_index(batch_op.f('ix_webhook_subscription_active'))
71
+
72
+ op.drop_table('webhook_subscription')
73
+ with op.batch_alter_table('webhook_delivery', schema=None) as batch_op:
74
+ batch_op.drop_index(batch_op.f('ix_webhook_delivery_subscription_id'))
75
+ batch_op.drop_index(batch_op.f('ix_webhook_delivery_outcome'))
76
+ batch_op.drop_index(batch_op.f('ix_webhook_delivery_event'))
77
+ batch_op.drop_index(batch_op.f('ix_webhook_delivery_created_at'))
78
+
79
+ op.drop_table('webhook_delivery')
80
+ # ### end Alembic commands ###
@@ -0,0 +1,48 @@
1
+ """widen webhook secret for at-rest sealing
2
+
3
+ Revision ID: 8f1c4a2d9b3e
4
+ Revises: 717754ab63f6
5
+ Create Date: 2026-07-06 20:30:00.000000
6
+
7
+ The subscription's signing ``secret`` is now sealed before it is persisted
8
+ (ADR 0076): the stored value is the ``enc:v1:`` Fernet ciphertext of the
9
+ client-supplied secret, whose seal of the schema's 256-char maximum input is
10
+ ~447 characters — so the column widens from 256 to 512. A widening VARCHAR
11
+ change loses no data; existing (legacy plaintext) rows are untouched and keep
12
+ delivering — the runtime unseal passes an unsealed value through unchanged.
13
+ """
14
+ # terp-allow-destructive-migration: widening webhook_subscription.secret 256 -> 512 (a pure widen — no value can be truncated) so the sealed-at-rest ciphertext fits (ADR 0076)
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+
19
+ from alembic import op
20
+ import sqlmodel
21
+
22
+
23
+ # revision identifiers, used by Alembic.
24
+ revision: str = '8f1c4a2d9b3e'
25
+ down_revision: str | None = '717754ab63f6'
26
+ branch_labels: str | Sequence[str] | None = None
27
+ depends_on: str | Sequence[str] | None = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
32
+ # arch-allow-no-destructive-migrations: widening secret 256 -> 512 (a pure widen — no value can be truncated) so the sealed-at-rest ciphertext fits (ADR 0076)
33
+ batch_op.alter_column(
34
+ 'secret',
35
+ existing_type=sqlmodel.sql.sqltypes.AutoString(length=256),
36
+ type_=sqlmodel.sql.sqltypes.AutoString(length=512),
37
+ existing_nullable=False,
38
+ )
39
+
40
+
41
+ def downgrade() -> None:
42
+ with op.batch_alter_table('webhook_subscription', schema=None) as batch_op:
43
+ batch_op.alter_column(
44
+ 'secret',
45
+ existing_type=sqlmodel.sql.sqltypes.AutoString(length=512),
46
+ type_=sqlmodel.sql.sqltypes.AutoString(length=256),
47
+ existing_nullable=False,
48
+ )