@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
@@ -1,20 +1,26 @@
1
1
  /**
2
2
  * Named-key loader — ports pkg/encryption/keymanager.go.
3
3
  *
4
- * One convention for every 32-byte key the server holds (sub-project
5
- * DD-002): env var (Base64) → ~/.stigmer/<file> (raw bytes, 0600) →
6
- * auto-generate and persist. The loader is generalized over the env var
7
- * and file name so sibling key material (the runner-token signing key,
8
- * oss#535) rides the same ladder instead of growing a divergent loader.
4
+ * One convention for every key the server holds: env var (Base64) →
5
+ * ~/.stigmer/<file> (0600) → auto-generate and persist. The
6
+ * loader is generalized over the env var, the file name and a KeyCodec so
7
+ * sibling key material rides the same ladder instead of growing a
8
+ * divergent loader: the runner-token signing key (oss#535) and the
9
+ * encryption key are 32 raw bytes (RAW_32_BYTE_KEY); the platform-token
10
+ * signing key is an RSA private key (platformtoken/key-ring.ts). The codec
11
+ * says what a key IS — how an env value and a file decode, how one is
12
+ * generated and written — and nothing about the ladder, which stays one.
9
13
  *
10
- * Ladder semantics, exactly Go's:
14
+ * Ladder semantics, exactly Go's, for every codec:
11
15
  * - An EXPLICITLY configured env key that is unusable (bad Base64, wrong
12
- * length) is an ERROR, never a degrade — silently ignoring deliberate
13
- * configuration would be worse than refusing to boot.
16
+ * length, not the key type the codec holds) is an ERROR, never a
17
+ * degrade — silently ignoring deliberate configuration would be worse
18
+ * than refusing to boot.
14
19
  * - A key file that fails its load checks (permissions other than 0600,
15
- * wrong size) is never adopted; the ladder FALLS THROUGH to
16
- * auto-generate, which overwrites the file's content — Go's shipped
17
- * behavior (loadKeyFromFile error → generate → save), ported as-is.
20
+ * content the codec does not accept) is never adopted; the ladder
21
+ * FALLS THROUGH to auto-generate, which overwrites the file's content —
22
+ * Go's shipped behavior (loadKeyFromFile error → generate → save),
23
+ * ported as-is.
18
24
  * - Auto-generation persists for future boots; a persist FAILURE is a
19
25
  * stderr warning only — the key is still usable for this process.
20
26
  *
@@ -50,34 +56,81 @@ export interface KeyLoaderOptions {
50
56
  }
51
57
 
52
58
  /**
53
- * Go GetOrCreateNamedKey: env var → key file → auto-generate. Throws only
54
- * on unusable EXPLICIT configuration (bad env value) or when no key can be
55
- * produced at all.
59
+ * What one kind of key is, for the ladder. `fromEnv` receives the env
60
+ * value already strictly Base64-decoded and THROWS when the bytes are not
61
+ * a usable key (explicit configuration is never silently ignored);
62
+ * `fromFile` answers `undefined` for content it does not accept, so the
63
+ * ladder falls through exactly as it does for a wrong-sized raw key.
64
+ */
65
+ export interface KeyCodec<Key> {
66
+ fromEnv(envVar: string, decoded: Buffer): Key;
67
+ fromFile(content: Buffer): Key | undefined;
68
+ generate(): Key;
69
+ toFile(key: Key): Buffer;
70
+ }
71
+
72
+ /** The 32 raw bytes the encryption and runner-token keys are. */
73
+ export const RAW_32_BYTE_KEY: KeyCodec<Buffer> = {
74
+ fromEnv(envVar, decoded) {
75
+ if (decoded.length !== KEY_SIZE) {
76
+ throw new Error(
77
+ `${envVar} must be exactly 32 bytes (256 bits) when decoded, got ${decoded.length} bytes`,
78
+ );
79
+ }
80
+ return decoded;
81
+ },
82
+ fromFile(content) {
83
+ return content.length === KEY_SIZE ? content : undefined;
84
+ },
85
+ generate() {
86
+ return randomBytes(KEY_SIZE);
87
+ },
88
+ toFile(key) {
89
+ return key;
90
+ },
91
+ };
92
+
93
+ /**
94
+ * Go GetOrCreateNamedKey: env var → key file → auto-generate, for a 32-byte
95
+ * raw key. Throws only on unusable EXPLICIT configuration (bad env value)
96
+ * or when no key can be produced at all.
56
97
  */
