@superblocksteam/shared 0.9601.3 → 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.
- package/dist/database-lifecycle/index.d.ts +4 -2
- package/dist/database-lifecycle/index.d.ts.map +1 -1
- package/dist/database-lifecycle/index.js +14 -5
- package/dist/database-lifecycle/index.js.map +1 -1
- package/dist/database-lifecycle/index.test.js +49 -1
- package/dist/database-lifecycle/index.test.js.map +1 -1
- package/dist/plugins/templates/postgres.d.ts.map +1 -1
- package/dist/plugins/templates/postgres.js +17 -14
- package/dist/plugins/templates/postgres.js.map +1 -1
- package/dist/plugins/templates/postgres.test.js +14 -1
- package/dist/plugins/templates/postgres.test.js.map +1 -1
- package/dist/tracing/errorSanitizer.js +4 -4
- package/dist/tracing/errorSanitizer.js.map +1 -1
- package/dist/tracing/errorSanitizer.test.js +19 -3
- package/dist/tracing/errorSanitizer.test.js.map +1 -1
- package/dist/types/ai/quota-paywall.d.ts +1 -1
- package/dist/types/ai/quota-paywall.d.ts.map +1 -1
- package/dist/types/ai/quota-paywall.js +14 -0
- package/dist/types/ai/quota-paywall.js.map +1 -1
- package/dist/types/ai/quota-paywall.test.js +6 -0
- package/dist/types/ai/quota-paywall.test.js.map +1 -1
- package/dist/types/billing/billing.d.ts +3 -0
- package/dist/types/billing/billing.d.ts.map +1 -1
- package/dist/types/billing/billing.js.map +1 -1
- package/dist/types/billing/index.d.ts +1 -0
- package/dist/types/billing/index.d.ts.map +1 -1
- package/dist/types/billing/index.js +1 -0
- package/dist/types/billing/index.js.map +1 -1
- package/dist/types/billing/pricingModel.d.ts +39 -0
- package/dist/types/billing/pricingModel.d.ts.map +1 -0
- package/dist/types/billing/pricingModel.js +54 -0
- package/dist/types/billing/pricingModel.js.map +1 -0
- package/dist/types/billing/pricingModel.test.d.ts +2 -0
- package/dist/types/billing/pricingModel.test.d.ts.map +1 -0
- package/dist/types/billing/pricingModel.test.js +52 -0
- package/dist/types/billing/pricingModel.test.js.map +1 -0
- package/dist-esm/database-lifecycle/index.d.ts +4 -2
- package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
- package/dist-esm/database-lifecycle/index.js +12 -4
- package/dist-esm/database-lifecycle/index.js.map +1 -1
- package/dist-esm/database-lifecycle/index.test.js +50 -2
- package/dist-esm/database-lifecycle/index.test.js.map +1 -1
- package/dist-esm/plugins/templates/postgres.d.ts.map +1 -1
- package/dist-esm/plugins/templates/postgres.js +17 -14
- package/dist-esm/plugins/templates/postgres.js.map +1 -1
- package/dist-esm/plugins/templates/postgres.test.js +14 -1
- package/dist-esm/plugins/templates/postgres.test.js.map +1 -1
- package/dist-esm/tracing/errorSanitizer.js +4 -4
- package/dist-esm/tracing/errorSanitizer.js.map +1 -1
- package/dist-esm/tracing/errorSanitizer.test.js +19 -3
- package/dist-esm/tracing/errorSanitizer.test.js.map +1 -1
- package/dist-esm/types/ai/quota-paywall.d.ts +1 -1
- package/dist-esm/types/ai/quota-paywall.d.ts.map +1 -1
- package/dist-esm/types/ai/quota-paywall.js +14 -0
- package/dist-esm/types/ai/quota-paywall.js.map +1 -1
- package/dist-esm/types/ai/quota-paywall.test.js +6 -0
- package/dist-esm/types/ai/quota-paywall.test.js.map +1 -1
- package/dist-esm/types/billing/billing.d.ts +3 -0
- package/dist-esm/types/billing/billing.d.ts.map +1 -1
- package/dist-esm/types/billing/billing.js.map +1 -1
- package/dist-esm/types/billing/index.d.ts +1 -0
- package/dist-esm/types/billing/index.d.ts.map +1 -1
- package/dist-esm/types/billing/index.js +1 -0
- package/dist-esm/types/billing/index.js.map +1 -1
- package/dist-esm/types/billing/pricingModel.d.ts +39 -0
- package/dist-esm/types/billing/pricingModel.d.ts.map +1 -0
- package/dist-esm/types/billing/pricingModel.js +48 -0
- package/dist-esm/types/billing/pricingModel.js.map +1 -0
- package/dist-esm/types/billing/pricingModel.test.d.ts +2 -0
- package/dist-esm/types/billing/pricingModel.test.d.ts.map +1 -0
- package/dist-esm/types/billing/pricingModel.test.js +50 -0
- package/dist-esm/types/billing/pricingModel.test.js.map +1 -0
- package/package.json +1 -1
- package/src/database-lifecycle/index.test.ts +57 -1
- package/src/database-lifecycle/index.ts +15 -5
- package/src/plugins/templates/postgres.test.ts +18 -1
- package/src/plugins/templates/postgres.ts +17 -14
- package/src/tracing/errorSanitizer.test.ts +20 -3
- package/src/tracing/errorSanitizer.ts +4 -4
- package/src/types/ai/quota-paywall.test.ts +9 -0
- package/src/types/ai/quota-paywall.ts +19 -0
- package/src/types/billing/billing.ts +3 -0
- package/src/types/billing/index.ts +1 -0
- package/src/types/billing/pricingModel.test.ts +60 -0
- 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 +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 @@
|
|
|
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
|
@@ -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:
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
|
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}_${
|
|
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=
|
|
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('
|
|
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
|
|
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\-._~+/]
|
|
15
|
-
/(\bapi[_\s]?key[:\s=]+)[a-zA-Z0-9\-._~+/]
|
|
16
|
-
/(\baccess[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]
|
|
17
|
-
/(\brefresh[_\s]?token[:\s=]+)[a-zA-Z0-9\-._~+/]
|
|
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
|
};
|
|
@@ -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
|
+
});
|