@superblocksteam/shared 0.9603.2 → 0.9604.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 (136) hide show
  1. package/dist/database-lifecycle/capacityPolicy.d.ts +51 -0
  2. package/dist/database-lifecycle/capacityPolicy.d.ts.map +1 -0
  3. package/dist/database-lifecycle/capacityPolicy.js +159 -0
  4. package/dist/database-lifecycle/capacityPolicy.js.map +1 -0
  5. package/dist/database-lifecycle/capacityPolicy.test.d.ts +2 -0
  6. package/dist/database-lifecycle/capacityPolicy.test.d.ts.map +1 -0
  7. package/dist/database-lifecycle/capacityPolicy.test.js +159 -0
  8. package/dist/database-lifecycle/capacityPolicy.test.js.map +1 -0
  9. package/dist/database-lifecycle/edit-lifecycle-capability.d.ts +18 -0
  10. package/dist/database-lifecycle/edit-lifecycle-capability.d.ts.map +1 -0
  11. package/dist/database-lifecycle/edit-lifecycle-capability.js +32 -0
  12. package/dist/database-lifecycle/edit-lifecycle-capability.js.map +1 -0
  13. package/dist/database-lifecycle/edit-lifecycle-capability.test.d.ts +2 -0
  14. package/dist/database-lifecycle/edit-lifecycle-capability.test.d.ts.map +1 -0
  15. package/dist/database-lifecycle/edit-lifecycle-capability.test.js +91 -0
  16. package/dist/database-lifecycle/edit-lifecycle-capability.test.js.map +1 -0
  17. package/dist/database-lifecycle/index.d.ts +50 -27
  18. package/dist/database-lifecycle/index.d.ts.map +1 -1
  19. package/dist/database-lifecycle/index.js +78 -54
  20. package/dist/database-lifecycle/index.js.map +1 -1
  21. package/dist/database-lifecycle/index.test.js +120 -77
  22. package/dist/database-lifecycle/index.test.js.map +1 -1
  23. package/dist/database-lifecycle/physicalPoolIdentity.d.ts +29 -0
  24. package/dist/database-lifecycle/physicalPoolIdentity.d.ts.map +1 -0
  25. package/dist/database-lifecycle/physicalPoolIdentity.js +64 -0
  26. package/dist/database-lifecycle/physicalPoolIdentity.js.map +1 -0
  27. package/dist/database-lifecycle/physicalPoolIdentity.test.d.ts +2 -0
  28. package/dist/database-lifecycle/physicalPoolIdentity.test.d.ts.map +1 -0
  29. package/dist/database-lifecycle/physicalPoolIdentity.test.js +51 -0
  30. package/dist/database-lifecycle/physicalPoolIdentity.test.js.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +1 -0
  34. package/dist/index.js.map +1 -1
  35. package/dist/types/audit/ocsf.d.ts +60 -6
  36. package/dist/types/audit/ocsf.d.ts.map +1 -1
  37. package/dist/types/audit/ocsf.js +12 -1
  38. package/dist/types/audit/ocsf.js.map +1 -1
  39. package/dist/types/deployment/deployQueueStatus.d.ts +13 -0
  40. package/dist/types/deployment/deployQueueStatus.d.ts.map +1 -1
  41. package/dist/types/deployment/index.d.ts +1 -1
  42. package/dist/types/deployment/index.d.ts.map +1 -1
  43. package/dist/types/deployment/index.js.map +1 -1
  44. package/dist/types/policyGate/index.d.ts +5 -0
  45. package/dist/types/policyGate/index.d.ts.map +1 -1
  46. package/dist/types/policyGate/index.js +5 -1
  47. package/dist/types/policyGate/index.js.map +1 -1
  48. package/dist/types/publishPipeline/plan.js +1 -1
  49. package/dist/types/publishPipeline/plan.js.map +1 -1
  50. package/dist/types/publishPipeline/plan.test.js +3 -3
  51. package/dist/types/publishPipeline/plan.test.js.map +1 -1
  52. package/dist/types/rbac/index.d.ts +2 -0
  53. package/dist/types/rbac/index.d.ts.map +1 -1
  54. package/dist/types/rbac/index.js +3 -1
  55. package/dist/types/rbac/index.js.map +1 -1
  56. package/dist/types/reviewPolicy/index.d.ts +30 -4
  57. package/dist/types/reviewPolicy/index.d.ts.map +1 -1
  58. package/dist/types/reviewPolicy/index.js +8 -1
  59. package/dist/types/reviewPolicy/index.js.map +1 -1
  60. package/dist-esm/database-lifecycle/capacityPolicy.d.ts +51 -0
  61. package/dist-esm/database-lifecycle/capacityPolicy.d.ts.map +1 -0
  62. package/dist-esm/database-lifecycle/capacityPolicy.js +150 -0
  63. package/dist-esm/database-lifecycle/capacityPolicy.js.map +1 -0
  64. package/dist-esm/database-lifecycle/capacityPolicy.test.d.ts +2 -0
  65. package/dist-esm/database-lifecycle/capacityPolicy.test.d.ts.map +1 -0
  66. package/dist-esm/database-lifecycle/capacityPolicy.test.js +157 -0
  67. package/dist-esm/database-lifecycle/capacityPolicy.test.js.map +1 -0
  68. package/dist-esm/database-lifecycle/edit-lifecycle-capability.d.ts +18 -0
  69. package/dist-esm/database-lifecycle/edit-lifecycle-capability.d.ts.map +1 -0
  70. package/dist-esm/database-lifecycle/edit-lifecycle-capability.js +29 -0
  71. package/dist-esm/database-lifecycle/edit-lifecycle-capability.js.map +1 -0
  72. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.d.ts +2 -0
  73. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.d.ts.map +1 -0
  74. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.js +89 -0
  75. package/dist-esm/database-lifecycle/edit-lifecycle-capability.test.js.map +1 -0
  76. package/dist-esm/database-lifecycle/index.d.ts +50 -27
  77. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  78. package/dist-esm/database-lifecycle/index.js +62 -51
  79. package/dist-esm/database-lifecycle/index.js.map +1 -1
  80. package/dist-esm/database-lifecycle/index.test.js +90 -47
  81. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  82. package/dist-esm/database-lifecycle/physicalPoolIdentity.d.ts +29 -0
  83. package/dist-esm/database-lifecycle/physicalPoolIdentity.d.ts.map +1 -0
  84. package/dist-esm/database-lifecycle/physicalPoolIdentity.js +59 -0
  85. package/dist-esm/database-lifecycle/physicalPoolIdentity.js.map +1 -0
  86. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.d.ts +2 -0
  87. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.d.ts.map +1 -0
  88. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.js +49 -0
  89. package/dist-esm/database-lifecycle/physicalPoolIdentity.test.js.map +1 -0
  90. package/dist-esm/index.d.ts +1 -0
  91. package/dist-esm/index.d.ts.map +1 -1
  92. package/dist-esm/index.js +1 -0
  93. package/dist-esm/index.js.map +1 -1
  94. package/dist-esm/types/audit/ocsf.d.ts +60 -6
  95. package/dist-esm/types/audit/ocsf.d.ts.map +1 -1
  96. package/dist-esm/types/audit/ocsf.js +12 -1
  97. package/dist-esm/types/audit/ocsf.js.map +1 -1
  98. package/dist-esm/types/deployment/deployQueueStatus.d.ts +13 -0
  99. package/dist-esm/types/deployment/deployQueueStatus.d.ts.map +1 -1
  100. package/dist-esm/types/deployment/index.d.ts +1 -1
  101. package/dist-esm/types/deployment/index.d.ts.map +1 -1
  102. package/dist-esm/types/deployment/index.js.map +1 -1
  103. package/dist-esm/types/policyGate/index.d.ts +5 -0
  104. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  105. package/dist-esm/types/policyGate/index.js +5 -1
  106. package/dist-esm/types/policyGate/index.js.map +1 -1
  107. package/dist-esm/types/publishPipeline/plan.js +1 -1
  108. package/dist-esm/types/publishPipeline/plan.js.map +1 -1
  109. package/dist-esm/types/publishPipeline/plan.test.js +3 -3
  110. package/dist-esm/types/publishPipeline/plan.test.js.map +1 -1
  111. package/dist-esm/types/rbac/index.d.ts +2 -0
  112. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  113. package/dist-esm/types/rbac/index.js +3 -1
  114. package/dist-esm/types/rbac/index.js.map +1 -1
  115. package/dist-esm/types/reviewPolicy/index.d.ts +30 -4
  116. package/dist-esm/types/reviewPolicy/index.d.ts.map +1 -1
  117. package/dist-esm/types/reviewPolicy/index.js +7 -0
  118. package/dist-esm/types/reviewPolicy/index.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/database-lifecycle/capacityPolicy.test.ts +209 -0
  121. package/src/database-lifecycle/capacityPolicy.ts +197 -0
  122. package/src/database-lifecycle/edit-lifecycle-capability.test.ts +116 -0
  123. package/src/database-lifecycle/edit-lifecycle-capability.ts +34 -0
  124. package/src/database-lifecycle/index.test.ts +105 -48
  125. package/src/database-lifecycle/index.ts +157 -90
  126. package/src/database-lifecycle/physicalPoolIdentity.test.ts +64 -0
  127. package/src/database-lifecycle/physicalPoolIdentity.ts +75 -0
  128. package/src/index.ts +1 -0
  129. package/src/types/audit/ocsf.ts +73 -7
  130. package/src/types/deployment/deployQueueStatus.ts +14 -0
  131. package/src/types/deployment/index.ts +2 -1
  132. package/src/types/policyGate/index.ts +5 -1
  133. package/src/types/publishPipeline/plan.test.ts +3 -3
  134. package/src/types/publishPipeline/plan.ts +1 -1
  135. package/src/types/rbac/index.ts +3 -1
  136. package/src/types/reviewPolicy/index.ts +44 -5
