@stigmer/server 3.24.0 → 3.25.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 (208) hide show
  1. package/dist/authorization/authorizer.d.ts +10 -4
  2. package/dist/authorization/authorizer.d.ts.map +1 -1
  3. package/dist/authorization/authorizer.js +11 -4
  4. package/dist/authorization/authorizer.js.map +1 -1
  5. package/dist/authorization/model/index.d.ts +4 -4
  6. package/dist/authorization/model/index.d.ts.map +1 -1
  7. package/dist/authorization/model/index.js +2 -0
  8. package/dist/authorization/model/index.js.map +1 -1
  9. package/dist/authorization/model/oauth_app.d.ts.map +1 -1
  10. package/dist/authorization/model/oauth_app.js +3 -2
  11. package/dist/authorization/model/oauth_app.js.map +1 -1
  12. package/dist/authorization/model/platform_client.d.ts +2 -0
  13. package/dist/authorization/model/platform_client.d.ts.map +1 -0
  14. package/dist/authorization/model/platform_client.js +31 -0
  15. package/dist/authorization/model/platform_client.js.map +1 -0
  16. package/dist/boot/compose.d.ts.map +1 -1
  17. package/dist/boot/compose.js +88 -5
  18. package/dist/boot/compose.js.map +1 -1
  19. package/dist/boot/route-shadowing.d.ts +25 -0
  20. package/dist/boot/route-shadowing.d.ts.map +1 -0
  21. package/dist/boot/route-shadowing.js +17 -0
  22. package/dist/boot/route-shadowing.js.map +1 -0
  23. package/dist/domain/iampolicy/membership.d.ts +6 -1
  24. package/dist/domain/iampolicy/membership.d.ts.map +1 -1
  25. package/dist/domain/iampolicy/membership.js +8 -2
  26. package/dist/domain/iampolicy/membership.js.map +1 -1
  27. package/dist/domain/identityaccount/constants.d.ts +13 -0
  28. package/dist/domain/identityaccount/constants.d.ts.map +1 -1
  29. package/dist/domain/identityaccount/constants.js +17 -0
  30. package/dist/domain/identityaccount/constants.js.map +1 -1
  31. package/dist/domain/identityaccount/controller.d.ts +3 -1
  32. package/dist/domain/identityaccount/controller.d.ts.map +1 -1
  33. package/dist/domain/identityaccount/controller.js +15 -6
  34. package/dist/domain/identityaccount/controller.js.map +1 -1
  35. package/dist/domain/identityaccount/operator.d.ts.map +1 -1
  36. package/dist/domain/identityaccount/operator.js +1 -0
  37. package/dist/domain/identityaccount/operator.js.map +1 -1
  38. package/dist/domain/identityaccount/provisioning.d.ts +18 -0
  39. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -1
  40. package/dist/domain/identityaccount/provisioning.js +1 -0
  41. package/dist/domain/identityaccount/provisioning.js.map +1 -1
  42. package/dist/domain/identityaccount/resource-store.js +9 -5
  43. package/dist/domain/identityaccount/resource-store.js.map +1 -1
  44. package/dist/domain/identityaccount/steps.d.ts +11 -2
  45. package/dist/domain/identityaccount/steps.d.ts.map +1 -1
  46. package/dist/domain/identityaccount/steps.js +42 -4
  47. package/dist/domain/identityaccount/steps.js.map +1 -1
  48. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -1
  49. package/dist/domain/identityaccount/store-contract.js +33 -0
  50. package/dist/domain/identityaccount/store-contract.js.map +1 -1
  51. package/dist/domain/identityaccount/store.d.ts +7 -5
  52. package/dist/domain/identityaccount/store.d.ts.map +1 -1
  53. package/dist/domain/identityaccount/store.js.map +1 -1
  54. package/dist/domain/platform/controller.d.ts +10 -0
  55. package/dist/domain/platform/controller.d.ts.map +1 -1
  56. package/dist/domain/platform/controller.js +2 -1
  57. package/dist/domain/platform/controller.js.map +1 -1
  58. package/dist/domain/platformclient/constants.d.ts +60 -0
  59. package/dist/domain/platformclient/constants.d.ts.map +1 -0
  60. package/dist/domain/platformclient/constants.js +78 -0
  61. package/dist/domain/platformclient/constants.js.map +1 -0
  62. package/dist/domain/platformclient/controller.d.ts +59 -0
  63. package/dist/domain/platformclient/controller.d.ts.map +1 -0
  64. package/dist/domain/platformclient/controller.js +191 -0
  65. package/dist/domain/platformclient/controller.js.map +1 -0
  66. package/dist/domain/platformclient/credentials.d.ts +13 -0
  67. package/dist/domain/platformclient/credentials.d.ts.map +1 -0
  68. package/dist/domain/platformclient/credentials.js +56 -0
  69. package/dist/domain/platformclient/credentials.js.map +1 -0
  70. package/dist/domain/platformclient/mint.d.ts +23 -0
  71. package/dist/domain/platformclient/mint.d.ts.map +1 -0
  72. package/dist/domain/platformclient/mint.js +254 -0
  73. package/dist/domain/platformclient/mint.js.map +1 -0
  74. package/dist/domain/platformclient/origin-guard.d.ts +9 -0
  75. package/dist/domain/platformclient/origin-guard.d.ts.map +1 -0
  76. package/dist/domain/platformclient/origin-guard.js +81 -0
  77. package/dist/domain/platformclient/origin-guard.js.map +1 -0
  78. package/dist/domain/platformclient/resource-store.d.ts +4 -0
  79. package/dist/domain/platformclient/resource-store.d.ts.map +1 -0
  80. package/dist/domain/platformclient/resource-store.js +117 -0
  81. package/dist/domain/platformclient/resource-store.js.map +1 -0
  82. package/dist/domain/platformclient/steps.d.ts +51 -0
  83. package/dist/domain/platformclient/steps.d.ts.map +1 -0
  84. package/dist/domain/platformclient/steps.js +339 -0
  85. package/dist/domain/platformclient/steps.js.map +1 -0
  86. package/dist/domain/platformclient/store-contract.d.ts +8 -0
  87. package/dist/domain/platformclient/store-contract.d.ts.map +1 -0
  88. package/dist/domain/platformclient/store-contract.js +208 -0
  89. package/dist/domain/platformclient/store-contract.js.map +1 -0
  90. package/dist/domain/platformclient/store.d.ts +58 -0
  91. package/dist/domain/platformclient/store.d.ts.map +1 -0
  92. package/dist/domain/platformclient/store.js +13 -0
  93. package/dist/domain/platformclient/store.js.map +1 -0
  94. package/dist/domain/platformclient/system-managed.d.ts +18 -0
  95. package/dist/domain/platformclient/system-managed.d.ts.map +1 -0
  96. package/dist/domain/platformclient/system-managed.js +80 -0
  97. package/dist/domain/platformclient/system-managed.js.map +1 -0
  98. package/dist/domain/platformclient/token-controller.d.ts +11 -0
  99. package/dist/domain/platformclient/token-controller.d.ts.map +1 -0
  100. package/dist/domain/platformclient/token-controller.js +32 -0
  101. package/dist/domain/platformclient/token-controller.js.map +1 -0
  102. package/dist/domain/platformclient/verifier.d.ts +10 -0
  103. package/dist/domain/platformclient/verifier.d.ts.map +1 -0
  104. package/dist/domain/platformclient/verifier.js +76 -0
  105. package/dist/domain/platformclient/verifier.js.map +1 -0
  106. package/dist/encryption/key-manager.d.ts +20 -3
  107. package/dist/encryption/key-manager.d.ts.map +1 -1
  108. package/dist/encryption/key-manager.js +64 -42
  109. package/dist/encryption/key-manager.js.map +1 -1
  110. package/dist/extensions/caller-guards.d.ts +8 -3
  111. package/dist/extensions/caller-guards.d.ts.map +1 -1
  112. package/dist/extensions/drivers.d.ts +37 -0
  113. package/dist/extensions/drivers.d.ts.map +1 -1
  114. package/dist/extensions/guest-token-minting.d.ts +26 -0
  115. package/dist/extensions/guest-token-minting.d.ts.map +1 -0
  116. package/dist/extensions/guest-token-minting.js +2 -0
  117. package/dist/extensions/guest-token-minting.js.map +1 -0
  118. package/dist/extensions/identity.d.ts +6 -4
  119. package/dist/extensions/identity.d.ts.map +1 -1
  120. package/dist/extensions/identity.js.map +1 -1
  121. package/dist/extensions/registry.d.ts +30 -1
  122. package/dist/extensions/registry.d.ts.map +1 -1
  123. package/dist/extensions/registry.js +34 -1
  124. package/dist/extensions/registry.js.map +1 -1
  125. package/dist/index.d.ts +13 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +6 -0
  128. package/dist/index.js.map +1 -1
  129. package/dist/pipeline/interceptors/auth.d.ts +14 -0
  130. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  131. package/dist/pipeline/interceptors/auth.js +24 -0
  132. package/dist/pipeline/interceptors/auth.js.map +1 -1
  133. package/dist/pipeline/steps/build-update-state.d.ts +11 -1
  134. package/dist/pipeline/steps/build-update-state.d.ts.map +1 -1
  135. package/dist/pipeline/steps/build-update-state.js +5 -2
  136. package/dist/pipeline/steps/build-update-state.js.map +1 -1
  137. package/dist/platformtoken/envelope.d.ts +100 -0
  138. package/dist/platformtoken/envelope.d.ts.map +1 -0
  139. package/dist/platformtoken/envelope.js +184 -0
  140. package/dist/platformtoken/envelope.js.map +1 -0
  141. package/dist/platformtoken/key-ring.d.ts +85 -0
  142. package/dist/platformtoken/key-ring.d.ts.map +1 -0
  143. package/dist/platformtoken/key-ring.js +164 -0
  144. package/dist/platformtoken/key-ring.js.map +1 -0
  145. package/package.json +6 -6
  146. package/src/authorization/README.md +1 -1
  147. package/src/authorization/__tests__/authorizer.test.ts +6 -2
  148. package/src/authorization/__tests__/wire-permissions.test.ts +1 -0
  149. package/src/authorization/authorizer.ts +11 -4
  150. package/src/authorization/model/__tests__/registry.test.ts +8 -1
  151. package/src/authorization/model/index.ts +6 -4
  152. package/src/authorization/model/oauth_app.ts +3 -2
  153. package/src/authorization/model/platform_client.ts +43 -0
  154. package/src/boot/__tests__/auth-enabled-composition.test.ts +2 -1
  155. package/src/boot/__tests__/route-shadowing.test.ts +67 -0
  156. package/src/boot/compose.ts +90 -5
  157. package/src/boot/route-shadowing.ts +47 -0
  158. package/src/domain/iampolicy/__tests__/membership.test.ts +7 -0
  159. package/src/domain/iampolicy/membership.ts +8 -2
  160. package/src/domain/identityaccount/__tests__/platform-client-accounts.test.ts +124 -0
  161. package/src/domain/identityaccount/__tests__/resource-store.test.ts +1 -0
  162. package/src/domain/identityaccount/constants.ts +20 -0
  163. package/src/domain/identityaccount/controller.ts +16 -4
  164. package/src/domain/identityaccount/operator.ts +1 -0
  165. package/src/domain/identityaccount/provisioning.ts +17 -0
  166. package/src/domain/identityaccount/resource-store.ts +9 -5
  167. package/src/domain/identityaccount/steps.ts +58 -3
  168. package/src/domain/identityaccount/store-contract.ts +51 -0
  169. package/src/domain/identityaccount/store.ts +7 -5
  170. package/src/domain/platform/__tests__/platform.test.ts +7 -1
  171. package/src/domain/platform/controller.ts +12 -1
  172. package/src/domain/platformclient/__tests__/controller.test.ts +397 -0
  173. package/src/domain/platformclient/__tests__/credentials.test.ts +46 -0
  174. package/src/domain/platformclient/__tests__/mint.test.ts +415 -0
  175. package/src/domain/platformclient/__tests__/resource-store.test.ts +140 -0
  176. package/src/domain/platformclient/__tests__/system-managed.test.ts +101 -0
  177. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +207 -0
  178. package/src/domain/platformclient/constants.ts +120 -0
  179. package/src/domain/platformclient/controller.ts +449 -0
  180. package/src/domain/platformclient/credentials.ts +68 -0
  181. package/src/domain/platformclient/mint.ts +412 -0
  182. package/src/domain/platformclient/origin-guard.ts +117 -0
  183. package/src/domain/platformclient/resource-store.ts +150 -0
  184. package/src/domain/platformclient/steps.ts +472 -0
  185. package/src/domain/platformclient/store-contract.ts +318 -0
  186. package/src/domain/platformclient/store.ts +63 -0
  187. package/src/domain/platformclient/system-managed.ts +115 -0
  188. package/src/domain/platformclient/token-controller.ts +49 -0
  189. package/src/domain/platformclient/verifier.ts +106 -0
  190. package/src/encryption/key-manager.ts +87 -44
  191. package/src/extensions/__tests__/extension-composition.test.ts +3 -2
  192. package/src/extensions/__tests__/platform-client-composed.test.ts +288 -0
  193. package/src/extensions/__tests__/registry.test.ts +4 -1
  194. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  195. package/src/extensions/caller-guards.ts +8 -3
  196. package/src/extensions/drivers.ts +37 -0
  197. package/src/extensions/guest-token-minting.ts +32 -0
  198. package/src/extensions/identity.ts +6 -4
  199. package/src/extensions/registry.ts +77 -3
  200. package/src/index.ts +55 -0
  201. package/src/pipeline/interceptors/auth.ts +27 -0
  202. package/src/pipeline/steps/__tests__/references.test.ts +2 -0
  203. package/src/pipeline/steps/build-update-state.ts +5 -2
  204. package/src/platformtoken/__tests__/envelope.test.ts +277 -0
  205. package/src/platformtoken/__tests__/key-ring.test.ts +205 -0
  206. package/src/platformtoken/envelope.ts +276 -0
  207. package/src/platformtoken/key-ring.ts +266 -0
  208. package/src/runnerauth/__tests__/runner-subject-composed.test.ts +9 -3
