deepline 0.3.113 → 0.3.114

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 (48) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +3 -1
  2. package/dist/bundling-sources/sdk/src/compat.ts +167 -11
  3. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  4. package/dist/bundling-sources/sdk/src/skills-version.ts +27 -1
  5. package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +6 -2
  6. package/dist/bundling-sources/shared_libs/observability/queue-health.ts +291 -6
  7. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
  10. package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
  11. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
  18. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1394 -22
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
  29. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
  30. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
  31. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
  32. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
  33. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
  34. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
  35. package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
  36. package/dist/cli/index.js +574 -114
  37. package/dist/cli/index.mjs +592 -133
  38. package/dist/index.d.mts +54 -1
  39. package/dist/index.d.ts +54 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.mjs +8 -3
  42. package/dist/install-integrity.json +7 -0
  43. package/dist/plays/bundle-play-file.mjs +1 -1
  44. package/dist/release.d.mts +1 -1
  45. package/dist/release.d.ts +1 -1
  46. package/dist/release.js +1 -1
  47. package/dist/release.mjs +1 -1
  48. package/package.json +1 -1
@@ -976,13 +976,13 @@
976
976
  "domainReportRequired": true
977
977
  },
978
978
  {
979
- "id": "vercel.play-webhook-receipt-recovery",
979
+ "id": "convex.recover-play-webhook-receipts",
980
980
  "name": "Recover Play webhook receipts",
981
- "scheduler": "vercel_cron",
982
- "cadence": "* * * * *",
981
+ "scheduler": "convex_cron",
982
+ "cadence": "every 60s",
983
983
  "timezone": "UTC",
984
- "source": "apps/deepline-api/vercel.json",
985
- "locator": "/api/v2/cron/play-webhook-receipt-recovery",
984
+ "source": "packages/convex/crons.ts",
985
+ "locator": "recover Play webhook receipts",
986
986
  "owner": "runtime",
987
987
  "criticality": "critical",
988
988
  "purpose": "Recover Play webhook receipts whose admission/dispatch continuation is delayed or stranded.",
@@ -990,7 +990,25 @@
990
990
  "failureDefinition": "A receipt cannot be safely recovered, remains stranded, or evidence cannot persist.",
991
991
  "startGraceMs": 300000,
992
992
  "runTimeoutMs": 1800000,
993
- "failureAlert": "vercel_route_wrapper",
993
+ "failureAlert": "convex_action_wrapper",
994
+ "domainReportRequired": true
995
+ },
996
+ {
997
+ "id": "convex.recover-signal-radar-inbound-receipts",
998
+ "name": "Recover Signal Radar inbound receipts",
999
+ "scheduler": "convex_cron",
1000
+ "cadence": "every 60s",
1001
+ "timezone": "UTC",
1002
+ "source": "packages/convex/crons.ts",
1003
+ "locator": "recover Signal Radar inbound receipts",
1004
+ "owner": "monitors",
1005
+ "criticality": "critical",
1006
+ "purpose": "Recover Signal Radar inbound receipts whose processing is delayed or stranded.",
1007
+ "successDefinition": "Every eligible receipt is retried or terminally classified with evidence; no eligible receipts is a valid no-op.",
1008
+ "failureDefinition": "A receipt cannot be safely recovered, remains stranded, or evidence cannot persist.",
1009
+ "startGraceMs": 300000,
1010
+ "runTimeoutMs": 1800000,
1011
+ "failureAlert": "convex_action_wrapper",
994
1012
  "domainReportRequired": true
995
1013
  },
996
1014
  {
@@ -1011,24 +1029,6 @@
1011
1029
  "failureAlert": "convex_action_wrapper",
1012
1030
  "domainReportRequired": true
1013
1031
  },
