@stigmer/server 3.31.0 → 3.33.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 (109) 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 +11 -1
  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/apikey/controller.d.ts.map +1 -1
  10. package/dist/domain/apikey/controller.js +7 -2
  11. package/dist/domain/apikey/controller.js.map +1 -1
  12. package/dist/domain/apikey/verifier.d.ts +5 -0
  13. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  14. package/dist/domain/apikey/verifier.js +32 -3
  15. package/dist/domain/apikey/verifier.js.map +1 -1
  16. package/dist/domain/iampolicy/constants.d.ts +11 -1
  17. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  18. package/dist/domain/iampolicy/constants.js +11 -1
  19. package/dist/domain/iampolicy/constants.js.map +1 -1
  20. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  21. package/dist/domain/iampolicy/controller.js +36 -12
  22. package/dist/domain/iampolicy/controller.js.map +1 -1
  23. package/dist/domain/iampolicy/steps.d.ts +98 -0
  24. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  25. package/dist/domain/iampolicy/steps.js +119 -4
  26. package/dist/domain/iampolicy/steps.js.map +1 -1
  27. package/dist/domain/platformclient/constants.d.ts +11 -1
  28. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  29. package/dist/domain/platformclient/constants.js +11 -1
  30. package/dist/domain/platformclient/constants.js.map +1 -1
  31. package/dist/domain/platformclient/mint.d.ts.map +1 -1
  32. package/dist/domain/platformclient/mint.js +28 -1
  33. package/dist/domain/platformclient/mint.js.map +1 -1
  34. package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
  35. package/dist/domain/platformclient/resource-store.js +13 -0
  36. package/dist/domain/platformclient/resource-store.js.map +1 -1
  37. package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
  38. package/dist/domain/platformclient/store-contract.js +59 -0
  39. package/dist/domain/platformclient/store-contract.js.map +1 -1
  40. package/dist/domain/platformclient/store.d.ts +20 -0
  41. package/dist/domain/platformclient/store.d.ts.map +1 -1
  42. package/dist/domain/platformclient/store.js.map +1 -1
  43. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  44. package/dist/domain/platformclient/verifier.js +7 -2
  45. package/dist/domain/platformclient/verifier.js.map +1 -1
  46. package/dist/extensions/identity.d.ts +14 -0
  47. package/dist/extensions/identity.d.ts.map +1 -1
  48. package/dist/extensions/identity.js.map +1 -1
  49. package/dist/identity/credential-use.d.ts +74 -0
  50. package/dist/identity/credential-use.d.ts.map +1 -0
  51. package/dist/identity/credential-use.js +59 -0
  52. package/dist/identity/credential-use.js.map +1 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +5 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/pipeline/steps/defaults.d.ts +15 -1
  58. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  59. package/dist/pipeline/steps/defaults.js +31 -1
  60. package/dist/pipeline/steps/defaults.js.map +1 -1
  61. package/dist/temporal/schedule/activities.d.ts.map +1 -1
  62. package/dist/temporal/schedule/activities.js +2 -1
  63. package/dist/temporal/schedule/activities.js.map +1 -1
  64. package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
  65. package/dist/temporal/schedule/run-starter.js +2 -1
  66. package/dist/temporal/schedule/run-starter.js.map +1 -1
  67. package/dist/temporal/schedule/status-writes.d.ts +0 -6
  68. package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
  69. package/dist/temporal/schedule/status-writes.js +8 -27
  70. package/dist/temporal/schedule/status-writes.js.map +1 -1
  71. package/dist/temporal/schedule/syncer.d.ts.map +1 -1
  72. package/dist/temporal/schedule/syncer.js +2 -1
  73. package/dist/temporal/schedule/syncer.js.map +1 -1
  74. package/package.json +6 -6
  75. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  76. package/src/authorization/model/data/authorization-model.json +16 -46
  77. package/src/boot/compose.ts +11 -1
  78. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  79. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  80. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  81. package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
  82. package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
  83. package/src/domain/apikey/controller.ts +12 -2
  84. package/src/domain/apikey/verifier.ts +46 -3
  85. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  86. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  87. package/src/domain/iampolicy/constants.ts +15 -1
  88. package/src/domain/iampolicy/controller.ts +43 -9
  89. package/src/domain/iampolicy/steps.ts +164 -4
  90. package/src/domain/platformclient/__tests__/mint.test.ts +104 -12
  91. package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
  92. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  93. package/src/domain/platformclient/constants.ts +11 -1
  94. package/src/domain/platformclient/mint.ts +34 -1
  95. package/src/domain/platformclient/resource-store.ts +18 -0
  96. package/src/domain/platformclient/store-contract.ts +100 -0
  97. package/src/domain/platformclient/store.ts +23 -0
  98. package/src/domain/platformclient/verifier.ts +7 -2
  99. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  100. package/src/extensions/identity.ts +14 -0
  101. package/src/identity/__tests__/credential-use.test.ts +186 -0
  102. package/src/identity/credential-use.ts +130 -0
  103. package/src/index.ts +5 -0
  104. package/src/pipeline/__tests__/steps.test.ts +72 -1
  105. package/src/pipeline/steps/defaults.ts +33 -1
  106. package/src/temporal/schedule/activities.ts +2 -1
  107. package/src/temporal/schedule/run-starter.ts +2 -1
  108. package/src/temporal/schedule/status-writes.ts +8 -31
  109. package/src/temporal/schedule/syncer.ts +2 -1
