@stigmer/server 3.23.1-dev.20260923075205 → 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 (226) 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/domain/workflow/agent-call-references.d.ts +60 -33
  107. package/dist/domain/workflow/agent-call-references.d.ts.map +1 -1
  108. package/dist/domain/workflow/agent-call-references.js +43 -23
  109. package/dist/domain/workflow/agent-call-references.js.map +1 -1
  110. package/dist/domain/workflow/registry/data/task-kind-registry.json +2 -2
  111. package/dist/encryption/key-manager.d.ts +20 -3
  112. package/dist/encryption/key-manager.d.ts.map +1 -1
  113. package/dist/encryption/key-manager.js +64 -42
  114. package/dist/encryption/key-manager.js.map +1 -1
  115. package/dist/extensions/caller-guards.d.ts +8 -3
  116. package/dist/extensions/caller-guards.d.ts.map +1 -1
  117. package/dist/extensions/drivers.d.ts +37 -0
  118. package/dist/extensions/drivers.d.ts.map +1 -1
  119. package/dist/extensions/guest-token-minting.d.ts +26 -0
  120. package/dist/extensions/guest-token-minting.d.ts.map +1 -0
  121. package/dist/extensions/guest-token-minting.js +2 -0
  122. package/dist/extensions/guest-token-minting.js.map +1 -0
  123. package/dist/extensions/identity.d.ts +6 -4
  124. package/dist/extensions/identity.d.ts.map +1 -1
  125. package/dist/extensions/identity.js.map +1 -1
  126. package/dist/extensions/registry.d.ts +30 -1
  127. package/dist/extensions/registry.d.ts.map +1 -1
  128. package/dist/extensions/registry.js +34 -1
  129. package/dist/extensions/registry.js.map +1 -1
  130. package/dist/index.d.ts +13 -1
  131. package/dist/index.d.ts.map +1 -1
  132. package/dist/index.js +6 -0
  133. package/dist/index.js.map +1 -1
  134. package/dist/pipeline/apiresource-labels.d.ts +10 -9
  135. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  136. package/dist/pipeline/apiresource-labels.js +10 -9
  137. package/dist/pipeline/apiresource-labels.js.map +1 -1
  138. package/dist/pipeline/interceptors/auth.d.ts +14 -0
  139. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  140. package/dist/pipeline/interceptors/auth.js +24 -0
  141. package/dist/pipeline/interceptors/auth.js.map +1 -1
  142. package/dist/pipeline/steps/build-update-state.d.ts +11 -1
  143. package/dist/pipeline/steps/build-update-state.d.ts.map +1 -1
  144. package/dist/pipeline/steps/build-update-state.js +5 -2
  145. package/dist/pipeline/steps/build-update-state.js.map +1 -1
  146. package/dist/pipeline/steps/references.d.ts +30 -4
  147. package/dist/pipeline/steps/references.d.ts.map +1 -1
  148. package/dist/pipeline/steps/references.js +13 -1
  149. package/dist/pipeline/steps/references.js.map +1 -1
  150. package/dist/platformtoken/envelope.d.ts +100 -0
  151. package/dist/platformtoken/envelope.d.ts.map +1 -0
  152. package/dist/platformtoken/envelope.js +184 -0
  153. package/dist/platformtoken/envelope.js.map +1 -0
  154. package/dist/platformtoken/key-ring.d.ts +85 -0
  155. package/dist/platformtoken/key-ring.d.ts.map +1 -0
  156. package/dist/platformtoken/key-ring.js +164 -0
  157. package/dist/platformtoken/key-ring.js.map +1 -0
  158. package/package.json +6 -6
  159. package/src/authorization/README.md +1 -1
  160. package/src/authorization/__tests__/authorizer.test.ts +6 -2
  161. package/src/authorization/__tests__/wire-permissions.test.ts +1 -0
  162. package/src/authorization/authorizer.ts +11 -4
  163. package/src/authorization/model/__tests__/registry.test.ts +8 -1
  164. package/src/authorization/model/index.ts +6 -4
  165. package/src/authorization/model/oauth_app.ts +3 -2
  166. package/src/authorization/model/platform_client.ts +43 -0
  167. package/src/boot/__tests__/auth-enabled-composition.test.ts +2 -1
  168. package/src/boot/__tests__/route-shadowing.test.ts +67 -0
  169. package/src/boot/compose.ts +90 -5
  170. package/src/boot/route-shadowing.ts +47 -0
  171. package/src/domain/iampolicy/__tests__/membership.test.ts +7 -0
  172. package/src/domain/iampolicy/membership.ts +8 -2
  173. package/src/domain/identityaccount/__tests__/platform-client-accounts.test.ts +124 -0
  174. package/src/domain/identityaccount/__tests__/resource-store.test.ts +1 -0
  175. package/src/domain/identityaccount/constants.ts +20 -0
  176. package/src/domain/identityaccount/controller.ts +16 -4
  177. package/src/domain/identityaccount/operator.ts +1 -0
  178. package/src/domain/identityaccount/provisioning.ts +17 -0
  179. package/src/domain/identityaccount/resource-store.ts +9 -5
  180. package/src/domain/identityaccount/steps.ts +58 -3
  181. package/src/domain/identityaccount/store-contract.ts +51 -0
  182. package/src/domain/identityaccount/store.ts +7 -5
  183. package/src/domain/platform/__tests__/platform.test.ts +7 -1
  184. package/src/domain/platform/controller.ts +12 -1
  185. package/src/domain/platformclient/__tests__/controller.test.ts +397 -0
  186. package/src/domain/platformclient/__tests__/credentials.test.ts +46 -0
  187. package/src/domain/platformclient/__tests__/mint.test.ts +415 -0
  188. package/src/domain/platformclient/__tests__/resource-store.test.ts +140 -0
  189. package/src/domain/platformclient/__tests__/system-managed.test.ts +101 -0
  190. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +207 -0
  191. package/src/domain/platformclient/constants.ts +120 -0
  192. package/src/domain/platformclient/controller.ts +449 -0
  193. package/src/domain/platformclient/credentials.ts +68 -0
  194. package/src/domain/platformclient/mint.ts +412 -0
  195. package/src/domain/platformclient/origin-guard.ts +117 -0
  196. package/src/domain/platformclient/resource-store.ts +150 -0
  197. package/src/domain/platformclient/steps.ts +472 -0
  198. package/src/domain/platformclient/store-contract.ts +318 -0
  199. package/src/domain/platformclient/store.ts +63 -0
  200. package/src/domain/platformclient/system-managed.ts +115 -0
  201. package/src/domain/platformclient/token-controller.ts +49 -0
  202. package/src/domain/platformclient/verifier.ts +106 -0
  203. package/src/domain/workflow/__tests__/agent-call-references.test.ts +178 -27
  204. package/src/domain/workflow/agent-call-references.ts +95 -48
  205. package/src/domain/workflow/registry/data/task-kind-registry.json +2 -2
  206. package/src/encryption/key-manager.ts +87 -44
  207. package/src/extensions/__tests__/extension-composition.test.ts +3 -2
  208. package/src/extensions/__tests__/platform-client-composed.test.ts +288 -0
  209. package/src/extensions/__tests__/registry.test.ts +4 -1
  210. package/src/extensions/__tests__/tier-truthfulness.test.ts +8 -0
  211. package/src/extensions/caller-guards.ts +8 -3
  212. package/src/extensions/drivers.ts +37 -0
  213. package/src/extensions/guest-token-minting.ts +32 -0
  214. package/src/extensions/identity.ts +6 -4
  215. package/src/extensions/registry.ts +77 -3
  216. package/src/index.ts +55 -0
  217. package/src/pipeline/apiresource-labels.ts +10 -9
  218. package/src/pipeline/interceptors/auth.ts +27 -0
  219. package/src/pipeline/steps/__tests__/references.test.ts +37 -1
  220. package/src/pipeline/steps/build-update-state.ts +5 -2
  221. package/src/pipeline/steps/references.ts +49 -5
  222. package/src/platformtoken/__tests__/envelope.test.ts +277 -0
  223. package/src/platformtoken/__tests__/key-ring.test.ts +205 -0
  224. package/src/platformtoken/envelope.ts +276 -0
  225. package/src/platformtoken/key-ring.ts +266 -0
  226. package/src/runnerauth/__tests__/runner-subject-composed.test.ts +9 -3
