deepline 0.3.113 → 0.3.115

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 (50) 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/dlq.ts +942 -0
  7. package/dist/bundling-sources/shared_libs/observability/queue-health.ts +361 -6
  8. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
  10. package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
  11. package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
  13. package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
  14. package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
  15. package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
  16. package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
  17. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
  19. package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
  22. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
  24. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
  25. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
  26. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
  27. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
  28. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1734 -147
  29. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
  30. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +245 -81
  31. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
  32. package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
  33. package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
  34. package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
  35. package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
  36. package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
  37. package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
  38. package/dist/cli/index.js +574 -114
  39. package/dist/cli/index.mjs +592 -133
  40. package/dist/index.d.mts +54 -1
  41. package/dist/index.d.ts +54 -1
  42. package/dist/index.js +8 -3
  43. package/dist/index.mjs +8 -3
  44. package/dist/install-integrity.json +8 -0
  45. package/dist/plays/bundle-play-file.mjs +1 -1
  46. package/dist/release.d.mts +1 -1
  47. package/dist/release.d.ts +1 -1
  48. package/dist/release.js +1 -1
  49. package/dist/release.mjs +1 -1
  50. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Process-local admission before the atomic Postgres launch transaction.
3
3
  *
4
- * A Vercel app process uses a deliberately tiny two-client scheduler pool.
5
- * Bulk submissions used to consume both clients while executor-token lease
6
- * reads needed one, so a burst turned a retryable token lookup into a failed
7
- * engine leg. One bulk submit at a time keeps a control-plane client free.
4
+ * An ordinary Vercel launch holds one submit permit. The private Workpool
5
+ * batch adapter may widen that permit to the already-fenced request pool
6
+ * capacity minus one reserved control-plane client. The request still owns a
7
+ * single global transport leader, so this does not turn customer webhook
8
+ * bursts into unbounded PgBouncer connections.
8
9
  *
9
10
  * This is intentionally not durable state and does not acknowledge a launch.
10
11
  * The existing submit transaction remains the only admission boundary. If the
@@ -23,6 +24,13 @@ export const RUNTIME_SCHEDULER_SUBMIT_ADMISSION = {
23
24
  maxWaitMs: 60_000,
24
25
  } as const;
25
26
 
