@superblocksteam/shared 0.9601.2 → 0.9602.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 (85) hide show
  1. package/dist/database-lifecycle/index.d.ts +4 -2
  2. package/dist/database-lifecycle/index.d.ts.map +1 -1
  3. package/dist/database-lifecycle/index.js +14 -5
  4. package/dist/database-lifecycle/index.js.map +1 -1
  5. package/dist/database-lifecycle/index.test.js +49 -1
  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 +17 -14
  9. package/dist/plugins/templates/postgres.js.map +1 -1
  10. package/dist/plugins/templates/postgres.test.js +14 -1
  11. package/dist/plugins/templates/postgres.test.js.map +1 -1
  12. package/dist/tracing/errorSanitizer.js +4 -4
  13. package/dist/tracing/errorSanitizer.js.map +1 -1
  14. package/dist/tracing/errorSanitizer.test.js +19 -3
  15. package/dist/tracing/errorSanitizer.test.js.map +1 -1
  16. package/dist/types/ai/quota-paywall.d.ts +1 -1
  17. package/dist/types/ai/quota-paywall.d.ts.map +1 -1
  18. package/dist/types/ai/quota-paywall.js +14 -0
  19. package/dist/types/ai/quota-paywall.js.map +1 -1
  20. package/dist/types/ai/quota-paywall.test.js +6 -0
  21. package/dist/types/ai/quota-paywall.test.js.map +1 -1
  22. package/dist/types/billing/billing.d.ts +3 -0
  23. package/dist/types/billing/billing.d.ts.map +1 -1
  24. package/dist/types/billing/billing.js.map +1 -1
  25. package/dist/types/billing/index.d.ts +1 -0
  26. package/dist/types/billing/index.d.ts.map +1 -1
  27. package/dist/types/billing/index.js +1 -0
  28. package/dist/types/billing/index.js.map +1 -1
  29. package/dist/types/billing/pricingModel.d.ts +39 -0
  30. package/dist/types/billing/pricingModel.d.ts.map +1 -0
  31. package/dist/types/billing/pricingModel.js +54 -0
  32. package/dist/types/billing/pricingModel.js.map +1 -0
  33. package/dist/types/billing/pricingModel.test.d.ts +2 -0
  34. package/dist/types/billing/pricingModel.test.d.ts.map +1 -0
  35. package/dist/types/billing/pricingModel.test.js +52 -0
  36. package/dist/types/billing/pricingModel.test.js.map +1 -0
  37. package/dist-esm/database-lifecycle/index.d.ts +4 -2
  38. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  39. package/dist-esm/database-lifecycle/index.js +12 -4
  40. package/dist-esm/database-lifecycle/index.js.map +1 -1
  41. package/dist-esm/database-lifecycle/index.test.js +50 -2
  42. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  43. package/dist-esm/plugins/templates/postgres.d.ts.map +1 -1
  44. package/dist-esm/plugins/templates/postgres.js +17 -14
  45. package/dist-esm/plugins/templates/postgres.js.map +1 -1
  46. package/dist-esm/plugins/templates/postgres.test.js +14 -1
  47. package/dist-esm/plugins/templates/postgres.test.js.map +1 -1
  48. package/dist-esm/tracing/errorSanitizer.js +4 -4
  49. package/dist-esm/tracing/errorSanitizer.js.map +1 -1
  50. package/dist-esm/tracing/errorSanitizer.test.js +19 -3
  51. package/dist-esm/tracing/errorSanitizer.test.js.map +1 -1
  52. package/dist-esm/types/ai/quota-paywall.d.ts +1 -1
  53. package/dist-esm/types/ai/quota-paywall.d.ts.map +1 -1
  54. package/dist-esm/types/ai/quota-paywall.js +14 -0
  55. package/dist-esm/types/ai/quota-paywall.js.map +1 -1
  56. package/dist-esm/types/ai/quota-paywall.test.js +6 -0
  57. package/dist-esm/types/ai/quota-paywall.test.js.map +1 -1
  58. package/dist-esm/types/billing/billing.d.ts +3 -0
  59. package/dist-esm/types/billing/billing.d.ts.map +1 -1
  60. package/dist-esm/types/billing/billing.js.map +1 -1
  61. package/dist-esm/types/billing/index.d.ts +1 -0
  62. package/dist-esm/types/billing/index.d.ts.map +1 -1
  63. package/dist-esm/types/billing/index.js +1 -0
  64. package/dist-esm/types/billing/index.js.map +1 -1
  65. package/dist-esm/types/billing/pricingModel.d.ts +39 -0
  66. package/dist-esm/types/billing/pricingModel.d.ts.map +1 -0
  67. package/dist-esm/types/billing/pricingModel.js +48 -0
  68. package/dist-esm/types/billing/pricingModel.js.map +1 -0
  69. package/dist-esm/types/billing/pricingModel.test.d.ts +2 -0
  70. package/dist-esm/types/billing/pricingModel.test.d.ts.map +1 -0
  71. package/dist-esm/types/billing/pricingModel.test.js +50 -0
  72. package/dist-esm/types/billing/pricingModel.test.js.map +1 -0
  73. package/package.json +1 -1
  74. package/src/database-lifecycle/index.test.ts +57 -1
  75. package/src/database-lifecycle/index.ts +15 -5
  76. package/src/plugins/templates/postgres.test.ts +18 -1
  77. package/src/plugins/templates/postgres.ts +17 -14
  78. package/src/tracing/errorSanitizer.test.ts +20 -3
  79. package/src/tracing/errorSanitizer.ts +4 -4
  80. package/src/types/ai/quota-paywall.test.ts +9 -0
  81. package/src/types/ai/quota-paywall.ts +19 -0
  82. package/src/types/billing/billing.ts +3 -0
  83. package/src/types/billing/index.ts +1 -0
  84. package/src/types/billing/pricingModel.test.ts +60 -0
  85. package/src/types/billing/pricingModel.ts +52 -0
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/billing/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/types/billing/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC"}
@@ -1,4 +1,5 @@
1
1
  export * from './freeCreditsTier.js';
