@superblocksteam/shared 0.9608.2 → 0.9609.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.
Files changed (91) hide show
  1. package/dist/jwt/verifier.d.ts.map +1 -1
  2. package/dist/jwt/verifier.js +6 -1
  3. package/dist/jwt/verifier.js.map +1 -1
  4. package/dist/jwt/verifier.test.js +64 -2
  5. package/dist/jwt/verifier.test.js.map +1 -1
  6. package/dist/types/application/index.d.ts +8 -0
  7. package/dist/types/application/index.d.ts.map +1 -1
  8. package/dist/types/application/index.js.map +1 -1
  9. package/dist/types/audit/ocsf.d.ts.map +1 -1
  10. package/dist/types/audit/ocsf.js +3 -1
  11. package/dist/types/audit/ocsf.js.map +1 -1
  12. package/dist/types/common/response.d.ts +8 -1
  13. package/dist/types/common/response.d.ts.map +1 -1
  14. package/dist/types/common/response.js +7 -0
  15. package/dist/types/common/response.js.map +1 -1
  16. package/dist/types/datasource/auth.d.ts +1 -0
  17. package/dist/types/datasource/auth.d.ts.map +1 -1
  18. package/dist/types/datasource/auth.js +26 -0
  19. package/dist/types/datasource/auth.js.map +1 -1
  20. package/dist/types/datasource/auth.test.js +57 -0
  21. package/dist/types/datasource/auth.test.js.map +1 -1
  22. package/dist/types/policyGate/index.d.ts +37 -0
  23. package/dist/types/policyGate/index.d.ts.map +1 -1
  24. package/dist/types/policyGate/index.js +17 -0
  25. package/dist/types/policyGate/index.js.map +1 -1
  26. package/dist/types/response/DeleteApplicationResponseBody.d.ts +2 -2
  27. package/dist/types/response/DeleteApplicationResponseBody.d.ts.map +1 -1
  28. package/dist/utils/index.d.ts +1 -0
  29. package/dist/utils/index.d.ts.map +1 -1
  30. package/dist/utils/index.js +1 -0
  31. package/dist/utils/index.js.map +1 -1
  32. package/dist/utils/launchDarkly.d.ts +18 -0
  33. package/dist/utils/launchDarkly.d.ts.map +1 -0
  34. package/dist/utils/launchDarkly.js +21 -0
  35. package/dist/utils/launchDarkly.js.map +1 -0
  36. package/dist/utils/launchDarkly.test.d.ts +2 -0
  37. package/dist/utils/launchDarkly.test.d.ts.map +1 -0
  38. package/dist/utils/launchDarkly.test.js +24 -0
  39. package/dist/utils/launchDarkly.test.js.map +1 -0
  40. package/dist-esm/jwt/verifier.d.ts.map +1 -1
  41. package/dist-esm/jwt/verifier.js +6 -1
  42. package/dist-esm/jwt/verifier.js.map +1 -1
  43. package/dist-esm/jwt/verifier.test.js +64 -2
  44. package/dist-esm/jwt/verifier.test.js.map +1 -1
  45. package/dist-esm/types/application/index.d.ts +8 -0
  46. package/dist-esm/types/application/index.d.ts.map +1 -1
  47. package/dist-esm/types/application/index.js.map +1 -1
  48. package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
  49. package/dist-esm/types/audit/ocsf.js +3 -1
  50. package/dist-esm/types/audit/ocsf.js.map +1 -1
  51. package/dist-esm/types/common/response.d.ts +8 -1
  52. package/dist-esm/types/common/response.d.ts.map +1 -1
  53. package/dist-esm/types/common/response.js +7 -0
  54. package/dist-esm/types/common/response.js.map +1 -1
  55. package/dist-esm/types/datasource/auth.d.ts +1 -0
  56. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  57. package/dist-esm/types/datasource/auth.js +25 -0
  58. package/dist-esm/types/datasource/auth.js.map +1 -1
  59. package/dist-esm/types/datasource/auth.test.js +58 -1
  60. package/dist-esm/types/datasource/auth.test.js.map +1 -1
  61. package/dist-esm/types/policyGate/index.d.ts +37 -0
  62. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  63. package/dist-esm/types/policyGate/index.js +15 -0
  64. package/dist-esm/types/policyGate/index.js.map +1 -1
  65. package/dist-esm/types/response/DeleteApplicationResponseBody.d.ts +2 -2
  66. package/dist-esm/types/response/DeleteApplicationResponseBody.d.ts.map +1 -1
  67. package/dist-esm/utils/index.d.ts +1 -0
  68. package/dist-esm/utils/index.d.ts.map +1 -1
  69. package/dist-esm/utils/index.js +1 -0
  70. package/dist-esm/utils/index.js.map +1 -1
  71. package/dist-esm/utils/launchDarkly.d.ts +18 -0
  72. package/dist-esm/utils/launchDarkly.d.ts.map +1 -0
  73. package/dist-esm/utils/launchDarkly.js +18 -0
  74. package/dist-esm/utils/launchDarkly.js.map +1 -0
  75. package/dist-esm/utils/launchDarkly.test.d.ts +2 -0
  76. package/dist-esm/utils/launchDarkly.test.d.ts.map +1 -0
  77. package/dist-esm/utils/launchDarkly.test.js +22 -0
  78. package/dist-esm/utils/launchDarkly.test.js.map +1 -0
  79. package/package.json +1 -1
  80. package/src/jwt/verifier.test.ts +62 -4
  81. package/src/jwt/verifier.ts +6 -1
  82. package/src/types/application/index.ts +11 -0
  83. package/src/types/audit/ocsf.ts +3 -1
  84. package/src/types/common/response.ts +8 -1
  85. package/src/types/datasource/auth.test.ts +80 -0
  86. package/src/types/datasource/auth.ts +32 -0
  87. package/src/types/policyGate/index.ts +53 -0
  88. package/src/types/response/DeleteApplicationResponseBody.ts +2 -2
  89. package/src/utils/index.ts +1 -0
  90. package/src/utils/launchDarkly.test.ts +25 -0
  91. package/src/utils/launchDarkly.ts +23 -0