27
+ // The private Vercel batch has at most 256 source lifecycles in flight. It
28
+ // may therefore need to park 225 of them behind its 31 reserved scheduler
29
+ // submit clients. Keep the per-process override at that same finite boundary:
30
+ // it is a backpressure buffer, not another durable transport or an unlimited
31
+ // way to consume PgBouncer connections.
32
+ const MAX_RUNTIME_SCHEDULER_SUBMIT_ADMISSION_QUEUED = 256;
33
+
26
34
  type Waiter = {
27
35
  enqueuedAt: number;
28
36
  resolve: (permit: RuntimeSchedulerSubmitAdmissionPermit) => void;
@@ -32,6 +40,8 @@ type Waiter = {
32
40
 
33
41
  type AdmissionEntry = {
34
42
  active: number;
43
+ maxActive: number;
44
+ maxQueued: number;
35
45
  waiters: Waiter[];
36
46
  };
37
47
 
@@ -54,6 +64,18 @@ export type RuntimeSchedulerSubmitAdmissionParams = {
54
64
  /** Kept in-memory only. Do not log this value because it contains DB auth. */
55
65
  schedulerUrl: string;
56
66
  schedulerSchema: string | null | undefined;
67
+ /**
68
+ * The private Vercel batch adapter may widen the local submitter to its
69
+ * bounded scheduler pool. Ordinary launchers omit this and retain one-at-a-
70
+ * time admission.
71
+ */
72
+ maxActive?: number;
73
+ /**
74
+ * A private, already-bounded batch may widen its local wait buffer to its
75
+ * lifecycle budget. Ordinary launchers omit this and retain the 128-entry
76
+ * transient queue.
77
+ */
78
+ maxQueued?: number;
57
79
  };
58
80
 
59
81
  export type RuntimeSchedulerSubmitAdmissionPermit = {
@@ -79,6 +101,37 @@ function pruneEntry(key: string, entry: AdmissionEntry): void {
79
101
  }
80
102
  }
81
103
 
104
+ function maxActiveFor(params: RuntimeSchedulerSubmitAdmissionParams): number {
105
+ const maxActive =
106
+ params.maxActive ?? RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxActive;
107
+ if (!Number.isInteger(maxActive) || maxActive < 1 || maxActive > 32) {
108
+ throw new Error('Runtime scheduler submit admission capacity is invalid.');
109
+ }
110
+ return maxActive;
111
+ }
112
+
113
+ function maxQueuedFor(params: RuntimeSchedulerSubmitAdmissionParams): number {
114
+ const maxQueued =
115
+ params.maxQueued ?? RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxQueued;
116
+ if (
117
+ !Number.isInteger(maxQueued) ||
118
+ maxQueued < 0 ||
119
+ maxQueued > MAX_RUNTIME_SCHEDULER_SUBMIT_ADMISSION_QUEUED
120
+ ) {
121
+ throw new Error('Runtime scheduler submit admission queue is invalid.');
122
+ }
123
+ return maxQueued;
124
+ }
125
+
126
+ function drainEntry(key: string, entry: AdmissionEntry): void {
127
+ while (entry.active < entry.maxActive && entry.waiters.length > 0) {
128
+ const next = entry.waiters.shift()!;
129
+ clearTimeout(next.timeout);
130
+ entry.active += 1;
131
+ next.resolve(permitFor(key, entry, entry.waiters.length, next.enqueuedAt));
132
+ }
133
+ }
134
+
82
135
  function permitFor(
83
136
  key: string,
84
137
  entry: AdmissionEntry,
@@ -92,15 +145,8 @@ function permitFor(
92
145
  release: () => {
93
146
  if (released) return;
94
147
  released = true;
95
- const next = entry.waiters.shift();
96
- if (next) {
97
- clearTimeout(next.timeout);
98
- next.resolve(
99
- permitFor(key, entry, entry.waiters.length, next.enqueuedAt),
100
- );
101
- return;
102
- }
103
148
  entry.active -= 1;
149
+ drainEntry(key, entry);
104
150
  pruneEntry(key, entry);
105
151
  },
106
152
  };
@@ -116,16 +162,34 @@ export async function acquireRuntimeSchedulerSubmitAdmission(
116
162
  ): Promise<RuntimeSchedulerSubmitAdmissionPermit> {
117
163
  const key = keyFor(params);
118
164
  const entries = registry();
119
- const entry = entries.get(key) ?? { active: 0, waiters: [] };
165
+ const requestedMaxActive = maxActiveFor(params);
166
+ const requestedMaxQueued = maxQueuedFor(params);
167
+ const entry = entries.get(key) ?? {
168
+ active: 0,
169
+ maxActive: requestedMaxActive,
170
+ maxQueued: requestedMaxQueued,
171
+ waiters: [],
172
+ };
120
173
  entries.set(key, entry);
121
174
  const enqueuedAt = Date.now();
122
175
 
123
- if (entry.active < RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxActive) {
176
+ // A private batch may arrive after ordinary work has established this lane.
177
+ // Upgrade its shared gate in place so both call paths stay under one actual
178
+ // pool ceiling instead of creating independent per-protocol queues.
179
+ if (requestedMaxActive > entry.maxActive) {
180
+ entry.maxActive = requestedMaxActive;
181
+ drainEntry(key, entry);
182
+ }
183
+ if (requestedMaxQueued > entry.maxQueued) {
184
+ entry.maxQueued = requestedMaxQueued;
185
+ }
186
+
187
+ if (entry.active < entry.maxActive && entry.waiters.length === 0) {
124
188
  entry.active += 1;
125
189
  return permitFor(key, entry, 0, enqueuedAt);
126
190
  }
127
191
 
128
- if (entry.waiters.length >= RUNTIME_SCHEDULER_SUBMIT_ADMISSION.maxQueued) {
192
+ if (entry.waiters.length >= entry.maxQueued) {
129
193
  pruneEntry(key, entry);
130
194
  throw new RuntimeSchedulerSubmissionAdmissionError(
131
195
  'full',
@@ -1,26 +1,29 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import {
3
- PLAY_SECRET_UNAVAILABLE,
4
- playSecretUnavailableErrorFrom,
5
- } from '../plays/secrets/errors';
3
+ classifyPlayAdmissionFailure,
4
+ PLAY_RUN_PRE_RUN_VALIDATION_FAILED,
5
+ RUNTIME_DB_SESSION_PRELOAD_INCOMPLETE,
6
+ } from './/play-admission-outcome';
7
+
8
+ export {
9
+ PLAY_RUN_PRE_RUN_VALIDATION_FAILED,
10
+ RUNTIME_DB_SESSION_PRELOAD_INCOMPLETE,
11
+ };
6
12
 
7
13
  const RETRY_BASE_MS = 1_000;
8
14
  const RETRY_CAP_MS = 15 * 60 * 1_000;
9
15
 
10
16
  /**
11
- * Admission has not called a customer provider or started user code. Unknown
12
- * failures therefore stay retryable: an internal 5xx must not discard a
13
- * source delivery. Only a proven local configuration error is blocked.
17
+ * Compatibility facade for the old webhook-named import. New source adapters
18
+ * use `classifyPlayAdmissionFailure`, while an older Vercel build can keep its
19
+ * boolean result shape during the Convex-before-Vercel rollout window.
14
20
  */
15
- export function classifyWebhookReceiptAdmissionFailure(error: unknown): {
16
- retryable: boolean;
17
- code: 'INTERNAL_ADMISSION_FAILURE' | typeof PLAY_SECRET_UNAVAILABLE;
18
- } {
19
- const unavailableSecret = playSecretUnavailableErrorFrom(error);
20
- if (unavailableSecret) {
21
- return { retryable: false, code: PLAY_SECRET_UNAVAILABLE };
22
- }
23
- return { retryable: true, code: 'INTERNAL_ADMISSION_FAILURE' };
21
+ export function classifyWebhookReceiptAdmissionFailure(error: unknown) {
22
+ const failure = classifyPlayAdmissionFailure(error);
23
+ return {
24
+ retryable: failure.disposition === 'retry',
25
+ code: failure.code,
26
+ };
24
27
  }
25
28
 
26
29
  /**
@@ -40,11 +43,8 @@ export function webhookReceiptRetryDelayMs(input: {
40
43
  .update(String(input.attempt))
41
44
  .digest();
42
45
  // Keep a nonzero delay and give concurrent deliveries independent stable
43
- // placement across the exponential window. The first retry spans 1–2s;
44
- // otherwise a burst of first-attempt failures would synchronise again.
46
+ // placement across the exponential window. Do not multiply a capped ceiling:
47
+ // that would pin every long-lived retry to the same 15-minute boundary.
45
48
  const fraction = hash.readUInt32BE(0) / 0xffff_ffff;
46
- return Math.min(
47
- RETRY_CAP_MS,
48
- Math.max(RETRY_BASE_MS, Math.round(ceiling * (1 + fraction))),
49
- );
49
+ return Math.round(RETRY_BASE_MS + (ceiling - RETRY_BASE_MS) * fraction);
50
50
  }
@@ -1572,7 +1572,10 @@ function docflowObservedExpression(input: {
1572
1572
  ? `async()=>(${input.expression})`
1573
1573
  : `()=>(${input.expression})`;
1574
1574
  const observation = `((runtimeContext,inputCaptures,execute)=>typeof runtimeContext.__deeplineObserveDocflowNode==="function"?runtimeContext.__deeplineObserveDocflowNode(${JSON.stringify(input.nodeId)},inputCaptures,${JSON.stringify(input.outputs)},execute):execute())(${input.contextName},${docflowInputCaptureSource(input.inputs)},${callback})`;
1575
- return input.awaitsResult ? `await ${observation}` : observation;
1575
+ // Parenthesize the awaited form so it is self-delimiting at every source
1576
+ // boundary, including compact `return{...}` syntax. This avoids relying on
1577
+ // whitespace repair around a character-offset replacement.
1578
+ return input.awaitsResult ? `(await ${observation})` : observation;
1576
1579
  }
1577
1580
 
1578
1581
  function expressionContainsTopLevelAwait(expression: AstNode): boolean {
@@ -425,6 +425,70 @@ const PRODUCT_NOTIFICATION_TYPE_REGISTRY = [
425
425
  },
426
426
  },
427
427
  },
428
+ {
429
+ id: 'billing.auto_topup.limit_reached',
430
+ eventName: ProductNotificationEvent.AutoTopupLimitObserved,
431
+ requiredFilter: {},
432
+ allowedTriggers: ['state_transition'],
433
+ catalog: {
434
+ name: 'Auto top-up purchase limit reached',
435
+ category: 'credits',
436
+ description:
437
+ 'Notify when the rolling 30-day purchase cap prevents an automatic top-up.',
438
+ allowMultiple: false,
439
+ editor: { kind: 'none' },
440
+ },
441
+ rule: {
442
+ default: {
443
+ ruleKey: 'default.auto_topup_limit_reached',
444
+ groupKey: 'default',
445
+ },
446
+ conditionSubject: 'workspace',
447
+ create: (_input, base) => ({
448
+ ...base,
449
+ trigger: { kind: 'state_transition', targetState: 'blocked' },
450
+ }),
451
+ validate: (trigger) => {
452
+ if (
453
+ trigger.kind !== 'state_transition' ||
454
+ trigger.targetState !== 'blocked' ||
455
+ trigger.reasonCode !== undefined
456
+ ) {
457
+ throw new Error(
458
+ 'Auto-top-up limit rules must enter the blocked state.',
459
+ );
460
+ }
461
+ },
462
+ present: ({ payload }) => ({
463
+ title: 'Auto top-up purchase limit reached',
464
+ summary: [
465
+ 'An automatic top-up for ',
466
+ { workspace: true },
467
+ ' was blocked by its rolling 30-day purchase cap.',
468
+ ],
469
+ details: [
470
+ [
471
+ 'Purchase cap: ',
472
+ {
473
+ strong: `${formatCredits(Number(payload.purchaseLimitCredits))} credits`,
474
+ },
475
+ ],
476
+ [
477
+ 'Remaining purchase allowance: ',
478
+ {
479
+ strong: `${formatCredits(Number(payload.remainingCredits))} credits`,
480
+ },
481
+ ],
482
+ 'The remaining allowance cannot cover the minimum top-up. Pending automatic purchases also reserve allowance.',
483
+ 'You can continue using your remaining credits, subject to your usage cap. Increase or remove the purchase cap in Billing, or wait for earlier purchases to leave the rolling 30-day window.',
484
+ ],
485
+ targetUrl: String(payload.targetUrl),
486
+ subjectKind: 'workspace',
487
+ subjectId: String(payload.accountId),
488
+ severity: 'warning',
489
+ }),
490
+ },
491
+ },
428
492
  {
429
493
  id: 'credits.low',
430
494
  eventName: ProductNotificationEvent.CreditsBalanceChanged,
@@ -14,7 +14,7 @@ import {
14
14
  * they create sparse copy-on-write overrides.
15
15
  */
16
16
  export const CURRENT_PRODUCT_NOTIFICATION_DEFAULT_POLICY = {
17
- version: 6,
17
+ version: 7,
18
18
  groups: [
19
19
  {
20
20
  groupKey: 'default',
@@ -17,6 +17,7 @@ export const ProductNotificationEvent = {
17
17
  CreditsBalanceChanged: 'credits.balance.changed',
18
18
  CreditsPurchaseCompleted: 'credits.purchase.completed',
19
19
  CreditsUsageRecorded: 'credits.usage.recorded',
20
+ AutoTopupLimitObserved: 'billing.auto_topup.limit_observed',
20
21
  BillingPlanPurchased: 'billing.plan.purchased',
21
22
  MonitorLifecycleChanged: 'monitor.lifecycle.changed',
22
23
  PlayLifecycleChanged: 'play.lifecycle.changed',
@@ -56,6 +57,7 @@ export const PRODUCT_NOTIFICATION_EVENT_NAMES = [
56
57
  ProductNotificationEvent.PlayRunCompleted,
57
58
  ProductNotificationEvent.WebhookDeliveryCompleted,
58
59
  ProductNotificationEvent.CreditsUsageRecorded,
60
+ ProductNotificationEvent.AutoTopupLimitObserved,
59
61
  ProductNotificationEvent.CreditsPurchaseCompleted,
60
62
  ProductNotificationEvent.BillingPlanPurchased,
61
63
  ] as const;
@@ -180,6 +182,20 @@ export const PRODUCT_NOTIFICATION_EVENT_DEFINITIONS: readonly ProductNotificatio
180
182
  allowedTriggers: ['threshold_crossing'],
181
183
  allowedThresholdUnits: ['percent', 'usage'],
182
184
  },
185
+ {
186
+ eventName: ProductNotificationEvent.AutoTopupLimitObserved,
187
+ producerKey: ProductNotificationProducerKey.BillingCredits,
188
+ requiredFacts: {
189
+ accountId: 'string',
190
+ currentState: 'string',
191
+ purchaseLimitCredits: 'number',
192
+ committedCredits: 'number',
193
+ remainingCredits: 'number',
194
+ targetUrl: 'string',
195
+ },
196
+ orderingSubjectFact: 'accountId',
197
+ allowedTriggers: ['state_transition'],
198
+ },
183
199
  {
184
200
  // One paid credit purchase (manual or automatic recharge) settled.
185
201
  eventName: ProductNotificationEvent.CreditsPurchaseCompleted,
@@ -389,7 +405,10 @@ export function adaptProductNotificationObservation(input: {
389
405
  if (trigger.kind === 'state_transition') {
390
406
  return {
391
407
  kind: 'state_transition',
392
- previousState: String(event.payload.previousState),
408
+ previousState:
409
+ typeof event.payload.previousState === 'string'
410
+ ? event.payload.previousState
411
+ : undefined,
393
412
  currentState: String(event.payload.currentState),
394
413
  previousReasonCode:
395
414
  typeof event.payload.previousReasonCode === 'string'
@@ -0,0 +1,224 @@
1
+ /**
2
+ * HMAC capabilities for a Convex-owned receipt to call its corresponding
3
+ * Vercel private adapter. This lives in a package because Convex is a separate
4
+ * workspace: it must mint the same short-lived capability that the app
5
+ * verifies, without importing application source across the workspace seam.
6
+ */
7
+ const TOKEN_VERSION = 1 as const;
8
+ const TOKEN_TTL_SECONDS = 5 * 60;
9
+ const TOKEN_ISSUER = 'deepline.internal' as const;
10
+ const TOKEN_DERIVATION_LABEL = 'deepline:internal-bootstrap-token:v1';
11
+
12
+ const PLAY_WEBHOOK_RECEIPT_AUDIENCE =
13
+ 'deepline.play_webhook_receipt.admission' as const;
14
+ const PLAY_WEBHOOK_RECEIPT_PURPOSE = 'play_webhook_receipt_admission' as const;
15
+ const PLAY_WEBHOOK_RECEIPT_BATCH_AUDIENCE =
16
+ 'deepline.play_webhook_receipt.batch_admission' as const;
17
+ const PLAY_WEBHOOK_RECEIPT_BATCH_PURPOSE =
18
+ 'play_webhook_receipt_batch_admission' as const;
19
+ const PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_PURPOSE =
20
+ 'play_webhook_receipt_large_batch_admission' as const;
21
+ export const PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_MAX_RECEIPTS = 1_024;
22
+ const PLAY_TRIGGER_EVENT_AUDIENCE =
23
+ 'deepline.play_trigger_event.admission' as const;
24
+ const PLAY_TRIGGER_EVENT_PURPOSE = 'play_trigger_event_admission' as const;
25
+ const SIGNAL_RADAR_INBOUND_RECEIPT_AUDIENCE =
26
+ 'deepline.signal_radar_inbound_receipt.process' as const;
27
+ const SIGNAL_RADAR_INBOUND_RECEIPT_PURPOSE =
28
+ 'signal_radar_inbound_receipt_process' as const;
29
+
30
+ function rootSecret(): string {
31
+ const secret = process.env.BETTER_AUTH_SECRET;
32
+ if (!secret) throw new Error('BETTER_AUTH_SECRET is not set.');
33
+ return `${secret}:${TOKEN_DERIVATION_LABEL}`;
34
+ }
35
+
36
+ function bytesToBase64(bytes: Uint8Array): string {
37
+ if (typeof Buffer !== 'undefined') {
38
+ return Buffer.from(bytes).toString('base64');
39
+ }
40
+ let binary = '';
41
+ for (const byte of bytes) binary += String.fromCharCode(byte);
42
+ return btoa(binary);
43
+ }
44
+
45
+ function toBase64Url(bytes: Uint8Array): string {
46
+ return bytesToBase64(bytes)
47
+ .replace(/\+/g, '-')
48
+ .replace(/\//g, '_')
49
+ .replace(/=+$/g, '');
50
+ }
51
+
52
+ function encodeJson(value: unknown): string {
53
+ return toBase64Url(new TextEncoder().encode(JSON.stringify(value)));
54
+ }
55
+
56
+ async function sign(value: string): Promise<string> {
57
+ const key = await crypto.subtle.importKey(
58
+ 'raw',
59
+ new TextEncoder().encode(rootSecret()),
60
+ { name: 'HMAC', hash: 'SHA-256' },
61
+ false,
62
+ ['sign'],
63
+ );
64
+ return toBase64Url(
65
+ new Uint8Array(
66
+ await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value)),
67
+ ),
68
+ );
69
+ }
70
+
71
+ async function mint(claims: Record<string, unknown>): Promise<string> {
72
+ const header = encodeJson({ alg: 'HS256', typ: 'JWT' });
73
+ const payload = encodeJson(claims);
74
+ const signingInput = `${header}.${payload}`;
75
+ return `${signingInput}.${await sign(signingInput)}`;
76
+ }
77
+
78
+ /**
79
+ * Bind a large request body to its capability without putting up to 1,024
80
+ * receipt IDs in an HTTP header. JSON preserves order and length, so this is
81
+ * an exact ordered set rather than a lossy collection fingerprint.
82
+ */
83
+ export async function hashPlayWebhookReceiptBatchIds(
84
+ receiptIds: readonly string[],
85
+ ): Promise<string> {
86
+ return toBase64Url(
87
+ new Uint8Array(
88
+ await crypto.subtle.digest(
89
+ 'SHA-256',
90
+ new TextEncoder().encode(JSON.stringify(receiptIds)),
91
+ ),
92
+ ),
93
+ );
94
+ }
95
+
96
+ function assertUniqueReceiptIds(
97
+ receiptIds: readonly string[],
98
+ maxLength: number,
99
+ label: string,
100
+ ) {
101
+ if (receiptIds.length < 1 || receiptIds.length > maxLength) {
102
+ throw new Error(
103
+ `${label} must contain between 1 and ${maxLength} receipts.`,
104
+ );
105
+ }
106
+ if (
107
+ receiptIds.some(
108
+ (receiptId) =>
109
+ typeof receiptId !== 'string' || receiptId.trim().length === 0,
110
+ )
111
+ ) {
112
+ throw new Error(`${label} receipt IDs must be non-empty strings.`);
113
+ }
114
+ if (new Set(receiptIds).size !== receiptIds.length) {
115
+ throw new Error(`${label} receipt IDs must be unique.`);
116
+ }
117
+ }
118
+
119
+ export async function mintPlayWebhookReceiptAdmissionToken(input: {
120
+ orgId: string;
121
+ receiptId: string;
122
+ nowMs?: number;
123
+ }): Promise<string> {
124
+ const issuedAt = Math.floor((input.nowMs ?? Date.now()) / 1000);
125
+ return await mint({
126
+ v: TOKEN_VERSION,
127
+ iss: TOKEN_ISSUER,
128
+ aud: PLAY_WEBHOOK_RECEIPT_AUDIENCE,
129
+ purpose: PLAY_WEBHOOK_RECEIPT_PURPOSE,
130
+ org_id: input.orgId,
131
+ receipt_id: input.receiptId,
132
+ sub: 'play_webhook_receipt_dispatcher',
133
+ iat: issuedAt,
134
+ exp: issuedAt + TOKEN_TTL_SECONDS,
135
+ });
136
+ }
137
+
138
+ /** Capability for one Convex-owned, single-workspace receipt batch. */
139
+ export async function mintPlayWebhookReceiptBatchAdmissionToken(input: {
140
+ orgId: string;
141
+ receiptIds: readonly string[];
142
+ nowMs?: number;
143
+ }): Promise<string> {
144
+ assertUniqueReceiptIds(input.receiptIds, 32, 'Webhook admission batch');
145
+ const issuedAt = Math.floor((input.nowMs ?? Date.now()) / 1000);
146
+ return await mint({
147
+ v: TOKEN_VERSION,
148
+ iss: TOKEN_ISSUER,
149
+ aud: PLAY_WEBHOOK_RECEIPT_BATCH_AUDIENCE,
150
+ purpose: PLAY_WEBHOOK_RECEIPT_BATCH_PURPOSE,
151
+ org_id: input.orgId,
152
+ receipt_ids: [...input.receiptIds],
153
+ sub: 'play_webhook_receipt_batch_dispatcher',
154
+ iat: issuedAt,
155
+ exp: issuedAt + TOKEN_TTL_SECONDS,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Capability for one large, exact receipt collection. Its digest keeps the
161
+ * signed header small enough for Vercel while the body still carries every
162
+ * independently durable source identity.
163
+ */
164
+ export async function mintPlayWebhookReceiptLargeBatchAdmissionToken(input: {
165
+ orgId: string;
166
+ receiptIds: readonly string[];
167
+ nowMs?: number;
168
+ }): Promise<string> {
169
+ assertUniqueReceiptIds(
170
+ input.receiptIds,
171
+ PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_MAX_RECEIPTS,
172
+ 'Webhook large admission batch',
173
+ );
174
+ const issuedAt = Math.floor((input.nowMs ?? Date.now()) / 1000);
175
+ return await mint({
176
+ v: TOKEN_VERSION,
177
+ iss: TOKEN_ISSUER,
178
+ aud: PLAY_WEBHOOK_RECEIPT_BATCH_AUDIENCE,
179
+ purpose: PLAY_WEBHOOK_RECEIPT_LARGE_BATCH_PURPOSE,
180
+ org_id: input.orgId,
181
+ receipt_ids_sha256: await hashPlayWebhookReceiptBatchIds(input.receiptIds),
182
+ sub: 'play_webhook_receipt_large_batch_dispatcher',
183
+ iat: issuedAt,
184
+ exp: issuedAt + TOKEN_TTL_SECONDS,
185
+ });
186
+ }
187
+
188
+ export async function mintPlayTriggerEventAdmissionToken(input: {
189
+ orgId: string;
190
+ eventId: string;
191
+ nowMs?: number;
192
+ }): Promise<string> {
193
+ const issuedAt = Math.floor((input.nowMs ?? Date.now()) / 1000);
194
+ return await mint({
195
+ v: TOKEN_VERSION,
196
+ iss: TOKEN_ISSUER,
197
+ aud: PLAY_TRIGGER_EVENT_AUDIENCE,
198
+ purpose: PLAY_TRIGGER_EVENT_PURPOSE,
199
+ org_id: input.orgId,
200
+ event_id: input.eventId,
201
+ sub: 'play_trigger_event_dispatcher',
202
+ iat: issuedAt,
203
+ exp: issuedAt + TOKEN_TTL_SECONDS,
204
+ });
205
+ }
206
+
207
+ export async function mintSignalRadarInboundReceiptToken(input: {
208
+ orgId: string;
209
+ receiptId: string;
210
+ nowMs?: number;
211
+ }): Promise<string> {
212
+ const issuedAt = Math.floor((input.nowMs ?? Date.now()) / 1000);
213
+ return await mint({
214
+ v: TOKEN_VERSION,
215
+ iss: TOKEN_ISSUER,
216
+ aud: SIGNAL_RADAR_INBOUND_RECEIPT_AUDIENCE,
217
+ purpose: SIGNAL_RADAR_INBOUND_RECEIPT_PURPOSE,
218
+ org_id: input.orgId,
219
+ receipt_id: input.receiptId,
220
+ sub: 'signal_radar_inbound_receipt_dispatcher',
221
+ iat: issuedAt,
222
+ exp: issuedAt + TOKEN_TTL_SECONDS,
223
+ });
224
+ }