@stigmer/server 3.32.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 (62) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +8 -1
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/apikey/controller.d.ts.map +1 -1
  5. package/dist/domain/apikey/controller.js +7 -2
  6. package/dist/domain/apikey/controller.js.map +1 -1
  7. package/dist/domain/apikey/verifier.d.ts +5 -0
  8. package/dist/domain/apikey/verifier.d.ts.map +1 -1
  9. package/dist/domain/apikey/verifier.js +32 -3
  10. package/dist/domain/apikey/verifier.js.map +1 -1
  11. package/dist/domain/platformclient/mint.d.ts.map +1 -1
  12. package/dist/domain/platformclient/mint.js +28 -1
  13. package/dist/domain/platformclient/mint.js.map +1 -1
  14. package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
  15. package/dist/domain/platformclient/resource-store.js +13 -0
  16. package/dist/domain/platformclient/resource-store.js.map +1 -1
  17. package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
  18. package/dist/domain/platformclient/store-contract.js +59 -0
  19. package/dist/domain/platformclient/store-contract.js.map +1 -1
  20. package/dist/domain/platformclient/store.d.ts +20 -0
  21. package/dist/domain/platformclient/store.d.ts.map +1 -1
  22. package/dist/domain/platformclient/store.js.map +1 -1
  23. package/dist/identity/credential-use.d.ts +74 -0
  24. package/dist/identity/credential-use.d.ts.map +1 -0
  25. package/dist/identity/credential-use.js +59 -0
  26. package/dist/identity/credential-use.js.map +1 -0
  27. package/dist/pipeline/steps/defaults.d.ts +10 -0
  28. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  29. package/dist/pipeline/steps/defaults.js +25 -0
  30. package/dist/pipeline/steps/defaults.js.map +1 -1
  31. package/dist/temporal/schedule/activities.d.ts.map +1 -1
  32. package/dist/temporal/schedule/activities.js +2 -1
  33. package/dist/temporal/schedule/activities.js.map +1 -1
  34. package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
  35. package/dist/temporal/schedule/run-starter.js +2 -1
  36. package/dist/temporal/schedule/run-starter.js.map +1 -1
  37. package/dist/temporal/schedule/status-writes.d.ts +0 -6
  38. package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
  39. package/dist/temporal/schedule/status-writes.js +8 -27
  40. package/dist/temporal/schedule/status-writes.js.map +1 -1
  41. package/dist/temporal/schedule/syncer.d.ts.map +1 -1
  42. package/dist/temporal/schedule/syncer.js +2 -1
  43. package/dist/temporal/schedule/syncer.js.map +1 -1
  44. package/package.json +6 -6
  45. package/src/boot/compose.ts +8 -1
  46. package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
  47. package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
  48. package/src/domain/apikey/controller.ts +12 -2
  49. package/src/domain/apikey/verifier.ts +46 -3
  50. package/src/domain/platformclient/__tests__/mint.test.ts +97 -11
  51. package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
  52. package/src/domain/platformclient/mint.ts +34 -1
  53. package/src/domain/platformclient/resource-store.ts +18 -0
  54. package/src/domain/platformclient/store-contract.ts +100 -0
  55. package/src/domain/platformclient/store.ts +23 -0
  56. package/src/identity/__tests__/credential-use.test.ts +186 -0
  57. package/src/identity/credential-use.ts +130 -0
  58. package/src/pipeline/steps/defaults.ts +27 -0
  59. package/src/temporal/schedule/activities.ts +2 -1
  60. package/src/temporal/schedule/run-starter.ts +2 -1
  61. package/src/temporal/schedule/status-writes.ts +8 -31
  62. package/src/temporal/schedule/syncer.ts +2 -1
