@superblocksteam/shared 0.9598.1 → 0.9599.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 (56) hide show
  1. package/dist/database-lifecycle/index.d.ts +26 -2
  2. package/dist/database-lifecycle/index.d.ts.map +1 -1
  3. package/dist/database-lifecycle/index.js +122 -3
  4. package/dist/database-lifecycle/index.js.map +1 -1
  5. package/dist/database-lifecycle/index.test.js +90 -6
  6. package/dist/database-lifecycle/index.test.js.map +1 -1
  7. package/dist/plugins/templates/postgres.d.ts.map +1 -1
  8. package/dist/plugins/templates/postgres.js +164 -1
  9. package/dist/plugins/templates/postgres.js.map +1 -1
  10. package/dist/plugins/templates/postgres.test.d.ts +2 -0
  11. package/dist/plugins/templates/postgres.test.d.ts.map +1 -0
  12. package/dist/plugins/templates/postgres.test.js +224 -0
  13. package/dist/plugins/templates/postgres.test.js.map +1 -0
  14. package/dist/types/datasource/auth.d.ts +4 -0
  15. package/dist/types/datasource/auth.d.ts.map +1 -1
  16. package/dist/types/datasource/auth.js +6 -1
  17. package/dist/types/datasource/auth.js.map +1 -1
  18. package/dist/types/datasource/index.d.ts +104 -3
  19. package/dist/types/datasource/index.d.ts.map +1 -1
  20. package/dist/types/datasource/index.js.map +1 -1
  21. package/dist/types/deployment/deployQueueStatus.d.ts +3 -1
  22. package/dist/types/deployment/deployQueueStatus.d.ts.map +1 -1
  23. package/dist/types/deployment/deployQueueStatus.js +2 -0
  24. package/dist/types/deployment/deployQueueStatus.js.map +1 -1
  25. package/dist-esm/database-lifecycle/index.d.ts +26 -2
  26. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  27. package/dist-esm/database-lifecycle/index.js +117 -2
  28. package/dist-esm/database-lifecycle/index.js.map +1 -1
  29. package/dist-esm/database-lifecycle/index.test.js +91 -7
  30. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  31. package/dist-esm/plugins/templates/postgres.d.ts.map +1 -1
  32. package/dist-esm/plugins/templates/postgres.js +165 -2
  33. package/dist-esm/plugins/templates/postgres.js.map +1 -1
  34. package/dist-esm/plugins/templates/postgres.test.d.ts +2 -0
  35. package/dist-esm/plugins/templates/postgres.test.d.ts.map +1 -0
  36. package/dist-esm/plugins/templates/postgres.test.js +222 -0
  37. package/dist-esm/plugins/templates/postgres.test.js.map +1 -0
  38. package/dist-esm/types/datasource/auth.d.ts +4 -0
  39. package/dist-esm/types/datasource/auth.d.ts.map +1 -1
  40. package/dist-esm/types/datasource/auth.js +5 -0
  41. package/dist-esm/types/datasource/auth.js.map +1 -1
  42. package/dist-esm/types/datasource/index.d.ts +104 -3
  43. package/dist-esm/types/datasource/index.d.ts.map +1 -1
  44. package/dist-esm/types/datasource/index.js.map +1 -1
  45. package/dist-esm/types/deployment/deployQueueStatus.d.ts +3 -1
  46. package/dist-esm/types/deployment/deployQueueStatus.d.ts.map +1 -1
  47. package/dist-esm/types/deployment/deployQueueStatus.js +2 -0
  48. package/dist-esm/types/deployment/deployQueueStatus.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/database-lifecycle/index.test.ts +107 -6
  51. package/src/database-lifecycle/index.ts +153 -10
  52. package/src/plugins/templates/postgres.test.ts +231 -0
  53. package/src/plugins/templates/postgres.ts +166 -2
  54. package/src/types/datasource/auth.ts +5 -0
  55. package/src/types/datasource/index.ts +115 -3
  56. package/src/types/deployment/deployQueueStatus.ts +3 -1
