@friggframework/core 2.0.0--canary.654.5821f44.0 → 2.0.0--canary.654.e9530b8.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.
@@ -6,7 +6,7 @@ const { getLogger, toSanitizedSurrogate } = require('../logs');
6
6
  const {
7
7
  summarizeLambdaEvent,
8
8
  toScopeInvocation,
9
- toRequestInvocation,
9
+ toRequestDetails,
10
10
  } = require('../logs/summarize-event');
11
11
  const {
12
12
  runInvocationScope,
@@ -58,8 +58,9 @@ const createHandler = (optionByName = {}) => {
58
58
  routeKey: eventSummary.routeKey,
59
59
  invocation: toScopeInvocation(eventSummary),
60
60
  };
61
- // The full redacted summary goes only on the entry and failure records.
62
- const requestInvocation = toRequestInvocation(eventSummary);
61
+ // Path, query keys and header names go only on the entry and failure
62
+ // records; the logger adds them to the scope's invocation.
63
+ const requestDetails = toRequestDetails(eventSummary);
63
64
 
64
65
  return runInvocationScope(
65
66
  scopeFields,
@@ -67,7 +68,7 @@ const createHandler = (optionByName = {}) => {
67
68
  try {
68
69
  log.info('Handler invoked', {
69
70
  eventName: 'frigg.handler.invoked',
70
- invocation: requestInvocation,
71
+ invocation: requestDetails,
71
72
  });
72
73
 
73
74
  // If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
@@ -109,7 +110,7 @@ const createHandler = (optionByName = {}) => {
109
110
  if (!isUserFacingResponse && error.isHaltError === true) {
110
111
  log.error('Handler halted', {
111
112
  eventName: 'frigg.handler.halted',
112
- invocation: requestInvocation,
113
+ invocation: requestDetails,
113
114
  error,
114
115
  });
115
116
  return;
@@ -117,7 +118,7 @@ const createHandler = (optionByName = {}) => {
117
118
 
118
119
  log.error('Handler failed', {
119
120
  eventName: 'frigg.handler.failed',
120
- invocation: requestInvocation,
121
+ invocation: requestDetails,
121
122
  error,
122
123
  });
123
124
 
@@ -1,4 +1,4 @@
1
- const { runInContext } = require('../logs/context');
1
+ const { runInContext, LOGGER_SCOPE_KEY } = require('../logs/context');
2
2
  const { summarizeMessageBody } = require('../logs/summarize-event');
3
3
  const { flushSinks, hasFlushableSinks } = require('../logs/logger-runtime');
4
4
 
@@ -164,22 +164,19 @@ async function runInvocationScope(fields, fn, opts = {}) {
164
164
  flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
165
165
  context,
166
166
  } = opts;
167
- return runInContext({ log: { ...(fields || {}) } }, async () => {
167
+ return runInContext({ [LOGGER_SCOPE_KEY]: { ...(fields || {}) } }, async () => {
168
168
  try {
169
169
  return await fn();
170
170
  } finally {
171
- try {
172
- await flushInvocation({
173
- telemetry,
174
- usageRollup,
175
- eventSummary,
176
- shouldUseDatabase,
177
- flushTimeoutMs,
178
- context,
179
- });
180
- } catch (_) {
181
- // A flush must never change the handler result.
182
- }
171
+ // Every step is guarded, so a flush never changes the result.
172
+ await flushInvocation({
173
+ telemetry,
174
+ usageRollup,
175
+ eventSummary,
176
+ shouldUseDatabase,
177
+ flushTimeoutMs,
178
+ context,
179
+ });
183
180
  }
184
181
  });
185
182
  }
@@ -201,7 +198,7 @@ function runMessageScope(record, fn) {
201
198
  );
202
199
  return runInContext(
203
200
  {
204
- log: {
201
+ [LOGGER_SCOPE_KEY]: {
205
202
  messageId: record?.messageId,
206
203
  receiveCount: toCount(record?.attributes?.ApproximateReceiveCount),
207
204
  integrationEvent: event,
package/logs/context.js CHANGED
@@ -64,15 +64,24 @@ function getContext() {
64
64
  return als().getStore() ?? null;
65
65
  }
66
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
+
67
71
  function getLoggerScope() {
68
72
  const store = getContext();
69
- if (!store) return {};
70
- const scope = {};
71
- for (const key of INTEGRATION_KEYS) {
72
- if (store[key] !== undefined) scope[key] = store[key];
73
- }
74
- if (isPlainObject(store[LOGGER_SCOPE_KEY])) {
75
- Object.assign(scope, store[LOGGER_SCOPE_KEY]);
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
+ if (isPlainObject(store[LOGGER_SCOPE_KEY])) {
81
+ Object.assign(scope, store[LOGGER_SCOPE_KEY]);
82
+ }
83
+ scope = Object.freeze(scope);
84
+ scopeByStore.set(store, scope);
76
85
  }
77
86
  return scope;
78
87
  }
@@ -89,4 +98,5 @@ module.exports = {
89
98
  getContext,
90
99
  getLoggerScope,
91
100
  hasOnlyLoggerKeys,
101
+ isPlainObject,
92
102
  };
@@ -0,0 +1,67 @@
1
+ // Field names of the record contract (ADR-048 §3). A leaf: no requires.
2
+
3
+ const REQUIRED_KEYS = ['timestamp', 'level', 'message', 'logger'];
4
+ const RESOURCE_KEYS = ['appName', 'stage'];
5
+ const TRACE_KEYS = ['trace_id', 'span_id', 'trace_flags'];
6
+ const DROPPED_KEYS_FIELD = 'droppedKeys';
7
+ const OWNED_KEYS = new Set([
8
+ ...REQUIRED_KEYS,
9
+ ...RESOURCE_KEYS,
10
+ ...TRACE_KEYS,
11
+ DROPPED_KEYS_FIELD,
12
+ ]);
13
+
14
+ // Set by the scope builders (logs/context.js, core/invocation-scope.js,
15
+ // core/create-handler.js) and the Module and IntegrationBase bindings.
16
+ const SCOPE_KEYS = [
17
+ 'integrationId',
18
+ 'integrationType',
19
+ 'userId',
20
+ 'version',
21
+ 'entityId',
22
+ 'credentialId',
23
+ 'requestId',
24
+ 'messageId',
25
+ 'receiveCount',
26
+ 'processId',
27
+ 'integrationEvent',
28
+ 'handlerName',
29
+ 'method',
30
+ 'route',
31
+ 'routeKey',
32
+ 'invocation',
33
+ ];
34
+
35
+ const CONTRACT_KEYS = [
36
+ ...new Set([...OWNED_KEYS, ...SCOPE_KEYS, 'eventName', 'statusCode', 'error']),
37
+ ];
38
+
39
+ const RESERVED_KEYS = new Set([
40
+ 'tenantId',
41
+ 'type',
42
+ 'time',
43
+ 'record',
44
+ 'errorType',
45
+ 'errorMessage',
46
+ 'stackTrace',
47
+ 'service',
48
+ 'env',
49
+ 'host',
50
+ 'source',
51
+ 'status',
52
+ 'severity',
53
+ ]);
54
+
55
+ const PAYLOAD_KEYS = new Set(['body', 'rawBody', 'payload', 'response']);
56
+
57
+ module.exports = {
58
+ REQUIRED_KEYS,
59
+ RESOURCE_KEYS,
60
+ TRACE_KEYS,
61
+ DROPPED_KEYS_FIELD,
62
+ OWNED_KEYS,
63
+ SCOPE_KEYS,
64
+ CONTRACT_KEYS,
65
+ RESERVED_KEYS,
66
+ PAYLOAD_KEYS,
67
+ };
@@ -69,16 +69,11 @@ function hasFlushableSinks() {
69
69
  }
70
70
 
71
71
  async function flushSinks({ signal } = {}) {
72
- const flushes = getSinks()
73
- .filter((sink) => typeof sink.flush === 'function')
74
- .map((sink) => {
75
- try {
76
- return Promise.resolve(sink.flush({ signal }));
77
- } catch (error) {
78
- return Promise.reject(error);
79
- }
80
- });
81
- await Promise.allSettled(flushes);
72
+ await Promise.allSettled(
73
+ getSinks()
74
+ .filter((sink) => typeof sink.flush === 'function')
75
+ .map(async (sink) => sink.flush({ signal }))
76
+ );
82
77
  }
83
78
 
84
79
  function setSpanContextProvider(provider) {
@@ -117,7 +112,7 @@ function resetLoggerForTests({ level, sinks, trackViolations } = {}) {
117
112
  const s = state();
118
113
  s.config = null;
119
114
  s.levelOverride = parseLevel(level);
120
- s.sinks = Array.isArray(sinks) ? [...sinks] : [require('./sinks').createStdoutSink()];
115
+ setSinks(sinks);
121
116
  s.spanContextProvider = null;
122
117
  s.warned.clear();
123
118
  s.violations.length = 0;
package/logs/logger.js CHANGED
@@ -5,31 +5,33 @@ const { buildRecord, DEFAULT_LOGGER_NAME } = require('./record');
5
5
 
6
6
  const LOGGER_OWN_NAME = 'frigg.logger';
7
7
 
8
- function copyBindings(bindings) {
9
- const copy = {};
10
- if (!bindings || typeof bindings !== 'object') return copy;
8
+ function copyBindings(bindings, into = {}) {
9
+ if (!bindings || typeof bindings !== 'object') return into;
11
10
  for (const key of Object.keys(bindings)) {
12
11
  try {
13
- copy[key] = bindings[key];
12
+ into[key] = bindings[key];
14
13
  } catch {
15
14
  // A throwing getter drops only that binding.
16
15
  }
17
16
  }
18
- return copy;
17
+ return into;
19
18
  }
20
19
 
20
+ // One plain object per record. Static sources were copied at child() time.
21
21
  function evaluateBindings(sources) {
22
22
  const merged = {};
23
23
  for (const source of sources) {
24
- let value = source;
25
- if (typeof source === 'function') {
26
- try {
27
- value = source();
28
- } catch {
29
- continue;
30
- }
24
+ if (typeof source !== 'function') {
25
+ Object.assign(merged, source);
26
+ continue;
27
+ }
28
+ let value;
29
+ try {
30
+ value = source();
31
+ } catch {
32
+ continue;
31
33
  }
32
- Object.assign(merged, copyBindings(value));
34
+ copyBindings(value, merged);
33
35
  }
34
36
  return merged;
35
37
  }
package/logs/record.js CHANGED
@@ -1,42 +1,17 @@
1
1
  const { LEVELS } = require('./levels');
2
- const { serializeValue, serializeError } = require('./serialize');
2
+ const { serializeValue, serializeError, isError } = require('./serialize');
3
+ const { isPlainObject } = require('./context');
4
+ const {
5
+ RESOURCE_KEYS,
6
+ DROPPED_KEYS_FIELD,
7
+ OWNED_KEYS,
8
+ RESERVED_KEYS,
9
+ PAYLOAD_KEYS,
10
+ } = require('./contract-keys');
3
11
 
4
12
  const MAX_RECORD_BYTES = 16 * 1024;
5
- const REQUIRED_KEYS = ['timestamp', 'level', 'message', 'logger'];
6
- const RESOURCE_KEYS = ['appName', 'stage'];
7
- const TRACE_KEYS = ['trace_id', 'span_id', 'trace_flags'];
8
- const DROPPED_KEYS_FIELD = 'droppedKeys';
9
- const OWNED_KEYS = new Set([
10
- ...REQUIRED_KEYS,
11
- ...RESOURCE_KEYS,
12
- ...TRACE_KEYS,
13
- DROPPED_KEYS_FIELD,
14
- ]);
15
- const RESERVED_KEYS = new Set([
16
- 'tenantId',
17
- 'type',
18
- 'time',
19
- 'record',
20
- 'errorType',
21
- 'errorMessage',
22
- 'stackTrace',
23
- 'service',
24
- 'env',
25
- 'host',
26
- 'source',
27
- 'status',
28
- 'severity',
29
- ]);
30
- const PAYLOAD_KEYS = new Set(['body', 'rawBody', 'payload', 'response']);
31
13
  const DEFAULT_LOGGER_NAME = 'frigg.unknown';
32
14
 
33
- function isError(value) {
34
- return (
35
- value instanceof Error ||
36
- Object.prototype.toString.call(value) === '[object Error]'
37
- );
38
- }
39
-
40
15
  function deepFreeze(value) {
41
16
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
42
17
  for (const key of Object.keys(value)) deepFreeze(value[key]);
@@ -64,39 +39,31 @@ function readFields(source) {
64
39
  return result;
65
40
  }
66
41
 
42
+ // Returns the message text. An Error message is serialized once; when the
43
+ // call site has no `error`, that result becomes the record's `error`.
67
44
  function buildMessage(message, callSite) {
68
45
  if (typeof message === 'string') {
69
- return serializeValue(message);
46
+ return { text: serializeValue(message) };
70
47
  }
71
- if (message === undefined || message === null) return '';
48
+ if (message === undefined || message === null) return { text: '' };
72
49
  if (isError(message)) {
73
50
  const error = serializeError(message);
74
- if (callSite.error === undefined) callSite.error = message;
75
- return typeof error?.message === 'string' ? error.message : '';
51
+ const text = typeof error?.message === 'string' ? error.message : '';
52
+ if (callSite.error !== undefined) return { text };
53
+ callSite.error = message;
54
+ return { text, error };
76
55
  }
77
56
  if (callSite.value === undefined) callSite.value = message;
78
- return '[non-string message]';
57
+ return { text: '[non-string message]' };
79
58
  }
80
59
 
81
60
  const INVOCATION_KEY = 'invocation';
82
61
 
83
- function isPlainObject(value) {
84
- return (
85
- value !== null &&
86
- typeof value === 'object' &&
87
- Object.getPrototypeOf(value) === Object.prototype
88
- );
89
- }
90
-
91
62
  // A boundary adds request detail to the scope's invocation; scope keys win.
92
- function extendInvocation(scoped, detail, dropped) {
63
+ function extendInvocation(scoped, detail) {
93
64
  const merged = { ...scoped };
94
- for (const [key, value] of Object.entries(detail)) {
95
- if (!Object.prototype.hasOwnProperty.call(scoped, key)) {
96
- merged[key] = value;
97
- } else if (JSON.stringify(scoped[key]) !== JSON.stringify(value)) {
98
- dropped.add(`${INVOCATION_KEY}.${key}`);
99
- }
65
+ for (const key of Object.keys(detail)) {
66
+ if (!(key in merged)) merged[key] = detail[key];
100
67
  }
101
68
  return merged;
102
69
  }
@@ -121,6 +88,60 @@ function byteLength(record) {
121
88
  return Buffer.byteLength(JSON.stringify(record));
122
89
  }
123
90
 
91
+ function isDroppedKey(key, dropPayloads) {
92
+ return (
93
+ OWNED_KEYS.has(key) ||
94
+ RESERVED_KEYS.has(key) ||
95
+ (dropPayloads && PAYLOAD_KEYS.has(key))
96
+ );
97
+ }
98
+
99
+ // Filters and serializes one source. `error` is serialized on its own, once
100
+ // (or taken from `preSerializedError`), and never walked by serializeValue.
101
+ function prepareSource(source, dropPayloads, preSerializedError) {
102
+ const accepted = {};
103
+ const keys = [];
104
+ const dropped = [];
105
+ let rawError;
106
+ for (const [key, value] of Object.entries(source)) {
107
+ if (value === undefined || value === null) continue;
108
+ if (isDroppedKey(key, dropPayloads)) {
109
+ dropped.push(key);
110
+ continue;
111
+ }
112
+ keys.push(key);
113
+ if (key === 'error') rawError = value;
114
+ else accepted[key] = value;
115
+ }
116
+ // One pass over the object, so key rules (denied keys, digests,
117
+ // headers, OAuth shapes) apply to top-level fields too.
118
+ const safe = serializeValue(accepted);
119
+ const entries = [];
120
+ for (const key of keys) {
121
+ const value =
122
+ key === 'error'
123
+ ? preSerializedError ?? serializeError(rawError)
124
+ : safe?.[key];
125
+ if (value !== undefined && value !== null) entries.push([key, value]);
126
+ }
127
+ return { entries, dropped };
128
+ }
129
+
130
+ // The scope object is frozen and shared by every record of one scope.
131
+ const preparedScopes = new WeakMap();
132
+
133
+ function prepareScope(scope, dropPayloads) {
134
+ if (!scope || typeof scope !== 'object') return { entries: [], dropped: [] };
135
+ let byLevel = preparedScopes.get(scope);
136
+ if (!byLevel) {
137
+ byLevel = {};
138
+ preparedScopes.set(scope, byLevel);
139
+ }
140
+ const slot = dropPayloads ? 'info' : 'debug';
141
+ if (!byLevel[slot]) byLevel[slot] = prepareSource(scope, dropPayloads);
142
+ return byLevel[slot];
143
+ }
144
+
124
145
  function buildRecord({
125
146
  level,
126
147
  logger,
@@ -135,10 +156,11 @@ function buildRecord({
135
156
  const dropPayloads = LEVELS[level] >= LEVELS.INFO;
136
157
  // log.error('msg', err): own props of an Error (axios config, request) must not become fields.
137
158
  const callSite = readFields(isError(fields) ? { error: fields } : fields);
159
+ const built = buildMessage(message, callSite);
138
160
  const record = {
139
161
  timestamp: now.toISOString(),
140
162
  level,
141
- message: buildMessage(message, callSite),
163
+ message: built.text,
142
164
  logger: typeof logger === 'string' && logger ? logger : DEFAULT_LOGGER_NAME,
143
165
  };
144
166
  for (const key of RESOURCE_KEYS) {
@@ -150,52 +172,37 @@ function buildRecord({
150
172
 
151
173
  const dropped = new Set();
152
174
  const callSiteKeys = [];
153
- const place = (source, isCallSite) => {
154
- const accepted = {};
155
- for (const [key, value] of Object.entries(source)) {
156
- if (value === undefined || value === null) continue;
157
- if (
158
- OWNED_KEYS.has(key) ||
159
- RESERVED_KEYS.has(key) ||
160
- (dropPayloads && PAYLOAD_KEYS.has(key)) ||
161
- (Object.prototype.hasOwnProperty.call(record, key) &&
162
- !canExtendInvocation(key, value))
163
- ) {
164
- dropped.add(key);
165
- continue;
166
- }
167
- accepted[key] = value;
168
- }
169
- // One pass over the object, so key rules (denied keys, digests,
170
- // headers, OAuth shapes) apply to top-level fields too.
171
- const safe = serializeValue(accepted);
172
- for (const key of Object.keys(accepted)) {
173
- const value =
174
- key === 'error' ? serializeError(accepted[key]) : safe?.[key];
175
- if (value === undefined || value === null) continue;
176
- if (key === INVOCATION_KEY && isPlainObject(record[key])) {
177
- record[key] = extendInvocation(record[key], value, dropped);
175
+ const place = ({ entries, dropped: droppedHere }, isCallSite) => {
176
+ for (const key of droppedHere) dropped.add(key);
177
+ for (const [key, value] of entries) {
178
+ if (Object.prototype.hasOwnProperty.call(record, key)) {
179
+ if (
180
+ key === INVOCATION_KEY &&
181
+ isPlainObject(record[key]) &&
182
+ isPlainObject(value)
183
+ ) {
184
+ record[key] = extendInvocation(record[key], value);
185
+ } else {
186
+ dropped.add(key);
187
+ }
178
188
  continue;
179
189
  }
180
190
  record[key] = value;
181
191
  if (isCallSite) callSiteKeys.push(key);
182
192
  }
183
193
  };
184
- const canExtendInvocation = (key, value) =>
185
- key === INVOCATION_KEY &&
186
- isPlainObject(record[key]) &&
187
- isPlainObject(value);
188
- place(readFields(scope), false);
189
- place(readFields(bindings), false);
190
- place(callSite, true);
191
-
192
- const measure = () => {
194
+ place(prepareScope(scope, dropPayloads), false);
195
+ place(prepareSource(bindings || {}, dropPayloads), false);
196
+ place(prepareSource(callSite, dropPayloads, built.error), true);
197
+
198
+ const setDroppedKeys = () => {
193
199
  delete record[DROPPED_KEYS_FIELD];
194
200
  if (dropped.size) record[DROPPED_KEYS_FIELD] = [...dropped];
195
- return byteLength(record);
196
201
  };
202
+ setDroppedKeys();
203
+ let size = byteLength(record);
197
204
 
198
- if (measure() > MAX_RECORD_BYTES) {
205
+ if (size > MAX_RECORD_BYTES) {
199
206
  const bySize = callSiteKeys
200
207
  .filter((key) => key !== 'error')
201
208
  .map((key) => [key, Buffer.byteLength(JSON.stringify(record[key]))])
@@ -203,27 +210,30 @@ function buildRecord({
203
210
  for (const [key] of bySize) {
204
211
  delete record[key];
205
212
  dropped.add(key);
206
- if (measure() <= MAX_RECORD_BYTES) break;
213
+ setDroppedKeys();
214
+ size = byteLength(record);
215
+ if (size <= MAX_RECORD_BYTES) break;
207
216
  }
208
217
  }
209
218
  for (const part of ['stack', 'cause']) {
210
219
  if (
220
+ size > MAX_RECORD_BYTES &&
211
221
  record.error &&
212
222
  typeof record.error === 'object' &&
213
- record.error[part] !== undefined &&
214
- measure() > MAX_RECORD_BYTES
223
+ record.error[part] !== undefined
215
224
  ) {
216
225
  const { [part]: _removed, ...rest } = record.error;
217
226
  record.error = rest;
227
+ size = byteLength(record);
218
228
  }
219
229
  }
220
- measure();
221
230
 
222
231
  return deepFreeze(record);
223
232
  }
224
233
 
225
234
  module.exports = {
226
235
  buildRecord,
236
+ deepFreeze,
227
237
  MAX_RECORD_BYTES,
228
238
  RESERVED_KEYS,
229
239
  PAYLOAD_KEYS,
package/logs/redact.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { CONTRACT_KEYS } = require('./contract-keys');
2
+
1
3
  const DENIED_SUFFIXES = [
2
4
  'token',
3
5
  'secret',
@@ -27,27 +29,9 @@ const deniedKeys = new Set([
27
29
 
28
30
  // Record-contract fields. A credential field with one of these names must not
29
31
  // blank the field in every record.
30
- const PROTECTED_KEYS = new Set(
31
- [
32
- 'integrationId',
33
- 'integrationType',
34
- 'userId',
35
- 'version',
36
- 'entityId',
37
- 'credentialId',
38
- 'requestId',
39
- 'messageId',
40
- 'processId',
41
- 'integrationEvent',
42
- 'eventName',
43
- 'handlerName',
44
- 'method',
45
- 'route',
46
- 'routeKey',
47
- 'statusCode',
48
- ].map((key) => key.toLowerCase())
49
- );
32
+ const PROTECTED_KEYS = new Set(CONTRACT_KEYS.map(normalizeKey));
50
33
 
34
+ const DENIED_SUFFIX = new RegExp(`(?:${DENIED_SUFFIXES.join('|')})$`);
51
35
  const PAIR_KEYS = new Set(['code', 'codeverifier']);
52
36
  const DIGEST_KEY = /(sha(1|256)|hash|digest|checksum)$/;
53
37
 
@@ -71,7 +55,6 @@ const JSON_PAIR = /"([^"\\]{1,100})"(\s*:\s*)"((?:[^"\\]|\\.)*)"/g;
71
55
  const TEXT_PAIR = /(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]{1,100})=([^\s&"'<>,;)]+)/g;
72
56
  const HEX_RUN = /(?<![A-Za-z0-9])[0-9a-fA-F]{40,}(?![A-Za-z0-9])/g;
73
57
  const BASE64_RUN = /(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/=_-])/g;
74
- const BASE64_SEGMENT_RUN = /(?<![A-Za-z0-9+=_-])[A-Za-z0-9+_-]{40,}={0,2}(?![A-Za-z0-9+=_-])/g;
75
58
  const FORM_TEXT = /^[A-Za-z0-9_.%[\]-]+=[^&\s]*(?:&[A-Za-z0-9_.%[\]-]+=[^&\s]*)+$/;
76
59
  const PATH_SEGMENT = /^[A-Za-z0-9_-]*$/;
77
60
 
@@ -87,15 +70,23 @@ function normalizeKey(key) {
87
70
  return key.toLowerCase().replace(/[-_\s]/g, '');
88
71
  }
89
72
 
90
- function isDeniedKey(key) {
91
- const normalized = normalizeKey(key);
73
+ // The *Normalized variants take a normalizeKey() result, so a walk
74
+ // normalizes each key once.
75
+ function isDeniedNormalized(normalized) {
92
76
  if (!normalized || PROTECTED_KEYS.has(normalized)) return false;
93
- if (deniedKeys.has(normalized)) return true;
94
- return DENIED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
77
+ return deniedKeys.has(normalized) || DENIED_SUFFIX.test(normalized);
78
+ }
79
+
80
+ function isDeniedKey(key) {
81
+ return isDeniedNormalized(normalizeKey(key));
82
+ }
83
+
84
+ function isDigestNormalized(normalized) {
85
+ return DIGEST_KEY.test(normalized);
95
86
  }
96
87
 
97
88
  function isDigestKey(key) {
98
- return DIGEST_KEY.test(normalizeKey(key));
89
+ return isDigestNormalized(normalizeKey(key));
99
90
  }
100
91
 
101
92
  function isPairKey(key, { withState = false } = {}) {
@@ -139,7 +130,7 @@ function isRedactedValue(value) {
139
130
  return value === 'REDACTED' || value.startsWith('[REDACTED');
140
131
  }
141
132
 
142
- function scrubTokens(text, { allowHex = false, segment = false } = {}) {
133
+ function scrubTokens(text, { allowHex = false } = {}) {
143
134
  let out = text.replace(PREFIXED_TOKEN, redacted);
144
135
  out = out.replace(JWT, redacted);
145
136
  out = out.replace(AUTH_SCHEME, (match, scheme, space, credential) => {
@@ -170,8 +161,8 @@ function scrubTokens(text, { allowHex = false, segment = false } = {}) {
170
161
  : match
171
162
  );
172
163
  if (!allowHex) out = out.replace(HEX_RUN, redacted);
173
- out = out.replace(segment ? BASE64_SEGMENT_RUN : BASE64_RUN, (run) =>
174
- hasMixedClasses(run) && (segment || !isPathLike(run))
164
+ out = out.replace(BASE64_RUN, (run) =>
165
+ hasMixedClasses(run) && !isPathLike(run)
175
166
  ? redacted(run)
176
167
  : run
177
168
  );
@@ -204,7 +195,7 @@ function redactPathSegment(segment) {
204
195
  if (PATH_TOKEN_SEGMENT.test(segment) && hasMixedClasses(segment)) {
205
196
  return redacted(segment);
206
197
  }
207
- return scrubTokens(segment, { segment: true });
198
+ return scrubTokens(segment);
208
199
  }
209
200
 
210
201
  // The raw path, not URL#pathname, which percent-encodes `{proxy+}`.
@@ -308,12 +299,13 @@ function scrubString(str, { allowHex = false } = {}) {
308
299
  }
309
300
  }
310
301
 
302
+ function isOAuthCallbackShapeNormalized(normalizedKeys) {
303
+ const keys = new Set(normalizedKeys);
304
+ return keys.has('code') && (keys.has('state') || keys.has('codeverifier'));
305
+ }
306
+
311
307
  function isOAuthCallbackShape(keys) {
312
- const normalized = new Set(keys.map(normalizeKey));
313
- return (
314
- normalized.has('code') &&
315
- (normalized.has('state') || normalized.has('codeverifier'))
316
- );
308
+ return isOAuthCallbackShapeNormalized(keys.map(normalizeKey));
317
309
  }
318
310
 
319
311
  function redactValue(value) {
@@ -324,8 +316,11 @@ function redactValue(value) {
324
316
  module.exports = {
325
317
  normalizeKey,
326
318
  isDeniedKey,
319
+ isDeniedNormalized,
327
320
  isDigestKey,
321
+ isDigestNormalized,
328
322
  isOAuthCallbackShape,
323
+ isOAuthCallbackShapeNormalized,
329
324
  addDeniedKeys,
330
325
  redactUrl,
331
326
  scrubString,
package/logs/serialize.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const {
2
- isDeniedKey,
3
- isDigestKey,
4
- isOAuthCallbackShape,
2
+ isDeniedNormalized,
3
+ isDigestNormalized,
4
+ isOAuthCallbackShapeNormalized,
5
5
  normalizeKey,
6
6
  redactUrl,
7
7
  scrubString,
@@ -27,8 +27,10 @@ function cutString(value) {
27
27
  return value.slice(0, MAX_STRING - suffix.length) + suffix;
28
28
  }
29
29
 
30
- function cleanString(value, key) {
31
- return cutString(scrubString(value, { allowHex: isDigestKey(key) }));
30
+ function cleanString(value, normalizedKey = '') {
31
+ return cutString(
32
+ scrubString(value, { allowHex: isDigestNormalized(normalizedKey) })
33
+ );
32
34
  }
33
35
 
34
36
  function isError(value) {
@@ -73,10 +75,13 @@ function headerNames(value) {
73
75
 
74
76
  function walkObject(value, depth, seen) {
75
77
  const keys = Object.keys(value);
76
- const dropAll = isOAuthCallbackShape(keys);
78
+ const normalizedKeys = keys.map(normalizeKey);
79
+ const dropAll = isOAuthCallbackShapeNormalized(normalizedKeys);
77
80
  const out = {};
78
- for (const key of keys) {
79
- if (dropAll || isDeniedKey(key)) {
81
+ for (let i = 0; i < keys.length; i += 1) {
82
+ const key = keys[i];
83
+ const normalized = normalizedKeys[i];
84
+ if (dropAll || isDeniedNormalized(normalized)) {
80
85
  out[key] = '[REDACTED]';
81
86
  continue;
82
87
  }
@@ -87,11 +92,11 @@ function walkObject(value, depth, seen) {
87
92
  out[key] = '[Getter threw]';
88
93
  continue;
89
94
  }
90
- if (HEADER_KEYS.has(normalizeKey(key))) {
95
+ if (HEADER_KEYS.has(normalized)) {
91
96
  out[key] = attempt(() => headerNames(child), '[Unserializable]');
92
97
  continue;
93
98
  }
94
- const result = walk(child, depth + 1, seen, key);
99
+ const result = walk(child, depth + 1, seen, normalized);
95
100
  if (result !== undefined) out[key] = result;
96
101
  }
97
102
  return out;
@@ -102,11 +107,12 @@ function walkContainer(value, depth, seen) {
102
107
  const out = {};
103
108
  for (const [k, v] of value) {
104
109
  const key = String(k);
105
- if (isDeniedKey(key)) {
110
+ const normalized = normalizeKey(key);
111
+ if (isDeniedNormalized(normalized)) {
106
112
  out[key] = '[REDACTED]';
107
113
  continue;
108
114
  }
109
- const result = walk(v, depth + 1, seen, key);
115
+ const result = walk(v, depth + 1, seen, normalized);
110
116
  if (result !== undefined) out[key] = result;
111
117
  }
112
118
  return out;
@@ -151,10 +157,10 @@ function walkSpecial(value) {
151
157
  return undefined;
152
158
  }
153
159
 
154
- function walk(value, depth, seen, key) {
160
+ function walk(value, depth, seen, normalizedKey) {
155
161
  switch (typeof value) {
156
162
  case 'string':
157
- return cleanString(value, key);
163
+ return cleanString(value, normalizedKey);
158
164
  case 'number':
159
165
  return Number.isFinite(value) ? value : String(value);
160
166
  case 'boolean':
@@ -313,6 +319,7 @@ function toSanitizedSurrogate(err) {
313
319
  }
314
320
 
315
321
  module.exports = {
322
+ isError,
316
323
  serializeValue,
317
324
  serializeError,
318
325
  toSanitizedSurrogate,
package/logs/sinks.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const fs = require('node:fs');
2
+ const { deepFreeze } = require('./record');
2
3
 
3
4
  const DEFAULT_MAX_RETRIES = 10;
4
5
  const MAX_SLEEP_MS = 20;
@@ -42,42 +43,35 @@ function createStdoutSink({
42
43
  }
43
44
  let offset = 0;
44
45
  let retries = 0;
46
+ // EAGAIN and a zero-byte write share one bounded retry budget.
47
+ const retry = () => {
48
+ if (retries >= maxRetries) {
49
+ reportFailure('EAGAIN');
50
+ return false;
51
+ }
52
+ retries += 1;
53
+ sleep(Math.min(retries, MAX_SLEEP_MS));
54
+ return true;
55
+ };
45
56
  while (offset < buffer.length) {
46
57
  let written;
47
58
  try {
48
59
  written = writeSync(fd, buffer, offset, buffer.length - offset);
49
60
  } catch (error) {
50
- if (error?.code === 'EAGAIN' && retries < maxRetries) {
51
- retries += 1;
52
- sleep(Math.min(retries, MAX_SLEEP_MS));
53
- continue;
54
- }
55
- reportFailure(error?.code);
56
- return;
57
- }
58
- if (!(written > 0)) {
59
- if (retries >= maxRetries) {
60
- reportFailure('EAGAIN');
61
+ if (error?.code !== 'EAGAIN') {
62
+ reportFailure(error?.code);
61
63
  return;
62
64
  }
63
- retries += 1;
64
- sleep(Math.min(retries, MAX_SLEEP_MS));
65
+ if (!retry()) return;
65
66
  continue;
66
67
  }
67
- offset += written;
68
+ if (written > 0) offset += written;
69
+ else if (!retry()) return;
68
70
  }
69
71
  },
70
72
  };
71
73
  }
72
74
 
73
- function deepFreeze(value) {
74
- if (value && typeof value === 'object') {
75
- for (const key of Object.keys(value)) deepFreeze(value[key]);
76
- Object.freeze(value);
77
- }
78
- return value;
79
- }
80
-
81
75
  function createMemorySink({ install = true } = {}) {
82
76
  const records = [];
83
77
  const sink = {
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.654.5821f44.0",
4
+ "version": "2.0.0--canary.654.e9530b8.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@friggframework/eslint-config": "2.0.0--canary.654.5821f44.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.654.5821f44.0",
52
- "@friggframework/test": "2.0.0--canary.654.5821f44.0",
50
+ "@friggframework/eslint-config": "2.0.0--canary.654.e9530b8.0",
51
+ "@friggframework/prettier-config": "2.0.0--canary.654.e9530b8.0",
52
+ "@friggframework/test": "2.0.0--canary.654.e9530b8.0",
53
53
  "@prisma/client": "^6.19.3",
54
54
  "@types/lodash": "4.17.15",
55
55
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -89,5 +89,5 @@
89
89
  "publishConfig": {
90
90
  "access": "public"
91
91
  },
92
- "gitHead": "5821f44da7972564eb82727042b9760556114670"
92
+ "gitHead": "e9530b84cdbe42939e8061edc241acb55222cf34"
93
93
  }