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.
- package/dist/bundling-sources/sdk/src/client.ts +3 -1
- package/dist/bundling-sources/sdk/src/compat.ts +167 -11
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/skills-version.ts +27 -1
- package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +6 -2
- package/dist/bundling-sources/shared_libs/observability/dlq.ts +942 -0
- package/dist/bundling-sources/shared_libs/observability/queue-health.ts +361 -6
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +24 -24
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +2 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work.ts +29 -1
- package/dist/bundling-sources/shared_libs/play-runtime/admission-transport-policy.ts +91 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/direct-admission-deadline.ts +34 -0
- package/dist/bundling-sources/shared_libs/play-runtime/fixture-behavior.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/internal-bootstrap-token.ts +325 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-admission-outcome.ts +134 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/contracts.ts +13 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/index.ts +4 -1
- package/dist/bundling-sources/shared_libs/play-runtime/play-runs/admission/postgres-adapter.ts +13 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-admission-probe.ts +32 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-webhook-identity.ts +28 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backend.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-cron-fire-task.ts +100 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +313 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-shared.ts +111 -3
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd.ts +16 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +67 -8
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +1734 -147
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +74 -13
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +245 -81
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts +79 -15
- package/dist/bundling-sources/shared_libs/play-runtime/webhook-receipt-retry.ts +21 -21
- package/dist/bundling-sources/shared_libs/plays/bundling/index.ts +4 -1
- package/dist/bundling-sources/shared_libs/product-notifications/catalog.ts +64 -0
- package/dist/bundling-sources/shared_libs/product-notifications/default-policy.ts +1 -1
- package/dist/bundling-sources/shared_libs/product-notifications/events.ts +20 -1
- package/dist/bundling-sources/shared_libs/security/receipt-admission-token.ts +224 -0
- package/dist/cli/index.js +574 -114
- package/dist/cli/index.mjs +592 -133
- package/dist/index.d.mts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +8 -3
- package/dist/index.mjs +8 -3
- package/dist/install-integrity.json +8 -0
- package/dist/plays/bundle-play-file.mjs +1 -1
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/submit-admission.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process-local admission before the atomic Postgres launch transaction.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
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 >=
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.
|
|
44
|
-
//
|
|
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.
|
|
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
|
-
|
|
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,
|
|
@@ -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:
|
|
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
|
+
}
|