@@ -1,4 +1,5 @@
1
1
  import { generateKeyPairSync } from 'node:crypto';
2
+ import { createServer, type Socket } from 'node:net';
2
3
 
3
4
  import { SignJWT, importPKCS8 } from 'jose';
4
5
  import { type JWTPayload } from 'jose';
@@ -17,12 +18,17 @@ const createSigningMaterial = (kid = KEY_ID): SigningMaterial => {
17
18
  modulusLength: 2048
18
19
  });
19
20
 
20
- const jwk = {
21
- ...publicKey.export({ format: 'jwk' }),
21
+ const exportedJwk = publicKey.export({ format: 'jwk' });
22
+ if (!exportedJwk.kty) {
23
+ throw new Error('Generated public key is missing kty');
24
+ }
25
+ const jwk: JwtKey = {
26
+ ...exportedJwk,
27
+ kty: exportedJwk.kty,
22
28
  kid,
23
29
  alg: 'RS256',
24
30
  use: 'sig'
25
- } as SigningMaterial['jwk'];
31
+ };
26
32
 
27
33
  return {
28
34
  jwk,
@@ -42,6 +48,14 @@ const buildJwtVerifier = (jwk: SigningMaterial['jwk']) => {
42
48
  return { verifier: new JwtVerifier(options), getKeysInterceptor };
43
49
  };
44
50
 
51
+ const errorCodes = (error: unknown): string[] => {
52
+ if (!error || typeof error !== 'object') return [];
53
+ const codes: string[] = [];
54
+ if ('code' in error && typeof error.code === 'string') codes.push(error.code);
55
+ if ('cause' in error) codes.push(...errorCodes(error.cause));
56
+ return codes;
57
+ };
58
+
45
59
  describe('JwtVerifier', () => {
46
60
  let signingMaterial: SigningMaterial;
47
61
  beforeAll(() => {
@@ -79,7 +93,8 @@ describe('JwtVerifier', () => {
79
93
 
80
94
  await expect(verifier.verify(invalidToken, { algorithms: ['RS256'] })).rejects.toMatchObject({
81
95
  status: 401,
82
- message: expect.stringContaining('signature verification failed')
96
+ message: expect.stringContaining('signature verification failed'),
97
+ cause: expect.objectContaining({ code: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' })
83
98
  });
84
99
  });
85
100
 
@@ -163,4 +178,47 @@ describe('JwtVerifier', () => {
163
178
  await expect(verifier.verify(token, { algorithms: ['ES256'] })).rejects.toMatchObject({ status: 401 });
164
179
  expect(getKeysInterceptor).not.toHaveBeenCalled();
165
180
  });
181
+
182
+ it('preserves malformed remote JWKS protocol failures for callers to classify', async () => {
183
+ const sockets = new Set<Socket>();
184
+ const server = createServer((socket) => {
185
+ sockets.add(socket);
186
+ socket.once('close', () => sockets.delete(socket));
187
+ socket.end('NOT HTTP\r\n\r\n');
188
+ });
189
+ await new Promise<void>((resolve, reject) => {
190
+ server.once('error', reject);
191
+ server.listen(0, '127.0.0.1', () => {
192
+ server.off('error', reject);
193
+ resolve();
194
+ });
195
+ });
196
+
197
+ try {
198
+ const address = server.address();
199
+ if (!address || typeof address === 'string') throw new Error('Expected a TCP address');
200
+ const verifier = new JwtVerifier({ jwksUri: `http://127.0.0.1:${address.port}/jwks` });
201
+ const key = await importPKCS8(signingMaterial.privateKeyPem, 'RS256');
202
+ const token = await new SignJWT({ sub: 'user-bad-jwks' })
203
+ .setProtectedHeader({ alg: 'RS256', kid: signingMaterial.jwk.kid })
204
+ .sign(key);
205
+
206
+ let failure: unknown;
207
+ try {
208
+ await verifier.verify(token, { algorithms: ['RS256'] });
209
+ } catch (error) {
210
+ failure = error;
211
+ }
212
+
213
+ expect(errorCodes(failure)).toContain('HPE_INVALID_CONSTANT');
214
+ } finally {
215
+ for (const socket of sockets) socket.destroy();
216
+ await new Promise<void>((resolve, reject) => {
217
+ server.close((error) => {
218
+ if (error) reject(error);
219
+ else resolve();
220
+ });
221
+ });
222
+ }
223
+ });
166
224
  });
@@ -44,7 +44,12 @@ export class JwtVerifier {
44
44
  const result = await jwtVerify<T>(token, jwkSet, options);
45
45
  return result.payload;
46
46
  } catch (err) {
47
- throw new UnauthorizedError(`JWT verification failed: ${(err as Error).message}`);
47
+ // Keep the original error reachable. jose tags it with a `code` that is
48
+ // the only reliable way to tell an expired token from a key lookup that
49
+ // timed out, and callers need that to answer the client honestly.
50
+ const failure = new UnauthorizedError(`JWT verification failed: ${(err as Error).message}`);
51
+ failure.cause = err;
52
+ throw failure;
48
53
  }
49
54
  }
50
55
 
@@ -472,6 +472,17 @@ export interface IApplicationV2Dto {
472
472
  removedProfiles?: Record<string, Array<{ id: string; key: string; displayName: string }>>;
473
473
  }
474
474
 
475
+ export type DatabaseDisposition = 'delete' | 'retain';
476
+
477
+ export type DatabaseRetirementEnqueueFailure = {
478
+ applicationId: string;
479
+ bindingKey: string;
480
+ };
481
+
482
+ export type DatabaseRetirementEnqueueResult = {
483
+ databaseRetirementEnqueueFailures: DatabaseRetirementEnqueueFailure[];
484
+ };
485
+
475
486
  export interface IHomepageApplicationV2Dto extends IResourceMetadata {
476
487
  deployedAt?: Date | null;
477
488
  creator?: {
@@ -289,7 +289,9 @@ export const AUDIT_EVENT_TYPE_CATALOG: AuditEventTypeEntry[] = [
289
289
  'reviewpolicy.allowlist.applications.added',
290
290
  'reviewpolicy.allowlist.applications.removed',
291
291
  'reviewpolicy.create',
292
- 'reviewpolicy.delete'
292
+ 'reviewpolicy.delete',
293
+ 'reviewpolicy.disable',
294
+ 'reviewpolicy.enable'
293
295
  ]
294
296
  },
295
297
  {
@@ -72,7 +72,14 @@ export enum SuperblocksError {
72
72
  APIKeyInvalid = 'APIKeyInvalid',
73
73
  AuthenticatedUserNotFound = 'AuthenticatedUserNotFound',
74
74
  UserTokenInvalid = 'UserTokenInvalid',
75
- JWTExpired = 'JWTExpired'
75
+ JWTExpired = 'JWTExpired',
76
+ /**
77
+ * We could not tell whether the token is good, because looking up the
78
+ * signing key failed (the request timed out, or the connection was
79
+ * refused). The token itself may be perfectly valid, so this is not a
80
+ * reason for the client to give up the way an expiry or a bad signature is.
81
+ */
82
+ JWTVerificationUnavailable = 'JWTVerificationUnavailable'
76
83
  }
77
84
 
78
85
  /**
@@ -5,6 +5,7 @@ import {
5
5
  TokenScope,
6
6
  extractPublic,
7
7
  getAuthId,
8
+ getAuthIdCandidates,
8
9
  getDisplayName,
9
10
  getOAuthCodeVerifierStorageKey,
10
11
  isPkceEnabled
@@ -229,6 +230,85 @@ describe('getAuthId normalizes boolean tokenScope in getClientId', () => {
229
230
  });
230
231
  });
231
232
 
233
+ describe('getAuthIdCandidates', () => {
234
+ test('user-scoped config also probes the org-unique configId keying (recognizes a datasource->user flip)', () => {
235
+ const authConfig = { clientId: 'my-client', tokenScope: TokenScope.USER, scope: 'read' };
236
+ const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1');
237
+ // The config's own current keying (user -> client id) PLUS the widened datasource keying (config id).
238
+ expect(candidates).toContain(getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1'));
239
+ expect(candidates).toContain(
240
+ getAuthId(
241
+ IntegrationAuthType.OAUTH2_CODE,
242
+ { clientId: 'my-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' },
243
+ 'int-1',
244
+ 'cfg-1'
245
+ )
246
+ );
247
+ expect(candidates).toHaveLength(2);
248
+ expect(candidates.some((c) => c.startsWith('oauth-code.cfg-1'))).toBe(true); // org-unique config id
249
+ expect(candidates.some((c) => c.startsWith('oauth-code.my-client'))).toBe(true); // its own current client-id key
250
+ });
251
+
252
+ test('datasource-scoped config widens ONLY to the configId keying, never synthesizes the client-id keying (SEC-73)', () => {
253
+ // The client-id keying is shared across tenants (many orgs can share a clientId + scope); synthesizing
254
+ // it for a datasource-scoped config would let one org witness ownership of another org's client-id-keyed
255
+ // shared token. It must never appear as a candidate for a datasource-scoped config.
256
+ const authConfig = { clientId: 'shared-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' };
257
+ const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1');
258
+ expect(candidates).toEqual([getAuthId(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'cfg-1')]);
259
+ expect(candidates).toHaveLength(1);
260
+ expect(candidates[0]).toMatch(/^oauth-code\.cfg-1/); // org-unique config id
261
+ expect(candidates.some((c) => c.startsWith('oauth-code.shared-client'))).toBe(false); // no cross-tenant client-id key
262
+ });
263
+
264
+ test('collapses to one candidate when config id equals client id', () => {
265
+ const authConfig = { clientId: 'same', tokenScope: TokenScope.USER, scope: 'read' };
266
+ const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, authConfig, 'int-1', 'same');
267
+ expect(candidates).toHaveLength(1);
268
+ });
269
+
270
+ test.each([IntegrationAuthType.OAUTH2_CLIENT_CREDS, IntegrationAuthType.OAUTH2_IMPLICIT])(
271
+ 'OAuth-split type %s (user-scoped) also probes the configId keying',
272
+ (authType) => {
273
+ const authConfig = { clientId: 'my-client', tokenScope: TokenScope.USER, scope: 'read' };
274
+ const candidates = getAuthIdCandidates(authType, authConfig, 'int-1', 'cfg-1');
275
+ expect(candidates).toHaveLength(2);
276
+ expect(candidates).toContain(
277
+ getAuthId(authType, { clientId: 'my-client', tokenScope: TokenScope.DATASOURCE, scope: 'read' }, 'int-1', 'cfg-1')
278
+ );
279
+ }
280
+ );
281
+
282
+ test.each([IntegrationAuthType.BASIC, IntegrationAuthType.FIREBASE, IntegrationAuthType.OAUTH2_PASSWORD])(
283
+ 'scope-invariant type %s returns a single candidate (tokenScope does not affect keying)',
284
+ (authType) => {
285
+ const authConfig = { clientId: 'my-client', apiKey: '{"projectId":"p"}', scope: 'read' };
286
+ const candidates = getAuthIdCandidates(authType, authConfig, 'int-1', 'cfg-1');
287
+ expect(candidates).toHaveLength(1);
288
+ expect(candidates[0]).toBe(getAuthId(authType, authConfig, 'int-1', 'cfg-1'));
289
+ }
290
+ );
291
+
292
+ test('scope-invariant auth type returns a single candidate (tokenScope does not affect keying)', () => {
293
+ const authConfig = {
294
+ subjectTokenSource: 'SUBJECT_TOKEN_SOURCE_STATIC_TOKEN',
295
+ subjectTokenSourceStaticToken: 't',
296
+ tokenUrl: 'https://idp.example.com/token',
297
+ scope: 'read',
298
+ audience: 'aud',
299
+ clientId: 'c'
300
+ };
301
+ const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, authConfig, 'int-1', 'cfg-1');
302
+ expect(candidates).toHaveLength(1);
303
+ expect(candidates[0]).toBe(getAuthId(IntegrationAuthType.OAUTH2_TOKEN_EXCHANGE, authConfig, 'int-1', 'cfg-1'));
304
+ });
305
+
306
+ test('undefined authConfig yields a single candidate', () => {
307
+ const candidates = getAuthIdCandidates(IntegrationAuthType.OAUTH2_CODE, undefined, 'int-1', 'cfg-1');
308
+ expect(candidates).toHaveLength(1);
309
+ });
310
+ });
311
+
232
312
  describe('getAuthId', () => {
233
313
  test('uses unknown-client for OAUTH2_IDP_TOKEN_PASSTHROUGH (no client identity)', () => {
234
314
  const authId = getAuthId(IntegrationAuthType.OAUTH2_IDP_TOKEN_PASSTHROUGH, {}, 'int-1', 'cfg-1');
@@ -60,6 +60,38 @@ export function getAuthId(
60
60
  return `${authType}.${getClientId(authType, authConfig, integrationId, integrationConfigurationId)}`;
61
61
  }
62
62
 
63
+ // A token minted while a config's tokenScope was 'datasource' keys on the org-unique configuration id;
64
+ // after the config is flipped to 'user' a fresh recompute keys on the client id instead and no longer
65
+ // matches, false-denying the legitimate owner. getAuthIdCandidates returns the config's current-scope
66
+ // authId PLUS the datasource (configuration-id) keying, so an ownership recompute recognizes a token
67
+ // across a post-mint datasource->user flip.
68
+ //
69
+ // It widens ONLY toward the datasource keying, which is org-unique (the configuration id is a per-config
70
+ // UUID). It deliberately does NOT synthesize the user (client-id) keying for a datasource-scoped config:
71
+ // the client-id key is shared across tenants (multiple orgs can share a clientId + scope), so adding it
72
+ // would let one org's config witness ownership of another org's client-id-keyed shared token. Recognizing
73
+ // the reverse user->datasource flip would require matching that shared key, so it is intentionally not
74
+ // done here; closing it safely needs org-scoped token storage, tracked separately. See SEC-73.
75
+ export function getAuthIdCandidates(
76
+ authType: AuthType | undefined,
77
+ authConfig: AuthConfig | undefined,
78
+ integrationId?: string,
79
+ integrationConfigurationId?: string
80
+ ): AuthId[] {
81
+ const current = getAuthId(authType, authConfig, integrationId, integrationConfigurationId);
82
+ const asDatasource = getAuthId(authType, withTokenScope(authConfig, TokenScope.DATASOURCE), integrationId, integrationConfigurationId);
83
+ return current === asDatasource ? [current] : [current, asDatasource];
84
+ }
85
+
86
+ // Return a shallow copy of authConfig with tokenScope overridden, without mutating the caller's object.
87
+ // undefined passes through so getAuthId falls back to its unknown-client key.
88
+ function withTokenScope(authConfig: AuthConfig | undefined, tokenScope: TokenScope): AuthConfig | undefined {
89
+ if (!authConfig) {
90
+ return authConfig;
91
+ }
92
+ return { ...authConfig, tokenScope } as AuthConfig;
93
+ }
94
+
63
95
  function getClientId(
64
96
  authType: AuthType | undefined,
65
97
  config: AuthConfig | undefined,
@@ -1,3 +1,5 @@
1
+ import type { ReviewFindingCategory, ReviewFindingConfidence, ReviewFindingSeverity } from '../reviewPolicy/index.js';
2
+
1
3
  /**
2
4
  * @deprecated Use the `superblocks.policy-gates.security-scans.enabled` LaunchDarkly flag instead.
3
5
  * Kept as `false` to match the fail-closed LD default; create and enable the
@@ -68,6 +70,25 @@ export type PolicyGateReadinessItem = {
68
70
  status: PolicyGateReadinessItemStatus;
69
71
  };
70
72
 
73
+ export function isHardBlockingPolicyItem(item: Pick<PolicyGateReadinessItem, 'mode' | 'status'>): boolean {
74
+ if (item.mode !== 'blocking' && item.mode !== 'approval') {
75
+ return false;
76
+ }
77
+ return item.status === 'blocked' || item.status === 'failed';
78
+ }
79
+
80
+ /**
81
+ * Whether every item is an outcome an acknowledged "Publish anyway" hold is
82
+ * allowed to bypass. `missing`/`stale`/`running`/`failed`/`approval_required`
83
+ * mean some gate has not finished or hard-blocks unconditionally; an ack
84
+ * granted while any of those exist could waive a review that never happened.
85
+ */
86
+ export function areReadinessItemsAckEligible(items: Pick<PolicyGateReadinessItem, 'status'>[]): boolean {
87
+ return (
88
+ items.length > 0 && items.every((item) => item.status === 'blocked' || item.status === 'passed' || item.status === 'advisory_findings')
89
+ );
90
+ }
91
+
71
92
  export type PolicyGateReadinessRun = {
72
93
  completedAt?: string;
73
94
  createdAt?: string;
@@ -122,3 +143,35 @@ export const POLICY_GATE_BLOCKED_ERROR = 'POLICY_GATE_BLOCKED';
122
143
  */
123
144
  export const POLICY_GATE_STALE_LIVE_EDIT_ERROR =
124
145
  'Policy gates can only run for the current app state. Deploy the current changes or restore to this commit and then deploy.';
146
+
147
+ export type ScanScopeChangeType = 'added' | 'deleted' | 'modified';
148
+
149
+ export type ScanScopeChangedFile = {
150
+ changeType: ScanScopeChangeType;
151
+ path: string;
152
+ };
153
+
154
+ export type ScanScopePriorFinding = {
155
+ blocking: boolean;
156
+ category: ReviewFindingCategory;
157
+ clarkRemediable?: boolean;
158
+ confidence?: ReviewFindingConfidence | null;
159
+ evidenceJson?: Record<string, unknown>;
160
+ fingerprint: string;
161
+ humanSummary?: string | null;
162
+ locations: unknown[];
163
+ remediationHintJson?: Record<string, unknown>;
164
+ severity: ReviewFindingSeverity;
165
+ technicalSummary?: string | null;
166
+ title: string;
167
+ };
168
+
169
+ export type ScanScopeJson = {
170
+ baseApplicationCommitId: string;
171
+ changedFiles: ScanScopeChangedFile[];
172
+ diffTruncated: boolean;
173
+ priorAiAgentRunId?: string;
174
+ priorFindings: ScanScopePriorFinding[];
175
+ priorReviewRunId?: string;
176
+ unifiedDiff: string;
177
+ };
@@ -1,3 +1,3 @@
1
- import { IApplicationV2Dto } from '../application/index.js';
1
+ import type { DatabaseRetirementEnqueueResult, IApplicationV2Dto } from '../application/index.js';
2
2
 
3
- export type DeleteApplicationResponseBody = IApplicationV2Dto;
3
+ export type DeleteApplicationResponseBody = IApplicationV2Dto & Partial<DatabaseRetirementEnqueueResult>;
@@ -6,6 +6,7 @@ export * from './configuration.js';
6
6
  export * from './env.js';
7
7
  export * from './environment.js';
8
8
  export * from './git-url.js';
9
+ export * from './launchDarkly.js';
9
10
  export * from './maskCredential.js';
10
11
  export * from './normalize.js';
11
12
  export * from './normalizeRoleName.js';
@@ -0,0 +1,25 @@
1
+ import { describe, test, expect } from 'vitest';
2
+
3
+ import { shouldSendLaunchDarklyEvents } from './launchDarkly.js';
4
+
5
+ describe('shouldSendLaunchDarklyEvents', () => {
6
+ test('sends events by default when unset', () => {
7
+ expect(shouldSendLaunchDarklyEvents(undefined)).toBe(true);
8
+ expect(shouldSendLaunchDarklyEvents(null)).toBe(true);
9
+ expect(shouldSendLaunchDarklyEvents('')).toBe(true);
10
+ });
11
+
12
+ test('only an explicit false disables events', () => {
13
+ expect(shouldSendLaunchDarklyEvents('false')).toBe(false);
14
+ expect(shouldSendLaunchDarklyEvents('FALSE')).toBe(false);
15
+ expect(shouldSendLaunchDarklyEvents(' false ')).toBe(false);
16
+ expect(shouldSendLaunchDarklyEvents(false)).toBe(false);
17
+ });
18
+
19
+ test('sends events for truthy and unrecognized values', () => {
20
+ expect(shouldSendLaunchDarklyEvents('true')).toBe(true);
21
+ expect(shouldSendLaunchDarklyEvents(true)).toBe(true);
22
+ expect(shouldSendLaunchDarklyEvents('0')).toBe(true);
23
+ expect(shouldSendLaunchDarklyEvents('no')).toBe(true);
24
+ });
25
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A LaunchDarkly send-events setting as it arrives from configuration: either an
3
+ * already-parsed boolean or the raw string form an env var carries.
4
+ */
5
+ export type LaunchDarklySendEventsSetting = boolean | string | undefined | null;
6
+
7
+ /**
8
+ * Analytics events drive LaunchDarkly's per-context billing: monthly active users are
9
+ * counted from the unique context keys LaunchDarkly receives events for. Throwaway
10
+ * contexts, such as the per-run organizations created by end-to-end tests, each bill as
11
+ * an additional monthly active user, so events should be off outside real deployments.
12
+ *
13
+ * Events are on by default and only an explicit 'false' turns them off, matching
14
+ * SUPERBLOCKS_SERVER_LAUNCH_DARKLY_SEND_EVENTS_ENABLED on the server-side SDK. Any
15
+ * unrecognized value keeps events on so a malformed setting cannot silently disable
16
+ * reporting for a real deployment.
17
+ */
18
+ export function shouldSendLaunchDarklyEvents(value: LaunchDarklySendEventsSetting): boolean {
19
+ if (typeof value === 'boolean') {
20
+ return value;
21
+ }
22
+ return value?.trim().toLowerCase() !== 'false';
23
+ }