@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.
Files changed (33) hide show
  1. package/dist/boot/compose.d.ts +0 -12
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +6 -2
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/platform/controller.d.ts +24 -11
  6. package/dist/domain/platform/controller.d.ts.map +1 -1
  7. package/dist/domain/platform/controller.js +47 -3
  8. package/dist/domain/platform/controller.js.map +1 -1
  9. package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
  10. package/dist/extensions/drivers.d.ts +11 -0
  11. package/dist/extensions/drivers.d.ts.map +1 -1
  12. package/dist/extensions/license-status.d.ts +64 -0
  13. package/dist/extensions/license-status.d.ts.map +1 -0
  14. package/dist/extensions/license-status.js +9 -0
  15. package/dist/extensions/license-status.js.map +1 -0
  16. package/dist/extensions/registry.d.ts +6 -0
  17. package/dist/extensions/registry.d.ts.map +1 -1
  18. package/dist/extensions/registry.js +10 -0
  19. package/dist/extensions/registry.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/package.json +5 -5
  24. package/src/boot/compose.ts +6 -14
  25. package/src/domain/platform/__tests__/platform.test.ts +147 -46
  26. package/src/domain/platform/controller.ts +77 -13
  27. package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
  28. package/src/extensions/__tests__/license-status-points.test.ts +50 -0
  29. package/src/extensions/drivers.ts +11 -0
  30. package/src/extensions/license-status.ts +71 -0
  31. package/src/extensions/registry.ts +19 -0
  32. package/src/index.ts +8 -0
  33. 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
+ });