@@ -3,10 +3,14 @@ import { describe, expect, test } from 'vitest';
3
3
  import {
4
4
  computeBindingKey,
5
5
  computeDesiredSpecHash,
6
+ computeNativeDbApplicationToken,
7
+ computeNativeDbDatabaseName,
6
8
  computeRequirementKey,
7
9
  containsCredentialMaterial,
8
10
  isSecretKey,
9
11
  isTerminalLifecycleState,
12
+ nativeDbIamSessionPolicy,
13
+ parseNativeDbIamDescriptor,
10
14
  redactLifecycleSecrets
11
15
  } from './index.js';
12
16
 
@@ -26,24 +30,25 @@ describe('database lifecycle contracts', () => {
26
30
  expect(computeRequirementKey(requirement)).toBe('orders-db~Orders%20DB:postgres');
27
31
  expect(computeRequirementKey({ logicalName: 'orders/db', engine: 'postgres' })).toBe('orders-db~orders%2Fdb:postgres');
28
32
  expect(computeRequirementKey({ logicalName: 'orders-db', engine: 'postgres' })).toBe('orders-db~orders-db:postgres');
33
+ // binding_key excludes the logical name on purpose: one database per
34
+ // (organization, application, environment, profile). Two different logical
35
+ // names under the same profile resolve to the same binding_key.
29
36
  expect(
30
37
  computeBindingKey({
31
38
  organizationId: 'org-1',
32
39
  applicationId: 'app-1',
33
40
  environment: 'edit',
34
- profile: 'Staging EU',
35
- requirementKey: 'orders-db~Orders%20DB:postgres'
41
+ profile: 'Staging EU'
36
42
  })
37
- ).toBe('org-1:app-1:edit:staging-eu~Staging%20EU:orders-db~Orders%20DB:postgres');
43
+ ).toBe('org-1:app-1:edit:staging-eu~Staging%20EU');
38
44
  expect(
39
45
  computeBindingKey({
40
46
  organizationId: 'org-1',
41
47
  applicationId: 'app-1',
42
48
  environment: 'edit',
43
- profile: 'staging',
44
- requirementKey: 'orders-db~Orders%20DB:postgres'
49
+ profile: 'staging'
45
50
  })
46
- ).toBe('org-1:app-1:edit:staging~staging:orders-db~Orders%20DB:postgres');
51
+ ).toBe('org-1:app-1:edit:staging~staging');
47
52
 
