@friggframework/core 2.0.0--canary.686.87e9875.0 → 2.0.0--canary.686.00d79d8.0
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/CLAUDE.md +3 -0
- package/application/commands/scheduler-commands.js +11 -7
- package/core/create-handler.js +9 -0
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +42 -11
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +20 -1
- package/modules/module.js +22 -4
- package/modules/use-cases/create-entity-from-credential.js +2 -2
- package/modules/use-cases/get-authorization-requirements.js +2 -2
- package/modules/use-cases/get-entities-for-user.js +11 -9
- package/modules/use-cases/get-entity-module-for-user.js +2 -2
- package/modules/use-cases/get-entity-options-by-id.js +1 -1
- package/modules/use-cases/get-entity-options-by-type.js +1 -1
- package/modules/use-cases/get-module-instance-from-type.js +1 -1
- package/modules/use-cases/get-module.js +1 -1
- package/modules/use-cases/multi-step-authorization.js +2 -2
- package/modules/use-cases/process-authorization-callback.js +1 -1
- package/modules/use-cases/refresh-entity-options.js +1 -1
- package/modules/use-cases/test-module-auth.js +1 -1
- package/package.json +5 -5
- package/queues/providers/netlify-background-provider.js +81 -7
- package/queues/providers/qstash-queue-provider.js +82 -5
- package/queues/providers/sqs-queue-provider.js +10 -1
- package/queues/queue-message-auth.js +187 -0
- package/queues/queue-provider.js +19 -0
package/CLAUDE.md
CHANGED
|
@@ -775,6 +775,9 @@ Use test doubles from `@friggframework/test` package for consistent mocking.
|
|
|
775
775
|
- `SECRET_ARN` - AWS Secrets Manager ARN for auto-injection
|
|
776
776
|
- `FRIGG_LOG_LEVEL` - Minimum log level (`TRACE` … `FATAL`, default `INFO`; `DEBUG` on local runs)
|
|
777
777
|
- `FRIGG_QUEUE_MAX_RECEIVE_COUNT` - Set by devtools on integration queue workers whose queue the stack owns; feeds `delivery.maxReceiveCount`
|
|
778
|
+
- `FRIGG_QUEUE_SIGNING_SECRET` - Required for HTTP-delivered queues (Netlify Background Functions). `NetlifyBackgroundProvider` signs each message (HMAC-SHA256 over `timestamp.rawBody`, `x-frigg-queue-signature` / `x-frigg-queue-timestamp`) and `verifyEvent()` refuses anything unsigned, tampered, signed with another secret or older than 5 minutes. Unset → `send()` throws and every event is refused (`queues/queue-message-auth.js`)
|
|
779
|
+
- `FRIGG_QUEUE_ALLOW_UNSIGNED` - `true` accepts unsigned queue messages when no secret is set, outside production only (`STAGE` dev/test/local, `NODE_ENV` development/test, or `NETLIFY_DEV`); ignored in production
|
|
780
|
+
- `QSTASH_CURRENT_SIGNING_KEY` / `QSTASH_NEXT_SIGNING_KEY` - Required with the QStash queue: `QStashQueueProvider.verifyEvent()` checks the `Upstash-Signature` JWT with them
|
|
778
781
|
|
|
779
782
|
## Version Information
|
|
780
783
|
|
|
@@ -121,17 +121,19 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
|
|
|
121
121
|
throw error;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
// An adapter with its own notion of a queue target (Netlify:
|
|
125
|
+
// the worker function) resolves it; otherwise the target is
|
|
126
|
+
// an SQS queue and its URL is required.
|
|
127
|
+
const service = getSchedulerService();
|
|
128
|
+
const resolvesOwnTarget =
|
|
129
|
+
typeof service?.toQueueResourceId === 'function';
|
|
130
|
+
|
|
131
|
+
if (!queueUrl && !resolvesOwnTarget) {
|
|
125
132
|
const error = new Error('queueUrl is required');
|
|
126
133
|
error.code = 'INVALID_JOB_DATA';
|
|
127
134
|
throw error;
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
// Derive ARN from URL (business logic - transformation)
|
|
131
|
-
const queueArn = deriveArnFromQueueUrl(queueUrl);
|
|
132
|
-
|
|
133
|
-
// Get scheduler service (via DI or factory)
|
|
134
|
-
const service = getSchedulerService();
|
|
135
137
|
if (!service) {
|
|
136
138
|
console.warn(
|
|
137
139
|
`[${integrationName}] Scheduler not configured, skipping job schedule`
|
|
@@ -157,7 +159,9 @@ function createSchedulerCommands({ integrationName, schedulerService }) {
|
|
|
157
159
|
const result = await service.scheduleOneTime({
|
|
158
160
|
scheduleName: jobId,
|
|
159
161
|
scheduleAt: scheduledAt,
|
|
160
|
-
queueResourceId:
|
|
162
|
+
queueResourceId: resolvesOwnTarget
|
|
163
|
+
? service.toQueueResourceId(queueUrl)
|
|
164
|
+
: deriveArnFromQueueUrl(queueUrl),
|
|
161
165
|
payload: sqsPayload,
|
|
162
166
|
});
|
|
163
167
|
|
package/core/create-handler.js
CHANGED
|
@@ -81,6 +81,15 @@ const createHandler = (optionByName = {}) => {
|
|
|
81
81
|
if (shouldUseDatabase) {
|
|
82
82
|
const { connectPrisma } = require('../database/prisma');
|
|
83
83
|
await connectPrisma();
|
|
84
|
+
|
|
85
|
+
// Extension bootstraps (e.g. db-credentials swapping a
|
|
86
|
+
// module's env for its DB loader) run once per
|
|
87
|
+
// process, after the connect and before any request
|
|
88
|
+
// builds a module. AWS and Netlify both reach here.
|
|
89
|
+
const {
|
|
90
|
+
initializeApp,
|
|
91
|
+
} = require('../extensions/initialize-app');
|
|
92
|
+
await initializeApp();
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
// Helps reuse the database connection. Lowers response times.
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
|
|
25
25
|
const { SchedulerServiceInterface } = require('./scheduler-service-interface');
|
|
26
26
|
|
|
27
|
+
// The one queue worker every Netlify deployment has (provider-netlify's
|
|
28
|
+
// worker-background function); it dispatches by integrationName.
|
|
29
|
+
const DEFAULT_WORKER_PATH = '/.netlify/functions/worker-background';
|
|
30
|
+
const DEFAULT_LEASE_MS = 5 * 60 * 1000;
|
|
31
|
+
|
|
27
32
|
class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
28
33
|
/**
|
|
29
34
|
* @param {Object} options
|
|
@@ -31,11 +36,15 @@ class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
|
31
36
|
* Must implement: save(schedule), delete(scheduleName),
|
|
32
37
|
* findByName(scheduleName), findDue(now)
|
|
33
38
|
* @param {Object} [options.queueProvider] - Queue provider for dispatching due jobs
|
|
39
|
+
* @param {number} [options.leaseMs] - how long a claimed (PROCESSING) job
|
|
40
|
+
* is held before another tick may take it again (default 5 minutes,
|
|
41
|
+
* well past a Netlify scheduled function's 60-second limit)
|
|
34
42
|
*/
|
|
35
43
|
constructor(options = {}) {
|
|
36
44
|
super();
|
|
37
45
|
this.repository = options.repository;
|
|
38
46
|
this.queueProvider = options.queueProvider;
|
|
47
|
+
this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
39
48
|
|
|
40
49
|
if (!this.repository) {
|
|
41
50
|
console.warn(
|
|
@@ -47,6 +56,20 @@ class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
|
47
56
|
}
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Where a due job is sent. Netlify has no per-integration queues, so an
|
|
61
|
+
* SQS-style queue URL (often unset here) maps to the worker function;
|
|
62
|
+
* a function path is kept as given.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} [queueUrl]
|
|
65
|
+
* @returns {string} a path NetlifyBackgroundProvider.send() accepts
|
|
66
|
+
*/
|
|
67
|
+
toQueueResourceId(queueUrl) {
|
|
68
|
+
return typeof queueUrl === 'string' && queueUrl.startsWith('/')
|
|
69
|
+
? queueUrl
|
|
70
|
+
: DEFAULT_WORKER_PATH;
|
|
71
|
+
}
|
|
72
|
+
|
|
50
73
|
async scheduleOneTime({
|
|
51
74
|
scheduleName,
|
|
52
75
|
scheduleAt,
|
|
@@ -129,23 +152,31 @@ class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
|
129
152
|
console.warn(
|
|
130
153
|
'[NetlifyScheduler] Cannot process due schedules without a repository'
|
|
131
154
|
);
|
|
132
|
-
return { processed: 0, errors: 0 };
|
|
155
|
+
return { processed: 0, errors: 0, skipped: 0 };
|
|
133
156
|
}
|
|
134
157
|
|
|
135
158
|
const now = new Date();
|
|
136
|
-
const
|
|
159
|
+
const lease = { leaseMs: this.leaseMs };
|
|
160
|
+
const dueSchedules = await this.repository.findDue(now, lease);
|
|
137
161
|
|
|
138
162
|
let processed = 0;
|
|
139
163
|
let errors = 0;
|
|
164
|
+
let skipped = 0;
|
|
140
165
|
|
|
141
166
|
for (const schedule of dueSchedules) {
|
|
142
167
|
try {
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
// Claim before dispatch. Two overlapping ticks can both see
|
|
169
|
+
// the row in findDue; only the one whose conditional update
|
|
170
|
+
// wins sends it. A repository without claim() (a custom one)
|
|
171
|
+
// keeps the old unconditional PROCESSING write.
|
|
172
|
+
const claimed =
|
|
173
|
+
typeof this.repository.claim === 'function'
|
|
174
|
+
? await this.repository.claim(schedule.scheduleName, now, lease)
|
|
175
|
+
: (await this.repository.save({ ...schedule, state: 'PROCESSING' }), true);
|
|
176
|
+
if (!claimed) {
|
|
177
|
+
skipped++;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
149
180
|
|
|
150
181
|
if (this.queueProvider) {
|
|
151
182
|
await this.queueProvider.send(
|
|
@@ -176,10 +207,10 @@ class NetlifySchedulerAdapter extends SchedulerServiceInterface {
|
|
|
176
207
|
}
|
|
177
208
|
|
|
178
209
|
console.log(
|
|
179
|
-
`[NetlifyScheduler] Processed ${processed} due schedules, ${errors} errors`
|
|
210
|
+
`[NetlifyScheduler] Processed ${processed} due schedules, ${errors} errors, ${skipped} claimed elsewhere`
|
|
180
211
|
);
|
|
181
|
-
return { processed, errors };
|
|
212
|
+
return { processed, errors, skipped };
|
|
182
213
|
}
|
|
183
214
|
}
|
|
184
215
|
|
|
185
|
-
module.exports = { NetlifySchedulerAdapter };
|
|
216
|
+
module.exports = { NetlifySchedulerAdapter, DEFAULT_WORKER_PATH, DEFAULT_LEASE_MS };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled Job Repository
|
|
3
|
+
*
|
|
4
|
+
* Prisma-based implementation of the repository interface required by
|
|
5
|
+
* NetlifySchedulerAdapter. Persists one-time scheduled jobs to the
|
|
6
|
+
* ScheduledJob model so they survive function restarts.
|
|
7
|
+
*
|
|
8
|
+
* Repository interface:
|
|
9
|
+
* save(scheduleData) — Upsert a scheduled job
|
|
10
|
+
* delete(scheduleName) — Remove a scheduled job
|
|
11
|
+
* findByName(scheduleName) — Find a single scheduled job
|
|
12
|
+
* findDue(now, { leaseMs }) — Find due jobs: PENDING, or PROCESSING with an
|
|
13
|
+
* expired lease (a dispatcher that died after
|
|
14
|
+
* claiming), where scheduledAt <= now
|
|
15
|
+
* claim(scheduleName, now, { leaseMs })
|
|
16
|
+
* — Atomically move one due job to PROCESSING;
|
|
17
|
+
* true only for the one caller that won it
|
|
18
|
+
*
|
|
19
|
+
* The lease clock is the row's updatedAt: a claim writes it, so a PROCESSING
|
|
20
|
+
* row whose updatedAt is older than now - leaseMs is claimable again.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LEASE_MS = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
// Rows a dispatcher may take: never claimed, or claimed by one that has not
|
|
26
|
+
// finished within the lease.
|
|
27
|
+
function claimableWhere(now, leaseMs = DEFAULT_LEASE_MS) {
|
|
28
|
+
return {
|
|
29
|
+
OR: [
|
|
30
|
+
{ state: 'PENDING' },
|
|
31
|
+
{
|
|
32
|
+
state: 'PROCESSING',
|
|
33
|
+
updatedAt: { lt: new Date(now.getTime() - leaseMs) },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class ScheduledJobRepository {
|
|
40
|
+
/**
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {import('@prisma/client').PrismaClient} options.prismaClient
|
|
43
|
+
*/
|
|
44
|
+
constructor({ prismaClient }) {
|
|
45
|
+
if (!prismaClient) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'ScheduledJobRepository requires a prismaClient instance'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
this.prisma = prismaClient;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async save(scheduleData) {
|
|
54
|
+
return this.prisma.scheduledJob.upsert({
|
|
55
|
+
where: { scheduleName: scheduleData.scheduleName },
|
|
56
|
+
update: {
|
|
57
|
+
scheduledAt: new Date(scheduleData.scheduledAt),
|
|
58
|
+
queueResourceId: scheduleData.queueResourceId,
|
|
59
|
+
payload: scheduleData.payload ?? undefined,
|
|
60
|
+
state: scheduleData.state || 'PENDING',
|
|
61
|
+
},
|
|
62
|
+
create: {
|
|
63
|
+
scheduleName: scheduleData.scheduleName,
|
|
64
|
+
scheduledAt: new Date(scheduleData.scheduledAt),
|
|
65
|
+
queueResourceId: scheduleData.queueResourceId,
|
|
66
|
+
payload: scheduleData.payload ?? undefined,
|
|
67
|
+
state: scheduleData.state || 'PENDING',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async delete(scheduleName) {
|
|
73
|
+
try {
|
|
74
|
+
await this.prisma.scheduledJob.delete({
|
|
75
|
+
where: { scheduleName },
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// P2025 = record not found — safe to ignore on delete
|
|
79
|
+
if (error.code !== 'P2025') {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async findByName(scheduleName) {
|
|
86
|
+
return this.prisma.scheduledJob.findUnique({
|
|
87
|
+
where: { scheduleName },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async findDue(now, { leaseMs = DEFAULT_LEASE_MS } = {}) {
|
|
92
|
+
return this.prisma.scheduledJob.findMany({
|
|
93
|
+
where: {
|
|
94
|
+
scheduledAt: { lte: now },
|
|
95
|
+
...claimableWhere(now, leaseMs),
|
|
96
|
+
},
|
|
97
|
+
orderBy: { scheduledAt: 'asc' },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Conditional update: only a row that is still claimable moves to
|
|
103
|
+
* PROCESSING, so of two overlapping ticks exactly one gets count === 1.
|
|
104
|
+
* (PostgreSQL re-checks the WHERE on the row it locked; MongoDB updates
|
|
105
|
+
* each document atomically.)
|
|
106
|
+
*
|
|
107
|
+
* @returns {Promise<boolean>} true when this caller claimed the job
|
|
108
|
+
*/
|
|
109
|
+
async claim(scheduleName, now, { leaseMs = DEFAULT_LEASE_MS } = {}) {
|
|
110
|
+
const { count } = await this.prisma.scheduledJob.updateMany({
|
|
111
|
+
where: { scheduleName, ...claimableWhere(now, leaseMs) },
|
|
112
|
+
data: { state: 'PROCESSING', updatedAt: now },
|
|
113
|
+
});
|
|
114
|
+
return count === 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { ScheduledJobRepository, DEFAULT_LEASE_MS };
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* - SCHEDULER_PROVIDER=mock -> Use in-memory mock scheduler
|
|
10
10
|
* - SCHEDULER_PROVIDER=netlify -> Netlify poll-and-dispatch scheduler
|
|
11
11
|
* (friggframework/frigg#545; needs `repository`, usually `queueProvider`)
|
|
12
|
+
* - Default on Netlify (NETLIFY=true, set by the Netlify runtime and build;
|
|
13
|
+
* provider-netlify's generated functions also set SCHEDULER_PROVIDER) -> Netlify
|
|
12
14
|
* - Default in dev/test/local stages -> Mock scheduler
|
|
13
15
|
* - Default in other stages -> EventBridge scheduler
|
|
14
16
|
* - Any other name -> require('@friggframework/provider-<name>').SchedulerAdapter
|
|
@@ -36,6 +38,12 @@ function determineProvider() {
|
|
|
36
38
|
return explicitProvider;
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
// Checked before the stage: a Netlify site with no STAGE (or STAGE=dev)
|
|
42
|
+
// must still persist schedules where scheduled-sync reads them.
|
|
43
|
+
if (process.env.NETLIFY === 'true') {
|
|
44
|
+
return SCHEDULER_PROVIDERS.NETLIFY;
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
const stage = process.env.STAGE || 'dev';
|
|
40
48
|
if (LOCAL_STAGES.includes(stage)) {
|
|
41
49
|
return SCHEDULER_PROVIDERS.MOCK;
|
|
@@ -44,6 +52,13 @@ function determineProvider() {
|
|
|
44
52
|
return SCHEDULER_PROVIDERS.EVENTBRIDGE;
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
function createDefaultScheduledJobRepository() {
|
|
56
|
+
const { ScheduledJobRepository } = require('./scheduled-job-repository');
|
|
57
|
+
// The prisma export is a lazy proxy: nothing connects until a query runs.
|
|
58
|
+
const { prisma } = require('../../database/prisma');
|
|
59
|
+
return new ScheduledJobRepository({ prismaClient: prisma });
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/**
|
|
48
63
|
* Create a scheduler service instance
|
|
49
64
|
*
|
|
@@ -74,7 +89,11 @@ function createSchedulerService(options = {}) {
|
|
|
74
89
|
case SCHEDULER_PROVIDERS.NETLIFY: {
|
|
75
90
|
const { NetlifySchedulerAdapter } = require('./netlify-scheduler-adapter');
|
|
76
91
|
return new NetlifySchedulerAdapter({
|
|
77
|
-
|
|
92
|
+
// Default: the Prisma ScheduledJob rows that the Netlify
|
|
93
|
+
// scheduled-sync function polls. Without it the adapter
|
|
94
|
+
// falls back to memory, and a schedule made in one function
|
|
95
|
+
// is never seen by the cron.
|
|
96
|
+
repository: options.repository || createDefaultScheduledJobRepository(),
|
|
78
97
|
queueProvider: options.queueProvider,
|
|
79
98
|
});
|
|
80
99
|
}
|
package/modules/module.js
CHANGED
|
@@ -21,9 +21,17 @@ class Module extends Delegate {
|
|
|
21
21
|
* @param {string} params.userId The user id
|
|
22
22
|
* @param {Object} params.entity The entity record from the database
|
|
23
23
|
* @param {string} [params.state] Optional OAuth state value forwarded to the API client (round-trips through the OAuth provider).
|
|
24
|
-
* @param {Object} [params.resolvedEnv] definition.env
|
|
24
|
+
* @param {Object} [params.resolvedEnv] definition.env as Module.create() resolved it (may be undefined).
|
|
25
|
+
* @param {boolean} [params.envResolved=false] true when resolvedEnv carries the resolved env, even an undefined one; otherwise the static definition.env is used.
|
|
25
26
|
*/
|
|
26
|
-
constructor({
|
|
27
|
+
constructor({
|
|
28
|
+
definition,
|
|
29
|
+
userId = null,
|
|
30
|
+
entity: entityObj = null,
|
|
31
|
+
state = null,
|
|
32
|
+
resolvedEnv,
|
|
33
|
+
envResolved = false,
|
|
34
|
+
}) {
|
|
27
35
|
super({ definition, userId, entity: entityObj });
|
|
28
36
|
|
|
29
37
|
this.validateDefinition(definition);
|
|
@@ -58,7 +66,17 @@ class Module extends Delegate {
|
|
|
58
66
|
// A pre-resolved env (Module.create()) supports definition.env as an
|
|
59
67
|
// async function, e.g. OAuth app credentials loaded from the database
|
|
60
68
|
// by the db-credentials extension; otherwise the static object.
|
|
61
|
-
|
|
69
|
+
// "Resolved" is an explicit flag, not a non-null value: a function env
|
|
70
|
+
// may legitimately resolve to undefined (no static env, no DB row),
|
|
71
|
+
// and falling back to definition.env would then read the function.
|
|
72
|
+
const envValues = envResolved ? resolvedEnv : this.definition.env;
|
|
73
|
+
if (typeof envValues === 'function') {
|
|
74
|
+
// Spreading a function yields no keys, so the API client would
|
|
75
|
+
// silently get no client_id / client_secret.
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`Module ${this.name}: definition.env is a function; build the module with await Module.create(...)`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
const apiParams = {
|
|
64
82
|
...envValues,
|
|
@@ -97,7 +115,7 @@ class Module extends Delegate {
|
|
|
97
115
|
*/
|
|
98
116
|
static async create({ definition, userId = null, entity = null, state = null }) {
|
|
99
117
|
const resolvedEnv = await Module.resolveEnv(definition);
|
|
100
|
-
return new Module({ definition, userId, entity, state, resolvedEnv });
|
|
118
|
+
return new Module({ definition, userId, entity, state, resolvedEnv, envResolved: true });
|
|
101
119
|
}
|
|
102
120
|
|
|
103
121
|
getName() {
|
|
@@ -36,7 +36,7 @@ class CreateEntityFromCredential {
|
|
|
36
36
|
this.processAuthorizationCallback = processAuthorizationCallback;
|
|
37
37
|
this.credentialRepository = credentialRepository;
|
|
38
38
|
this.moduleDefinitions = moduleDefinitions || [];
|
|
39
|
-
this.createModule = createModule || ((params) =>
|
|
39
|
+
this.createModule = createModule || ((params) => Module.create(params));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -78,7 +78,7 @@ class CreateEntityFromCredential {
|
|
|
78
78
|
// API can call the provider and a token refresh during
|
|
79
79
|
// getEntityDetails updates the owner's credential.
|
|
80
80
|
const { id, userId, externalId, authIsValid, ...data } = credential;
|
|
81
|
-
const module = this.createModule({
|
|
81
|
+
const module = await this.createModule({
|
|
82
82
|
userId: ownership.ownerId,
|
|
83
83
|
definition,
|
|
84
84
|
entity: {
|
|
@@ -3,7 +3,7 @@ const { findDefinition, getAuthStepCount } = require('./entity-types');
|
|
|
3
3
|
|
|
4
4
|
const defaultCreateModule = (params) => {
|
|
5
5
|
const { Module } = require('../module');
|
|
6
|
-
return
|
|
6
|
+
return Module.create(params);
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -80,7 +80,7 @@ class GetAuthorizationRequirements {
|
|
|
80
80
|
if (typeof definition.getAuthRequirementsForStep === 'function') {
|
|
81
81
|
raw = await definition.getAuthRequirementsForStep(step);
|
|
82
82
|
} else {
|
|
83
|
-
const module = this.createModule({
|
|
83
|
+
const module = await this.createModule({
|
|
84
84
|
userId,
|
|
85
85
|
definition,
|
|
86
86
|
...(state ? { state } : {}),
|
|
@@ -27,16 +27,18 @@ class GetEntitiesForUser {
|
|
|
27
27
|
describeRecord(entity, ctx, 'entity')
|
|
28
28
|
);
|
|
29
29
|
|
|
30
|
-
return
|
|
31
|
-
|
|
30
|
+
return Promise.all(
|
|
31
|
+
entities.map(async (entity) => {
|
|
32
|
+
const definition = this.definitionMap.get(entity.moduleName);
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const moduleInstance = await Module.create({
|
|
35
|
+
userId: entity.userId,
|
|
36
|
+
definition: definition,
|
|
37
|
+
entity: entity,
|
|
38
|
+
});
|
|
39
|
+
return mapModuleClassToModuleDTO(moduleInstance);
|
|
40
|
+
})
|
|
41
|
+
);
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -20,7 +20,7 @@ function userOwns(user, ownerId) {
|
|
|
20
20
|
|
|
21
21
|
const defaultCreateModule = (params) => {
|
|
22
22
|
const { Module } = require('../module');
|
|
23
|
-
return
|
|
23
|
+
return Module.create(params);
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -62,7 +62,7 @@ class GetEntityModuleForUser {
|
|
|
62
62
|
{ code: 'ENTITY_TYPE_NOT_FOUND' }
|
|
63
63
|
);
|
|
64
64
|
}
|
|
65
|
-
const module = this.createModule({
|
|
65
|
+
const module = await this.createModule({
|
|
66
66
|
userId: entity.userId,
|
|
67
67
|
entity,
|
|
68
68
|
definition,
|
|
@@ -63,7 +63,7 @@ class GetEntityOptionsById {
|
|
|
63
63
|
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
64
|
// organization's entity, and a token refresh during this call must
|
|
65
65
|
// update the owner's credential, not create one under the caller.
|
|
66
|
-
const module =
|
|
66
|
+
const module = await Module.create({
|
|
67
67
|
userId: entity.userId ?? userId,
|
|
68
68
|
entity,
|
|
69
69
|
definition: moduleDefinition,
|
|
@@ -21,7 +21,7 @@ class GetEntityOptionsByType {
|
|
|
21
21
|
if (!moduleDefinition) {
|
|
22
22
|
throw new Error(`Module definition not found for type: ${type}`);
|
|
23
23
|
}
|
|
24
|
-
const moduleInstance =
|
|
24
|
+
const moduleInstance = await Module.create({
|
|
25
25
|
userId,
|
|
26
26
|
definition: moduleDefinition,
|
|
27
27
|
});
|
|
@@ -59,7 +59,7 @@ class GetModule {
|
|
|
59
59
|
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
60
60
|
// organization's entity, and a token refresh during this call must
|
|
61
61
|
// update the owner's credential, not create one under the caller.
|
|
62
|
-
const module =
|
|
62
|
+
const module = await Module.create({
|
|
63
63
|
userId: entity.userId ?? userId,
|
|
64
64
|
entity,
|
|
65
65
|
definition: moduleDefinition,
|
|
@@ -10,7 +10,7 @@ const log = getLogger('frigg.api.authorize');
|
|
|
10
10
|
|
|
11
11
|
const defaultCreateModule = (params) => {
|
|
12
12
|
const { Module } = require('../module');
|
|
13
|
-
return
|
|
13
|
+
return Module.create(params);
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const isMultiStep = (definition) =>
|
|
@@ -225,7 +225,7 @@ class SubmitAuthorizationStep {
|
|
|
225
225
|
);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
const module = this.createModule({ userId: ownership.ownerId, definition });
|
|
228
|
+
const module = await this.createModule({ userId: ownership.ownerId, definition });
|
|
229
229
|
let result;
|
|
230
230
|
try {
|
|
231
231
|
result = await definition.processAuthorizationStep(
|
|
@@ -64,7 +64,7 @@ class RefreshEntityOptions {
|
|
|
64
64
|
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
65
65
|
// organization's entity, and a token refresh during this call must
|
|
66
66
|
// update the owner's credential, not create one under the caller.
|
|
67
|
-
const module =
|
|
67
|
+
const module = await Module.create({
|
|
68
68
|
userId: entity.userId ?? userId,
|
|
69
69
|
entity,
|
|
70
70
|
definition: moduleDefinition,
|
|
@@ -63,7 +63,7 @@ class TestModuleAuth {
|
|
|
63
63
|
// Built for the entity's OWNER, not the caller: a caller may use an
|
|
64
64
|
// organization's entity, and a token refresh during this call must
|
|
65
65
|
// update the owner's credential, not create one under the caller.
|
|
66
|
-
const module =
|
|
66
|
+
const module = await Module.create({
|
|
67
67
|
userId: entity.userId ?? userId,
|
|
68
68
|
entity,
|
|
69
69
|
definition: moduleDefinition,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.686.
|
|
4
|
+
"version": "2.0.0--canary.686.00d79d8.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@friggframework/eslint-config": "2.0.0--canary.686.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0--canary.686.
|
|
53
|
-
"@friggframework/test": "2.0.0--canary.686.
|
|
51
|
+
"@friggframework/eslint-config": "2.0.0--canary.686.00d79d8.0",
|
|
52
|
+
"@friggframework/prettier-config": "2.0.0--canary.686.00d79d8.0",
|
|
53
|
+
"@friggframework/test": "2.0.0--canary.686.00d79d8.0",
|
|
54
54
|
"@prisma/client": "^6.19.3",
|
|
55
55
|
"@types/lodash": "4.17.15",
|
|
56
56
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -90,5 +90,5 @@
|
|
|
90
90
|
"publishConfig": {
|
|
91
91
|
"access": "public"
|
|
92
92
|
},
|
|
93
|
-
"gitHead": "
|
|
93
|
+
"gitHead": "00d79d89a9657b62bf89544cbfdf3338e84aea0c"
|
|
94
94
|
}
|
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
* Queue ID format: The function name or URL path for the background function.
|
|
9
9
|
* Convention: function files named with '-background' suffix (e.g., 'worker-background.js')
|
|
10
10
|
*
|
|
11
|
+
* Authentication: a background function is a public URL and Netlify does
|
|
12
|
+
* not authenticate its caller, so every message is signed with
|
|
13
|
+
* FRIGG_QUEUE_SIGNING_SECRET (HMAC-SHA256 over timestamp + raw body, see
|
|
14
|
+
* ../queue-message-auth.js) and verifyEvent() checks that signature before
|
|
15
|
+
* the worker parses or dispatches anything. Without the secret, send() and
|
|
16
|
+
* verifyEvent() refuse, except outside production with
|
|
17
|
+
* FRIGG_QUEUE_ALLOW_UNSIGNED=true.
|
|
18
|
+
*
|
|
11
19
|
* Limitations:
|
|
12
20
|
* - No built-in retry (must implement manually)
|
|
13
21
|
* - No dead-letter queue
|
|
@@ -15,12 +23,42 @@
|
|
|
15
23
|
* - 15-minute max execution time
|
|
16
24
|
*/
|
|
17
25
|
const { QueueProvider } = require('../queue-provider');
|
|
26
|
+
const {
|
|
27
|
+
SIGNING_SECRET_ENV,
|
|
28
|
+
ALLOW_UNSIGNED_ENV,
|
|
29
|
+
SIGNATURE_HEADER,
|
|
30
|
+
TIMESTAMP_HEADER,
|
|
31
|
+
allowsUnsignedMessages,
|
|
32
|
+
readSigningSecret,
|
|
33
|
+
signQueueMessage,
|
|
34
|
+
verifyQueueMessage,
|
|
35
|
+
readHeader,
|
|
36
|
+
readRawBody,
|
|
37
|
+
} = require('../queue-message-auth');
|
|
18
38
|
|
|
19
39
|
class NetlifyBackgroundProvider extends QueueProvider {
|
|
40
|
+
/**
|
|
41
|
+
* @param {Object} [options]
|
|
42
|
+
* @param {string} [options.baseUrl] - site URL (default process.env.URL)
|
|
43
|
+
* @param {string} [options.signingSecret] - default FRIGG_QUEUE_SIGNING_SECRET, read per call
|
|
44
|
+
* @param {Object} [options.env] - environment to read (default process.env)
|
|
45
|
+
* @param {() => number} [options.now] - clock in milliseconds (tests)
|
|
46
|
+
*/
|
|
20
47
|
constructor(options = {}) {
|
|
21
48
|
super();
|
|
22
49
|
// Base URL for triggering background functions (e.g., https://mysite.netlify.app)
|
|
23
50
|
this.baseUrl = options.baseUrl || process.env.URL || '';
|
|
51
|
+
this.signingSecret = options.signingSecret;
|
|
52
|
+
this.env = options.env || process.env;
|
|
53
|
+
this.now = options.now || Date.now;
|
|
54
|
+
// verifyEvent() proves an HTTP delivery came from Frigg.
|
|
55
|
+
this.verifiesHttpDelivery = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read on every call, not in the constructor: secrets may be loaded into
|
|
59
|
+
// the environment after the provider is built (loadSecrets).
|
|
60
|
+
_secret() {
|
|
61
|
+
return this.signingSecret || readSigningSecret(this.env);
|
|
24
62
|
}
|
|
25
63
|
|
|
26
64
|
/**
|
|
@@ -31,10 +69,25 @@ class NetlifyBackgroundProvider extends QueueProvider {
|
|
|
31
69
|
*/
|
|
32
70
|
async send(message, queueId) {
|
|
33
71
|
const url = `${this.baseUrl}${queueId}`;
|
|
72
|
+
const body = JSON.stringify(message);
|
|
73
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
74
|
+
|
|
75
|
+
const secret = this._secret();
|
|
76
|
+
if (secret) {
|
|
77
|
+
Object.assign(headers, signQueueMessage({ body, secret, now: this.now() }));
|
|
78
|
+
} else if (!allowsUnsignedMessages(this.env)) {
|
|
79
|
+
// The worker would refuse the message, and a background function
|
|
80
|
+
// answers 202 either way, so the loss would be silent. Fail here.
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${SIGNING_SECRET_ENV} is required to send to a Netlify background function ` +
|
|
83
|
+
`(${ALLOW_UNSIGNED_ENV}=true allows unsigned messages outside production only)`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
34
87
|
const response = await fetch(url, {
|
|
35
88
|
method: 'POST',
|
|
36
|
-
headers
|
|
37
|
-
body
|
|
89
|
+
headers,
|
|
90
|
+
body,
|
|
38
91
|
});
|
|
39
92
|
|
|
40
93
|
if (!response.ok && response.status !== 202) {
|
|
@@ -49,7 +102,8 @@ class NetlifyBackgroundProvider extends QueueProvider {
|
|
|
49
102
|
/**
|
|
50
103
|
* Send batch of messages sequentially (background functions are HTTP-triggered).
|
|
51
104
|
*/
|
|
52
|
-
async batchSend(
|
|
105
|
+
async batchSend(batch, queueId) {
|
|
106
|
+
const entries = batch ?? [];
|
|
53
107
|
const results = [];
|
|
54
108
|
for (const entry of entries) {
|
|
55
109
|
const result = await this.send(entry, queueId);
|
|
@@ -58,15 +112,35 @@ class NetlifyBackgroundProvider extends QueueProvider {
|
|
|
58
112
|
return { results };
|
|
59
113
|
}
|
|
60
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Check the Frigg signature on an incoming background-function event.
|
|
117
|
+
*
|
|
118
|
+
* @param {Object} event - Netlify v1 (Lambda-shaped) event
|
|
119
|
+
* @returns {Promise<{ ok: boolean, reason?: string, unsigned?: boolean }>}
|
|
120
|
+
*/
|
|
121
|
+
async verifyEvent(event) {
|
|
122
|
+
const secret = this._secret();
|
|
123
|
+
if (!secret) {
|
|
124
|
+
return allowsUnsignedMessages(this.env)
|
|
125
|
+
? { ok: true, unsigned: true }
|
|
126
|
+
: { ok: false, reason: 'signing_secret_not_configured' };
|
|
127
|
+
}
|
|
128
|
+
return verifyQueueMessage({
|
|
129
|
+
body: readRawBody(event),
|
|
130
|
+
timestamp: readHeader(event, TIMESTAMP_HEADER),
|
|
131
|
+
signature: readHeader(event, SIGNATURE_HEADER),
|
|
132
|
+
secret,
|
|
133
|
+
now: this.now(),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
61
137
|
/**
|
|
62
138
|
* Parse Netlify Background Function event.
|
|
63
139
|
* Background functions receive the raw HTTP event body as the event.
|
|
64
140
|
*/
|
|
65
141
|
parseEvent(event) {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
? JSON.parse(event.body)
|
|
69
|
-
: event.body;
|
|
142
|
+
const raw = readRawBody(event);
|
|
143
|
+
const body = raw !== null ? JSON.parse(raw) : event.body;
|
|
70
144
|
|
|
71
145
|
// Background functions receive a single message per invocation
|
|
72
146
|
return [body];
|
|
@@ -16,8 +16,24 @@
|
|
|
16
16
|
* - QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY for verification
|
|
17
17
|
*
|
|
18
18
|
* Queue ID format: The destination URL that QStash will POST to when delivering the message.
|
|
19
|
+
*
|
|
20
|
+
* Verification (verifyEvent), per Upstash's documented scheme and its
|
|
21
|
+
* @upstash/qstash Receiver: every delivery carries an `Upstash-Signature`
|
|
22
|
+
* header, a JWT signed HS256 with the current signing key (the next key
|
|
23
|
+
* during rotation). Its claims are iss "Upstash", sub the destination URL,
|
|
24
|
+
* exp / nbf / iat, and body = base64url(SHA-256(raw body)). A delivery is
|
|
25
|
+
* accepted when the JWT verifies with either key, has not expired, and its
|
|
26
|
+
* body claim matches the body received (trailing '=' padding ignored, as the
|
|
27
|
+
* Receiver does). With neither key configured it is refused, except outside
|
|
28
|
+
* production with FRIGG_QUEUE_ALLOW_UNSIGNED=true.
|
|
19
29
|
*/
|
|
30
|
+
const crypto = require('crypto');
|
|
20
31
|
const { QueueProvider } = require('../queue-provider');
|
|
32
|
+
const {
|
|
33
|
+
allowsUnsignedMessages,
|
|
34
|
+
readHeader,
|
|
35
|
+
readRawBody,
|
|
36
|
+
} = require('../queue-message-auth');
|
|
21
37
|
|
|
22
38
|
const QSTASH_API_BASE = 'https://qstash.upstash.io/v2';
|
|
23
39
|
|
|
@@ -28,6 +44,68 @@ class QStashQueueProvider extends QueueProvider {
|
|
|
28
44
|
options.token || process.env.QSTASH_TOKEN;
|
|
29
45
|
this.retries =
|
|
30
46
|
options.retries !== undefined ? options.retries : 3;
|
|
47
|
+
this.currentSigningKey = options.currentSigningKey;
|
|
48
|
+
this.nextSigningKey = options.nextSigningKey;
|
|
49
|
+
// Optional: when set, the JWT's sub must equal this destination URL.
|
|
50
|
+
this.destinationUrl = options.destinationUrl;
|
|
51
|
+
this.env = options.env || process.env;
|
|
52
|
+
this.clockToleranceSeconds = options.clockToleranceSeconds || 0;
|
|
53
|
+
// verifyEvent() proves an HTTP delivery came from QStash.
|
|
54
|
+
this.verifiesHttpDelivery = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Read per call: secrets may reach the environment after construction.
|
|
58
|
+
_signingKeys() {
|
|
59
|
+
return [
|
|
60
|
+
this.currentSigningKey || this.env.QSTASH_CURRENT_SIGNING_KEY,
|
|
61
|
+
this.nextSigningKey || this.env.QSTASH_NEXT_SIGNING_KEY,
|
|
62
|
+
].filter((key) => typeof key === 'string' && key.length > 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Verify the Upstash-Signature JWT of a QStash delivery.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} event - Lambda-shaped HTTP event
|
|
69
|
+
* @returns {Promise<{ ok: boolean, reason?: string, unsigned?: boolean }>}
|
|
70
|
+
*/
|
|
71
|
+
async verifyEvent(event) {
|
|
72
|
+
const keys = this._signingKeys();
|
|
73
|
+
if (keys.length === 0) {
|
|
74
|
+
return allowsUnsignedMessages(this.env)
|
|
75
|
+
? { ok: true, unsigned: true }
|
|
76
|
+
: { ok: false, reason: 'signing_keys_not_configured' };
|
|
77
|
+
}
|
|
78
|
+
const token = readHeader(event, 'upstash-signature');
|
|
79
|
+
if (!token) return { ok: false, reason: 'missing_signature' };
|
|
80
|
+
const body = readRawBody(event);
|
|
81
|
+
if (body === null) return { ok: false, reason: 'missing_body' };
|
|
82
|
+
|
|
83
|
+
const jose = require('jose');
|
|
84
|
+
let payload;
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
try {
|
|
87
|
+
({ payload } = await jose.jwtVerify(token, new TextEncoder().encode(key), {
|
|
88
|
+
issuer: 'Upstash',
|
|
89
|
+
algorithms: ['HS256'],
|
|
90
|
+
requiredClaims: ['exp', 'body'],
|
|
91
|
+
clockTolerance: this.clockToleranceSeconds,
|
|
92
|
+
}));
|
|
93
|
+
break;
|
|
94
|
+
} catch (_) {
|
|
95
|
+
// try the next key
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!payload) return { ok: false, reason: 'invalid_signature' };
|
|
99
|
+
|
|
100
|
+
if (this.destinationUrl !== undefined && payload.sub !== this.destinationUrl) {
|
|
101
|
+
return { ok: false, reason: 'invalid_subject' };
|
|
102
|
+
}
|
|
103
|
+
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('base64url');
|
|
104
|
+
const claimed = typeof payload.body === 'string' ? payload.body.replace(/=+$/, '') : '';
|
|
105
|
+
if (claimed !== bodyHash.replace(/=+$/, '')) {
|
|
106
|
+
return { ok: false, reason: 'body_mismatch' };
|
|
107
|
+
}
|
|
108
|
+
return { ok: true };
|
|
31
109
|
}
|
|
32
110
|
|
|
33
111
|
/**
|
|
@@ -71,7 +149,8 @@ class QStashQueueProvider extends QueueProvider {
|
|
|
71
149
|
/**
|
|
72
150
|
* Send batch of messages via QStash batch API.
|
|
73
151
|
*/
|
|
74
|
-
async batchSend(
|
|
152
|
+
async batchSend(batch, queueId) {
|
|
153
|
+
const entries = batch ?? [];
|
|
75
154
|
if (!this.token) {
|
|
76
155
|
throw new Error(
|
|
77
156
|
'QSTASH_TOKEN is required. Get one at https://console.upstash.com'
|
|
@@ -109,10 +188,8 @@ class QStashQueueProvider extends QueueProvider {
|
|
|
109
188
|
* QStash POSTs the message body directly to the destination URL.
|
|
110
189
|
*/
|
|
111
190
|
parseEvent(event) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
? JSON.parse(event.body)
|
|
115
|
-
: event.body;
|
|
191
|
+
const raw = readRawBody(event);
|
|
192
|
+
const body = raw !== null ? JSON.parse(raw) : event.body;
|
|
116
193
|
|
|
117
194
|
// QStash delivers a single message per invocation
|
|
118
195
|
return [body];
|
|
@@ -34,7 +34,8 @@ class SqsQueueProvider extends QueueProvider {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
async batchSend(
|
|
37
|
+
async batchSend(batch, queueUrl) {
|
|
38
|
+
const entries = batch ?? [];
|
|
38
39
|
let last = {};
|
|
39
40
|
for (let i = 0; i < entries.length; i += SQS_BATCH_SIZE) {
|
|
40
41
|
const Entries = entries.slice(i, i + SQS_BATCH_SIZE).map((entry) => ({
|
|
@@ -56,6 +57,14 @@ class SqsQueueProvider extends QueueProvider {
|
|
|
56
57
|
const records = event?.Records || [];
|
|
57
58
|
return records.map((record) => JSON.parse(record.body));
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* SQS events reach a Lambda only through its event source mapping, which
|
|
63
|
+
* AWS authenticates; there is no public URL to post to.
|
|
64
|
+
*/
|
|
65
|
+
async verifyEvent() {
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
module.exports = { SqsQueueProvider };
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue message authentication for HTTP-delivered queues.
|
|
3
|
+
*
|
|
4
|
+
* An SQS-triggered Lambda is invoked by AWS itself, so its events are trusted
|
|
5
|
+
* by the transport. An HTTP-delivered queue (a Netlify Background Function,
|
|
6
|
+
* a QStash destination) is a public URL: anyone who can reach it can post a
|
|
7
|
+
* message. These helpers let the sending adapter prove a message came from
|
|
8
|
+
* Frigg and let the receiving adapter check that proof before anything runs.
|
|
9
|
+
*
|
|
10
|
+
* Scheme (Frigg-signed messages):
|
|
11
|
+
* timestamp = whole seconds since the epoch, as a decimal string
|
|
12
|
+
* signature = 'v1=' + hex(HMAC-SHA256(secret, `${timestamp}.${rawBody}`))
|
|
13
|
+
* sent as the `x-frigg-queue-timestamp` and `x-frigg-queue-signature`
|
|
14
|
+
* headers. The receiver recomputes the HMAC over the raw body it was given,
|
|
15
|
+
* compares in constant time, and refuses a timestamp more than
|
|
16
|
+
* MAX_MESSAGE_AGE_SECONDS away from its own clock (replay window).
|
|
17
|
+
*
|
|
18
|
+
* Fail closed: with no secret configured, a message is accepted unsigned only
|
|
19
|
+
* when FRIGG_QUEUE_ALLOW_UNSIGNED=true AND the environment is not production
|
|
20
|
+
* (isProductionEnvironment). Anything else is refused.
|
|
21
|
+
*/
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
|
|
24
|
+
const SIGNING_SECRET_ENV = 'FRIGG_QUEUE_SIGNING_SECRET';
|
|
25
|
+
const ALLOW_UNSIGNED_ENV = 'FRIGG_QUEUE_ALLOW_UNSIGNED';
|
|
26
|
+
const SIGNATURE_HEADER = 'x-frigg-queue-signature';
|
|
27
|
+
const TIMESTAMP_HEADER = 'x-frigg-queue-timestamp';
|
|
28
|
+
const SIGNATURE_VERSION = 'v1';
|
|
29
|
+
const MAX_MESSAGE_AGE_SECONDS = 5 * 60;
|
|
30
|
+
|
|
31
|
+
// Stages core already treats as non-production (database/prisma.js bypasses
|
|
32
|
+
// encryption for the same list).
|
|
33
|
+
const NON_PRODUCTION_STAGES = ['dev', 'test', 'local'];
|
|
34
|
+
const NON_PRODUCTION_NODE_ENVS = ['development', 'test'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Production unless the environment says otherwise: an unset STAGE and
|
|
38
|
+
* NODE_ENV count as production, so a missing setting never opens the door.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} [env=process.env]
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function isProductionEnvironment(env = process.env) {
|
|
44
|
+
if (NON_PRODUCTION_STAGES.includes(env.STAGE)) return false;
|
|
45
|
+
if (NON_PRODUCTION_NODE_ENVS.includes(env.NODE_ENV)) return false;
|
|
46
|
+
if (env.NETLIFY_DEV === 'true') return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Unsigned messages are allowed only by an explicit opt-in outside production.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} [env=process.env]
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function allowsUnsignedMessages(env = process.env) {
|
|
57
|
+
return env[ALLOW_UNSIGNED_ENV] === 'true' && !isProductionEnvironment(env);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Object} [env=process.env]
|
|
62
|
+
* @returns {string|undefined} the configured signing secret, if non-empty
|
|
63
|
+
*/
|
|
64
|
+
function readSigningSecret(env = process.env) {
|
|
65
|
+
const secret = env[SIGNING_SECRET_ENV];
|
|
66
|
+
return typeof secret === 'string' && secret.length > 0 ? secret : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function computeSignature(secret, timestamp, rawBody) {
|
|
70
|
+
const mac = crypto
|
|
71
|
+
.createHmac('sha256', secret)
|
|
72
|
+
.update(`${timestamp}.${rawBody}`)
|
|
73
|
+
.digest('hex');
|
|
74
|
+
return `${SIGNATURE_VERSION}=${mac}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Sign a raw message body.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} params
|
|
81
|
+
* @param {string} params.body - the exact string that will be sent
|
|
82
|
+
* @param {string} params.secret
|
|
83
|
+
* @param {number} [params.now] - milliseconds since the epoch (default Date.now())
|
|
84
|
+
* @returns {{ [header: string]: string }} headers to add to the request
|
|
85
|
+
*/
|
|
86
|
+
function signQueueMessage({ body, secret, now = Date.now() }) {
|
|
87
|
+
if (!secret) {
|
|
88
|
+
throw new Error('A signing secret is required to sign a queue message');
|
|
89
|
+
}
|
|
90
|
+
const timestamp = String(Math.floor(now / 1000));
|
|
91
|
+
return {
|
|
92
|
+
[TIMESTAMP_HEADER]: timestamp,
|
|
93
|
+
[SIGNATURE_HEADER]: computeSignature(secret, timestamp, body),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function constantTimeEqual(a, b) {
|
|
98
|
+
const left = Buffer.from(a, 'utf8');
|
|
99
|
+
const right = Buffer.from(b, 'utf8');
|
|
100
|
+
// timingSafeEqual throws on unequal lengths; the length of a v1 signature
|
|
101
|
+
// is fixed, so comparing lengths first leaks nothing secret.
|
|
102
|
+
if (left.length !== right.length) return false;
|
|
103
|
+
return crypto.timingSafeEqual(left, right);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify a Frigg-signed message.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} params
|
|
110
|
+
* @param {string|null|undefined} params.body - raw body as received
|
|
111
|
+
* @param {string|undefined} params.timestamp - x-frigg-queue-timestamp
|
|
112
|
+
* @param {string|undefined} params.signature - x-frigg-queue-signature
|
|
113
|
+
* @param {string} params.secret
|
|
114
|
+
* @param {number} [params.now] - milliseconds since the epoch
|
|
115
|
+
* @param {number} [params.maxAgeSeconds]
|
|
116
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
117
|
+
*/
|
|
118
|
+
function verifyQueueMessage({
|
|
119
|
+
body,
|
|
120
|
+
timestamp,
|
|
121
|
+
signature,
|
|
122
|
+
secret,
|
|
123
|
+
now = Date.now(),
|
|
124
|
+
maxAgeSeconds = MAX_MESSAGE_AGE_SECONDS,
|
|
125
|
+
}) {
|
|
126
|
+
if (!secret) return { ok: false, reason: 'signing_secret_not_configured' };
|
|
127
|
+
if (!signature || !timestamp) return { ok: false, reason: 'missing_signature' };
|
|
128
|
+
if (typeof body !== 'string') return { ok: false, reason: 'missing_body' };
|
|
129
|
+
if (!/^\d{1,15}$/.test(timestamp)) return { ok: false, reason: 'invalid_timestamp' };
|
|
130
|
+
|
|
131
|
+
const ageSeconds = Math.abs(Math.floor(now / 1000) - Number(timestamp));
|
|
132
|
+
if (ageSeconds > maxAgeSeconds) return { ok: false, reason: 'stale_timestamp' };
|
|
133
|
+
|
|
134
|
+
const expected = computeSignature(secret, timestamp, body);
|
|
135
|
+
if (!constantTimeEqual(expected, signature)) {
|
|
136
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
137
|
+
}
|
|
138
|
+
return { ok: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Case-insensitive header lookup over a Lambda/Netlify v1 event's headers.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} event
|
|
145
|
+
* @param {string} name - lower-case header name
|
|
146
|
+
* @returns {string|undefined}
|
|
147
|
+
*/
|
|
148
|
+
function readHeader(event, name) {
|
|
149
|
+
const headers = event?.headers || {};
|
|
150
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
151
|
+
if (key.toLowerCase() === name) {
|
|
152
|
+
return Array.isArray(value) ? value[0] : value;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The raw request body exactly as signed: a string body as is, a base64
|
|
160
|
+
* body decoded to its UTF-8 text. An already-parsed object body cannot be
|
|
161
|
+
* verified (its bytes are gone), so it reads as absent.
|
|
162
|
+
*
|
|
163
|
+
* @param {Object} event
|
|
164
|
+
* @returns {string|null}
|
|
165
|
+
*/
|
|
166
|
+
function readRawBody(event) {
|
|
167
|
+
if (typeof event?.body !== 'string') return null;
|
|
168
|
+
if (event.isBase64Encoded) {
|
|
169
|
+
return Buffer.from(event.body, 'base64').toString('utf8');
|
|
170
|
+
}
|
|
171
|
+
return event.body;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
SIGNING_SECRET_ENV,
|
|
176
|
+
ALLOW_UNSIGNED_ENV,
|
|
177
|
+
SIGNATURE_HEADER,
|
|
178
|
+
TIMESTAMP_HEADER,
|
|
179
|
+
MAX_MESSAGE_AGE_SECONDS,
|
|
180
|
+
isProductionEnvironment,
|
|
181
|
+
allowsUnsignedMessages,
|
|
182
|
+
readSigningSecret,
|
|
183
|
+
signQueueMessage,
|
|
184
|
+
verifyQueueMessage,
|
|
185
|
+
readHeader,
|
|
186
|
+
readRawBody,
|
|
187
|
+
};
|
package/queues/queue-provider.js
CHANGED
|
@@ -41,6 +41,25 @@ class QueueProvider {
|
|
|
41
41
|
parseEvent(event) {
|
|
42
42
|
throw new Error('Method parseEvent must be implemented by subclass');
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Prove an incoming event was sent by Frigg before anything is parsed or
|
|
47
|
+
* dispatched. HTTP-delivered queues (Netlify Background Functions,
|
|
48
|
+
* QStash) are public URLs, so their adapters check a signature here.
|
|
49
|
+
*
|
|
50
|
+
* The default refuses: an adapter that does not know how to verify its
|
|
51
|
+
* events must not have them run. An adapter whose transport already
|
|
52
|
+
* authenticates the caller (SQS triggering a Lambda) overrides this, and
|
|
53
|
+
* must not set `verifiesHttpDelivery`: an HTTP entry point (Netlify's
|
|
54
|
+
* worker-background) consults only adapters that set it to true, since
|
|
55
|
+
* a transport-trust answer says nothing about an HTTP caller.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} event - Raw event from the runtime
|
|
58
|
+
* @returns {Promise<{ ok: boolean, reason?: string, unsigned?: boolean }>}
|
|
59
|
+
*/
|
|
60
|
+
async verifyEvent(event) {
|
|
61
|
+
return { ok: false, reason: 'verification_not_implemented' };
|
|
62
|
+
}
|
|
44
63
|
}
|
|
45
64
|
|
|
46
65
|
module.exports = { QueueProvider };
|