@@ -1,43 +1,69 @@
1
1
  import { sha256Base64 } from '../signing/hashing.js';
2
+ import { ApiTriggerType } from '../types/api/index.js';
3
+ import type { DatabaseEngine } from './capacityPolicy.js';
2
4
 
3
5
  export const LIFECYCLE_TERMINAL_STATES = ['ready', 'failed', 'cancelled'] as const;
4
6
  export const LIFECYCLE_NON_TERMINAL_STATES = ['pending', 'provisioning', 'migrating', 'retiring'] as const;
5
7
  export const LIFECYCLE_STATES = [...LIFECYCLE_NON_TERMINAL_STATES, ...LIFECYCLE_TERMINAL_STATES] as const;
6
8
  export const LIFECYCLE_MIGRATION_STATES = ['pending', 'migrated', 'failed'] as const;
7
- // The environment axis (edit/preview/deployed) lives on the binding, so a
8
- // single `ensure_database` covers what used to be split into
9
+ // A single `ensure_database` covers what used to be split into
9
10
  // ensure_dev_database / ensure_prod_database. `migrate_schema` and
10
11
  // `retire_database` were always environment-agnostic.
11
- export const LIFECYCLE_OPERATIONS = ['ensure_database', 'migrate_schema', 'retire_database'] as const;
12
- // Mirrors the platform's view modes (proto api.v1.ViewMode: VIEW_MODE_EDIT /
13
- // VIEW_MODE_PREVIEW / VIEW_MODE_DEPLOYED). Working-state migrations and the
14
- // shared workspace database serve edit/preview; deploy-commit migrations and
15
- // the deploy gate concern deployed.
16
- export const LIFECYCLE_ENVIRONMENTS = ['edit', 'preview', 'deployed'] as const;
17
- export const DATABASE_ENGINES = ['postgres', 'snowflake', 'snowflake_postgres', 'lakebase'] as const;
12
+ // `ensure_physical_database_instance` is claimable pool-level work (wizard /
13
+ // eager provisioning). It is not bound to a database_binding row; the claim
14
+ // payload carries provisioningId instead of binding identity. Appended last
15
+ // because Postgres `ALTER TYPE ... ADD VALUE` appends, and TypeORM's
16
+ // entity-vs-schema check requires this array to match that live order.
17
+ export const LIFECYCLE_OPERATIONS = ['ensure_database', 'migrate_schema', 'retire_database', 'ensure_physical_database_instance'] as const;
18
18
  export const DATABASE_LIFECYCLE_MANAGED_BY = 'database_lifecycle';
