@stigmer/server 3.32.0 → 3.34.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 (76) hide show
  1. package/dist/authorization/model/data/authorization-model.json +6 -0
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +8 -1
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/domain/apikey/controller.d.ts.map +1 -1
  6. package/dist/domain/apikey/controller.js +7 -2
  7. package/dist/domain/apikey/controller.js.map +1 -1
  8. package/dist/domain/apikey/verifier.d.ts +5 -0
  9. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  10. package/dist/domain/apikey/verifier.js +32 -3
  11. package/dist/domain/apikey/verifier.js.map +1 -1
  12. package/dist/domain/organization/controller.js +14 -4
  13. package/dist/domain/organization/controller.js.map +1 -1
  14. package/dist/domain/platformclient/mint.d.ts.map +1 -1
  15. package/dist/domain/platformclient/mint.js +28 -1
  16. package/dist/domain/platformclient/mint.js.map +1 -1
  17. package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
  18. package/dist/domain/platformclient/resource-store.js +13 -0
  19. package/dist/domain/platformclient/resource-store.js.map +1 -1
  20. package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
  21. package/dist/domain/platformclient/store-contract.js +59 -0
  22. package/dist/domain/platformclient/store-contract.js.map +1 -1
  23. package/dist/domain/platformclient/store.d.ts +20 -0
  24. package/dist/domain/platformclient/store.d.ts.map +1 -1
  25. package/dist/domain/platformclient/store.js.map +1 -1
  26. package/dist/extensions/gate-slots.d.ts +11 -1
  27. package/dist/extensions/gate-slots.d.ts.map +1 -1
  28. package/dist/extensions/gate-slots.js +1 -0
  29. package/dist/extensions/gate-slots.js.map +1 -1
  30. package/dist/identity/credential-use.d.ts +74 -0
  31. package/dist/identity/credential-use.d.ts.map +1 -0
  32. package/dist/identity/credential-use.js +59 -0
  33. package/dist/identity/credential-use.js.map +1 -0
  34. package/dist/pipeline/steps/defaults.d.ts +10 -0
  35. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  36. package/dist/pipeline/steps/defaults.js +25 -0
  37. package/dist/pipeline/steps/defaults.js.map +1 -1
  38. package/dist/temporal/schedule/activities.d.ts.map +1 -1
  39. package/dist/temporal/schedule/activities.js +2 -1
  40. package/dist/temporal/schedule/activities.js.map +1 -1
  41. package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
  42. package/dist/temporal/schedule/run-starter.js +2 -1
  43. package/dist/temporal/schedule/run-starter.js.map +1 -1
  44. package/dist/temporal/schedule/status-writes.d.ts +0 -6
  45. package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
  46. package/dist/temporal/schedule/status-writes.js +8 -27
  47. package/dist/temporal/schedule/status-writes.js.map +1 -1
  48. package/dist/temporal/schedule/syncer.d.ts.map +1 -1
  49. package/dist/temporal/schedule/syncer.js +2 -1
  50. package/dist/temporal/schedule/syncer.js.map +1 -1
  51. package/package.json +6 -6
  52. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  53. package/src/authorization/__tests__/wire-permissions.test.ts +0 -6
  54. package/src/authorization/model/data/authorization-model.json +6 -0
  55. package/src/boot/compose.ts +8 -1
  56. package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
  57. package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
  58. package/src/domain/apikey/controller.ts +12 -2
  59. package/src/domain/apikey/verifier.ts +46 -3
  60. package/src/domain/organization/controller.ts +17 -4
  61. package/src/domain/platformclient/__tests__/mint.test.ts +97 -11
  62. package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
  63. package/src/domain/platformclient/mint.ts +34 -1
  64. package/src/domain/platformclient/resource-store.ts +18 -0
  65. package/src/domain/platformclient/store-contract.ts +100 -0
  66. package/src/domain/platformclient/store.ts +23 -0
  67. package/src/extensions/__tests__/extension-composition.test.ts +49 -3
  68. package/src/extensions/__tests__/registry.test.ts +3 -0
  69. package/src/extensions/gate-slots.ts +11 -0
  70. package/src/identity/__tests__/credential-use.test.ts +186 -0
  71. package/src/identity/credential-use.ts +130 -0
  72. package/src/pipeline/steps/defaults.ts +27 -0
  73. package/src/temporal/schedule/activities.ts +2 -1
  74. package/src/temporal/schedule/run-starter.ts +2 -1
  75. package/src/temporal/schedule/status-writes.ts +8 -31
  76. package/src/temporal/schedule/syncer.ts +2 -1
