@friggframework/core 2.0.0--canary.704.79d9a63.0 → 2.0.0--canary.686.e94de4d.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 +28 -40
- package/README.md +105 -0
- package/application/commands/entity-commands.js +60 -18
- package/application/commands/integration-commands.js +20 -73
- package/application/commands/scheduler-commands.js +11 -7
- package/application/commands/user-commands.js +17 -5
- package/application/index.js +6 -2
- package/core/Worker.js +31 -7
- package/core/create-handler.js +9 -0
- package/credential/repositories/credential-repository-documentdb.js +34 -0
- package/credential/repositories/credential-repository-interface.js +15 -0
- package/credential/repositories/credential-repository-mongo.js +23 -0
- package/credential/repositories/credential-repository-postgres.js +28 -0
- package/credential/use-cases/credential-operations-for-user.js +202 -0
- package/credential/use-cases/reauthorize-credential-for-user.js +73 -0
- package/database/config.js +48 -24
- package/database/documentdb-encryption-service.js +9 -22
- package/database/documentdb-utils.js +2 -0
- package/database/encryption/README.md +76 -14
- package/database/encryption/documentdb-encryption-service.md +78 -1
- package/database/encryption/encryption-config.js +219 -0
- package/database/encryption/encryption-schema-registry.js +93 -12
- package/database/encryption/integration-mapping-encryption.js +1 -1
- package/database/organization-where.js +18 -0
- package/database/prisma.js +15 -38
- package/database/unique-violation.js +25 -0
- package/database/use-cases/check-encryption-health-use-case.js +43 -52
- package/errors/index.js +0 -4
- package/extensions/bootstrap-runner.js +43 -0
- package/extensions/extension-loader.js +187 -0
- package/extensions/index.js +41 -0
- package/extensions/initialize-app.js +123 -0
- package/extensions/route-mounter.js +103 -0
- package/extensions/schema-composer.js +188 -0
- package/generated/prisma-mongodb/edge.js +43 -4
- package/generated/prisma-mongodb/index-browser.js +40 -1
- package/generated/prisma-mongodb/index.d.ts +4761 -133
- package/generated/prisma-mongodb/index.js +43 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +92 -0
- package/generated/prisma-mongodb/wasm.js +43 -4
- package/generated/prisma-postgresql/edge.js +43 -4
- package/generated/prisma-postgresql/index-browser.js +40 -1
- package/generated/prisma-postgresql/index.d.ts +4961 -28
- package/generated/prisma-postgresql/index.js +43 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +87 -0
- package/generated/prisma-postgresql/wasm.js +43 -4
- package/handlers/WEBHOOKS.md +0 -29
- package/handlers/app-definition-loader.js +121 -19
- package/handlers/app-handler-helpers.js +25 -3
- package/handlers/backend-utils.js +31 -25
- package/handlers/middleware/admin-auth.js +14 -1
- package/handlers/routers/auth.js +46 -10
- package/handlers/routers/health.js +13 -4
- package/handlers/routers/integration-defined-routers.js +144 -92
- package/handlers/routers/integration-webhook-routers.js +102 -64
- package/handlers/workers/integration-defined-workers.js +5 -2
- package/index.js +65 -3
- package/infrastructure/scheduler/index.js +19 -6
- package/infrastructure/scheduler/netlify-scheduler-adapter.js +216 -0
- package/infrastructure/scheduler/scheduled-job-repository.js +118 -0
- package/infrastructure/scheduler/scheduler-service-factory.js +55 -7
- package/integrations/index.js +1 -0
- package/integrations/integration-base.js +0 -29
- package/integrations/integration-router.js +131 -96
- package/integrations/repositories/integration-mapping-repository-documentdb.js +0 -50
- package/integrations/repositories/integration-mapping-repository-interface.js +0 -15
- package/integrations/repositories/integration-mapping-repository-mongo.js +0 -29
- package/integrations/repositories/integration-mapping-repository-postgres.js +0 -30
- package/integrations/repositories/integration-mapping-repository.js +0 -29
- package/integrations/repositories/integration-not-found.js +14 -0
- package/integrations/repositories/integration-repository-documentdb.js +35 -35
- package/integrations/repositories/integration-repository-interface.js +17 -31
- package/integrations/repositories/integration-repository-mongo.js +60 -129
- package/integrations/repositories/integration-repository-postgres.js +62 -134
- package/integrations/repositories/process-repository-mongo.js +1 -1
- package/integrations/schedule-config.js +80 -0
- package/integrations/tests/doubles/test-integration-repository.js +12 -1
- package/integrations/use-cases/create-integration.js +72 -75
- package/integrations/use-cases/dispatch-ongoing-syncs.js +100 -0
- package/integrations/use-cases/get-integrations-for-user.js +80 -40
- package/integrations/use-cases/index.js +7 -1
- package/integrations/use-cases/integration-operations-for-user.js +256 -0
- package/integrations/use-cases/resolve-integration-ownership.js +177 -0
- package/integrations/utils/map-integration-dto.js +43 -14
- package/management-api/create-management-api-router.js +49 -0
- package/management-api/error-response.js +116 -0
- package/management-api/management-api-config.js +151 -0
- package/management-api/meta-router.js +76 -0
- package/management-api/openapi/build-openapi.js +144 -0
- package/management-api/openapi/components.js +408 -0
- package/management-api/openapi/v1-operations.js +83 -0
- package/management-api/openapi/v2-operations.js +264 -0
- package/management-api/route-registry.js +411 -0
- package/management-api/test-support/http-client.js +39 -0
- package/management-api/use-cases/get-management-api-meta.js +57 -0
- package/management-api/v1-deprecation.js +120 -0
- package/management-api/v2/create-v2-router.js +85 -0
- package/management-api/v2/dependencies.js +281 -0
- package/management-api/v2/dto.js +143 -0
- package/management-api/v2/handlers/authorization.js +94 -0
- package/management-api/v2/handlers/credentials.js +33 -0
- package/management-api/v2/handlers/entities.js +77 -0
- package/management-api/v2/handlers/integrations.js +128 -0
- package/management-api/v2/handlers/proxy.js +34 -0
- package/management-api/v2/request.js +85 -0
- package/management-api/v2/test-support/fake-dependencies.js +103 -0
- package/modules/domain/authorization-session.js +144 -0
- package/modules/entity-scope.js +15 -0
- package/modules/entity-type-registry.js +311 -0
- package/modules/index.js +14 -0
- package/modules/module-factory.js +12 -0
- package/modules/module.js +53 -2
- package/modules/proxy/proxy-errors.js +20 -0
- package/modules/proxy/proxy-path.js +179 -0
- package/modules/proxy/proxy-policy.js +248 -0
- package/modules/repositories/authorization-session-repository-documentdb.js +112 -0
- package/modules/repositories/authorization-session-repository-factory.js +31 -0
- package/modules/repositories/authorization-session-repository-interface.js +47 -0
- package/modules/repositories/authorization-session-repository-prisma.js +87 -0
- package/modules/repositories/entity-not-found.js +18 -0
- package/modules/repositories/module-repository-documentdb.js +51 -6
- package/modules/repositories/module-repository-interface.js +19 -2
- package/modules/repositories/module-repository-mongo.js +67 -74
- package/modules/repositories/module-repository-postgres.js +66 -74
- package/modules/repositories/module-repository.js +2 -1
- package/modules/requester/proxy-request.js +244 -0
- package/modules/requester/requester.js +27 -2
- package/modules/scope-config.js +192 -0
- package/modules/scope-policy.js +112 -0
- package/modules/scope-resolution.js +288 -0
- package/modules/scope-visibility.js +247 -0
- package/modules/tests/doubles/in-memory-authorization-session-repository.js +45 -0
- package/modules/tests/doubles/ownership-matching.js +18 -0
- package/modules/use-cases/authorize-entity-for-user.js +61 -0
- package/modules/use-cases/create-entity-from-credential.js +113 -0
- package/modules/use-cases/entity-operations-for-user.js +162 -0
- package/modules/use-cases/entity-types.js +139 -0
- package/modules/use-cases/execute-entity-proxy-request.js +286 -0
- package/modules/use-cases/get-authorization-requirements.js +100 -0
- package/modules/use-cases/get-entities-for-user.js +26 -13
- package/modules/use-cases/get-entity-module-for-user.js +113 -0
- package/modules/use-cases/get-entity-options-by-id.js +10 -4
- 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 +10 -4
- package/modules/use-cases/manage-access.js +21 -0
- package/modules/use-cases/multi-step-authorization.js +329 -0
- package/modules/use-cases/process-authorization-callback.js +53 -19
- package/modules/use-cases/refresh-entity-options.js +10 -4
- package/modules/use-cases/test-module-auth.js +10 -4
- package/modules/utils/map-module-dto.js +3 -5
- package/package.json +6 -5
- package/prisma-mongodb/schema.prisma +92 -0
- package/prisma-postgresql/migrations/20261003000000_add_authorization_session/migration.sql +28 -0
- package/prisma-postgresql/migrations/20261015000000_add_organization_membership/migration.sql +26 -0
- package/prisma-postgresql/migrations/20261016000000_add_entity_integration_organization/migration.sql +25 -0
- package/prisma-postgresql/migrations/20261017000000_add_scheduled_job/migration.sql +28 -0
- package/prisma-postgresql/schema.prisma +87 -0
- package/providers/index.js +23 -0
- package/providers/resolve-provider.js +93 -0
- package/providers/runtime-detection.js +124 -0
- package/queues/index.js +25 -0
- package/queues/providers/index.js +11 -0
- package/queues/providers/netlify-background-provider.js +150 -0
- package/queues/providers/qstash-queue-provider.js +219 -0
- package/queues/providers/sqs-queue-provider.js +70 -0
- package/queues/queue-client-interface.js +55 -0
- package/queues/queue-message-auth.js +193 -0
- package/queues/queue-provider-factory.js +101 -0
- package/queues/queue-provider.js +65 -0
- package/queues/sqs-queue-client.js +81 -0
- package/types/index.d.ts +2 -0
- package/types/modules/index.d.ts +197 -0
- package/types/user/index.d.ts +134 -0
- package/user/adopter-jwt-config.js +177 -0
- package/user/adopter-jwt-errors.js +59 -0
- package/user/adopter-token-verifier.js +122 -0
- package/user/app-user-helpers.js +97 -0
- package/user/create-user-authentication.js +64 -0
- package/user/membership.js +51 -0
- package/user/repositories/organization-membership-record.js +19 -0
- package/user/repositories/organization-membership-repository-documentdb.js +106 -0
- package/user/repositories/organization-membership-repository-factory.js +42 -0
- package/user/repositories/organization-membership-repository-interface.js +57 -0
- package/user/repositories/organization-membership-repository-mongo.js +13 -0
- package/user/repositories/organization-membership-repository-postgres.js +29 -0
- package/user/repositories/organization-membership-repository-prisma.js +81 -0
- package/user/repositories/user-repository-documentdb.js +27 -2
- package/user/repositories/user-repository-interface.js +18 -0
- package/user/repositories/user-repository-mongo.js +24 -0
- package/user/repositories/user-repository-postgres.js +18 -0
- package/user/tests/doubles/adopter-jwt-fixtures.js +220 -0
- package/user/tests/doubles/test-user-repository.js +1 -1
- package/user/use-cases/authenticate-user.js +7 -1
- package/user/use-cases/ensure-organization-membership.js +92 -0
- package/user/use-cases/find-individual-users-by-organization.js +54 -0
- package/user/use-cases/get-user-from-adopter-jwt.js +95 -124
- package/user/use-cases/get-user-from-bearer-token.js +2 -7
- package/user/use-cases/get-user-from-x-frigg-headers.js +83 -80
- package/user/user.js +64 -1
- package/errors/mapping-already-exists-error.js +0 -14
- package/integrations/use-cases/add-entities-to-integration.js +0 -92
- package/integrations/use-cases/remove-entities-from-integration.js +0 -70
- package/integrations/utils/assert-entity-ids-input.js +0 -42
- package/modules/utils/to-credential-id.js +0 -14
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployed-runtime detection, provider-aware.
|
|
3
|
+
*
|
|
4
|
+
* "Deployed" decides whether a missing secret may be tolerated: a deployed
|
|
5
|
+
* function must fail closed whatever STAGE says, while a laptop, a test run or
|
|
6
|
+
* a local emulator may run in development mode. Reading only AWS Lambda's
|
|
7
|
+
* variables (AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT, AWS_EXECUTION_ENV)
|
|
8
|
+
* makes every other host look local: a Netlify Function with STAGE=dev would
|
|
9
|
+
* be treated like `frigg start`.
|
|
10
|
+
*
|
|
11
|
+
* So the active provider is asked as well. The provider is the app
|
|
12
|
+
* definition's `provider` or FRIGG_PROVIDER (which provider-netlify's
|
|
13
|
+
* generated function entry points set to 'netlify'), resolved to its
|
|
14
|
+
* @friggframework/provider-<name> package, and its `detect(env)` says whether
|
|
15
|
+
* the process runs on that platform.
|
|
16
|
+
*
|
|
17
|
+
* A local run is never deployed: serverless-offline / `serverless invoke
|
|
18
|
+
* local` (IS_OFFLINE, IS_LOCAL), `netlify dev` (NETLIFY_DEV=true) and Jest
|
|
19
|
+
* (JEST_WORKER_ID).
|
|
20
|
+
*/
|
|
21
|
+
const { resolveProvider } = require('./resolve-provider');
|
|
22
|
+
|
|
23
|
+
function isSet(value) {
|
|
24
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTrue(value) {
|
|
28
|
+
if (typeof value !== 'string') return false;
|
|
29
|
+
const normalized = value.trim().toLowerCase();
|
|
30
|
+
return normalized === 'true' || normalized === '1';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} [env=process.env]
|
|
35
|
+
* @returns {boolean} true under a local emulator or test runner
|
|
36
|
+
*/
|
|
37
|
+
function isLocalRun(env = process.env) {
|
|
38
|
+
return (
|
|
39
|
+
isTrue(env.IS_OFFLINE) ||
|
|
40
|
+
isTrue(env.IS_LOCAL) ||
|
|
41
|
+
isTrue(env.NETLIFY_DEV) ||
|
|
42
|
+
isSet(env.JEST_WORKER_ID)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The AWS Lambda runtime sets AWS_LAMBDA_FUNCTION_NAME, LAMBDA_TASK_ROOT and
|
|
48
|
+
* AWS_EXECUTION_ENV (AWS_Lambda_<runtime>).
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} [env=process.env]
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function isAwsLambdaRuntime(env = process.env) {
|
|
54
|
+
return (
|
|
55
|
+
isSet(env.AWS_LAMBDA_FUNCTION_NAME) ||
|
|
56
|
+
isSet(env.LAMBDA_TASK_ROOT) ||
|
|
57
|
+
String(env.AWS_EXECUTION_ENV ?? '').startsWith('AWS_Lambda_')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The provider plugin of the running app, or null for AWS (covered by
|
|
63
|
+
* isAwsLambdaRuntime) or when no provider is named or its package is absent.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} [env=process.env]
|
|
66
|
+
* @param {Object} [options]
|
|
67
|
+
* @param {Object} [options.appDefinition]
|
|
68
|
+
* @param {Function} [options.resolve=resolveProvider]
|
|
69
|
+
* @returns {Object|null}
|
|
70
|
+
*/
|
|
71
|
+
function resolveActiveProvider(env = process.env, { appDefinition, resolve = resolveProvider } = {}) {
|
|
72
|
+
const name = appDefinition?.provider || env.FRIGG_PROVIDER;
|
|
73
|
+
if (!isSet(name) || name === 'aws') return null;
|
|
74
|
+
try {
|
|
75
|
+
return resolve(null, { provider: name });
|
|
76
|
+
} catch (_) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True when the process runs as a deployed function on AWS Lambda or on the
|
|
83
|
+
* active provider's platform, and not under a local emulator or test runner.
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} [env=process.env]
|
|
86
|
+
* @param {Object} [options]
|
|
87
|
+
* @param {Object} [options.provider] - provider plugin to ask (default: the active one)
|
|
88
|
+
* @param {Object} [options.appDefinition] - reads `provider`
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function isDeployedRuntime(env = process.env, { provider, appDefinition } = {}) {
|
|
92
|
+
if (isLocalRun(env)) return false;
|
|
93
|
+
if (isAwsLambdaRuntime(env)) return true;
|
|
94
|
+
if (provider === undefined) {
|
|
95
|
+
// A named non-AWS provider whose package cannot be loaded (or has no
|
|
96
|
+
// detect()) cannot say where we run. Fail closed: provider-netlify's
|
|
97
|
+
// generated entry points set FRIGG_PROVIDER, so treating that as local
|
|
98
|
+
// would let a deployed function accept unsigned queue messages.
|
|
99
|
+
const name = appDefinition?.provider || env.FRIGG_PROVIDER;
|
|
100
|
+
if (isSet(name) && name !== 'aws') {
|
|
101
|
+
const active = resolveActiveProvider(env, { appDefinition });
|
|
102
|
+
if (!active || typeof active.detect !== 'function') return true;
|
|
103
|
+
return askProvider(active, env, true);
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (!provider || typeof provider.detect !== 'function') return false;
|
|
108
|
+
return askProvider(provider, env, false);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function askProvider(active, env, onError) {
|
|
112
|
+
try {
|
|
113
|
+
return active.detect(env) === true;
|
|
114
|
+
} catch (_) {
|
|
115
|
+
return onError;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
isDeployedRuntime,
|
|
121
|
+
isAwsLambdaRuntime,
|
|
122
|
+
isLocalRun,
|
|
123
|
+
resolveActiveProvider,
|
|
124
|
+
};
|
package/queues/index.js
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
const { QueuerUtil } = require('./queuer-util');
|
|
2
|
+
const { QueueProvider } = require('./queue-provider');
|
|
3
|
+
const { QueueClientInterface } = require('./queue-client-interface');
|
|
4
|
+
const {
|
|
5
|
+
createQueueProvider,
|
|
6
|
+
determineProvider,
|
|
7
|
+
QUEUE_PROVIDERS,
|
|
8
|
+
} = require('./queue-provider-factory');
|
|
9
|
+
const {
|
|
10
|
+
NetlifyBackgroundProvider,
|
|
11
|
+
QStashQueueProvider,
|
|
12
|
+
} = require('./providers');
|
|
13
|
+
|
|
2
14
|
module.exports = {
|
|
3
15
|
QueuerUtil,
|
|
16
|
+
QueueProvider,
|
|
17
|
+
QueueClientInterface,
|
|
18
|
+
createQueueProvider,
|
|
19
|
+
determineProvider,
|
|
20
|
+
QUEUE_PROVIDERS,
|
|
21
|
+
NetlifyBackgroundProvider,
|
|
22
|
+
QStashQueueProvider,
|
|
23
|
+
get SqsQueueProvider() {
|
|
24
|
+
return require('./providers/sqs-queue-provider').SqsQueueProvider;
|
|
25
|
+
},
|
|
26
|
+
get SqsQueueClient() {
|
|
27
|
+
return require('./sqs-queue-client').SqsQueueClient;
|
|
28
|
+
},
|
|
4
29
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { NetlifyBackgroundProvider } = require('./netlify-background-provider');
|
|
2
|
+
const { QStashQueueProvider } = require('./qstash-queue-provider');
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
NetlifyBackgroundProvider,
|
|
6
|
+
QStashQueueProvider,
|
|
7
|
+
// Lazy: requiring the barrel must not construct anything AWS-specific.
|
|
8
|
+
get SqsQueueProvider() {
|
|
9
|
+
return require('./sqs-queue-provider').SqsQueueProvider;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netlify Background Functions Queue Provider (Adapter)
|
|
3
|
+
*
|
|
4
|
+
* Uses Netlify Background Functions for async job processing.
|
|
5
|
+
* Background functions have a 15-minute execution limit and are triggered via HTTP POST.
|
|
6
|
+
* They return 202 immediately while processing continues.
|
|
7
|
+
*
|
|
8
|
+
* Queue ID format: The function name or URL path for the background function.
|
|
9
|
+
* Convention: function files named with '-background' suffix (e.g., 'worker-background.js')
|
|
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
|
+
*
|
|
19
|
+
* Limitations:
|
|
20
|
+
* - No built-in retry (must implement manually)
|
|
21
|
+
* - No dead-letter queue
|
|
22
|
+
* - No FIFO ordering guarantees
|
|
23
|
+
* - 15-minute max execution time
|
|
24
|
+
*/
|
|
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');
|
|
38
|
+
|
|
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
|
+
*/
|
|
47
|
+
constructor(options = {}) {
|
|
48
|
+
super();
|
|
49
|
+
// Base URL for triggering background functions (e.g., https://mysite.netlify.app)
|
|
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);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a message by invoking a Netlify Background Function via HTTP POST.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} message - Message payload
|
|
68
|
+
* @param {string} queueId - Background function path (e.g., '/.netlify/functions/worker-background')
|
|
69
|
+
*/
|
|
70
|
+
async send(message, queueId) {
|
|
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
|
+
|
|
87
|
+
const response = await fetch(url, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers,
|
|
90
|
+
body,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok && response.status !== 202) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Failed to invoke background function at ${queueId}: ${response.status} ${response.statusText}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { status: response.status, queueId };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send batch of messages sequentially (background functions are HTTP-triggered).
|
|
104
|
+
*/
|
|
105
|
+
async batchSend(batch, queueId) {
|
|
106
|
+
const entries = batch ?? [];
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const result = await this.send(entry, queueId);
|
|
110
|
+
results.push(result);
|
|
111
|
+
}
|
|
112
|
+
return { results };
|
|
113
|
+
}
|
|
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
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse Netlify Background Function event.
|
|
139
|
+
* Background functions receive the raw HTTP event body as the event.
|
|
140
|
+
*/
|
|
141
|
+
parseEvent(event) {
|
|
142
|
+
const raw = readRawBody(event);
|
|
143
|
+
const body = raw !== null ? JSON.parse(raw) : event.body;
|
|
144
|
+
|
|
145
|
+
// Background functions receive a single message per invocation
|
|
146
|
+
return [body];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { NetlifyBackgroundProvider };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QStash Queue Provider (Adapter)
|
|
3
|
+
*
|
|
4
|
+
* Upstash QStash implementation - platform-agnostic HTTP-based message queue.
|
|
5
|
+
* Works on any platform (Netlify, Vercel, AWS, bare metal, etc.)
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Configurable retry with exponential backoff
|
|
9
|
+
* - Dead-letter queue via callbacks
|
|
10
|
+
* - At-least-once delivery
|
|
11
|
+
* - Scheduling support (delay, cron)
|
|
12
|
+
* - Message deduplication
|
|
13
|
+
*
|
|
14
|
+
* Requires:
|
|
15
|
+
* - QSTASH_TOKEN env var (from Upstash console)
|
|
16
|
+
* - QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY for verification
|
|
17
|
+
*
|
|
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, carries exp, nbf, sub and
|
|
26
|
+
* body, is inside its exp / nbf window, names this request's URL as its sub
|
|
27
|
+
* (the configured destinationUrl, else the event's rawUrl: a delivery
|
|
28
|
+
* published to another endpoint cannot be replayed here), and its body claim
|
|
29
|
+
* matches the body received (trailing '=' padding ignored, as the Receiver
|
|
30
|
+
* does). A request whose URL cannot be determined is refused. With neither
|
|
31
|
+
* key configured it is refused, except outside production with
|
|
32
|
+
* FRIGG_QUEUE_ALLOW_UNSIGNED=true.
|
|
33
|
+
*/
|
|
34
|
+
const crypto = require('crypto');
|
|
35
|
+
const { QueueProvider } = require('../queue-provider');
|
|
36
|
+
const {
|
|
37
|
+
allowsUnsignedMessages,
|
|
38
|
+
readHeader,
|
|
39
|
+
readRawBody,
|
|
40
|
+
} = require('../queue-message-auth');
|
|
41
|
+
|
|
42
|
+
const QSTASH_API_BASE = 'https://qstash.upstash.io/v2';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {*} value
|
|
46
|
+
* @returns {string|null} the URL in canonical form, or null when it is not one
|
|
47
|
+
*/
|
|
48
|
+
function normalizeUrl(value) {
|
|
49
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
50
|
+
try {
|
|
51
|
+
return new URL(value).href;
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class QStashQueueProvider extends QueueProvider {
|
|
58
|
+
constructor(options = {}) {
|
|
59
|
+
super();
|
|
60
|
+
this.token =
|
|
61
|
+
options.token || process.env.QSTASH_TOKEN;
|
|
62
|
+
this.retries =
|
|
63
|
+
options.retries !== undefined ? options.retries : 3;
|
|
64
|
+
this.currentSigningKey = options.currentSigningKey;
|
|
65
|
+
this.nextSigningKey = options.nextSigningKey;
|
|
66
|
+
// The URL QStash delivers to. When unset, the JWT's sub is compared
|
|
67
|
+
// with the request's own URL (event.rawUrl, set by Netlify).
|
|
68
|
+
this.destinationUrl = options.destinationUrl;
|
|
69
|
+
this.env = options.env || process.env;
|
|
70
|
+
this.clockToleranceSeconds = options.clockToleranceSeconds || 0;
|
|
71
|
+
// verifyEvent() proves an HTTP delivery came from QStash.
|
|
72
|
+
this.verifiesHttpDelivery = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Read per call: secrets may reach the environment after construction.
|
|
76
|
+
_signingKeys() {
|
|
77
|
+
return [
|
|
78
|
+
this.currentSigningKey || this.env.QSTASH_CURRENT_SIGNING_KEY,
|
|
79
|
+
this.nextSigningKey || this.env.QSTASH_NEXT_SIGNING_KEY,
|
|
80
|
+
].filter((key) => typeof key === 'string' && key.length > 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Verify the Upstash-Signature JWT of a QStash delivery.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object} event - Lambda-shaped HTTP event
|
|
87
|
+
* @returns {Promise<{ ok: boolean, reason?: string, unsigned?: boolean }>}
|
|
88
|
+
*/
|
|
89
|
+
async verifyEvent(event) {
|
|
90
|
+
const keys = this._signingKeys();
|
|
91
|
+
if (keys.length === 0) {
|
|
92
|
+
return allowsUnsignedMessages(this.env)
|
|
93
|
+
? { ok: true, unsigned: true }
|
|
94
|
+
: { ok: false, reason: 'signing_keys_not_configured' };
|
|
95
|
+
}
|
|
96
|
+
const token = readHeader(event, 'upstash-signature');
|
|
97
|
+
if (!token) return { ok: false, reason: 'missing_signature' };
|
|
98
|
+
const body = readRawBody(event);
|
|
99
|
+
if (body === null) return { ok: false, reason: 'missing_body' };
|
|
100
|
+
|
|
101
|
+
const jose = require('jose');
|
|
102
|
+
let payload;
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
try {
|
|
105
|
+
({ payload } = await jose.jwtVerify(token, new TextEncoder().encode(key), {
|
|
106
|
+
issuer: 'Upstash',
|
|
107
|
+
algorithms: ['HS256'],
|
|
108
|
+
requiredClaims: ['exp', 'nbf', 'sub', 'body'],
|
|
109
|
+
clockTolerance: this.clockToleranceSeconds,
|
|
110
|
+
}));
|
|
111
|
+
break;
|
|
112
|
+
} catch (_) {
|
|
113
|
+
// try the next key
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!payload) return { ok: false, reason: 'invalid_signature' };
|
|
117
|
+
|
|
118
|
+
const requestUrl = normalizeUrl(this.destinationUrl || event?.rawUrl);
|
|
119
|
+
if (!requestUrl) return { ok: false, reason: 'missing_request_url' };
|
|
120
|
+
if (normalizeUrl(payload.sub) !== requestUrl) {
|
|
121
|
+
return { ok: false, reason: 'invalid_subject' };
|
|
122
|
+
}
|
|
123
|
+
const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('base64url');
|
|
124
|
+
const claimed = typeof payload.body === 'string' ? payload.body.replace(/=+$/, '') : '';
|
|
125
|
+
if (claimed !== bodyHash.replace(/=+$/, '')) {
|
|
126
|
+
return { ok: false, reason: 'body_mismatch' };
|
|
127
|
+
}
|
|
128
|
+
return { ok: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Send a message via QStash.
|
|
133
|
+
*
|
|
134
|
+
* @param {Object} message - Message payload
|
|
135
|
+
* @param {string} queueId - Destination URL that QStash will POST to
|
|
136
|
+
*/
|
|
137
|
+
async send(message, queueId) {
|
|
138
|
+
if (!this.token) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
'QSTASH_TOKEN is required. Get one at https://console.upstash.com'
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const headers = {
|
|
145
|
+
Authorization: `Bearer ${this.token}`,
|
|
146
|
+
'Content-Type': 'application/json',
|
|
147
|
+
'Upstash-Retries': String(this.retries),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const response = await fetch(
|
|
151
|
+
`${QSTASH_API_BASE}/publish/${queueId}`,
|
|
152
|
+
{
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers,
|
|
155
|
+
body: JSON.stringify(message),
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
const errorBody = await response.text().catch(() => 'unknown');
|
|
161
|
+
throw new Error(
|
|
162
|
+
`QStash publish failed: ${response.status} ${response.statusText} - ${errorBody}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return response.json();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Send batch of messages via QStash batch API.
|
|
171
|
+
*/
|
|
172
|
+
async batchSend(batch, queueId) {
|
|
173
|
+
const entries = batch ?? [];
|
|
174
|
+
if (!this.token) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'QSTASH_TOKEN is required. Get one at https://console.upstash.com'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const messages = entries.map((entry) => ({
|
|
181
|
+
destination: queueId,
|
|
182
|
+
body: JSON.stringify(entry),
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
retries: this.retries,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
const response = await fetch(`${QSTASH_API_BASE}/batch`, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
Authorization: `Bearer ${this.token}`,
|
|
191
|
+
'Content-Type': 'application/json',
|
|
192
|
+
},
|
|
193
|
+
body: JSON.stringify(messages),
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
const errorBody = await response.text().catch(() => 'unknown');
|
|
198
|
+
throw new Error(
|
|
199
|
+
`QStash batch publish failed: ${response.status} - ${errorBody}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return response.json();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parse QStash webhook delivery event.
|
|
208
|
+
* QStash POSTs the message body directly to the destination URL.
|
|
209
|
+
*/
|
|
210
|
+
parseEvent(event) {
|
|
211
|
+
const raw = readRawBody(event);
|
|
212
|
+
const body = raw !== null ? JSON.parse(raw) : event.body;
|
|
213
|
+
|
|
214
|
+
// QStash delivers a single message per invocation
|
|
215
|
+
return [body];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { QStashQueueProvider };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQS Queue Provider (Adapter)
|
|
3
|
+
*
|
|
4
|
+
* AWS SQS implementation of the QueueProvider port, from
|
|
5
|
+
* friggframework/frigg#545 (where it lives in @friggframework/provider-aws).
|
|
6
|
+
* Kept in core here because the AWS SDK is still a core dependency; the SDK
|
|
7
|
+
* is required on first send, not at module load.
|
|
8
|
+
*/
|
|
9
|
+
const { v4: uuid } = require('uuid');
|
|
10
|
+
const { QueueProvider } = require('../queue-provider');
|
|
11
|
+
const {
|
|
12
|
+
SqsQueueClient,
|
|
13
|
+
defaultSqsClientConfig,
|
|
14
|
+
} = require('../sqs-queue-client');
|
|
15
|
+
|
|
16
|
+
const SQS_BATCH_SIZE = 10;
|
|
17
|
+
|
|
18
|
+
class SqsQueueProvider extends QueueProvider {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} [options]
|
|
21
|
+
* @param {QueueClientInterface} [options.queueClient] - defaults to an SqsQueueClient.
|
|
22
|
+
*/
|
|
23
|
+
constructor({ queueClient } = {}) {
|
|
24
|
+
super();
|
|
25
|
+
this.queueClient =
|
|
26
|
+
queueClient ||
|
|
27
|
+
new SqsQueueClient({ clientConfig: defaultSqsClientConfig() });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async send(message, queueUrl) {
|
|
31
|
+
return this.queueClient.sendMessage({
|
|
32
|
+
MessageBody: JSON.stringify(message),
|
|
33
|
+
QueueUrl: queueUrl,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async batchSend(batch, queueUrl) {
|
|
38
|
+
const entries = batch ?? [];
|
|
39
|
+
let last = {};
|
|
40
|
+
for (let i = 0; i < entries.length; i += SQS_BATCH_SIZE) {
|
|
41
|
+
const Entries = entries.slice(i, i + SQS_BATCH_SIZE).map((entry) => ({
|
|
42
|
+
Id: uuid(),
|
|
43
|
+
MessageBody: JSON.stringify(entry),
|
|
44
|
+
}));
|
|
45
|
+
last = await this.queueClient.sendMessageBatch({
|
|
46
|
+
Entries,
|
|
47
|
+
QueueUrl: queueUrl,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return last;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse an SQS event: event.Records[].body (JSON string).
|
|
55
|
+
*/
|
|
56
|
+
parseEvent(event) {
|
|
57
|
+
const records = event?.Records || [];
|
|
58
|
+
return records.map((record) => JSON.parse(record.body));
|
|
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
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = { SqsQueueProvider };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue Client Interface (Port)
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract for low-level queue message operations.
|
|
5
|
+
* Used by Worker class and QueuerUtil for sending/receiving queue messages.
|
|
6
|
+
*
|
|
7
|
+
* Following Frigg's hexagonal architecture pattern:
|
|
8
|
+
* - Port defines WHAT the service does (contract)
|
|
9
|
+
* - Adapters implement HOW (AWS SQS, Netlify, QStash, etc.)
|
|
10
|
+
*
|
|
11
|
+
* Distinct from QueueProvider which is the higher-level provider for
|
|
12
|
+
* integration-level queue operations (send/batchSend/parseEvent).
|
|
13
|
+
* QueueClientInterface is the lower-level "how do I talk to the queue service" port.
|
|
14
|
+
*/
|
|
15
|
+
class QueueClientInterface {
|
|
16
|
+
/**
|
|
17
|
+
* Send a single message to a queue
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} params
|
|
20
|
+
* @param {string} params.QueueUrl - Queue endpoint/identifier
|
|
21
|
+
* @param {string} params.MessageBody - Serialized message body
|
|
22
|
+
* @param {number} [params.DelaySeconds] - Delay before message becomes visible
|
|
23
|
+
* @returns {Promise<{MessageId: string}>} Result with message identifier
|
|
24
|
+
*/
|
|
25
|
+
async sendMessage(params) {
|
|
26
|
+
throw new Error('Method sendMessage must be implemented by subclass');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Send a batch of messages to a queue
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} params
|
|
33
|
+
* @param {string} params.QueueUrl - Queue endpoint/identifier
|
|
34
|
+
* @param {Array<{Id: string, MessageBody: string}>} params.Entries - Messages to send
|
|
35
|
+
* @returns {Promise<Object>} Batch send result
|
|
36
|
+
*/
|
|
37
|
+
async sendMessageBatch(params) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Method sendMessageBatch must be implemented by subclass'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a queue name to a URL/endpoint
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} params
|
|
47
|
+
* @param {string} params.QueueName - Queue name to resolve
|
|
48
|
+
* @returns {Promise<string>} Resolved queue URL/endpoint
|
|
49
|
+
*/
|
|
50
|
+
async getQueueUrl(params) {
|
|
51
|
+
throw new Error('Method getQueueUrl must be implemented by subclass');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { QueueClientInterface };
|