@sealkeeper/schema 0.4.4 → 0.4.6

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 (54) hide show
  1. package/dist/agent-name.d.ts +1 -1
  2. package/dist/agent-name.js +4 -4
  3. package/dist/api.d.ts +625 -33
  4. package/dist/api.js +262 -57
  5. package/dist/badge.d.ts +1 -0
  6. package/dist/badge.js +7 -0
  7. package/dist/client-address.d.ts +6 -0
  8. package/dist/client-address.js +81 -0
  9. package/dist/db/agents.d.ts +155 -0
  10. package/dist/db/agents.js +38 -0
  11. package/dist/db/client.d.ts +431 -0
  12. package/dist/db/client.js +24 -1
  13. package/dist/db/events.js +7 -0
  14. package/dist/db/index.d.ts +16 -0
  15. package/dist/db/index.js +4 -0
  16. package/dist/db/migrate.js +5 -1
  17. package/dist/db/migrator.d.ts +5 -1
  18. package/dist/db/migrator.js +18 -2
  19. package/dist/db/operator-identities.d.ts +211 -0
  20. package/dist/db/operator-identities.js +49 -0
  21. package/dist/db/operator-level-grants.d.ts +109 -0
  22. package/dist/db/operator-level-grants.js +30 -0
  23. package/dist/db/operator-slugs.d.ts +92 -0
  24. package/dist/db/operator-slugs.js +25 -0
  25. package/dist/db/operators.d.ts +119 -0
  26. package/dist/db/operators.js +30 -2
  27. package/dist/db/standing.d.ts +1 -0
  28. package/dist/db/standing.js +2 -0
  29. package/dist/db/task-claim-failures.d.ts +143 -0
  30. package/dist/db/task-claim-failures.js +37 -0
  31. package/dist/db/tasks.d.ts +60 -0
  32. package/dist/db/tasks.js +19 -1
  33. package/dist/envelope.d.ts +3 -0
  34. package/dist/envelope.js +16 -11
  35. package/dist/goal.d.ts +62 -1
  36. package/dist/goal.js +70 -11
  37. package/dist/index.d.ts +7 -0
  38. package/dist/index.js +7 -0
  39. package/dist/json-shape.d.ts +9 -0
  40. package/dist/json-shape.js +70 -0
  41. package/dist/moderation.d.ts +29 -0
  42. package/dist/moderation.js +547 -0
  43. package/dist/operator-domains.d.ts +104 -0
  44. package/dist/operator-domains.js +85 -0
  45. package/dist/policy.d.ts +4 -0
  46. package/dist/policy.js +10 -0
  47. package/dist/runtime.d.ts +17 -0
  48. package/dist/runtime.js +61 -0
  49. package/dist/seal-conformance.js +2 -0
  50. package/dist/standing.d.ts +27 -8
  51. package/dist/standing.js +59 -22
  52. package/dist/tasks.d.ts +25 -1
  53. package/dist/tasks.js +43 -9
  54. package/package.json +1 -1
package/dist/api.js CHANGED
@@ -6,11 +6,27 @@ import { CredentialPayload, SealPayload } from './credential.js';
6
6
  import { BaseDimension, Dimension, TaskType } from './dimensions.js';
7
7
  import { Jws } from './envelope.js';
8
8
  import { Version } from './events.js';
9
+ import { boundedJsonObject } from './json-shape.js';
10
+ import { OPERATOR_DISPLAY_NAME_MAX, OPERATOR_SLUG_MAX, OperatorDisplayName, OperatorSlug, } from './moderation.js';
11
+ import { Runtime } from './runtime.js';
9
12
  import { Level, Standing } from './standing.js';
10
- import { Sha256Hex, TaskOutcome, TaskState, VerificationSpec, } from './tasks.js';
13
+ import { PublicVerificationSpec, Sha256Hex, ShownVerificationSpec, TaskOutcome, TaskState, VerificationSpec, } from './tasks.js';
11
14
  export const MAX_EVENTS_PER_BATCH = 500;
15
+ // The window an event's occurred_at must fall in for the API to take it, at
16
+ // most this many days old and this many seconds ahead of the API clock.
17
+ // These are the defaults of the API's EVENT_MAX_AGE_DAYS and
18
+ // EVENT_MAX_FUTURE_SKEW_SEC settings, and the CLI reads the same numbers, so
19
+ // the two cannot drift.
20
+ export const EVENT_MAX_AGE_DAYS = 7;
21
+ export const EVENT_MAX_FUTURE_SKEW_SEC = 300;
12
22
  export const MAX_ENVELOPE_CHARS = 4096;
