@stigmer/server 3.31.0 → 3.32.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 (58) hide show
  1. package/dist/authorization/model/data/authorization-model.json +16 -46
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +3 -0
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
  6. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  7. package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
  8. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  9. package/dist/domain/iampolicy/constants.d.ts +11 -1
  10. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  11. package/dist/domain/iampolicy/constants.js +11 -1
  12. package/dist/domain/iampolicy/constants.js.map +1 -1
  13. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  14. package/dist/domain/iampolicy/controller.js +36 -12
  15. package/dist/domain/iampolicy/controller.js.map +1 -1
  16. package/dist/domain/iampolicy/steps.d.ts +98 -0
  17. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  18. package/dist/domain/iampolicy/steps.js +119 -4
  19. package/dist/domain/iampolicy/steps.js.map +1 -1
  20. package/dist/domain/platformclient/constants.d.ts +11 -1
  21. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  22. package/dist/domain/platformclient/constants.js +11 -1
  23. package/dist/domain/platformclient/constants.js.map +1 -1
  24. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  25. package/dist/domain/platformclient/verifier.js +7 -2
  26. package/dist/domain/platformclient/verifier.js.map +1 -1
  27. package/dist/extensions/identity.d.ts +14 -0
  28. package/dist/extensions/identity.d.ts.map +1 -1
  29. package/dist/extensions/identity.js.map +1 -1
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/pipeline/steps/defaults.d.ts +5 -1
  35. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  36. package/dist/pipeline/steps/defaults.js +6 -1
  37. package/dist/pipeline/steps/defaults.js.map +1 -1
  38. package/package.json +6 -6
  39. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  40. package/src/authorization/model/data/authorization-model.json +16 -46
  41. package/src/boot/compose.ts +3 -0
  42. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  43. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  44. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  45. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  46. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  47. package/src/domain/iampolicy/constants.ts +15 -1
  48. package/src/domain/iampolicy/controller.ts +43 -9
  49. package/src/domain/iampolicy/steps.ts +164 -4
  50. package/src/domain/platformclient/__tests__/mint.test.ts +7 -1
  51. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  52. package/src/domain/platformclient/constants.ts +11 -1
  53. package/src/domain/platformclient/verifier.ts +7 -2
  54. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  55. package/src/extensions/identity.ts +14 -0
  56. package/src/index.ts +5 -0
  57. package/src/pipeline/__tests__/steps.test.ts +72 -1
  58. package/src/pipeline/steps/defaults.ts +6 -1
@@ -26,8 +26,12 @@
26
26
  * The identity is the account the token was minted for: `sub` IS the
27
27
  * account id (the mint signs it), so no account read is needed; class
28
28
  * `user`, issuer "stigmer", the token carried (the memory-capture gate and
29
- * the origin guard read its `platform_client_id`), and the email and name
30
- * the platform asserted, for display only.
29
+ * the origin guard read its `platform_client_id`, guards decoding their
30
+ * own skip logic from the raw token as extensions/caller-guards.ts
31
+ * prescribes), the email and name the platform asserted, for display
32
+ * only, and the minting client as `platformClientId` for the audit-actor
33
+ * seam, so every resource a minted user writes names the client they came
34
+ * through (stigmer/stigmer#1256).
31
35
  */
32
36
  import { Code, ConnectError } from "@connectrpc/connect";
33
37
 
@@ -100,6 +104,7 @@ export function newPlatformClientTokenVerifier(
100
104
  rawToken: token,
101
105
  ...(email !== undefined ? { email } : {}),
102
106
  ...(displayName !== undefined ? { displayName } : {}),
107
+ platformClientId,
103
108
  };
104
109
  },
105
110
  };
@@ -17,10 +17,13 @@
17
17
  * `owner`, the member `member` — each written AS the account it names
18
18
  * (the audit actor), `findMyOrganizations` answers the organization
19
19
  * to both, and the marker is set.
20
- * 3. The founder revokes everyone, the member first and then themself,
21
- * and the directory boots once more: NO row returns. The marker, not
22
- * "zero rows", is what makes the reconciliation one-shot — a revoked
23
- * founder is not handed the organization back by a reboot.
20
+ * 3. The founder removes the member, and is refused removing themself
21
+ * (an organization keeps its last owner on the member lanes); the
22
+ * founder's row then goes from the store, the way boot 2 made its
23
+ * shape, and the directory boots once more: NO row returns. The
24
+ * marker, not "zero rows", is what makes the reconciliation one-shot
25
+ * — a founder whose row is gone is not handed the organization back
26
+ * by a reboot.
24
27
  *
