@stigmer/server 3.16.1-dev.20260917224426 → 3.17.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/boot/compose.d.ts +0 -12
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +6 -2
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +24 -11
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +47 -3
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/dist/extensions/drivers.d.ts +11 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/license-status.d.ts +64 -0
- package/dist/extensions/license-status.d.ts.map +1 -0
- package/dist/extensions/license-status.js +9 -0
- package/dist/extensions/license-status.js.map +1 -0
- package/dist/extensions/registry.d.ts +6 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +10 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/boot/compose.ts +6 -14
- package/src/domain/platform/__tests__/platform.test.ts +147 -46
- package/src/domain/platform/controller.ts +77 -13
- package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
- package/src/extensions/__tests__/license-status-points.test.ts +50 -0
- package/src/extensions/drivers.ts +11 -0
- package/src/extensions/license-status.ts +71 -0
- package/src/extensions/registry.ts +19 -0
- package/src/index.ts +8 -0
- package/src/pipeline/__tests__/billing-contract-rules.test.ts +260 -0
|
@@ -53,6 +53,7 @@ import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-p
|
|
|
53
53
|
import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
|
|
54
54
|
import type { AuthorizationQueryEngine } from "./authorization-queries.js";
|
|
55
55
|
import type { IdentityFederation } from "./identity-federation.js";
|
|
56
|
+
import type { LicenseStatusProvider } from "./license-status.js";
|
|
56
57
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
57
58
|
import type { OrganizationDirectory } from "./organization-directory.js";
|
|
58
59
|
import type { PolicyGrantScope } from "./policy-grant-scope.js";
|
|
@@ -219,4 +220,14 @@ export interface ExtensionDrivers {
|
|
|
219
220
|
* with the edition reason — the identityFederation absent shape.
|
|
220
221
|
*/
|
|
221
222
|
readonly authorizationQueries?: AuthorizationQueryEngine;
|
|
223
|
+
/**
|
|
224
|
+
* The license-status provider (single-instance point): what license
|
|
225
|
+
* this server holds, answered by PlatformQueryController.getLicenseStatus
|
|
226
|
+
* in every edition. An Enterprise unit registers the provider that
|
|
227
|
+
* verifies its configured ticket; when absent, the built-in `absent`
|
|
228
|
+
* answer installs at the compose.ts consumption site, so open source
|
|
229
|
+
* and the cloud (which hold no key) answer the same question the same
|
|
230
|
+
* way (extensions/license-status.ts carries the contract).
|
|
231
|
+
*/
|
|
232
|
+
readonly licenseStatus?: LicenseStatusProvider;
|
|
222
233
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The license-status extension point: what license does this server hold?
|
|
3
|
+
*
|
|
4
|
+
* Every edition answers the question through
|
|
5
|
+
* PlatformQueryController.getLicenseStatus, because a server's license is a
|
|
6
|
+
* fact about the server, like its edition, and the console banner that
|
|
7
|
+
* reads it ("licensed to Acme until March") must work wherever it is shown.
|
|
8
|
+
* Only one edition ever has a license to report: the Enterprise composition
|
|
9
|
+
* registers the provider that verifies the ticket it was configured with.
|
|
10
|
+
* Open source and the cloud hold no key, so with no provider composed the
|
|
11
|
+
* built-in `absent` answer below installs at the boot/compose.ts
|
|
12
|
+
* consumption site (the default-lives-with-the-consumer doctrine; see
|
|
13
|
+
* drivers.ts).
|
|
14
|
+
*
|
|
15
|
+
* The port speaks the generated claim types rather than a domain twin. The
|
|
16
|
+
* claims ARE the wire — the ticket's payload is the proto JSON of
|
|
17
|
+
* LicenseClaims — so a hand-written copy would be exactly the duplicate of a
|
|
18
|
+
* generated type the SDK and server guides forbid.
|
|
19
|
+
*
|
|
20
|
+
* The controller owns the clock: it hands the provider ONE instant and
|
|
21
|
+
* stamps the answer's checked_at from the same one, so a report and its
|
|
22
|
+
* timestamp can never disagree about when the license was evaluated. A
|
|
23
|
+
* provider therefore takes `now` and never reads the wall clock itself.
|
|
24
|
+
*
|
|
25
|
+
* The report is a union keyed on the state, not a bag of optional fields,
|
|
26
|
+
* so the wire's presence contract (GetLicenseStatusOutput) is a fact the
|
|
27
|
+
* compiler holds rather than a sentence a provider has to remember: an
|
|
28
|
+
* `invalid` report cannot carry the claims that did not verify, a `valid`
|
|
29
|
+
* one cannot omit them, and `unspecified` cannot be reported at all.
|
|
30
|
+
*/
|
|
31
|
+
import type { LicenseClaims } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
32
|
+
import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The states in which a ticket's signature verified and its claims are
|
|
36
|
+
* trusted; what separates them is only where the clock stands in the term.
|
|
37
|
+
*/
|
|
38
|
+
export type VerifiedLicenseState =
|
|
39
|
+
| LicenseState.valid
|
|
40
|
+
| LicenseState.expiring
|
|
41
|
+
| LicenseState.grace
|
|
42
|
+
| LicenseState.expired;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A provider's answer, evaluated at the instant it was given. One arm per
|
|
46
|
+
* presence shape: no ticket configured; a ticket that named a key and did
|
|
47
|
+
* not verify; a ticket that verified, with the key it named and the claims
|
|
48
|
+
* it carried. The controller copies the arm to the wire field for field.
|
|
49
|
+
*/
|
|
50
|
+
export type LicenseStatusReport =
|
|
51
|
+
| { readonly state: LicenseState.absent }
|
|
52
|
+
| { readonly state: LicenseState.invalid; readonly keyId: string }
|
|
53
|
+
| {
|
|
54
|
+
readonly state: VerifiedLicenseState;
|
|
55
|
+
readonly keyId: string;
|
|
56
|
+
readonly claims: LicenseClaims;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The provider contract (single-instance point, ExtensionDrivers.licenseStatus). */
|
|
60
|
+
export interface LicenseStatusProvider {
|
|
61
|
+
/** The license's state as of `now`, the instant the answer is stamped with. */
|
|
62
|
+
status(now: Date): Promise<LicenseStatusReport>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The built-in answer for a server that holds no license: open source and
|
|
67
|
+
* the cloud, and an Enterprise composition that registered no provider.
|
|
68
|
+
*/
|
|
69
|
+
export const ABSENT_LICENSE_STATUS: LicenseStatusProvider = {
|
|
70
|
+
status: () => Promise.resolve({ state: LicenseState.absent }),
|
|
71
|
+
};
|
|
@@ -53,6 +53,7 @@ import type { SecretCodec } from "../encryption/codec.js";
|
|
|
53
53
|
import { V1_VERSION } from "../encryption/v1-codec.js";
|
|
54
54
|
import type { AuthorizationQueryEngine } from "./authorization-queries.js";
|
|
55
55
|
import type { IdentityFederation } from "./identity-federation.js";
|
|
56
|
+
import type { LicenseStatusProvider } from "./license-status.js";
|
|
56
57
|
import type { ListReadScope } from "./list-read-scope.js";
|
|
57
58
|
import type { PolicyGrantScope } from "./policy-grant-scope.js";
|
|
58
59
|
import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
|
|
@@ -272,6 +273,11 @@ export interface ResolvedExtensionDrivers {
|
|
|
272
273
|
* tuple-half RPC arms refuse UNIMPLEMENTED with the edition reason.
|
|
273
274
|
*/
|
|
274
275
|
readonly authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
276
|
+
/**
|
|
277
|
+
* The license-status provider — undefined = compose.ts installs the
|
|
278
|
+
* built-in `absent` answer, so every edition serves getLicenseStatus.
|
|
279
|
+
*/
|
|
280
|
+
readonly licenseStatus: LicenseStatusProvider | undefined;
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
/**
|
|
@@ -325,6 +331,8 @@ export function resolveExtensions(
|
|
|
325
331
|
let policyGrantScopeDeclaredBy: string | undefined;
|
|
326
332
|
let authorizationQueries: AuthorizationQueryEngine | undefined;
|
|
327
333
|
let authorizationQueriesDeclaredBy: string | undefined;
|
|
334
|
+
let licenseStatus: LicenseStatusProvider | undefined;
|
|
335
|
+
let licenseStatusDeclaredBy: string | undefined;
|
|
328
336
|
const artifactStorageDrivers = new Map<
|
|
329
337
|
string,
|
|
330
338
|
ArtifactStorageDriverFactory
|
|
@@ -516,6 +524,16 @@ export function resolveExtensions(
|
|
|
516
524
|
authorizationQueriesDeclaredBy = unit.name;
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
if (unit.drivers?.licenseStatus !== undefined) {
|
|
528
|
+
if (licenseStatusDeclaredBy !== undefined) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`extension '${unit.name}' registers a LicenseStatusProvider, but '${licenseStatusDeclaredBy}' already did — exactly one may be composed`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
licenseStatus = unit.drivers.licenseStatus;
|
|
534
|
+
licenseStatusDeclaredBy = unit.name;
|
|
535
|
+
}
|
|
536
|
+
|
|
519
537
|
if (unit.drivers?.artifactStorageDrivers !== undefined) {
|
|
520
538
|
for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
|
|
521
539
|
if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
|
|
@@ -641,6 +659,7 @@ export function resolveExtensions(
|
|
|
641
659
|
iamPolicyStore,
|
|
642
660
|
policyGrantScope,
|
|
643
661
|
authorizationQueries,
|
|
662
|
+
licenseStatus,
|
|
644
663
|
},
|
|
645
664
|
services,
|
|
646
665
|
workers,
|
package/src/index.ts
CHANGED
|
@@ -111,6 +111,14 @@ export type {
|
|
|
111
111
|
} from "./extensions/resource-authorization.js";
|
|
112
112
|
export type { OrganizationDirectory } from "./extensions/organization-directory.js";
|
|
113
113
|
export { ALL_ORGANIZATIONS } from "./extensions/organization-directory.js";
|
|
114
|
+
// The license-status seam (drivers.licenseStatus): the provider an
|
|
115
|
+
// Enterprise composition registers so getLicenseStatus answers from its
|
|
116
|
+
// configured ticket. The built-in `absent` default is the composition
|
|
117
|
+
// root's and is not exported.
|
|
118
|
+
export type {
|
|
119
|
+
LicenseStatusProvider,
|
|
120
|
+
LicenseStatusReport,
|
|
121
|
+
} from "./extensions/license-status.js";
|
|
114
122
|
// The 20260911.11 identity-account seams (Q-IA-9): the store PORT a
|
|
115
123
|
// composition drives the domain through (drivers.identityAccountStore;
|
|
116
124
|
// a driver throws DuplicateAccountError for a held id), the federation
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the message-level rules of the plan, subscription and license
|
|
3
|
+
* contract through the shared validator every edition runs
|
|
4
|
+
* (steps/validation.ts, the same instance the transport interceptor uses).
|
|
5
|
+
*
|
|
6
|
+
* Why here and not in a conformance suite: every billing kind is cloud_only,
|
|
7
|
+
* so the open-source server never serves a Plan or a License and the
|
|
8
|
+
* cross-edition suites, where field rules are normally proven against a
|
|
9
|
+
* running server, cannot reach them. The vocabulary those kinds share with
|
|
10
|
+
* the Enterprise edition (Entitlements, LicenseClaims) is verified offline
|
|
11
|
+
* by a server that holds no store for it at all. The one place in this
|
|
12
|
+
* repository where a rule on these messages can be proven red-then-green is
|
|
13
|
+
* the validator itself, so this file drives it directly, one row per rule,
|
|
14
|
+
* a passing shape beside every refused one.
|
|
15
|
+
*
|
|
16
|
+
* What the rows pin: the instrument decides which PlanTerms a plan may carry;
|
|
17
|
+
* a license's timestamps are ordered (issue, expiry, grace); a limit is
|
|
18
|
+
* present or absent, never zero; a feature is named once. A row that
|
|
19
|
+
* stops matching means the contract moved, and the proto is where it moved.
|
|
20
|
+
*/
|
|
21
|
+
import { create } from "@bufbuild/protobuf";
|
|
22
|
+
import type { DescMessage, MessageInitShape } from "@bufbuild/protobuf";
|
|
23
|
+
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
24
|
+
import {
|
|
25
|
+
PlanInstrument,
|
|
26
|
+
PlanSpecSchema,
|
|
27
|
+
} from "@stigmer/protos/ai/stigmer/billing/plan/v1/spec_pb";
|
|
28
|
+
import { LicenseSpecSchema } from "@stigmer/protos/ai/stigmer/billing/license/v1/spec_pb";
|
|
29
|
+
import {
|
|
30
|
+
EntitlementLimitsSchema,
|
|
31
|
+
EntitlementsSchema,
|
|
32
|
+
Feature,
|
|
33
|
+
} from "@stigmer/protos/ai/stigmer/platform/v1/entitlement_pb";
|
|
34
|
+
import {
|
|
35
|
+
LicenseClaimsSchema,
|
|
36
|
+
LicenseTerm,
|
|
37
|
+
} from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
|
|
38
|
+
import { describe, expect, it } from "vitest";
|
|
39
|
+
|
|
40
|
+
import { validator } from "../steps/validation.js";
|
|
41
|
+
|
|
42
|
+
const t = (iso: string) => timestampFromDate(new Date(iso));
|
|
43
|
+
|
|
44
|
+
const customer = {
|
|
45
|
+
id: "cus_acme",
|
|
46
|
+
displayName: "Acme Corp",
|
|
47
|
+
contactEmail: "billing@acme.example",
|
|
48
|
+
};
|
|
49
|
+
const entitlements = { features: [Feature.channels] };
|
|
50
|
+
const orderedTerm = {
|
|
51
|
+
issuedAt: t("2026-09-01T00:00:00Z"),
|
|
52
|
+
expiresAt: t("2027-09-01T00:00:00Z"),
|
|
53
|
+
graceUntil: t("2027-10-01T00:00:00Z"),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** One row: a message, its init, and the rule id it must trip, or none. */
|
|
57
|
+
interface Row<Desc extends DescMessage> {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly schema: Desc;
|
|
60
|
+
readonly init: MessageInitShape<Desc>;
|
|
61
|
+
readonly refusedBy?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function row<Desc extends DescMessage>(r: Row<Desc>): Row<Desc> {
|
|
65
|
+
return r;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rows: ReadonlyArray<Row<DescMessage>> = [
|
|
69
|
+
row({
|
|
70
|
+
name: "a subscription plan with monthly terms",
|
|
71
|
+
schema: PlanSpecSchema,
|
|
72
|
+
init: {
|
|
73
|
+
instrument: PlanInstrument.subscription,
|
|
74
|
+
entitlements,
|
|
75
|
+
terms: { monthlyMinimumMicros: 1n, usageShareBasisPoints: 100 },
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
row({
|
|
79
|
+
name: "a license plan with an annual price",
|
|
80
|
+
schema: PlanSpecSchema,
|
|
81
|
+
init: {
|
|
82
|
+
instrument: PlanInstrument.license,
|
|
83
|
+
entitlements,
|
|
84
|
+
terms: { annualPriceMicros: 1n },
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
row({
|
|
88
|
+
name: "a plan with no terms at all",
|
|
89
|
+
schema: PlanSpecSchema,
|
|
90
|
+
init: { instrument: PlanInstrument.subscription, entitlements },
|
|
91
|
+
}),
|
|
92
|
+
row({
|
|
93
|
+
name: "a license plan carrying a monthly minimum",
|
|
94
|
+
schema: PlanSpecSchema,
|
|
95
|
+
init: {
|
|
96
|
+
instrument: PlanInstrument.license,
|
|
97
|
+
entitlements,
|
|
98
|
+
terms: { monthlyMinimumMicros: 1n },
|
|
99
|
+
},
|
|
100
|
+
refusedBy: "plan_spec.license_has_no_monthly_terms",
|
|
101
|
+
}),
|
|
102
|
+
row({
|
|
103
|
+
name: "a license plan carrying a usage share",
|
|
104
|
+
schema: PlanSpecSchema,
|
|
105
|
+
init: {
|
|
106
|
+
instrument: PlanInstrument.license,
|
|
107
|
+
entitlements,
|
|
108
|
+
terms: { usageShareBasisPoints: 100 },
|
|
109
|
+
},
|
|
110
|
+
refusedBy: "plan_spec.license_has_no_monthly_terms",
|
|
111
|
+
}),
|
|
112
|
+
row({
|
|
113
|
+
name: "a subscription plan carrying an annual price",
|
|
114
|
+
schema: PlanSpecSchema,
|
|
115
|
+
init: {
|
|
116
|
+
instrument: PlanInstrument.subscription,
|
|
117
|
+
entitlements,
|
|
118
|
+
terms: { annualPriceMicros: 1n },
|
|
119
|
+
},
|
|
120
|
+
refusedBy: "plan_spec.subscription_has_no_annual_price",
|
|
121
|
+
}),
|
|
122
|
+
row({
|
|
123
|
+
name: "a usage share above one hundred percent",
|
|
124
|
+
schema: PlanSpecSchema,
|
|
125
|
+
init: {
|
|
126
|
+
instrument: PlanInstrument.subscription,
|
|
127
|
+
entitlements,
|
|
128
|
+
terms: { usageShareBasisPoints: 10001 },
|
|
129
|
+
},
|
|
130
|
+
refusedBy: "int32.gte_lte",
|
|
131
|
+
}),
|
|
132
|
+
row({
|
|
133
|
+
name: "ordered license claims",
|
|
134
|
+
schema: LicenseClaimsSchema,
|
|
135
|
+
init: {
|
|
136
|
+
licenseId: "lic_1",
|
|
137
|
+
customer,
|
|
138
|
+
term: LicenseTerm.paid,
|
|
139
|
+
entitlements,
|
|
140
|
+
...orderedTerm,
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
row({
|
|
144
|
+
name: "claims expiring before they were issued",
|
|
145
|
+
schema: LicenseClaimsSchema,
|
|
146
|
+
init: {
|
|
147
|
+
licenseId: "lic_1",
|
|
148
|
+
customer,
|
|
149
|
+
term: LicenseTerm.paid,
|
|
150
|
+
entitlements,
|
|
151
|
+
...orderedTerm,
|
|
152
|
+
expiresAt: t("2026-08-01T00:00:00Z"),
|
|
153
|
+
graceUntil: t("2026-08-02T00:00:00Z"),
|
|
154
|
+
},
|
|
155
|
+
refusedBy: "license_claims.expires_after_issue",
|
|
156
|
+
}),
|
|
157
|
+
row({
|
|
158
|
+
name: "claims whose grace ends before expiry",
|
|
159
|
+
schema: LicenseClaimsSchema,
|
|
160
|
+
init: {
|
|
161
|
+
licenseId: "lic_1",
|
|
162
|
+
customer,
|
|
163
|
+
term: LicenseTerm.paid,
|
|
164
|
+
entitlements,
|
|
165
|
+
...orderedTerm,
|
|
166
|
+
graceUntil: t("2027-08-01T00:00:00Z"),
|
|
167
|
+
},
|
|
168
|
+
refusedBy: "license_claims.grace_not_before_expiry",
|
|
169
|
+
}),
|
|
170
|
+
row({
|
|
171
|
+
name: "a customer with no contact address",
|
|
172
|
+
schema: LicenseClaimsSchema,
|
|
173
|
+
init: {
|
|
174
|
+
licenseId: "lic_1",
|
|
175
|
+
customer: { id: "cus_acme", displayName: "Acme Corp" },
|
|
176
|
+
term: LicenseTerm.paid,
|
|
177
|
+
entitlements,
|
|
178
|
+
...orderedTerm,
|
|
179
|
+
},
|
|
180
|
+
refusedBy: "required",
|
|
181
|
+
}),
|
|
182
|
+
row({
|
|
183
|
+
name: "a customer whose contact address is not an email",
|
|
184
|
+
schema: LicenseClaimsSchema,
|
|
185
|
+
init: {
|
|
186
|
+
licenseId: "lic_1",
|
|
187
|
+
customer: { ...customer, contactEmail: "acme" },
|
|
188
|
+
term: LicenseTerm.paid,
|
|
189
|
+
entitlements,
|
|
190
|
+
...orderedTerm,
|
|
191
|
+
},
|
|
192
|
+
refusedBy: "string.email",
|
|
193
|
+
}),
|
|
194
|
+
row({
|
|
195
|
+
name: "a license spec whose grace ends before expiry",
|
|
196
|
+
schema: LicenseSpecSchema,
|
|
197
|
+
init: {
|
|
198
|
+
customer,
|
|
199
|
+
entitlements,
|
|
200
|
+
term: LicenseTerm.trial,
|
|
201
|
+
expiresAt: orderedTerm.expiresAt,
|
|
202
|
+
graceUntil: t("2027-08-01T00:00:00Z"),
|
|
203
|
+
},
|
|
204
|
+
refusedBy: "license_spec.grace_not_before_expiry",
|
|
205
|
+
}),
|
|
206
|
+
row({
|
|
207
|
+
name: "a license spec with grace on the expiry instant",
|
|
208
|
+
schema: LicenseSpecSchema,
|
|
209
|
+
init: {
|
|
210
|
+
customer,
|
|
211
|
+
entitlements,
|
|
212
|
+
term: LicenseTerm.trial,
|
|
213
|
+
expiresAt: orderedTerm.expiresAt,
|
|
214
|
+
graceUntil: orderedTerm.expiresAt,
|
|
215
|
+
},
|
|
216
|
+
}),
|
|
217
|
+
row({
|
|
218
|
+
name: "a limit of zero, which is never what an operator meant",
|
|
219
|
+
schema: EntitlementLimitsSchema,
|
|
220
|
+
init: { maxOrganizations: 0 },
|
|
221
|
+
refusedBy: "int32.gt",
|
|
222
|
+
}),
|
|
223
|
+
row({
|
|
224
|
+
name: "an absent limit, which is no limit",
|
|
225
|
+
schema: EntitlementLimitsSchema,
|
|
226
|
+
init: {},
|
|
227
|
+
}),
|
|
228
|
+
row({
|
|
229
|
+
name: "a feature named twice",
|
|
230
|
+
schema: EntitlementsSchema,
|
|
231
|
+
init: { features: [Feature.channels, Feature.channels] },
|
|
232
|
+
refusedBy: "repeated.unique",
|
|
233
|
+
}),
|
|
234
|
+
row({
|
|
235
|
+
name: "the unspecified feature",
|
|
236
|
+
schema: EntitlementsSchema,
|
|
237
|
+
init: { features: [Feature.feature_unspecified] },
|
|
238
|
+
refusedBy: "enum.not_in",
|
|
239
|
+
}),
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
describe("the billing contract's rules, through the shared validator", () => {
|
|
243
|
+
for (const r of rows) {
|
|
244
|
+
const verdict =
|
|
245
|
+
r.refusedBy === undefined ? "passes" : `is refused by ${r.refusedBy}`;
|
|
246
|
+
it(`${r.name} ${verdict}`, () => {
|
|
247
|
+
const result = validator().validate(r.schema, create(r.schema, r.init));
|
|
248
|
+
if (r.refusedBy === undefined) {
|
|
249
|
+
expect(result.kind).toBe("valid");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (result.kind !== "invalid") {
|
|
253
|
+
throw new Error(`expected a refusal, got ${result.kind}`);
|
|
254
|
+
}
|
|
255
|
+
expect(result.error.violations.map((v) => v.ruleId)).toContain(
|
|
256
|
+
r.refusedBy,
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
});
|