@stigmer/server 3.14.1 → 3.15.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 (116) hide show
  1. package/dist/boot/compose.d.ts +8 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +98 -12
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/apikey/verifier.d.ts +8 -1
  6. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  7. package/dist/domain/apikey/verifier.js +14 -2
  8. package/dist/domain/apikey/verifier.js.map +1 -1
  9. package/dist/domain/identityaccount/constants.d.ts +66 -0
  10. package/dist/domain/identityaccount/constants.d.ts.map +1 -0
  11. package/dist/domain/identityaccount/constants.js +153 -0
  12. package/dist/domain/identityaccount/constants.js.map +1 -0
  13. package/dist/domain/identityaccount/controller.d.ts +34 -0
  14. package/dist/domain/identityaccount/controller.d.ts.map +1 -0
  15. package/dist/domain/identityaccount/controller.js +342 -0
  16. package/dist/domain/identityaccount/controller.js.map +1 -0
  17. package/dist/domain/identityaccount/operator.d.ts +14 -0
  18. package/dist/domain/identityaccount/operator.d.ts.map +1 -0
  19. package/dist/domain/identityaccount/operator.js +67 -0
  20. package/dist/domain/identityaccount/operator.js.map +1 -0
  21. package/dist/domain/identityaccount/provisioning.d.ts +52 -0
  22. package/dist/domain/identityaccount/provisioning.d.ts.map +1 -0
  23. package/dist/domain/identityaccount/provisioning.js +114 -0
  24. package/dist/domain/identityaccount/provisioning.js.map +1 -0
  25. package/dist/domain/identityaccount/resolve.d.ts +36 -0
  26. package/dist/domain/identityaccount/resolve.d.ts.map +1 -0
  27. package/dist/domain/identityaccount/resolve.js +12 -0
  28. package/dist/domain/identityaccount/resolve.js.map +1 -0
  29. package/dist/domain/identityaccount/resource-store.d.ts +4 -0
  30. package/dist/domain/identityaccount/resource-store.d.ts.map +1 -0
  31. package/dist/domain/identityaccount/resource-store.js +131 -0
  32. package/dist/domain/identityaccount/resource-store.js.map +1 -0
  33. package/dist/domain/identityaccount/steps.d.ts +79 -0
  34. package/dist/domain/identityaccount/steps.d.ts.map +1 -0
  35. package/dist/domain/identityaccount/steps.js +291 -0
  36. package/dist/domain/identityaccount/steps.js.map +1 -0
  37. package/dist/domain/identityaccount/store-contract.d.ts +22 -0
  38. package/dist/domain/identityaccount/store-contract.d.ts.map +1 -0
  39. package/dist/domain/identityaccount/store-contract.js +307 -0
  40. package/dist/domain/identityaccount/store-contract.js.map +1 -0
  41. package/dist/domain/identityaccount/store.d.ts +66 -0
  42. package/dist/domain/identityaccount/store.d.ts.map +1 -0
  43. package/dist/domain/identityaccount/store.js +13 -0
  44. package/dist/domain/identityaccount/store.js.map +1 -0
  45. package/dist/extensions/drivers.d.ts +26 -0
  46. package/dist/extensions/drivers.d.ts.map +1 -1
  47. package/dist/extensions/gate-slots.d.ts +16 -1
  48. package/dist/extensions/gate-slots.d.ts.map +1 -1
  49. package/dist/extensions/gate-slots.js +1 -0
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/extensions/identity-federation.d.ts +36 -0
  52. package/dist/extensions/identity-federation.d.ts.map +1 -0
  53. package/dist/extensions/identity-federation.js +2 -0
  54. package/dist/extensions/identity-federation.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +18 -1
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +20 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/identity/oidc-discovery.d.ts +36 -0
  60. package/dist/identity/oidc-discovery.d.ts.map +1 -0
  61. package/dist/identity/oidc-discovery.js +60 -0
  62. package/dist/identity/oidc-discovery.js.map +1 -0
  63. package/dist/identity/oidc-userinfo.d.ts +30 -0
  64. package/dist/identity/oidc-userinfo.d.ts.map +1 -0
  65. package/dist/identity/oidc-userinfo.js +28 -0
  66. package/dist/identity/oidc-userinfo.js.map +1 -0
  67. package/dist/identity/oidc-verifier.d.ts +6 -0
  68. package/dist/identity/oidc-verifier.d.ts.map +1 -1
  69. package/dist/identity/oidc-verifier.js +35 -34
  70. package/dist/identity/oidc-verifier.js.map +1 -1
  71. package/dist/index.d.ts +7 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +3 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/pipeline/interceptors/auth.d.ts +10 -0
  76. package/dist/pipeline/interceptors/auth.d.ts.map +1 -1
  77. package/dist/pipeline/interceptors/auth.js +9 -1
  78. package/dist/pipeline/interceptors/auth.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/boot/__tests__/auth-enabled-composition.test.ts +226 -2
  81. package/src/boot/compose.ts +118 -13
  82. package/src/domain/apikey/__tests__/verifier.test.ts +146 -22
  83. package/src/domain/apikey/verifier.ts +24 -2
  84. package/src/domain/identityaccount/__tests__/constants.test.ts +155 -0
  85. package/src/domain/identityaccount/__tests__/identityaccount.test.ts +484 -0
  86. package/src/domain/identityaccount/__tests__/operator.test.ts +180 -0
  87. package/src/domain/identityaccount/__tests__/provisioning.test.ts +281 -0
  88. package/src/domain/identityaccount/__tests__/resolve.test.ts +114 -0
  89. package/src/domain/identityaccount/__tests__/resource-store.test.ts +216 -0
  90. package/src/domain/identityaccount/__tests__/support.ts +58 -0
  91. package/src/domain/identityaccount/constants.ts +178 -0
  92. package/src/domain/identityaccount/controller.ts +667 -0
  93. package/src/domain/identityaccount/operator.ts +94 -0
  94. package/src/domain/identityaccount/provisioning.ts +182 -0
  95. package/src/domain/identityaccount/resolve.ts +52 -0
  96. package/src/domain/identityaccount/resource-store.ts +157 -0
  97. package/src/domain/identityaccount/steps.ts +377 -0
  98. package/src/domain/identityaccount/store-contract.ts +471 -0
  99. package/src/domain/identityaccount/store.ts +70 -0
  100. package/src/extensions/__tests__/identity-account-composed.test.ts +803 -0
  101. package/src/extensions/__tests__/identity-account-points.test.ts +129 -0
  102. package/src/extensions/__tests__/registry.test.ts +4 -0
  103. package/src/extensions/__tests__/tier-truthfulness.test.ts +299 -0
  104. package/src/extensions/drivers.ts +26 -0
  105. package/src/extensions/gate-slots.ts +16 -0
  106. package/src/extensions/identity-federation.ts +58 -0
  107. package/src/extensions/registry.ts +44 -1
  108. package/src/identity/__tests__/oidc-discovery.test.ts +122 -0
  109. package/src/identity/__tests__/oidc-userinfo.test.ts +103 -0
  110. package/src/identity/__tests__/oidc-verifier.test.ts +173 -3
  111. package/src/identity/oidc-discovery.ts +84 -0
  112. package/src/identity/oidc-userinfo.ts +64 -0
  113. package/src/identity/oidc-verifier.ts +42 -45
  114. package/src/index.ts +19 -0
  115. package/src/pipeline/interceptors/auth.ts +13 -1
  116. package/src/pipeline/steps/__tests__/authorize-annotation-completeness.test.ts +184 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Pins the two registry points the identity-account domain adds