@@ -4,7 +4,8 @@
4
4
  * - no ring (a server that trusts every request) and a verify-only ring
5
5
  * refuse FAILED_PRECONDITION before any credential is read;
6
6
  * - an unknown client_id and a wrong secret answer one UNAUTHENTICATED
7
- * copy; an expired secret refuses, an unset or never-expiring one mints;
7
+ * copy; an expired secret refuses with copy naming the owner's fix (not
8
+ * rotation, #1254), an unset or never-expiring one mints;
8
9
  * an org_id other than the owning organization refuses;
9
10
  * - an existing platform-client account is reused with no grant and no
10
11
  * write; an account under the subject in any other mode is refused;
@@ -14,13 +15,17 @@
14
15
  * nothing; a failed create leaves the grant, and the next mint completes
15
16
  * the account; a concurrent winner is read back;
16
17
  * - the default role is viewer, owner is refused, and the token carries
17
- * the cloud's claim set for the account.
18
+ * the cloud's claim set for the account;
19
+ * - a successful mint stamps the client's last use through the port's
20
+ * atomic modifyById (never the whole-row update), once per resolution;
21
+ * a refused mint records nothing; a stamp that cannot be written is
22
+ * logged and the token is still answered (stigmer/stigmer#1255).
18
23
  */
19
24
  import { generateKeyPairSync } from "node:crypto";
20
25
 
21
26
  import { Code, ConnectError } from "@connectrpc/connect";
22
- import { create } from "@bufbuild/protobuf";
23
- import { timestampFromDate } from "@bufbuild/protobuf/wkt";
27
+ import { clone, create } from "@bufbuild/protobuf";
28
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
24
29
  import { describe, expect, it } from "vitest";
25
30
 
26
31
  import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
@@ -29,12 +34,18 @@ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/
29
34
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
30
35
  import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
31
36
  import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
32
- import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
37
+ import {
38
+ PlatformClientSchema,
39
+ PlatformClientStatusSchema,
40
+ } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
33
41
  import { MintUserTokenRequestSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
34
42
  import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
35
43
 
44
+ import { createLogger } from "../../../boot/logger.js";
45
+ import type { Logger } from "../../../boot/logger.js";
36
46
  import type { CallerIdentity } from "../../../extensions/identity.js";
37
47
  import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
48
+ import { LAST_USED_RESOLUTION_MS } from "../../../identity/credential-use.js";
38
49
  import { verifyPlatformToken } from "../../../platformtoken/envelope.js";
39
50
  import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
40
51
  import type { PlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
@@ -100,6 +111,10 @@ interface Harness {
100
111
  }>;
101
112
  readonly granted: Array<{ spec: IamPolicySpec; caller: CallerIdentity }>;
102
113
  readonly accounts: Map<string, IdentityAccount>;
114
+ /** The client row as the port holds it; modifyById writes here. */
115
+ readonly stored: { client: PlatformClient };
116
+ /** Every port write, by method: the stamp must ride modifyById alone. */
117
+ readonly clientWrites: string[];
103
118
  }
104
119
 
105
120
  function harness(
@@ -108,9 +123,12 @@ function harness(
108
123
  keys?: PlatformTokenKeyRing | undefined;
109
124
  failGrant?: boolean;
110
125
  failCreate?: "fault" | "lost-race";
126
+ failStamp?: boolean;
127
+ logger?: Logger;
111
128
  } = {},
112
129
  ): Harness {
113
- const client = options.client ?? platformClient();
130
+ const stored = { client: options.client ?? platformClient() };
131
+ const clientWrites: string[] = [];
114
132
  const events: string[] = [];
115
133
  const created: Harness["created"] = [];
116
134
  const granted: Harness["granted"] = [];
@@ -125,12 +143,25 @@ function harness(
125
143
  };
126
144
  const deps: PlatformClientMintDeps = {
127
145
  clients: {
128
- save: async () => {},
129
- update: async () => {},
146
+ save: async () => {
147
+ clientWrites.push("save");
148
+ },
149
+ update: async () => {
150
+ clientWrites.push("update");
151
+ },
152
+ modifyById: async (id, modify) => {
153
+ clientWrites.push("modifyById");
154
+ if (options.failStamp === true) throw new Error("client store down");
155
+ if (id !== stored.client.metadata?.id) return undefined;
156
+ const next = clone(PlatformClientSchema, stored.client);
157
+ modify(next);
158
+ stored.client = next;
159
+ return next;
160
+ },
130
161
  deleteById: async () => {},
131
- findById: async () => client,
162
+ findById: async () => stored.client,
132
163
  findByClientId: async (clientId) =>
133
- clientId === client.spec?.clientId ? client : undefined,
164
+ clientId === stored.client.spec?.clientId ? stored.client : undefined,
134
165
  findByOrgAndSlug: async () => undefined,
135
166
  findByOrg: async () => [],
136
167
  },
@@ -155,10 +186,10 @@ function harness(
155
186
  },
156
187
  grantPath,
157
188
  keys: "keys" in options ? options.keys : RING,
158
- logger: silentLogger,
189
+ logger: options.logger ?? silentLogger,
159
190
  now: () => NOW,
160
191
  };
161
- return { deps, events, created, granted, accounts };
192
+ return { deps, events, created, granted, accounts, stored, clientWrites };
162
193
  }
163
194
 
164
195
  function request(
@@ -232,6 +263,11 @@ describe("mintUserToken — the server and the client", () => {
232
263
  );
233
264
  expect(expired.code).toBe(Code.FailedPrecondition);
234
265
  expect(expired.rawMessage).toBe(EXPIRED_CLIENT_SECRET_MESSAGE);
266
+ // The literal, so a change to the copy is deliberate: it names the
267
+ // owner's fix, never rotation, which leaves the expiry as it is (#1254).
268
+ expect(expired.rawMessage).toBe(
269
+ "PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.",
270
+ );
235
271
 
236
272
  await mintUserToken(harness({ client: platformClient() }).deps, request());
237
273
  await mintUserToken(
@@ -413,3 +449,59 @@ describe("mintUserToken — the user", () => {
413
449
  );
414
450
  });
415
451
  });
452
+
453
+ describe("mintUserToken — the client's last use", () => {
454
+ it("a successful mint stamps the client's last use at the mint's clock, through modifyById alone", async () => {
455
+ const h = harness();
456
+ await mintUserToken(h.deps, request());
457
+ expect(h.clientWrites).toEqual(["modifyById"]);
458
+ expect(timestampDate(h.stored.client.status!.lastUsedAt!)).toEqual(NOW);
459
+ expect(h.stored.client.status?.audit?.statusAudit?.event).toBe("updated");
460
+ expect(
461
+ h.stored.client.spec?.clientSecretHash,
462
+ "the stamp never rewrites the credential",
463
+ ).toBe(hashClientSecret(SECRET));
464
+ });
465
+
466
+ it("a mint inside the resolution writes nothing", async () => {
467
+ const recent = platformClient();
468
+ recent.status = create(PlatformClientStatusSchema, {
469
+ lastUsedAt: timestampFromDate(
470
+ new Date(NOW.getTime() - LAST_USED_RESOLUTION_MS / 2),
471
+ ),
472
+ });
473
+ const h = harness({ client: recent });
474
+ await mintUserToken(h.deps, request());
475
+ expect(h.clientWrites).toEqual([]);
476
+ });
477
+
478
+ it("a refused mint records nothing", async () => {
479
+ const h = harness();
480
+ await refusal(mintUserToken(h.deps, request({ clientSecret: "wrong" })));
481
+ await refusal(mintUserToken(h.deps, request({ orgId: "globex" })));
482
+ expect(h.clientWrites).toEqual([]);
483
+ expect(h.stored.client.status?.lastUsedAt).toBeUndefined();
484
+ });
485
+
486
+ it("a stamp that cannot be written is logged with the client's id and the token is still answered", async () => {
487
+ const lines: string[] = [];
488
+ const h = harness({
489
+ failStamp: true,
490
+ logger: createLogger({
491
+ level: "warn",
492
+ pretty: false,
493
+ write: (line) => lines.push(line),
494
+ }),
495
+ });
496
+ const response = await mintUserToken(h.deps, request());
497
+ expect(response.accessToken).not.toBe("");
498
+ expect(lines).toHaveLength(1);
499
+ expect(JSON.parse(lines[0]!)).toMatchObject({
500
+ level: "warn",
501
+ credential: "platform client",
502
+ id: "pcl_dashboard",
503
+ error: "client store down",
504
+ });
505
+ expect(lines[0], "the secret is never logged").not.toContain(SECRET);
506
+ });
507
+ });
@@ -39,6 +39,10 @@ const CONTRACT_CASE_NAMES = [
39
39
  "the same slug in another organization is a different client",
40
40
  "update replaces the row in place",
41
41
  "update of an unknown id writes nothing",
42
+ "modifyById persists what modify did, answers the persisted row, and the lookups follow it",
43
+ "modifyById of an unknown id answers undefined and writes nothing",
44
+ "a modify that throws writes nothing and rejects with its error",
45
+ "concurrent modifyById calls on one row lose no write",
42
46
  "deleteById frees the id, the client_id and the slug; deleting an unknown id resolves",
43
47
  "findByOrg answers every client of the organization and no other",
44
48
  "a disconnected store is an infrastructure fault, never 'not found'",
@@ -5,7 +5,8 @@
5
5
  * or typed one, refuses a bad one with the envelope's copy, refuses a
6
6
  * token naming no client, refuses a deleted client's token with the
7
7
  * cloud's liveness copy, lets a store fault propagate (never a
8
- * revocation), and stamps the account from `sub` as a `user`;
8
+ * revocation), and stamps the account from `sub` as a `user` with the
9
+ * minting client it verified, for the audit actor (#1256);
9
10
  * - the guard reads no client for a request without an Origin, passes an
10
11
  * open allowlist and a listed origin case-insensitively, refuses an
11
12
  * unlisted origin and the opaque "null" with the cloud's copy, and
@@ -83,7 +84,7 @@ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
83
84
  }
84
85
 
85
86
  describe("the PlatformClient user-token verifier", () => {
86
- it("stamps the account the token was minted for as a user, with the asserted profile", async () => {
87
+ it("stamps the account the token was minted for as a user, with the asserted profile and the minting client", async () => {
87
88
  const token = userToken();
88
89
  const identity = await newPlatformClientTokenVerifier({
89
90
  keys: ring,
@@ -96,6 +97,7 @@ describe("the PlatformClient user-token verifier", () => {
96
97
  rawToken: token,
97
98
  email: "pat@example.com",
98
99
  displayName: "Pat",
100
+ platformClientId: "pcl_dashboard",
99
101
  });
100
102
  });
101
103
 
@@ -11,6 +11,11 @@
11
11
  * sentences open source adds — a server that cannot mint, the guest
12
12
  * method's edition refusal, the mismatched-account guard — follow the
13
13
  * house style (single-quoted handles).
14
+ *
15
+ * One inherited sentence was changed on purpose: the expired-secret
16
+ * refusal told integrators to rotate, which never helps, because rotating
17
+ * leaves the expiry as it is (steps.ts, newRotateClientCredentialsStep).
18
+ * It now names the one fix and who can apply it (stigmer/stigmer#1254).
14
19
  */
15
20
 
16
21
  /** The kind's name as the shared error constructors render it. */
@@ -63,8 +68,13 @@ export function referenceNotFoundMessage(org: string, slug: string): string {
63
68
  export const INVALID_CLIENT_CREDENTIALS_MESSAGE =
64
69
  "Invalid client_id or client_secret";
65
70
 
71
+ /**
72
+ * The mint's refusal for a client whose expires_at has passed. Rotating
73
+ * does not extend the expiry, and both update and rotateSecret need the
74
+ * client's owner (can_edit), so the sentence names the owner's fix.
75
+ */
66
76
  export const EXPIRED_CLIENT_SECRET_MESSAGE =
67
- "PlatformClient secret has expired. Use rotateSecret to generate a new one.";
77
+ "PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.";
68
78
 
69
79
  export function organizationMismatchMessage(owningOrg: string): string {
70
80
  return `org_id must be empty or the PlatformClient's owning organization ('${owningOrg}'); cross-organization minting is not supported`;
@@ -44,6 +44,14 @@
44
44
  *
45
45
  * The email and name in the token and on the account are the platform's
46
46
  * assertion; nothing on the server derives authority from either.
47
+ *
48
+ * A signed token is a successful mint, so the client's use is then
49
+ * recorded on its status.last_used_at (identity/credential-use.ts): at most
50
+ * once a resolution, judged from the row authentication already read,
51
+ * written through the port's atomic `modifyById` — never the whole-row
52
+ * `update`, which could write back the secret hash a concurrent rotation
53
+ * just replaced — and best-effort: a failed stamp is logged and the token
54
+ * is still answered. A refused mint records nothing (stigmer/stigmer#1255).
47
55
  */
48
56
  import { Code, ConnectError } from "@connectrpc/connect";
49
57
  import { create } from "@bufbuild/protobuf";
@@ -54,6 +62,7 @@ import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityacc
54
62
  import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/enum_pb";
55
63
  import { IdentityAccountSpecSchema } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/spec_pb";
56
64
  import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
65
+ import { PlatformClientStatusSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
57
66
  import type {
58
67
  MintUserTokenRequest,
59
68
  MintUserTokenResponse,
@@ -63,6 +72,7 @@ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
63
72
 
64
73
  import type { Logger } from "../../boot/logger.js";
65
74
  import type { CallerIdentity } from "../../extensions/identity.js";
75
+ import { recordCredentialUse } from "../../identity/credential-use.js";
66
76
  import { internalError } from "../../pipeline/errors.js";
67
77
  import { serverActingFor } from "../../pipeline/interceptors/auth.js";
68
78
  import { validator } from "../../pipeline/steps/validation.js";
@@ -164,6 +174,7 @@ export async function mintUserToken(
164
174
  user,
165
175
  );
166
176
 
177
+ const now = deps.now();
167
178
  const signed = signPlatformToken(
168
179
  keys,
169
180
  {
@@ -174,8 +185,9 @@ export async function mintUserToken(
174
185
  [USER_TOKEN_CLAIMS.org]: owningOrg,
175
186
  [USER_TOKEN_CLAIMS.platformClientId]: client.metadata?.id ?? "",
176
187
  },
177
- { now: deps.now() },
188
+ { now },
178
189
  );
190
+ await recordClientUse(deps, client, now);
179
191
  return create(MintUserTokenResponseSchema, {
180
192
  accessToken: signed.token,
181
193
  tokenType: BEARER_TOKEN_TYPE,
@@ -223,6 +235,27 @@ async function authenticateClient(
223
235
  return client;
224
236
  }
225
237
 
238
+ /** Stamps the minting client's last use on its own row, through the port's atomic write. */
239
+ function recordClientUse(
240
+ deps: PlatformClientMintDeps,
241
+ client: PlatformClient,
242
+ now: Date,
243
+ ): Promise<void> {
244
+ const id = client.metadata?.id ?? "";
245
+ return recordCredentialUse({
246
+ credential: "platform client",
247
+ id,
248
+ lastUsedAt: client.status?.lastUsedAt,
249
+ now,
250
+ logger: deps.logger,
251
+ write: (stamp) =>
252
+ deps.clients.modifyById(id, (live) => {
253
+ live.status ??= create(PlatformClientStatusSchema);
254
+ stamp(live.status);
255
+ }),
256
+ });
257
+ }
258
+
226
259
  /** The account id for the user: the existing platform-client account, or one provisioned now. */
227
260
  async function resolveOrProvisionAccount(
228
261
  deps: PlatformClientMintDeps,
@@ -20,6 +20,8 @@
20
20
  * (org, slug) or client_id; `update` writes nothing for an unknown id; the
21
21
  * residual window between the read and the write is the platform-wide
22
22
  * CheckDuplicate-then-upsert window, not this adapter's to close.
23
+ * `modifyById` has no such window: it IS Store.updateResource, the generic
24
+ * store's own atomic read-modify-write, its not-found read as `undefined`.
23
25
  *
24
26
  * Store faults follow the ratified mapping: a typed ResourceNotFoundError
25
27
  * reads as `undefined`; anything else propagates — an outage must never
@@ -135,6 +137,22 @@ export function newResourcePlatformClientStore(
135
137
  await store.saveResource(KIND, id, PlatformClientSchema, client);
136
138
  },
137
139
 
140
+ async modifyById(id, modify): Promise<PlatformClient | undefined> {
141
+ try {
142
+ return await store.updateResource(
143
+ KIND,
144
+ id,
145
+ PlatformClientSchema,
146
+ modify,
147
+ );
148
+ } catch (error) {
149
+ if (error instanceof ResourceNotFoundError) {
150
+ return undefined;
151
+ }
152
+ throw error;
153
+ }
154
+ },
155
+
138
156
  async deleteById(id): Promise<void> {
139
157
  await store.deleteResource(KIND, id);
140
158
  },
@@ -242,6 +242,106 @@ const CASES: ReadonlyArray<PortContractDeclaration<PlatformClientStore>> = [
242
242
  );
243
243
  },
244
244
  ],
245
+ [
246
+ "modifyById persists what modify did, answers the persisted row, and the lookups follow it",
247
+ async ({ store }) => {
248
+ await store.save(ACME_DASHBOARD);
249
+ const answered = await store.modifyById(
250
+ "pcl_contract_acme_dashboard",
251
+ (client) => {
252
+ assert.ok(client.spec, "the saved client carries a spec");
253
+ client.spec.allowedOrigins = ["https://app.acme.example"];
254
+ },
255
+ );
256
+ const expected = platformClient({
257
+ id: "pcl_contract_acme_dashboard",
258
+ org: "acme",
259
+ slug: "dashboard",
260
+ clientId: "stgm_cid_contract_acme_dashboard",
261
+ allowedOrigins: ["https://app.acme.example"],
262
+ });
263
+ assertSameClient(answered, expected, "modifyById must answer the persisted row");
264
+ assertSameClient(
265
+ await store.findById("pcl_contract_acme_dashboard"),
266
+ expected,
267
+ "findById must answer the modified row",
268
+ );
269
+ assertSameClient(
270
+ await store.findByClientId("stgm_cid_contract_acme_dashboard"),
271
+ expected,
272
+ "findByClientId must answer the modified row",
273
+ );
274
+ assertSameClient(
275
+ await store.findByOrgAndSlug("acme", "dashboard"),
276
+ expected,
277
+ "findByOrgAndSlug must answer the modified row",
278
+ );
279
+ },
280
+ ],
281
+ [
282
+ "modifyById of an unknown id answers undefined and writes nothing",
283
+ async ({ store }) => {
284
+ let called = false;
285
+ const answered = await store.modifyById(
286
+ "pcl_contract_acme_dashboard",
287
+ () => {
288
+ called = true;
289
+ },
290
+ );
291
+ assert.equal(answered, undefined);
292
+ assert.equal(called, false, "modify must not run for a row that is not held");
293
+ assert.equal(
294
+ await store.findById("pcl_contract_acme_dashboard"),
295
+ undefined,
296
+ "modifyById must modify, never create",
297
+ );
298
+ },
299
+ ],
300
+ [
301
+ "a modify that throws writes nothing and rejects with its error",
302
+ async ({ store }) => {
303
+ await store.save(ACME_DASHBOARD);
304
+ const refusal = new Error("modify refused");
305
+ await assert.rejects(
306
+ store.modifyById("pcl_contract_acme_dashboard", (client) => {
307
+ assert.ok(client.spec, "the saved client carries a spec");
308
+ client.spec.allowedOrigins = ["https://half.written.example"];
309
+ throw refusal;
310
+ }),
311
+ (error: unknown) => error === refusal,
312
+ "the modify's own error must propagate",
313
+ );
314
+ assertSameClient(
315
+ await store.findById("pcl_contract_acme_dashboard"),
316
+ ACME_DASHBOARD,
317
+ "nothing a throwing modify did may persist",
318
+ );
319
+ },
320
+ ],
321
+ [
322
+ "concurrent modifyById calls on one row lose no write",
323
+ async ({ store }) => {
324
+ await store.save(ACME_DASHBOARD);
325
+ const origins = Array.from(
326
+ { length: 8 },
327
+ (_, i) => `https://writer-${i}.acme.example`,
328
+ );
329
+ await Promise.all(
330
+ origins.map((origin) =>
331
+ store.modifyById("pcl_contract_acme_dashboard", (client) => {
332
+ assert.ok(client.spec, "the saved client carries a spec");
333
+ client.spec.allowedOrigins.push(origin);
334
+ }),
335
+ ),
336
+ );
337
+ const stored = await store.findById("pcl_contract_acme_dashboard");
338
+ assert.deepEqual(
339
+ [...(stored?.spec?.allowedOrigins ?? [])].sort(),
340
+ [...origins].sort(),
341
+ "every concurrent modify must see the one before it: a read-then-write driver loses edits here",
342
+ );
343
+ },
344
+ ],
245
345
  [
246
346
  "deleteById frees the id, the client_id and the slug; deleting an unknown id resolves",
247
347
  async ({ store }) => {
@@ -13,6 +13,14 @@
13
13
  * duplicate check), by organization (listByOrg). A port carries nothing
14
14
  * only one edition calls.
15
15
  *
16
+ * One write is atomic: `modifyById`, the port's rendering of
17
+ * `Store.updateResource`, for the status the platform writes on its own
18
+ * (the mint's last-use stamp, identity/credential-use.ts). `update` stays
19
+ * a whole-row replace for the chains that load, edit and persist; a stamp
20
+ * written that way would race them and could restore a rotated secret. The
21
+ * driver supplies only the atomicity; what `modify` does stays the
22
+ * domain's, written once for every edition.
23
+ *
16
24
  * Contract every implementation must satisfy — proven by the port-contract
17
25
  * kit (store-contract.ts, exported), which the OSS adapter's test iterates
18
26
  * on both drivers and a composition's driver test iterates too:
@@ -21,6 +29,10 @@
21
29
  * overwrite;
22
30
  * - `update` replaces by id and never creates: an unknown id writes
23
31
  * nothing;
32
+ * - `modifyById` reads the row, applies `modify` and persists the result
33
+ * in one atomic step, so no concurrent write is lost; it answers the
34
+ * persisted row, an unknown id answers `undefined` and writes nothing,
35
+ * and a `modify` that throws writes nothing and rejects with its error;
24
36
  * - `deleteById` of an unknown id resolves;
25
37
  * - `findByOrg` answers every client of the organization and no other,
26
38
  * in no promised order (the chain sorts);
@@ -49,6 +61,17 @@ export interface PlatformClientStore {
49
61
  save(client: PlatformClient): Promise<void>;
50
62
  /** Full-row replace by id; an unknown id writes nothing. */
51
63
  update(client: PlatformClient): Promise<void>;
64
+ /**
65
+ * Atomic read-modify-write by id: `modify` mutates the stored client in
66
+ * place and the result is persisted, or nothing is. `modify` MUST be
67
+ * synchronous (Store.updateResource's rule: a driver may hold a write
68
+ * transaction or a row lock while it runs). Answers the persisted client;
69
+ * an unknown id answers `undefined`.
70
+ */
71
+ modifyById(
72
+ id: string,
73
+ modify: (client: PlatformClient) => void,
74
+ ): Promise<PlatformClient | undefined>;
52
75
  /** Removes the row; no error when it does not exist. */
53
76
  deleteById(id: string): Promise<void>;
54
77
  findById(id: string): Promise<PlatformClient | undefined>;
@@ -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