57
98
  export function getOrCreateNamedKey(
58
99
  envVar: string,
59
100
  fileName: string,
60
101
  options: KeyLoaderOptions = {},
61
102
  ): Buffer {
103
+ return getOrCreateKey(RAW_32_BYTE_KEY, envVar, fileName, options);
104
+ }
105
+
106
+ /** The ladder over any codec: env var → key file → auto-generate. */
107
+ export function getOrCreateKey<Key>(
108
+ codec: KeyCodec<Key>,
109
+ envVar: string,
110
+ fileName: string,
111
+ options: KeyLoaderOptions = {},
112
+ ): Key {
62
113
  const env = options.env ?? process.env;
63
114
 
64
- // 1. Environment variable (highest priority) — Base64-encoded 32 bytes.
115
+ // 1. Environment variable (highest priority) — Base64-encoded.
65
116
  const envValue = env[envVar];
66
117
  if (envValue !== undefined && envValue !== "") {
67
- return decodeEnvKey(envVar, envValue);
118
+ return codec.fromEnv(envVar, decodeEnvBase64(envVar, envValue));
68
119
  }
69
120
 
70
- // 2. Local key file — raw 32 bytes, refused on insecure permissions.
121
+ // 2. Local key file — refused on insecure permissions or content the
122
+ // codec does not accept.
71
123
  const keyPath = namedKeyFilePath(fileName, options);
72
- const fromFile = loadKeyFromFile(keyPath);
124
+ const content = readKeyFile(keyPath);
125
+ const fromFile = content === undefined ? undefined : codec.fromFile(content);
73
126
  if (fromFile !== undefined) {
74
127
  return fromFile;
75
128
  }
76
129
 
77
130
  // 3. Auto-generate (local development); persist for future boots.
78
- const key = randomBytes(KEY_SIZE);
131
+ const key = codec.generate();
79
132
  try {
80
- saveKeyToFile(keyPath, key);
133
+ saveKeyToFile(keyPath, codec.toFile(key));
81
134
  } catch (error) {
82
135
  // Warn but don't fail — the key is still usable (Go's posture).
83
136
  process.stderr.write(
@@ -87,22 +140,17 @@ export function getOrCreateNamedKey(
87
140
  return key;
88
141
  }
89
142
 
90
- /** Strict Base64 decode + length check for an env-configured key. */
91
- function decodeEnvKey(envVar: string, value: string): Buffer {
92
- const key = Buffer.from(value, "base64");
143
+ /** Strict Base64 decode for an env-configured key. */
144
+ function decodeEnvBase64(envVar: string, value: string): Buffer {
145
+ const decoded = Buffer.from(value, "base64");
93
146
  // Node's base64 decoder is lenient (skips invalid characters, accepts
94
147
  // missing padding) and never throws; Go's StdEncoding errors on any
95
148
  // non-canonical input. Round-tripping — Buffer always re-emits the
96
149
  // canonical padded form — restores Go's strictness.
97
- if (key.toString("base64") !== value) {
150
+ if (decoded.toString("base64") !== value) {
98
151
  throw new Error(`invalid Base64 encoding in ${envVar}`);
99
152
  }
100
- if (key.length !== KEY_SIZE) {
101
- throw new Error(
102
- `${envVar} must be exactly 32 bytes (256 bits) when decoded, got ${key.length} bytes`,
103
- );
104
- }
105
- return key;
153
+ return decoded;
106
154
  }
107
155
 
108
156
  /** Path to a named key file under <home>/.stigmer. */
@@ -115,13 +163,13 @@ export function namedKeyFilePath(
115
163
  }
116
164
 
117
165
  /**
118
- * Reads raw key bytes, mirroring Go loadKeyFromFile: missing file →
119
- * undefined (fall through the ladder); present-but-wrong (insecure
120
- * permissions, wrong size) also falls through — exactly Go's behavior,
121
- * where any load error falls to auto-generate. The 0600 check is skipped
122
- * on Windows, where POSIX modes are not meaningful.
166
+ * Reads a key file's bytes, mirroring Go loadKeyFromFile: missing file →
167
+ * undefined (fall through the ladder); present with insecure permissions
168
+ * also falls through — exactly Go's behavior, where any load error falls
169
+ * to auto-generate. The 0600 check is skipped on Windows, where POSIX
170
+ * modes are not meaningful.
123
171
  */
124
- function loadKeyFromFile(keyPath: string): Buffer | undefined {
172
+ function readKeyFile(keyPath: string): Buffer | undefined {
125
173
  let stat: fs.Stats;
126
174
  try {
127
175
  stat = fs.statSync(keyPath);
@@ -134,23 +182,18 @@ function loadKeyFromFile(keyPath: string): Buffer | undefined {
134
182
  return undefined;
135
183
  }
136
184
  }
137
- let key: Buffer;
138
185
  try {
139
- key = fs.readFileSync(keyPath);
186
+ return fs.readFileSync(keyPath);
140
187
  } catch {
141
188
  return undefined;
142
189
  }
143
- if (key.length !== KEY_SIZE) {
144
- return undefined;
145
- }
146
- return key;
147
190
  }
148
191
 
149
192
  /** Writes the key with secure permissions (dir 0700, file 0600). */
150
- function saveKeyToFile(keyPath: string, key: Buffer): void {
193
+ function saveKeyToFile(keyPath: string, content: Buffer): void {
151
194
  fs.mkdirSync(path.dirname(keyPath), {
152
195
  recursive: true,
153
196
  mode: KEY_DIR_PERMISSIONS,
154
197
  });
155
- fs.writeFileSync(keyPath, key, { mode: KEY_FILE_PERMISSIONS });
198
+ fs.writeFileSync(keyPath, content, { mode: KEY_FILE_PERMISSIONS });
156
199
  }
@@ -549,8 +549,9 @@ describe("extension composition (require-authentication posture)", () => {
549
549
  // composes its Authorizer, and with it the runner-subject verifier
550
550
  // between the API-key lane and the unit's own (runnerauth/
551
551
  // runner-subject-verifier.ts). A unit with its own Authorizer gets
552
- // neither (runner-subject-composed.test.ts pins that chain).
553
- expect(parsed.verifiers).toBe("apikey, runner, fake-verifier");
552
+ // neither (runner-subject-composed.test.ts pins that chain). The
553
+ // PlatformClient lane rides the posture itself, ahead of both.
554
+ expect(parsed.verifiers).toBe("apikey, platform-client, runner, fake-verifier");
554
555
  });
555
556
 
556
557
  it("a declared posture whose unit registers no verifier of its own is a boot throw naming the unit — the API-key lane alone cannot bootstrap", async () => {
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The PlatformClient lane on a composed server in the authentication
3
+ * posture with the built-in Authorizer (a unit that vouches for tokens,
4
+ * declares the posture and registers no Authorizer — the open-source OIDC
5
+ * self-host's shape), with the key ring supplied so the run is hermetic:
6
+ * - the founder creates a client; an anonymous mint answers a token; the
7
+ * token authenticates as the end user's derived account, which holds the
8
+ * auto-grant role on the owning organization and sees that one alone;
9
+ * - the platform-client verifier sits ahead of the unit's verifier and
10
+ * claims only its lane: the founder's tokens still pass, and a TYPED
11
+ * platform token (a lane open source does not speak) is refused as
12
+ * unclaimed;
13
+ * - the origin guard refuses an unlisted Origin with the cloud's copy and
14
+ * passes a request without one;
15
+ * - deleting the client refuses its token on the next request with the
16
+ * cloud's liveness copy;
17
+ * - an outsider cannot read the client by reference.
18
+ */
19
+ import { generateKeyPairSync } from "node:crypto";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import path from "node:path";
23
+
24
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
25
+ import type { Interceptor, Transport } from "@connectrpc/connect";
26
+ import { createGrpcTransport } from "@connectrpc/connect-node";
27
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
28
+
29
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
31
+ import { IdentityAccountQueryController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/query_pb";
32
+ import { PlatformClientCommandController } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/command_pb";
33
+ import { PlatformClientQueryController } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/query_pb";
34
+ import { PlatformClientTokenController } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
35
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
36
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
37
+ import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
38
+
39
+ import { loadConfig } from "../../boot/config.js";
40
+ import { composeServer } from "../../boot/compose.js";
41
+ import type { ComposedServer } from "../../boot/compose.js";
42
+ import { accountIdFor } from "../../domain/identityaccount/constants.js";
43
+ import {
44
+ DELETED_CLIENT_MESSAGE,
45
+ originRefusalMessage,
46
+ } from "../../domain/platformclient/constants.js";
47
+ import { signPlatformToken } from "../../platformtoken/envelope.js";
48
+ import { platformTokenKeyRingFromPem } from "../../platformtoken/key-ring.js";
49
+ import type { SigningPlatformTokenKeyRing } from "../../platformtoken/key-ring.js";
50
+ import type { ServerExtension } from "../registry.js";
51
+ import {
52
+ baseConfig,
53
+ bearer,
54
+ fakeJwt,
55
+ fakeVerifier,
56
+ silentLogger,
57
+ transportFor,
58
+ } from "./composed-support.js";
59
+
60
+ const FOUNDER = "fake|pc-founder";
61
+ const OUTSIDER = "fake|pc-outsider";
62
+ const ORG = "pc-org";
63
+
64
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
65
+ modulusLength: 2048,
66
+ });
67
+ const built = platformTokenKeyRingFromPem({
68
+ privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
69
+ publicKeyPems: [publicKey.export({ format: "pem", type: "spki" }).toString()],
70
+ });
71
+ if (built.signer === undefined) throw new Error("fixture ring must sign");
72
+ const RING: SigningPlatformTokenKeyRing = { ...built, signer: built.signer };
73
+
74
+ /** Stamps the lowercase `origin` header a browser attaches to a cross-origin call. */
75
+ function browserOrigin(origin: string): Interceptor {
76
+ return (next) => (request) => {
77
+ request.header.set("origin", origin);
78
+ return next(request);
79
+ };
80
+ }
81
+
82
+ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
83
+ try {
84
+ await promise;
85
+ } catch (error) {
86
+ if (error instanceof ConnectError) return error;
87
+ throw error;
88
+ }
89
+ throw new Error("expected a ConnectError refusal");
90
+ }
91
+
92
+ describe("PlatformClient on a composed server (authentication posture, built-in Authorizer)", () => {
93
+ let dir: string;
94
+ let server: ComposedServer;
95
+ let port: number;
96
+
97
+ const asFounder = (): Transport =>
98
+ transportFor(port, fakeJwt(FOUNDER, "founder@example.com"));
99
+ const asOutsider = (): Transport =>
100
+ transportFor(port, fakeJwt(OUTSIDER, "outsider@example.com"));
101
+ const presenting = (token: string, origin?: string): Transport =>
102
+ createGrpcTransport({
103
+ baseUrl: `http://127.0.0.1:${port}`,
104
+ interceptors: [
105
+ bearer(token),
106
+ ...(origin !== undefined ? [browserOrigin(origin)] : []),
107
+ ],
108
+ });
109
+
110
+ beforeAll(async () => {
111
+ dir = mkdtempSync(path.join(tmpdir(), "platform-client-composed-"));
112
+ const unit: ServerExtension = {
113
+ name: "fake-oidc-only",
114
+ requireAuthentication: true,
115
+ identityVerifiers: [fakeVerifier],
116
+ drivers: { platformTokenKeys: RING },
117
+ };
118
+ server = await composeServer({
119
+ config: loadConfig(baseConfig(dir)),
120
+ logger: silentLogger,
121
+ extensions: [unit],
122
+ portOverride: 0,
123
+ host: "127.0.0.1",
124
+ });
125
+ port = await server.start();
126
+
127
+ await createClient(
128
+ IdentityAccountCommandController,
129
+ asFounder(),
130
+ ).provisionMyAccount({});
131
+ await createClient(OrganizationCommandController, asFounder()).create({
132
+ apiVersion: "tenancy.stigmer.ai/v1",
133
+ kind: "Organization",
134
+ metadata: { name: ORG, slug: ORG, org: "" },
135
+ spec: { description: ORG },
136
+ });
137
+ await createClient(
138
+ IdentityAccountCommandController,
139
+ asOutsider(),
140
+ ).provisionMyAccount({});
141
+ });
142
+
143
+ afterAll(async () => {
144
+ await server?.shutdown();
145
+ rmSync(dir, { recursive: true, force: true });
146
+ });
147
+
148
+ async function createPlatformClient(allowedOrigins: string[] = []) {
149
+ const created = await createClient(
150
+ PlatformClientCommandController,
151
+ asFounder(),
152
+ ).create({
153
+ apiVersion: "iam.stigmer.ai/v1",
154
+ kind: "PlatformClient",
155
+ metadata: {
156
+ name: `client-${Math.random().toString(36).slice(2, 8)}`,
157
+ org: ORG,
158
+ },
159
+ spec: {
160
+ autoProvisionAccounts: true,
161
+ autoGrantOnOrg: true,
162
+ autoGrantRole: IamRole.member,
163
+ allowedOrigins,
164
+ },
165
+ });
166
+ return {
167
+ id: created.platformClient?.metadata?.id ?? "",
168
+ slug: created.platformClient?.metadata?.slug ?? "",
169
+ clientId: created.platformClient?.spec?.clientId ?? "",
170
+ clientSecret: created.clientSecret,
171
+ };
172
+ }
173
+
174
+ async function mint(
175
+ client: { clientId: string; clientSecret: string },
176
+ userId: string,
177
+ ) {
178
+ const minted = await createClient(
179
+ PlatformClientTokenController,
180
+ transportFor(port),
181
+ ).mintUserToken({
182
+ clientId: client.clientId,
183
+ clientSecret: client.clientSecret,
184
+ userId,
185
+ userEmail: `${userId}@product.example`,
186
+ userName: "Product User",
187
+ });
188
+ return minted.accessToken;
189
+ }
190
+
191
+ it("mints for the end user's derived account, which sees its owning organization alone", async () => {
192
+ const client = await createPlatformClient();
193
+ const token = await mint(client, "user-1");
194
+
195
+ const me = await createClient(
196
+ IdentityAccountQueryController,
197
+ presenting(token),
198
+ ).whoAmI({});
199
+ expect(me.metadata?.id).toBe(accountIdFor(`stgm_pc|${ORG}|user-1`));
200
+
201
+ const mine = await createClient(
202
+ OrganizationQueryController,
203
+ presenting(token),
204
+ ).findMyOrganizations({});
205
+ expect(
206
+ mine.entries.map((organization) => organization.metadata?.slug),
207
+ ).toEqual([ORG]);
208
+ });
209
+
210
+ it("claims only its lane: the unit's tokens still pass, and a typed platform token is refused as unclaimed", async () => {
211
+ await createClient(
212
+ OrganizationQueryController,
213
+ asFounder(),
214
+ ).findMyOrganizations({});
215
+
216
+ const guest = signPlatformToken(RING, {
217
+ sub: "ida_guest",
218
+ token_type: "guest",
219
+ }).token;
220
+ const refused = await refusal(
221
+ createClient(
222
+ OrganizationQueryController,
223
+ presenting(guest),
224
+ ).findMyOrganizations({}),
225
+ );
226
+ expect(refused.code).toBe(Code.Unauthenticated);
227
+ });
228
+
229
+ it("refuses an unlisted Origin with the cloud's copy and passes a request without one", async () => {
230
+ const client = await createPlatformClient(["https://app.example"]);
231
+ const token = await mint(client, "user-2");
232
+
233
+ const denied = await refusal(
234
+ createClient(
235
+ OrganizationQueryController,
236
+ presenting(token, "https://evil.example"),
237
+ ).findMyOrganizations({}),
238
+ );
239
+ expect(denied.code).toBe(Code.PermissionDenied);
240
+ expect(denied.rawMessage).toBe(
241
+ originRefusalMessage("https://evil.example"),
242
+ );
243
+
244
+ await createClient(
245
+ OrganizationQueryController,
246
+ presenting(token),
247
+ ).findMyOrganizations({});
248
+ await createClient(
249
+ OrganizationQueryController,
250
+ presenting(token, "https://app.example"),
251
+ ).findMyOrganizations({});
252
+ });
253
+
254
+ it("refuses a deleted client's token on the next request with the liveness copy", async () => {
255
+ const client = await createPlatformClient();
256
+ const token = await mint(client, "user-3");
257
+ await createClient(
258
+ OrganizationQueryController,
259
+ presenting(token),
260
+ ).findMyOrganizations({});
261
+
262
+ await createClient(PlatformClientCommandController, asFounder()).delete({
263
+ resourceId: client.id,
264
+ });
265
+
266
+ const denied = await refusal(
267
+ createClient(
268
+ OrganizationQueryController,
269
+ presenting(token),
270
+ ).findMyOrganizations({}),
271
+ );
272
+ expect(denied.code).toBe(Code.Unauthenticated);
273
+ expect(denied.rawMessage).toBe(DELETED_CLIENT_MESSAGE);
274
+ });
275
+
276
+ it("an outsider cannot read the client by reference", async () => {
277
+ const client = await createPlatformClient();
278
+ const denied = await refusal(
279
+ createClient(PlatformClientQueryController, asOutsider()).getByReference({
280
+ org: ORG,
281
+ slug: client.slug,
282
+ kind: ApiResourceKind.platform_client,
283
+ }),
284
+ );
285
+ expect(denied.code).toBe(Code.PermissionDenied);
286
+ expect(denied.rawMessage).toBe("unauthorized to view platform client");
287
+ });
288
+ });
@@ -193,7 +193,10 @@ describe("resolveExtensions — merge semantics", () => {
193
193
  "g2",
194
194
  "g3",
195
195
  ]);
196
- expect(resolved.services).toEqual([registerA, registerB]);
196
+ expect(resolved.services).toEqual([
197
+ { unit: "alpha", register: registerA },
198
+ { unit: "beta", register: registerB },
199
+ ]);
197
200
  expect(resolved.workers).toEqual([workerFactory]);
198
201
  expect(resolved.statusObservers).toEqual([observerA]);
199
202
  expect(resolved.responseDecorators).toEqual([decoratorB]);
@@ -218,6 +218,14 @@ describe("tier truthfulness against the empty composition", () => {
218
218
  );
219
219
  expect(served.has("iampolicy")).toBe(true);
220
220
  });
221
+
222
+ it("platform_client is open_source and served in the empty composition — the mint refuses without a posture, the kind does not", () => {
223
+ const served = servedKindSegments(server.routes);
224
+ expect(getKindMeta(ApiResourceKind.platform_client).tier).toBe(
225
+ ResourceTier.open_source,
226
+ );
227
+ expect(served.has("platformclient")).toBe(true);
228
+ });
221
229
  });
222
230
 
223
231
  describe("the check bites (mutation proofs over the same function)", () => {
@@ -10,9 +10,14 @@
10
10
  * allowed_origins, oss#375). Java separates the two classes the same way.
11
11
  *
12
12
  * List point — guards concatenate in unit order and run in composed
13
- * order; the first throw wins. OSS composes zero guards, so with none
14
- * registered the serving chain behaves byte-identically to before the
15
- * point existed (the conformance rosters pin that).
13
+ * order; the first throw wins. Open source's own guards run first (the
14
+ * verifier list's "OSS entries first" rule): today the PlatformClient
15
+ * origin guard (domain/platformclient/origin-guard.ts), composed only
16
+ * under an authentication posture, where PlatformClient tokens exist.
17
+ * Liveness is not a guard in open source: the PlatformClient verifier
18
+ * refuses a deleted client's tokens itself, so verifier-only lanes (the
19
+ * extension HTTP edges) refuse them too. With no posture and no unit
20
+ * guards, the serving chain runs no guard at all.
16
21
  *
17
22
  * The in-process exemption is STRUCTURAL, not policed: guards are a
18
23
  * parameter of the serving chain's identity source only
@@ -35,6 +35,11 @@
35
35
  * it reaches a URL a user supplied: the MCP endpoint it probes at save
36
36
  * time and the login server it reaches on Sign in) — landed with the
37
37
  * save-time OAuth completion for URL-only MCP servers, 2026-09-19
38
+ * - platform-client store + platform-token keys + guest-token minting
39
+ * (PlatformClient served in every edition: the port the cloud's
40
+ * `cloud.iam_platform_client` driver fills, the RS256 key ring every
41
+ * self-signed token rides, and the one token method only an edition
42
+ * that hosts shared-agent pages serves) — landed 2026-09-23
38
43
  *
39
44
  * Merge rules (enforced by resolveExtensions, DD-006 §2b): the two
40
45
  * provider kinds are single-instance points — a second declaring unit is
@@ -50,12 +55,15 @@ import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-s
50
55
  import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
51
56
  import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
52
57
  import type { IamPolicyStore } from "../domain/iampolicy/store.js";
58
+ import type { PlatformClientStore } from "../domain/platformclient/store.js";
53
59
  import type { SecretCodec } from "../encryption/codec.js";
54
60
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
55
61
  import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
62
+ import type { PlatformTokenKeyRing } from "../platformtoken/key-ring.js";
56
63
  import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-provider.js";
57
64
  import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
58
65
  import type { AuthorizationQueryEngine } from "./authorization-queries.js";
66
+ import type { GuestTokenMinting } from "./guest-token-minting.js";
59
67
  import type { IdentityFederation } from "./identity-federation.js";
60
68
  import type { LicenseStatusProvider } from "./license-status.js";
61
69
  import type { OutboundEgressPolicy } from "./outbound-egress.js";
@@ -247,4 +255,33 @@ export interface ExtensionDrivers {
247
255
  * (extensions/outbound-egress.ts carries the contract).
248
256
  */
249
257
  readonly outboundEgress?: OutboundEgressPolicy;
258
+ /**
259
+ * The platform-client store driver (single-instance point). The
260
+ * platform-client domain's persistence is a PORT like the
261
+ * identity-account domain's: when composed, every chain, the mint and
262
+ * the verifier's liveness read go through this driver (the cloud serves
263
+ * the domain over its own `cloud.iam_platform_client` table this way,
264
+ * legacy `pc_` ids included). When absent, the OSS adapter over the
265
+ * generic Store installs at the compose.ts consumption site.
266
+ */
267
+ readonly platformClientStore?: PlatformClientStore;
268
+ /**
269
+ * The platform-token key ring (single-instance point): the RS256 keys
270
+ * every token the server signs for itself rides
271
+ * (platformtoken/key-ring.ts). Consumed only under an authentication
272
+ * posture — nothing verifies a token on a server that trusts every
273
+ * request. When absent there, open source composes its own ring on the
274
+ * key-manager ladder; a composition declaring an edition other than
275
+ * open source must supply one (compose.ts refuses to boot without it,
276
+ * so a hosted edition never signs with a generated key).
277
+ */
278
+ readonly platformTokenKeys?: PlatformTokenKeyRing;
279
+ /**
280
+ * The guest-token capability (single-instance point): the one
281
+ * PlatformClientTokenController method only an edition hosting
282
+ * shared-agent pages serves. When absent, `mintGuestToken` refuses
283
+ * UNIMPLEMENTED with the edition sentence
284
+ * (extensions/guest-token-minting.ts carries the contract).
285
+ */
286
+ readonly guestTokenMinting?: GuestTokenMinting;
250
287
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The guest-token capability: `PlatformClientTokenController.mintGuestToken`,
3
+ * the credential-free mint for anonymous visitors of a shared agent's
4
+ * hosted page. Single instance, registered as `drivers.guestTokenMinting`
5
+ * (the identityFederation shape: a driver point whose absence is a
6
+ * refusal, never a silent no-op).
7
+ *
8
+ * Why a capability and not a second service: open source registers the
9
+ * whole PlatformClientTokenController, because `mintUserToken` is core,
10
+ * and the composition refuses an extension that registers a service the
11
+ * core already routes. Guest sharing — the hosted page, its rate limits,
12
+ * the share resolution, the org's system-managed client and guest
13
+ * account — is an edition's, so the one method that needs it is
14
+ * dispatched here.
15
+ *
16
+ * Absent, the controller answers UNIMPLEMENTED with the edition sentence
17
+ * (domain/platformclient/constants.ts). Present, the capability owns the
18
+ * whole arm: the request has passed the protovalidate interceptor, and
19
+ * `headers` carries the request's `origin` for the embed checks. It signs
20
+ * with the ring its own composition supplied (`signPlatformToken`).
21
+ */
22
+ import type {
23
+ MintGuestTokenRequest,
24
+ MintGuestTokenResponse,
25
+ } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
26
+
27
+ export interface GuestTokenMinting {
28
+ mintGuestToken(
29
+ request: MintGuestTokenRequest,
30
+ headers: Headers,
31
+ ): Promise<MintGuestTokenResponse>;
32
+ }
@@ -23,10 +23,12 @@
23
23
  * known classes from the type surface).
24
24
  *
25
25
  * `internal` is ratified contract with a structural guarantee: it is
26
- * minted ONLY by the in-process chain's identity interceptor
27
- * (boot/inprocess.ts). No IdentityVerifier may produce it — the serving
28
- * chain always overwrites the position-1 identity from the wire, so a
29
- * spoofed internal class cannot enter through a transport.
26
+ * minted ONLY by server code, in pipeline/interceptors/auth.ts — the
27
+ * in-process chain's identity interceptor (boot/inprocess.ts) and
28
+ * `serverActingFor`, the server acting for a principal it authenticated
29
+ * itself (the PlatformClient mint). No IdentityVerifier may produce it —
30
+ * the serving chain always overwrites the position-1 identity from the
31
+ * wire, so a spoofed internal class cannot enter through a transport.
30
32
  */
31
33
  export type CallerClass =
32
34
  | "user"