@stigmer/server 3.16.0 → 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 (48) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +6 -0
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/platform/controller.d.ts +26 -6
  5. package/dist/domain/platform/controller.d.ts.map +1 -1
  6. package/dist/domain/platform/controller.js +44 -1
  7. package/dist/domain/platform/controller.js.map +1 -1
  8. package/dist/domain/plugin/materialize/agent.d.ts +10 -1
  9. package/dist/domain/plugin/materialize/agent.d.ts.map +1 -1
  10. package/dist/domain/plugin/materialize/agent.js +35 -1
  11. package/dist/domain/plugin/materialize/agent.js.map +1 -1
  12. package/dist/domain/plugin/materialize/plan.d.ts.map +1 -1
  13. package/dist/domain/plugin/materialize/plan.js +1 -1
  14. package/dist/domain/plugin/materialize/plan.js.map +1 -1
  15. package/dist/domain/project/reconcile.d.ts.map +1 -1
  16. package/dist/domain/project/reconcile.js +14 -10
  17. package/dist/domain/project/reconcile.js.map +1 -1
  18. package/dist/domain/workflow/registry/data/task-kind-registry.json +4 -1
  19. package/dist/extensions/drivers.d.ts +11 -0
  20. package/dist/extensions/drivers.d.ts.map +1 -1
  21. package/dist/extensions/license-status.d.ts +64 -0
  22. package/dist/extensions/license-status.d.ts.map +1 -0
  23. package/dist/extensions/license-status.js +9 -0
  24. package/dist/extensions/license-status.js.map +1 -0
  25. package/dist/extensions/registry.d.ts +6 -0
  26. package/dist/extensions/registry.d.ts.map +1 -1
  27. package/dist/extensions/registry.js +10 -0
  28. package/dist/extensions/registry.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/boot/compose.ts +6 -0
  34. package/src/domain/platform/__tests__/platform.test.ts +148 -1
  35. package/src/domain/platform/controller.ts +76 -6
  36. package/src/domain/plugin/__tests__/materialize.test.ts +60 -1
  37. package/src/domain/plugin/__tests__/plugin.test.ts +6 -0
  38. package/src/domain/plugin/materialize/agent.ts +38 -0
  39. package/src/domain/plugin/materialize/plan.ts +7 -1
  40. package/src/domain/project/__tests__/reconcile.test.ts +51 -5
  41. package/src/domain/project/reconcile.ts +20 -15
  42. package/src/domain/workflow/registry/data/task-kind-registry.json +4 -1
  43. package/src/extensions/__tests__/license-status-points.test.ts +50 -0
  44. package/src/extensions/drivers.ts +11 -0
  45. package/src/extensions/license-status.ts +71 -0
  46. package/src/extensions/registry.ts +19 -0
  47. package/src/index.ts +8 -0
  48. package/src/pipeline/__tests__/billing-contract-rules.test.ts +260 -0
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Platform controller — ports pkg/domain/platform: the server's
3
3
  * self-description surface (NOT a resource domain — no store, no
4
- * pipelines; three direct handlers on PlatformQueryController).
4
+ * pipelines; four direct handlers on PlatformQueryController).
5
5
  *