19
- export const NATIVE_DB_AUTH_DESCRIPTOR_VERSION = 2 as const;
20
- export const NATIVE_DB_AUTH_DESCRIPTOR_VERSIONS = [1, NATIVE_DB_AUTH_DESCRIPTOR_VERSION] as const;
19
+ export const NATIVE_DB_AUTH_DESCRIPTOR_VERSION = 3 as const;
21
20
  export const NATIVE_DB_CONNECTOR_ROLE_NAME_PREFIX = 'superblocks-native-db-connector';
22
21
  export const NATIVE_DB_IAM_AUTH_MODE = 'aws_iam_role' as const;
23
22
  export const NATIVE_DB_IDENTIFIER_HASH_DOMAIN = 'superblocks-native-db:v1';
24
23
  export const DATABASE_LIFECYCLE_CAPABILITY_MANAGED_IAM_V1 = 'managed-IAM-v1';
25
24
 
25
+ export function isLifecycleManagedIntegrationAllowedForApiTrigger(
26
+ triggerType: ApiTriggerType,
27
+ managedBy: string | null | undefined
28
+ ): boolean {
29
+ if (managedBy !== DATABASE_LIFECYCLE_MANAGED_BY) {
30
+ return true;
31
+ }
32
+ return triggerType === ApiTriggerType.UI;
33
+ }
34
+
26
35
  // Agent capability tag keys. A lifecycle worker publishes these in the
27
36
  // `tags` map of its agent registration (merged into — never replacing — the
28
- // tag map the agent already publishes; the existing `profile` tag carries
29
- // datatag coverage, same meaning it has for execution routing. Lifecycle
30
- // environment/profile coverage is published as pairs so edit-only staging
31
- // profiles do not imply deployed staging support. The server matches pending
32
- // lifecycle requests against these at poll/claim time and gates task creation
33
- // on "some active org agent supports this".
37
+ // tag map the agent already publishes). The existing `profile` tag carries
38
+ // datatag coverage, same meaning it has for execution routing, and is the
39
+ // sole lifecycle routing key. The server matches pending lifecycle requests
40
+ // against these at poll/claim time and gates task creation on "some active
41
+ // org agent supports this".
34
42
  export const DATABASE_LIFECYCLE_TAG_OPERATIONS = 'databaseLifecycle:operations';
35
43
  export const DATABASE_LIFECYCLE_TAG_ENGINES = 'databaseLifecycle:engines';
36
- export const DATABASE_LIFECYCLE_TAG_ENVIRONMENT_PROFILES = 'databaseLifecycle:environmentProfiles';
37
44
  export const DATABASE_LIFECYCLE_TAG_CAPABILITIES = 'databaseLifecycle:capabilities';
45
+ export {
46
+ CapacityPolicyValidationError,
47
+ DATABASE_ENGINES,
48
+ DATABASE_LIFECYCLE_TAG_CAPACITY_POLICIES,
49
+ PHYSICAL_DATABASE_CAPACITY_THRESHOLD_TYPES,
50
+ computePhysicalDatabasePoolKey,
51
+ encodeCapacityPolicyTag,
52
+ parseCapacityPolicyTag,
53
+ parseCapacityPolicyTags,
54
+ validateCapacityPolicyCompatibility,
55
+ type DatabaseEngine,
56
+ type PhysicalDatabaseCapacityPolicy,
57
+ type PhysicalDatabaseCapacityThresholdType,
58
+ type PhysicalDatabasePoolKey
59
+ } from './capacityPolicy.js';
60
+ // The stable, customer-managed name of the deployment a worker belongs to. It
61
+ // says which physical pool that worker owns, which is what keeps one
62
+ // deployment from being handed work meant for another. The claim path also
63
+ // treats it as the binding-ownership authority, so a client-supplied query
64
+ // value cannot bypass it.
65
+ export { DATABASE_LIFECYCLE_TAG_AGENT_NAME, PhysicalPoolIdentityError, parsePhysicalPoolAgentNameTag } from './physicalPoolIdentity.js';
38
66
 