48
53
  await expect(computeDesiredSpecHash(requirement)).resolves.toBe(
49
54
  await computeDesiredSpecHash({ ...requirement, extensions: ['uuid-ossp', 'pgcrypto'] })
@@ -51,6 +56,25 @@ describe('database lifecycle contracts', () => {
51
56
  await expect(computeDesiredSpecHash(requirement)).resolves.toBe('QsCl6RVzJVEwf4Nuk0D27rRtycYe2vQZMxcXLt9WUkQ=');
52
57
  });
53
58
 
59
+ test('ignores migrationDirectory when hashing the desired spec', async () => {
60
+ const infrastructureSpec = {
61
+ logicalName: 'Orders DB',
62
+ engine: 'postgres',
63
+ version: '16',
64
+ sizing: { storageGb: 20, tier: 'shared' },
65
+ extensions: ['pgcrypto', 'uuid-ossp']
66
+ } as const;
67
+
68
+ const withoutDirectory = await computeDesiredSpecHash(infrastructureSpec);
69
+ const withDirectory = await computeDesiredSpecHash({ ...infrastructureSpec, migrationDirectory: 'db/migrations' });
70
+ const withOtherDirectory = await computeDesiredSpecHash({ ...infrastructureSpec, migrationDirectory: 'db/other-migrations' });
71
+
72
+ // migrationDirectory only says where the SQL files live, not what
73
+ // infrastructure to provision, so changing it must not look like drift.
74
+ expect(withDirectory).toBe(withoutDirectory);
75
+ expect(withOtherDirectory).toBe(withoutDirectory);
76
+ });
77
+
54
78
  test('hashes desired specs with locale-invariant ordering', async () => {
55
79
  const first = await computeDesiredSpecHash({
56
80
  logicalName: 'Éclair DB',
@@ -129,4 +153,81 @@ describe('database lifecycle contracts', () => {
129
153
  expect(containsCredentialMaterial('host=db.internal port=5432')).toBe(false);
130
154
  expect(containsCredentialMaterial('arn:aws:secretsmanager:us-east-1:123:secret:orders')).toBe(false);
131
155
  });
156
+
157
+ test('builds a canonical session policy from a valid managed Postgres IAM descriptor', () => {
158
+ const descriptor = parseNativeDbIamDescriptor({
159
+ application_id: 'application-id',
160
+ auth_descriptor_version: 1,
161
+ auth_mode: 'aws_iam_role',
162
+ aws_account_id: '123456789012',
163
+ binding_id: 'binding-id',
164
+ cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
165
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
166
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
167
+ host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
168
+ port: 5432,
169
+ region: 'us-west-2',
170
+ username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
171
+ });
172
+
173
+ expect(descriptor).toBeDefined();
174
+ if (!descriptor) {
175
+ throw new Error('Expected a valid IAM descriptor');
176
+ }
177
+ expect(nativeDbIamSessionPolicy(descriptor)).toBe(
178
+ '{"Version":"2012-10-17","Statement":[{"Sid":"ConnectToThisNativeDatabaseUser","Effect":"Allow","Action":"rds-db:connect","Resource":"arn:aws:rds-db:us-west-2:123456789012:dbuser:cluster-ABC123DEF456EXAMPLE/sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime"}]}'
179
+ );
180
+ });
181
+
182
+ test('rejects malformed or non-commercial managed Postgres IAM descriptors', () => {
183
+ const descriptor = {
184
+ application_id: 'application-id',
185
+ auth_descriptor_version: 1,
186
+ auth_mode: 'aws_iam_role',
187
+ aws_account_id: '123456789012',
188
+ binding_id: 'binding-id',
189
+ cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
190
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
191
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
192
+ host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
193
+ port: 5432,
194
+ region: 'us-west-2',
195
+ username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
196
+ };
197
+
198
+ expect(parseNativeDbIamDescriptor({ ...descriptor, auth_descriptor_version: 2 })).toBeUndefined();
199
+ expect(parseNativeDbIamDescriptor({ ...descriptor, cluster_resource_id: 'not-an-rds-resource' })).toBeUndefined();
200
+ expect(parseNativeDbIamDescriptor({ ...descriptor, connector_role_arn: 'arn:aws:iam::210987654321:role/other' })).toBeUndefined();
201
+ expect(parseNativeDbIamDescriptor({ ...descriptor, database: 'orders' })).toBeUndefined();
202
+ expect(parseNativeDbIamDescriptor({ ...descriptor, host: descriptor.host.toUpperCase() })).toBeUndefined();
203
+ expect(parseNativeDbIamDescriptor({ ...descriptor, host: 'native-db.cluster-example.us-east-1.rds.amazonaws.com' })).toBeUndefined();
204
+ expect(parseNativeDbIamDescriptor({ ...descriptor, port: 0 })).toBeUndefined();
205
+ expect(parseNativeDbIamDescriptor({ ...descriptor, region: 'cn-north-1' })).toBeUndefined();
206
+ expect(parseNativeDbIamDescriptor({ ...descriptor, region: 'us-gov-west-1' })).toBeUndefined();
207
+ expect(parseNativeDbIamDescriptor({ ...descriptor, username: 'sbndb_000000000000_7bfeae1170db923274f1943f_runtime' })).toBeUndefined();
208
+ });
209
+
210
+ test('accepts standalone RDS IAM resource IDs', () => {
211
+ expect(
212
+ parseNativeDbIamDescriptor({
213
+ application_id: 'application-id',
214
+ auth_descriptor_version: 1,
215
+ auth_mode: 'aws_iam_role',
216
+ aws_account_id: '123456789012',
217
+ binding_id: 'binding-id',
218
+ cluster_resource_id: 'db-ABCDEF0123456789-1',
219
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
220
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
221
+ host: 'native-db.example.us-west-2.rds.amazonaws.com',
222
+ port: 5432,
223
+ region: 'us-west-2',
224
+ username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
225
+ })
226
+ ).toBeDefined();
227
+ });
228
+
229
+ test('derives canonical IAM database and runtime-user identity tokens', async () => {
230
+ await expect(computeNativeDbApplicationToken('application-id')).resolves.toBe('7bfeae1170db923274f1943f');
231
+ await expect(computeNativeDbDatabaseName('abcdef123456', 'database-id')).resolves.toBe('sbndb_abcdef123456_dad72612f7a80580b028178f');
232
+ });
132
233
  });
@@ -16,6 +16,11 @@ export const LIFECYCLE_OPERATIONS = ['ensure_database', 'migrate_schema', 'retir
16
16
  export const LIFECYCLE_ENVIRONMENTS = ['edit', 'preview', 'deployed'] as const;
17
17
  export const DATABASE_ENGINES = ['postgres', 'snowflake', 'snowflake_postgres', 'lakebase'] as const;
18
18
  export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle';
19
+ export const NATIVE_DB_AUTH_DESCRIPTOR_VERSION = 1 as const;
20
+ export const NATIVE_DB_CONNECTOR_ROLE_NAME_PREFIX = 'superblocks-native-db-connector';
21
+ export const NATIVE_DB_IAM_AUTH_MODE = 'aws_iam_role' as const;
22
+ export const NATIVE_DB_IDENTIFIER_HASH_DOMAIN = 'superblocks-native-db:v1';
23
+ export const DATABASE_LIFECYCLE_CAPABILITY_MANAGED_IAM_V1 = 'managed-IAM-v1';
19
24
 
20
25
  // Agent capability tag keys. A lifecycle worker publishes these in the
21
26
  // `tags` map of its agent registration (merged into — never replacing — the
@@ -28,6 +33,7 @@ export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle';
28
33
  export const DATABASE_LIFECYCLE_TAG_OPERATIONS = 'databaseLifecycle:operations';
29
34
  export const DATABASE_LIFECYCLE_TAG_ENGINES = 'databaseLifecycle:engines';
30
35
  export const DATABASE_LIFECYCLE_TAG_ENVIRONMENT_PROFILES = 'databaseLifecycle:environmentProfiles';
36
+ export const DATABASE_LIFECYCLE_TAG_CAPABILITIES = 'databaseLifecycle:capabilities';
31
37
 
32
38
  export type LifecycleEnvironment = (typeof LIFECYCLE_ENVIRONMENTS)[number];
33
39
  export type DatabaseEngine = (typeof DATABASE_ENGINES)[number];
@@ -60,6 +66,21 @@ export type CredentialRef = {
60
66
  field?: string;
61
67
  };
62
68
 
69
+ export type NativeDbIamDescriptor = {
70
+ application_id: string;
71
+ auth_descriptor_version: typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
72
+ auth_mode: typeof NATIVE_DB_IAM_AUTH_MODE;
73
+ aws_account_id: string;
74
+ binding_id: string;
75
+ cluster_resource_id: string;
76
+ connector_role_arn: string;
77
+ database: string;
78
+ host: string;
79
+ port: number;
80
+ region: string;
81
+ username: string;
82
+ };
83
+
63
84
  export type DatabaseRequirement = {
64
85
  logicalName: string;
65
86
  engine: DatabaseEngine;
@@ -177,6 +198,8 @@ export type LifecycleDispatchContinuation = {
177
198
  // worker's `MigrationState` at the default "pending"; an empty slice means
178
199
  // "vacuous truth, mark migrated"; a non-empty slice triggers the runner.
179
200
  export type LifecycleDispatchPayload = {
201
+ applicationId: string;
202
+ bindingId: string;
180
203
  bindingKey: string;
181
204
  connectionMetadata?: Record<string, string | number | boolean>;
182
205
  continuation?: LifecycleDispatchContinuation;
@@ -196,12 +219,18 @@ export function computeRequirementKey(requirement: Pick<DatabaseRequirement, 'lo
196
219
  return `${slugify(requirement.logicalName)}~${encodeURIComponent(requirement.logicalName)}:${requirement.engine}`;
197
220
  }
198
221
 
222
+ // binding_key is the product identity of a database in the control plane, and
223
+ // is deliberately scoped to (organization, application, environment, profile)
224
+ // only — NOT the logical name. This is what makes "one database per profile"
225
+ // enforceable by the plain UNIQUE (binding_key) constraint: asking for another
226
+ // database under a different logical name resolves to the same binding_key
227
+ // instead of minting a new one. The logical name still lives on the binding row
228
+ // and in resource_key (physical identity); it just doesn't grant a second slot.
199
229
  export function computeBindingKey(input: {
200
230
  organizationId: string;
201
231
  applicationId: string;
202
232
  environment: LifecycleEnvironment;
203
233
  profile: string;
204
- requirementKey: string;
205
234
  }): string {
206
235
  return [input.organizationId, ...bindingKeySegments(input)].join(':');
207
236
  }
@@ -210,22 +239,114 @@ export function computeLegacyBindingKeyWithoutOrganization(input: {
210
239
  applicationId: string;
211
240
  environment: LifecycleEnvironment;
212
241
  profile: string;
213
- requirementKey: string;
214
242
  }): string {
215
243
  return bindingKeySegments(input).join(':');
216
244
  }
217
245
 
218
- function bindingKeySegments(input: {
219
- applicationId: string;
220
- environment: LifecycleEnvironment;
221
- profile: string;
222
- requirementKey: string;
223
- }): string[] {
224
- return [input.applicationId, input.environment, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`, input.requirementKey];
246
+ function bindingKeySegments(input: { applicationId: string; environment: LifecycleEnvironment; profile: string }): string[] {
247
+ return [input.applicationId, input.environment, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`];
225
248
  }
226
249
 
250
+ // Hashes only the infrastructure spec. migrationDirectory is deliberately
251
+ // excluded: it says where the SQL files live, not what database to provision,
252
+ // so changing it must not make a ready binding look drifted and re-dispatch
253
+ // ensure_database. Callers that persist the requirement must still rewrite
254
+ // desiredSpec when migrationDirectory changes (same hash is not "no-op").
255
+ // Specs without a migrationDirectory keep their existing hash.
227
256
  export async function computeDesiredSpecHash(requirement: DatabaseRequirement): Promise<string> {
228
- return await sha256Base64(JSON.stringify(canonicalize(requirement)));
257
+ const infrastructureSpec = { ...requirement };
258
+ delete infrastructureSpec.migrationDirectory;
259
+ return await sha256Base64(JSON.stringify(canonicalize(infrastructureSpec)));
260
+ }
261
+
262
+ export function nativeDbIamSessionPolicy(descriptor: NativeDbIamDescriptor): string {
263
+ return JSON.stringify({
264
+ Version: '2012-10-17',
265
+ Statement: [
266
+ {
267
+ Sid: 'ConnectToThisNativeDatabaseUser',
268
+ Effect: 'Allow',
269
+ Action: 'rds-db:connect',
270
+ Resource: `arn:aws:rds-db:${descriptor.region}:${descriptor.aws_account_id}:dbuser:${descriptor.cluster_resource_id}/${descriptor.username}`
271
+ }
272
+ ]
273
+ });
274
+ }
275
+
276
+ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescriptor | undefined {
277
+ if (!isUnknownRecord(value)) {
278
+ return undefined;
279
+ }
280
+
281
+ const databaseIdentifier = typeof value.database === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})$/.exec(value.database) : null;
282
+ const usernameIdentifier =
283
+ typeof value.username === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})_runtime$/.exec(value.username) : null;
284
+ const connectorRoleArn =
285
+ typeof value.connector_role_arn === 'string'
286
+ ? /^arn:aws:iam::(\d{12}):role\/(?:[\w+=,.@-]+\/)*[\w+=,.@-]+$/.exec(value.connector_role_arn)
287
+ : null;
288
+ if (
289
+ !isNonEmptyString(value.application_id) ||
290
+ value.auth_descriptor_version !== NATIVE_DB_AUTH_DESCRIPTOR_VERSION ||
291
+ value.auth_mode !== NATIVE_DB_IAM_AUTH_MODE ||
292
+ !isNonEmptyString(value.aws_account_id) ||
293
+ !/^\d{12}$/.test(value.aws_account_id) ||
294
+ !isNonEmptyString(value.binding_id) ||
295
+ !isNonEmptyString(value.cluster_resource_id) ||
296
+ !/^(cluster|db)-[A-Za-z0-9-]+$/.test(value.cluster_resource_id) ||
297
+ !connectorRoleArn ||
298
+ connectorRoleArn[1] !== value.aws_account_id ||
299
+ !databaseIdentifier ||
300
+ !usernameIdentifier ||
301
+ databaseIdentifier[1] !== usernameIdentifier[1] ||
302
+ !isValidNativeDbRdsHostname(value.host, value.region) ||
303
+ typeof value.port !== 'number' ||
304
+ !Number.isInteger(value.port) ||
305
+ value.port < 1 ||
306
+ value.port > 65535 ||
307
+ !isCommercialAwsRegion(value.region)
308
+ ) {
309
+ return undefined;
310
+ }
311
+
312
+ return {
313
+ application_id: value.application_id,
314
+ auth_descriptor_version: NATIVE_DB_AUTH_DESCRIPTOR_VERSION,
315
+ auth_mode: NATIVE_DB_IAM_AUTH_MODE,
316
+ aws_account_id: value.aws_account_id,
317
+ binding_id: value.binding_id,
318
+ cluster_resource_id: value.cluster_resource_id,
319
+ connector_role_arn: connectorRoleArn[0],
320
+ database: databaseIdentifier[0],
321
+ host: value.host,
322
+ port: value.port,
323
+ region: value.region,
324
+ username: usernameIdentifier[0]
325
+ };
326
+ }
327
+
328
+ export async function computeNativeDbApplicationToken(trustedApplicationId: string): Promise<string> {
329
+ return (await computeNativeDbIdentifierToken('application', trustedApplicationId)).slice(0, 24);
330
+ }
331
+
332
+ export async function computeNativeDbDatabaseName(deploymentToken: string, trustedDatabaseId: string): Promise<string> {
333
+ if (!/^[0-9a-f]{12}$/.test(deploymentToken)) {
334
+ throw new Error('Native database deployment token must be 12 lowercase hexadecimal characters');
335
+ }
336
+ return `sbndb_${deploymentToken}_${(await computeNativeDbIdentifierToken('database', trustedDatabaseId)).slice(0, 24)}`;
337
+ }
338
+
339
+ async function computeNativeDbIdentifierToken(kind: 'application' | 'database', trustedId: string): Promise<string> {
340
+ if (!isNonEmptyString(trustedId)) {
341
+ throw new Error(`Trusted native database ${kind} ID must be a non-empty string`);
342
+ }
343
+ const digest = await crypto.subtle.digest(
344
+ 'SHA-256',
345
+ new TextEncoder().encode(`${NATIVE_DB_IDENTIFIER_HASH_DOMAIN}:${kind}:${trustedId}`)
346
+ );
347
+ return Array.from(new Uint8Array(digest))
348
+ .map((byte) => byte.toString(16).padStart(2, '0'))
349
+ .join('');
229
350
  }
230
351
 
231
352
  // resource_key identifies the physical resource a binding maps to in
@@ -292,6 +413,28 @@ function codepointCompare(left: string, right: string): number {
292
413
  return 0;
293
414
  }
294
415
 
416
+ function isCommercialAwsRegion(value: unknown): value is string {
417
+ return typeof value === 'string' && /^[a-z]{2}-[a-z]+-[0-9]+$/.test(value) && !value.startsWith('cn-') && !value.startsWith('us-gov-');
418
+ }
419
+
420
+ function isNonEmptyString(value: unknown): value is string {
421
+ return typeof value === 'string' && value.trim().length > 0;
422
+ }
423
+
424
+ function isUnknownRecord(value: unknown): value is Record<string, unknown> {
425
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
426
+ }
427
+
428
+ function isValidNativeDbRdsHostname(host: unknown, region: unknown): host is string {
429
+ if (typeof host !== 'string' || typeof region !== 'string') {
430
+ return false;
431
+ }
432
+ const suffix = `.${region}.rds.amazonaws.com`;
433
+ const prefix = host.endsWith(suffix) ? host.slice(0, -suffix.length) : '';
434
+ const validLabel = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
435
+ return host.length <= 253 && host === host.toLowerCase() && prefix !== '' && host.split('.').every((label) => validLabel.test(label));
436
+ }
437
+
295
438
  function redact(value: unknown, preserveCredentialRefs = false): unknown {
296
439
  if (Array.isArray(value)) {
297
440
  return value.map((entry) => redact(entry, preserveCredentialRefs));
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { FormComponentType, getRowItemsFromSectionItem } from '../../types/index.js';
4
+ import { PostgresPlugin } from './postgres.js';
5
+
6
+ const datasourceItems = PostgresPlugin.datasourceTemplate?.sections.flatMap((section) => section.items.flatMap(getRowItemsFromSectionItem));
7
+
8
+ const findDatasourceItems = (name: string) => datasourceItems?.filter((item) => item.name === name) ?? [];
9
+
10
+ describe('Postgres datasource template', () => {
11
+ it('gates IAM fields only by Postgres plugin version 0.0.13', () => {
12
+ const iamFieldNames = [
13
+ 'authentication.authType',
14
+ 'authentication.custom.databaseName.value',
15
+ 'authentication.custom.iamRoleArn.value',
16
+ 'authentication.custom.region.value',
17
+ 'authentication.custom.sessionPolicy.value',
18
+ 'authentication.username',
19
+ 'connection.useSsl'
20
+ ];
21
+
22
+ for (const fieldName of iamFieldNames) {
23
+ const versionThirteenItems = findDatasourceItems(fieldName).filter((item) => item.startVersion === '0.0.13');
24
+ expect(versionThirteenItems.length).toBeGreaterThan(0);
25
+ expect(versionThirteenItems.every((item) => item.ldFlag === undefined)).toBe(true);
26
+ }
27
+ });
28
+
29
+ it('adds the version 0.0.13 authentication method and IAM role fields', () => {
30
+ expect(findDatasourceItems('authentication.authType')).toEqual([
31
+ expect.objectContaining({
32
+ componentType: FormComponentType.DROPDOWN,
33
+ display: {
34
+ show: {
35
+ connectionType: ['fields']
36
+ }
37
+ },
38
+ initialValue: 'password',
39
+ options: [
40
+ {
41
+ displayName: 'Username & password',
42
+ key: 'password',
43
+ value: 'password'
44
+ },
45
+ {
46
+ displayName: 'AWS IAM Role',
47
+ key: 'aws_iam_role',
48
+ value: 'aws_iam_role'
49
+ }
50
+ ],
51
+ startVersion: '0.0.13'
52
+ })
53
+ ]);
54
+
55
+ expect(findDatasourceItems('authentication.custom.iamRoleArn.value')).toEqual([
56
+ expect.objectContaining({
57
+ display: {
58
+ show: {
59
+ 'authentication.authType': ['aws_iam_role'],
60
+ connectionType: ['fields']
61
+ }
62
+ },
63
+ rules: [{ message: 'Role ARN is required', required: true }],
64
+ startVersion: '0.0.13'
65
+ })
66
+ ]);
67
+ expect(findDatasourceItems('authentication.custom.region.value')).toEqual([
68
+ expect.objectContaining({
69
+ display: {
70
+ show: {
71
+ 'authentication.authType': ['aws_iam_role'],
72
+ connectionType: ['fields']
73
+ }
74
+ },
75
+ rules: [{ message: 'Region is required', required: true }],
76
+ startVersion: '0.0.13'
77
+ })
78
+ ]);
79
+ expect(findDatasourceItems('authentication.custom.sessionPolicy.value')).toEqual([
80
+ expect.objectContaining({
81
+ display: {
82
+ show: {
83
+ 'authentication.authType': ['aws_iam_role'],
84
+ connectionType: ['fields']
85
+ }
86
+ },
87
+ rules: [{ required: false }],
88
+ startVersion: '0.0.13'
89
+ })
90
+ ]);
91
+ });
92
+
93
+ it('keeps username visible and password compatible with legacy configurations', () => {
94
+ expect(findDatasourceItems('authentication.username')).toEqual([
95
+ expect.objectContaining({
96
+ endVersion: '0.0.10',
97
+ startVersion: '0.0.1'
98
+ }),
99
+ expect.objectContaining({
100
+ display: {
101
+ show: {
102
+ connectionType: ['fields']
103
+ }
104
+ },
105
+ endVersion: '0.0.12',
106
+ startVersion: '0.0.11'
107
+ }),
108
+ expect.objectContaining({
109
+ display: {
110
+ show: {
111
+ 'authentication.authType': ['', 'undefined', 'password'],
112
+ connectionType: ['fields']
113
+ }
114
+ },
115
+ startVersion: '0.0.13'
116
+ }),
117
+ expect.objectContaining({
118
+ display: {
119
+ show: {
120
+ 'authentication.authType': ['aws_iam_role'],
121
+ connectionType: ['fields']
122
+ }
123
+ },
124
+ rules: [{ message: 'Database username is required', required: true }],
125
+ startVersion: '0.0.13'
126
+ })
127
+ ]);
128
+ expect(findDatasourceItems('authentication.password')).toEqual([
129
+ expect.objectContaining({
130
+ endVersion: '0.0.10',
131
+ startVersion: '0.0.1'
132
+ }),
133
+ expect.objectContaining({
134
+ display: {
135
+ show: {
136
+ connectionType: ['fields']
137
+ }
138
+ },
139
+ endVersion: '0.0.12',
140
+ startVersion: '0.0.11'
141
+ }),
142
+ expect.objectContaining({
143
+ display: {
144
+ show: {
145
+ 'authentication.authType': ['', 'undefined', 'password'],
146
+ connectionType: ['fields']
147
+ }
148
+ },
149
+ startVersion: '0.0.13'
150
+ })
151
+ ]);
152
+ });
153
+
154
+ it('requires a database name only for IAM role authentication', () => {
155
+ expect(findDatasourceItems('authentication.custom.databaseName.value')).toEqual([
156
+ expect.objectContaining({
157
+ endVersion: '0.0.10',
158
+ startVersion: '0.0.1'
159
+ }),
160
+ expect.objectContaining({
161
+ display: {
162
+ show: {
163
+ connectionType: ['fields']
164
+ }
165
+ },
166
+ endVersion: '0.0.12',
167
+ startVersion: '0.0.11'
168
+ }),
169
+ expect.objectContaining({
170
+ display: {
171
+ show: {
172
+ 'authentication.authType': ['', 'undefined', 'password'],
173
+ connectionType: ['fields']
174
+ }
175
+ },
176
+ startVersion: '0.0.13'
177
+ }),
178
+ expect.objectContaining({
179
+ display: {
180
+ show: {
181
+ 'authentication.authType': ['aws_iam_role'],
182
+ connectionType: ['fields']
183
+ }
184
+ },
185
+ rules: [{ message: 'Database name is required', required: true }],
186
+ startVersion: '0.0.13'
187
+ })
188
+ ]);
189
+ });
190
+
191
+ it('requires TLS for IAM role authentication without changing password mode', () => {
192
+ expect(findDatasourceItems('connection.useSsl')).toEqual([
193
+ expect.objectContaining({
194
+ endVersion: '0.0.10',
195
+ startVersion: '0.0.1'
196
+ }),
197
+ expect.objectContaining({
198
+ display: {
199
+ show: {
200
+ connectionType: ['fields']
201
+ }
202
+ },
203
+ endVersion: '0.0.12',
204
+ startVersion: '0.0.11'
205
+ }),
206
+ expect.objectContaining({
207
+ disabled: false,
208
+ display: {
209
+ show: {
210
+ 'authentication.authType': ['', 'undefined', 'password'],
211
+ connectionType: ['fields']
212
+ }
213
+ },
214
+ initialValue: true,
215
+ startVersion: '0.0.13'
216
+ }),
217
+ expect.objectContaining({
218
+ disabled: true,
219
+ display: {
220
+ show: {
221
+ 'authentication.authType': ['aws_iam_role'],
222
+ connectionType: ['fields']
223
+ }
224
+ },
225
+ initialValue: true,
226
+ rules: [{ enum: [true], message: 'SSL is required for AWS IAM role authentication' }],
227
+ startVersion: '0.0.13'
228
+ })
229
+ ]);
230
+ });
231
+ });