@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.
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +8 -1
- package/dist/boot/compose.js.map +1 -1
- package/dist/domain/apikey/controller.d.ts.map +1 -1
- package/dist/domain/apikey/controller.js +7 -2
- package/dist/domain/apikey/controller.js.map +1 -1
- package/dist/domain/apikey/verifier.d.ts +5 -0
- package/dist/domain/apikey/verifier.d.ts.map +1 -1
- package/dist/domain/apikey/verifier.js +32 -3
- package/dist/domain/apikey/verifier.js.map +1 -1
- package/dist/domain/platformclient/mint.d.ts.map +1 -1
- package/dist/domain/platformclient/mint.js +28 -1
- package/dist/domain/platformclient/mint.js.map +1 -1
- package/dist/domain/platformclient/resource-store.d.ts.map +1 -1
- package/dist/domain/platformclient/resource-store.js +13 -0
- package/dist/domain/platformclient/resource-store.js.map +1 -1
- package/dist/domain/platformclient/store-contract.d.ts.map +1 -1
- package/dist/domain/platformclient/store-contract.js +59 -0
- package/dist/domain/platformclient/store-contract.js.map +1 -1
- package/dist/domain/platformclient/store.d.ts +20 -0
- package/dist/domain/platformclient/store.d.ts.map +1 -1
- package/dist/domain/platformclient/store.js.map +1 -1
- package/dist/identity/credential-use.d.ts +74 -0
- package/dist/identity/credential-use.d.ts.map +1 -0
- package/dist/identity/credential-use.js +59 -0
- package/dist/identity/credential-use.js.map +1 -0
- package/dist/pipeline/steps/defaults.d.ts +10 -0
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +25 -0
- package/dist/pipeline/steps/defaults.js.map +1 -1
- package/dist/temporal/schedule/activities.d.ts.map +1 -1
- package/dist/temporal/schedule/activities.js +2 -1
- package/dist/temporal/schedule/activities.js.map +1 -1
- package/dist/temporal/schedule/run-starter.d.ts.map +1 -1
- package/dist/temporal/schedule/run-starter.js +2 -1
- package/dist/temporal/schedule/run-starter.js.map +1 -1
- package/dist/temporal/schedule/status-writes.d.ts +0 -6
- package/dist/temporal/schedule/status-writes.d.ts.map +1 -1
- package/dist/temporal/schedule/status-writes.js +8 -27
- package/dist/temporal/schedule/status-writes.js.map +1 -1
- package/dist/temporal/schedule/syncer.d.ts.map +1 -1
- package/dist/temporal/schedule/syncer.js +2 -1
- package/dist/temporal/schedule/syncer.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/compose.ts +8 -1
- package/src/domain/apikey/__tests__/apikey.test.ts +17 -2
- package/src/domain/apikey/__tests__/verifier.test.ts +86 -3
- package/src/domain/apikey/controller.ts +12 -2
- package/src/domain/apikey/verifier.ts +46 -3
- package/src/domain/platformclient/__tests__/mint.test.ts +97 -11
- package/src/domain/platformclient/__tests__/resource-store.test.ts +4 -0
- package/src/domain/platformclient/mint.ts +34 -1
- package/src/domain/platformclient/resource-store.ts +18 -0
- package/src/domain/platformclient/store-contract.ts +100 -0
- package/src/domain/platformclient/store.ts +23 -0
- package/src/identity/__tests__/credential-use.test.ts +186 -0
- package/src/identity/credential-use.ts +130 -0
- package/src/pipeline/steps/defaults.ts +27 -0
- package/src/temporal/schedule/activities.ts +2 -1
- package/src/temporal/schedule/run-starter.ts +2 -1
- package/src/temporal/schedule/status-writes.ts +8 -31
- 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
|
|
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 {
|
|
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
|
|
58
|
+
import { ensureStatus } from "./status-writes.js";
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* The scheduled session's pinned subject prefix — cross-edition contract
|