3
+ * (20260911.11, Q-IA-9), in the O5 driver-point shape the registry already
4
+ * enforces for organizationDirectory and listReadScope:
5
+ *
6
+ * - `drivers.identityAccountStore` — the store driver (the cloud serves
7
+ * the domain over cloud.iam_identity_account through it); single
8
+ * instance; absent = the OSS adapter over the generic Store;
9
+ * - `drivers.identityFederation` — the four federation RPC arms plus
10
+ * providerExists; single instance; absent = the four RPCs refuse
11
+ * UNIMPLEMENTED with the edition reason (the domain suite pins the
12
+ * refusal; this file pins the registry);
13
+ * - the slot `identity-account-provision:post-persist` is declared and
14
+ * empty with no extensions (registry.test.ts pins the full slot roster).
15
+ */
16
+ import type { DescMessage } from "@bufbuild/protobuf";
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import type { IdentityAccountStore } from "../../domain/identityaccount/store.js";
20
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
21
+ import { DECLARED_GATE_SLOTS } from "../gate-slots.js";
22
+ import type { GateSlotName } from "../gate-slots.js";
23
+ import type { IdentityFederation } from "../identity-federation.js";
24
+ import { resolveExtensions } from "../registry.js";
25
+
26
+ const unimplemented = () => Promise.reject(new Error("not exercised here"));
27
+
28
+ const store: IdentityAccountStore = {
29
+ save: unimplemented,
30
+ update: unimplemented,
31
+ deleteById: unimplemented,
32
+ findById: unimplemented,
33
+ findByIdpId: unimplemented,
34
+ findDirectByIdpId: unimplemented,
35
+ findDirectByEmail: unimplemented,
36
+ findByIds: unimplemented,
37
+ };
38
+
39
+ const federation: IdentityFederation = {
40
+ createFederatedAccount: unimplemented,
41
+ updateFederatedAccount: unimplemented,
42
+ deprovisionFederatedAccount: unimplemented,
43
+ getByExternalSub: unimplemented,
44
+ providerExists: unimplemented,
45
+ };
46
+
47
+ describe("the identityAccountStore driver point", () => {
48
+ it("is undefined with no extensions — the OSS adapter serves", () => {
49
+ expect(resolveExtensions([]).drivers.identityAccountStore).toBeUndefined();
50
+ });
51
+
52
+ it("carries the one registered instance through", () => {
53
+ const resolved = resolveExtensions([
54
+ { name: "cloud-iam", drivers: { identityAccountStore: store } },
55
+ ]);
56
+ expect(resolved.drivers.identityAccountStore).toBe(store);
57
+ });
58
+
59
+ it("throws on a second registration, naming both units", () => {
60
+ expect(() =>
61
+ resolveExtensions([
62
+ { name: "store-a", drivers: { identityAccountStore: store } },
63
+ { name: "store-b", drivers: { identityAccountStore: store } },
64
+ ]),
65
+ ).toThrowError(
66
+ /extension 'store-b' registers an IdentityAccountStore, but 'store-a' already did/,
67
+ );
68
+ });
69
+ });
70
+
71
+ describe("the identityFederation capability point", () => {
72
+ it("is undefined with no extensions — the four RPCs refuse", () => {
73
+ expect(resolveExtensions([]).drivers.identityFederation).toBeUndefined();
74
+ });
75
+
76
+ it("carries the one registered instance through", () => {
77
+ const resolved = resolveExtensions([
78
+ { name: "cloud-iam", drivers: { identityFederation: federation } },
79
+ ]);
80
+ expect(resolved.drivers.identityFederation).toBe(federation);
81
+ });
82
+
83
+ it("throws on a second registration, naming both units", () => {
84
+ expect(() =>
85
+ resolveExtensions([
86
+ { name: "fed-a", drivers: { identityFederation: federation } },
87
+ { name: "fed-b", drivers: { identityFederation: federation } },
88
+ ]),
89
+ ).toThrowError(
90
+ /extension 'fed-b' registers an IdentityFederation, but 'fed-a' already did/,
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("the identity-account-provision:post-persist slot", () => {
96
+ it("is declared vocabulary and empty with no extensions", () => {
97
+ expect(
98
+ DECLARED_GATE_SLOTS.has("identity-account-provision:post-persist"),
99
+ ).toBe(true);
100
+ expect(
101
+ resolveExtensions([]).gateSteps.get(
102
+ "identity-account-provision:post-persist",
103
+ ),
104
+ ).toBeUndefined();
105
+ });
106
+
107
+ it("accepts a gate step from a unit and keeps unit order", () => {
108
+ const step = (name: string): PipelineStep<DescMessage> => ({
109
+ name,
110
+ execute: () => Promise.resolve(),
111
+ });
112
+ const resolved = resolveExtensions([
113
+ {
114
+ name: "cloud-iam",
115
+ gateSteps: new Map<GateSlotName, PipelineStep<DescMessage>[]>([
116
+ [
117
+ "identity-account-provision:post-persist",
118
+ [step("EnsurePersonalOrganization")],
119
+ ],
120
+ ]),
121
+ },
122
+ ]);
123
+ expect(
124
+ resolved.gateSteps
125
+ .get("identity-account-provision:post-persist")
126
+ ?.map((s) => s.name),
127
+ ).toEqual(["EnsurePersonalOrganization"]);
128
+ });
129
+ });
@@ -477,6 +477,10 @@ describe("resolveExtensions — loud-fail throws (DD-006 §2b)", () => {
477
477
  "agent-execution-create:pre-side-effect-gate",
478
478
  "agent-execution-recover:pre-side-effect-gate",
479
479
  "agent-execution-submit-approval:gate",
480
+ // 20260911.11 Q-IA-9: the seventh ratified slot — after the account
481
+ // persists inside provisionMyAccount, before the reply (the cloud's
482
+ // personal-organization ensure and backfill ride it).
483
+ "identity-account-provision:post-persist",
480
484
  "org-create:post-persist",
481
485
  "sandbox-acquisition:gate",
482
486
  "session-create:pre-side-effect-gate",
@@ -0,0 +1,299 @@
1
+ /**
2
+ * The tier-truthfulness invariant (editions program 20260911.03 DD-001;
3
+ * P0 review finding 4; landed with the first tier flip, 20260911.11
4
+ * Q-IA-6): a kind's `kind_meta.tier` states what the edition's server
5
+ * SERVES, not a plan. The SDK reads a tier as "available now"
6
+ * (sdk/typescript/src/resource-availability.ts), so a tier that runs ahead
7
+ * of the server turns hidden hooks into UNIMPLEMENTED errors, and a tier
8
+ * that lags hides a served kind.
9
+ *
10
+ * Proven against the EMPTY composition (no extension units — the
11
+ * open-source server as shipped): every `open_source` kind has a routed
12
+ * service and no `enterprise` or `cloud_only` kind does. Services are
13
+ * enumerated by replaying the composed `routes` closure into a recording
14
+ * router (compose.ts hands the same closure to both transports); a service
15
+ * maps to its kind by the package's kind segment, which equals
16
+ * `kind_meta.name` lowercased for every kind in the registry (checked at
17
+ * the gate against all 28 entries).
18
+ *
19
+ * Two exceptions were ruled at the P1 gate (20260911.04 T01_1_review.md,
20
+ * "Placements taken at the gate"). Under this check they take two shapes:
21
+ * - platform is a lie the ruling exempts by name: served by every
22
+ * edition (getServerInfo is is_public and every console reads it)
23
+ * while the tier names the platform#operator seat an Enterprise
24
+ * deployment sells. Its service lives at `ai.stigmer.platform.v1`, the
25
+ * one package where the kind IS the group;
26
+ * - api_resource_version is a record kind with no service in any
27
+ * edition; tiered cloud_only and unserved it tells no lie, so it needs
28
+ * no exemption — its own arm pins that it stays unserved.
29
+ * A third exemption can only join by the same recorded act.
30
+ *
31
+ * The mutation arms prove the check bites: the same function over a
32
+ * registry with one tier flipped reports the lie.
33
+ */
34
+ import { mkdtempSync, rmSync } from "node:fs";
35
+ import { tmpdir } from "node:os";
36
+ import path from "node:path";
37
+
38
+ import type { DescService } from "@bufbuild/protobuf";
39
+ import type { ConnectRouter } from "@connectrpc/connect";
40
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
41
+
42
+ import {
43
+ ApiResourceKind,
44
+ ResourceTier,
45
+ } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
46
+
47
+ import { loadConfig } from "../../boot/config.js";
48
+ import { composeServer } from "../../boot/compose.js";
49
+ import type { ComposedServer } from "../../boot/compose.js";
50
+ import { createLogger } from "../../boot/logger.js";
51
+ import { getKindMeta } from "../../pipeline/apiresource-meta.js";
52
+
53
+ /** The registry as the check reads it: every kind with kind_meta, by tier. */
54
+ interface KindEntry {
55
+ readonly kind: ApiResourceKind;
56
+ readonly name: string;
57
+ readonly tier: ResourceTier;
58
+ }
59
+
60
+ /** The pre-ruled exemption, by name, with the ruling that exempts it. */
61
+ const RULED_EXCEPTIONS: ReadonlyMap<ApiResourceKind, string> = new Map([
62
+ [
63
+ ApiResourceKind.platform,
64
+ "served by every edition; the tier names the platform#operator seat (P1 gate, placements)",
65
+ ],
66
+ ]);
67
+
68
+ function registryEntries(): KindEntry[] {
69
+ return Object.values(ApiResourceKind)
70
+ .filter((value): value is ApiResourceKind => typeof value === "number")
71
+ .filter((kind) => kind !== ApiResourceKind.api_resource_kind_unknown)
72
+ .map((kind) => {
73
+ const meta = getKindMeta(kind);
74
+ return { kind, name: meta.name, tier: meta.tier };
75
+ });
76
+ }
77
+
78
+ /**
79
+ * The kind segment of a service's fully-qualified name:
80
+ * `ai.stigmer.<group>.<kind>.v1.<Service>` → `<kind>`, and for the one
81
+ * package where the kind is the group, `ai.stigmer.<group>.v1.<Service>` →
82
+ * `<group>` (platform). Services outside the registry's shape (health,
83
+ * third-party, kind-less groups like search) yield a segment that matches
84
+ * no kind and fall out of the comparison.
85
+ */
86
+ function kindSegmentOf(serviceTypeName: string): string | undefined {
87
+ const parts = serviceTypeName.split(".");
88
+ if (parts[0] !== "ai" || parts[1] !== "stigmer") return undefined;
89
+ if (parts.length === 5) return parts[2];
90
+ if (parts.length >= 6) return parts[3];
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * The invariant as a pure function. Returns one sentence per lie so the
96
+ * failure names the kind and the direction.
97
+ */
98
+ function tierViolations(
99
+ entries: ReadonlyArray<KindEntry>,
100
+ servedKindSegments: ReadonlySet<string>,
101
+ exceptions: ReadonlyMap<ApiResourceKind, string>,
102
+ ): string[] {
103
+ const violations: string[] = [];
104
+ for (const entry of entries) {
105
+ if (exceptions.has(entry.kind)) continue;
106
+ const served = servedKindSegments.has(entry.name.toLowerCase());
107
+ switch (entry.tier) {
108
+ case ResourceTier.open_source:
109
+ if (!served) {
110
+ violations.push(
111
+ `${entry.name} is tiered open_source but the empty composition routes no service for it`,
112
+ );
113
+ }
114
+ break;
115
+ case ResourceTier.enterprise:
116
+ case ResourceTier.cloud_only:
117
+ if (served) {
118
+ violations.push(
119
+ `${entry.name} is tiered ${ResourceTier[entry.tier]} but the empty composition serves it — the tier lags the server`,
120
+ );
121
+ }
122
+ break;
123
+ case ResourceTier.resource_tier_unspecified:
124
+ violations.push(
125
+ `${entry.name} has no tier — every kind_meta must declare one`,
126
+ );
127
+ break;
128
+ default: {
129
+ const exhaustive: never = entry.tier;
130
+ throw new Error(`unknown tier ${String(exhaustive)}`);
131
+ }
132
+ }
133
+ }
134
+ return violations;
135
+ }
136
+
137
+ /** Replays the composed routes into a recorder and returns the served kind segments. */
138
+ function servedKindSegments(
139
+ routes: (router: ConnectRouter) => void,
140
+ ): Set<string> {
141
+ const served = new Set<string>();
142
+ const recorder = {
143
+ handlers: [],
144
+ service(desc: DescService) {
145
+ const segment = kindSegmentOf(desc.typeName);
146
+ if (segment !== undefined) served.add(segment);
147
+ return recorder;
148
+ },
149
+ rpc() {
150
+ return recorder;
151
+ },
152
+ };
153
+ routes(recorder as unknown as ConnectRouter);
154
+ return served;
155
+ }
156
+
157
+ describe("tier truthfulness against the empty composition", () => {
158
+ let dir: string;
159
+ let server: ComposedServer;
160
+
161
+ beforeAll(async () => {
162
+ dir = mkdtempSync(path.join(tmpdir(), "tier-truthfulness-test-"));
163
+ server = await composeServer({
164
+ config: loadConfig({
165
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
166
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
167
+ DB_PATH: path.join(dir, "stigmer.db"),
168
+ STORAGE_PATH: path.join(dir, "storage"),
169
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
170
+ }),
171
+ logger: createLogger({ level: "error", pretty: false, write: () => {} }),
172
+ portOverride: 0,
173
+ host: "127.0.0.1",
174
+ });
175
+ });
176
+
177
+ afterAll(async () => {
178
+ await server.shutdown();
179
+ rmSync(dir, { recursive: true, force: true });
180
+ });
181
+
182
+ it("every kind's tier states what the open-source server serves (two ruled exceptions)", () => {
183
+ const served = servedKindSegments(server.routes);
184
+ expect(served.size).toBeGreaterThan(10);
185
+ expect(tierViolations(registryEntries(), served, RULED_EXCEPTIONS)).toEqual(
186
+ [],
187
+ );
188
+ });
189
+
190
+ it("the ruled exemption is exactly the lie the registry tells today, no fewer", () => {
191
+ // If platform stops being a lie (its tier becomes open_source), it must
192
+ // leave the exemption by a recorded act rather than linger as dead
193
+ // vocabulary.
194
+ const served = servedKindSegments(server.routes);
195
+ const withoutExceptions = tierViolations(
196
+ registryEntries(),
197
+ served,
198
+ new Map(),
199
+ );
200
+ const exceptionNames = [...RULED_EXCEPTIONS.keys()]
201
+ .map((kind) => getKindMeta(kind).name)
202
+ .sort();
203
+ const lyingNames = withoutExceptions
204
+ .map((sentence) => sentence.split(" ")[0] ?? "")
205
+ .sort();
206
+ expect(lyingNames).toEqual(exceptionNames);
207
+ });
208
+
209
+ it("api_resource_version stays a record kind with no service (the P1 gate's second placement)", () => {
210
+ const served = servedKindSegments(server.routes);
211
+ expect(served.has("apiresourceversion")).toBe(false);
212
+ });
213
+
214
+ it("identity_account is open_source and served — the flip and the serving PR are one change (Q-EC-2b)", () => {
215
+ const served = servedKindSegments(server.routes);
216
+ expect(getKindMeta(ApiResourceKind.identity_account).tier).toBe(
217
+ ResourceTier.open_source,
218
+ );
219
+ expect(served.has("identityaccount")).toBe(true);
220
+ });
221
+ });
222
+
223
+ describe("the check bites (mutation proofs over the same function)", () => {
224
+ const served = new Set(["agent", "apikey", "identityaccount"]);
225
+ const entries: KindEntry[] = [
226
+ {
227
+ kind: ApiResourceKind.agent,
228
+ name: "Agent",
229
+ tier: ResourceTier.open_source,
230
+ },
231
+ {
232
+ kind: ApiResourceKind.api_key,
233
+ name: "ApiKey",
234
+ tier: ResourceTier.open_source,
235
+ },
236
+ {
237
+ kind: ApiResourceKind.identity_account,
238
+ name: "IdentityAccount",
239
+ tier: ResourceTier.open_source,
240
+ },
241
+ {
242
+ kind: ApiResourceKind.invitation,
243
+ name: "Invitation",
244
+ tier: ResourceTier.cloud_only,
245
+ },
246
+ ];
247
+
248
+ it("passes a truthful registry", () => {
249
+ expect(tierViolations(entries, served, new Map())).toEqual([]);
250
+ });
251
+
252
+ it("a tier that runs ahead of the server (open_source, unserved) is named", () => {
253
+ const ahead = entries.map((entry) =>
254
+ entry.kind === ApiResourceKind.invitation
255
+ ? { ...entry, tier: ResourceTier.open_source }
256
+ : entry,
257
+ );
258
+ expect(tierViolations(ahead, served, new Map())).toEqual([
259
+ "Invitation is tiered open_source but the empty composition routes no service for it",
260
+ ]);
261
+ });
262
+
263
+ it("a tier that lags the server (cloud_only, served) is named — reverting the flip fails here", () => {
264
+ const lagging = entries.map((entry) =>
265
+ entry.kind === ApiResourceKind.identity_account
266
+ ? { ...entry, tier: ResourceTier.cloud_only }
267
+ : entry,
268
+ );
269
+ expect(tierViolations(lagging, served, new Map())).toEqual([
270
+ "IdentityAccount is tiered cloud_only but the empty composition serves it — the tier lags the server",
271
+ ]);
272
+ });
273
+
274
+ it("a kind with no tier is a lie of omission", () => {
275
+ const untiered = [
276
+ ...entries,
277
+ {
278
+ kind: ApiResourceKind.memory,
279
+ name: "Memory",
280
+ tier: ResourceTier.resource_tier_unspecified,
281
+ },
282
+ ];
283
+ expect(tierViolations(untiered, served, new Map())).toEqual([
284
+ "Memory has no tier — every kind_meta must declare one",
285
+ ]);
286
+ });
287
+
288
+ it("an exception is honoured only by name", () => {
289
+ const lagging = entries.map((entry) =>
290
+ entry.kind === ApiResourceKind.identity_account
291
+ ? { ...entry, tier: ResourceTier.cloud_only }
292
+ : entry,
293
+ );
294
+ const exceptions = new Map([
295
+ [ApiResourceKind.identity_account, "a test-only exemption"],
296
+ ]);
297
+ expect(tierViolations(lagging, served, exceptions)).toEqual([]);
298
+ });
299
+ });
@@ -23,6 +23,9 @@
23
23
  * - schedule-fire caller (who a schedule fire acts as) — landed with
24
24
  * the stigmer-cloud#572 fix (the Java schedule-token mechanism's
25
25
  * seam; ruled 2026-09-01)
26
+ * - identity-account store + identity federation (the first domain
27
+ * whose persistence is a PORT, and the four federated RPC arms only
28
+ * one edition serves) — landed with 20260911.11, gate ruling Q-IA-9
26
29
  *
27
30
  * Merge rules (enforced by resolveExtensions, DD-006 §2b): the two
28
31
  * provider kinds are single-instance points — a second declaring unit is
@@ -36,11 +39,13 @@
36
39
  */
37
40
  import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-storage.js";
38
41
  import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
42
+ import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
39
43
  import type { SecretCodec } from "../encryption/codec.js";
40
44
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
41
45
  import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
42
46
  import type { RunnerCredentialProvider } from "../runnerauth/runner-credential-provider.js";
43
47
  import type { SandboxProvisionerFactory } from "../sandbox/provisioner.js";
48
+ import type { IdentityFederation } from "./identity-federation.js";
44
49
  import type { ListReadScope } from "./list-read-scope.js";
45
50
  import type { OrganizationDirectory } from "./organization-directory.js";
46
51
  import type { ResourceAuthorizationLifecycle } from "./resource-authorization.js";
@@ -143,4 +148,25 @@ export interface ExtensionDrivers {
143
148
  * the `internal` class — OSS behavior byte-identical.
144
149
  */
145
150
  readonly scheduleFireCaller?: ScheduleFireCallerMint;
151
+ /**
152
+ * The identity-account store driver (20260911.11, Q-IA-9;
153
+ * single-instance point). The identity-account domain is the first
154
+ * whose persistence is a PORT rather than the generic Store: when
155
+ * composed, the domain's every read and write — the create path, the
156
+ * provisioner, the boot-time operator ensure and BOTH OSS verifiers'
157
+ * subject resolution — goes through this driver (the cloud serves the
158
+ * domain over its own `cloud.iam_identity_account` table this way).
159
+ * When absent, the OSS adapter over the generic Store installs at the
160
+ * compose.ts consumption site — OSS behavior byte-identical.
161
+ */
162
+ readonly identityAccountStore?: IdentityAccountStore;
163
+ /**
164
+ * The identity-federation capability (20260911.11, Q-IA-9;
165
+ * single-instance point): the four federated-account RPC arms plus the
166
+ * IdP-exists probe their shared precondition rides. When composed, the
167
+ * identity-account controller dispatches to it after its own shared
168
+ * checks; when absent, the four RPCs refuse UNIMPLEMENTED with the
169
+ * edition reason — the organizationDirectory absent-method shape.
170
+ */
171
+ readonly identityFederation?: IdentityFederation;
146
172
  }
@@ -19,6 +19,21 @@
19
19
  * splice — its capacity gates ride the two declared agent-execution
20
20
  * slots, whose positions coincide exactly with the Java session gate.
21
21
  *
22
+ * 20260911.11 (Q-IA-9) declares the seventh,
23
+ * `identity-account-provision:post-persist`: inside provisionMyAccount,
24
+ * after the provisioner has answered the caller's row — created on this
25
+ * call or found by the idempotent early return — and before the reply,
26
+ * with the caller RE-STAMPED as the account (the position-1 identity was
27
+ * idp-shaped because no row existed when the verifier ran). It fires on
28
+ * EVERY provisionMyAccount, not only the creating one: the cloud's
29
+ * personal-organization step backfills accounts that predate personal
30
+ * organizations on the idempotent path, which is exactly why a slot was
31
+ * chosen over the tuple lifecycle's onResourceCreated (never fired for a
32
+ * row that already exists). Never on the create RPC and never at the
33
+ * boot-time operator ensure, which take the create path, not this one.
34
+ * Non-transactional in the `org-create:post-persist` sense: a gate
35
+ * failure fails the request, the row survives, the next call heals.
36
+ *
22
37
  * Two enforcement layers, deliberately redundant, both derived from the
23
38
  * ONE literal tuple below (lockstep by construction):
24
39
  * - GateSlotName (compile time): the union of declared slot literals.
@@ -55,6 +70,7 @@ export const GATE_SLOT_NAMES = [
55
70
  "session-create:pre-side-effect-gate",
56
71
  "org-create:post-persist",
57
72
  "sandbox-acquisition:gate",
73
+ "identity-account-provision:post-persist",
58
74
  ] as const;
59
75
 
60
76
  /** The declared slot-name union — a registration outside it fails tsc. */
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The identity-federation capability (20260911.11, Q-IA-9): the four
3
+ * federated-account RPC arms the identity-account controller dispatches
4
+ * to when a composed unit provides them, plus the identity-provider
5
+ * existence check their shared precondition rides. Single instance,
6
+ * registered as `drivers.identityFederation` (the organizationDirectory
7
+ * shape: a driver point that is byte-identical OSS behaviour when absent).
8
+ *
9
+ * Absent, the controller refuses the four RPCs UNIMPLEMENTED with the
10
+ * edition reason (domain/identityaccount/constants.ts) — never INTERNAL,
11
+ * the organization directory's absent-method precedent. Present, the
12
+ * controller still owns what is shared: the annotation's Authorize step
13
+ * on the organization, the ref-with-slug and org-match checks, and the
14
+ * IdP-exists refusal through `providerExists`; the arm receives the
15
+ * RESOLVED ref (org filled from the input when the ref left it empty)
16
+ * and the authenticated caller, and owns only what differs per edition —
17
+ * the natural-key lookups and writes on the federated rows.
18
+ *
19
+ * The federated natural key (provider ref + external subject) and its
20
+ * lookups stay on the unit's own store: the IdentityAccountStore port
21
+ * carries direct-account methods only, because a port does not carry
22
+ * methods only one edition calls.
23
+ */
24
+ import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
25
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
26
+ import type {
27
+ CreateFederatedAccountInput,
28
+ DeprovisionFederatedAccountInput,
29
+ ExternalSubLookup,
30
+ UpdateFederatedAccountInput,
31
+ } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/io_pb";
32
+
33
+ import type { CallerIdentity } from "./identity.js";
34
+
35
+ export interface IdentityFederation {
36
+ createFederatedAccount(
37
+ input: CreateFederatedAccountInput,
38
+ ref: ApiResourceReference,
39
+ caller: CallerIdentity,
40
+ ): Promise<IdentityAccount>;
41
+ updateFederatedAccount(
42
+ input: UpdateFederatedAccountInput,
43
+ ref: ApiResourceReference,
44
+ caller: CallerIdentity,
45
+ ): Promise<IdentityAccount>;
46
+ deprovisionFederatedAccount(
47
+ input: DeprovisionFederatedAccountInput,
48
+ ref: ApiResourceReference,
49
+ caller: CallerIdentity,
50
+ ): Promise<IdentityAccount>;
51
+ getByExternalSub(
52
+ lookup: ExternalSubLookup,
53
+ ref: ApiResourceReference,
54
+ caller: CallerIdentity,
55
+ ): Promise<IdentityAccount>;
56
+ /** Whether the IdentityProvider `org/slug` exists — the arms' shared precondition. */
57
+ providerExists(org: string, slug: string): Promise<boolean>;
58
+ }
@@ -30,7 +30,11 @@
30
30
  * provider) and O6's sandbox provisioners are consumed at their
31
31
  * compose.ts construction sites; the require-authentication posture
32
32
  * (20260904.02) is consumed where compose.ts builds the serving chain's
33
- * identity source, OR'd with the OIDC-issuer arm.
33
+ * identity source, OR'd with the OIDC-issuer arm; the identity-account
34
+ * points (20260911.11) at the identity-accounts stage (the store driver,
35
+ * ahead of the boot-time operator ensure) and the routes stage (the
36
+ * federation capability and the provision slot's steps, into the
37
+ * identity-account controller).
34
38
  */
35
39
  import type { DescMessage } from "@bufbuild/protobuf";
36
40
  import type { ConnectRouter } from "@connectrpc/connect";
@@ -40,8 +44,10 @@ import { ServerEdition } from "@stigmer/protos/ai/stigmer/platform/v1/server_inf
40
44
  import type { ArtifactStorageDriverFactory } from "../artifactstorage/artifact-storage.js";
41
45
  import { BUILT_IN_STORAGE_TYPES } from "../artifactstorage/artifact-storage.js";
42
46
  import type { ChannelRuntime } from "../domain/agentchannel/channel-runtime.js";
47
+ import type { IdentityAccountStore } from "../domain/identityaccount/store.js";
43
48
  import type { SecretCodec } from "../encryption/codec.js";
44
49
  import { V1_VERSION } from "../encryption/v1-codec.js";
50
+ import type { IdentityFederation } from "./identity-federation.js";
45
51
  import type { ListReadScope } from "./list-read-scope.js";
46
52
  import type { ModelCatalogProvider } from "../domain/workflow/registry/model-catalog-provider.js";
47
53
  import type { VisitorErrorPolicy } from "../pipeline/interceptors/error-boundary.js";
@@ -233,6 +239,17 @@ export interface ResolvedExtensionDrivers {
233
239
  * codec in and resolves the write version fail-fast.
234
240
  */
235
241
  readonly secretCodecs: ReadonlyMap<string, SecretCodec>;
242
+ /**
243
+ * The 20260911.11 identity-account store driver — undefined = the
244
+ * compose.ts identity-accounts stage installs the OSS adapter over the
245
+ * generic Store, OSS behavior byte-identical.
246
+ */
247
+ readonly identityAccountStore: IdentityAccountStore | undefined;
248
+ /**
249
+ * The 20260911.11 identity-federation capability — undefined = the
250
+ * four federated RPCs refuse UNIMPLEMENTED with the edition reason.
251
+ */
252
+ readonly identityFederation: IdentityFederation | undefined;
236
253
  }
237
254
 
238
255
  /**
@@ -276,6 +293,10 @@ export function resolveExtensions(
276
293
  let visitorErrorPolicyDeclaredBy: string | undefined;
277
294
  let scheduleFireCaller: ScheduleFireCallerMint | undefined;
278
295
  let scheduleFireCallerDeclaredBy: string | undefined;
296
+ let identityAccountStore: IdentityAccountStore | undefined;
297
+ let identityAccountStoreDeclaredBy: string | undefined;
298
+ let identityFederation: IdentityFederation | undefined;
299
+ let identityFederationDeclaredBy: string | undefined;
279
300
  const artifactStorageDrivers = new Map<
280
301
  string,
281
302
  ArtifactStorageDriverFactory
@@ -417,6 +438,26 @@ export function resolveExtensions(
417
438
  scheduleFireCallerDeclaredBy = unit.name;
418
439
  }
419
440
 
441
+ if (unit.drivers?.identityAccountStore !== undefined) {
442
+ if (identityAccountStoreDeclaredBy !== undefined) {
443
+ throw new Error(
444
+ `extension '${unit.name}' registers an IdentityAccountStore, but '${identityAccountStoreDeclaredBy}' already did — exactly one may be composed`,
445
+ );
446
+ }
447
+ identityAccountStore = unit.drivers.identityAccountStore;
448
+ identityAccountStoreDeclaredBy = unit.name;
449
+ }
450
+
451
+ if (unit.drivers?.identityFederation !== undefined) {
452
+ if (identityFederationDeclaredBy !== undefined) {
453
+ throw new Error(
454
+ `extension '${unit.name}' registers an IdentityFederation, but '${identityFederationDeclaredBy}' already did — exactly one may be composed`,
455
+ );
456
+ }
457
+ identityFederation = unit.drivers.identityFederation;
458
+ identityFederationDeclaredBy = unit.name;
459
+ }
460
+
420
461
  if (unit.drivers?.artifactStorageDrivers !== undefined) {
421
462
  for (const [name, factory] of unit.drivers.artifactStorageDrivers) {
422
463
  if ((BUILT_IN_STORAGE_TYPES as ReadonlyArray<string>).includes(name)) {
@@ -537,6 +578,8 @@ export function resolveExtensions(
537
578
  visitorErrorPolicy,
538
579
  secretCodecs,
539
580
  scheduleFireCaller,
581
+ identityAccountStore,
582
+ identityFederation,
540
583
  },
541
584
  services,
542
585
  workers,