@@ -78,10 +78,16 @@ import { formatObjectRef } from "./tuples.js";
78
78
 
79
79
  /**
80
80
  * Kinds whose missing row is a denial, never `not-found` (arm 4): the
81
- * cloud's `PROBE_EXEMPT_KINDS` restricted to the kinds this edition
82
- * serves — `platform`, `identity_provider`, `platform_client` and
83
- * `invitation` are refused by arm 2 before any row is asked about. The
84
- * cloud's drift test pins the two sets equal over the open-source tier.
81
+ * cloud's `PROBE_EXEMPT_KINDS` (stigmer-cloud
82
+ * src/authorizer/fga-authorizer.ts) restricted to the kinds this edition
83
+ * serves — `platform`, `identity_provider` and `invitation` are refused by
84
+ * arm 2 before any row is asked about. The cloud exempts them because
85
+ * their rows live in its own tables, out of its probe's reach; matching
86
+ * the set here keeps the wire identical across editions (an unknown
87
+ * platform client's id is PERMISSION_DENIED with the annotation's copy in
88
+ * both). The cloud's model drift test pins every kind here inside its
89
+ * `PROBE_EXEMPT_KINDS`, which is why this set is exported; a kind that
90
+ * moves into open source joins this set in the same change.
85
91
  *
86
92
  * `iam_policy` is reachable as a target through the IamPolicy RPCs' own
87
93
  * `resource.kind` (a caller may name it); the grant scope refuses such a
@@ -91,6 +97,7 @@ import { formatObjectRef } from "./tuples.js";
91
97
  export const NOT_FOUND_EXEMPT_KINDS: ReadonlySet<ApiResourceKind> = new Set([
92
98
  ApiResourceKind.identity_account,
93
99
  ApiResourceKind.iam_policy,
100
+ ApiResourceKind.platform_client,
94
101
  ]);
95
102
 
96
103
  /** The Java RequestAuthorizationService's pre-check copy, byte for byte (the cloud renders the same). */
