@friggframework/core 2.0.0-next.111 → 2.0.0-next.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +101 -13
  3. package/README.md +19 -22
  4. package/core/CLAUDE.md +28 -13
  5. package/core/Worker.js +32 -37
  6. package/core/create-handler.js +97 -197
  7. package/core/index.js +8 -1
  8. package/core/invocation-scope.js +225 -0
  9. package/core/secrets-to-env.js +7 -6
  10. package/database/config.js +0 -7
  11. package/database/documentdb-encryption-service.js +6 -2
  12. package/database/documentdb-utils.js +2 -1
  13. package/database/encryption/README.md +25 -1
  14. package/database/encryption/encryption-schema-registry.js +3 -0
  15. package/database/encryption/integration-mapping-encryption.js +97 -0
  16. package/database/encryption/logger.js +5 -2
  17. package/database/encryption/prisma-encryption-extension.js +20 -9
  18. package/errors/fetch-error.js +48 -65
  19. package/handlers/app-definition-loader.js +38 -2
  20. package/handlers/app-handler-helpers.js +18 -4
  21. package/handlers/backend-utils.js +10 -13
  22. package/handlers/database-migration-handler.js +10 -1
  23. package/handlers/integration-event-dispatcher.js +2 -2
  24. package/handlers/routers/integration-webhook-routers.js +13 -6
  25. package/handlers/routers/websocket.js +27 -7
  26. package/handlers/workers/db-migration.js +12 -1
  27. package/handlers/workers/dlq-processor.js +64 -38
  28. package/index.js +19 -1
  29. package/integrations/integration-base.js +56 -38
  30. package/integrations/integration-router.js +21 -20
  31. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  32. package/integrations/repositories/integration-mapping-query.js +193 -0
  33. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  34. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  35. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  36. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  37. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  38. package/jest.config.js +2 -0
  39. package/logs/.eslintrc.json +26 -1
  40. package/logs/__fixtures__/events.js +82 -0
  41. package/logs/__fixtures__/matchers.js +45 -0
  42. package/logs/__fixtures__/secrets.js +59 -0
  43. package/logs/__fixtures__/vectors.js +329 -0
  44. package/logs/__fixtures__/with-env.js +45 -0
  45. package/logs/context.js +109 -0
  46. package/logs/contract-keys.js +67 -0
  47. package/logs/debug-shims.js +46 -0
  48. package/logs/denied-keys.js +21 -0
  49. package/logs/index.js +14 -3
  50. package/logs/jest-logger-setup.js +25 -0
  51. package/logs/levels.js +71 -0
  52. package/logs/logger-runtime.js +137 -0
  53. package/logs/logger.js +134 -42
  54. package/logs/record.js +276 -0
  55. package/logs/redact.js +328 -0
  56. package/logs/serialize.js +329 -0
  57. package/logs/sinks.js +93 -0
  58. package/logs/summarize-event.js +163 -0
  59. package/modules/module.js +32 -19
  60. package/modules/requester/oauth-2.js +28 -22
  61. package/modules/requester/requester.js +69 -6
  62. package/package.json +5 -6
  63. package/queues/queue-delivery.js +41 -0
  64. package/queues/queuer-util.js +1 -15
  65. package/telemetry/bind-telemetry-context.js +4 -0
  66. package/telemetry/instrument-handler.js +19 -2
  67. package/telemetry/no-op-telemetry.js +4 -0
  68. package/telemetry/otel-telemetry.js +25 -6
  69. package/telemetry/telemetry-context.js +19 -8
  70. package/telemetry/telemetry-runtime.js +17 -0
  71. package/telemetry/telemetry-service.js +1 -0
  72. package/types/errors/index.d.ts +24 -10
  73. package/types/logs/index.d.ts +61 -2
  74. package/user/repositories/user-repository-documentdb.js +10 -42
