@@ -62,17 +62,20 @@ async def main():
6262
6363from __future__ import annotations
6464
65+ import inspect
6566import json
6667import re
6768import time
6869import uuid
69- from typing import TYPE_CHECKING , Any , ClassVar
70+ from collections .abc import Awaitable , Callable
71+ from typing import TYPE_CHECKING , Any , ClassVar , TypeAlias
7072
7173from adcp .decisioning .account_projection import strip_credentials_from_wire_result
7274
7375if TYPE_CHECKING :
7476 from psycopg_pool import AsyncConnectionPool
7577
78+ from adcp .decisioning .context import RequestContext
7679 from adcp .decisioning .pg .task_webhook_outbox import PgTaskWebhookOutbox
7780
7881try :
@@ -95,6 +98,8 @@ async def main():
9598# the only protection against SQL injection or Unicode homoglyph substitution.
9699_SAFE_IDENTIFIER_RE = re .compile (r"^[a-z_][a-z0-9_]{0,62}$" )
97100
101+ WebhookSigningScopeResolver : TypeAlias = Callable [["RequestContext[Any]" ], str | Awaitable [str ]]
102+
98103
99104class PgTaskRegistry :
100105 """PostgreSQL-backed :class:`~adcp.decisioning.TaskRegistry` — v6.1.
@@ -131,6 +136,7 @@ def __init__(
131136 * ,
132137 pool : AsyncConnectionPool ,
133138 task_webhook_outbox : PgTaskWebhookOutbox | None = None ,
139+ webhook_signing_scope_resolver : WebhookSigningScopeResolver | None = None ,
134140 _table : str = _DEFAULT_TABLE ,
135141 ) -> None :
136142 if not PG_AVAILABLE :
@@ -141,9 +147,18 @@ def __init__(
141147 raise ValueError (
142148 "PgTaskRegistry and PgTaskWebhookOutbox must use the same connection pool"
143149 )
150+ uses_sender_resolver = (
151+ task_webhook_outbox is not None and task_webhook_outbox ._sender_resolver is not None
152+ )
153+ if uses_sender_resolver != (webhook_signing_scope_resolver is not None ):
154+ raise ValueError (
155+ "webhook_signing_scope_resolver is required exactly when the "
156+ "PgTaskWebhookOutbox uses sender_resolver"
157+ )
144158 self ._pool = pool
145159 self ._table = _table
146160 self .task_webhook_outbox = task_webhook_outbox
161+ self ._webhook_signing_scope_resolver = webhook_signing_scope_resolver
147162 self .atomic_task_webhook_outbox = task_webhook_outbox is not None
148163
149164 # Pre-format queries at construction so the hot path avoids f-strings per call.
@@ -247,6 +262,7 @@ async def issue(
247262 webhook_url : str | None = None ,
248263 webhook_operation_id : str | None = None ,
249264 webhook_token : str | None = None ,
265+ webhook_signing_scope_id : str | None = None ,
250266 ** _extra : Any ,
251267 ) -> str :
252268 """Allocate a task_id, persist a ``submitted`` row, return the id.
@@ -269,6 +285,8 @@ async def issue(
269285 raise ValueError ("webhook_url must be non-empty when supplied" )
270286 if webhook_operation_id is not None and not webhook_operation_id :
271287 raise ValueError ("webhook_operation_id must be non-empty when supplied" )
288+ if webhook_url is None and webhook_signing_scope_id is not None :
289+ raise ValueError ("webhook_signing_scope_id requires webhook_url" )
272290 outbox = self .task_webhook_outbox
273291 if webhook_url is not None :
274292 if outbox is None :
@@ -287,6 +305,7 @@ async def issue(
287305 url = webhook_url ,
288306 operation_id = webhook_operation_id ,
289307 token = webhook_token ,
308+ signing_scope_id = webhook_signing_scope_id ,
290309 )
291310 now = time .time ()
292311 async with self ._pool .connection () as conn :
@@ -305,6 +324,46 @@ async def issue(
305324 )
306325 return task_id
307326
327+ async def resolve_webhook_signing_scope (
328+ self ,
329+ context : RequestContext [Any ],
330+ ) -> str | None :
331+ """Derive an opaque signing scope from trusted framework context.
332+
333+ The callback is operator wiring and receives the hydrated
334+ :class:`RequestContext`. It must use internal tenant/platform metadata,
335+ never buyer request ``context``, ``push_notification_config``, or an
336+ unqualified buyer account id.
337+ """
338+ resolver = self ._webhook_signing_scope_resolver
339+ if resolver is None :
340+ return None
341+ from adcp .decisioning .types import AdcpError
342+
343+ try :
344+ value : object = resolver (context )
345+ if inspect .isawaitable (value ):
346+ value = await value
347+ except Exception :
348+ raise AdcpError (
349+ "INTERNAL_ERROR" ,
350+ message = "Webhook signing scope resolution failed" ,
351+ recovery = "terminal" ,
352+ ) from None
353+ if not isinstance (value , str ):
354+ raise AdcpError (
355+ "INTERNAL_ERROR" ,
356+ message = "Webhook signing scope resolver returned an invalid value" ,
357+ recovery = "terminal" ,
358+ )
359+ # Reuse the outbox's bounded opaque-ID validation before any task row
360+ # is issued. This is trusted server state, but it still crosses a DB
361+ # and authenticated-envelope boundary.
362+ if self .task_webhook_outbox is None :
363+ raise RuntimeError ("signing scope resolver requires a task webhook outbox" )
364+ self .task_webhook_outbox ._validate_signing_scope_id (value )
365+ return value
366+
308367 async def update_progress (
309368 self ,
310369 task_id : str ,
@@ -475,12 +534,14 @@ async def _enqueue_terminal_if_registered(
475534 )
476535 if registration_nonce is None :
477536 raise RuntimeError (f"Task { task_id !r} has incomplete webhook registration" )
478- url , operation_id , token = self .task_webhook_outbox .open_registration (
479- account_id = account_id ,
480- task_id = task_id ,
481- task_type = task_type ,
482- encrypted_registration = bytes (encrypted_registration ),
483- nonce = bytes (registration_nonce ),
537+ url , operation_id , token , signing_scope_id = (
538+ self .task_webhook_outbox ._open_registration_with_scope (
539+ account_id = account_id ,
540+ task_id = task_id ,
541+ task_type = task_type ,
542+ encrypted_registration = bytes (encrypted_registration ),
543+ nonce = bytes (registration_nonce ),
544+ )
484545 )
485546 await self .task_webhook_outbox .enqueue_terminal (
486547 conn ,
@@ -492,6 +553,7 @@ async def _enqueue_terminal_if_registered(
492553 url = url ,
493554 operation_id = operation_id ,
494555 token = token ,
556+ signing_scope_id = signing_scope_id ,
495557 )
496558 # The encrypted outbox envelope now owns the callback registration.
497559 # Clear the task-row copy in this same transaction.
@@ -515,4 +577,9 @@ async def discard(self, task_id: str) -> None:
515577PostgresTaskRegistry = PgTaskRegistry
516578
517579
518- __all__ = ["PG_AVAILABLE" , "PgTaskRegistry" , "PostgresTaskRegistry" ]
580+ __all__ = [
581+ "PG_AVAILABLE" ,
582+ "PgTaskRegistry" ,
583+ "PostgresTaskRegistry" ,
584+ "WebhookSigningScopeResolver" ,
585+ ]
0 commit comments