2
+ export * from './pricingModel.js';
2
3
  export * from './billing.js';
3
4
  export * from './spendAlert.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/billing/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/types/billing/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Which pricing model an organization is on, persisted on `billing.pricing_model`.
3
+ *
4
+ * `seats` (default) is the legacy seat-based model: the Stripe subscription is
5
+ * anchored on a `builder_seat` line item and AI usage is gated per assigned seat.
6
+ * `credits` is the PLG model where the org buys credit packs (surfaced as "GAUs")
7
+ * and deployed apps, and AI usage is metered against the org-level pool with no seats.
8
+ *
9
+ * Values match the PostgreSQL `pricing_model_enum`. Existing orgs default to `seats`
10
+ * so they are grandfathered onto seat-based pricing until explicitly migrated.
11
+ */
12
+ export declare enum PricingModelEnum {
13
+ CREDITS = "credits",
14
+ SEATS = "seats"
15
+ }
16
+ export type PricingModel = `${PricingModelEnum}`;
17
+ /**
18
+ * True only when the org is on the seatless credits (GAU) pricing model. Anything
19
+ * absent or unrecognized is treated as seat-based — the grandfather-safe default.
20
+ */
21
+ export declare function isCreditsPricing(pricingModel: PricingModel | undefined | null): boolean;
22
+ /**
23
+ * True when the org is on seat-based pricing — the inverse of {@link isCreditsPricing}.
24
+ * Anything absent or unrecognized reads as seat-based, so use this rather than a raw
25
+ * `=== 'seats'` comparison, which would miss the grandfathered (unset) case.
26
+ */
27
+ export declare function isSeatsPricing(pricingModel: PricingModel | undefined | null): boolean;
28
+ /**
29
+ * Coerce the raw value of the `superblocks.billing.default-pricing-model` LaunchDarkly
30
+ * flag into a valid {@link PricingModel} for stamping onto a new org at creation. (Conversion
31
+ * does not re-resolve the flag; it carries the existing stamp forward — see
32
+ * `createBillingHistoryRowForOrg`.)
33
+ *
34
+ * Fails **closed to `seats`** for anything unrecognized — an LD typo, a variation the
35
+ * running code doesn't know yet, or a wrong-typed value — so a misconfigured flag can
36
+ * never strand a new org on a half-built pricing model.
37
+ */
38
+ export declare function resolvePricingModel(rawFlagValue: unknown): PricingModel;
39
+ //# sourceMappingURL=pricingModel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricingModel.d.ts","sourceRoot":"","sources":["../../../src/types/billing/pricingModel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,oBAAY,gBAAgB;IAC1B,OAAO,YAAY;IACnB,KAAK,UAAU;CAChB;AAED,MAAM,MAAM,YAAY,GAAG,GAAG,gBAAgB,EAAE,CAAC;AAEjD;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAEvF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI,GAAG,OAAO,CAErF;AAID;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,OAAO,GAAG,YAAY,CAIvE"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which pricing model an organization is on, persisted on `billing.pricing_model`.
3
+ *
4
+ * `seats` (default) is the legacy seat-based model: the Stripe subscription is
5
+ * anchored on a `builder_seat` line item and AI usage is gated per assigned seat.
6
+ * `credits` is the PLG model where the org buys credit packs (surfaced as "GAUs")
7
+ * and deployed apps, and AI usage is metered against the org-level pool with no seats.
8
+ *
9
+ * Values match the PostgreSQL `pricing_model_enum`. Existing orgs default to `seats`
10
+ * so they are grandfathered onto seat-based pricing until explicitly migrated.
11
+ */
12
+ export var PricingModelEnum;
13
+ (function (PricingModelEnum) {
14
+ PricingModelEnum["CREDITS"] = "credits";
15
+ PricingModelEnum["SEATS"] = "seats";
16
+ })(PricingModelEnum || (PricingModelEnum = {}));
17
+ /**
18
+ * True only when the org is on the seatless credits (GAU) pricing model. Anything
19
+ * absent or unrecognized is treated as seat-based — the grandfather-safe default.
20
+ */
21
+ export function isCreditsPricing(pricingModel) {
22
+ return pricingModel === PricingModelEnum.CREDITS;
23
+ }
24
+ /**
25
+ * True when the org is on seat-based pricing — the inverse of {@link isCreditsPricing}.
26
+ * Anything absent or unrecognized reads as seat-based, so use this rather than a raw
27
+ * `=== 'seats'` comparison, which would miss the grandfathered (unset) case.
28
+ */
29
+ export function isSeatsPricing(pricingModel) {
30
+ return !isCreditsPricing(pricingModel);
31
+ }
32
+ const KNOWN_PRICING_MODELS = new Set(Object.values(PricingModelEnum));
33
+ /**
34
+ * Coerce the raw value of the `superblocks.billing.default-pricing-model` LaunchDarkly
35
+ * flag into a valid {@link PricingModel} for stamping onto a new org at creation. (Conversion
36
+ * does not re-resolve the flag; it carries the existing stamp forward — see
37
+ * `createBillingHistoryRowForOrg`.)
38
+ *
39
+ * Fails **closed to `seats`** for anything unrecognized — an LD typo, a variation the
40
+ * running code doesn't know yet, or a wrong-typed value — so a misconfigured flag can
41
+ * never strand a new org on a half-built pricing model.
42
+ */
43
+ export function resolvePricingModel(rawFlagValue) {
44
+ return typeof rawFlagValue === 'string' && KNOWN_PRICING_MODELS.has(rawFlagValue)
45
+ ? rawFlagValue
46
+ : PricingModelEnum.SEATS;
47
+ }
48
+ //# sourceMappingURL=pricingModel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricingModel.js","sourceRoot":"","sources":["../../../src/types/billing/pricingModel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,CAAN,IAAY,gBAGX;AAHD,WAAY,gBAAgB;IAC1B,uCAAmB,CAAA;IACnB,mCAAe,CAAA;AACjB,CAAC,EAHW,gBAAgB,KAAhB,gBAAgB,QAG3B;AAID;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,YAA6C;IAC5E,OAAO,YAAY,KAAK,gBAAgB,CAAC,OAAO,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,YAA6C;IAC1E,OAAO,CAAC,gBAAgB,CAAC,YAAY,CAAC,CAAC;AACzC,CAAC;AAED,MAAM,oBAAoB,GAAwB,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC;AAE3F;;;;;;;;;GASG;AACH,MAAM,UAAU,mBAAmB,CAAC,YAAqB;IACvD,OAAO,OAAO,YAAY,KAAK,QAAQ,IAAI,oBAAoB,CAAC,GAAG,CAAC,YAAY,CAAC;QAC/E,CAAC,CAAE,YAA6B;QAChC,CAAC,CAAC,gBAAgB,CAAC,KAAK,CAAC;AAC7B,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pricingModel.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricingModel.test.d.ts","sourceRoot":"","sources":["../../../src/types/billing/pricingModel.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isCreditsPricing, isSeatsPricing, PricingModelEnum, resolvePricingModel } from './pricingModel.js';
3
+ describe('PricingModelEnum', () => {
4
+ it('uses stable string values for the billing.pricing_model column', () => {
5
+ expect(PricingModelEnum.SEATS).toBe('seats');
6
+ expect(PricingModelEnum.CREDITS).toBe('credits');
7
+ });
8
+ });
9
+ describe('isCreditsPricing', () => {
10
+ it('is true only for credits-based orgs', () => {
11
+ expect(isCreditsPricing(PricingModelEnum.CREDITS)).toBe(true);
12
+ });
13
+ it('is false for seat-based orgs', () => {
14
+ expect(isCreditsPricing(PricingModelEnum.SEATS)).toBe(false);
15
+ });
16
+ it('defaults to false (seat-based) when the model is absent, so existing orgs are grandfathered', () => {
17
+ expect(isCreditsPricing(undefined)).toBe(false);
18
+ expect(isCreditsPricing(null)).toBe(false);
19
+ });
20
+ });
21
+ describe('isSeatsPricing', () => {
22
+ it('is true for seat-based orgs', () => {
23
+ expect(isSeatsPricing(PricingModelEnum.SEATS)).toBe(true);
24
+ });
25
+ it('is false for credits-based orgs', () => {
26
+ expect(isSeatsPricing(PricingModelEnum.CREDITS)).toBe(false);
27
+ });
28
+ it('treats an absent model as seat-based, so existing orgs are grandfathered', () => {
29
+ expect(isSeatsPricing(undefined)).toBe(true);
30
+ expect(isSeatsPricing(null)).toBe(true);
31
+ });
32
+ });
33
+ describe('resolvePricingModel', () => {
34
+ it('passes through recognized model values from the LD flag', () => {
35
+ expect(resolvePricingModel('credits')).toBe(PricingModelEnum.CREDITS);
36
+ expect(resolvePricingModel('seats')).toBe(PricingModelEnum.SEATS);
37
+ });
38
+ it('fails closed to seats for an unrecognized flag value (LD typo / unknown variation)', () => {
39
+ expect(resolvePricingModel('gau')).toBe(PricingModelEnum.SEATS);
40
+ expect(resolvePricingModel('Credits')).toBe(PricingModelEnum.SEATS);
41
+ expect(resolvePricingModel('')).toBe(PricingModelEnum.SEATS);
42
+ });
43
+ it('fails closed to seats when the flag is missing or the wrong type', () => {
44
+ expect(resolvePricingModel(undefined)).toBe(PricingModelEnum.SEATS);
45
+ expect(resolvePricingModel(null)).toBe(PricingModelEnum.SEATS);
46
+ expect(resolvePricingModel(true)).toBe(PricingModelEnum.SEATS);
47
+ expect(resolvePricingModel(42)).toBe(PricingModelEnum.SEATS);
48
+ });
49
+ });
50
+ //# sourceMappingURL=pricingModel.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pricingModel.test.js","sourceRoot":"","sources":["../../../src/types/billing/pricingModel.test.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superblocksteam/shared",
3
- "version": "0.9601.2",
3
+ "version": "0.9602.0",
4
4
  "description": "Superblocks Shared Resources",