@@ -0,0 +1,82 @@
1
+ const { SECRETS } = require('./secrets');
2
+
3
+ function httpApiV2Event() {
4
+ return {
5
+ version: '2.0',
6
+ routeKey: 'GET /api/authorize',
7
+ rawPath: '/api/authorize',
8
+ rawQueryString: `code=${SECRETS.oauthCode}&state=abc123&api_key=${SECRETS.apiKeyQuery}`,
9
+ cookies: [`session=${SECRETS.cookie}`],
10
+ headers: {
11
+ 'x-frigg-api-key': SECRETS.friggApiKey,
12
+ authorization: `Bearer ${SECRETS.bearer}`,
13
+ cookie: `session=${SECRETS.cookie}`,
14
+ 'content-type': 'application/json',
15
+ },
16
+ queryStringParameters: {
17
+ code: SECRETS.oauthCode,
18
+ state: 'abc123',
19
+ api_key: SECRETS.apiKeyQuery,
20
+ },
21
+ requestContext: {
22
+ http: { method: 'GET', path: '/api/authorize', sourceIp: '203.0.113.9' },
23
+ requestId: 'req-v2-1',
24
+ },
25
+ body: JSON.stringify({
26
+ code: SECRETS.oauthCode,
27
+ code_verifier: SECRETS.codeVerifier,
28
+ client_secret: SECRETS.clientSecret,
29
+ }),
30
+ isBase64Encoded: false,
31
+ };
32
+ }
33
+
34
+ function restV1Event() {
35
+ return {
36
+ resource: '/api/{proxy+}',
37
+ path: '/api/integrations',
38
+ httpMethod: 'POST',
39
+ headers: {
40
+ Authorization: `Basic ${Buffer.from(`user:${SECRETS.password}`).toString('base64')}`,
41
+ 'X-Frigg-Api-Key': SECRETS.friggApiKey,
42
+ Cookie: `session=${SECRETS.cookie}`,
43
+ },
44
+ multiValueHeaders: {
45
+ Authorization: [`Basic ${Buffer.from(`user:${SECRETS.password}`).toString('base64')}`],
46
+ 'Set-Cookie': [`session=${SECRETS.cookie}`],
47
+ },
48
+ queryStringParameters: { access_token: SECRETS.accessToken },
49
+ multiValueQueryStringParameters: { access_token: [SECRETS.accessToken] },
50
+ requestContext: { requestId: 'req-v1-1', stage: 'dev' },
51
+ body: JSON.stringify({ password: SECRETS.password, refresh_token: SECRETS.refreshToken }),
52
+ isBase64Encoded: false,
53
+ };
54
+ }
55
+
56
+ function sqsEvent() {
57
+ return {
58
+ Records: [
59
+ {
60
+ messageId: 'msg-1',
61
+ receiptHandle: 'receipt-1',
62
+ attributes: { ApproximateReceiveCount: '1' },
63
+ body: JSON.stringify({
64
+ event: 'PROCESS_BATCH',
65
+ data: {
66
+ processId: 'proc-1',
67
+ integrationId: 'int-1',
68
+ access_token: SECRETS.accessToken,
69
+ credentials: { client_secret: SECRETS.clientSecret },
70
+ },
71
+ }),
72
+ },
73
+ {
74
+ messageId: 'msg-2',
75
+ attributes: { ApproximateReceiveCount: '3' },
76
+ body: `not json Authorization: Bearer ${SECRETS.bearer}`,
77
+ },
78
+ ],
79
+ };
80
+ }
81
+
82
+ module.exports = { httpApiV2Event, restV1Event, sqsEvent };
@@ -0,0 +1,45 @@
1
+ const WINDOW = 8;
2
+
3
+ function forms(secret) {
4
+ return [
5
+ secret,
6
+ encodeURIComponent(secret),
7
+ Buffer.from(secret).toString('base64'),
8
+ ];
9
+ }
10
+
11
+ function windows(value) {
12
+ if (value.length <= WINDOW) return [value];
13
+ const result = [];
14
+ for (let i = 0; i + WINDOW <= value.length; i += 1) {
15
+ result.push(value.slice(i, i + WINDOW));
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function findSecretWindow(received, secrets) {
21
+ const haystack =
22
+ typeof received === 'string' ? received : JSON.stringify(received);
23
+ for (const secret of secrets) {
24
+ for (const form of forms(secret)) {
25
+ for (const w of windows(form)) {
26
+ if (haystack.includes(w)) return { secret, window: w };
27
+ }
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function toContainNoSecretWindow(received, secrets) {
34
+ const list = Array.isArray(secrets) ? secrets : Object.values(secrets);
35
+ const hit = findSecretWindow(received, list);
36
+ return {
37
+ pass: hit === null,
38
+ message: () =>
39
+ hit
40
+ ? `expected no ${WINDOW}-char window of a secret, found "${hit.window}" (from a secret of length ${hit.secret.length})`
41
+ : 'expected a secret window, found none',
42
+ };
43
+ }
44
+
45
+ module.exports = { toContainNoSecretWindow, findSecretWindow, WINDOW };
@@ -0,0 +1,59 @@
1
+ // Fake values, generated at load time so secret scanners find no literal in
2
+ // the source. A fixed seed per name keeps every run identical.
3
+ const MIXED = 'BCDFGHJKLMNPQRSTVWXZbcdfghjkmnpqrstvwxz23456789';
4
+ const HEX = '0123456789abcdef';
5
+
6
+ function seededRandom(name) {
7
+ let seed = [...name].reduce(
8
+ (h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619),
9
+ 2166136261
10
+ );
11
+ return () => {
12
+ seed = (seed + 0x6d2b79f5) | 0;
13
+ let t = Math.imul(seed ^ (seed >>> 15), seed | 1);
14
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
18
+
19
+ function draw(name, alphabet, length) {
20
+ const next = seededRandom(name);
21
+ let out = '';
22
+ for (let i = 0; i < length; i += 1) {
23
+ out += alphabet[Math.floor(next() * alphabet.length)];
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // The fixed 'Zq' + 'B7k' start puts upper case, lower case and a digit in every token.
29
+ function fakeToken(name, length = 28) {
30
+ return `ZqB7k${draw(name, MIXED, length - 5)}`;
31
+ }
32
+
33
+ const base64url = (value) => Buffer.from(value).toString('base64url');
34
+
35
+ const SECRETS = Object.freeze({
36
+ bearer: fakeToken('bearer'),
37
+ apiKeyQuery: fakeToken('apiKeyQuery'),
38
+ friggApiKey: fakeToken('friggApiKey'),
39
+ cookie: fakeToken('cookie'),
40
+ oauthCode: fakeToken('oauthCode'),
41
+ codeVerifier: fakeToken('codeVerifier'),
42
+ password: fakeToken('password'),
43
+ clientSecret: fakeToken('clientSecret'),
44
+ accessToken: fakeToken('accessToken'),
45
+ refreshToken: fakeToken('refreshToken'),
46
+ idToken: fakeToken('idToken'),
47
+ hashword: fakeToken('hashword'),
48
+ dbPassword: fakeToken('dbPassword'),
49
+ signature: fakeToken('signature'),
50
+ jwt: [
51
+ base64url(JSON.stringify({ alg: 'HS256' })),
52
+ base64url(JSON.stringify({ sub: `fake-${draw('jwtSub', MIXED, 8)}` })),
53
+ base64url(draw('jwtSig', MIXED, 24)),
54
+ ].join('.'),
55
+ hexToken: draw('hexToken', HEX, 40),
56
+ base64Run: Buffer.from(draw('base64Run', MIXED, 42)).toString('base64'),
57
+ });
58
+
59
+ module.exports = { SECRETS, fakeToken };
@@ -0,0 +1,329 @@
1
+ // ADR-048 §14 redaction vectors. Each vector is one log call that carries
2
+ // secrets; the suite asserts that no 8-char window of them reaches a record.
3
+ const { SECRETS: S, fakeToken } = require('./secrets');
4
+ const { httpApiV2Event, restV1Event, sqsEvent } = require('./events');
5
+ const { summarizeLambdaEvent } = require('../summarize-event');
6
+
7
+ const secretUrl = `https://api.example.com/v1/items?api_key=${S.apiKeyQuery}`;
8
+ const SIGNATURE_VALUE = fakeToken('signatureValue', 27);
9
+
10
+ // Built by concatenation so push protection does not match the prefixes.
11
+ const TOKEN_TAIL = fakeToken('prefixTail', 25);
12
+ const PREFIXED_TOKENS = [
13
+ 'sk_' + 'live_' + TOKEN_TAIL,
14
+ 'whsec' + '_' + TOKEN_TAIL,
15
+ 'xox' + 'b-' + TOKEN_TAIL,
16
+ 'gh' + 'p_' + TOKEN_TAIL,
17
+ 'AK' + 'IA' + 'ZQPR3FIXTAIL8HN2',
18
+ ];
19
+
20
+ function errorWithMessage(message, name = 'Error') {
21
+ const error = new Error(message);
22
+ error.name = name;
23
+ return error;
24
+ }
25
+
26
+ function nodeFetchError() {
27
+ const { FetchError } = require('node-fetch');
28
+ return new FetchError(
29
+ `request to ${secretUrl} failed, reason: connect ECONNREFUSED Authorization: Bearer ${S.bearer}`,
30
+ 'system',
31
+ { code: 'ECONNREFUSED' }
32
+ );
33
+ }
34
+
35
+ function causeChain() {
36
+ const deepest = errorWithMessage(`token refresh failed: refresh_token=${S.refreshToken}`);
37
+ const third = new Error('third', { cause: deepest });
38
+ const second = new Error('second', { cause: third });
39
+ return new Error('top', { cause: second });
40
+ }
41
+
42
+ function cyclicObject() {
43
+ const node = { name: 'node', access_token: S.accessToken };
44
+ node.self = node;
45
+ node.list = [node, { password: S.password }];
46
+ return node;
47
+ }
48
+
49
+ function aggregateError() {
50
+ return new AggregateError(
51
+ [
52
+ errorWithMessage(`GET ${secretUrl} 401`),
53
+ errorWithMessage(`Authorization: Basic ${Buffer.from(`u:${S.password}`).toString('base64')}`),
54
+ ],
55
+ 'batch failed'
56
+ );
57
+ }
58
+
59
+ function prismaValidationError() {
60
+ const error = errorWithMessage(
61
+ [
62
+ 'Invalid `prisma.credential.create()` invocation:',
63
+ '',
64
+ `{ data: { access_token: "${S.accessToken}", refresh_token: "${S.refreshToken}" } }`,
65
+ '',
66
+ 'Argument `userId` is missing.',
67
+ ].join('\n'),
68
+ 'PrismaClientValidationError'
69
+ );
70
+ error.clientVersion = '6.19.3';
71
+ return error;
72
+ }
73
+
74
+ function vectors() {
75
+ return [
76
+ {
77
+ name: 'HTTP API v2 event through summarizeLambdaEvent',
78
+ level: 'info',
79
+ fields: () => ({ invocation: summarizeLambdaEvent(httpApiV2Event()) }),
80
+ secrets: [S.friggApiKey, S.cookie, S.oauthCode, S.bearer, S.apiKeyQuery, S.codeVerifier, S.clientSecret],
81
+ expectSanitized: (record) => {
82
+ expect(record.invocation.headerNames).toEqual(
83
+ expect.arrayContaining(['x-frigg-api-key', 'cookie', 'authorization'])
84
+ );
85
+ expect(record.invocation.queryKeys).toEqual(expect.arrayContaining(['code', 'api_key']));
86
+ },
87
+ },
88
+ {
89
+ name: 'HTTP API v2 event as a raw field',
90
+ level: 'debug',
91
+ fields: () => ({ event: httpApiV2Event() }),
92
+ secrets: [S.friggApiKey, S.cookie, S.oauthCode, S.bearer, S.apiKeyQuery, S.codeVerifier, S.clientSecret],
93
+ expectSanitized: (record) => {
94
+ expect(record.event.headers).toEqual(
95
+ expect.arrayContaining(['x-frigg-api-key', 'cookie'])
96
+ );
97
+ },
98
+ },
99
+ {
100
+ name: 'REST v1 event as a raw field',
101
+ level: 'debug',
102
+ fields: () => ({ event: restV1Event() }),
103
+ secrets: [S.friggApiKey, S.cookie, S.password, S.accessToken, S.refreshToken],
104
+ expectSanitized: (record) => {
105
+ expect(record.event.httpMethod).toBe('POST');
106
+ },
107
+ },
108
+ {
109
+ name: 'SQS event as a raw field',
110
+ level: 'debug',
111
+ fields: () => ({ event: sqsEvent() }),
112
+ secrets: [S.accessToken, S.clientSecret, S.bearer],
113
+ expectSanitized: (record) => {
114
+ expect(record.event.Records[0].messageId).toBe('msg-1');
115
+ },
116
+ },
117
+ {
118
+ name: 'node-fetch FetchError',
119
+ level: 'error',
120
+ fields: () => ({ error: nodeFetchError() }),
121
+ secrets: [S.apiKeyQuery, S.bearer],
122
+ expectSanitized: (record) => {
123
+ expect(record.error.message).toContain('api_key=REDACTED');
124
+ },
125
+ },
126
+ {
127
+ name: 'Error as the message',
128
+ level: 'error',
129
+ message: () => errorWithMessage(`GET ${secretUrl} Authorization: Bearer ${S.bearer}`),
130
+ secrets: [S.apiKeyQuery, S.bearer],
131
+ expectSanitized: (record) => {
132
+ expect(record.message).toContain('api_key=REDACTED');
133
+ },
134
+ },
135
+ {
136
+ name: 'cause chain with a secret at depth 3',
137
+ level: 'error',
138
+ fields: () => ({ error: causeChain() }),
139
+ secrets: [S.refreshToken],
140
+ expectSanitized: (record) => {
141
+ expect(record.error.cause.cause.message).toBe('third');
142
+ },
143
+ },
144
+ {
145
+ name: 'cyclic object',
146
+ level: 'warn',
147
+ fields: () => ({ node: cyclicObject() }),
148
+ secrets: [S.accessToken, S.password],
149
+ expectSanitized: (record) => {
150
+ expect(JSON.stringify(record)).toContain('[Circular]');
151
+ },
152
+ },
153
+ {
154
+ name: 'AggregateError',
155
+ level: 'error',
156
+ fields: () => ({ error: aggregateError() }),
157
+ secrets: [S.apiKeyQuery, S.password],
158
+ expectSanitized: (record) => {
159
+ expect(record.error.type).toBe('AggregateError');
160
+ },
161
+ },
162
+ {
163
+ name: 'Prisma validation error',
164
+ level: 'error',
165
+ fields: () => ({ error: prismaValidationError() }),
166
+ secrets: [S.accessToken, S.refreshToken],
167
+ expectSanitized: (record) => {
168
+ expect(record.error.message).toContain('Argument `userId` is missing.');
169
+ },
170
+ },
171
+ {
172
+ name: 'connection strings',
173
+ level: 'warn',
174
+ message: () =>
175
+ `connect failed postgresql://frigg:${S.dbPassword}@db.internal:5432/app and mongodb+srv://frigg:${S.dbPassword}@cluster0.example.net/app`,
176
+ secrets: [S.dbPassword],
177
+ expectSanitized: (record) => {
178
+ expect(record.message).toContain('db.internal');
179
+ },
180
+ },
181
+ {
182
+ name: 'JWT',
183
+ level: 'info',
184
+ fields: () => ({ note: `id token ${S.jwt} received` }),
185
+ secrets: [S.jwt],
186
+ },
187
+ {
188
+ name: 'Bearer and Basic credentials',
189
+ level: 'info',
190
+ fields: () => ({
191
+ note: `Authorization: Bearer ${S.bearer}; Authorization: Basic ${Buffer.from(`u:${S.password}`).toString('base64')}`,
192
+ }),
193
+ secrets: [S.bearer, S.password],
194
+ },
195
+ {
196
+ name: 'base64 run',
197
+ level: 'info',
198
+ fields: () => ({ note: `blob ${S.base64Run} end` }),
199
+ secrets: [S.base64Run],
200
+ },
201
+ {
202
+ name: 'provider token prefixes',
203
+ level: 'warn',
204
+ message: () => `provider rejected ${PREFIXED_TOKENS.join(' and ')}`,
205
+ fields: () => ({ note: PREFIXED_TOKENS.join(',') }),
206
+ secrets: PREFIXED_TOKENS,
207
+ },
208
+ {
209
+ name: '40-hex token',
210
+ level: 'error',
211
+ message: () => `lookup failed for key ${S.hexToken}`,
212
+ secrets: [S.hexToken],
213
+ },
214
+ {
215
+ name: 'client_secret, signature and code pairs',
216
+ level: 'info',
217
+ fields: () => ({
218
+ note: `client_secret=${S.clientSecret} signature=${SIGNATURE_VALUE} code=${S.oauthCode}`,
219
+ }),
220
+ secrets: [S.clientSecret, SIGNATURE_VALUE, S.oauthCode],
221
+ },
222
+ {
223
+ name: 'JSON string',
224
+ level: 'info',
225
+ fields: () => ({ note: JSON.stringify({ token: S.accessToken, id: 7 }) }),
226
+ secrets: [S.accessToken],
227
+ },
228
+ {
229
+ name: 'k=v& string',
230
+ level: 'info',
231
+ fields: () => ({ note: `user=alice&password=${S.password}&next=/home` }),
232
+ secrets: [S.password],
233
+ },
234
+ {
235
+ name: 'URLSearchParams body',
236
+ level: 'debug',
237
+ fields: () => ({
238
+ body: new URLSearchParams({
239
+ grant_type: 'authorization_code',
240
+ code: S.oauthCode,
241
+ code_verifier: S.codeVerifier,
242
+ client_secret: S.clientSecret,
243
+ }),
244
+ }),
245
+ secrets: [S.oauthCode, S.codeVerifier, S.clientSecret],
246
+ expectSanitized: (record) => {
247
+ expect(JSON.stringify(record.body)).toContain('grant_type');
248
+ },
249
+ },
250
+ {
251
+ name: 'Headers, URL and Buffer',
252
+ level: 'debug',
253
+ fields: () => ({
254
+ headers: new Headers({ authorization: `Bearer ${S.bearer}`, 'x-frigg-api-key': S.friggApiKey }),
255
+ url: new URL(`https://user:${S.password}@api.example.com/x?access_token=${S.accessToken}`),
256
+ raw: Buffer.from(`secret=${S.clientSecret}`),
257
+ }),
258
+ secrets: [S.bearer, S.friggApiKey, S.password, S.accessToken, S.clientSecret],
259
+ expectSanitized: (record) => {
260
+ expect(JSON.stringify(record.url)).toContain('api.example.com');
261
+ },
262
+ },
263
+ {
264
+ name: 'OAuth callback params',
265
+ level: 'debug',
266
+ fields: () => ({ params: { code: S.oauthCode, state: 'abc123', code_verifier: S.codeVerifier } }),
267
+ secrets: [S.oauthCode, S.codeVerifier],
268
+ expectSanitized: (record) => {
269
+ expect(Object.keys(record.params)).toEqual(
270
+ expect.arrayContaining(['code', 'state'])
271
+ );
272
+ },
273
+ },
274
+ {
275
+ name: 'hashword',
276
+ level: 'info',
277
+ fields: () => ({ user: { username: 'alice', hashword: S.hashword } }),
278
+ secrets: [S.hashword],
279
+ expectSanitized: (record) => {
280
+ expect(record.user.username).toBe('alice');
281
+ },
282
+ },
283
+ {
284
+ name: 'denied keys as bindings and top-level fields',
285
+ level: 'info',
286
+ bindings: () => ({ apiKey: S.friggApiKey }),
287
+ fields: () => ({ access_token: S.accessToken, id_token: S.idToken, cookies: [S.cookie] }),
288
+ secrets: [S.friggApiKey, S.accessToken, S.idToken, S.cookie],
289
+ },
290
+ ];
291
+ }
292
+
293
+ // A real Requester call that fails with 401. The URL carries a query key and
294
+ // the request an Authorization header.
295
+ async function requesterFetchError() {
296
+ const { Requester } = require('../../modules/requester/requester');
297
+ class TestRequester extends Requester {
298
+ async addAuthHeaders(headers) {
299
+ return { ...headers, Authorization: `Bearer ${S.bearer}` };
300
+ }
301
+ }
302
+ const response = {
303
+ status: 401,
304
+ ok: false,
305
+ bodyUsed: false,
306
+ headers: {
307
+ get: () => 'application/json',
308
+ raw: () => ({ 'content-type': ['application/json'] }),
309
+ [Symbol.iterator]: function* () {
310
+ yield ['content-type', 'application/json'];
311
+ },
312
+ },
313
+ json: async () => ({ access_token: S.accessToken }),
314
+ text: async () => `{"access_token":"${S.accessToken}"}`,
315
+ };
316
+ const requester = new TestRequester({
317
+ backOff: [],
318
+ requestTimeoutMs: 0,
319
+ fetch: async () => response,
320
+ });
321
+ return requester._get({ url: secretUrl }).then(
322
+ () => {
323
+ throw new Error('expected the request to fail');
324
+ },
325
+ (error) => error
326
+ );
327
+ }
328
+
329
+ module.exports = { vectors, requesterFetchError, secretUrl };
@@ -0,0 +1,45 @@
1
+ const { createMemorySink } = require('../sinks');
2
+ const { resetLoggerForTests } = require('../logger-runtime');
3
+
4
+ function applyEnv(vars) {
5
+ const saved = {};
6
+ for (const [key, value] of Object.entries(vars)) {
7
+ saved[key] = Object.prototype.hasOwnProperty.call(process.env, key)
8
+ ? process.env[key]
9
+ : undefined;
10
+ if (value === undefined) delete process.env[key];
11
+ else process.env[key] = value;
12
+ }
13
+ return () => {
14
+ for (const [key, value] of Object.entries(saved)) {
15
+ if (value === undefined) delete process.env[key];
16
+ else process.env[key] = value;
17
+ }
18
+ };
19
+ }
20
+
21
+ // Sets env keys (undefined deletes), resets the logger to read them, and
22
+ // passes a fresh memory sink to fn. Restores env and the test logger after.
23
+ function withEnv(vars, fn) {
24
+ const restore = applyEnv(vars);
25
+ const sink = createMemorySink({ install: false });
26
+ resetLoggerForTests({ sinks: [sink] });
27
+ const done = () => {
28
+ restore();
29
+ resetLoggerForTests({ level: 'TRACE', sinks: [createMemorySink({ install: false })] });
30
+ };
31
+ let result;
32
+ try {
33
+ result = fn(sink);
34
+ } catch (error) {
35
+ done();
36
+ throw error;
37
+ }
38
+ if (result && typeof result.then === 'function') {
39
+ return result.finally(done);
40
+ }
41
+ done();
42
+ return result;
43
+ }
44
+
45
+ module.exports = { withEnv, applyEnv };
@@ -0,0 +1,109 @@
1
+ const { AsyncLocalStorage } = require('node:async_hooks');
2
+
3
+ const LOGGER_SCOPE_KEY = 'log';
4
+ const INTEGRATION_KEYS = ['integrationId', 'integrationType', 'userId', 'version'];
5
+
6
+ // Shared across jest module registries and nested core copies.
7
+ const STORE_KEY = Symbol.for('@friggframework/core.logs.context');
8
+
9
+ function als() {
10
+ if (!globalThis[STORE_KEY]) {
11
+ globalThis[STORE_KEY] = new AsyncLocalStorage();
12
+ }
13
+ return globalThis[STORE_KEY];
14
+ }
15
+
16
+ function isPlainObject(value) {
17
+ if (value === null || typeof value !== 'object') return false;
18
+ const proto = Object.getPrototypeOf(value);
19
+ return proto === Object.prototype || proto === null;
20
+ }
21
+
22
+ function freezeCopy(value) {
23
+ if (Array.isArray(value)) {
24
+ return Object.freeze(value.map(freezeCopy));
25
+ }
26
+ if (isPlainObject(value)) {
27
+ const copy = {};
28
+ for (const key of Object.keys(value)) {
29
+ copy[key] = freezeCopy(value[key]);
30
+ }
31
+ return Object.freeze(copy);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function assignDefined(target, source) {
37
+ for (const key of Object.keys(source)) {
38
+ if (source[key] !== undefined) target[key] = source[key];
39
+ }
40
+ return target;
41
+ }
42
+
43
+ function mergeStores(outer, inner) {
44
+ const merged = { ...(outer || {}) };
45
+ if (!inner || typeof inner !== 'object') return freezeCopy(merged);
46
+ for (const key of Object.keys(inner)) {
47
+ const value = inner[key];
48
+ if (value === undefined) continue;
49
+ if (key === LOGGER_SCOPE_KEY && isPlainObject(value)) {
50
+ merged[key] = assignDefined({ ...(merged[key] || {}) }, value);
51
+ continue;
52
+ }
53
+ merged[key] = value;
54
+ }
55
+ return freezeCopy(merged);
56
+ }
57
+
58
+ function runInContext(context, fn) {
59
+ const storage = als();
60
+ return storage.run(mergeStores(storage.getStore(), context), fn);
61
+ }
62
+
63
+ function getContext() {
64
+ return als().getStore() ?? null;
65
+ }
66
+
67
+ // The store is frozen per scope, so one scope object per store is enough.
68
+ const EMPTY_SCOPE = Object.freeze({});
69
+ const scopeByStore = new WeakMap();
70
+
71
+ function getLoggerScope() {
72
+ const store = getContext();
73
+ if (!store) return EMPTY_SCOPE;
74
+ let scope = scopeByStore.get(store);
75
+ if (!scope) {
76
+ scope = {};
77
+ for (const key of INTEGRATION_KEYS) {
78
+ if (store[key] !== undefined) scope[key] = store[key];
79
+ }
80
+ // A set (hydrated) id beats a logger-only one, e.g. from a URL or a
81
+ // message body; a null id leaves the logger-only value in place.
82
+ const loggerKeys = store[LOGGER_SCOPE_KEY];
83
+ if (isPlainObject(loggerKeys)) {
84
+ for (const [key, value] of Object.entries(loggerKeys)) {
85
+ if (scope[key] === undefined || scope[key] === null) {
86
+ scope[key] = value;
87
+ }
88
+ }
89
+ }
90
+ scope = Object.freeze(scope);
91
+ scopeByStore.set(store, scope);
92
+ }
93
+ return scope;
94
+ }
95
+
96
+ function hasOnlyLoggerKeys(store) {
97
+ if (!store || typeof store !== 'object') return false;
98
+ const keys = Object.keys(store);
99
+ return keys.length > 0 && keys.every((key) => key === LOGGER_SCOPE_KEY);
100
+ }
101
+
102
+ module.exports = {
103
+ LOGGER_SCOPE_KEY,
104
+ runInContext,
105
+ getContext,
106
+ getLoggerScope,
107
+ hasOnlyLoggerKeys,
108
+ isPlainObject,
109
+ };