@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,484 @@
1
+ /**
2
+ * Pins the identity-account domain through a composed OSS server in the
3
+ * trusted-local posture — the behaviours the conformance suite cannot
4
+ * express cross-edition because only open source has this posture
5
+ * (T01_0_plan.md §3a, §5, §10; T01_1_review.md A1, A2):
6
+ *
7
+ * - a fresh server answers whoAmI with the operator's account before any
8
+ * client has called anything (A2: the boot-time ensure), and
9
+ * provisionMyAccount is then the idempotent early return;
10
+ * - create is internal to the platform (the cloud#393 gate, A7): a wire
11
+ * user is PERMISSION_DENIED; the platform's own pipelines reach it
12
+ * through the in-process transport (the `internal` caller class),
13
+ * which is how every create arm below creates;
14
+ * - create derives the id from the subject and replaces a caller-supplied
15
+ * one (the organization precedent); the backend-assigned fields
16
+ * (is_machine_account, provisioning_mode) ignore what the caller sent;
17
+ * - a second create for a held subject is ALREADY_EXISTS; two subjects
18
+ * sharing an email both create — the account's uniqueness is its
19
+ * subject, never its slug;
20
+ * - update round-trips preferences (the console's one write) and refuses
21
+ * a changed subject with FAILED_PRECONDITION (the schedule domain's
22
+ * immutability shape);
23
+ * - the byte-pinned NOT_FOUND copy on get / getByEmail / getByIdpId /
24
+ * whoAmI; getActorInfo names the account;
25
+ * - the four federation RPCs refuse UNIMPLEMENTED with the edition
26
+ * reason when no unit composes the capability — never INTERNAL.
27
+ *
28
+ * The OIDC arms (resolve on hit, idp-shaped on miss, provision, then
29
+ * resolve) need a token-bearing caller and live in the conformance suite's
30
+ * OIDC-posture file (Q-IA-10).
31
+ */
32
+ import { mkdtempSync, rmSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import path from "node:path";
35
+
36
+ import { clone, create } from "@bufbuild/protobuf";
37
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
38
+ import type { Client, Transport } from "@connectrpc/connect";
39
+ import { createGrpcTransport } from "@connectrpc/connect-node";
40
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
41
+
42
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
43
+ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
44
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
45
+ import { IdentityAccountCommandController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/command_pb";
46
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
47
+ import { IdentityAccountQueryController } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/query_pb";
48
+ import type { IdentityAccountSpec } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
49
+ import { IdentityAccountPreferencesSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
50
+
51
+ import { loadConfig } from "../../../boot/config.js";
52
+ import { composeServer } from "../../../boot/compose.js";
53
+ import type { ComposedServer } from "../../../boot/compose.js";
54
+ import { createLogger } from "../../../boot/logger.js";
55
+ import {
56
+ resetOperatorIdentityForTests,
57
+ setOperatorIdentity,
58
+ } from "../../../pipeline/steps/defaults.js";
59
+ import {
60
+ ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE,
61
+ CREATE_IS_INTERNAL_MESSAGE,
62
+ FEDERATION_UNIMPLEMENTED_REASON,
63
+ accountIdFor,
64
+ accountNotFoundMessage,
65
+ idpIdImmutableMessage,
66
+ } from "../constants.js";
67
+
68
+ const OPERATOR_EMAIL = "operator@example.com";
69
+ const OPERATOR_NAME = "The Operator";
70
+
71
+ describe("identityaccount domain (composed server, trusted-local posture)", () => {
72
+ let dir: string;
73
+ let server: ComposedServer;
74
+ let transport: Transport;
75
+ /** The wire: what a client of this server sees (the operator's identity). */
76
+ let command: Client<typeof IdentityAccountCommandController>;
77
+ let query: Client<typeof IdentityAccountQueryController>;
78
+ /** The platform's own pipeline: the in-process transport's `internal` caller. */
79
+ let platform: Client<typeof IdentityAccountCommandController>;
80
+
81
+ beforeAll(async () => {
82
+ dir = mkdtempSync(path.join(tmpdir(), "identityaccount-domain-test-"));
83
+ // The operator seam main.ts installs once per process (A5): the
84
+ // interceptor stamps callers from it and the boot-time ensure reads it.
85
+ setOperatorIdentity(OPERATOR_EMAIL, OPERATOR_NAME);
86
+ server = await composeServer({
87
+ config: loadConfig({
88
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
89
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
90
+ DB_PATH: path.join(dir, "stigmer.db"),
91
+ STORAGE_PATH: path.join(dir, "storage"),
92
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
93
+ STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
94
+ STIGMER_OPERATOR_NAME: OPERATOR_NAME,
95
+ }),
96
+ logger: createLogger({ level: "error", pretty: false, write: () => {} }),
97
+ portOverride: 0,
98
+ host: "127.0.0.1",
99
+ });
100
+ const port = await server.start();
101
+ transport = createGrpcTransport({ baseUrl: `http://127.0.0.1:${port}` });
102
+ command = createClient(IdentityAccountCommandController, transport);
103
+ query = createClient(IdentityAccountQueryController, transport);
104
+ platform = createClient(
105
+ IdentityAccountCommandController,
106
+ server.inProcessTransport,
107
+ );
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await server.shutdown();
112
+ resetOperatorIdentityForTests();
113
+ rmSync(dir, { recursive: true, force: true });
114
+ });
115
+
116
+ let seq = 0;
117
+ function accountInput(overrides?: {
118
+ id?: string;
119
+ idpId?: string;
120
+ email?: string;
121
+ isMachineAccount?: boolean;
122
+ provisioningMode?: IdentityAccountProvisioningMode;
123
+ }) {
124
+ seq += 1;
125
+ const idpId = overrides?.idpId ?? `auth0|subject${seq}`;
126
+ return {
127
+ apiVersion: "iam.stigmer.ai/v1",
128
+ kind: "IdentityAccount",
129
+ metadata: {
130
+ ...(overrides?.id !== undefined ? { id: overrides.id } : {}),
131
+ name: overrides?.email ?? `person${seq}@example.com`,
132
+ },
133
+ spec: {
134
+ idpId,
135
+ email: overrides?.email ?? `person${seq}@example.com`,
136
+ firstName: "Test",
137
+ lastName: `Person${seq}`,
138
+ ...(overrides?.isMachineAccount !== undefined
139
+ ? { isMachineAccount: overrides.isMachineAccount }
140
+ : {}),
141
+ ...(overrides?.provisioningMode !== undefined
142
+ ? { provisioningMode: overrides.provisioningMode }
143
+ : {}),
144
+ },
145
+ };
146
+ }
147
+
148
+ /** A copy of `account` with `edit` applied to its spec — the console's full-envelope write. */
149
+ function withSpec(
150
+ account: IdentityAccount,
151
+ edit: (spec: IdentityAccountSpec) => void,
152
+ ): IdentityAccount {
153
+ const copy = clone(IdentityAccountSchema, account);
154
+ if (copy.spec === undefined) {
155
+ throw new Error("the server answered an account without a spec");
156
+ }
157
+ edit(copy.spec);
158
+ return copy;
159
+ }
160
+
161
+ async function connectErrorOf(
162
+ promise: Promise<unknown>,
163
+ ): Promise<ConnectError> {
164
+ try {
165
+ await promise;
166
+ } catch (error) {
167
+ return ConnectError.from(error);
168
+ }
169
+ throw new Error("expected the call to fail");
170
+ }
171
+
172
+ describe("the operator account (A2)", () => {
173
+ it("a fresh server answers whoAmI with the operator before any client called anything", async () => {
174
+ const me = await query.whoAmI({});
175
+ expect(me.metadata?.id).toBe(accountIdFor(`local|${OPERATOR_EMAIL}`));
176
+ expect(me.metadata?.name).toBe(OPERATOR_NAME);
177
+ expect(me.spec?.idpId).toBe(`local|${OPERATOR_EMAIL}`);
178
+ expect(me.spec?.email).toBe(OPERATOR_EMAIL);
179
+ expect(me.spec?.provisioningMode).toBe(
180
+ IdentityAccountProvisioningMode.direct,
181
+ );
182
+ expect(me.spec?.isMachineAccount).toBe(false);
183
+ expect(me.status?.audit?.specAudit?.event).toBe("created");
184
+ });
185
+
186
+ it("provisionMyAccount for the operator is the idempotent early return", async () => {
187
+ const me = await query.whoAmI({});
188
+ const before = (
189
+ await server.store.listResources(ApiResourceKind.identity_account)
190
+ ).length;
191
+ const provisioned = await command.provisionMyAccount({});
192
+ expect(provisioned).toEqual(me);
193
+ const after = (
194
+ await server.store.listResources(ApiResourceKind.identity_account)
195
+ ).length;
196
+ expect(after).toBe(before);
197
+ });
198
+
199
+ it("update round-trips preferences — the console's one write — and whoAmI reflects it", async () => {
200
+ const me = await query.whoAmI({});
201
+ const updated = await command.update(
202
+ withSpec(me, (spec) => {
203
+ spec.preferences = create(IdentityAccountPreferencesSchema, {
204
+ defaultHarness: "cursor",
205
+ defaultCursorModel: "gpt-5",
206
+ });
207
+ }),
208
+ );
209
+ expect(updated.spec?.preferences?.defaultHarness).toBe("cursor");
210
+ const again = await query.whoAmI({});
211
+ expect(again.spec?.preferences).toMatchObject({
212
+ defaultHarness: "cursor",
213
+ defaultCursorModel: "gpt-5",
214
+ });
215
+ expect(again.metadata?.id).toBe(me.metadata?.id);
216
+ });
217
+ });
218
+
219
+ describe("create (A1)", () => {
220
+ it("derives the id from the subject and replaces a caller-supplied id", async () => {
221
+ const created = await platform.create(
222
+ accountInput({
223
+ id: "ida_01hzzzzzzzzzzzzzzzzzzzzzzz",
224
+ idpId: "auth0|derive-me",
225
+ }),
226
+ );
227
+ expect(created.metadata?.id).toBe(accountIdFor("auth0|derive-me"));
228
+ expect(created.status?.audit?.specAudit?.event).toBe("created");
229
+ const fetched = await query.get({ value: created.metadata?.id ?? "" });
230
+ expect(fetched.spec?.idpId).toBe("auth0|derive-me");
231
+ });
232
+
233
+ it("the backend assigns is_machine_account and provisioning_mode, whatever the caller sent", async () => {
234
+ const person = await platform.create(
235
+ accountInput({
236
+ idpId: "auth0|human",
237
+ isMachineAccount: true,
238
+ provisioningMode: IdentityAccountProvisioningMode.federated,
239
+ }),
240
+ );
241
+ expect(person.spec?.isMachineAccount).toBe(false);
242
+ expect(person.spec?.provisioningMode).toBe(
243
+ IdentityAccountProvisioningMode.direct,
244
+ );
245
+
246
+ const machine = await platform.create(
247
+ accountInput({
248
+ idpId: "svc123@clients",
249
+ email: "",
250
+ isMachineAccount: false,
251
+ }),
252
+ );
253
+ expect(machine.spec?.isMachineAccount).toBe(true);
254
+ expect(machine.spec?.provisioningMode).toBe(
255
+ IdentityAccountProvisioningMode.direct,
256
+ );
257
+ });
258
+
259
+ it("a second create for a held subject is ALREADY_EXISTS and the first row stands", async () => {
260
+ const first = await platform.create(
261
+ accountInput({ idpId: "auth0|held", email: "first@example.com" }),
262
+ );
263
+ const error = await connectErrorOf(
264
+ platform.create(
265
+ accountInput({ idpId: "auth0|held", email: "second@example.com" }),
266
+ ),
267
+ );
268
+ expect(error.code).toBe(Code.AlreadyExists);
269
+ expect(error.rawMessage).toContain("auth0|held");
270
+ const fetched = await query.get({ value: first.metadata?.id ?? "" });
271
+ expect(fetched.spec?.email).toBe("first@example.com");
272
+ });
273
+
274
+ it("two subjects sharing an email both create — uniqueness is the subject, never the slug", async () => {
275
+ const a = await platform.create(
276
+ accountInput({ idpId: "auth0|shared-a", email: "shared@example.com" }),
277
+ );
278
+ const b = await platform.create(
279
+ accountInput({ idpId: "auth0|shared-b", email: "shared@example.com" }),
280
+ );
281
+ expect(a.metadata?.id).not.toBe(b.metadata?.id);
282
+ });
283
+
284
+ it("a create without a subject is INVALID_ARGUMENT — the proto's required field", async () => {
285
+ const error = await connectErrorOf(
286
+ platform.create(accountInput({ idpId: "" })),
287
+ );
288
+ expect(error.code).toBe(Code.InvalidArgument);
289
+ });
290
+ });
291
+
292
+ describe("update (A1)", () => {
293
+ it("refuses a changed subject with FAILED_PRECONDITION and the fixed copy", async () => {
294
+ const created = await platform.create(
295
+ accountInput({ idpId: "auth0|immutable" }),
296
+ );
297
+ const error = await connectErrorOf(
298
+ command.update(
299
+ withSpec(created, (spec) => {
300
+ spec.idpId = "auth0|someone-else";
301
+ }),
302
+ ),
303
+ );
304
+ expect(error.code).toBe(Code.FailedPrecondition);
305
+ expect(error.rawMessage).toBe(idpIdImmutableMessage("auth0|immutable"));
306
+ const fetched = await query.get({ value: created.metadata?.id ?? "" });
307
+ expect(fetched.spec?.idpId).toBe("auth0|immutable");
308
+ });
309
+
310
+ it("keeps the backend-assigned fields on update too", async () => {
311
+ const created = await platform.create(
312
+ accountInput({ idpId: "auth0|keep" }),
313
+ );
314
+ const updated = await command.update(
315
+ withSpec(created, (spec) => {
316
+ spec.firstName = "Edited";
317
+ spec.email = "rewritten@example.com";
318
+ spec.isMachineAccount = true;
319
+ spec.provisioningMode =
320
+ IdentityAccountProvisioningMode.platform_client;
321
+ }),
322
+ );
323
+ expect(updated.spec?.firstName).toBe("Edited");
324
+ // The IdP asserted the email; the client cannot rewrite it (A9).
325
+ expect(updated.spec?.email).toBe(created.spec?.email);
326
+ expect(updated.spec?.isMachineAccount).toBe(false);
327
+ expect(updated.spec?.provisioningMode).toBe(
328
+ IdentityAccountProvisioningMode.direct,
329
+ );
330
+ });
331
+ });
332
+
333
+ describe("the create RPC is internal to the platform (A7, the cloud#393 gate)", () => {
334
+ it("a wire user is PERMISSION_DENIED with the cloud's copy, and nothing is written", async () => {
335
+ const before = (
336
+ await server.store.listResources(ApiResourceKind.identity_account)
337
+ ).length;
338
+ const error = await connectErrorOf(
339
+ command.create(accountInput({ idpId: "auth0|over-the-wire" })),
340
+ );
341
+ expect(error.code).toBe(Code.PermissionDenied);
342
+ expect(error.rawMessage).toBe(CREATE_IS_INTERNAL_MESSAGE);
343
+ expect(
344
+ (await server.store.listResources(ApiResourceKind.identity_account))
345
+ .length,
346
+ ).toBe(before);
347
+ });
348
+ });
349
+
350
+ describe("reads and their byte-pinned copy", () => {
351
+ it("get / getByEmail / getByIdpId answer the cloud's NOT_FOUND sentences", async () => {
352
+ const byId = await connectErrorOf(
353
+ query.get({ value: "ida_00000000000000000000000000" }),
354
+ );
355
+ expect(byId.code).toBe(Code.NotFound);
356
+ expect(byId.rawMessage).toBe(
357
+ accountNotFoundMessage("ida_00000000000000000000000000"),
358
+ );
359
+
360
+ const byEmail = await connectErrorOf(
361
+ query.getByEmail({ value: "nobody@example.com" }),
362
+ );
363
+ expect(byEmail.code).toBe(Code.NotFound);
364
+ expect(byEmail.rawMessage).toBe(
365
+ accountNotFoundMessage("nobody@example.com"),
366
+ );
367
+
368
+ const byIdpId = await connectErrorOf(
369
+ query.getByIdpId({ value: "auth0|nobody" }),
370
+ );
371
+ expect(byIdpId.code).toBe(Code.NotFound);
372
+ expect(byIdpId.rawMessage).toBe(accountNotFoundMessage("auth0|nobody"));
373
+ });
374
+
375
+ it("getByEmail and getByIdpId find what create wrote", async () => {
376
+ const created = await platform.create(
377
+ accountInput({
378
+ idpId: "auth0|findable",
379
+ email: "findable@example.com",
380
+ }),
381
+ );
382
+ expect(
383
+ (await query.getByEmail({ value: "findable@example.com" })).metadata
384
+ ?.id,
385
+ ).toBe(created.metadata?.id);
386
+ expect(
387
+ (await query.getByIdpId({ value: "auth0|findable" })).metadata?.id,
388
+ ).toBe(created.metadata?.id);
389
+ });
390
+
391
+ it("getActorInfo names the account the way audit stamps do", async () => {
392
+ const created = await platform.create(
393
+ accountInput({ idpId: "auth0|actor", email: "actor@example.com" }),
394
+ );
395
+ const actor = await query.getActorInfo({
396
+ value: created.metadata?.id ?? "",
397
+ });
398
+ expect(actor.id).toBe(created.metadata?.id);
399
+ expect(actor.email).toBe("actor@example.com");
400
+ });
401
+
402
+ it("the whoAmI copy is the cloud's sentence (asserted through the constant the handler throws)", () => {
403
+ expect(ACCOUNT_NOT_FOUND_FOR_CALLER_MESSAGE).toBe(
404
+ "Identity account not found for the authenticated user",
405
+ );
406
+ });
407
+ });
408
+
409
+ describe("delete", () => {
410
+ it("returns the account and frees the subject", async () => {
411
+ const created = await platform.create(
412
+ accountInput({ idpId: "auth0|deletable" }),
413
+ );
414
+ const deleted = await command.delete({
415
+ value: created.metadata?.id ?? "",
416
+ });
417
+ expect(deleted.metadata?.id).toBe(created.metadata?.id);
418
+ const error = await connectErrorOf(
419
+ query.get({ value: created.metadata?.id ?? "" }),
420
+ );
421
+ expect(error.code).toBe(Code.NotFound);
422
+ const recreated = await platform.create(
423
+ accountInput({ idpId: "auth0|deletable" }),
424
+ );
425
+ expect(recreated.metadata?.id).toBe(created.metadata?.id);
426
+ });
427
+ });
428
+
429
+ describe("the federation capability, absent (Q-IA-9, §5)", () => {
430
+ // Inputs that pass the boundary validator (chain position 3, before
431
+ // any handler) so the refusal under test is the HANDLER's: the
432
+ // capability is consulted before any lookup or authorization.
433
+ const ref = { org: "acme", slug: "okta" };
434
+ it.each([
435
+ [
436
+ "createFederatedAccount",
437
+ () =>
438
+ command.createFederatedAccount({
439
+ org: "acme",
440
+ identityProviderRef: ref,
441
+ externalSub: "okta|1",
442
+ email: "person@example.com",
443
+ }),
444
+ ],
445
+ [
446
+ "updateFederatedAccount",
447
+ () =>
448
+ command.updateFederatedAccount({
449
+ org: "acme",
450
+ identityProviderRef: ref,
451
+ externalSub: "okta|1",
452
+ email: "person@example.com",
453
+ }),
454
+ ],
455
+ [
456
+ "deprovisionFederatedAccount",
457
+ () =>
458
+ command.deprovisionFederatedAccount({
459
+ org: "acme",
460
+ identityProviderRef: ref,
461
+ externalSub: "okta|1",
462
+ }),
463
+ ],
464
+ [
465
+ "getByExternalSub",
466
+ () =>
467
+ query.getByExternalSub({
468
+ org: "acme",
469
+ identityProviderRef: ref,
470
+ externalSub: "okta|1",
471
+ }),
472
+ ],
473
+ ] as const)(
474
+ "%s refuses UNIMPLEMENTED with the edition reason, never INTERNAL",
475
+ async (name, call) => {
476
+ const error = await connectErrorOf(call());
477
+ expect(error.code).toBe(Code.Unimplemented);
478
+ expect(error.rawMessage).toContain(
479
+ `.${name} is not implemented: ${FEDERATION_UNIMPLEMENTED_REASON}`,
480
+ );
481
+ },
482
+ );
483
+ });
484
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Pins the trusted-local operator account (operator.ts; T01_1_review.md
3
+ * A2): the server knows its one principal from config, so the server
4
+ * states the fact at boot instead of asking three clients to provision
5
+ * it. `ensureOperatorAccount` is create-if-absent under the derived id of
6
+ * `local|<operator email, or "system">`:
7
+ *
8
+ * - a fresh store gets exactly one account, provisioning_mode direct,
9
+ * profile from the operator identity (display name → metadata.name,
10
+ * email → spec.email; the unconfigured laptop has an empty profile,
11
+ * which is the truth — spec.email is not a required field);
12
+ * - a later call never touches the row: preferences set through
13
+ * `update` survive a reboot, and a changed display name is edited like
14
+ * any profile, never re-synced from config;
15
+ * - a changed operator email is a different subject, so a different
16
+ * account — the email IS the identity (boot/config.ts
17
+ * loadOperatorIdentity);
18
+ * - two ensures racing (a two-replica boot) converge to one row by
19
+ * primary key.
20
+ *
21
+ * The row goes through the domain's ONE create path (the same chain the
22
+ * create RPC runs), built here over the temp store with the permissive
23
+ * authorizer and no lifecycle driver — the no-extension composition.
24
+ *
25
+ * Whether the stage runs at all — only when no authentication posture is
26
+ * on — is pinned where the posture is decided: boot/__tests__.
27
+ */
28
+ import { clone, create } from "@bufbuild/protobuf";
29
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
30
+
31
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
32
+ import { IdentityAccountSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
33
+ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
34
+ import { IdentityAccountPreferencesSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
35
+
36
+ import { createLogger } from "../../../boot/logger.js";
37
+ import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
38
+ import { tempStore } from "../../../store/sqlite/__tests__/support.js";
39
+ import type { TempStore } from "../../../store/sqlite/__tests__/support.js";
40
+ import { accountIdFor, localIdpIdFor } from "../constants.js";
41
+ import { newCreateAccountPath } from "../controller.js";
42
+ import { ensureOperatorAccount } from "../operator.js";
43
+ import type { OperatorAccountDeps } from "../operator.js";
44
+ import { newResourceIdentityAccountStore } from "../resource-store.js";
45
+
46
+ let temp: TempStore;
47
+
48
+ beforeEach(() => {
49
+ temp = tempStore();
50
+ });
51
+
52
+ afterEach(() => {
53
+ temp.cleanup();
54
+ });
55
+
56
+ /** The domain over the temp store: the port and its one create path. */
57
+ function domainOver(store: TempStore["store"]): OperatorAccountDeps {
58
+ const accounts = newResourceIdentityAccountStore(store);
59
+ return {
60
+ accounts,
61
+ createAccount: newCreateAccountPath(
62
+ {
63
+ accounts,
64
+ logger: createLogger({
65
+ level: "error",
66
+ pretty: false,
67
+ write: () => {},
68
+ }),
69
+ authorizer: newPermissiveSingleTeamAuthorizer(),
70
+ authorizationLifecycle: undefined,
71
+ },
72
+ ApiResourceKind.identity_account,
73
+ ),
74
+ };
75
+ }
76
+
77
+ describe("ensureOperatorAccount", () => {
78
+ it("creates the configured operator's account once, under the derived id", async () => {
79
+ const accounts = domainOver(temp.store);
80
+ const account = await ensureOperatorAccount(accounts, {
81
+ email: "operator@example.com",
82
+ displayName: "The Operator",
83
+ });
84
+
85
+ expect(account.metadata?.id).toBe(
86
+ accountIdFor("local|operator@example.com"),
87
+ );
88
+ expect(account.metadata?.name).toBe("The Operator");
89
+ expect(account.spec).toMatchObject({
90
+ idpId: localIdpIdFor("operator@example.com"),
91
+ email: "operator@example.com",
92
+ provisioningMode: IdentityAccountProvisioningMode.direct,
93
+ isMachineAccount: false,
94
+ });
95
+ expect(account.status?.audit?.specAudit?.event).toBe("created");
96
+ expect(
97
+ await temp.store.listResources(ApiResourceKind.identity_account),
98
+ ).toHaveLength(1);
99
+ });
100
+
101
+ it("the unconfigured laptop is the 'system' operator with an empty profile", async () => {
102
+ const accounts = domainOver(temp.store);
103
+ const account = await ensureOperatorAccount(accounts, {
104
+ email: "",
105
+ displayName: "",
106
+ });
107
+
108
+ expect(account.metadata?.id).toBe(accountIdFor("local|system"));
109
+ expect(account.spec?.idpId).toBe("local|system");
110
+ expect(account.spec?.email).toBe("");
111
+ expect(account.metadata?.name).toBe("system");
112
+ });
113
+
114
+ it("a second boot never touches the row — preferences and an edited name survive", async () => {
115
+ const accounts = domainOver(temp.store);
116
+ const operator = {
117
+ email: "operator@example.com",
118
+ displayName: "The Operator",
119
+ };
120
+ const created = await ensureOperatorAccount(accounts, operator);
121
+
122
+ // The console's writes: a renamed profile and a preference, through
123
+ // the port the update RPC persists with.
124
+ const edited = clone(IdentityAccountSchema, created);
125
+ if (edited.metadata === undefined || edited.spec === undefined) {
126
+ throw new Error(
127
+ "the create path answered an account without metadata or spec",
128
+ );
129
+ }
130
+ edited.metadata.name = "Renamed by the console";
131
+ edited.spec.preferences = create(IdentityAccountPreferencesSchema, {
132
+ defaultHarness: "cursor",
133
+ });
134
+ await accounts.accounts.update(edited);
135
+
136
+ const again = await ensureOperatorAccount(accounts, {
137
+ ...operator,
138
+ displayName: "A New Config Name",
139
+ });
140
+ expect(again.metadata?.name).toBe("Renamed by the console");
141
+ expect(again.spec?.preferences?.defaultHarness).toBe("cursor");
142
+ expect(
143
+ await temp.store.listResources(ApiResourceKind.identity_account),
144
+ ).toHaveLength(1);
145
+ });
146
+
147
+ it("a changed operator email is a different subject, so a different account", async () => {
148
+ const accounts = domainOver(temp.store);
149
+ await ensureOperatorAccount(accounts, {
150
+ email: "old@example.com",
151
+ displayName: "",
152
+ });
153
+ const next = await ensureOperatorAccount(accounts, {
154
+ email: "new@example.com",
155
+ displayName: "",
156
+ });
157
+
158
+ expect(next.metadata?.id).toBe(accountIdFor("local|new@example.com"));
159
+ expect(
160
+ await temp.store.listResources(ApiResourceKind.identity_account),
161
+ ).toHaveLength(2);
162
+ });
163
+
164
+ it("two ensures racing converge to one row", async () => {
165
+ const accounts = domainOver(temp.store);
166
+ const operator = {
167
+ email: "operator@example.com",
168
+ displayName: "The Operator",
169
+ };
170
+ const [a, b] = await Promise.all([
171
+ ensureOperatorAccount(accounts, operator),
172
+ ensureOperatorAccount(accounts, operator),
173
+ ]);
174
+
175
+ expect(a.metadata?.id).toBe(b.metadata?.id);
176
+ expect(
177
+ await temp.store.listResources(ApiResourceKind.identity_account),
178
+ ).toHaveLength(1);
179
+ });
180
+ });