@@ -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>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Pins the credential last-use rule (credential-use.ts):
3
+ * - staleness is measured against the stored stamp, a never-used
4
+ * credential is stale, and exactly one resolution is stale again;
5
+ * - the stamp only moves forward, and a write bumps the status audit's
6
+ * updated_at + event while keeping who created the slot;
7
+ * - recordCredentialUse writes only when stale, treats a row deleted
8
+ * meanwhile as nothing to record, and logs any other failure without
9
+ * throwing.
10
+ */
11
+ import { create } from "@bufbuild/protobuf";
12
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import {
16
+ ApiResourceAuditActorSchema,
17
+ ApiResourceAuditInfoSchema,
18
+ ApiResourceAuditSchema,
19
+ } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
20
+
21
+ import { createLogger } from "../../boot/logger.js";
22
+ import { ResourceNotFoundError } from "../../store/interface.js";
23
+ import {
24
+ LAST_USED_RESOLUTION_MS,
25
+ lastUseIsStale,
26
+ recordCredentialUse,
27
+ stampLastUsed,
28
+ type CredentialUseStatus,
29
+ } from "../credential-use.js";
30
+
31
+ const NOW = new Date("2026-09-28T12:00:00Z");
32
+
33
+ function ago(ms: number): Date {
34
+ return new Date(NOW.getTime() - ms);
35
+ }
36
+
37
+ function capturingLogger(): {
38
+ lines: string[];
39
+ logger: ReturnType<typeof createLogger>;
40
+ } {
41
+ const lines: string[] = [];
42
+ return {
43
+ lines,
44
+ logger: createLogger({
45
+ level: "debug",
46
+ pretty: false,
47
+ write: (line) => lines.push(line),
48
+ }),
49
+ };
50
+ }
51
+
52
+ describe("lastUseIsStale", () => {
53
+ it("is stale for a credential never used", () => {
54
+ expect(lastUseIsStale(undefined, NOW)).toBe(true);
55
+ });
56
+
57
+ it("is fresh inside one resolution and stale from exactly one resolution on", () => {
58
+ const justInside = timestampFromDate(ago(LAST_USED_RESOLUTION_MS - 1));
59
+ const exactly = timestampFromDate(ago(LAST_USED_RESOLUTION_MS));
60
+ expect(lastUseIsStale(justInside, NOW)).toBe(false);
61
+ expect(lastUseIsStale(exactly, NOW)).toBe(true);
62
+ });
63
+
64
+ it("is fresh for a stamp ahead of this replica's clock", () => {
65
+ expect(lastUseIsStale(timestampFromDate(new Date(NOW.getTime() + 5_000)), NOW)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("stampLastUsed", () => {
70
+ it("stamps a never-used status and bumps the status audit", () => {
71
+ const status: CredentialUseStatus = {};
72
+ expect(stampLastUsed(status, NOW)).toBe(true);
73
+ expect(timestampDate(status.lastUsedAt!)).toEqual(NOW);
74
+ expect(status.audit?.statusAudit?.event).toBe("updated");
75
+ expect(status.audit?.statusAudit?.updatedAt).toBeDefined();
76
+ });
77
+
78
+ it("keeps who created the status-audit slot and never touches the spec audit", () => {
79
+ const createdBy = create(ApiResourceAuditActorSchema, { id: "ida_creator" });
80
+ const createdAt = timestampFromDate(ago(86_400_000));
81
+ const specAudit = create(ApiResourceAuditInfoSchema, { createdBy, createdAt, event: "created" });
82
+ const priorSlot = create(ApiResourceAuditInfoSchema, { createdBy, createdAt, event: "created" });
83
+ const status: CredentialUseStatus = {
84
+ audit: create(ApiResourceAuditSchema, { specAudit, statusAudit: priorSlot }),
85
+ };
86
+
87
+ stampLastUsed(status, NOW);
88
+
89
+ expect(status.audit?.statusAudit?.createdBy?.id).toBe("ida_creator");
90
+ expect(status.audit?.statusAudit?.createdAt).toEqual(createdAt);
91
+ expect(status.audit?.statusAudit?.event).toBe("updated");
92
+ expect(status.audit?.specAudit).toBe(specAudit);
93
+ expect(priorSlot.event, "a new slot is set, the old one is not mutated").toBe("created");
94
+ });
95
+
96
+ it("only moves forward: a stamp at or after the use is kept, audit untouched", () => {
97
+ const later = timestampFromDate(new Date(NOW.getTime() + 1_000));
98
+ const status: CredentialUseStatus = { lastUsedAt: later };
99
+ expect(stampLastUsed(status, NOW)).toBe(false);
100
+ expect(status.lastUsedAt).toBe(later);
101
+ expect(status.audit).toBeUndefined();
102
+
103
+ const same = timestampFromDate(NOW);
104
+ const atSameInstant: CredentialUseStatus = { lastUsedAt: same };
105
+ expect(stampLastUsed(atSameInstant, NOW)).toBe(false);
106
+ });
107
+ });
108
+
109
+ describe("recordCredentialUse", () => {
110
+ function use(overrides: {
111
+ lastUsedAt?: Date;
112
+ write: (stamp: (status: CredentialUseStatus) => void) => Promise<unknown>;
113
+ logger?: ReturnType<typeof createLogger>;
114
+ }) {
115
+ return {
116
+ credential: "api key",
117
+ id: "key_1",
118
+ lastUsedAt:
119
+ overrides.lastUsedAt === undefined ? undefined : timestampFromDate(overrides.lastUsedAt),
120
+ now: NOW,
121
+ logger: overrides.logger ?? capturingLogger().logger,
122
+ write: overrides.write,
123
+ };
124
+ }
125
+
126
+ it("writes the stamp through the kind's atomic write when stale", async () => {
127
+ const row: CredentialUseStatus = {};
128
+ let writes = 0;
129
+ await recordCredentialUse(
130
+ use({
131
+ write: async (stamp) => {
132
+ writes += 1;
133
+ stamp(row);
134
+ },
135
+ }),
136
+ );
137
+ expect(writes).toBe(1);
138
+ expect(timestampDate(row.lastUsedAt!)).toEqual(NOW);
139
+ });
140
+
141
+ it("does not write inside the resolution", async () => {
142
+ let writes = 0;
143
+ await recordCredentialUse(
144
+ use({
145
+ lastUsedAt: ago(LAST_USED_RESOLUTION_MS / 2),
146
+ write: async () => {
147
+ writes += 1;
148
+ },
149
+ }),
150
+ );
151
+ expect(writes).toBe(0);
152
+ });
153
+
154
+ it("treats a row deleted meanwhile as nothing to record, silently", async () => {
155
+ const { lines, logger } = capturingLogger();
156
+ await recordCredentialUse(
157
+ use({
158
+ logger,
159
+ write: async () => {
160
+ throw new ResourceNotFoundError("api_key/key_1");
161
+ },
162
+ }),
163
+ );
164
+ expect(lines).toEqual([]);
165
+ });
166
+
167
+ it("logs any other failure with the credential id and never throws", async () => {
168
+ const { lines, logger } = capturingLogger();
169
+ await expect(
170
+ recordCredentialUse(
171
+ use({
172
+ logger,
173
+ write: async () => {
174
+ throw new Error("database unavailable");
175
+ },
176
+ }),
177
+ ),
178
+ ).resolves.toBeUndefined();
179
+ expect(lines).toHaveLength(1);
180
+ const entry = JSON.parse(lines[0]!) as Record<string, unknown>;
181
+ expect(entry.level).toBe("warn");
182
+ expect(entry.credential).toBe("api key");
183
+ expect(entry.id).toBe("key_1");
184
+ expect(entry.error).toBe("database unavailable");
185
+ });
186
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * When a credential was last used: the one rule the two credential kinds
3
+ * that carry `status.last_used_at` share. An API key is stamped by the
4
+ * identity verifier that accepts it (domain/apikey/verifier.ts), a
5
+ * PlatformClient by a successful mint (domain/platformclient/mint.ts). It
6
+ * lives here, beside the other infrastructure the credential lanes share,
7
+ * because neither domain owns the other (stigmer/stigmer#1255).
8
+ *
9
+ * The stamp is status the platform writes on its own, so it follows the
10
+ * house rule for such writes: an atomic read-modify-write on the row
11
+ * (`Store.updateResource`; the PlatformClient port's `modifyById`), the
12
+ * narrow status-audit bump (pipeline/steps/defaults.ts), and best-effort
13
+ * with a loud log, as `stampLastFireAt` records a schedule's manual fire
14
+ * (domain/schedule/trigger.ts). Never a load-then-save of the whole row:
15
+ * the credential's own update chains replace the row whole, and a stale
16
+ * copy written back by a stamp would undo them (a rotated PlatformClient
17
+ * secret would work again).
18
+ *
19
+ * The resolution keeps a credential that rides every request from costing
20
+ * a write per request, and it needs no state: the caller already holds the
21
+ * row it just verified, so "stamped less than a resolution ago" is one
22
+ * comparison with the stored value, the same answer on every replica and
23
+ * after every restart. Callers that pass the check at the same instant
24
+ * each write once; every write only moves the stamp forward, so the extra
25
+ * writes change nothing.
26
+ */
27
+ import type { Timestamp } from "@bufbuild/protobuf/wkt";
28
+ import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt";
29
+
30
+ import type { ApiResourceAudit } from "@stigmer/protos/ai/stigmer/commons/apiresource/status_pb";
31
+
32
+ import type { Logger } from "../boot/logger.js";
33
+ import { bumpStatusAudit } from "../pipeline/steps/defaults.js";
34
+ import { ResourceNotFoundError } from "../store/interface.js";
35
+
36
+ /**
37
+ * How stale a stored last use may be before a new use records again: one
38
+ * minute. Fresh enough that the console's "Used 3m" is true to the minute;
39
+ * coarse enough that a key behind every request costs at most one write a
40
+ * minute per replica. Both fields' proto comments state it.
41
+ */
42
+ export const LAST_USED_RESOLUTION_MS = 60_000;
43
+
44
+ /** The status shape both credential kinds share. */
45
+ export interface CredentialUseStatus {
46
+ lastUsedAt?: Timestamp;
47
+ audit?: ApiResourceAudit;
48
+ }
49
+
50
+ /** True when a use at `now` should be recorded over the stored `lastUsedAt`. */
51
+ export function lastUseIsStale(
52
+ lastUsedAt: Timestamp | undefined,
53
+ now: Date,
54
+ ): boolean {
55
+ return (
56
+ lastUsedAt === undefined ||
57
+ now.getTime() - timestampDate(lastUsedAt).getTime() >=
58
+ LAST_USED_RESOLUTION_MS
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Records a use at `usedAt`, forward only: a stored stamp at or after it (a
64
+ * replica whose clock runs ahead, a racing caller that wrote first) is
65
+ * kept, and the audit is left alone. Returns whether it wrote. Synchronous,
66
+ * because it runs inside an atomic write's `modify`.
67
+ */
68
+ export function stampLastUsed(
69
+ status: CredentialUseStatus,
70
+ usedAt: Date,
71
+ ): boolean {
72
+ const stored = status.lastUsedAt;
73
+ if (
74
+ stored !== undefined &&
75
+ timestampDate(stored).getTime() >= usedAt.getTime()
76
+ ) {
77
+ return false;
78
+ }
79
+ status.lastUsedAt = timestampFromDate(usedAt);
80
+ bumpStatusAudit(status);
81
+ return true;
82
+ }
83
+
84
+ export interface CredentialUse {
85
+ /** What the log calls the credential ("api key", "platform client"). */
86
+ readonly credential: string;
87
+ /** The credential's resource id: logged, never the secret. */
88
+ readonly id: string;
89
+ /** The stored stamp, as the caller read the row it just verified. */
90
+ readonly lastUsedAt: Timestamp | undefined;
91
+ readonly now: Date;
92
+ readonly logger: Logger;
93
+ /**
94
+ * The kind's atomic write: applies `stamp` to the row's status (created
95
+ * when absent) inside one read-modify-write. A row deleted since the
96
+ * caller read it is a typed not-found or a write that finds nothing.
97
+ */
98
+ readonly write: (
99
+ stamp: (status: CredentialUseStatus) => void,
100
+ ) => Promise<unknown>;
101
+ }
102
+
103
+ /**
104
+ * Records the use when the stored stamp is stale. Never throws: the
105
+ * credential already did its job, and a bookkeeping failure must not turn
106
+ * an accepted request or a minted token into an error. A row deleted
107
+ * meanwhile has nothing to record; any other failure is logged.
108
+ */
109
+ export async function recordCredentialUse(use: CredentialUse): Promise<void> {
110
+ if (!lastUseIsStale(use.lastUsedAt, use.now)) {
111
+ return;
112
+ }
113
+ try {
114
+ await use.write((status) => {
115
+ stampLastUsed(status, use.now);
116
+ });
117
+ } catch (error) {
118
+ if (error instanceof ResourceNotFoundError) {
119
+ return;
120
+ }
121
+ use.logger.warn(
122
+ "Credential last_used_at not recorded (best-effort — the request is unaffected)",
123
+ {
124
+ credential: use.credential,
125
+ id: use.id,
126
+ error: error instanceof Error ? error.message : String(error),
127
+ },
128
+ );
129
+ }
130
+ }
@@ -15,6 +15,12 @@
15
15
  * SpecAudit for definition changes (search recency, version "pushed at"),
16
16
  * StatusAudit for operational changes (Recents, lifecycle metadata).
17
17
  *
18
+ * A write the PLATFORM makes on its own (a schedule's clock, a credential's
19
+ * last-use stamp) takes the narrower `bumpStatusAudit` instead: status-audit
20
+ * updated_at + event only, never an actor, because no caller made it (Go's
21
+ * clock bump, "the same two leaves every cloud runtime patch bumps"). Two
22
+ * flavors, two writers, both wire-visible — keep them distinct.
23
+ *
18
24
  * This module is also the one home of how an id is SPELLED. `generateId`
19
25
  * mints `{prefix}_{ulid}`; `derivedId` is its sibling for the kinds
20
26
  * metadata.proto names as deriving their id from a natural key instead
@@ -175,6 +181,27 @@ export function setAuditFieldsForUpdate(
175
181
  );
176
182
  }
177
183
 
184
+ /**
185
+ * Stamps the status-audit slot for a write the platform makes on its own —
186
+ * updated_at + event "updated", nothing else (see the module header for why
187
+ * this is not setAuditFieldsForUpdate). Like that helper it SETS a newly
188
+ * allocated slot rather than assigning into the existing one (#540); the
189
+ * slot's created_by/created_at/updated_by carry over unchanged.
190
+ */
191
+ export function bumpStatusAudit(status: { audit?: ApiResourceAudit }): void {
192
+ if (status.audit === undefined) {
193
+ status.audit = create(ApiResourceAuditSchema);
194
+ }
195
+ const prior = status.audit.statusAudit;
196
+ const next =
197
+ prior === undefined
198
+ ? create(ApiResourceAuditInfoSchema)
199
+ : clone(ApiResourceAuditInfoSchema, prior);
200
+ next.updatedAt = timestampNow();
201
+ next.event = "updated";
202
+ status.audit.statusAudit = next;
203
+ }
204
+
178
205
  // Operator identity (stigmer/stigmer#400): installed once at boot — before
179
206
  // any request — and read by the identity chassis when it mints the
180
207
  // trusted-local CallerIdentity (O2; audit stamping now derives its actor
@@ -20,6 +20,7 @@ import type { Schedule } from "@stigmer/protos/ai/stigmer/agentic/schedule/v1/ap
20
20
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
21
21
 
22
22
  import type { Logger } from "../../boot/logger.js";
23
+ import { bumpStatusAudit } from "../../pipeline/steps/defaults.js";
23
24
  import { ResourceNotFoundError } from "../../store/interface.js";
24
25
  import type { Store } from "../../store/interface.js";
25
26
  import type { ScheduleTemporalConfig } from "./config.js";
@@ -64,7 +65,7 @@ import {
64
65
  } from "./run-ledger.js";
65
66
  import type { RunStarter } from "./run-starter.js";
66
67
  import type { ScheduleSyncer } from "./syncer.js";
67
- import { bumpStatusAudit, ensureStatus } from "./status-writes.js";
68
+ import { ensureStatus } from "./status-writes.js";
68
69
 
69
70
  export interface ScheduleTickActivityDeps {
70
71
  readonly store: Store;
@@ -49,12 +49,13 @@ import type { Logger } from "../../boot/logger.js";
49
49
  import type { CallerIdentity } from "../../extensions/identity.js";
50
50
  import type { ScheduleFireCallerMint } from "../../extensions/schedule-fire-caller.js";
51
51
  import { ScheduleFireCallerRefusedError } from "../../extensions/schedule-fire-caller.js";
52
+ import { bumpStatusAudit } from "../../pipeline/steps/defaults.js";
52
53
  import { findResourceBySlug } from "../../pipeline/steps/helpers.js";
53
54
  import { ResourceNotFoundError } from "../../store/interface.js";
54
55
  import type { Store } from "../../store/interface.js";
55
56
  import type { ScheduleTemporalConfig } from "./config.js";
56
57
  import { scheduleModelPinningRefusal } from "./model-pinning.js";
57
- import { ensureStatus, bumpStatusAudit } from "./status-writes.js";
58
+ import { ensureStatus } from "./status-writes.js";
58
59
 
59
60
  /**
60
61
  * The scheduled session's pinned subject prefix — cross-edition contract