39
- export type LifecycleEnvironment = (typeof LIFECYCLE_ENVIRONMENTS)[number];
40
- export type DatabaseEngine = (typeof DATABASE_ENGINES)[number];
41
67
  export type LifecycleOperation = (typeof LIFECYCLE_OPERATIONS)[number];
42
68
  export type LifecycleTerminalState = (typeof LIFECYCLE_TERMINAL_STATES)[number];
43
69
  export type LifecycleNonTerminalState = (typeof LIFECYCLE_NON_TERMINAL_STATES)[number];
@@ -58,18 +84,15 @@ export type LifecycleErrorCode =
58
84
  // the request is cancelled rather than applied to the current binding.
59
85
  | 'stale_dispatch';
60
86
 
61
- export const CREDENTIAL_RESOLVERS = ['opa_local', 'aws_secrets_manager', 'gcp_secret_manager', 'vault', 'kubernetes_secret'] as const;
62
- export type CredentialResolver = (typeof CREDENTIAL_RESOLVERS)[number];
63
-
64
- export type CredentialRef = {
65
- resolver: CredentialResolver;
87
+ export type PhysicalMasterCredentialRef = {
88
+ resolver: 'aws_secrets_manager';
66
89
  ref: string;
67
90
  field?: string;
68
91
  };
69
92
 
70
93
  export type NativeDbIamDescriptor = {
71
94
  application_id: string;
72
- auth_descriptor_version: (typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSIONS)[number];
95
+ auth_descriptor_version: typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
73
96
  auth_mode: typeof NATIVE_DB_IAM_AUTH_MODE;
74
97
  aws_account_id: string;
75
98
  binding_id: string;
@@ -98,31 +121,25 @@ type DatabaseBindingBase = {
98
121
  requirementKey: string;
99
122
  logicalName: string;
100
123
  applicationId: string;
101
- environment: LifecycleEnvironment;
102
124
  // The datatag key (Profile.key, e.g. 'staging' / 'production') this
103
- // binding serves. The same datatag can exist across environments, so the
104
- // pair (environment, profile) not either alone — scopes a binding.
125
+ // binding serves, and the only axis that scopes it. One profile is one
126
+ // logical database with one DB user.
105
127
  profile: string;
106
128
  desiredSpecHash: string;
107
129
  migrationState: LifecycleMigrationState;
108
130
  };
109
131
 
110
- // A binding that has completed provisioning. The `ready` discriminant
111
- // guarantees that the OPA returned both `connectionMetadata` and
112
- // `credentialRefs`, so consumers can rely on those without null checks.
132
+ // A binding that has completed IAM provisioning.
113
133
  export type ReadyDatabaseBinding = DatabaseBindingBase & {
114
134
  lifecycleState: 'ready';
115
135
  connectionMetadata: Record<string, string | number | boolean>;
116
- credentialRefs: Record<string, CredentialRef>;
117
136
  };
118
137
 
119
- // A binding in any non-ready state. Connection metadata and credential refs
120
- // may be present from a prior `ready` transition or absent if the binding
121
- // never reached `ready`.
138
+ // A binding in any non-ready state. Connection metadata may be present from a
139
+ // prior `ready` transition or absent if the binding never reached `ready`.
122
140
  export type IncompleteDatabaseBinding = DatabaseBindingBase & {
123
141
  lifecycleState: Exclude<LifecycleState, 'ready'>;
124
142
  connectionMetadata?: Record<string, string | number | boolean>;
125
- credentialRefs?: Record<string, CredentialRef>;
126
143
  };
127
144
 
128
145
  export type DatabaseBinding = ReadyDatabaseBinding | IncompleteDatabaseBinding;
@@ -152,13 +169,15 @@ export type PhysicalDatabaseInstance = {
152
169
  id: string;
153
170
  organizationId: string;
154
171
  region: string;
155
- environment: LifecycleEnvironment;
156
172
  engine: DatabaseEngine; // postgres-only in V1
157
173
  endpoint: string;
158
- masterCredentialRef: CredentialRef;
174
+ masterCredentialRef: PhysicalMasterCredentialRef;
159
175
  capacityMax: number;
160
176
  capacityUsed: number;
161
177
  status: PhysicalDatabaseInstanceStatus;
178
+ // Stable customer OPA deployment identity that owns this physical pool.
179
+ // Absent on legacy rows (no backfill); agent-scoped lookups skip those.
180
+ agentName?: string;
162
181
  metadata: Record<string, unknown>;
163
182
  created?: Date;
164
183
  updated?: Date;
@@ -183,6 +202,57 @@ export type LifecycleDispatchContinuation = {
183
202
  reservationId?: string;
184
203
  };
185
204
 
205
+ // Wizard-facing view of a physical pool provisioning run. Narrower than
206
+ // `LIFECYCLE_STATES` on purpose: the UI only distinguishes "nothing has run"
207
+ // from in-progress, done, and failed. `not_started` is the absence of a row;
208
+ // queued and running both read as `provisioning`.
209
+ export const PHYSICAL_POOL_PROVISIONING_STATUSES = ['not_started', 'provisioning', 'active', 'failed'] as const;
210
+ export type PhysicalPoolProvisioningStatus = (typeof PHYSICAL_POOL_PROVISIONING_STATUSES)[number];
211
+
212
+ export type PhysicalPoolProvisioningActor = {
213
+ displayName: string;
214
+ id: string;
215
+ };
216
+
217
+ export type PhysicalPoolProvisioningError = {
218
+ code: string;
219
+ message: string;
220
+ };
221
+
222
+ // Stable wizard/admin response for POST/GET physical-pool-provisioning.
223
+ export type PhysicalPoolProvisioningState = {
224
+ alreadyInProgress?: boolean;
225
+ completedAt?: string;
226
+ error?: PhysicalPoolProvisioningError;
227
+ physicalDatabaseInstanceId?: string;
228
+ provisioningId?: string;
229
+ startedAt?: string;
230
+ startedBy?: PhysicalPoolProvisioningActor;
231
+ status: PhysicalPoolProvisioningStatus;
232
+ };
233
+
234
+ // One physical instance as the admin surface reports it. Narrower than
235
+ // `PhysicalDatabaseInstance`: credentials and provider metadata are not part of
236
+ // an admin listing.
237
+ export type PhysicalPoolInstanceSummary = {
238
+ capacityMax: number;
239
+ capacityUsed: number;
240
+ endpoint: string;
241
+ id: string;
242
+ region: string;
243
+ status: PhysicalDatabaseInstanceStatus;
244
+ };
245
+
246
+ // Every pool an organization has, whether it finished provisioning or not.
247
+ // A pool with no instances is one that is still running or that failed; a pool
248
+ // with instances and no run was provisioned before the wizard existed.
249
+ export type PhysicalPoolSummary = {
250
+ agentName: string;
251
+ engine: DatabaseEngine;
252
+ instances: PhysicalPoolInstanceSummary[];
253
+ provisioning: PhysicalPoolProvisioningState;
254
+ };
255
+
186
256
  // Canonical wire payload for a lifecycle dispatch sent from the server to a
187
257
  // lifecycle worker. The worker's `DispatchPayload` struct in
188
258
  // orchestrator/pkg/databaselifecycle/dispatch.go decodes this JSON shape;
@@ -191,13 +261,17 @@ export type LifecycleDispatchContinuation = {
191
261
  // The server describes WHAT (binding identity, desired spec, migrations,
192
262
  // connection/credential context); the worker owns HOW (Terraform modules,
193
263
  // state backends, credential resolvers, shared physical database instances — all resolved from
194
- // the worker's local config keyed by the payload's environment + profile).
264
+ // the worker's single local lifecycle config, selected by the payload's profile).
195
265
  //
196
266
  // `migrations` is present iff the operation should consider migration
197
267
  // state — `ensure_database` / `migrate_schema` dispatches carry an array
198
268
  // (possibly empty); `retire_database` omits it. An omitted slice keeps the
199
269
  // worker's `MigrationState` at the default "pending"; an empty slice means
200
270
  // "vacuous truth, mark migrated"; a non-empty slice triggers the runner.
271
+ //
272
+ // For `ensure_physical_database_instance`, binding identity fields are empty
273
+ // placeholders (the OPA keys the lock on provisioningId) and `provisioningId`
274
+ // is required.
201
275
  export type LifecycleDispatchPayload = {
202
276
  applicationId: string;
203
277
  bindingId: string;
@@ -206,46 +280,46 @@ export type LifecycleDispatchPayload = {
206
280
  continuation?: LifecycleDispatchContinuation;
207
281
  desiredSpec: DatabaseRequirement;
208
282
  desiredSpecHash: string;
209
- environment: LifecycleEnvironment;
283
+ // Emitted only for `ensure_physical_database_instance`, and named to match
284
+ // the worker's `environment` field. The control plane no longer routes on an
285
+ // environment, but the worker still validates one before resolving pool
286
+ // topology and deriving its Terraform state key. It is a fixed value the
287
+ // server supplies rather than anything a caller picks, and it goes away with
288
+ // the worker's remaining shims in ENG-5127.
289
+ environment?: string;
210
290
  migrations?: LifecycleMigration[];
211
291
  operation: LifecycleOperation;
212
292
  profile: string;
293
+ provisioningId?: string;
213
294
  requestId: string;
214
295
  resourceKey: string;
215
- runtimeCredentialRefs?: Record<string, CredentialRef>;
216
- migrationCredentialRefs?: Record<string, CredentialRef>;
217
296
  };
218
297
 
298
+ export function physicalPoolProvisioningResourceKey(provisioningId: string): string {
299
+ return `physical-pool-provisioning:${provisioningId}`;
300
+ }
301
+
219
302
  export function computeRequirementKey(requirement: Pick<DatabaseRequirement, 'logicalName' | 'engine'>): string {
220
303
  return `${slugify(requirement.logicalName)}~${encodeURIComponent(requirement.logicalName)}:${requirement.engine}`;
221
304
  }
222
305
 
223
306
  // binding_key is the product identity of a database in the control plane, and
224
- // is deliberately scoped to (organization, application, environment, profile)
225
- // only — NOT the logical name. This is what makes "one database per profile"
226
- // enforceable by the plain UNIQUE (binding_key) constraint: asking for another
227
- // database under a different logical name resolves to the same binding_key
228
- // instead of minting a new one. The logical name still lives on the binding row
229
- // and in resource_key (physical identity); it just doesn't grant a second slot.
230
- export function computeBindingKey(input: {
231
- organizationId: string;
232
- applicationId: string;
233
- environment: LifecycleEnvironment;
234
- profile: string;
235
- }): string {
307
+ // is deliberately scoped to (organization, application, profile) only — NOT the
308
+ // logical name. This is what makes "one database per profile" enforceable by the
309
+ // plain UNIQUE (binding_key) constraint: asking for another database under a
310
+ // different logical name resolves to the same binding_key instead of minting a
311
+ // new one. The logical name still lives on the binding row and in resource_key
312
+ // (physical identity); it just doesn't grant a second slot.
313
+ export function computeBindingKey(input: { organizationId: string; applicationId: string; profile: string }): string {
236
314
  return [input.organizationId, ...bindingKeySegments(input)].join(':');
237
315
  }
238
316
 
239
- export function computeLegacyBindingKeyWithoutOrganization(input: {
240
- applicationId: string;
241
- environment: LifecycleEnvironment;
242
- profile: string;
243
- }): string {
317
+ export function computeLegacyBindingKeyWithoutOrganization(input: { applicationId: string; profile: string }): string {
244
318
  return bindingKeySegments(input).join(':');
245
319
  }
246
320
 
247
- function bindingKeySegments(input: { applicationId: string; environment: LifecycleEnvironment; profile: string }): string[] {
248
- return [input.applicationId, input.environment, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`];
321
+ function bindingKeySegments(input: { applicationId: string; profile: string }): string[] {
322
+ return [input.applicationId, `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`];
249
323
  }
250
324
 
251
325
  // Hashes only the infrastructure spec. migrationDirectory is deliberately
@@ -279,9 +353,9 @@ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescripto
279
353
  return undefined;
280
354
  }
281
355
 
282
- const databaseIdentifier = typeof value.database === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})$/.exec(value.database) : null;
356
+ const databaseIdentifier = typeof value.database === 'string' ? /^sbndb_([0-9a-f]{16})_([0-9a-f]{24})$/.exec(value.database) : null;
283
357
  const usernameIdentifier =
284
- typeof value.username === 'string' ? /^sbndb_([0-9a-f]{12})_([0-9a-f]{24})_runtime$/.exec(value.username) : null;
358
+ typeof value.username === 'string' ? /^sbndb_([0-9a-f]{16})_([0-9a-f]{24})_runtime$/.exec(value.username) : null;
285
359
  const connectorRoleArn =
286
360
  typeof value.connector_role_arn === 'string'
287
361
  ? /^arn:aws:iam::(\d{12}):role\/(?:[\w+=,.@-]+\/)*[\w+=,.@-]+$/.exec(value.connector_role_arn)
@@ -300,7 +374,7 @@ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescripto
300
374
  !databaseIdentifier ||
301
375
  !usernameIdentifier ||
302
376
  databaseIdentifier[1] !== usernameIdentifier[1] ||
303
- (value.auth_descriptor_version === NATIVE_DB_AUTH_DESCRIPTOR_VERSION && databaseIdentifier[2] !== usernameIdentifier[2]) ||
377
+ usernameIdentifier[0] !== databaseIdentifier[0] + '_runtime' ||
304
378
  !isValidNativeDbRdsHostname(value.host, value.region) ||
305
379
  typeof value.port !== 'number' ||
306
380
  !Number.isInteger(value.port) ||
@@ -331,18 +405,15 @@ export async function computeNativeDbApplicationToken(trustedApplicationId: stri
331
405
  return (await computeNativeDbIdentifierToken('application', trustedApplicationId)).slice(0, 24);
332
406
  }
333
407
 
334
- export async function computeNativeDbBindingToken(trustedBindingId: string): Promise<string> {
335
- return (await computeNativeDbIdentifierToken('database', trustedBindingId)).slice(0, 24);
336
- }
337
-
338
- export async function computeNativeDbDatabaseName(deploymentToken: string, trustedDatabaseId: string): Promise<string> {
339
- if (!/^[0-9a-f]{12}$/.test(deploymentToken)) {
340
- throw new Error('Native database deployment token must be 12 lowercase hexadecimal characters');
341
- }
342
- return `sbndb_${deploymentToken}_${await computeNativeDbBindingToken(trustedDatabaseId)}`;
408
+ export async function computeNativeDbProfileToken(profile: string): Promise<string> {
409
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(profile.toLowerCase()));
410
+ return Array.from(new Uint8Array(digest))
411
+ .slice(0, 8)
412
+ .map((byte) => byte.toString(16).padStart(2, '0'))
413
+ .join('');
343
414
  }
344
415
 
345
- async function computeNativeDbIdentifierToken(kind: 'application' | 'database', trustedId: string): Promise<string> {
416
+ async function computeNativeDbIdentifierToken(kind: 'application', trustedId: string): Promise<string> {
346
417
  if (!isNonEmptyString(trustedId)) {
347
418
  throw new Error(`Trusted native database ${kind} ID must be a non-empty string`);
348
419
  }
@@ -364,7 +435,6 @@ export function computeResourceKey(input: {
364
435
  organizationId: string;
365
436
  applicationId: string;
366
437
  requirementKey: string;
367
- environment: LifecycleEnvironment;
368
438
  profile: string;
369
439
  actorScope?: string;
370
440
  }): string {
@@ -372,7 +442,6 @@ export function computeResourceKey(input: {
372
442
  input.organizationId,
373
443
  input.applicationId,
374
444
  input.requirementKey,
375
- input.environment,
376
445
  `${slugify(input.profile)}~${encodeURIComponent(input.profile)}`,
377
446
  input.actorScope ?? 'default'
378
447
  ].join('/');
@@ -424,7 +493,7 @@ function isCommercialAwsRegion(value: unknown): value is string {
424
493
  }
425
494
 
426
495
  function isNativeDbAuthDescriptorVersion(value: unknown): value is NativeDbIamDescriptor['auth_descriptor_version'] {
427
- return value === 1 || value === NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
496
+ return value === NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
428
497
  }
429
498
 
430
499
  function isNonEmptyString(value: unknown): value is string {
@@ -445,20 +514,20 @@ function isValidNativeDbRdsHostname(host: unknown, region: unknown): host is str
445
514
  return host.length <= 253 && host === host.toLowerCase() && prefix !== '' && host.split('.').every((label) => validLabel.test(label));
446
515
  }
447
516
 
448
- function redact(value: unknown, preserveCredentialRefs = false): unknown {
517
+ function redact(value: unknown, preservePhysicalMasterCredentialRef = false): unknown {
449
518
  if (Array.isArray(value)) {
450
- return value.map((entry) => redact(entry, preserveCredentialRefs));
519
+ return value.map((entry) => redact(entry, preservePhysicalMasterCredentialRef));
451
520
  }
452
521
  if (value && typeof value === 'object') {
453
522
  return Object.fromEntries(
454
523
  Object.entries(value).map(([key, entryValue]) => {
455
- if (isCredentialRefKey(key)) {
524
+ if (isPhysicalMasterCredentialRefKey(key)) {
456
525
  return [key, redact(entryValue, true)];
457
526
  }
458
- if (!preserveCredentialRefs && SECRET_KEY_PATTERN.test(key)) {
527
+ if (!preservePhysicalMasterCredentialRef && SECRET_KEY_PATTERN.test(key)) {
459
528
  return [key, '[REDACTED]'];
460
529
  }
461
- return [key, redact(entryValue, preserveCredentialRefs)];
530
+ return [key, redact(entryValue, preservePhysicalMasterCredentialRef)];
462
531
  })
463
532
  );
464
533
  }
@@ -467,11 +536,9 @@ function redact(value: unknown, preserveCredentialRefs = false): unknown {
467
536
 
468
537
  // Shared secret-key tripwire: keys whose name strongly suggests a raw secret
469
538
  // value. Used by `redactLifecycleSecrets` to scrub logs/error reporting and by
470
- // the server-side `assertNoRawCredentialMaterial` guard to refuse callbacks
471
- // that violate the typed-CredentialRef contract. NOT a security boundary --
472
- // callers must still send `Record<string, string | number | boolean>` for
473
- // `connectionMetadata` and `CredentialRef` for `credentialRefs`; this catches
474
- // obvious misuse only.
539
+ // the server-side `assertNoRawCredentialMaterial` guard to refuse callbacks.
540
+ // NOT a security boundary -- callers must still send primitive
541
+ // `connectionMetadata`; this catches obvious misuse only.
475
542
  export const SECRET_KEY_PATTERN = /password|secret|token|private[_-]?key|dsn/i;
476
543
 
477
544
  // Schemes whose URI form embeds credentials before `@`. Used as a value-side
@@ -513,6 +580,6 @@ const DSN_WITH_INLINE_AUTH = new RegExp(`(?:${DSN_SCHEME_GROUP}):\\/\\/[^/@\\s:]
513
580
  const CREDENTIAL_KV_PATTERN = /(?:password|pwd|token|secret|private[_-]?key|api[_-]?key)\s*=\s*[^;&\s"]+/i;
514
581
  const JSON_ENCODED_SECRET_PATTERN = /["']\s*(?:password|secret|token|private[_-]?key|dsn)\s*["']\s*:/i;
515
582
 
516
- function isCredentialRefKey(key: string): boolean {
517
- return /credential[_-]?ref/i.test(key);
583
+ function isPhysicalMasterCredentialRefKey(key: string): boolean {
584
+ return /^master[_-]?credential[_-]?ref$/i.test(key);
518
585
  }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import type { PhysicalDatabaseCapacityPolicy } from './capacityPolicy.js';
4
+ import { PhysicalPoolIdentityError, parsePhysicalPoolAgentNameTag } from './physicalPoolIdentity.js';
5
+
6
+ const policy = (agentName: string): PhysicalDatabaseCapacityPolicy => ({
7
+ agentName,
8
+ engine: 'postgres',
9
+ thresholdType: 'minAvailableCapacityPercent',
10
+ thresholdValue: 20
11
+ });
12
+
13
+ describe('parsePhysicalPoolAgentNameTag', () => {
14
+ test('reads the pool a deployment named', () => {
15
+ expect(parsePhysicalPoolAgentNameTag(['customer-opa'])).toBe('customer-opa');
16
+ expect(parsePhysicalPoolAgentNameTag([' customer-opa '])).toBe('customer-opa');
17
+ });
18
+
19
+ test('reports no identity for a deployment that named no pool', () => {
20
+ expect(parsePhysicalPoolAgentNameTag(undefined)).toBeUndefined();
21
+ expect(parsePhysicalPoolAgentNameTag([])).toBeUndefined();
22
+ });
23
+
24
+ test('rejects a tag that is not one non-empty name', () => {
25
+ expect(() => parsePhysicalPoolAgentNameTag([''])).toThrow(PhysicalPoolIdentityError);
26
+ expect(() => parsePhysicalPoolAgentNameTag([' '])).toThrow(PhysicalPoolIdentityError);
27
+ expect(() => parsePhysicalPoolAgentNameTag(['customer-opa', 'other-opa'])).toThrow(/must name exactly one/);
28
+ });
29
+
30
+ // Replicas of one deployment publish the same registration.
31
+ test('accepts a name repeated across replicas', () => {
32
+ expect(parsePhysicalPoolAgentNameTag(['customer-opa', 'customer-opa'])).toBe('customer-opa');
33
+ });
34
+
35
+ describe('agreement with capacity policies', () => {
36
+ test('accepts a policy for the pool the deployment named', () => {
37
+ expect(parsePhysicalPoolAgentNameTag(['customer-opa'], [policy('customer-opa')])).toBe('customer-opa');
38
+ });
39
+
40
+ test('rejects a policy for a pool the deployment does not own', () => {
41
+ expect(() => parsePhysicalPoolAgentNameTag(['customer-opa'], [policy('other-opa')])).toThrow(/must name the same pool/);
42
+ });
43
+
44
+ // A deployment scales the pool it owns and nothing else. Accepting a
45
+ // foreign policy alongside a matching one would let proactive evaluation
46
+ // key off a pool the wizard would never resolve to.
47
+ test('rejects a foreign policy published alongside the deployments own', () => {
48
+ expect(() => parsePhysicalPoolAgentNameTag(['customer-opa'], [policy('customer-opa'), policy('other-opa')])).toThrow(
49
+ /must name the same pool/
50
+ );
51
+ });
52
+
53
+ // Older workers publish identity only inside the policy.
54
+ test('falls back to the policy name when the deployment published no tag', () => {
55
+ expect(parsePhysicalPoolAgentNameTag(undefined, [policy('customer-opa')])).toBe('customer-opa');
56
+ });
57
+
58
+ test('refuses to guess when policies alone name two pools', () => {
59
+ expect(() => parsePhysicalPoolAgentNameTag(undefined, [policy('customer-opa'), policy('other-opa')])).toThrow(
60
+ /publish databaseLifecycle:agentName/
61
+ );
62
+ });
63
+ });
64
+ });
@@ -0,0 +1,75 @@
1
+ import type { PhysicalDatabaseCapacityPolicy } from './capacityPolicy.js';
2
+
3
+ /**
4
+ * Agent tag key for the customer-managed deployment name that identifies a
5
+ * worker's physical database pool, `(organizationId, agentName, engine)`.
6
+ *
7
+ * Published on its own rather than read out of
8
+ * `databaseLifecycle:capacityPolicies`, so a customer who wants no proactive
9
+ * scaling still gets a resolvable pool. One value, e.g. `customer-opa`.
10
+ */
11
+ export const DATABASE_LIFECYCLE_TAG_AGENT_NAME = 'databaseLifecycle:agentName';
12
+
13
+ export class PhysicalPoolIdentityError extends Error {
14
+ readonly code = 'physical_pool_identity_invalid';
15
+
16
+ constructor(message: string) {
17
+ super(message);
18
+ this.name = 'PhysicalPoolIdentityError';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * The single physical pool a worker's registration names, or undefined when it
24
+ * published none.
25
+ *
26
+ * Throws {@link PhysicalPoolIdentityError} when the registration names more
27
+ * than one pool. Guessing between them would provision infrastructure under
28
+ * one deployment's Terraform state and hand it to another, so callers must
29
+ * either refuse the registration or withhold the identity.
30
+ *
31
+ * Falls back to the capacity policy's name when the tag is absent, for a
32
+ * worker predating {@link DATABASE_LIFECYCLE_TAG_AGENT_NAME} that is still
33
+ * rolling: the policy names the same pool, so identity stays unambiguous.
34
+ */
35
+ export function parsePhysicalPoolAgentNameTag(
36
+ published: string[] | undefined,
37
+ capacityPolicies: readonly PhysicalDatabaseCapacityPolicy[] = []
38
+ ): string | undefined {
39
+ if (published !== undefined && !Array.isArray(published)) {
40
+ throw new PhysicalPoolIdentityError(`${DATABASE_LIFECYCLE_TAG_AGENT_NAME} must be an array holding one deployment name.`);
41
+ }
42
+ const values = published ?? [];
43
+ if (values.some((value) => typeof value !== 'string' || value.trim() === '')) {
44
+ throw new PhysicalPoolIdentityError(`${DATABASE_LIFECYCLE_TAG_AGENT_NAME} must be a non-empty string when provided.`);
45
+ }
46
+
47
+ const declared = new Set(values.map((value) => value.trim()));
48
+ if (declared.size > 1) {
49
+ throw new PhysicalPoolIdentityError(
50
+ `${DATABASE_LIFECYCLE_TAG_AGENT_NAME} names ${declared.size} pools (${[...declared].sort().join(', ')}); it must name exactly one.`
51
+ );
52
+ }
53
+
54
+ const policyNames = new Set(capacityPolicies.map((policy) => policy.agentName));
55
+ const [declaredName] = declared;
56
+ if (declaredName !== undefined) {
57
+ // Every policy has to be for the pool this deployment owns. A policy for
58
+ // another pool would have proactive evaluation scaling something the
59
+ // deployment cannot provision into.
60
+ if ([...policyNames].some((policyName) => policyName !== declaredName)) {
61
+ throw new PhysicalPoolIdentityError(
62
+ `${DATABASE_LIFECYCLE_TAG_AGENT_NAME} is ${declaredName} but databaseLifecycle:capacityPolicies names ${[...policyNames].sort().join(', ')}; they must name the same pool.`
63
+ );
64
+ }
65
+ return declaredName;
66
+ }
67
+
68
+ if (policyNames.size > 1) {
69
+ throw new PhysicalPoolIdentityError(
70
+ `databaseLifecycle:capacityPolicies names ${policyNames.size} pools; publish ${DATABASE_LIFECYCLE_TAG_AGENT_NAME} to say which one this deployment owns.`
71
+ );
72
+ }
73
+ const [policyName] = policyNames;
74
+ return policyName;
75
+ }
package/src/index.ts CHANGED
@@ -27,3 +27,4 @@ export * from './tracing/errorSanitizer.js';
27
27
  export * from './jwt/index.js';
28
28
  export * from './org-features/index.js';
29
29
  export * from './database-lifecycle/index.js';
30
+ export * from './database-lifecycle/edit-lifecycle-capability.js';