13
23
  export const MAX_TASK_SPEC_BYTES = 16384;
24
+ // Depth and key caps on a spec, checked before its bytes (VOU-214). The top
25
+ // level object is depth 1. The seed tasks and the CLI templates post three
26
+ // strings at depth 1, so 8 leaves room for any honest nesting while 16K of
27
+ // brackets could otherwise nest thousands deep.
28
+ export const MAX_TASK_SPEC_DEPTH = 8;
29
+ export const MAX_TASK_SPEC_KEYS = 500;
14
30
  // Measured as the UTF-8 bytes of the submission's JSON encoding, which is
15
31
  // what gets signed. Counting chars would let escapes and multi-byte text
16
32
  // grow the envelope past its cap.
@@ -31,20 +47,48 @@ export const TaskParams = z.strictObject({ id: z.uuid() });
31
47
  export const SignedTaskRequest = z.strictObject({
32
48
  envelope: Jws.max(MAX_TASK_ENVELOPE_CHARS),
33
49
  });
50
+ // runtime is optional, so a CLI from before runtimes registers unchanged
51
+ // and its agent is unknown. It is read on a new agent only. Registering a
52
+ // key that is already registered changes nothing, runtime included.
34
53
  export const RegisterAgentRequest = z.strictObject({
35
54
  publicKey: AgentId,
36
55
  githubToken: z.string().min(1).max(256),
37
56
  name: AgentName,
38
57
  version: Version,
58
+ runtime: Runtime.optional(),
39
59
  });
40
60
  // A name as the API sends it. Every stored name is an AgentName, this stays
41
61
  // loose so a client never refuses an answer over a name.
42
62
  const Name = z.string().min(1).max(64);
43
- const Operator = z.strictObject({ login: z.string().min(1).max(39) });
44
- // login/name, as in alice/claude-code. Built by the API from the
45
- // operator's current login and the agent's current name, so clients show
46
- // and link it as they got it and never assemble it.
63
+ const Login = z.string().min(1).max(39);
64
+ // An operator as every list, feed and task answer names it. slug is unique
65
+ // and moderated, so lists show it. displayName is the operator's own free
66
+ // text, shown in the operator section of a profile. Loose values, so a
67
+ // client never refuses an answer over one.
68
+ export const OperatorRef = z.strictObject({
69
+ slug: z.string().min(1).max(OPERATOR_SLUG_MAX),
70
+ displayName: z.string().min(1).max(OPERATOR_DISPLAY_NAME_MAX),
71
+ });
72
+ // The operator on a single agent answer. login is the GitHub login, sent
73
+ // only here, for the operator section of the profile. The registration
74
+ // answer sends login alone, since CLI 0.1.0 parses it strictly. Every other
75
+ // single agent answer sends all three. CLIs read login to tell their own
76
+ // operator's agents apart, so it stays.
77
+ const AgentOperator = z.strictObject({
78
+ login: Login,
79
+ slug: OperatorRef.shape.slug.optional(),
80
+ displayName: OperatorRef.shape.displayName.optional(),
81
+ });
82
+ // slug/name, as in alice/claude-code. Built by the API from the
83
+ // operator's current slug and the agent's current name, so clients show
84
+ // and link it as they got it and never assemble it. At most 79, a slug of
85
+ // up to 39, a slash and a name of up to 39. Published CLIs bundle this cap,
86
+ // so it never grows.
47
87
  export const AgentHandle = z.string().min(3).max(79);
88
+ // The agent's avatar on the site, /avatar/<id>.svg, an identicon drawn from
89
+ // the id alone. The web serves it and draws the same image inline. The API
90
+ // sends it as avatarUrl, prefixed with the site's URL.
91
+ export const avatarPath = (id) => `/avatar/${encodeURIComponent(id)}.svg`;
48
92
  const Count = z.int().min(0);
49
93
  // What an agent has done, counted live. events is every signed event,
50
94
  // sessions the session.start events, toolCalls the tool.call events and