6
- * Proven by platform.conformance.test.ts (getServerInfo +
7
- * getRunnerBootstrapConfig shapes; getRunnerScopedToken is deliberately
8
- * excluded there — its arms are exercised mid-execution) and
9
- * __tests__/platform.test.ts (the fail-soft mint matrix, the #15
10
- * composed-server pattern).
6
+ * Proven by platform.conformance.test.ts (getServerInfo,
7
+ * getRunnerBootstrapConfig and getLicenseStatus shapes; getRunnerScopedToken
8
+ * is deliberately excluded there — its arms are exercised mid-execution)
9
+ * and __tests__/platform.test.ts (the fail-soft mint matrix, the license
10
+ * status arms, the #15 composed-server pattern).
11
+ *
12
+ * getLicenseStatus is the one arm every edition answers from a driver
13
+ * point: a server's license is a fact about the server, like its edition,
14
+ * and the provider behind `deps.licenseStatus` is the one place that
15
+ * knows it (extensions/license-status.ts). The controller owns the clock
16
+ * — it hands the provider one instant and stamps checked_at from the same
17
+ * one — and copies the provider's presence contract to the wire unchanged.
11
18
  *
12
19
  * getRunnerScopedToken is the mint side of the runner-token lane
13
20
  * (oss#535) — the seam src/runnerauth/. What the token IS depends on the
@@ -41,8 +48,11 @@
41
48
  */
42
49
  import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
43
50
  import { create } from "@bufbuild/protobuf";
51
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
44
52
 
53
+ import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
45
54
  import {
55
+ GetLicenseStatusOutputSchema,
46
56
  GetRunnerBootstrapConfigOutputSchema,
47
57
  GetRunnerScopedTokenOutputSchema,
48
58
  GetServerInfoOutputSchema,
@@ -50,6 +60,7 @@ import {
50
60
  ServerEdition,
51
61
  } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
52
62
  import type {
63
+ GetLicenseStatusOutput,
53
64
  GetRunnerBootstrapConfigOutput,
54
65
  GetRunnerScopedTokenInput,
55
66
  GetRunnerScopedTokenOutput,
@@ -57,6 +68,7 @@ import type {
57
68
  } from "@stigmer/protos/ai/stigmer/platform/v1/server_info_pb";
58
69
 
59
70
  import type { Logger } from "../../boot/logger.js";
71
+ import type { LicenseStatusProvider } from "../../extensions/license-status.js";
60
72
  import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
61
73
  import type {
62
74
  RunnerCredentialProvider,
@@ -88,6 +100,18 @@ export interface PlatformControllerDeps {
88
100
  * cloud composition answers `cloud` without forking this controller.
89
101
  */
90
102
  readonly edition: ServerEdition;
103
+ /**
104
+ * What license this server holds (extensions/license-status.ts). The
105
+ * composition root installs the built-in `absent` provider when no unit
106
+ * registers one, so the field is required: a server that cannot answer
107
+ * the question is a wiring error, never a silent no-op.
108
+ */
109
+ readonly licenseStatus: LicenseStatusProvider;
110
+ /**
111
+ * The clock getLicenseStatus evaluates against — injected so a test can
112
+ * pin that the provider's instant and checked_at are the same one.
113
+ */
114
+ readonly now: () => Date;
91
115
  readonly logger: Logger;
92
116
  }
93
117
 
@@ -98,6 +122,7 @@ export function registerPlatformServices(
98
122
  ): void {
99
123
  router.service(PlatformQueryController, {
100
124
  getServerInfo: () => getServerInfo(deps),
125
+ getLicenseStatus: () => getLicenseStatus(deps),
101
126
  getRunnerBootstrapConfig: (_input, ctx) =>
102
127
  getRunnerBootstrapConfig(deps, ctx),
103
128
  getRunnerScopedToken: (input, ctx) =>
@@ -113,6 +138,51 @@ function getServerInfo(deps: PlatformControllerDeps): GetServerInfoOutput {
113
138
  });
114
139
  }
115
140
 
141
+ /**
142
+ * The state of the license this server holds, as the composed provider
143
+ * reports it at one instant.
144
+ *
145
+ * The report's arm (extensions/license-status.ts) is copied to the wire
146
+ * field for field, so the output's presence contract holds by construction:
147
+ * claims exactly when a ticket verified, key_id whenever one was presented.
148
+ * checked_at is stamped from the SAME instant the provider evaluated
149
+ * against, so the answer can never say "valid as of now" about a moment
150
+ * other than the one it was asked about. No caller check beyond
151
+ * authentication: the annotation is is_skip_authorization because the
152
+ * banner this feeds is for every signed-in person, and the handler adds
153
+ * none (the RPC comment states it).
154
+ */
155
+ async function getLicenseStatus(
156
+ deps: PlatformControllerDeps,
157
+ ): Promise<GetLicenseStatusOutput> {
158
+ const now = deps.now();
159
+ const report = await deps.licenseStatus.status(now);
160
+ const output = create(GetLicenseStatusOutputSchema, {
161
+ state: report.state,
162
+ checkedAt: timestampFromDate(now),
163
+ });
164
+ switch (report.state) {
165
+ case LicenseState.absent:
166
+ return output;
167
+ case LicenseState.invalid:
168
+ output.keyId = report.keyId;
169
+ return output;
170
+ case LicenseState.valid:
171
+ case LicenseState.expiring:
172
+ case LicenseState.grace:
173
+ case LicenseState.expired:
174
+ output.keyId = report.keyId;
175
+ output.claims = report.claims;
176
+ return output;
177
+ default: {
178
+ const exhaustive: never = report;
179
+ throw new Error(
180
+ `unhandled license report: ${JSON.stringify(exhaustive)}`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
116
186
  /**
117
187
  * Go GetRunnerBootstrapConfig: the Temporal coordinates an embedded runner
118
188
  * should connect to so it can self-bootstrap from a token alone. The
@@ -7,7 +7,9 @@
7
7
  * agent, the `mcpServers` key-to-slug rule, a skill archive rooted at its
8
8
  * SKILL.md with the plugin's labels on the request, a version the tag
9
9
  * pattern rejects becoming a warning, an overlay that redefines the
10
- * transport refused, and the convergence and drop rules over members.
10
+ * transport refused, the composed agent declaring its tools' variables
11
+ * (OAuth targets excluded, an authored agent left as written), and the
12
+ * convergence and drop rules over members.
11
13
  */
12
14
  import { describe, expect, it } from "vitest";
13
15
 
@@ -19,8 +21,11 @@ import type { PluginPackage } from "@stigmer/plugin-package";
19
21
  import { cursorPlugin, openPlugin } from "@stigmer/plugin-package/testing";
20
22
  import type { PluginFixture } from "@stigmer/plugin-package/testing";
21
23
  import { create } from "@bufbuild/protobuf";
24
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
25
+ import { AgentSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
22
26
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
23
27
  import {
28
+ McpServerAuthSchema,
24
29
  McpServerSpecSchema,
25
30
  StdioServerConfigSchema,
26
31
  } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
@@ -224,6 +229,60 @@ describe("planMaterialization", () => {
224
229
  expect(builtPlan.skills[0]!.request.tag).toBe("");
225
230
  });
226
231
 
232
+ it("declares the servers' variables on the composed agent so a session asks for them", () => {
233
+ expect(plan.agent!.resource.spec?.env).toEqual({
234
+ GITHUB_TOKEN: expect.objectContaining({ isSecret: true, optional: false }),
235
+ });
236
+ });
237
+
238
+ it("leaves an OAuth-managed target out of the agent's declarations", () => {
239
+ const overlay = create(McpServerSchema, {
240
+ spec: create(McpServerSpecSchema, {
241
+ auth: create(McpServerAuthSchema, { targetEnvVar: "GITHUB_TOKEN" }),
242
+ }),
243
+ });
244
+ const withOAuth = planMaterialization(
245
+ pkg,
246
+ inMemoryPluginFiles(thermos),
247
+ {
248
+ workflows: [],
249
+ mcpServers: [
250
+ {
251
+ path: "ai.stigmer/mcp-servers/my_github.yaml",
252
+ server: "my_github",
253
+ resource: overlay,
254
+ },
255
+ ],
256
+ },
257
+ IDENTITY,
258
+ "",
259
+ );
260
+ // The server still declares it (the connect flow reads it there); the
261
+ // agent does not, because the value comes from a managed environment.
262
+ expect(withOAuth.mcpServers[0]!.resource.spec?.env).toHaveProperty("GITHUB_TOKEN");
263
+ expect(withOAuth.agent!.resource.spec?.env).toEqual({});
264
+ });
265
+
266
+ it("takes an authored agent as written, declarations included", () => {
267
+ const authored = create(AgentSchema, {
268
+ spec: create(AgentSpecSchema, {
269
+ instructions: "The author's own instructions for this agent.",
270
+ }),
271
+ });
272
+ const withOverlay = planMaterialization(
273
+ pkg,
274
+ inMemoryPluginFiles(thermos),
275
+ {
276
+ agent: { path: "ai.stigmer/agent.yaml", resource: authored },
277
+ workflows: [],
278
+ mcpServers: [],
279
+ },
280
+ IDENTITY,
281
+ "",
282
+ );
283
+ expect(withOverlay.agent!.resource.spec?.env).toEqual({});
284
+ });
285
+
227
286
  it("refuses a server overlay that redefines the transport", () => {
228
287
  const overlay = create(McpServerSchema, {
229
288
  spec: create(McpServerSpecSchema, {
@@ -296,6 +296,12 @@ describe("Plugin push — materialisation", () => {
296
296
  isSecret: true,
297
297
  optional: false,
298
298
  });
299
+ // The agent declares the same variable, which is what makes a session
300
+ // ask for it and the execution's least-privilege filter pass it.
301
+ expect(agent.spec?.env["GITHUB_TOKEN"]).toMatchObject({
302
+ isSecret: true,
303
+ optional: false,
304
+ });
299
305
  });
300
306
 
301
307
  it("installs an MCP-only plugin as its servers and no agent", async () => {
@@ -14,6 +14,18 @@
14
14
  * `mcp_server_usage`, every `agents/*.md` as a `sub_agent` with access to
15
15
  * all the plugin's servers and the skills it asked for.
16
16
  *
17
+ * The composed agent declares in `env` the union of its servers' variables,
18
+ * OAuth-managed target variables excluded. An execution filters its
19
+ * environment to the AGENT's declared keys, and every client's session
20
+ * start asks the user for the AGENT's declared keys, so an agent declaring
21
+ * nothing over a server declaring `${API_TOKEN}` is never asked for the
22
+ * token and fails at its first tool call. The declaration on the agent is
23
+ * what routes the user's values to the execution; the declaration on the
24
+ * server stays too (the connect flow and the runner's per-server filter
25
+ * read it there). An OAuth target is excluded because its value is injected
26
+ * from a managed environment, never asked of the user. An author's
27
+ * `agent.yaml` is taken as written: they chose what to declare.
28
+ *
17
29
  * Model hints are recorded, never applied: a dialect's `model` becomes a
18
30
  * status warning and the sub-agent runs on the session's model. The
19
31
  * catalog seam exposes neither family nor cost tier, the runner drops a
@@ -25,6 +37,7 @@
25
37
  import { create } from "@bufbuild/protobuf";
26
38
 
27
39
  import type { PluginPackage, PluginSubAgent } from "@stigmer/plugin-package";
40
+ import type { EnvVarDeclaration } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
28
41
  import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
29
42
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
30
43
  import {
@@ -47,6 +60,7 @@ import { renderDefaultAgentInstructions } from "./default-agent-instructions.js"
47
60
  import { memberMetadata } from "./identity.js";
48
61
  import type { PluginIdentity } from "./identity.js";
49
62
  import { mcpServerSlugOf } from "./mcp-servers.js";
63
+ import type { PlannedMcpServer } from "./mcp-servers.js";
50
64
  import { skillSlugOf } from "./skills.js";
51
65
 
52
66
  export interface PlannedAgent {
@@ -67,6 +81,7 @@ export function carriesAgent(plugin: PluginPackage): boolean {
67
81
  export function planAgent(
68
82
  plugin: PluginPackage,
69
83
  overlayAgent: Agent | undefined,
84
+ mcpServers: readonly PlannedMcpServer[],
70
85
  identity: PluginIdentity,
71
86
  warnings: PluginWarning[],
72
87
  ): PlannedAgent | undefined {
@@ -95,6 +110,7 @@ export function planAgent(
95
110
  const spec = create(AgentSpecSchema, {
96
111
  description: plugin.description ?? "",
97
112
  instructions: renderDefaultAgentInstructions(plugin),
113
+ env: toolVariables(mcpServers),
98
114
  skillRefs: [...skillSlugByName.values()].map((slug) =>
99
115
  create(ApiResourceReferenceSchema, {
100
116
  org: identity.org,
@@ -127,6 +143,28 @@ export function planAgent(
127
143
  };
128
144
  }
129
145
 
146
+ /**
147
+ * The variables the agent's tools need from the user: every server's
148
+ * declarations, minus each server's OAuth target. Two servers declaring
149
+ * one name share one declaration; the first server's wins, and the
150
+ * library already refuses a plugin whose variables disagree about a name.
151
+ */
152
+ export function toolVariables(
153
+ mcpServers: readonly PlannedMcpServer[],
154
+ ): Record<string, EnvVarDeclaration> {
155
+ const declarations: Record<string, EnvVarDeclaration> = {};
156
+ for (const server of mcpServers) {
157
+ const spec = server.resource.spec;
158
+ if (spec === undefined) continue;
159
+ const oauthTarget = spec.auth?.targetEnvVar ?? "";
160
+ for (const [name, declaration] of Object.entries(spec.env)) {
161
+ if (name === oauthTarget || name in declarations) continue;
162
+ declarations[name] = declaration;
163
+ }
164
+ }
165
+ return declarations;
166
+ }
167
+
130
168
  function planSubAgent(
131
169
  subAgent: PluginSubAgent,
132
170
  identity: PluginIdentity,
@@ -96,7 +96,13 @@ export function planMaterialization(
96
96
  ),
97
97
  identity,
98
98
  );
99
- const agent = planAgent(plugin, overlays.agent?.resource, identity, warnings);
99
+ const agent = planAgent(
100
+ plugin,
101
+ overlays.agent?.resource,
102
+ mcpServers,
103
+ identity,
104
+ warnings,
105
+ );
100
106
  const workflows = planWorkflows(overlays.workflows, identity);
101
107
 
102
108
  const members: PlannedMember[] = [
@@ -237,10 +237,12 @@ describe("orphan deletion (Go service_test.go)", () => {
237
237
  ]);
238
238
  });
239
239
 
240
- it("orphan lookup is slug-only, matching across orgs (pinned Go behavior)", async () => {
241
- // Seeded under org "local"; the orphan reference carries a DIFFERENT
242
- // org — resolution still matches, because Go's resolveResourceID
243
- // filters by slug alone (reconcile/service.go:126).
240
+ it("orphan lookup is scoped to the reference's org: another org's same-slug resource is never touched", async () => {
241
+ // Slugs are org-scoped identifiers. Seeded under org "local"; the orphan
242
+ // reference names a DIFFERENT org, so it must not resolve to this row.
243
+ // (The Go port matched by slug alone; on a backend with a system org
244
+ // beside the user's, a prune could then delete the wrong org's
245
+ // resource — stigmer/stigmer#1151.)
244
246
  await seedAgent("agent-id-x", "cross-org-agent");
245
247
  const deleter = new RecordingDeleter();
246
248
  const previous = [
@@ -251,9 +253,53 @@ describe("orphan deletion (Go service_test.go)", () => {
251
253
  }),
252
254
  ];
253
255
  const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
256
+ expect(result.removed).toHaveLength(0);
257
+ expect(result.errors).toHaveLength(1);
258
+ expect(result.errors[0]?.resourceKey).toBe("agent:cross-org-agent");
259
+ expect(result.errors[0]?.message).toBe("failed to resolve resource for deletion");
260
+ expect(deleter.calls).toHaveLength(0);
261
+ });
262
+
263
+ it("orphan lookup picks the reference's org when two orgs hold the slug", async () => {
264
+ await seedAgent("agent-id-local", "shared-slug");
265
+ await store.saveResource(
266
+ ApiResourceKind.agent,
267
+ "agent-id-other",
268
+ AgentSchema,
269
+ create(AgentSchema, {
270
+ apiVersion: "agentic.stigmer.ai/v1",
271
+ kind: "Agent",
272
+ metadata: { id: "agent-id-other", name: "shared-slug", slug: "shared-slug", org: "another-org" },
273
+ }),
274
+ );
275
+ const deleter = new RecordingDeleter();
276
+ const previous = [
277
+ create(ApiResourceReferenceSchema, {
278
+ org: "another-org",
279
+ kind: ApiResourceKind.agent,
280
+ slug: "shared-slug",
281
+ }),
282
+ ];
283
+ const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
284
+ expect(result.removed).toHaveLength(1);
285
+ expect(deleter.calls).toEqual([
286
+ { kind: ApiResourceKind.agent, resourceId: "agent-id-other" },
287
+ ]);
288
+ });
289
+
290
+ it("a reference without an org keeps the unscoped lookup (a pre-scoping client's project)", async () => {
291
+ await seedAgent("agent-id-y", "legacy-agent");
292
+ const deleter = new RecordingDeleter();
293
+ const previous = [
294
+ create(ApiResourceReferenceSchema, {
295
+ kind: ApiResourceKind.agent,
296
+ slug: "legacy-agent",
297
+ }),
298
+ ];
299
+ const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
254
300
  expect(result.removed).toHaveLength(1);
255
301
  expect(deleter.calls).toEqual([
256
- { kind: ApiResourceKind.agent, resourceId: "agent-id-x" },
302
+ { kind: ApiResourceKind.agent, resourceId: "agent-id-y" },
257
303
  ]);
258
304
  });
259
305
 
@@ -37,6 +37,7 @@ import { ReconciliationSummarySchema } from "@stigmer/protos/ai/stigmer/tenancy/
37
37
  import type { ReconciliationSummary } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/status_pb";
38
38
 
39
39
  import type { Logger } from "../../boot/logger.js";
40
+ import { findResourceBySlug } from "../../pipeline/steps/helpers.js";
40
41
  import { metadataOf } from "../../pipeline/steps/shapes.js";
41
42
  import type { Store } from "../../store/interface.js";
42
43
 
@@ -205,28 +206,32 @@ async function deleteOrphans(
205
206
  }
206
207
 
207
208
  /**
208
- * Resolves an orphan reference to its resource id — Go resolveResourceID:
209
- * FindByField on metadata.slug, SLUG ONLY, no org filter (the pinned
210
- * behavior: first match of that kind+slug across all orgs; tolerable in
211
- * single-tenant OSS).
209
+ * Resolves an orphan reference to its resource id, within the org the
210
+ * reference names. Slugs are org-scoped identifiers: a local backend holds
211
+ * the `stigmer` system org beside the user's, a self-host holds many, and
212
+ * the same slug may exist in several. The Go port matched by slug alone and
213
+ * could prune another org's resource (stigmer/stigmer#1151); the CLI has
214
+ * always stamped `org` on every member reference, so scoping is a change
215
+ * of lookup, not of contract. A reference without an org (a project written
216
+ * before members carried one) keeps the unscoped match, the helper's
217
+ * documented empty-org arm.
212
218
  */
213
219
  async function resolveResourceId(
214
220
  store: Store,
215
221
  ref: ApiResourceReference,
216
222
  ): Promise<string> {
217
223
  const schema = schemaForKind(ref.kind);
218
- let resource;
219
- try {
220
- resource = await store.findByField(
221
- ref.kind,
222
- "metadata.slug",
223
- ref.slug,
224
- schema,
225
- );
226
- } catch (cause) {
224
+ const resource = await findResourceBySlug(
225
+ store,
226
+ ref.kind,
227
+ schema,
228
+ ref.slug,
229
+ ref.org,
230
+ );
231
+ if (resource === undefined) {
232
+ const where = ref.org === "" ? "" : ` in org '${ref.org}'`;
227
233
  throw new Error(
228
- `resource ${kindName(ref.kind)}/${ref.slug} not found: ${cause instanceof Error ? cause.message : String(cause)}`,
229
- { cause },
234
+ `resource ${kindName(ref.kind)}/${ref.slug} not found${where}`,
230
235
  );
231
236
  }
232
237
 
@@ -1518,7 +1518,10 @@
1518
1518
  "schedule",
1519
1519
  "memory",
1520
1520
  "plugin",
1521
- "project"
1521
+ "project",
1522
+ "plan",
1523
+ "subscription",
1524
+ "license"
1522
1525
  ],
1523
1526
  "type": "string"
1524
1527
  },
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Pins the licenseStatus driver point in the single-instance shape the
3
+ * registry already enforces for organizationDirectory and identityFederation:
4
+ *
5
+ * - `drivers.licenseStatus` — the provider that answers what license this
6
+ * server holds (an Enterprise unit registers the one that verifies a
7
+ * configured ticket); single instance; absent = the built-in `absent`
8
+ * provider installs at the compose.ts consumption site, so every
9
+ * edition answers getLicenseStatus and only one ever answers anything
10
+ * but absent.
11
+ *
12
+ * The controller's use of the resolved provider is pinned in
13
+ * domain/platform/__tests__/platform.test.ts; this file pins the registry.
14
+ */
15
+ import { LicenseState } from "@stigmer/protos/ai/stigmer/platform/v1/license_pb";
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type { LicenseStatusProvider } from "../license-status.js";
19
+ import { resolveExtensions } from "../registry.js";
20
+
21
+ // An invalid report is the smallest complete one a provider can give: the
22
+ // registry test needs an instance to carry through, not a verified ticket.
23
+ const provider: LicenseStatusProvider = {
24
+ status: () =>
25
+ Promise.resolve({ state: LicenseState.invalid, keyId: "lk_test" }),
26
+ };
27
+
28
+ describe("the licenseStatus driver point", () => {
29
+ it("is undefined with no extensions — the built-in absent provider serves", () => {
30
+ expect(resolveExtensions([]).drivers.licenseStatus).toBeUndefined();
31
+ });
32
+
33
+ it("carries the one registered instance through", () => {
34
+ const resolved = resolveExtensions([
35
+ { name: "enterprise-license", drivers: { licenseStatus: provider } },
36
+ ]);
37
+ expect(resolved.drivers.licenseStatus).toBe(provider);
38
+ });
39
+
40
+ it("throws on a second registration, naming both units", () => {
41
+ expect(() =>
42
+ resolveExtensions([
43
+ { name: "license-a", drivers: { licenseStatus: provider } },
44
+ { name: "license-b", drivers: { licenseStatus: provider } },
45
+ ]),
46
+ ).toThrowError(
47
+ /extension 'license-b' registers a LicenseStatusProvider, but 'license-a' already did/,
48
+ );
49
+ });
50
+ });
@@ -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,