25
28
  * The unit vouches for unsigned JWT-shaped tokens and declares the
26
29
  * require-authentication posture with no Authorizer (composed-support.ts;
@@ -35,7 +38,7 @@ import { tmpdir } from "node:os";
35
38
  import path from "node:path";
36
39
 
37
40
  import { fromBinary } from "@bufbuild/protobuf";
38
- import { createClient } from "@connectrpc/connect";
41
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
39
42
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
43
 
41
44
  import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
@@ -184,16 +187,21 @@ describe("role reconciliation (composed server, OIDC with no unit Authorizer, th
184
187
  ]);
185
188
  });
186
189
 
187
- it("boot 3: every role revoked after the marker stays revoked — a reboot hands nothing back", async () => {
190
+ it("boot 3: every role removed after the marker stays removed — a reboot hands nothing back", async () => {
188
191
  const policies = createClient(IamPolicyCommandController, asFounder());
189
192
  await policies.revokeOrgAccess({
190
193
  identityAccountId: memberId,
191
194
  organizationId: ORG,
192
195
  });
193
- await policies.revokeOrgAccess({
194
- identityAccountId: founderId,
195
- organizationId: ORG,
196
- });
196
+ const lastOwner = await policies
197
+ .revokeOrgAccess({ identityAccountId: founderId, organizationId: ORG })
198
+ .then(
199
+ () => undefined,
200
+ (error: unknown) => ConnectError.from(error),
201
+ );
202
+ expect(lastOwner?.code).toBe(Code.FailedPrecondition);
203
+ // The founder's row, the one left, goes the way boot 2 made its shape.
204
+ await server.store.deleteResourcesByKind(ApiResourceKind.iam_policy);
197
205
  expect(await rolesIn(server.store)).toEqual([]);
198
206
  await server.shutdown();
199
207
 
@@ -74,6 +74,20 @@ export interface CallerIdentity {
74
74
  */
75
75
  readonly email?: string;
76
76
  readonly displayName?: string;
77
+ /**
78
+ * The PlatformClient whose minted user token this caller presented,
79
+ * carried for the audit-actor seam as `email` and `displayName` are:
80
+ * the audit actor records it (ApiResourceAuditActor.platform_client_id),
81
+ * and that server-owned record is what later readers key on, never this
82
+ * field (stigmer/stigmer#1256). Set ONLY by the PlatformClient
83
+ * user-token verifier (domain/platformclient/verifier.ts), from the
84
+ * claim it has just verified and whose client it has just found alive;
85
+ * absent on every other credential. It is provenance, not authorization
86
+ * data (the account the token names is still the principal), and not a
87
+ * guard vocabulary: caller guards decode their own skip logic from
88
+ * `rawToken` (extensions/caller-guards.ts).
89
+ */
90
+ readonly platformClientId?: string;
77
91
  /**
78
92
  * The transport the request entered through (C2 Stage 3, ruling R5).
79
93
  * Absent = the wire; the in-process interceptor stamps `in-process` on
package/src/index.ts CHANGED
@@ -168,6 +168,11 @@ export type {
168
168
  IamPolicyStoreContractFixture,
169
169
  } from "./domain/iampolicy/store-contract.js";
170
170
  export { iamPolicyStoreContract } from "./domain/iampolicy/store-contract.js";
171
+ // The owner rule's refusal, the one sentence every door that grants the
172
+ // owner role answers when its caller is no owner: the IamPolicy lanes here,
173
+ // and an edition's own lanes that grant a role on a person's behalf (an
174
+ // owner invitation).
175
+ export { OWNER_ASSIGNMENT_DENIED_MESSAGE } from "./domain/iampolicy/constants.js";
171
176
  export type { PolicyGrantScope } from "./extensions/policy-grant-scope.js";
172
177
  export type { AuthorizationQueryEngine } from "./extensions/authorization-queries.js";
173
178
  // How an access list names a grantee that is not a person
@@ -5,7 +5,10 @@
5
5
  * state building (status clearing, ULID minting, audit slots #540,
6
6
  * visibility default + oss#573 immutability), the loaders' error copy, the
7
7
  * best-effort search indexing, visibility validation with cloud-identical
8
- * copy, reference normalization/validation, and operator identity (#400).
8
+ * copy, reference normalization/validation, operator identity (#400), and
9
+ * the PlatformClient provenance the audit actor records for a minted
10
+ * caller (#1256): stamped from the verified identity only, never from the
11
+ * request, and kept through an update by anyone else.
9
12
  *
10
13
  * Uses the organization resource (the vertical slice) and agent (whose
11
14
  * spec carries ApiResourceReference fields) as vehicles.
@@ -248,6 +251,74 @@ describe("operator identity (#400) → audit-actor derivation (O2 ruling Q5)", (
248
251
  });
249
252
  });
250
253
 
254
+ describe("PlatformClient provenance on the audit actor (#1256)", () => {
255
+ const minted = testCallerIdentity({
256
+ identityId: "ida_pat",
257
+ issuer: "stigmer",
258
+ platformClientId: "pcl_dashboard",
259
+ });
260
+
261
+ it("a minted caller's actor names the client; every other identity stamps it empty", () => {
262
+ expect(auditActorFor(minted).platformClientId).toBe("pcl_dashboard");
263
+ expect(auditActorFor(minted).id).toBe("ida_pat");
264
+ expect(auditActorFor(trustedLocalIdentity()).platformClientId).toBe("");
265
+ expect(auditActorFor(testCallerIdentity()).platformClientId).toBe("");
266
+ });
267
+
268
+ it("BuildNewState stamps the caller's client and discards a client the request claimed", async () => {
269
+ const forged = create(OrganizationSchema, {
270
+ apiVersion: "tenancy.stigmer.ai/v1",
271
+ kind: "Organization",
272
+ metadata: { name: "Acme" },
273
+ spec: { description: "a test organization" },
274
+ status: {
275
+ audit: {
276
+ specAudit: {
277
+ createdBy: { id: "ida_pat", platformClientId: "pcl_forged" },
278
+ },
279
+ },
280
+ },
281
+ });
282
+ const byMinted = new RequestContext(
283
+ OrganizationSchema,
284
+ clone(OrganizationSchema, forged),
285
+ minted,
286
+ ORG,
287
+ );
288
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(byMinted);
289
+ expect(
290
+ byMinted.newState.status?.audit?.specAudit?.createdBy?.platformClientId,
291
+ ).toBe("pcl_dashboard");
292
+
293
+ const byOther = orgCtx(clone(OrganizationSchema, forged));
294
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(byOther);
295
+ expect(
296
+ byOther.newState.status?.audit?.specAudit?.createdBy?.platformClientId,
297
+ ).toBe("");
298
+ });
299
+
300
+ it("an update by another caller keeps the creator's client and stamps its own actor", async () => {
301
+ const createCtx = new RequestContext(
302
+ OrganizationSchema,
303
+ org({ name: "Acme" }),
304
+ minted,
305
+ ORG,
306
+ );
307
+ await newResolveSlugStep<typeof OrganizationSchema>().execute(createCtx);
308
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(createCtx);
309
+ const existing = createCtx.newState;
310
+
311
+ const ctx = orgCtx(org({ id: existing.metadata!.id, name: "Acme Renamed" }));
312
+ ctx.set(EXISTING_RESOURCE_KEY, clone(OrganizationSchema, existing));
313
+ await newBuildUpdateStateStep<typeof OrganizationSchema>().execute(ctx);
314
+
315
+ const audit = ctx.newState.status?.audit?.specAudit;
316
+ expect(audit?.createdBy?.platformClientId).toBe("pcl_dashboard");
317
+ expect(audit?.updatedBy?.id).toBe("test-caller");
318
+ expect(audit?.updatedBy?.platformClientId).toBe("");
319
+ });
320
+ });
321
+
251
322
  describe("BuildUpdateState", () => {
252
323
  it("preserves id/slug/org/visibility (oss#573), replaces spec, keeps creation audit", async () => {
253
324
  // The stored resource, created through the real create steps.
@@ -237,13 +237,18 @@ export function operatorIdentitySnapshot(): {
237
237
  * unconfigured → the "system" placeholder, which the runner deliberately
238
238
  * demotes to anonymous (SYSTEM_CREATOR_SENTINEL). Verifier-produced
239
239
  * identities (O3's OIDC claims onward) stamp their own email/displayName
240
- * when present, identityId alone otherwise.
240
+ * when present, identityId alone otherwise. A PlatformClient-minted
241
+ * caller also stamps the client it came through (platform_client_id):
242
+ * the account alone cannot say, and the execution-context builder keys
243
+ * the PlatformClient environment layer on this record (#1256). Every
244
+ * other identity stamps it empty, so local-posture bytes are unchanged.
241
245
  */
242
246
  export function auditActorFor(identity: CallerIdentity): ApiResourceAuditActor {
243
247
  return create(ApiResourceAuditActorSchema, {
244
248
  id: identity.identityId,
245
249
  email: identity.email ?? "",
246
250
  displayName: identity.displayName ?? "",
251
+ platformClientId: identity.platformClientId ?? "",
247
252
  });
248
253
  }
249
254