5
5
  "license": "Superblocks Community Software License",
6
6
  "repository": "https://github.com/superblocksteam/shared.git",
@@ -4,6 +4,7 @@ import {
4
4
  computeBindingKey,
5
5
  computeDesiredSpecHash,
6
6
  computeNativeDbApplicationToken,
7
+ computeNativeDbBindingToken,
7
8
  computeNativeDbDatabaseName,
8
9
  computeRequirementKey,
9
10
  containsCredentialMaterial,
@@ -179,6 +180,57 @@ describe('database lifecycle contracts', () => {
179
180
  );
180
181
  });
181
182
 
183
+ test('reads binding-scoped v2 managed Postgres IAM descriptors', () => {
184
+ expect(
185
+ parseNativeDbIamDescriptor({
186
+ application_id: 'application-id',
187
+ auth_descriptor_version: 2,
188
+ auth_mode: 'aws_iam_role',
189
+ aws_account_id: '123456789012',
190
+ binding_id: 'binding-id',
191
+ cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
192
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
193
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
194
+ host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
195
+ port: 5432,
196
+ region: 'us-west-2',
197
+ username: 'sbndb_abcdef123456_0123456789abcdef01234567_runtime'
198
+ })
199
+ ).toEqual({
200
+ application_id: 'application-id',
201
+ auth_descriptor_version: 2,
202
+ auth_mode: 'aws_iam_role',
203
+ aws_account_id: '123456789012',
204
+ binding_id: 'binding-id',
205
+ cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
206
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
207
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
208
+ host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
209
+ port: 5432,
210
+ region: 'us-west-2',
211
+ username: 'sbndb_abcdef123456_0123456789abcdef01234567_runtime'
212
+ });
213
+ });
214
+
215
+ test('rejects v2 descriptors when database and runtime username use different binding tokens', () => {
216
+ expect(
217
+ parseNativeDbIamDescriptor({
218
+ application_id: 'application-id',
219
+ auth_descriptor_version: 2,
220
+ auth_mode: 'aws_iam_role',
221
+ aws_account_id: '123456789012',
222
+ binding_id: 'binding-id',
223
+ cluster_resource_id: 'cluster-ABC123DEF456EXAMPLE',
224
+ connector_role_arn: 'arn:aws:iam::123456789012:role/superblocks-native-db-connector-prod',
225
+ database: 'sbndb_abcdef123456_0123456789abcdef01234567',
226
+ host: 'native-db.cluster-example.us-west-2.rds.amazonaws.com',
227
+ port: 5432,
228
+ region: 'us-west-2',
229
+ username: 'sbndb_abcdef123456_fedcba9876543210fedcba98_runtime'
230
+ })
231
+ ).toBeUndefined();
232
+ });
233
+
182
234
  test('rejects malformed or non-commercial managed Postgres IAM descriptors', () => {
183
235
  const descriptor = {
184
236
  application_id: 'application-id',
@@ -195,7 +247,7 @@ describe('database lifecycle contracts', () => {
195
247
  username: 'sbndb_abcdef123456_7bfeae1170db923274f1943f_runtime'
196
248
  };
197
249
 
198
- expect(parseNativeDbIamDescriptor({ ...descriptor, auth_descriptor_version: 2 })).toBeUndefined();
250
+ expect(parseNativeDbIamDescriptor({ ...descriptor, auth_descriptor_version: 3 })).toBeUndefined();
199
251
  expect(parseNativeDbIamDescriptor({ ...descriptor, cluster_resource_id: 'not-an-rds-resource' })).toBeUndefined();
200
252
  expect(parseNativeDbIamDescriptor({ ...descriptor, connector_role_arn: 'arn:aws:iam::210987654321:role/other' })).toBeUndefined();
201
253
  expect(parseNativeDbIamDescriptor({ ...descriptor, database: 'orders' })).toBeUndefined();
@@ -230,4 +282,8 @@ describe('database lifecycle contracts', () => {
230
282
  await expect(computeNativeDbApplicationToken('application-id')).resolves.toBe('7bfeae1170db923274f1943f');
231
283
  await expect(computeNativeDbDatabaseName('abcdef123456', 'database-id')).resolves.toBe('sbndb_abcdef123456_dad72612f7a80580b028178f');
232
284
  });
285
+
286
+ test('derives a stable binding identity token for v2 database and runtime-user names', async () => {
287
+ await expect(computeNativeDbBindingToken('binding-id')).resolves.toBe('927968fc37b7b41be7e14cf9');
288
+ });
233
289
  });
@@ -16,7 +16,8 @@ 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;
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;
20
21
  export const NATIVE_DB_CONNECTOR_ROLE_NAME_PREFIX = 'superblocks-native-db-connector';
21
22
  export const NATIVE_DB_IAM_AUTH_MODE = 'aws_iam_role' as const;
22
23
  export const NATIVE_DB_IDENTIFIER_HASH_DOMAIN = 'superblocks-native-db:v1';
@@ -68,7 +69,7 @@ export type CredentialRef = {
68
69
 
69
70
  export type NativeDbIamDescriptor = {
70
71
  application_id: string;
71
- auth_descriptor_version: typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
72
+ auth_descriptor_version: (typeof NATIVE_DB_AUTH_DESCRIPTOR_VERSIONS)[number];
72
73
  auth_mode: typeof NATIVE_DB_IAM_AUTH_MODE;
73
74
  aws_account_id: string;
74
75
  binding_id: string;
@@ -287,7 +288,7 @@ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescripto
287
288
  : null;
288
289
  if (
289
290
  !isNonEmptyString(value.application_id) ||
290
- value.auth_descriptor_version !== NATIVE_DB_AUTH_DESCRIPTOR_VERSION ||
291
+ !isNativeDbAuthDescriptorVersion(value.auth_descriptor_version) ||
291
292
  value.auth_mode !== NATIVE_DB_IAM_AUTH_MODE ||
292
293
  !isNonEmptyString(value.aws_account_id) ||
293
294
  !/^\d{12}$/.test(value.aws_account_id) ||
@@ -299,6 +300,7 @@ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescripto
299
300
  !databaseIdentifier ||
300
301
  !usernameIdentifier ||
301
302
  databaseIdentifier[1] !== usernameIdentifier[1] ||
303
+ (value.auth_descriptor_version === NATIVE_DB_AUTH_DESCRIPTOR_VERSION && databaseIdentifier[2] !== usernameIdentifier[2]) ||
302
304
  !isValidNativeDbRdsHostname(value.host, value.region) ||
303
305
  typeof value.port !== 'number' ||
304
306
  !Number.isInteger(value.port) ||
@@ -311,7 +313,7 @@ export function parseNativeDbIamDescriptor(value: unknown): NativeDbIamDescripto
311
313
 
312
314
  return {
313
315
  application_id: value.application_id,
314
- auth_descriptor_version: NATIVE_DB_AUTH_DESCRIPTOR_VERSION,
316
+ auth_descriptor_version: value.auth_descriptor_version,
315
317
  auth_mode: NATIVE_DB_IAM_AUTH_MODE,
316
318
  aws_account_id: value.aws_account_id,
317
319
  binding_id: value.binding_id,
@@ -329,11 +331,15 @@ export async function computeNativeDbApplicationToken(trustedApplicationId: stri
329
331
  return (await computeNativeDbIdentifierToken('application', trustedApplicationId)).slice(0, 24);
330
332
  }
331
333
 
334
+ export async function computeNativeDbBindingToken(trustedBindingId: string): Promise<string> {
335
+ return (await computeNativeDbIdentifierToken('database', trustedBindingId)).slice(0, 24);
336
+ }
337
+
332
338
  export async function computeNativeDbDatabaseName(deploymentToken: string, trustedDatabaseId: string): Promise<string> {
333
339
  if (!/^[0-9a-f]{12}$/.test(deploymentToken)) {
334
340
  throw new Error('Native database deployment token must be 12 lowercase hexadecimal characters');
335
341
  }
336
- return `sbndb_${deploymentToken}_${(await computeNativeDbIdentifierToken('database', trustedDatabaseId)).slice(0, 24)}`;
342
+ return `sbndb_${deploymentToken}_${await computeNativeDbBindingToken(trustedDatabaseId)}`;
337
343
  }
338
344
 
339
345
  async function computeNativeDbIdentifierToken(kind: 'application' | 'database', trustedId: string): Promise<string> {
@@ -417,6 +423,10 @@ function isCommercialAwsRegion(value: unknown): value is string {
417
423
  return typeof value === 'string' && /^[a-z]{2}-[a-z]+-[0-9]+$/.test(value) && !value.startsWith('cn-') && !value.startsWith('us-gov-');
418
424
  }
419
425
 
426
+ function isNativeDbAuthDescriptorVersion(value: unknown): value is NativeDbIamDescriptor['auth_descriptor_version'] {
427
+ return value === 1 || value === NATIVE_DB_AUTH_DESCRIPTOR_VERSION;
428
+ }
429
+
420
430
  function isNonEmptyString(value: unknown): value is string {
421
431
  return typeof value === 'string' && value.trim().length > 0;
422
432
  }
@@ -85,11 +85,28 @@ describe('Postgres datasource template', () => {
85
85
  }
86
86
  },
87
87
  rules: [{ required: false }],
88
- startVersion: '0.0.13'
88
+ startVersion: '0.0.13',
89
+ style: {
90
+ height: '100px'
91
+ }
89
92
  })
90
93
  ]);
91
94
  });
92
95
 
96
+ it('places session policy after the required IAM username field', () => {
97
+ const iamFieldOrder =
98
+ datasourceItems
99
+ ?.filter((item) => item.startVersion === '0.0.13')
100
+ .filter((item) => item.display?.show?.['authentication.authType']?.includes('aws_iam_role'))
101
+ .map((item) => item.name) ?? [];
102
+
103
+ const usernameIndex = iamFieldOrder.indexOf('authentication.username');
104
+ const sessionPolicyIndex = iamFieldOrder.indexOf('authentication.custom.sessionPolicy.value');
105
+
106
+ expect(usernameIndex).toBeGreaterThanOrEqual(0);
107
+ expect(sessionPolicyIndex).toBeGreaterThan(usernameIndex);
108
+ });
109
+
93
110
  it('keeps username visible and password compatible with legacy configurations', () => {
94
111
  expect(findDatasourceItems('authentication.username')).toEqual([
95
112
  expect.objectContaining({
@@ -277,20 +277,6 @@ export const PostgresPlugin: Plugin = {
277
277
  }
278
278
  }
279
279
  },
280
- {
281
- label: 'Session policy JSON (optional)',
282
- name: 'authentication.custom.sessionPolicy.value',
283
- startVersion: PostgresPluginVersions.V13,
284
- componentType: FormComponentType.CODE_EDITOR,
285
- language: EditorLanguage.JSON,
286
- rules: [{ required: false }],
287
- display: {
288
- show: {
289
- 'authentication.authType': [PostgresAuthType.AWS_IAM_ROLE],
290
- connectionType: ['fields']
291
- }
292
- }
293
- },
294
280
  // DATABASE USERNAME
295
281
  { ...BASE_DATABASE_USERNAME, startVersion: PostgresPluginVersions.V1, endVersion: PostgresPluginVersions.V10 } as FormItem,
296
282
  {
@@ -328,6 +314,23 @@ export const PostgresPlugin: Plugin = {
328
314
  }
329
315
  }
330
316
  },
317
+ {
318
+ label: 'Session policy JSON (optional)',
319
+ name: 'authentication.custom.sessionPolicy.value',
320
+ startVersion: PostgresPluginVersions.V13,
321
+ componentType: FormComponentType.CODE_EDITOR,
322
+ language: EditorLanguage.JSON,
323
+ rules: [{ required: false }],
324
+ style: {
325
+ height: '100px'
326
+ },
327
+ display: {
328
+ show: {
329
+ 'authentication.authType': [PostgresAuthType.AWS_IAM_ROLE],
330
+ connectionType: ['fields']
331
+ }
332
+ }
333
+ },
331
334
  // DATABASE PASSWORD
332
335
  { ...BASE_DATABASE_PASSWORD, startVersion: PostgresPluginVersions.V1, endVersion: PostgresPluginVersions.V10 } as FormItem,
333
336
  {
@@ -58,13 +58,13 @@ describe('Error Sanitization', () => {
58
58
  const error = new Error('Test error');
59
59
  error.stack = `Error: Invalid token eyJhbGciOiJIUzI1NiI...
60
60
  at AuthService.validateToken (/app/src/auth.js:42:15)
61
- at api_key=sk_live_abc123 (/app/src/api.js:20:10)`;
61
+ at api_key=sk_live_abc123def456ghi789 (/app/src/api.js:20:10)`;
62
62
 
63
63
  const sanitized = sanitizeError(error);
64
64
 
65
65
  expect(sanitized.stack).toContain('[REDACTED]');
66
66
  expect(sanitized.stack).not.toContain('eyJhbGciOiJIUzI1NiI');
67
- expect(sanitized.stack).not.toContain('sk_live_abc123');
67
+ expect(sanitized.stack).not.toContain('sk_live_abc123def456ghi789');
68
68
  });
69
69
 
70
70
  it('should preserve error type and non-sensitive properties', () => {
@@ -85,7 +85,7 @@ describe('Error Sanitization', () => {
85
85
 
86
86
  it('should handle non-Error objects', () => {
87
87
  const errorObj = {
88
- message: 'Auth failed with token abc123',
88
+ message: 'Auth failed with token abc123def456ghi789jkl012',
89
89
  token: 'secret-token',
90
90
  data: {
91
91
  user: 'john',
@@ -101,6 +101,23 @@ describe('Error Sanitization', () => {
101
101
  expect((sanitized.data as Record<string, unknown>).password).toBe('[REDACTED]');
102
102
  });
103
103
 
104
+ it('should preserve short values after token/api_key (false positive prevention)', () => {
105
+ const testCases = [
106
+ { input: 'Token does not have apps:update scope', expected: 'Token does not have apps:update scope' },
107
+ { input: 'token usage exceeded limit', expected: 'token usage exceeded limit' },
108
+ { input: 'API key not found', expected: 'API key not found' },
109
+ { input: 'access token expired', expected: 'access token expired' },
110
+ { input: 'refresh token is missing', expected: 'refresh token is missing' },
111
+ { input: 'token: abcdef012345678', expected: 'token: abcdef012345678' },
112
+ { input: 'token: abcdef0123456789', expected: 'token: [REDACTED]' }
113
+ ];
114
+ for (const { input, expected } of testCases) {
115
+ const error = new Error(input);
116
+ const sanitized = sanitizeError(error);
117
+ expect(sanitized.message).toBe(expected);
118
+ }
119
+ });
120
+
104
121
  it('should not double-sanitize already sanitized errors', () => {
105
122
  const error = new Error('Invalid token: eyJhbGciOiJIUzI1NiI...');
106
123
 
@@ -11,10 +11,10 @@ const SENSITIVE_PATTERNS = [
11
11
  /(\bbearer\s+)[a-zA-Z0-9\-._~+/]+=*/gi,
12
12
  /(\bbasic\s+)\S+/gi, // Basic auth can contain any non-whitespace characters
13
13
  /(\bjwt\s+)[a-zA-Z0-9\-._~+/]+=*/gi,
14
- /(\btoken[:\s=]+)[a-zA-Z0-9\-._~+/]+=*/gi,
15
- /(\bapi[_\s]?key[:\s=]+)[a-zA-Z0-9\-._~+/]+=*/gi,
16
- /(\baccess[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]+=*/gi,
17
- /(\brefresh[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]+=*/gi,
14
+ /(\btoken[:\s=]+)[a-zA-Z0-9\-._~+/]{16,}=*/gi,
15
+ /(\bapi[_\s]?key[:\s=]+)[a-zA-Z0-9\-._~+/]{16,}=*/gi,
16
+ /(\baccess[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]{16,}=*/gi,
17
+ /(\brefresh[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]{16,}=*/gi,
18
18
 
19
19
  // JWT pattern (base64.base64.base64) - standalone tokens
20
20
  /\b[A-Za-z0-9-_]{20,}\.[A-Za-z0-9-_]{20,}\.[A-Za-z0-9-_]{20,}\b/g
@@ -46,6 +46,14 @@ describe('getAiQuotaPaywallReasonFromMessage', () => {
46
46
  expect(getAiQuotaPaywallReasonFromMessage('YOUR FREE TRIAL HAS ENDED')).toBe('trial_expired');
47
47
  });
48
48
 
49
+ it('returns provisioning_error for the provisioning phrasings, including the server 402/429 copy', () => {
50
+ expect(getAiQuotaPaywallReasonFromMessage('provisioning_error')).toBe('provisioning_error');
51
+ expect(getAiQuotaPaywallReasonFromMessage("Your organization's plan hasn't finished provisioning. Try again in a few minutes.")).toBe(
52
+ 'provisioning_error'
53
+ );
54
+ expect(getAiQuotaPaywallReasonFromMessage('The plan has not finished provisioning')).toBe('provisioning_error');
55
+ });
56
+
49
57
  it('returns undefined when the message does not match any known phrasing', () => {
50
58
  expect(getAiQuotaPaywallReasonFromMessage('Connection refused')).toBeUndefined();
51
59
  expect(getAiQuotaPaywallReasonFromMessage('')).toBeUndefined();
@@ -62,6 +70,7 @@ describe('AI_QUOTA_PAYWALL_REASON_CONFIG', () => {
62
70
  'deploy_quota_exceeded',
63
71
  'no_seat_assigned',
64
72
  'payment_past_due',
73
+ 'provisioning_error',
65
74
  'token_limit_exceeded',
66
75
  'trial_expired'
67
76
  ] as const;
@@ -4,6 +4,7 @@ export type AiQuotaPaywallReason =
4
4
  | 'dollar_commit_exhausted'
5
5
  | 'no_seat_assigned'
6
6
  | 'payment_past_due'
7
+ | 'provisioning_error'
7
8
  | 'token_limit_exceeded'
8
9
  | 'trial_expired'
9
10
  | 'user_credit_limit_exceeded';
@@ -20,6 +21,7 @@ export const AI_QUOTA_PAYWALL_REASONS: ReadonlySet<AiQuotaPaywallReason> = new S
20
21
  'dollar_commit_exhausted',
21
22
  'no_seat_assigned',
22
23
  'payment_past_due',
24
+ 'provisioning_error',
23
25
  'token_limit_exceeded',
24
26
  'trial_expired',
25
27
  'user_credit_limit_exceeded'
@@ -60,6 +62,18 @@ export const getAiQuotaPaywallReasonFromMessage = (message: string): AiQuotaPayw
60
62
  return 'trial_expired';
61
63
  }
62
64
 
65
+ // A paid org whose entitlement row hasn't been written yet (webhook delayed or
66
+ // failed). Must be matched before the generic 'ai usage limit' fallback below —
67
+ // this state is a provisioning problem, not exhaustion, and its paywall must not
68
+ // suggest buying more credits.
69
+ if (
70
+ normalizedMessage.includes('provisioning_error') ||
71
+ normalizedMessage.includes("hasn't finished provisioning") ||
72
+ normalizedMessage.includes('has not finished provisioning')
73
+ ) {
74
+ return 'provisioning_error';
75
+ }
76
+
63
77
  if (normalizedMessage.includes('token_limit_exceeded')) {
64
78
  return 'token_limit_exceeded';
65
79
  }
@@ -126,6 +140,11 @@ export const AI_QUOTA_PAYWALL_REASON_CONFIG: Record<AiQuotaPaywallReason, { titl
126
140
  user_credit_limit_exceeded: {
127
141
  title: "You've reached your assigned usage limit",
128
142
  subtitle: 'Contact an admin to request an increase.'
143
+ },
144
+ provisioning_error: {
145
+ title: "Your organization's plan isn't fully set up yet",
146
+ subtitle:
147
+ "Your subscription is active, but its usage allowance hasn't finished provisioning. Try again in a few minutes — if this persists, contact support@superblocks.com."
129
148
  }
130
149
  };
131
150
 
@@ -1,4 +1,5 @@
1
1
  import type { FreeCreditsTier } from './freeCreditsTier.js';
2
+ import type { PricingModel } from './pricingModel.js';
2
3
 
3
4
  export enum BillingPlan {
4
5
  TRIAL = 'TRIAL',
@@ -53,4 +54,6 @@ export type Billing = {
53
54
  showCcGateIfTrial?: boolean;
54
55
  /** Set at signup; used for LD targeting (`freeCreditsTier`). */
55
56
  freeCreditsTier?: FreeCreditsTier;
57
+ /** Set at signup; `seats` (legacy, default) or `credits` (seatless PLG/GAU pricing). Absent = seat-based. */
58
+ pricingModel?: PricingModel;
56
59
  };
@@ -1,3 +1,4 @@
1
1
  export * from './freeCreditsTier.js';
2
+ export * from './pricingModel.js';
2
3
  export * from './billing.js';
3
4
  export * from './spendAlert.js';
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { isCreditsPricing, isSeatsPricing, PricingModelEnum, resolvePricingModel } from './pricingModel.js';
4
+
5
+ describe('PricingModelEnum', () => {
6
+ it('uses stable string values for the billing.pricing_model column', () => {
7
+ expect(PricingModelEnum.SEATS).toBe('seats');
8
+ expect(PricingModelEnum.CREDITS).toBe('credits');
9
+ });
10
+ });
11
+
12
+ describe('isCreditsPricing', () => {
13
+ it('is true only for credits-based orgs', () => {
14
+ expect(isCreditsPricing(PricingModelEnum.CREDITS)).toBe(true);
15
+ });
16
+
17
+ it('is false for seat-based orgs', () => {
18
+ expect(isCreditsPricing(PricingModelEnum.SEATS)).toBe(false);
19
+ });
20
+
21
+ it('defaults to false (seat-based) when the model is absent, so existing orgs are grandfathered', () => {
22
+ expect(isCreditsPricing(undefined)).toBe(false);
23
+ expect(isCreditsPricing(null)).toBe(false);
24
+ });
25
+ });
26
+
27
+ describe('isSeatsPricing', () => {
28
+ it('is true for seat-based orgs', () => {
29
+ expect(isSeatsPricing(PricingModelEnum.SEATS)).toBe(true);
30
+ });
31
+
32
+ it('is false for credits-based orgs', () => {
33
+ expect(isSeatsPricing(PricingModelEnum.CREDITS)).toBe(false);
34
+ });
35
+
36
+ it('treats an absent model as seat-based, so existing orgs are grandfathered', () => {
37
+ expect(isSeatsPricing(undefined)).toBe(true);
38
+ expect(isSeatsPricing(null)).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('resolvePricingModel', () => {
43
+ it('passes through recognized model values from the LD flag', () => {
44
+ expect(resolvePricingModel('credits')).toBe(PricingModelEnum.CREDITS);
45
+ expect(resolvePricingModel('seats')).toBe(PricingModelEnum.SEATS);
46
+ });
47
+
48
+ it('fails closed to seats for an unrecognized flag value (LD typo / unknown variation)', () => {
49
+ expect(resolvePricingModel('gau')).toBe(PricingModelEnum.SEATS);
50
+ expect(resolvePricingModel('Credits')).toBe(PricingModelEnum.SEATS);
51
+ expect(resolvePricingModel('')).toBe(PricingModelEnum.SEATS);
52
+ });
53
+
54
+ it('fails closed to seats when the flag is missing or the wrong type', () => {
55
+ expect(resolvePricingModel(undefined)).toBe(PricingModelEnum.SEATS);
56
+ expect(resolvePricingModel(null)).toBe(PricingModelEnum.SEATS);
57
+ expect(resolvePricingModel(true)).toBe(PricingModelEnum.SEATS);
58
+ expect(resolvePricingModel(42)).toBe(PricingModelEnum.SEATS);
59
+ });
60
+ });