@@ -33,14 +33,28 @@
33
33
  * identityId changes on a hit: email/displayName stay the stamp's, the
34
34
  * actor the key recorded at minting. The credential checks run first, so
35
35
  * a revoked or expired key never reaches the account store.
36
+ *
37
+ * An accepted key's use is then recorded on its status.last_used_at
38
+ * (identity/credential-use.ts): at most once a resolution, judged from the
39
+ * row this read already holds, written atomically through
40
+ * Store.updateResource so it can never undo a concurrent update of the
41
+ * key, and best-effort — a failed stamp is logged and the request
42
+ * proceeds. A refused key records nothing (stigmer/stigmer#1255).
36
43
  */
37
44
  import { Code, ConnectError } from "@connectrpc/connect";
45
+ import { create } from "@bufbuild/protobuf";
38
46
  import { timestampDate } from "@bufbuild/protobuf/wkt";
39
47
 
48
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
49
+ import { ApiKeySchema, ApiKeyStatusSchema } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
50
+ import type { ApiKey } from "@stigmer/protos/ai/stigmer/iam/apikey/v1/api_pb";
51
+
52
+ import type { Logger } from "../../boot/logger.js";
40
53
  import type {
41
54
  CallerIdentity,
42
55
  IdentityVerifier,
43
56
  } from "../../extensions/identity.js";
57
+ import { recordCredentialUse } from "../../identity/credential-use.js";
44
58
  import type { Store } from "../../store/interface.js";
45
59
  import { identityIdForSubject } from "../identityaccount/resolve.js";
46
60
  import type { AccountsBySubject } from "../identityaccount/resolve.js";
@@ -58,12 +72,16 @@ export interface ApiKeyVerifierDeps {
58
72
  readonly store: Store;
59
73
  /** The identity-account domain's subject lookup the creator stamp resolves through (A6). */
60
74
  readonly accounts: AccountsBySubject;
75
+ /** Where a failed last-use stamp is reported. */
76
+ readonly logger: Logger;
77
+ /** The clock both the expiry check and the last-use stamp read. */
78
+ readonly now: () => Date;
61
79
  }
62
80
 
63
81
  export function newApiKeyIdentityVerifier(
64
82
  deps: ApiKeyVerifierDeps,
65
83
  ): IdentityVerifier {
66
- const { store, accounts } = deps;
84
+ const { store, accounts, logger } = deps;
67
85
  return {
68
86
  name: "apikey",
69
87
  async verify(token: string): Promise<CallerIdentity | null> {
@@ -74,8 +92,9 @@ export function newApiKeyIdentityVerifier(
74
92
  if (key === undefined) {
75
93
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
76
94
  }
95
+ const now = deps.now();
77
96
  const expiresAt = key.spec?.expiresAt;
78
- if (expiresAt !== undefined && timestampDate(expiresAt) <= new Date()) {
97
+ if (expiresAt !== undefined && timestampDate(expiresAt) <= now) {
79
98
  throw new ConnectError(TOKEN_EXPIRED_MESSAGE, Code.Unauthenticated);
80
99
  }
81
100
  const owner = key.status?.audit?.specAudit?.createdBy;
@@ -85,8 +104,10 @@ export function newApiKeyIdentityVerifier(
85
104
  // through the pipeline; guards hand-seeded or corrupted rows).
86
105
  throw new ConnectError(INVALID_TOKEN_MESSAGE, Code.Unauthenticated);
87
106
  }
107
+ const identityId = await identityIdForSubject(accounts, owner.id);
108
+ await recordKeyUse(store, logger, key, now);
88
109
  return {
89
- identityId: await identityIdForSubject(accounts, owner.id),
110
+ identityId,
90
111
  callerClass: "user",
91
112
  issuer: "",
92
113
  rawToken: token,
@@ -96,3 +117,25 @@ export function newApiKeyIdentityVerifier(
96
117
  },
97
118
  };
98
119
  }
120
+
121
+ /** Stamps the accepted key's last use on its own row, atomically. */
122
+ function recordKeyUse(
123
+ store: Store,
124
+ logger: Logger,
125
+ key: ApiKey,
126
+ now: Date,
127
+ ): Promise<void> {
128
+ const id = key.metadata?.id ?? "";
129
+ return recordCredentialUse({
130
+ credential: "api key",
131
+ id,
132
+ lastUsedAt: key.status?.lastUsedAt,
133
+ now,
134
+ logger,
135
+ write: (stamp) =>
136
+ store.updateResource(ApiResourceKind.api_key, id, ApiKeySchema, (live) => {
137
+ live.status ??= create(ApiKeyStatusSchema);
138
+ stamp(live.status);
139
+ }),
140
+ });
141
+ }
@@ -156,6 +156,11 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
156
156
  * ValidateProto so clients can omit the slug and have it derived before
157
157
  * field constraints (slug pattern, 2–15 chars) are checked.
158
158
  *
159
+ * The pre-side-effect gate slot splices before Persist, after the last pure
160
+ * step, the session chain's position: every step above it only reads, so a
161
+ * refusal there leaves no organization behind. It is where a limit on
162
+ * which organizations may exist is enforced.
163
+ *
159
164
  * The post-persist gate slot splices after Persist, before IndexSearch —
160
165
  * the verified Java OrganizationCreateHandler ordering (FGA tuple
161
166
  * seeding, billing account getOrCreate: synchronous, a failure fails the
@@ -190,11 +195,19 @@ async function createOrganization(
190
195
  .addStep(newCheckOrgDuplicateStep(deps.store))
191
196
  .addStep(newBuildNewStateStep())
192
197
  .addStep(newGuardReservedLabelsStep(deps.authorizer))
193
- .addStep(newCopySlugToIdStep())
198
+ .addStep(newCopySlugToIdStep());
199
+ // The pre-side-effect gate slot (see the doc comment above). Empty in OSS.
200
+ for (const step of stepsForSlot<typeof OrganizationSchema>(
201
+ deps.gateSteps,
202
+ "org-create:pre-side-effect-gate",
203
+ )) {
204
+ builder.addStep(step);
205
+ }
206
+ builder
194
207
  .addStep(newPersistStep(deps.store))
195
- // The C2 tuple step runs BEFORE the slot — the verified Java order
196
- // (createAuthorizationTuples → linkManagedOrgToIdentityProvider →
197
- // provisionBillingAccount). No-op with no driver composed.
208
+ // The C2 tuple step runs BEFORE the post-persist slot — the verified
209
+ // Java order (createAuthorizationTuples → linkManagedOrgToIdentityProvider
210
+ // → provisionBillingAccount). No-op with no driver composed.
198
211
  .addStep(
199
212
  newCreateAuthorizationTuplesStep(
200
213
  deps.authorizationLifecycle,
@@ -15,13 +15,17 @@
15
15
  * nothing; a failed create leaves the grant, and the next mint completes
16
16
  * the account; a concurrent winner is read back;
17
17
  * - the default role is viewer, owner is refused, and the token carries
18
- * 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).
19
23
  */
20
24
  import { generateKeyPairSync } from "node:crypto";
21
25
 
22
26
  import { Code, ConnectError } from "@connectrpc/connect";
23
- import { create } from "@bufbuild/protobuf";
24
- import { timestampFromDate } from "@bufbuild/protobuf/wkt";
27
+ import { clone, create } from "@bufbuild/protobuf";
28
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
25
29
  import { describe, expect, it } from "vitest";
26
30
 
27
31
  import type { IdentityAccount } from "@stigmer/protos/ai/stigmer/iam/identityaccount/v1/api_pb";
@@ -30,12 +34,18 @@ import { IdentityAccountProvisioningMode } from "@stigmer/protos/ai/stigmer/iam/
30
34
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
31
35
  import type { IamPolicySpec } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
32
36
  import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
33
- 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";
34
41
  import { MintUserTokenRequestSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/token_pb";
35
42
  import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
36
43
 
44
+ import { createLogger } from "../../../boot/logger.js";
45
+ import type { Logger } from "../../../boot/logger.js";
37
46
  import type { CallerIdentity } from "../../../extensions/identity.js";
38
47
  import { silentLogger } from "../../../extensions/__tests__/composed-support.js";
48
+ import { LAST_USED_RESOLUTION_MS } from "../../../identity/credential-use.js";
39
49
  import { verifyPlatformToken } from "../../../platformtoken/envelope.js";
40
50
  import { platformTokenKeyRingFromPem } from "../../../platformtoken/key-ring.js";
41
51
  import type { PlatformTokenKeyRing } from "../../../platformtoken/key-ring.js";
@@ -101,6 +111,10 @@ interface Harness {
101
111
  }>;
102
112
  readonly granted: Array<{ spec: IamPolicySpec; caller: CallerIdentity }>;
103
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[];
104
118
  }
105
119
 
106
120
  function harness(
@@ -109,9 +123,12 @@ function harness(
109
123
  keys?: PlatformTokenKeyRing | undefined;
110
124
  failGrant?: boolean;
111
125
  failCreate?: "fault" | "lost-race";
126
+ failStamp?: boolean;
127
+ logger?: Logger;
112
128
  } = {},
113
129
  ): Harness {
114
- const client = options.client ?? platformClient();
130
+ const stored = { client: options.client ?? platformClient() };
131
+ const clientWrites: string[] = [];
115
132
  const events: string[] = [];
116
133
  const created: Harness["created"] = [];
117
134
  const granted: Harness["granted"] = [];
@@ -126,12 +143,25 @@ function harness(
126
143
  };
127
144
  const deps: PlatformClientMintDeps = {
128
145
  clients: {
129
- save: async () => {},
130
- 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
+ },
131
161
  deleteById: async () => {},
132
- findById: async () => client,
162
+ findById: async () => stored.client,
133
163
  findByClientId: async (clientId) =>
134
- clientId === client.spec?.clientId ? client : undefined,
164
+ clientId === stored.client.spec?.clientId ? stored.client : undefined,
135
165
  findByOrgAndSlug: async () => undefined,
136
166
  findByOrg: async () => [],
137
167
  },
@@ -156,10 +186,10 @@ function harness(
156
186
  },
157
187
  grantPath,
158
188
  keys: "keys" in options ? options.keys : RING,
159
- logger: silentLogger,
189
+ logger: options.logger ?? silentLogger,
160
190
  now: () => NOW,
161
191
  };
162
- return { deps, events, created, granted, accounts };
192
+ return { deps, events, created, granted, accounts, stored, clientWrites };
163
193
  }
164
194
 
165
195
  function request(
@@ -419,3 +449,59 @@ describe("mintUserToken — the user", () => {
419
449
  );
420
450
  });
421
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'",
@@ -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>;
@@ -736,9 +736,10 @@ describe("extension composition (O5 driver substitution)", () => {
736
736
  /**
737
737
  * The O4 gate-slot + status-hook arms: an extension gate spliced into a
738
738
  * declared slot refuses with its own ConnectError code and copy — before
739
- * the side effect on the pre-side-effect slot (nothing persisted), after
740
- * it on the post-persist slot (the row survives the failed request, the
741
- * inherited Java semantics — O4 verification V1); the status observers
739
+ * the side effect on a pre-side-effect slot (nothing persisted: the
740
+ * session and organization chains), after it on the post-persist slot
741
+ * (the row survives the failed request, the inherited Java semantics — O4
742
+ * verification V1); the status observers
742
743
  * see the terminal updateStatus transition exactly once (the Q4
743
744
  * phase-change rule) and the response decorator contributes the control
744
745
  * signal on the shared reply schema.
@@ -746,6 +747,7 @@ describe("extension composition (O5 driver substitution)", () => {
746
747
  describe("extension composition (O4 gate slots + status hooks)", () => {
747
748
  const REFUSED_SESSION = "o4-refused-session";
748
749
  const REFUSED_ORG_SLUG = "o4refusedorg";
750
+ const PRE_REFUSED_ORG_SLUG = "prerefusedorg";
749
751
  let server: ComposedServer;
750
752
  let dir: string;
751
753
  let portTransport: Transport;
@@ -777,11 +779,25 @@ describe("extension composition (O4 gate slots + status hooks)", () => {
777
779
  },
778
780
  };
779
781
 
782
+ const orgPreSideEffectGate: PipelineStep<DescMessage> = {
783
+ name: "FakeOrgPreSideEffectGate",
784
+ execute: (ctx) => {
785
+ const org = ctx.newState as { metadata?: { slug?: string } };
786
+ if (org.metadata?.slug === PRE_REFUSED_ORG_SLUG) {
787
+ throw new ConnectError(
788
+ "fake organization limit reached",
789
+ Code.FailedPrecondition,
790
+ );
791
+ }
792
+ },
793
+ };
794
+
780
795
  const gateExtension: ServerExtension = {
781
796
  name: "fake-gates-and-hooks",
782
797
  gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
783
798
  ["session-create:pre-side-effect-gate", [sessionGate]],
784
799
  ["org-create:post-persist", [orgPostPersistGate]],
800
+ ["org-create:pre-side-effect-gate", [orgPreSideEffectGate]],
785
801
  ]),
786
802
  statusTransitionHooks: {
787
803
  observers: [
@@ -892,6 +908,36 @@ describe("extension composition (O4 gate slots + status hooks)", () => {
892
908
  expect(org.metadata?.slug).toBe(REFUSED_ORG_SLUG);
893
909
  });
894
910
 
911
+ it("a pre-side-effect gate refusal aborts organization create with no row written", async () => {
912
+ const command = createClient(OrganizationCommandController, portTransport);
913
+ const query = createClient(OrganizationQueryController, portTransport);
914
+
915
+ let refused: ConnectError | undefined;
916
+ try {
917
+ await command.create(
918
+ create(OrganizationSchema, {
919
+ apiVersion: "tenancy.stigmer.ai/v1",
920
+ kind: "Organization",
921
+ metadata: { name: "Pre Refused Org", slug: PRE_REFUSED_ORG_SLUG },
922
+ }),
923
+ );
924
+ } catch (error) {
925
+ refused = ConnectError.from(error);
926
+ }
927
+ expect(refused?.code).toBe(Code.FailedPrecondition);
928
+ expect(refused?.rawMessage).toBe("fake organization limit reached");
929
+
930
+ // The slot sits BEFORE Persist: the refused organization was never
931
+ // written, which is the whole reason the slot exists.
932
+ let getError: ConnectError | undefined;
933
+ try {
934
+ await query.get({ value: PRE_REFUSED_ORG_SLUG });
935
+ } catch (error) {
936
+ getError = ConnectError.from(error);
937
+ }
938
+ expect(getError?.code).toBe(Code.NotFound);
939
+ });
940
+
895
941
  it("observers see the terminal updateStatus transition once and the decorator contributes the signal", async () => {
896
942
  const executionId = "aexec_o4_hooks";
897
943
  await server.store.saveResource(
@@ -488,6 +488,9 @@ describe("resolveExtensions — loud-fail throws (DD-006 §2b)", () => {
488
488
  // personal-organization ensure and backfill ride it).
489
489
  "identity-account-provision:post-persist",
490
490
  "org-create:post-persist",
491
+ // The ninth: the organization create chain before Persist, where a
492
+ // limit on which organizations may exist refuses with nothing written.
493
+ "org-create:pre-side-effect-gate",
491
494
  "sandbox-acquisition:gate",
492
495
  "session-create:pre-side-effect-gate",
493
496
  ]);