@@ -34,6 +34,7 @@ const DECLARED_KINDS = [
34
34
  ApiResourceKind.iam_policy,
35
35
  ApiResourceKind.api_key,
36
36
  ApiResourceKind.oauth_app,
37
+ ApiResourceKind.platform_client,
37
38
  ApiResourceKind.organization,
38
39
  ApiResourceKind.agent,
39
40
  ApiResourceKind.agent_channel,
@@ -94,6 +95,13 @@ const WIRE_PERMISSIONS_BY_TYPE: Readonly<
94
95
  "can_grant_access",
95
96
  "can_view_access",
96
97
  ],
98
+ platform_client: [
99
+ "can_view",
100
+ "can_edit",
101
+ "can_delete",
102
+ "can_grant_access",
103
+ "can_view_access",
104
+ ],
97
105
  organization: [
98
106
  "can_view",
99
107
  "can_edit",
@@ -262,7 +270,6 @@ describe("the built-in model's registry", () => {
262
270
  for (const unserved of [
263
271
  ApiResourceKind.platform,
264
272
  ApiResourceKind.identity_provider,
265
- ApiResourceKind.platform_client,
266
273
  ApiResourceKind.invitation,
267
274
  ]) {
268
275
  expect(declarationFor(unserved), kindEnumName(unserved)).toBeUndefined();
@@ -12,11 +12,11 @@
12
12
  * chain past the depth bound) and for the cloud's drift test, which
13
13
  * builds a model from the live `.fga` files and compares.
14
14
  *
15
- * Registered: every kind of the open-source tier — the twenty-three
16
- * `kind_meta.tier: open_source` members — and nothing else. The four
15
+ * Registered: every kind of the open-source tier — the twenty-four
16
+ * `kind_meta.tier: open_source` members — and nothing else. The three
17
17
  * files `fga.mod` lists that have no declaration here (`platform`,
18
- * `identity_provider`, `platform_client`, `invitation`) are kinds this
19
- * edition does not serve: a check that targets one is refused by the
18
+ * `identity_provider`, `invitation`) are kinds this edition does not
19
+ * serve: a check that targets one is refused by the
20
20
  * driver, and a tuple that names one (`identity_provider#platform_user`)
21
21
  * resolves to nobody. The registry test pins the list against the tier.
22
22
  */
@@ -38,6 +38,7 @@ import { mcpServerDeclaration } from "./mcp_server.js";
38
38
  import { memoryDeclaration } from "./memory.js";
39
39
  import { oauthAppDeclaration } from "./oauth_app.js";
40
40
  import { organizationDeclaration } from "./organization.js";
41
+ import { platformClientDeclaration } from "./platform_client.js";
41
42
  import { pluginDeclaration } from "./plugin.js";
42
43
  import type { KindDeclaration } from "./rewrite.js";
43
44
  import { scheduleDeclaration } from "./schedule.js";
@@ -77,6 +78,7 @@ export const builtInModel: Model = newModel([
77
78
  iamPolicyDeclaration,
78
79
  apiKeyDeclaration,
79
80
  oauthAppDeclaration,
81
+ platformClientDeclaration,
80
82
  organizationDeclaration,
81
83
  agentDeclaration,
82
84
  agentChannelDeclaration,
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Transcript of fga/model/iam/oauth_app.fga — the one organization-scoped
3
- * kind whose `owner` has NO admin arm while its `viewer` does: the file's
2
+ * Transcript of fga/model/iam/oauth_app.fga — an organization-scoped kind
3
+ * whose `owner` has NO admin arm while its `viewer` does (platform_client
4
+ * shares the shape, for the same reason): the file's
4
5
  * "administrative visibility" for the organization's admins over a
5
6
  * registration that holds vendor client credentials, with change and
6
7
  * deletion the creator's (or an explicit grant's) alone, and no public
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Transcript of fga/model/iam/platform_client.fga — relation for relation
3
+ * the oauth_app shape: organization-scoped, an `owner` with no admin arm
4
+ * and a `viewer` that has one. A platform client holds credential material,
5
+ * so the model is restricted: its creator (the owner) edits, deletes,
6
+ * rotates and grants; the organization's admins may view it; ordinary
7
+ * members have no access at all, which is why listByOrg narrows through
8
+ * the list read scope. There is no public level.
9
+ */
10
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
11
+ import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
12
+
13
+ import {
14
+ computed,
15
+ declareKind,
16
+ direct,
17
+ from,
18
+ objectOf,
19
+ union,
20
+ } from "./rewrite.js";
21
+
22
+ export const platformClientDeclaration = declareKind({
23
+ kind: ApiResourceKind.platform_client,
24
+ schema: PlatformClientSchema,
25
+ source: "fga/model/iam/platform_client.fga",
26
+ relations: [
27
+ ["organization", direct(objectOf("organization"))],
28
+ ["owner", direct(objectOf("identity_account"))],
29
+ [
30
+ "viewer",
31
+ union(
32
+ direct(objectOf("identity_account")),
33
+ computed("owner"),
34
+ from("admin", "organization"),
35
+ ),
36
+ ],
37
+ ["can_view", computed("viewer")],
38
+ ["can_edit", computed("owner")],
39
+ ["can_delete", computed("owner")],
40
+ ["can_grant_access", computed("owner")],
41
+ ["can_view_access", computed("viewer")],
42
+ ],
43
+ });
@@ -236,10 +236,11 @@ describe("the require-authentication posture on the wire", () => {
236
236
  );
237
237
  });
238
238
 
239
- it("is_public methods stay reachable tokenless (getServerInfo)", async () => {
239
+ it("is_public methods stay reachable tokenless (getServerInfo), and it reports the posture", async () => {
240
240
  const platform = createClient(PlatformQueryController, transportWith());
241
241
  const info = await platform.getServerInfo({});
242
242
  expect(info.edition).not.toBe("");
243
+ expect(info.authenticationRequired).toBe(true);
243
244
  });
244
245
 
245
246
  it("the gRPC health service stays reachable tokenless — a Kubernetes grpc probe survives the posture (stigmer#974)", async () => {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pins the routes stage's refusal of a shadowing registration: Connect's
3
+ * router lets the last registration of a path win silently, so an
4
+ * extension that registers a service the core already serves — or that
5
+ * another extension already registered — must fail boot, naming itself and
6
+ * whoever serves the path. Distinct services pass untouched, in order.
7
+ */
8
+ import { createConnectRouter } from "@connectrpc/connect";
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { OAuthAppQueryController } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/query_pb";
12
+ import { PlatformClientQueryController } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/query_pb";
13
+ import { PlatformClientTokenController } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
14
+
15
+ import { registerExtensionServicesUnshadowed } from "../route-shadowing.js";
16
+
17
+ describe("registerExtensionServicesUnshadowed", () => {
18
+ it("refuses an extension service the core already serves, naming the unit and the path", () => {
19
+ const router = createConnectRouter();
20
+ router.service(PlatformClientTokenController, {});
21
+ expect(() =>
22
+ registerExtensionServicesUnshadowed(router, [
23
+ {
24
+ unit: "cloud-iam",
25
+ register: (r) => void r.service(PlatformClientTokenController, {}),
26
+ },
27
+ ]),
28
+ ).toThrow(
29
+ /extension 'cloud-iam' registers \/ai\.stigmer\.iam\.platformclient\.v1\.PlatformClientTokenController\/mintUserToken, which the core already serves/,
30
+ );
31
+ });
32
+
33
+ it("refuses a second extension registering what a first one did", () => {
34
+ const router = createConnectRouter();
35
+ expect(() =>
36
+ registerExtensionServicesUnshadowed(router, [
37
+ {
38
+ unit: "first",
39
+ register: (r) => void r.service(OAuthAppQueryController, {}),
40
+ },
41
+ {
42
+ unit: "second",
43
+ register: (r) =>
44
+ void r.rpc(OAuthAppQueryController.method.get, () => {
45
+ throw new Error("never called");
46
+ }),
47
+ },
48
+ ]),
49
+ ).toThrow(
50
+ /extension 'second' registers .*OAuthAppQueryController\/get, which extension 'first' already serves/,
51
+ );
52
+ });
53
+
54
+ it("registers distinct services from every unit", () => {
55
+ const router = createConnectRouter();
56
+ router.service(OAuthAppQueryController, {});
57
+ registerExtensionServicesUnshadowed(router, [
58
+ {
59
+ unit: "billing",
60
+ register: (r) => void r.service(PlatformClientQueryController, {}),
61
+ },
62
+ ]);
63
+ expect(router.handlers.map((handler) => handler.requestPath)).toContain(
64
+ "/ai.stigmer.iam.platformclient.v1.PlatformClientQueryController/listByOrg",
65
+ );
66
+ });
67
+ });
@@ -108,6 +108,11 @@ import { registerExecutionContextServices } from "../domain/executioncontext/con
108
108
  import { registerGitHubServices } from "../domain/github/controller.js";
109
109
  import { registerMemoryServices } from "../domain/memory/controller.js";
110
110
  import { registerOAuthAppServices } from "../domain/oauthapp/controller.js";
111
+ import { registerPlatformClientServices } from "../domain/platformclient/controller.js";
112
+ import { newPlatformClientOriginGuard } from "../domain/platformclient/origin-guard.js";
113
+ import { newResourcePlatformClientStore } from "../domain/platformclient/resource-store.js";
114
+ import { registerPlatformClientTokenService } from "../domain/platformclient/token-controller.js";
115
+ import { newPlatformClientTokenVerifier } from "../domain/platformclient/verifier.js";
111
116
  import { registerOrganizationServices } from "../domain/organization/controller.js";
112
117
  import { registerMcpServerServices } from "../domain/mcpserver/controller.js";
113
118
  import { registerPlatformServices } from "../domain/platform/controller.js";
@@ -144,6 +149,7 @@ import {
144
149
  import type { SecretCodec } from "../encryption/codec.js";
145
150
  import { getOrCreateNamedKey } from "../encryption/key-manager.js";
146
151
  import { V1_VERSION } from "../encryption/v1-codec.js";
152
+ import { resolvePlatformTokenKeys } from "../platformtoken/key-ring.js";
147
153
  import { registerActivityServices } from "../query/activity/controller.js";
148
154
  import { ActivityHandler } from "../query/activity/handler.js";
149
155
  import { registerSearchServices } from "../query/search/controller.js";
@@ -203,6 +209,7 @@ import {
203
209
  } from "../transport/console/handler.js";
204
210
  import { createRegistryLanes } from "../transport/registry/lanes.js";
205
211
  import { createUnifiedPortServer } from "../transport/server.js";
212
+ import { registerExtensionServicesUnshadowed } from "./route-shadowing.js";
206
213
  import type { ServerConfig } from "./config.js";
207
214
  import { createInProcessClients } from "./inprocess.js";
208
215
  import type { InProcessClients } from "./inprocess.js";
@@ -413,6 +420,14 @@ export async function composeServer(
413
420
  // bindings are what the IamPolicy controller registers over (routes).
414
421
  const iamPolicies =
415
422
  extensions.drivers.iamPolicyStore ?? newResourceIamPolicyStore(store);
423
+ // The PlatformClient store PORT, bound the same way
424
+ // (`drivers.platformClientStore`; open source's adapter over `store`
425
+ // when absent): the chains, the mint and the verifier's liveness read all
426
+ // go through this one binding, so a composed driver is followed
427
+ // everywhere by construction.
428
+ const platformClients =
429
+ extensions.drivers.platformClientStore ??
430
+ newResourcePlatformClientStore(store);
416
431
  const iamPolicyGrantPath = newIamPolicyGrantPath({
417
432
  policies: iamPolicies,
418
433
  lifecycle: extensions.drivers.resourceAuthorizationLifecycle,
@@ -690,6 +705,26 @@ export async function composeServer(
690
705
  writeVersionValue === "" ? DEFAULT_WRITE_VERSION : writeVersionValue,
691
706
  registeredCodecs: [...secretCodecs.keys()].sort(),
692
707
  });
708
+ // The platform-token key ring joins the FATAL side of the asymmetry:
709
+ // under an authentication posture a server that cannot sign or verify
710
+ // its own tokens would refuse every PlatformClient-minted caller, and an
711
+ // explicitly configured key that does not load is configuration the
712
+ // operator meant (the ladder's rule). No posture, no ring
713
+ // (resolvePlatformTokenKeys carries the four arms).
714
+ const platformTokenKeys = resolvePlatformTokenKeys({
715
+ requireAuthentication,
716
+ supplied: extensions.drivers.platformTokenKeys,
717
+ edition: extensions.edition,
718
+ });
719
+ logger.info("platform-token keys resolved", {
720
+ source:
721
+ platformTokenKeys === undefined
722
+ ? "none (no authentication posture)"
723
+ : extensions.drivers.platformTokenKeys !== undefined
724
+ ? "extension"
725
+ : "open-source ladder",
726
+ signing: platformTokenKeys?.signer !== undefined,
727
+ });
693
728
  // Stage: temporal (#18). Construction is sync and connection-free —
694
729
  // initialConnect/startWorkers/startHealthMonitor run in start(), all
695
730
  // NON-fatal (Go server.go: the server boots and serves with the engine
@@ -1284,6 +1319,34 @@ export async function composeServer(
1284
1319
  authorizer,
1285
1320
  authorizationLifecycle,
1286
1321
  });
1322
+ // PlatformClient, OAuthApp's inbound counterpart: served in every
1323
+ // composition over the store PORT bound above. The token service is
1324
+ // registered here too — mintGuestToken dispatches to the composed
1325
+ // capability — so nothing else may register it (route-shadowing.ts).
1326
+ // The mint writes end users through the identity-account create path
1327
+ // and the one grant path, as the platform client (mint.ts), and signs
1328
+ // with the ring the keys stage resolved (none without a posture: the
1329
+ // mint then refuses).
1330
+ registerPlatformClientServices(router, {
1331
+ clients: platformClients,
1332
+ store,
1333
+ logger,
1334
+ authorizer,
1335
+ authorizationLifecycle,
1336
+ listReadScope,
1337
+ });
1338
+ registerPlatformClientTokenService(router, {
1339
+ mint: {
1340
+ clients: platformClients,
1341
+ accounts: identityAccounts,
1342
+ createAccount: createIdentityAccount,
1343
+ grantPath: iamPolicyGrantPath,
1344
+ keys: platformTokenKeys,
1345
+ logger,
1346
+ now: () => new Date(),
1347
+ },
1348
+ guestTokenMinting: extensions.drivers.guestTokenMinting,
1349
+ });
1287
1350
  registerExecutionContextServices(router, {
1288
1351
  store,
1289
1352
  logger,
@@ -1549,6 +1612,7 @@ export async function composeServer(
1549
1612
  runnerAuthService: runnerCredentials,
1550
1613
  edition: extensions.edition,
1551
1614
  version: options.version ?? SERVER_VERSION,
1615
+ authenticationRequired: requireAuthentication,
1552
1616
  // The built-in `absent` answer installs here, at the consumer, when
1553
1617
  // no unit registered a license-status provider: open source and the
1554
1618
  // cloud hold no key, and only an Enterprise unit ever has one.
@@ -1559,10 +1623,9 @@ export async function composeServer(
1559
1623
  // Extension services register after the whole OSS set, inside the ONE
1560
1624
  // routes closure — so the serving router AND the in-process transport
1561
1625
  // see them by construction (blueprint §2a; DD-002's parity doctrine
1562
- // extends to extension services unchanged).
1563
- for (const registerExtensionServices of extensions.services) {
1564
- registerExtensionServices(router);
1565
- }
1626
+ // extends to extension services unchanged). A registration that would
1627
+ // shadow a route already served is a boot throw (route-shadowing.ts).
1628
+ registerExtensionServicesUnshadowed(router, extensions.services);
1566
1629
  };
1567
1630
  const inProcessWiring = createInProcessClients(routes, logger);
1568
1631
  inProcess = inProcessWiring.clients;
@@ -1610,6 +1673,18 @@ export async function composeServer(
1610
1673
  ...(requireAuthentication
1611
1674
  ? [newApiKeyIdentityVerifier({ store, accounts: identityAccounts })]
1612
1675
  : []),
1676
+ // The PlatformClient user-token lane rides the ring, which the keys
1677
+ // stage resolves only under an authentication posture. It sits before
1678
+ // `oidc` for the runner-subject verifier's reason (stigmer#1137): the
1679
+ // OIDC verifier would claim the platform token and throw on it.
1680
+ ...(platformTokenKeys !== undefined
1681
+ ? [
1682
+ newPlatformClientTokenVerifier({
1683
+ keys: platformTokenKeys,
1684
+ clients: platformClients,
1685
+ }),
1686
+ ]
1687
+ : []),
1613
1688
  ...(authorizationPosture === "built-in"
1614
1689
  ? [
1615
1690
  newRunnerSubjectIdentityVerifier({
@@ -1652,6 +1727,16 @@ export async function composeServer(
1652
1727
  ? [`extension '${extensions.requireAuthentication.declaredBy}'`]
1653
1728
  : []),
1654
1729
  ];
1730
+ // Caller guards: open source's own first, then the extensions' — the
1731
+ // rule the verifier list follows ("OSS entries first"). The origin guard
1732
+ // exists only where PlatformClient tokens can be presented, under the
1733
+ // posture that verifies them.
1734
+ const callerGuards = [
1735
+ ...(platformTokenKeys !== undefined
1736
+ ? [newPlatformClientOriginGuard({ clients: platformClients, logger })]
1737
+ : []),
1738
+ ...extensions.callerGuards,
1739
+ ];
1655
1740
  logger.info("authentication posture resolved", {
1656
1741
  posture: requireAuthentication ? "required" : "trusted-local",
1657
1742
  source: postureSources.length > 0 ? postureSources.join(" + ") : "none",
@@ -1680,7 +1765,7 @@ export async function composeServer(
1680
1765
  logger,
1681
1766
  createVerifierChainInterceptor(
1682
1767
  identityVerifiers,
1683
- extensions.callerGuards,
1768
+ callerGuards,
1684
1769
  logger,
1685
1770
  requireAuthentication,
1686
1771
  ),
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The routes stage's one refusal: an extension may not register a route
3
+ * the router already serves.
4
+ *
5
+ * Connect's router keeps every registration, and both the serving adapter
6
+ * and the in-process transport build their path table with a plain map
7
+ * set, so a second registration of `/<service>/<method>` silently wins.
8
+ * Extensions register after the whole core set (compose.ts), so a
9
+ * composition that re-registers a service the core has come to serve —
10
+ * the moment a cloud domain moves into open source and the cloud's pin
11
+ * advances without deleting its own handlers — would shadow the core with
12
+ * no error, and serve from whatever store its old handlers read. Refusing
13
+ * at boot turns that into the loudest possible failure; the fix is always
14
+ * the domain's driver point, never a second service.
15
+ *
16
+ * Checked per request path, because `router.service` registers every
17
+ * method of a descriptor (the unimplemented ones answering UNIMPLEMENTED)
18
+ * and `router.rpc` registers one; two extensions colliding with each
19
+ * other are refused the same way.
20
+ */
21
+ import type { ConnectRouter } from "@connectrpc/connect";
22
+
23
+ import type { ResolvedServiceRegistration } from "../extensions/registry.js";
24
+
25
+ /** Runs each extension registration, refusing any path already routed. */
26
+ export function registerExtensionServicesUnshadowed(
27
+ router: ConnectRouter,
28
+ registrations: ReadonlyArray<ResolvedServiceRegistration>,
29
+ ): void {
30
+ // Request path → who serves it, as the refusal names them.
31
+ const servedBy = new Map<string, string>(
32
+ router.handlers.map((handler) => [handler.requestPath, "the core"]),
33
+ );
34
+ for (const { unit, register } of registrations) {
35
+ const before = router.handlers.length;
36
+ register(router);
37
+ for (const handler of router.handlers.slice(before)) {
38
+ const server = servedBy.get(handler.requestPath);
39
+ if (server !== undefined) {
40
+ throw new Error(
41
+ `extension '${unit}' registers ${handler.requestPath}, which ${server} already serves — a route is never registered twice; an edition changes a core domain's behaviour through its driver points`,
42
+ );
43
+ }
44
+ servedBy.set(handler.requestPath, `extension '${unit}'`);
45
+ }
46
+ }
47
+ }
@@ -639,6 +639,13 @@ describe("membership rules", () => {
639
639
  IdentityAccountProvisioningMode.machine;
640
640
  expect(isPersonAccount(machineMode)).toBe(false);
641
641
  });
642
+
643
+ it("a platform-client end user is not: the mint grants it its client's role and nothing else", () => {
644
+ const endUser = account("stgm_pc|acme|user-7", OPERATOR_EMAIL);
645
+ endUser.spec!.provisioningMode =
646
+ IdentityAccountProvisioningMode.platform_client;
647
+ expect(isPersonAccount(endUser)).toBe(false);
648
+ });
642
649
  });
643
650
 
644
651
  describe("ensureRolesForExistingAccounts", () => {
@@ -195,14 +195,20 @@ export function isPersonStamp(stamp: string): boolean {
195
195
  * `user`-class gate, for the reconciliation, which has a row and no
196
196
  * caller. A machine account (`is_machine_account`, or the `machine`
197
197
  * provisioning mode) earns no role, exactly as a `machine`-class caller
198
- * earns none at the hook.
198
+ * earns none at the hook. Nor does a platform-client end user: it is a
199
+ * person on its platform's product, not on this install — the mint grants
200
+ * it exactly its client's auto-grant role on the owning organization and
201
+ * never runs these rules, and the reconciliation must not either, or every
202
+ * product user would become a member of every organization (and the
203
+ * operator-email arm would read an email the platform asserted).
199
204
  */
200
205
  export function isPersonAccount(account: IdentityAccount): boolean {
201
206
  const spec = account.spec;
202
207
  return (
203
208
  spec !== undefined &&
204
209
  !spec.isMachineAccount &&
205
- spec.provisioningMode !== IdentityAccountProvisioningMode.machine
210
+ spec.provisioningMode !== IdentityAccountProvisioningMode.machine &&
211
+ spec.provisioningMode !== IdentityAccountProvisioningMode.platform_client
206
212
  );
207
213
  }
208
214
 
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pins the identity-account create path's platform-client arm over the OSS
3
+ * adapter on sqlite:
4
+ * - `platform_client` provisioning writes the mode, the owning
5
+ * organization and the derived id, with the actor the caller named;
6
+ * - the `stgm_pc|` subject namespace is reserved in both directions: a
7
+ * direct account refuses it INVALID_ARGUMENT before anything is written,
8
+ * and a platform-client account without it is the caller's bug;
9
+ * - the direct lookups never answer the platform-client account, while the
10
+ * any-mode lookup (the mint's) does.
11
+ */
12
+ import { Code, ConnectError } from "@connectrpc/connect";
13
+ import { create } from "@bufbuild/protobuf";
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+
16
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
17
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
18
+ import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
19
+
20
+ import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
21
+ import { serverActingFor } from "../../../pipeline/interceptors/auth.js";
22
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
23
+ import { tempStore } from "../../../store/sqlite/__tests__/support.js";
24
+ import type { TempStore } from "../../../store/sqlite/__tests__/support.js";
25
+ import { accountIdFor, reservedSubjectMessage } from "../constants.js";
26
+ import { newCreateAccountPath } from "../controller.js";
27
+ import type { CreateAccount } from "../provisioning.js";
28
+ import { newResourceIdentityAccountStore } from "../resource-store.js";
29
+ import type { IdentityAccountStore } from "../store.js";
30
+
31
+ let temp: TempStore;
32
+ let accounts: IdentityAccountStore;
33
+ let createAccount: CreateAccount;
34
+
35
+ beforeEach(() => {
36
+ temp = tempStore();
37
+ accounts = newResourceIdentityAccountStore(temp.store);
38
+ createAccount = newCreateAccountPath(
39
+ {
40
+ accounts,
41
+ logger: silentLogger,
42
+ authorizer: newPermissiveSingleTeamAuthorizer(),
43
+ authorizationLifecycle: undefined,
44
+ },
45
+ ApiResourceKind.identity_account,
46
+ );
47
+ });
48
+
49
+ afterEach(() => {
50
+ temp.cleanup();
51
+ });
52
+
53
+ const SUBJECT = "stgm_pc|acme|user-7";
54
+ const CLIENT = serverActingFor("pcl_dashboard");
55
+
56
+ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
57
+ try {
58
+ await promise;
59
+ } catch (error) {
60
+ if (error instanceof ConnectError) return error;
61
+ throw error;
62
+ }
63
+ throw new Error("expected a ConnectError refusal");
64
+ }
65
+
66
+ describe("the create path's platform-client arm", () => {
67
+ it("writes the mode, the owning organization and the derived id, stamped as the client", async () => {
68
+ const account = await createAccount(
69
+ {
70
+ name: "pat@example.com",
71
+ spec: create(IdentityAccountSpecSchema, {
72
+ idpId: SUBJECT,
73
+ email: "pat@example.com",
74
+ }),
75
+ provisioning: { mode: "platform_client", org: "acme" },
76
+ },
77
+ CLIENT,
78
+ );
79
+ expect(account.metadata?.id).toBe(accountIdFor(SUBJECT));
80
+ expect(account.metadata?.org).toBe("acme");
81
+ expect(account.spec?.provisioningMode).toBe(
82
+ IdentityAccountProvisioningMode.platform_client,
83
+ );
84
+ expect(account.status?.audit?.specAudit?.createdBy?.id).toBe(
85
+ "pcl_dashboard",
86
+ );
87
+
88
+ expect(await accounts.findDirectByIdpId(SUBJECT)).toBeUndefined();
89
+ expect(await accounts.findDirectByEmail("pat@example.com")).toBeUndefined();
90
+ expect((await accounts.findByIdpId(SUBJECT))?.metadata?.id).toBe(
91
+ accountIdFor(SUBJECT),
92
+ );
93
+ });
94
+
95
+ it("refuses a direct account under the reserved subject prefix, writing nothing", async () => {
96
+ const denied = await refusal(
97
+ createAccount(
98
+ {
99
+ name: "impostor",
100
+ spec: create(IdentityAccountSpecSchema, { idpId: SUBJECT }),
101
+ provisioning: { mode: "direct" },
102
+ },
103
+ CLIENT,
104
+ ),
105
+ );
106
+ expect(denied.code).toBe(Code.InvalidArgument);
107
+ expect(denied.rawMessage).toBe(reservedSubjectMessage(SUBJECT));
108
+ expect(await accounts.findByIdpId(SUBJECT)).toBeUndefined();
109
+ });
110
+
111
+ it("refuses a platform-client account without the reserved prefix as a server fault", async () => {
112
+ const denied = await refusal(
113
+ createAccount(
114
+ {
115
+ name: "stray",
116
+ spec: create(IdentityAccountSpecSchema, { idpId: "auth0|stray" }),
117
+ provisioning: { mode: "platform_client", org: "acme" },
118
+ },
119
+ CLIENT,
120
+ ),
121
+ );
122
+ expect(denied.code).toBe(Code.Internal);
123
+ });
124
+ });
@@ -54,6 +54,7 @@ const CONTRACT_CASE_NAMES = [
54
54
  "a disconnected store is an infrastructure fault, never 'not found'",
55
55
  "two concurrent saves of one subject end in fulfilments and DuplicateAccountErrors only, and the winner is readable by subject",
56
56
  "findDirectByIdpId and findDirectByEmail never answer a federated account, while findByIdpId (any mode) does",
57
+ "findDirectByIdpId and findDirectByEmail never answer a platform-client account, while findByIdpId (any mode) does",
57
58
  "findByIds answers one row per distinct id, in first-occurrence order; an empty request answers an empty list",
58
59
  "update of an unknown id is a no-op: no row appears",
59
60
  "deleteById of an unknown id resolves",
@@ -110,6 +110,26 @@ export function isMachineSubject(idpId: string): boolean {
110
110
  return idpId.endsWith(MACHINE_ACCOUNT_SUFFIX);
111
111
  }
112
112
 
113
+ /**
114
+ * The subject namespace of PlatformClient end users:
115
+ * `stgm_pc|<org>|<external_user_id>` (the composite every client of one
116
+ * organization resolves a user to; domain/platformclient/mint.ts builds
117
+ * it). Reserved in both directions, because derived account ids make every
118
+ * subject one namespace: a direct account may not take a `stgm_pc|`
119
+ * subject (an issuer emitting one would otherwise provision an account the
120
+ * mint then resolves), and the mint refuses a `stgm_pc|` account in any
121
+ * other mode.
122
+ */
123
+ export const PLATFORM_CLIENT_SUBJECT_PREFIX = "stgm_pc|";
124
+
125
+ export function isPlatformClientSubject(idpId: string): boolean {
126
+ return idpId.startsWith(PLATFORM_CLIENT_SUBJECT_PREFIX);
127
+ }
128
+
129
+ export function reservedSubjectMessage(idpId: string): string {
130
+ return `subject '${idpId}' uses the '${PLATFORM_CLIENT_SUBJECT_PREFIX}' prefix reserved for accounts a platform client provisions`;
131
+ }
132
+
113
133
  // ---------------------------------------------------------------------------
114
134
  // Byte-pinned copy (the cloud handlers' sentences, moved as-is).
115
135
  // ---------------------------------------------------------------------------