1014
- {
1015
- "id": "github.modal-ci-runner-weekly-proof",
1016
- "name": "Modal CI runner",
1017
- "scheduler": "github_actions",
1018
- "cadence": "23 6 * * 1",
1019
- "timezone": "UTC",
1020
- "source": ".github/workflows/modal-ci-runner.yml",
1021
- "locator": "schedule:",
1022
- "owner": "platform",
1023
- "criticality": "maintenance",
1024
- "purpose": "Lint, test, deploy, and prove the pinned Modal CI runner image and isolated smoke path.",
1025
- "successDefinition": "Pinned package lint/tests, control-plane/image deploy, and exact-image credential-isolation smoke all pass.",
1026
- "failureDefinition": "Any lint/test/deploy/health/smoke/cleanup proof fails or image identity is wrong.",
1027
- "startGraceMs": 600000,
1028
- "runTimeoutMs": 3600000,
1029
- "failureAlert": "github_workflow_run",
1030
- "domainReportRequired": true
1031
- },
1032
1032
  {
1033
1033
  "id": "convex.async-usage-recovery-continuation",
1034
1034
  "name": "Async usage recovery continuation",
@@ -18,6 +18,8 @@ export const MANUALLY_RUNNABLE_CONVEX_CRON_JOB_IDS = [
18
18
  'convex.monitor-deployment-job-tick',
19
19
  'convex.provider-monitor-global-audit-daily',
20
20
  'convex.recover-notification-delivery-work',
21
+ 'convex.recover-play-webhook-receipts',
22
+ 'convex.recover-signal-radar-inbound-receipts',
21
23
  'convex.process-notification-usage-refreshes',
22
24
  'convex.observe-operational-queue-health',
23
25
  'convex.cleanup-expired-notification-audit-records',
@@ -1,4 +1,6 @@
1
- export const SCHEDULED_WORK_OBSERVABILITY_VERSION = 1 as const;
1
+ /** The only scheduled-work telemetry envelope emitted after the hard cut. */
2
+ export const SCHEDULED_WORK_OBSERVABILITY_VERSION = 2 as const;
3
+ export const SCHEDULED_WORK_CONTRACT_VERSION = 2 as const;
2
4
 
3
5
  export type ScheduledWorkScheduler =
4
6
  | 'convex_cron'
@@ -11,6 +13,17 @@ export type ScheduledWorkScheduler =
11
13
 
12
14
  export type ScheduledWorkOutcome = 'running' | 'succeeded' | 'failed';
13
15
  export type ScheduledWorkTrigger = 'scheduled' | 'manual';
16
+ export type ScheduledWorkDomainOutcome = 'success' | 'warning' | 'failure';
17
+ export type ScheduledWorkFailureClass = 'execution' | 'domain';
18
+
19
+ export type ScheduledWorkTelemetryDisposition = {
20
+ /** The occurrence slot this event belongs to, when one exists. */
21
+ occurrenceId?: string;
22
+ expectedAt?: number;
23
+ /** Explicit domain result; absence is a migration gap, not a green run. */
24
+ domainOutcome?: ScheduledWorkDomainOutcome;
25
+ failureClass?: ScheduledWorkFailureClass;
26
+ };
14
27
 
15
28
  export type ScheduledWorkVolume = {
16
29
  unit: string;
@@ -44,9 +57,12 @@ export type ScheduledWorkObservation = {
44
57
 
45
58
  export function scheduledWorkTelemetryFields(
46
59
  observation: ScheduledWorkObservation,
60
+ disposition: ScheduledWorkTelemetryDisposition = {},
47
61
  ): Record<string, string | number> {
48
62
  return {
49
63
  scheduledWorkSchemaVersion: SCHEDULED_WORK_OBSERVABILITY_VERSION,
64
+ // Keep one explicit marker for the hard-cut occurrence/domain contract.
65
+ scheduledWorkContractVersion: SCHEDULED_WORK_CONTRACT_VERSION,
50
66
  scheduledWorkJobId: observation.jobId,
51
67
  scheduledWorkJobName: observation.jobName,
52
68
  scheduledWorkScheduler: observation.scheduler,
@@ -55,6 +71,18 @@ export function scheduledWorkTelemetryFields(
55
71
  scheduledWorkOutcome: observation.outcome,
56
72
  scheduledWorkInvocationId: observation.invocationId,
57
73
  scheduledWorkExpectedWithinMs: observation.expectedWithinMs,
74
+ ...(disposition.occurrenceId === undefined
75
+ ? {}
76
+ : { scheduledWorkOccurrenceId: disposition.occurrenceId }),
77
+ ...(disposition.expectedAt === undefined
78
+ ? {}
79
+ : { scheduledWorkExpectedAt: disposition.expectedAt }),
80
+ ...(disposition.domainOutcome === undefined
81
+ ? {}
82
+ : { scheduledWorkDomainOutcome: disposition.domainOutcome }),
83
+ ...(disposition.failureClass === undefined
84
+ ? {}
85
+ : { scheduledWorkFailureClass: disposition.failureClass }),
58
86
  ...(observation.trigger === undefined
59
87
  ? {}
60
88
  : { scheduledWorkTrigger: observation.trigger }),
@@ -0,0 +1,91 @@
1
+ import { PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_MAX_RECEIPTS } from '../security/receipt-admission-token';
2
+
3
+ /**
4
+ * The per-request concurrency budget for moving a durable Play source into
5
+ * the Runtime Postgres + Absurd admission transaction. This is not an
6
+ * execution limit: Absurd owns execution capacity independently.
7
+ *
8
+ * Thirty-two is the private batch's entire scheduler-client budget for a
9
+ * normal 1,000-delivery burst. One client remains reserved below, rather than
10
+ * letting source admission consume every control-plane connection.
11
+ */
12
+ export const PLAY_ADMISSION_MAX_PARALLELISM = 32;
13
+
14
+ /**
15
+ * The private batch owns all 32 serverless scheduler clients, but one remains
16
+ * available for executor-token and other critical runtime control-plane work.
17
+ * Only atomic Runtime Postgres + Absurd submission uses this value.
18
+ * Source-lifecycle preflight has its own bounded budget because it does not
19
+ * consume a scheduler-pool client.
20
+ */
21
+ export const PLAY_ADMISSION_MAX_ACTIVE_SUBMITS =
22
+ PLAY_ADMISSION_MAX_PARALLELISM - 1;
23
+
24
+ /**
25
+ * A batch is allowed to prepare more independent source lifecycles than it
26
+ * may submit to Runtime Postgres at once. Preparation is mostly Convex/CPU
27
+ * work (read the immutable receipt, validate its pinned launch, mint its
28
+ * authority); it must not sit behind a scarce PgBouncer connection. The
29
+ * scheduler submitter remains capped by `PLAY_ADMISSION_MAX_ACTIVE_SUBMITS`.
30
+ *
31
+ * This is deliberately finite. It lets a 1K burst fill the 31 durable-submit
32
+ * slots while prior sources are still doing their non-Postgres preparation,
33
+ * without turning the private route into an unbounded fan-out.
34
+ */
35
+ export const PLAY_ADMISSION_MAX_ACTIVE_LIFECYCLES = 256;
36
+
37
+ /**
38
+ * A private 1K batch may queue behind the bounded scheduler submit cohort for
39
+ * longer than an ordinary single-run request, but never beyond this deadline.
40
+ */
41
+ export const PLAY_ADMISSION_MAX_ACQUIRE_QUEUE_WAIT_MS = 120_000;
42
+
43
+ /**
44
+ * One managed transport request may carry this many independently durable
45
+ * sources. The Vercel adapter processes one legacy wave at bounded
46
+ * source-lifecycle concurrency; its scheduler submit gate separately reserves
47
+ * critical control-plane capacity.
48
+ */
49
+ export const PLAY_ADMISSION_BATCH_SIZE = PLAY_ADMISSION_MAX_PARALLELISM;
50
+
51
+ /**
52
+ * `batch_v2` carries one full, bounded ingress burst into a single Vercel
53
+ * invocation. The invocation still submits at most
54
+ * `PLAY_ADMISSION_MAX_ACTIVE_SUBMITS` Runs at a time; this only removes the
55
+ * serial Convex-to-Vercel round trip between 32-receipt waves. The current
56
+ * dispatcher sends one request and lets that request drain its owned tail.
57
+ */
58
+ export const PLAY_ADMISSION_LARGE_BATCH_SIZE =
59
+ PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_MAX_RECEIPTS;
60
+
61
+ /**
62
+ * `batch_v1` remains during the Convex-before-Vercel rollout window. It
63
+ * drains this many 32-source waves from one fenced Workpool action. New
64
+ * Retained custom-runner v2 actions may take a second large wave while their
65
+ * finite rollout cohort drains. Current scheduled dispatches own one
66
+ * exact wave and do not use this compatibility limit.
67
+ */
68
+ export const PLAY_ADMISSION_BATCHES_PER_DISPATCH = 32;
69
+
70
+ export const PLAY_ADMISSION_LARGE_BATCHES_PER_DISPATCH = 2;
71
+
72
+ /**
73
+ * Upper bound for one direct drain under continuous traffic, not an admission
74
+ * SLO or a debounce. A request stops as soon as its queue is empty. Keep the
75
+ * exported name and persisted deadline compatible with retained dispatches.
76
+ */
77
+ export const PLAY_ADMISSION_DIRECT_BATCH_COLLECTION_MAX_MS = 60_000;
78
+
79
+ /** Poll/debounce interval for retained legacy dispatch implementations only. */
80
+ export const PLAY_ADMISSION_DIRECT_BATCH_QUIET_MS = 250;
81
+
82
+ /**
83
+ * Workpool is recovery, not the normal batch transport. It becomes eligible
84
+ * quickly, but the ownerless direct-dispatch fence keeps it from claiming the
85
+ * same source while the private request drains. That gives a lost action
86
+ * prompt recovery without adding another normal-path batch request.
87
+ */
88
+ export const PLAY_ADMISSION_DIRECT_FALLBACK_DELAY_MS = 15_000;
89
+
90
+ /** A pending source older than this is actionable queue delay. */
91
+ export const PLAY_ADMISSION_DEGRADED_QUEUE_AGE_MS = 60_000;
@@ -1123,6 +1123,8 @@ export function resolveToolRuntimeTimeoutMs(
1123
1123
  normalized === 'deeplineagent_deeplineagent' ||
1124
1124
  normalized === 'ai_inference' ||
1125
1125
  normalized === 'deeplineagent_ai_inference' ||
1126
+ normalized === 'ai_evaluate' ||
1127
+ normalized === 'deeplineagent_ai_evaluate' ||
1126
1128
  normalized === 'aiinference'
1127
1129
  ? DEEPLINEAGENT_TOOL_RUNTIME_TIMEOUT_MS
1128
1130
  : DEFAULT_TOOL_RUNTIME_TIMEOUT_MS;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bounds a synchronous direct-admission attempt. The source and its delayed
3
+ * Workpool recovery item already committed before this helper is used, so a
4
+ * timeout is deliberately an ambiguous outcome rather than a failed delivery.
5
+ * The race does not cancel already-started work; a replay must use the same
6
+ * idempotency identity and converge on the existing Runtime Run/Absurd task.
7
+ */
8
+ export const PLAY_DIRECT_ADMISSION_DEADLINE_MS = 1_500;
9
+
10
+ export class PlayDirectAdmissionDeadlineExceededError extends Error {
11
+ constructor(timeoutMs: number) {
12
+ super(`Direct Play admission exceeded its ${timeoutMs}ms deadline.`);
13
+ this.name = 'PlayDirectAdmissionDeadlineExceededError';
14
+ }
15
+ }
16
+
17
+ export async function withPlayDirectAdmissionDeadline<T>(
18
+ fn: () => Promise<T> | T,
19
+ timeoutMs = PLAY_DIRECT_ADMISSION_DEADLINE_MS,
20
+ ): Promise<T> {
21
+ let timer: ReturnType<typeof setTimeout> | undefined;
22
+ const work = Promise.resolve().then(fn);
23
+ const deadline = new Promise<never>((_resolve, reject) => {
24
+ timer = setTimeout(
25
+ () => reject(new PlayDirectAdmissionDeadlineExceededError(timeoutMs)),
26
+ timeoutMs,
27
+ );
28
+ });
29
+ try {
30
+ return await Promise.race([work, deadline]);
31
+ } finally {
32
+ if (timer !== undefined) clearTimeout(timer);
33
+ }
34
+ }
@@ -456,6 +456,7 @@ export function isReplaySafeFixtureToolId(toolId: string): boolean {
456
456
  return (
457
457
  shouldRouteFixtureToolId(normalized) &&
458
458
  normalized !== 'ai_inference' &&
459
+ normalized !== 'ai_evaluate' &&
459
460
  normalized !== 'deeplineagent' &&
460
461
  !normalized.startsWith('deeplineagent_')
461
462
  );
@@ -10,6 +10,13 @@ import {
10
10
  type FixtureBehavior,
11
11
  } from './/fixture-behavior';
12
12
  import { normalizeRuntimeSchedulerSchema } from './/runtime-scheduler-topology';
13
+ import {
14
+ mintPlayWebhookReceiptLargeBatchAdmissionToken as mintPlayWebhookReceiptLargeBatchAdmissionTokenShared,
15
+ mintPlayTriggerEventAdmissionToken as mintPlayTriggerEventAdmissionTokenShared,
16
+ mintPlayWebhookReceiptBatchAdmissionToken as mintPlayWebhookReceiptBatchAdmissionTokenShared,
17
+ mintPlayWebhookReceiptAdmissionToken as mintPlayWebhookReceiptAdmissionTokenShared,
18
+ mintSignalRadarInboundReceiptToken as mintSignalRadarInboundReceiptTokenShared,
19
+ } from '../security/receipt-admission-token';
13
20
  import { SignJWT, exportJWK, importJWK, jwtVerify, type JWK } from 'jose';
14
21
  import {
15
22
  directCompletedTerminalContract,
@@ -79,6 +86,24 @@ const PRODUCT_NOTIFICATION_DELIVERY_AUDIENCE =
79
86
  'deepline.product_notification.delivery' as const;
80
87
  const PRODUCT_NOTIFICATION_DELIVERY_PURPOSE =
81
88
  'product_notification_delivery' as const;
89
+ const PLAY_WEBHOOK_RECEIPT_ADMISSION_AUDIENCE =
90
+ 'deepline.play_webhook_receipt.admission' as const;
91
+ const PLAY_WEBHOOK_RECEIPT_ADMISSION_PURPOSE =
92
+ 'play_webhook_receipt_admission' as const;
93
+ const PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_AUDIENCE =
94
+ 'deepline.play_webhook_receipt.batch_admission' as const;
95
+ const PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_PURPOSE =
96
+ 'play_webhook_receipt_batch_admission' as const;
97
+ const PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_ADMISSION_PURPOSE =
98
+ 'play_webhook_receipt_large_batch_admission' as const;
99
+ const PLAY_TRIGGER_EVENT_ADMISSION_AUDIENCE =
100
+ 'deepline.play_trigger_event.admission' as const;
101
+ const PLAY_TRIGGER_EVENT_ADMISSION_PURPOSE =
102
+ 'play_trigger_event_admission' as const;
103
+ const SIGNAL_RADAR_INBOUND_RECEIPT_AUDIENCE =
104
+ 'deepline.signal_radar_inbound_receipt.process' as const;
105
+ const SIGNAL_RADAR_INBOUND_RECEIPT_PURPOSE =
106
+ 'signal_radar_inbound_receipt_process' as const;
82
107
  const PRODUCT_NOTIFICATION_UNSUBSCRIBE_AUDIENCE =
83
108
  'deepline.product_notification.unsubscribe' as const;
84
109
  const PRODUCT_NOTIFICATION_UNSUBSCRIBE_PURPOSE =
@@ -146,6 +171,67 @@ export type ProductNotificationDeliveryTokenClaims = {
146
171
  v: typeof INTERNAL_BOOTSTRAP_TOKEN_VERSION;
147
172
  };
148
173
 
174
+ /** Capability for exactly one Convex-owned webhook receipt admission. */
175
+ export type PlayWebhookReceiptAdmissionTokenClaims = {
176
+ aud: typeof PLAY_WEBHOOK_RECEIPT_ADMISSION_AUDIENCE;
177
+ exp: number;
178
+ iat: number;
179
+ iss: typeof INTERNAL_BOOTSTRAP_TOKEN_ISSUER;
180
+ org_id: string;
181
+ purpose: typeof PLAY_WEBHOOK_RECEIPT_ADMISSION_PURPOSE;
182
+ receipt_id: string;
183
+ sub: 'play_webhook_receipt_dispatcher';
184
+ v: typeof INTERNAL_BOOTSTRAP_TOKEN_VERSION;
185
+ };
186
+
187
+ /** Capability for a bounded, single-workspace collection of receipt IDs. */
188
+ type PlayWebhookReceiptBatchAdmissionTokenBaseClaims = {
189
+ aud: typeof PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_AUDIENCE;
190
+ exp: number;
191
+ iat: number;
192
+ iss: typeof INTERNAL_BOOTSTRAP_TOKEN_ISSUER;
193
+ org_id: string;
194
+ v: typeof INTERNAL_BOOTSTRAP_TOKEN_VERSION;
195
+ };
196
+
197
+ export type PlayWebhookReceiptBatchAdmissionTokenClaims =
198
+ | (PlayWebhookReceiptBatchAdmissionTokenBaseClaims & {
199
+ purpose: typeof PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_PURPOSE;
200
+ receipt_ids: string[];
201
+ sub: 'play_webhook_receipt_batch_dispatcher';
202
+ })
203
+ | (PlayWebhookReceiptBatchAdmissionTokenBaseClaims & {
204
+ purpose: typeof PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_ADMISSION_PURPOSE;
205
+ receipt_ids_sha256: string;
206
+ sub: 'play_webhook_receipt_large_batch_dispatcher';
207
+ });
208
+
209
+ /** Capability for exactly one Convex-owned cron trigger-event admission. */
210
+ export type PlayTriggerEventAdmissionTokenClaims = {
211
+ aud: typeof PLAY_TRIGGER_EVENT_ADMISSION_AUDIENCE;
212
+ event_id: string;
213
+ exp: number;
214
+ iat: number;
215
+ iss: typeof INTERNAL_BOOTSTRAP_TOKEN_ISSUER;
216
+ org_id: string;
217
+ purpose: typeof PLAY_TRIGGER_EVENT_ADMISSION_PURPOSE;
218
+ sub: 'play_trigger_event_dispatcher';
219
+ v: typeof INTERNAL_BOOTSTRAP_TOKEN_VERSION;
220
+ };
221
+
222
+ /** Capability for one Convex-owned monitor inbound receipt processor call. */
223
+ export type SignalRadarInboundReceiptTokenClaims = {
224
+ aud: typeof SIGNAL_RADAR_INBOUND_RECEIPT_AUDIENCE;
225
+ exp: number;
226
+ iat: number;
227
+ iss: typeof INTERNAL_BOOTSTRAP_TOKEN_ISSUER;
228
+ org_id: string;
229
+ purpose: typeof SIGNAL_RADAR_INBOUND_RECEIPT_PURPOSE;
230
+ receipt_id: string;
231
+ sub: 'signal_radar_inbound_receipt_dispatcher';
232
+ v: typeof INTERNAL_BOOTSTRAP_TOKEN_VERSION;
233
+ };
234
+
149
235
  export type ProductNotificationUnsubscribeTokenClaims = {
150
236
  aud: typeof PRODUCT_NOTIFICATION_UNSUBSCRIBE_AUDIENCE;
151
237
  group_key: string;
@@ -1045,6 +1131,245 @@ export async function verifyProductNotificationDeliveryToken(
1045
1131
  return claims as ProductNotificationDeliveryTokenClaims;
1046
1132
  }
1047
1133
 
1134
+ export async function mintPlayWebhookReceiptAdmissionToken(input: {
1135
+ orgId: string;
1136
+ receiptId: string;
1137
+ nowMs?: number;
1138
+ }): Promise<string> {
1139
+ return await mintPlayWebhookReceiptAdmissionTokenShared(input);
1140
+ }
1141
+
1142
+ export async function mintPlayWebhookReceiptBatchAdmissionToken(input: {
1143
+ orgId: string;
1144
+ receiptIds: readonly string[];
1145
+ nowMs?: number;
1146
+ }): Promise<string> {
1147
+ return await mintPlayWebhookReceiptBatchAdmissionTokenShared(input);
1148
+ }
1149
+
1150
+ export async function mintPlayWebhookReceiptLargeBatchAdmissionToken(input: {
1151
+ orgId: string;
1152
+ receiptIds: readonly string[];
1153
+ nowMs?: number;
1154
+ }): Promise<string> {
1155
+ return await mintPlayWebhookReceiptLargeBatchAdmissionTokenShared(input);
1156
+ }
1157
+
1158
+ export async function verifyPlayWebhookReceiptBatchAdmissionToken(
1159
+ token: string,
1160
+ nowMs = Date.now(),
1161
+ ): Promise<PlayWebhookReceiptBatchAdmissionTokenClaims> {
1162
+ const segments = token.split('.');
1163
+ if (segments.length !== 3)
1164
+ throw new Error('Invalid webhook receipt batch token.');
1165
+ const [encodedHeader, encodedClaims, signature] = segments;
1166
+ const header = decodeJson<Record<string, unknown>>(encodedHeader);
1167
+ if (header.alg !== 'HS256' || header.typ !== 'JWT') {
1168
+ throw new Error('Invalid webhook receipt batch token.');
1169
+ }
1170
+ const expectedSignature = await signTokenMaterial(
1171
+ `${encodedHeader}.${encodedClaims}`,
1172
+ );
1173
+ if (
1174
+ !timingSafeEqual(fromBase64Url(signature), fromBase64Url(expectedSignature))
1175
+ ) {
1176
+ throw new Error('Invalid webhook receipt batch token.');
1177
+ }
1178
+ const claims = decodeJson<Record<string, unknown>>(encodedClaims);
1179
+ const hasInvalidBaseClaims =
1180
+ claims.v !== INTERNAL_BOOTSTRAP_TOKEN_VERSION ||
1181
+ claims.iss !== INTERNAL_BOOTSTRAP_TOKEN_ISSUER ||
1182
+ claims.aud !== PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_AUDIENCE ||
1183
+ typeof claims.org_id !== 'string' ||
1184
+ claims.org_id.length === 0 ||
1185
+ typeof claims.iat !== 'number' ||
1186
+ !Number.isFinite(claims.iat) ||
1187
+ typeof claims.exp !== 'number' ||
1188
+ !Number.isFinite(claims.exp);
1189
+ const hasValidV1Claims =
1190
+ claims.purpose === PLAY_WEBHOOK_RECEIPT_BATCH_ADMISSION_PURPOSE &&
1191
+ claims.sub === 'play_webhook_receipt_batch_dispatcher' &&
1192
+ Array.isArray(claims.receipt_ids) &&
1193
+ claims.receipt_ids.length >= 1 &&
1194
+ claims.receipt_ids.length <= 32 &&
1195
+ !claims.receipt_ids.some(
1196
+ (receiptId) => typeof receiptId !== 'string' || receiptId.length === 0,
1197
+ ) &&
1198
+ new Set(claims.receipt_ids).size === claims.receipt_ids.length;
1199
+ const hasValidV2Claims =
1200
+ claims.purpose === PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_ADMISSION_PURPOSE &&
1201
+ claims.sub === 'play_webhook_receipt_large_batch_dispatcher' &&
1202
+ typeof claims.receipt_ids_sha256 === 'string' &&
1203
+ /^[A-Za-z0-9_-]{43}$/.test(claims.receipt_ids_sha256);
1204
+ if (
1205
+ hasInvalidBaseClaims ||
1206
+ (!hasValidV1Claims && !hasValidV2Claims)
1207
+ ) {
1208
+ throw new Error('Invalid webhook receipt batch token.');
1209
+ }
1210
+ // `hasInvalidBaseClaims` is deliberately a named diagnostic, but TypeScript
1211
+ // cannot narrow `Record<string, unknown>` through that boolean. Reassert the
1212
+ // already-validated temporal claim types before comparing them below.
1213
+ const expiresAt = claims.exp;
1214
+ const issuedAt = claims.iat;
1215
+ if (typeof expiresAt !== 'number' || typeof issuedAt !== 'number') {
1216
+ throw new Error('Invalid webhook receipt batch token.');
1217
+ }
1218
+ const nowSeconds = Math.floor(nowMs / 1000);
1219
+ if (expiresAt <= nowSeconds || issuedAt > nowSeconds + 30) {
1220
+ throw new Error('Webhook receipt batch token expired.');
1221
+ }
1222
+ return claims as PlayWebhookReceiptBatchAdmissionTokenClaims;
1223
+ }
1224
+
1225
+ export async function verifyPlayWebhookReceiptAdmissionToken(
1226
+ token: string,
1227
+ nowMs = Date.now(),
1228
+ ): Promise<PlayWebhookReceiptAdmissionTokenClaims> {
1229
+ const segments = token.split('.');
1230
+ if (segments.length !== 3) throw new Error('Invalid webhook receipt token.');
1231
+ const [encodedHeader, encodedClaims, signature] = segments;
1232
+ const header = decodeJson<Record<string, unknown>>(encodedHeader);
1233
+ if (header.alg !== 'HS256' || header.typ !== 'JWT') {
1234
+ throw new Error('Invalid webhook receipt token.');
1235
+ }
1236
+ const expectedSignature = await signTokenMaterial(
1237
+ `${encodedHeader}.${encodedClaims}`,
1238
+ );
1239
+ if (
1240
+ !timingSafeEqual(fromBase64Url(signature), fromBase64Url(expectedSignature))
1241
+ ) {
1242
+ throw new Error('Invalid webhook receipt token.');
1243
+ }
1244
+ const claims = decodeJson<Record<string, unknown>>(encodedClaims);
1245
+ if (
1246
+ claims.v !== INTERNAL_BOOTSTRAP_TOKEN_VERSION ||
1247
+ claims.iss !== INTERNAL_BOOTSTRAP_TOKEN_ISSUER ||
1248
+ claims.aud !== PLAY_WEBHOOK_RECEIPT_ADMISSION_AUDIENCE ||
1249
+ claims.purpose !== PLAY_WEBHOOK_RECEIPT_ADMISSION_PURPOSE ||
1250
+ claims.sub !== 'play_webhook_receipt_dispatcher' ||
1251
+ typeof claims.org_id !== 'string' ||
1252
+ claims.org_id.length === 0 ||
1253
+ typeof claims.receipt_id !== 'string' ||
1254
+ claims.receipt_id.length === 0 ||
1255
+ typeof claims.iat !== 'number' ||
1256
+ !Number.isFinite(claims.iat) ||
1257
+ typeof claims.exp !== 'number' ||
1258
+ !Number.isFinite(claims.exp)
1259
+ ) {
1260
+ throw new Error('Invalid webhook receipt token.');
1261
+ }
1262
+ const nowSeconds = Math.floor(nowMs / 1000);
1263
+ if (claims.exp <= nowSeconds || claims.iat > nowSeconds + 30) {
1264
+ throw new Error('Webhook receipt token expired.');
1265
+ }
1266
+ return claims as PlayWebhookReceiptAdmissionTokenClaims;
1267
+ }
1268
+
1269
+ export async function mintPlayTriggerEventAdmissionToken(input: {
1270
+ orgId: string;
1271
+ eventId: string;
1272
+ nowMs?: number;
1273
+ }): Promise<string> {
1274
+ return await mintPlayTriggerEventAdmissionTokenShared(input);
1275
+ }
1276
+
1277
+ export async function verifyPlayTriggerEventAdmissionToken(
1278
+ token: string,
1279
+ nowMs = Date.now(),
1280
+ ): Promise<PlayTriggerEventAdmissionTokenClaims> {
1281
+ const segments = token.split('.');
1282
+ if (segments.length !== 3) throw new Error('Invalid trigger event token.');
1283
+ const [encodedHeader, encodedClaims, signature] = segments;
1284
+ const header = decodeJson<Record<string, unknown>>(encodedHeader);
1285
+ if (header.alg !== 'HS256' || header.typ !== 'JWT') {
1286
+ throw new Error('Invalid trigger event token.');
1287
+ }
1288
+ const expectedSignature = await signTokenMaterial(
1289
+ `${encodedHeader}.${encodedClaims}`,
1290
+ );
1291
+ if (
1292
+ !timingSafeEqual(fromBase64Url(signature), fromBase64Url(expectedSignature))
1293
+ ) {
1294
+ throw new Error('Invalid trigger event token.');
1295
+ }
1296
+ const claims = decodeJson<Record<string, unknown>>(encodedClaims);
1297
+ if (
1298
+ claims.v !== INTERNAL_BOOTSTRAP_TOKEN_VERSION ||
1299
+ claims.iss !== INTERNAL_BOOTSTRAP_TOKEN_ISSUER ||
1300
+ claims.aud !== PLAY_TRIGGER_EVENT_ADMISSION_AUDIENCE ||
1301
+ claims.purpose !== PLAY_TRIGGER_EVENT_ADMISSION_PURPOSE ||
1302
+ claims.sub !== 'play_trigger_event_dispatcher' ||
1303
+ typeof claims.org_id !== 'string' ||
1304
+ claims.org_id.length === 0 ||
1305
+ typeof claims.event_id !== 'string' ||
1306
+ claims.event_id.length === 0 ||
1307
+ typeof claims.iat !== 'number' ||
1308
+ !Number.isFinite(claims.iat) ||
1309
+ typeof claims.exp !== 'number' ||
1310
+ !Number.isFinite(claims.exp)
1311
+ ) {
1312
+ throw new Error('Invalid trigger event token.');
1313
+ }
1314
+ const nowSeconds = Math.floor(nowMs / 1000);
1315
+ if (claims.exp <= nowSeconds || claims.iat > nowSeconds + 30) {
1316
+ throw new Error('Trigger event token expired.');
1317
+ }
1318
+ return claims as PlayTriggerEventAdmissionTokenClaims;
1319
+ }
1320
+
1321
+ export async function mintSignalRadarInboundReceiptToken(input: {
1322
+ orgId: string;
1323
+ receiptId: string;
1324
+ nowMs?: number;
1325
+ }): Promise<string> {
1326
+ return await mintSignalRadarInboundReceiptTokenShared(input);
1327
+ }
1328
+
1329
+ export async function verifySignalRadarInboundReceiptToken(
1330
+ token: string,
1331
+ nowMs = Date.now(),
1332
+ ): Promise<SignalRadarInboundReceiptTokenClaims> {
1333
+ const segments = token.split('.');
1334
+ if (segments.length !== 3) throw new Error('Invalid inbound receipt token.');
1335
+ const [encodedHeader, encodedClaims, signature] = segments;
1336
+ const header = decodeJson<Record<string, unknown>>(encodedHeader);
1337
+ if (header.alg !== 'HS256' || header.typ !== 'JWT') {
1338
+ throw new Error('Invalid inbound receipt token.');
1339
+ }
1340
+ const expectedSignature = await signTokenMaterial(
1341
+ `${encodedHeader}.${encodedClaims}`,
1342
+ );
1343
+ if (
1344
+ !timingSafeEqual(fromBase64Url(signature), fromBase64Url(expectedSignature))
1345
+ ) {
1346
+ throw new Error('Invalid inbound receipt token.');
1347
+ }
1348
+ const claims = decodeJson<Record<string, unknown>>(encodedClaims);
1349
+ if (
1350
+ claims.v !== INTERNAL_BOOTSTRAP_TOKEN_VERSION ||
1351
+ claims.iss !== INTERNAL_BOOTSTRAP_TOKEN_ISSUER ||
1352
+ claims.aud !== SIGNAL_RADAR_INBOUND_RECEIPT_AUDIENCE ||
1353
+ claims.purpose !== SIGNAL_RADAR_INBOUND_RECEIPT_PURPOSE ||
1354
+ claims.sub !== 'signal_radar_inbound_receipt_dispatcher' ||
1355
+ typeof claims.org_id !== 'string' ||
1356
+ claims.org_id.length === 0 ||
1357
+ typeof claims.receipt_id !== 'string' ||
1358
+ claims.receipt_id.length === 0 ||
1359
+ typeof claims.iat !== 'number' ||
1360
+ !Number.isFinite(claims.iat) ||
1361
+ typeof claims.exp !== 'number' ||
1362
+ !Number.isFinite(claims.exp)
1363
+ ) {
1364
+ throw new Error('Invalid inbound receipt token.');
1365
+ }
1366
+ const nowSeconds = Math.floor(nowMs / 1000);
1367
+ if (claims.exp <= nowSeconds || claims.iat > nowSeconds + 30) {
1368
+ throw new Error('Inbound receipt token expired.');
1369
+ }
1370
+ return claims as SignalRadarInboundReceiptTokenClaims;
1371
+ }
1372
+
1048
1373
  /**
1049
1374
  * Mints a durable, narrowly scoped opt-out command. It never expires because
1050
1375
  * an old notification must remain usable to stop future email; rotating the