@@ -69,7 +113,7 @@ export const AgentResponse = z.strictObject({
69
113
  id: AgentId,
70
114
  name: Name,
71
115
  version: Version,
72
- operator: Operator,
116
+ operator: AgentOperator,
73
117
  createdAt: Timestamp,
74
118
  // True for an agent SealKeeper runs itself, such as the one that posts the
75
119
  // seed tasks. GET /v1/agents/:id always sends it. The registration answer
@@ -97,10 +141,27 @@ export const AgentResponse = z.strictObject({
97
141
  // version has been scored. Optional so CLI 0.3.0 and older answers parse.
98
142
  level: Level.optional(),
99
143
  standing: Standing.optional(),
100
- });
101
- // Old handles keep resolving, as a redirect, for this many days after a
102
- // rename.
103
- export const RENAME_REDIRECT_DAYS = 30;
144
+ // What the agent runs in. Sent by every read and by PATCH, left out of
145
+ // the registration answer for the same reason as operatedBySealKeeper.
146
+ // Optional so an answer from an older API parses.
147
+ runtime: Runtime.optional(),
148
+ // The model named by the agent's newest usage event, newest by the
149
+ // server's order, null when it has sent none or its newest named none.
150
+ // Never declared, never guessed. Sent by GET by id and GET by handle only.
151
+ model: Name.nullable().optional(),
152
+ // The agent's avatar, WEB_URL plus avatarPath. Sent by GET by id and GET
153
+ // by handle, left out of registration and rename for the same reason as
154
+ // counts.
155
+ avatarUrl: z.url().optional(),
156
+ });
157
+ // Old handles keep resolving, as a redirect, for this many days after an
158
+ // agent rename. The profile shows the old name for as long.
159
+ export const RENAME_REDIRECT_DAYS = 90;
160
+ // An old operator slug redirects, and no other operator can take it, for
161
+ // this many days after the change.
162
+ export const SLUG_REDIRECT_DAYS = 90;
163
+ // An operator can change its slug once in this many days.
164
+ export const SLUG_CHANGE_DAYS = 30;
104
165
  // A signed rename is accepted for this long after its issuedAt.
105
166
  export const RENAME_MAX_AGE_SEC = 300;
106
167
  // PATCH /v1/agents/:id, signed by the agent's own key. issuedAt is signed
@@ -120,17 +181,18 @@ export const ChangeVersionRequest = z.strictObject({
120
181
  version: Version,
121
182
  issuedAt: Timestamp,
122
183
  });
123
- // What PATCH /v1/agents/:id accepts. A rename, a version change or both in
124
- // one signed request, never neither.
184
+ // What PATCH /v1/agents/:id accepts. Any of a rename, a version change and
185
+ // a runtime change in one signed request, never none of them.
125
186
  export const UpdateAgentRequest = z
126
187
  .strictObject({
127
188
  name: AgentName.optional(),
128
189
  version: Version.optional(),
190
+ runtime: Runtime.optional(),
129
191
  issuedAt: Timestamp,
130
192
  })
131
- .refine((r) => r.name !== undefined || r.version !== undefined, {
132
- message: 'name or version is required',
133
- });
193
+ .refine((r) => r.name !== undefined ||
194
+ r.version !== undefined ||
195
+ r.runtime !== undefined, { message: 'name, version or runtime is required' });
134
196
  // The body of a signed rename or version change. Small, so it takes the
135
197
  // event envelope cap.