@@ -0,0 +1,412 @@
1
+ /**
2
+ * The PlatformClient mint: `mintUserToken`, the public RPC a platform's
3
+ * backend calls with its client_id + client_secret and one of its users,
4
+ * answered with a short-lived Stigmer user token for that user.
5
+ *
6
+ * The contract is the cloud's, refusal for refusal and in the same order
7
+ * (constants.ts carries the copy): the credentials, then the secret's
8
+ * expiry (unset means never), then the `org_id` confirmation (a token is
9
+ * always scoped to the client's owning organization), then the user.
10
+ *
11
+ * The user is keyed `stgm_pc|<org>|<user_id>`, so every client of one
12
+ * organization resolves one user to one account, and that account's id is
13
+ * derived from the subject (identityaccount/constants.ts accountIdFor). An
14
+ * account that exists is used as it is — its roles and profile stay what
15
+ * they are, whatever the client's settings say now — and must have been
16
+ * provisioned by a platform client: the subject namespace is reserved in
17
+ * both directions. An account that does not exist is provisioned when the
18
+ * client allows it, in this order:
19
+ *
20
+ * 1. The account's input is built and validated, so nothing below runs
21
+ * for a user the create chain would refuse.
22
+ * 2. When the client auto-grants, the role (viewer by default; never
23
+ * owner) is granted on the owning organization to the DERIVED account
24
+ * id, before the account row exists. The grant path is idempotent.
25
+ * 3. The account is created through the identity-account domain's one
26
+ * create path, in `platform_client` mode. A concurrent first mint that
27
+ * won the race is read back.
28
+ *
29
+ * Granting first is what makes every failure retry-safe with nothing to
30
+ * undo: a failed grant has written nothing; a failed create leaves one
31
+ * policy row naming an account that does not exist yet, which the next
32
+ * mint for that user completes. Creating first would need a rollback, and
33
+ * a rollback that fails leaves an account that can never receive its role
34
+ * (the existing-account arm never grants).
35
+ *
36
+ * Who acts: the request is public and tokenless, so the chain stamped the
37
+ * trusted-local operator on it — this module never reads that caller. The
38
+ * account and the policy are written as the CLIENT (`serverActingFor`, an
39
+ * `internal` caller naming the client's id), so the audit says which
40
+ * platform client brought the user in. End users are not people on the
41
+ * install: the membership rules never run for them (the create path is
42
+ * called directly, and identity-account provisioning's hook is not), and
43
+ * the boot reconciliation skips their mode (iampolicy/membership.ts).
44
+ *
45
+ * The email and name in the token and on the account are the platform's
46
+ * assertion; nothing on the server derives authority from either.
47
+ */
48
+ import { Code, ConnectError } from "@connectrpc/connect";
49
+ import { create } from "@bufbuild/protobuf";
50
+ import { timestampDate } from "@bufbuild/protobuf/wkt";
51
+
52
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
53
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
54
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
55
+ import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
56
+ import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
57
+ import type {
58
+ MintUserTokenRequest,
59
+ MintUserTokenResponse,
60
+ } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
61
+ import { MintUserTokenResponseSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
62
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
63
+
64
+ import type { Logger } from "../../boot/logger.js";
65
+ import type { CallerIdentity } from "../../extensions/identity.js";
66
+ import { internalError } from "../../pipeline/errors.js";
67
+ import { serverActingFor } from "../../pipeline/interceptors/auth.js";
68
+ import { validator } from "../../pipeline/steps/validation.js";
69
+ import {
70
+ canSign,
71
+ type PlatformTokenKeyRing,
72
+ } from "../../platformtoken/key-ring.js";
73
+ import { signPlatformToken } from "../../platformtoken/envelope.js";
74
+ import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
75
+ import { organizationRole } from "../iampolicy/specs.js";
76
+ import {
77
+ PLATFORM_CLIENT_SUBJECT_PREFIX,
78
+ accountIdFor,
79
+ } from "../identityaccount/constants.js";
80
+ import type {
81
+ CreateAccount,
82
+ CreateAccountInput,
83
+ } from "../identityaccount/provisioning.js";
84
+ import { resolveCreateRace } from "../identityaccount/provisioning.js";
85
+ import type { IdentityAccountStore } from "../identityaccount/store.js";
86
+ import {
87
+ BEARER_TOKEN_TYPE,
88
+ EXPIRED_CLIENT_SECRET_MESSAGE,
89
+ INVALID_CLIENT_CREDENTIALS_MESSAGE,
90
+ MINTING_DISABLED_MESSAGE,
91
+ MINTING_REQUIRES_AUTHENTICATION_MESSAGE,
92
+ OWNER_AUTO_GRANT_MESSAGE,
93
+ PROVISIONING_FAILED_MESSAGE,
94
+ USER_TOKEN_CLAIMS,
95
+ blankSubjectPartMessage,
96
+ foreignAccountMessage,
97
+ noAccountMessage,
98
+ organizationMismatchMessage,
99
+ subjectSeparatorMessage,
100
+ } from "./constants.js";
101
+ import { secretMatchesHash } from "./credentials.js";
102
+ import type { PlatformClientStore } from "./store.js";
103
+
104
+ /** The separator of the composite subject; neither part may contain it. */
105
+ const SUBJECT_SEPARATOR = "|";
106
+
107
+ /** The role an auto-grant gives when the client leaves it unspecified (the proto's default). */
108
+ const DEFAULT_AUTO_GRANT_ROLE = IamRole.viewer;
109
+
110
+ export interface PlatformClientMintDeps {
111
+ readonly clients: PlatformClientStore;
112
+ /** The any-mode subject lookup: an existing end user is found whatever wrote it. */
113
+ readonly accounts: Pick<IdentityAccountStore, "findByIdpId">;
114
+ /** The identity-account domain's one create path (identityaccount/controller.ts). */
115
+ readonly createAccount: CreateAccount;
116
+ readonly grantPath: Pick<IamPolicyGrantPath, "grant">;
117
+ /** The composed ring; undefined when the server trusts every request. */
118
+ readonly keys: PlatformTokenKeyRing | undefined;
119
+ readonly logger: Logger;
120
+ readonly now: () => Date;
121
+ }
122
+
123
+ /** The end user a mint request names, as the account and the token carry them. */
124
+ interface EndUser {
125
+ readonly externalUserId: string;
126
+ readonly email: string;
127
+ readonly name: string;
128
+ }
129
+
130
+ export async function mintUserToken(
131
+ deps: PlatformClientMintDeps,
132
+ request: MintUserTokenRequest,
133
+ ): Promise<MintUserTokenResponse> {
134
+ const keys = deps.keys;
135
+ if (keys === undefined) {
136
+ throw new ConnectError(
137
+ MINTING_REQUIRES_AUTHENTICATION_MESSAGE,
138
+ Code.FailedPrecondition,
139
+ );
140
+ }
141
+ if (!canSign(keys)) {
142
+ throw new ConnectError(MINTING_DISABLED_MESSAGE, Code.FailedPrecondition);
143
+ }
144
+
145
+ const client = await authenticateClient(deps, request);
146
+ const owningOrg = client.metadata?.org ?? "";
147
+ // A confirmation, never a selector: a token is scoped to the owning org.
148
+ if (request.orgId !== "" && request.orgId !== owningOrg) {
149
+ throw new ConnectError(
150
+ organizationMismatchMessage(owningOrg),
151
+ Code.InvalidArgument,
152
+ );
153
+ }
154
+
155
+ const user: EndUser = {
156
+ externalUserId: request.userId,
157
+ email: request.userEmail,
158
+ name: request.userName,
159
+ };
160
+ const accountId = await resolveOrProvisionAccount(
161
+ deps,
162
+ client,
163
+ owningOrg,
164
+ user,
165
+ );
166
+
167
+ const signed = signPlatformToken(
168
+ keys,
169
+ {
170
+ [USER_TOKEN_CLAIMS.subject]: accountId,
171
+ [USER_TOKEN_CLAIMS.externalUserId]: user.externalUserId,
172
+ [USER_TOKEN_CLAIMS.email]: user.email,
173
+ [USER_TOKEN_CLAIMS.name]: user.name,
174
+ [USER_TOKEN_CLAIMS.org]: owningOrg,
175
+ [USER_TOKEN_CLAIMS.platformClientId]: client.metadata?.id ?? "",
176
+ },
177
+ { now: deps.now() },
178
+ );
179
+ return create(MintUserTokenResponseSchema, {
180
+ accessToken: signed.token,
181
+ tokenType: BEARER_TOKEN_TYPE,
182
+ expiresIn: keys.ttlSeconds,
183
+ });
184
+ }
185
+
186
+ /**
187
+ * The client the credentials name, or UNAUTHENTICATED with one copy for an
188
+ * unknown client_id and a wrong secret alike; then the secret's expiry.
189
+ */
190
+ async function authenticateClient(
191
+ deps: PlatformClientMintDeps,
192
+ request: MintUserTokenRequest,
193
+ ): Promise<PlatformClient> {
194
+ let client: PlatformClient | undefined;
195
+ try {
196
+ client = await deps.clients.findByClientId(request.clientId);
197
+ } catch (error) {
198
+ throw internalError(error, "failed to authenticate platform client");
199
+ }
200
+ if (
201
+ client === undefined ||
202
+ !secretMatchesHash(
203
+ request.clientSecret,
204
+ client.spec?.clientSecretHash ?? "",
205
+ )
206
+ ) {
207
+ throw new ConnectError(
208
+ INVALID_CLIENT_CREDENTIALS_MESSAGE,
209
+ Code.Unauthenticated,
210
+ );
211
+ }
212
+ const spec = client.spec;
213
+ if (
214
+ spec?.neverExpires !== true &&
215
+ spec?.expiresAt !== undefined &&
216
+ deps.now().getTime() > timestampDate(spec.expiresAt).getTime()
217
+ ) {
218
+ throw new ConnectError(
219
+ EXPIRED_CLIENT_SECRET_MESSAGE,
220
+ Code.FailedPrecondition,
221
+ );
222
+ }
223
+ return client;
224
+ }
225
+
226
+ /** The account id for the user: the existing platform-client account, or one provisioned now. */
227
+ async function resolveOrProvisionAccount(
228
+ deps: PlatformClientMintDeps,
229
+ client: PlatformClient,
230
+ org: string,
231
+ user: EndUser,
232
+ ): Promise<string> {
233
+ const idpId = platformClientSubject(org, user.externalUserId);
234
+ const existing = await findAccount(deps, idpId);
235
+ if (existing !== undefined) {
236
+ return platformClientAccountId(existing, user, org);
237
+ }
238
+
239
+ const spec = client.spec;
240
+ if (spec?.autoProvisionAccounts !== true) {
241
+ throw new ConnectError(
242
+ noAccountMessage(user.externalUserId, org),
243
+ Code.FailedPrecondition,
244
+ );
245
+ }
246
+ const input = accountInput(idpId, org, user);
247
+ const actor = serverActingFor(client.metadata?.id ?? "");
248
+
249
+ if (spec.autoGrantOnOrg) {
250
+ await grantAutoRole(deps, client, org, accountIdFor(idpId), actor);
251
+ }
252
+
253
+ try {
254
+ const created = await deps.createAccount(input, actor);
255
+ return created.metadata?.id ?? "";
256
+ } catch (error) {
257
+ const winner = await resolveCreateRace(error, () =>
258
+ deps.accounts.findByIdpId(idpId),
259
+ );
260
+ return platformClientAccountId(winner, user, org);
261
+ }
262
+ }
263
+
264
+ /** The account under the subject, any mode; a store fault is INTERNAL, never "no account". */
265
+ async function findAccount(
266
+ deps: PlatformClientMintDeps,
267
+ idpId: string,
268
+ ): Promise<IdentityAccount | undefined> {
269
+ try {
270
+ return await deps.accounts.findByIdpId(idpId);
271
+ } catch (error) {
272
+ throw internalError(error, "failed to resolve platform client user");
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The account's id, when a platform client provisioned it: a row under a
278
+ * `stgm_pc|` subject in any other mode is refused rather than minted for.
279
+ */
280
+ function platformClientAccountId(
281
+ account: IdentityAccount,
282
+ user: EndUser,
283
+ org: string,
284
+ ): string {
285
+ if (
286
+ account.spec?.provisioningMode !==
287
+ IdentityAccountProvisioningMode.platform_client
288
+ ) {
289
+ throw new ConnectError(
290
+ foreignAccountMessage(user.externalUserId, org),
291
+ Code.FailedPrecondition,
292
+ );
293
+ }
294
+ return account.metadata?.id ?? "";
295
+ }
296
+
297
+ /**
298
+ * The account the create path will write, validated now so no grant is
299
+ * written for a user the chain would refuse. The cloud's shape: named by
300
+ * the email, else the external id; first and last name split from the
301
+ * display name at its first space, else the email's local part.
302
+ */
303
+ function accountInput(
304
+ idpId: string,
305
+ org: string,
306
+ user: EndUser,
307
+ ): CreateAccountInput {
308
+ const input: CreateAccountInput = {
309
+ name: user.email !== "" ? user.email : user.externalUserId,
310
+ spec: create(IdentityAccountSpecSchema, {
311
+ idpId,
312
+ email: user.email,
313
+ ...splitName(user.name, user.email),
314
+ }),
315
+ provisioning: { mode: "platform_client", org },
316
+ };
317
+ const result = validator().validate(
318
+ IdentityAccountSchema,
319
+ create(IdentityAccountSchema, {
320
+ apiVersion: "iam.stigmer.ai/v1",
321
+ kind: "IdentityAccount",
322
+ metadata: { name: input.name, org },
323
+ spec: input.spec,
324
+ }),
325
+ );
326
+ if (result.kind === "invalid") {
327
+ throw new ConnectError(result.error.message, Code.InvalidArgument);
328
+ }
329
+ if (result.kind === "error") {
330
+ throw new ConnectError(
331
+ `validation could not run: ${result.error.message}`,
332
+ Code.Internal,
333
+ );
334
+ }
335
+ return input;
336
+ }
337
+
338
+ /**
339
+ * Grants the client's auto-grant role on the owning organization to the
340
+ * derived account id, as the client. Owner is refused here too: the
341
+ * contract refuses it on every write since this release, and a row stored
342
+ * before that must still never make an end user an owner.
343
+ */
344
+ async function grantAutoRole(
345
+ deps: PlatformClientMintDeps,
346
+ client: PlatformClient,
347
+ org: string,
348
+ accountId: string,
349
+ actor: CallerIdentity,
350
+ ): Promise<void> {
351
+ const configured = client.spec?.autoGrantRole ?? IamRole.iam_role_unspecified;
352
+ if (configured === IamRole.owner) {
353
+ throw new ConnectError(OWNER_AUTO_GRANT_MESSAGE, Code.InvalidArgument);
354
+ }
355
+ const role =
356
+ configured === IamRole.iam_role_unspecified
357
+ ? DEFAULT_AUTO_GRANT_ROLE
358
+ : configured;
359
+ try {
360
+ await deps.grantPath.grant(organizationRole(accountId, role, org), actor);
361
+ } catch (error) {
362
+ deps.logger.error(
363
+ "platform client auto-grant failed; nothing was provisioned",
364
+ {
365
+ accountId,
366
+ platformClientId: client.metadata?.id ?? "",
367
+ error: error instanceof Error ? error.message : String(error),
368
+ },
369
+ );
370
+ throw new ConnectError(PROVISIONING_FAILED_MESSAGE, Code.Internal);
371
+ }
372
+ }
373
+
374
+ /** `stgm_pc|<org>|<user_id>`, each part non-blank and free of the separator. */
375
+ export function platformClientSubject(
376
+ org: string,
377
+ externalUserId: string,
378
+ ): string {
379
+ requireSubjectPart("org", org);
380
+ requireSubjectPart("externalUserId", externalUserId);
381
+ return `${PLATFORM_CLIENT_SUBJECT_PREFIX}${org}${SUBJECT_SEPARATOR}${externalUserId}`;
382
+ }
383
+
384
+ function requireSubjectPart(
385
+ part: "org" | "externalUserId",
386
+ value: string,
387
+ ): void {
388
+ if (value.trim() === "") {
389
+ throw new ConnectError(blankSubjectPartMessage(part), Code.InvalidArgument);
390
+ }
391
+ if (value.includes(SUBJECT_SEPARATOR)) {
392
+ throw new ConnectError(
393
+ subjectSeparatorMessage(part, value),
394
+ Code.InvalidArgument,
395
+ );
396
+ }
397
+ }
398
+
399
+ /** The cloud's name split: the display name at its first space, else the email's local part. */
400
+ function splitName(
401
+ name: string,
402
+ email: string,
403
+ ): { firstName: string; lastName: string } {
404
+ if (name !== "") {
405
+ const space = name.indexOf(" ");
406
+ return space > 0
407
+ ? { firstName: name.slice(0, space), lastName: name.slice(space + 1) }
408
+ : { firstName: name, lastName: "" };
409
+ }
410
+ const at = email.indexOf("@");
411
+ return { firstName: at >= 0 ? email.slice(0, at) : email, lastName: "" };
412
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The PlatformClient origin guard: a request bearing a user token minted by
3
+ * a client that lists `allowed_origins` must come from one of them. The
4
+ * check needs the request's headers, which a verifier never sees, so it is
5
+ * a caller guard (extensions/caller-guards.ts) — open source's own,
6
+ * composed before any extension's.
7
+ *
8
+ * The rule, the cloud's since the Java service (spec.proto's field doc):
9
+ * - no `Origin` header, or a blank one: allowed — only browsers send
10
+ * Origin, and the control is a browser-context defense against a
11
+ * leaked token replayed from another site's page. The guard reads no
12
+ * client for such a request, so server-to-server callers pay nothing;
13
+ * - an empty `allowed_origins`: allowed (open mode);
14
+ * - otherwise the origin must match an entry case-insensitively (RFC 6454
15
+ * comparison), and the opaque origin "null" never matches.
16
+ * A refusal is PERMISSION_DENIED with the cloud's copy, logged with the
17
+ * procedure, the client and the origin.
18
+ *
19
+ * Only the untyped user token is the guard's: the claims are read from the
20
+ * token the verifier chain already verified on this request (the pattern
21
+ * caller-guards.ts prescribes), and any other credential — an API key, an
22
+ * OIDC token, a typed platform token — passes untouched. Liveness is not
23
+ * re-checked here beyond the one read the origin needs: the verifier
24
+ * refused a deleted client already, and a client deleted since is refused
25
+ * the same way, fail closed.
26
+ */
27
+ import { Code, ConnectError } from "@connectrpc/connect";
28
+ import type { DescMethod } from "@bufbuild/protobuf";
29
+
30
+ import type { Logger } from "../../boot/logger.js";
31
+ import type { CallerGuard } from "../../extensions/caller-guards.js";
32
+ import type { CallerIdentity } from "../../extensions/identity.js";
33
+ import {
34
+ TOKEN_TYPE_CLAIM,
35
+ decodeVerifiedPlatformTokenPayload,
36
+ stringClaim,
37
+ } from "../../platformtoken/envelope.js";
38
+ import {
39
+ DELETED_CLIENT_MESSAGE,
40
+ USER_TOKEN_CLAIMS,
41
+ originRefusalMessage,
42
+ } from "./constants.js";
43
+ import type { PlatformClientStore } from "./store.js";
44
+
45
+ /** The opaque-origin serialization, which never matches a listed origin. */
46
+ const OPAQUE_ORIGIN = "null";
47
+
48
+ export interface PlatformClientOriginGuardDeps {
49
+ readonly clients: Pick<PlatformClientStore, "findById">;
50
+ readonly logger: Logger;
51
+ }
52
+
53
+ export function newPlatformClientOriginGuard(
54
+ deps: PlatformClientOriginGuardDeps,
55
+ ): CallerGuard {
56
+ return {
57
+ name: "platform-client-origin",
58
+ async guard(
59
+ caller: CallerIdentity,
60
+ method: DescMethod,
61
+ headers: Headers,
62
+ ): Promise<void> {
63
+ const origin = headers.get("origin") ?? "";
64
+ if (origin.trim() === "") {
65
+ return;
66
+ }
67
+ const platformClientId = mintingClientOf(caller);
68
+ if (platformClientId === undefined) {
69
+ return;
70
+ }
71
+ const procedure = `/${method.parent.typeName}/${method.name}`;
72
+ const client = await deps.clients.findById(platformClientId);
73
+ if (client === undefined) {
74
+ throw new ConnectError(DELETED_CLIENT_MESSAGE, Code.Unauthenticated);
75
+ }
76
+ if (originAllowed(origin, client.spec?.allowedOrigins ?? [])) {
77
+ return;
78
+ }
79
+ deps.logger.warn("origin refusal: origin not in allowed_origins", {
80
+ procedure,
81
+ platformClientId,
82
+ origin,
83
+ });
84
+ throw new ConnectError(
85
+ originRefusalMessage(origin),
86
+ Code.PermissionDenied,
87
+ );
88
+ },
89
+ };
90
+ }
91
+
92
+ /** The minting client of an untyped platform user token; undefined for every other credential. */
93
+ function mintingClientOf(caller: CallerIdentity): string | undefined {
94
+ const payload = decodeVerifiedPlatformTokenPayload(caller.rawToken);
95
+ if (
96
+ payload === undefined ||
97
+ stringClaim(payload, TOKEN_TYPE_CLAIM) !== undefined
98
+ ) {
99
+ return undefined;
100
+ }
101
+ return stringClaim(payload, USER_TOKEN_CLAIMS.platformClientId);
102
+ }
103
+
104
+ /** The cloud's origin rule for a non-blank Origin (see the header). */
105
+ function originAllowed(
106
+ origin: string,
107
+ allowed: ReadonlyArray<string>,
108
+ ): boolean {
109
+ if (allowed.length === 0) {
110
+ return true;
111
+ }
112
+ if (origin === OPAQUE_ORIGIN) {
113
+ return false;
114
+ }
115
+ const presented = origin.toLowerCase();
116
+ return allowed.some((entry) => entry.toLowerCase() === presented);
117
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The open-source PlatformClientStore: the port (store.ts) over the
3
+ * generic Store, rows of the `resources` table by kind. The composition
4
+ * root installs it when no extension registers `drivers.platformClientStore`.
5
+ *
6
+ * `findById` is a primary-key read, the one lookup on a per-request path
7
+ * (the verifier's liveness read, the origin guard). The other three scan
8
+ * the kind: `findByClientId` through Store.findByField (ApiKey's lookup,
9
+ * domain/apikey/lookup.ts: a decode-and-scan per mint, watched by
10
+ * stigmer#987), `findByOrg` by listing the kind, and `findByOrgAndSlug`
11
+ * over that listing — an exact pair, unlike the shared slug helper, whose
12
+ * empty org matches every organization. A
13
+ * PlatformClient census is a handful of rows per organization, and none of
14
+ * the three sits on a per-request path.
15
+ *
16
+ * `save` and `update` are read-then-write because the generic Store's
17
+ * saveResource is an upsert with neither a create-only nor an update-only
18
+ * form — the identity-account adapter's posture, with its reasoning
19
+ * (domain/identityaccount/resource-store.ts): `save` refuses a held id,
20
+ * (org, slug) or client_id; `update` writes nothing for an unknown id; the
21
+ * residual window between the read and the write is the platform-wide
22
+ * CheckDuplicate-then-upsert window, not this adapter's to close.
23
+ *
24
+ * Store faults follow the ratified mapping: a typed ResourceNotFoundError
25
+ * reads as `undefined`; anything else propagates — an outage must never
26
+ * read as "no client", which on the verifier's path would revoke every
27
+ * live token.
28
+ */
29
+ import { fromBinary } from "@bufbuild/protobuf";
30
+
31
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
32
+ import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
33
+ import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
34
+
35
+ import { ResourceNotFoundError } from "../../store/interface.js";
36
+ import type { Store } from "../../store/interface.js";
37
+ import { DuplicatePlatformClientError } from "./store.js";
38
+ import type { PlatformClientStore } from "./store.js";
39
+
40
+ const KIND = ApiResourceKind.platform_client;
41
+
42
+ export function newResourcePlatformClientStore(
43
+ store: Store,
44
+ ): PlatformClientStore {
45
+ async function readById(id: string): Promise<PlatformClient | undefined> {
46
+ try {
47
+ return await store.getResource(KIND, id, PlatformClientSchema);
48
+ } catch (error) {
49
+ if (error instanceof ResourceNotFoundError) {
50
+ return undefined;
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ async function readByClientId(
57
+ clientId: string,
58
+ ): Promise<PlatformClient | undefined> {
59
+ if (clientId === "") {
60
+ return undefined;
61
+ }
62
+ try {
63
+ return await store.findByField(
64
+ KIND,
65
+ "spec.clientId",
66
+ clientId,
67
+ PlatformClientSchema,
68
+ );
69
+ } catch (error) {
70
+ if (error instanceof ResourceNotFoundError) {
71
+ return undefined;
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /** Every client of the organization; a row that does not decode is skipped, as every scan does. */
78
+ async function listOrg(org: string): Promise<PlatformClient[]> {
79
+ const clients: PlatformClient[] = [];
80
+ for (const data of await store.listResources(KIND)) {
81
+ let client: PlatformClient;
82
+ try {
83
+ client = fromBinary(PlatformClientSchema, data);
84
+ } catch {
85
+ continue;
86
+ }
87
+ if ((client.metadata?.org ?? "") === org) {
88
+ clients.push(client);
89
+ }
90
+ }
91
+ return clients;
92
+ }
93
+
94
+ /** The exact (org, slug) pair — an empty org is no wildcard here. */
95
+ async function readByOrgAndSlug(
96
+ org: string,
97
+ slug: string,
98
+ ): Promise<PlatformClient | undefined> {
99
+ if (org === "" || slug === "") {
100
+ return undefined;
101
+ }
102
+ return (await listOrg(org)).find(
103
+ (client) => (client.metadata?.slug ?? "") === slug,
104
+ );
105
+ }
106
+
107
+ return {
108
+ async save(client): Promise<void> {
109
+ const id = client.metadata?.id ?? "";
110
+ const org = client.metadata?.org ?? "";
111
+ const slug = client.metadata?.slug ?? "";
112
+ if ((await readById(id)) !== undefined) {
113
+ throw new DuplicatePlatformClientError(
114
+ `platform client '${id}' already exists`,
115
+ );
116
+ }
117
+ if ((await readByOrgAndSlug(org, slug)) !== undefined) {
118
+ throw new DuplicatePlatformClientError(
119
+ `platform client '${org}/${slug}' already exists`,
120
+ );
121
+ }
122
+ if ((await readByClientId(client.spec?.clientId ?? "")) !== undefined) {
123
+ throw new DuplicatePlatformClientError(
124
+ "a platform client already holds this client_id",
125
+ );
126
+ }
127
+ await store.saveResource(KIND, id, PlatformClientSchema, client);
128
+ },
129
+
130
+ async update(client): Promise<void> {
131
+ const id = client.metadata?.id ?? "";
132
+ if ((await readById(id)) === undefined) {
133
+ return;
134
+ }
135
+ await store.saveResource(KIND, id, PlatformClientSchema, client);
136
+ },
137
+
138
+ async deleteById(id): Promise<void> {
139
+ await store.deleteResource(KIND, id);
140
+ },
141
+
142
+ findById: readById,
143
+
144
+ findByClientId: readByClientId,
145
+
146
+ findByOrgAndSlug: readByOrgAndSlug,
147
+
148
+ findByOrg: listOrg,
149
+ };
150
+ }