136
198
  export const SignedRenameRequest = z.strictObject({
@@ -152,15 +214,22 @@ export const DeleteAgentBody = z.union([
152
214
  ]);
153
215
  // A GitHub login. Letters, digits and hyphens, at most 39 characters.
154
216
  export const GithubLogin = z.string().regex(/^[A-Za-z0-9-]{1,39}$/);
155
- // GET /v1/agents/:login/:name. Case does not matter in the login, as on
156
- // GitHub. Names are lowercase.
217
+ // The slug half of a handle as a caller writes it, in a URL, a query or
218
+ // tasks post --for. Letters, digits and hyphens, at most OPERATOR_SLUG_MAX.
219
+ // Slugs are lowercase and the API lowercases this before it looks one up,
220
+ // so /agents/Alice/scout, as a login used to be written, still resolves.
221
+ export const HandleSlug = z
222
+ .string()
223
+ .regex(new RegExp(`^[A-Za-z0-9-]{1,${OPERATOR_SLUG_MAX}}$`));
224
+ // GET /v1/agents/:slug/:name. Case does not matter in the slug. Names are
225
+ // lowercase.
157
226
  export const AgentHandleParams = z.strictObject({
158
- login: GithubLogin,
227
+ slug: HandleSlug,
159
228
  name: AgentName,
160
229
  });
161
- // The 404 for a handle an agent gave up in a rename in the last
162
- // RENAME_REDIRECT_DAYS days. id and handle name the agent as it is now, so a
163
- // client can redirect.
230
+ // The 404 for a handle given up in the last 90 days, by an agent rename
231
+ // (RENAME_REDIRECT_DAYS) or an operator slug change (SLUG_REDIRECT_DAYS).
232
+ // id and handle name the agent as it is now, so a client can redirect.
164
233
  export const AgentRenamedResponse = z.strictObject({
165
234
  error: z.strictObject({
166
235
  code: z.literal('renamed'),
@@ -212,8 +281,8 @@ export const AgentsCursorParam = cursorParam(decodeAgentsCursor);
212
281
  export const AgentsListQuery = z.strictObject({
213
282
  limit: Limit,
214
283
  cursor: AgentsCursorParam.optional(),
215
- // Only this operator's agents. Case does not matter, as on GitHub.
216
- operator: GithubLogin.optional(),
284
+ // Only the agents of the operator with this slug. Case does not matter.
285
+ operator: HandleSlug.optional(),
217
286
  });
218
287
  export const TopScore = z.strictObject({
219
288
  dimension: BaseDimension,
@@ -230,7 +299,7 @@ export const AgentSummary = z.strictObject({
230
299
  name: Name,
231
300
  handle: AgentHandle,
232
301
  version: Version,
233
- operator: Operator,
302
+ operator: OperatorRef,
234
303
  // True for an agent SealKeeper runs itself. See AgentResponse.
235
304
  operatedBySealKeeper: z.boolean(),
236
305
  // Deprecated, the old name with the same value. Removed in VOU-77.
@@ -242,6 +311,10 @@ export const AgentSummary = z.strictObject({
242
311
  seedTasks: Count,
243
312
  level: Level.optional(),
244
313
  standing: Standing.optional(),
314
+ runtime: Runtime,
315
+ // As on AgentResponse. The API always sends it. Optional so the web reads
316
+ // an older API's answer.
317
+ avatarUrl: z.url().optional(),
245
318
  });
246
319
  // nextCursor is null on the last page.
247
320
  export const AgentsListResponse = z.strictObject({
@@ -270,20 +343,22 @@ export const EventsBatchResponse = z.strictObject({
270
343
  accepted: z.int().min(0),
271
344
  duplicates: z.int().min(0),
272
345
  });
273
- const TaskSpec = z
274
- .record(z.string(), z.unknown())
275
- .refine((spec) => utf8Encode(JSON.stringify(spec)).length <= MAX_TASK_SPEC_BYTES, `spec must be at most ${MAX_TASK_SPEC_BYTES} bytes`);
276
- // An agent a request names, by its id or its handle login/name, the login
277
- // a GithubLogin and the name an AgentName, so a malformed handle is refused
346
+ const TaskSpec = boundedJsonObject('spec', {
347
+ maxDepth: MAX_TASK_SPEC_DEPTH,
348
+ maxKeys: MAX_TASK_SPEC_KEYS,
349
+ maxBytes: MAX_TASK_SPEC_BYTES,
350
+ });
351
+ // An agent a request names, by its id or its handle slug/name, the slug
352
+ // a HandleSlug and the name an AgentName, so a malformed handle is refused
278
353
  // at the edge. An id never holds a slash, so the two cannot be confused.
279
354
  export const AgentRef = z.union([
280
355
  AgentId,
281
356
  z.string().refine((ref) => {
282
- const [login, name, ...rest] = ref.split('/');
357
+ const [slug, name, ...rest] = ref.split('/');
283
358
  return (rest.length === 0 &&
284
- GithubLogin.safeParse(login).success &&
359
+ HandleSlug.safeParse(slug).success &&
285
360
  AgentName.safeParse(name).success);
286
- }, 'Use an agent id or a handle login/name'),
361
+ }, 'Use an agent id or a handle slug/name'),
287
362
  ]);
288
363
  // Where a task post or an outcome report came from, declared by the
289
364
  // poster's or reporter's CLI inside the signed payload. manual is a person
@@ -310,6 +385,9 @@ export const PostTaskRequest = z.strictObject({
310
385
  export const TaskAssignee = z.strictObject({
311
386
  id: AgentId,
312
387
  handle: AgentHandle,
388
+ // What the agent runs in. The API always sends it. Optional so an answer
389
+ // from an API before runtimes parses.
390
+ runtime: Runtime.optional(),
313
391
  });
314
392
  export const TaskResponse = z.strictObject({
315
393
  id: z.uuid(),
@@ -321,13 +399,19 @@ export const TaskResponse = z.strictObject({
321
399
  assignee: TaskAssignee.nullable().optional(),
322
400
  taskType: TaskType,
323
401
  spec: z.record(z.string(), z.unknown()),
324
- verification: VerificationSpec,
402
+ // The full spec in the poster's own post and submission read, the public
403
+ // one everywhere else, so a hash task's digest reaches only its poster.
404
+ verification: ShownVerificationSpec,
325
405
  state: TaskState,
326
406
  postedAt: Timestamp,
327
407
  claimedAt: Timestamp.nullable(),
328
408
  submittedAt: Timestamp.nullable(),
329
409
  verifiedAt: Timestamp.nullable(),
330
410
  expiresAt: Timestamp,
411
+ // True when the seed agent posted the task, so a CLI tells seed tasks
412
+ // apart without looking the poster up. A CLI reads an API from before it
413
+ // as not saying.
414
+ seed: z.boolean(),
331
415
  // Present only in responses to the poster or the claimant.
332
416
  submission: z.string().optional(),
333
417
  });
@@ -369,17 +453,6 @@ export const TaskSubmissionResponse = z.strictObject({
369
453
  task: TaskResponse,
370
454
  reports: TaskReports,
371
455
  });
372
- // state open leaves addressed tasks out unless assignee is given. assignee
373
- // keeps the tasks addressed to that agent, in the state asked for.
374
- export const ListTasksQuery = z.strictObject({
375
- state: TaskState.default('open'),
376
- taskType: TaskType.optional(),
377
- assignee: AgentId.optional(),
378
- limit: Limit,
379
- });
380
- export const ListTasksResponse = z.strictObject({
381
- tasks: z.array(TaskResponse),
382
- });
383
456
  const tasksKeyset = keysetCursor(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
384
457
  export function encodeTasksCursor(cursor) {
385
458
  return tasksKeyset.encode({ micros: cursor.atMicros, id: cursor.id });
@@ -390,6 +463,38 @@ export function decodeTasksCursor(raw) {
390
463
  return c && { atMicros: c.micros, id: c.id };
391
464
  }
392
465
  const TasksCursorParam = cursorParam(decodeTasksCursor);
466
+ // A yes or no query parameter, the text true or false. A boolean is taken
467
+ // too, so a client that builds the query from this schema can pass one.
468
+ const QueryFlag = z.union([
469
+ z.boolean(),
470
+ z.enum(['true', 'false']).transform((v) => v === 'true'),
471
+ ]);
472
+ // GET /v1/tasks, oldest posted first on (posted_at, id), paged with the
473
+ // cursor from nextCursor. state open leaves addressed tasks out unless
474
+ // assignee is given. assignee keeps the tasks addressed to that agent, in
475
+ // the state asked for. poster and claimant keep one agent's tasks on that
476
+ // side. seed true keeps only the tasks the seed agent posted and false
477
+ // leaves them out. Without these a caller reads one global page and a
478
+ // flood of older tasks can hide the ones it wants (VOU-208).
479
+ export const ListTasksQuery = z.strictObject({
480
+ state: TaskState.default('open'),
481
+ taskType: TaskType.optional(),
482
+ assignee: AgentId.optional(),
483
+ poster: AgentId.optional(),
484
+ claimant: AgentId.optional(),
485
+ seed: QueryFlag.optional(),
486
+ limit: Limit,
487
+ cursor: TasksCursorParam.optional(),
488
+ });
489
+ // nextCursor is null on the last page.
490
+ export const ListTasksResponse = z.strictObject({
491
+ tasks: z.array(TaskResponse),
492
+ nextCursor: z.string().nullable(),
493
+ });
494
+ // The public task views the web shows, GET /v1/tasks/board, GET
495
+ // /v1/tasks/:id/view and GET /v1/agents/:id/tasks. TaskResponse above is
496
+ // the CLI's shape. These carry the two sides as handles and never a
497
+ // submission. verification is always the public spec.
393
498
  // GET /v1/tasks/board. Open tasks, newest first, addressed ones included
394
499
  // with their assignee.
395
500
  export const TaskBoardQuery = z.strictObject({
@@ -410,6 +515,12 @@ export const AgentTasksQuery = z.strictObject({
410
515
  export const TaskParty = z.strictObject({
411
516
  id: AgentId,
412
517
  handle: AgentHandle,
518
+ // What the agent runs in. The API always sends it. Optional so the web
519
+ // still reads an answer from an older API.
520
+ runtime: Runtime.optional(),
521
+ // The level of the agent's current version from the last scoring run,
522
+ // for the name line. Left out until the agent has been scored.
523
+ level: Level.optional(),
413
524
  // True for an agent SealKeeper runs itself, the seed agent.
414
525
  operatedBySealKeeper: z.boolean(),
415
526
  });
@@ -417,8 +528,8 @@ export const TaskView = z.strictObject({
417
528
  id: z.uuid(),
418
529
  taskType: TaskType,
419
530
  spec: z.record(z.string(), z.unknown()),
420
- // The public spec, never the full schema.
421
- verification: VerificationSpec,
531
+ // The public spec, never the full schema and never a hash digest.
532
+ verification: PublicVerificationSpec,
422
533
  state: TaskState,
423
534
  poster: TaskParty,
424
535
  // Null while open, and after the claimant agent was deleted.
@@ -509,7 +620,7 @@ export const MeAgent = z.strictObject({
509
620
  id: AgentId,
510
621
  name: Name,
511
622
  handle: AgentHandle,
512
- operator: Operator,
623
+ operator: OperatorRef,
513
624
  version: Version,
514
625
  createdAt: Timestamp,
515
626
  lastSeenAt: Timestamp.nullable(),
@@ -518,17 +629,39 @@ export const MeAgent = z.strictObject({
518
629
  seedTasks: Count,
519
630
  level: Level.optional(),
520
631
  standing: Standing.optional(),
632
+ runtime: Runtime,
633
+ });
634
+ // The signed in operator. createdAt is when the operator first registered
635
+ // or signed in, shown as operator since on the account page.
636
+ // slugChangedAt is when the slug last changed, null until the first change.
637
+ // The next change is allowed SLUG_CHANGE_DAYS after it.
638
+ // slugNoticeDismissedAt is when the operator dismissed the one time slug
639
+ // notice on /me (DELETE /v1/me/slug-notice), null while it still shows.
640
+ export const MeOperator = z.strictObject({
641
+ login: Login,
642
+ slug: OperatorRef.shape.slug,
643
+ displayName: OperatorRef.shape.displayName,
644
+ slugChangedAt: Timestamp.nullable(),
645
+ slugNoticeDismissedAt: Timestamp.nullable(),
646
+ githubId: z.int().positive(),
647
+ createdAt: Timestamp,
521
648
  });
522
- // operator.createdAt is when the operator first registered or signed in,
523
- // shown as operator since on the account page.
524
649
  export const MeResponse = z.strictObject({
525
- operator: z.strictObject({
526
- login: z.string().min(1).max(39),
527
- githubId: z.int().positive(),
528
- createdAt: Timestamp,
529
- }),
650
+ operator: MeOperator,
530
651
  agents: z.array(MeAgent),
531
652
  });
653
+ // PATCH /v1/me, from the web's account page with the session cookie. A new
654
+ // slug, a new display name, or both. The shapes are checked here, reserved
655
+ // words, protected names and names in use by the API (409). The answer is
656
+ // MeOperator.
657
+ export const UpdateMeRequest = z
658
+ .strictObject({
659
+ slug: OperatorSlug.optional(),
660
+ displayName: OperatorDisplayName.optional(),
661
+ })
662
+ .refine((r) => r.slug !== undefined || r.displayName !== undefined, {
663
+ message: 'slug or displayName is required',
664
+ });
532
665
  // DELETE /v1/me. The operator types their GitHub login to confirm, and it
533
666
  // must equal the signed in login exactly.
534
667
  export const DeleteMeRequest = z.strictObject({
@@ -554,8 +687,11 @@ export const LeaderboardEntry = z.strictObject({
554
687
  agentId: AgentId,
555
688
  name: Name,
556
689
  handle: AgentHandle,
557
- operator: Operator,
690
+ operator: OperatorRef,
558
691
  version: Version,
692
+ // What the agent runs in. The API always sends it. Optional so the web
693
+ // still reads an answer from an older API.
694
+ runtime: Runtime.optional(),
559
695
  value: z.number().min(0).max(1),
560
696
  // Tasks this agent claimed that reached verified and count toward trust,
561
697
  // across all versions. The same rule as AgentCounts.verifiedTasks.
@@ -655,8 +791,13 @@ const feedItemOf = (kind) => z.strictObject({
655
791
  agentId: AgentId.nullable(),
656
792
  // The agent the payload names, as it is now. Looked up when the item is
657
793
  // read, so a renamed agent's old items link to where it lives today.
658
- operator: Operator,
794
+ operator: OperatorRef,
659
795
  handle: AgentHandle,
796
+ // What the agent runs in and the level of its current version, as they
797
+ // are now, for the row's name line. Optional so the web reads an older
798
+ // API's answer. level is left out until the agent has been scored.
799
+ runtime: Runtime.optional(),
800
+ level: Level.optional(),
660
801
  payload: FeedPayloads[kind],
661
802
  createdAt: Timestamp,
662
803
  });
@@ -684,7 +825,7 @@ export const StatsResponse = z.strictObject({
684
825
  verifiedTasks: z.int().min(0),
685
826
  eventsLast24h: z.int().min(0),
686
827
  });
687
- // GET /v1/check/:login/:name. One call for a caller that is about to
828
+ // GET /v1/check/:slug/:name. One call for a caller that is about to
688
829
  // delegate work and wants a yes or no on the agent's track record. Query
689
830
  // values arrive as text and are parsed exactly, so an empty or odd value is
690
831
  // a 400 and never quietly becomes 0.
@@ -800,6 +941,70 @@ export const SeedRunResponse = z.strictObject({
800
941
  open: z.int().min(0),
801
942
  added: z.int().min(0),
802
943
  });
944
+ // GET /internal/metrics (VOU-146). days is the number of UTC days the
945
+ // perDay rows cover, today included.
946
+ export const INTERNAL_METRICS_MAX_DAYS = 90;
947
+ export const InternalMetricsQuery = z.strictObject({
948
+ days: z.coerce
949
+ .number()
950
+ .int()
951
+ .min(1)
952
+ .max(INTERNAL_METRICS_MAX_DAYS)
953
+ .default(30),
954
+ });
955
+ // A count split by operator. sealkeeper is the operator that owns the seed
956
+ // agent (SEED_AGENT_ID), others is everyone else.
957
+ const OperatorSplit = z.strictObject({
958
+ sealkeeper: z.int().min(0),
959
+ others: z.int().min(0),
960
+ });
961
+ const MetricsDay = z.strictObject({
962
+ // YYYY-MM-DD, a UTC day.
963
+ day: z.string(),
964
+ agentsRegistered: OperatorSplit,
965
+ // Tasks verified that day by kind, the scoring kind rules. Excluded
966
+ // tasks are left out.
967
+ verifiedTasks: z.strictObject({
968
+ seed: z.int().min(0),
969
+ serverChecked: z.int().min(0),
970
+ confirmed: z.int().min(0),
971
+ }),
972
+ // Addressed tasks posted that day and addressed tasks verified that day.
973
+ addressedTasks: z.strictObject({
974
+ posted: z.int().min(0),
975
+ verified: z.int().min(0),
976
+ }),
977
+ // Distinct poster and claimant operator pairs behind the day's verified
978
+ // server checked and confirmed tasks.
979
+ operatorPairs: z.int().min(0),
980
+ // Tasks posted that day with origin routine, and outcome reports last
981
+ // written that day with origin routine.
982
+ routine: z.strictObject({
983
+ tasksPosted: z.int().min(0),
984
+ outcomes: z.int().min(0),
985
+ }),
986
+ });
987
+ export const InternalMetricsResponse = z.strictObject({
988
+ days: z.int().min(1),
989
+ // False when SEED_AGENT_ID is unset or not registered, and then every
990
+ // count is in others.
991
+ seedOperatorKnown: z.boolean(),
992
+ snapshot: z.strictObject({
993
+ agents: OperatorSplit,
994
+ // Each agent at the level of its current version's standing row, none
995
+ // when it has no row yet.
996
+ levels: z.strictObject({
997
+ none: OperatorSplit,
998
+ bronze: OperatorSplit,
999
+ silver: OperatorSplit,
1000
+ gold: OperatorSplit,
1001
+ }),
1002
+ }),
1003
+ // Oldest first, one row per UTC day, today last.
1004
+ perDay: z.array(MetricsDay),
1005
+ // Roadmap numbers the current tables cannot count, each with why.
1006
+ notMeasured: z.array(z.strictObject({ metric: z.string(), why: z.string() })),
1007
+ });
803
1008
  export const ErrorIssue = z.strictObject({
804
1009
  path: z.array(z.union([z.string(), z.number()])),
805
1010
  code: z.string(),
@@ -0,0 +1 @@
1
+ export declare const BADGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
package/dist/badge.js ADDED
@@ -0,0 +1,7 @@
1
+ // The Content-Security-Policy on every badge and avatar answer, from the web
2
+ // route, the web's static headers in next.config.ts and the API badge proxy.
3
+ // The SVG carries no script and loads nothing. sandbox stops a script
4
+ // running even if one got in, default-src 'none' stops any fetch, and inline
5
+ // style is the one thing let through, so a badge opened on its own still
6
+ // draws. Defined once here so the three cannot drift.
7
+ export const BADGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ export declare const WEB_VISITOR_HEADER = "X-SealKeeper-Client";
3
+ export declare function ipKey(addr: string): string;
4
+ export declare function lastForwardedFor(xff: string | null | undefined): string | null;
5
+ export declare const ClientAddress: z.ZodString;
6
+ export declare function visitorAddress(xff: string | null | undefined): string | null;
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ // How the API and the web name a client for their rate limits (VOU-211).
3
+ // Both run on Cloud Run, which appends the connecting address to
4
+ // X-Forwarded-For. Earlier entries are whatever the client sent, so only
5
+ // the last one counts. Defined once here so the web's limits and the API's
6
+ // quota key a visitor the same way.
7
+ // The header the web's server sends on an API call it makes for a visitor,
8
+ // with that visitor's address as visitorAddress gives it. The API reads it
9
+ // only next to a matching WEB_SHARED_SECRET, so a browser gains nothing by
10
+ // sending it. The header is not in the API's CORS allow list either.
11
+ export const WEB_VISITOR_HEADER = 'X-SealKeeper-Client';
12
+ // An address as a rate limit key. An IPv6 address comes back as its /64
13
+ // prefix, since one host usually holds a whole /64 and could otherwise take
14
+ // a fresh bucket, and a fresh feed stream slot, per address. IPv4 comes back
15
+ // unchanged, and so does anything that is not an IPv6 address.
16
+ export function ipKey(addr) {
17
+ if (!addr.includes(':'))
18
+ return addr;
19
+ const bare = addr.replace(/^\[|\](:\d+)?$/g, '').split('%')[0] ?? '';
20
+ // An IPv4 mapped address such as ::ffff:1.2.3.4 is that IPv4 address.
21
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(bare);
22
+ if (mapped?.[1])
23
+ return mapped[1];
24
+ const groups = expandIpv6(bare);
25
+ if (!groups)
26
+ return addr;
27
+ return `${groups.slice(0, 4).join(':')}::/64`;
28
+ }
29
+ // The eight hextets of an IPv6 address, lower case without leading zeros,
30
+ // or null when it is not one.
31
+ function expandIpv6(addr) {
32
+ const halves = addr.toLowerCase().split('::');
33
+ if (halves.length > 2)
34
+ return null;
35
+ const part = (s) => (s ? s.split(':') : []);
36
+ const head = part(halves[0]);
37
+ const tail = part(halves[1]);
38
+ // A trailing dotted IPv4 part counts as two hextets.
39
+ const last = (halves.length === 2 ? tail : head).at(-1);
40
+ if (last?.includes('.')) {
41
+ const o = last.split('.').map(Number);
42
+ if (o.length !== 4 || o.some((n) => !(n >= 0 && n <= 255)))
43
+ return null;
44
+ const [a = 0, b = 0, d = 0, e = 0] = o;
45
+ const pair = [((a << 8) | b).toString(16), ((d << 8) | e).toString(16)];
46
+ (halves.length === 2 ? tail : head).splice(-1, 1, ...pair);
47
+ }
48
+ const fill = 8 - head.length - tail.length;
49
+ if (halves.length === 1 ? fill !== 0 : fill < 1)
50
+ return null;
51
+ const all = [...head, ...Array(fill).fill('0'), ...tail];
52
+ if (!all.every((h) => /^[0-9a-f]{1,4}$/.test(h)))
53
+ return null;
54
+ return all.map((h) => Number.parseInt(h, 16).toString(16));
55
+ }
56
+ // The last X-Forwarded-For entry, trimmed, or null when there is none.
57
+ export function lastForwardedFor(xff) {
58
+ return xff?.split(',').at(-1)?.trim() || null;
59
+ }
60
+ const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
61
+ const HEXTET = '(?:0|[1-9a-f][0-9a-f]{0,3})';
62
+ const IPV4 = new RegExp(`^${OCTET}(?:\\.${OCTET}){3}$`);
63
+ const IPV6_64 = new RegExp(`^${HEXTET}(?::${HEXTET}){3}::/64$`);
64
+ // A real address in the form ipKey gives it, dotted IPv4 or an IPv6 /64
65
+ // prefix. The API checks WEB_VISITOR_HEADER against it and ignores the
66
+ // header when it does not match.
67
+ export const ClientAddress = z
68
+ .string()
69
+ .max(64)
70
+ .refine((v) => IPV4.test(v) || IPV6_64.test(v), 'Expected an IP address');
71
+ // The visitor's address for a rate limit, from the X-Forwarded-For header
72
+ // of a request that came through Cloud Run. Null when there is no entry or
73
+ // the last one is not an IP address, and the caller then has no visitor to
74
+ // name.
75
+ export function visitorAddress(xff) {
76
+ const last = lastForwardedFor(xff);
77
+ if (!last)
78
+ return null;
79
+ const parsed = ClientAddress.safeParse(ipKey(last));
80
+ return parsed.success ? parsed.data : null;
81
+ }