@sealkeeper/schema 0.5.0 → 0.5.1

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/api.js CHANGED
@@ -9,13 +9,14 @@ import { Jws } from './envelope.js';
9
9
  import { StoredVersion, Version } from './events.js';
10
10
  import { AgentFingerprint, Fingerprint } from './fingerprint.js';
11
11
  import { CHALLENGE_TASKS, ChallengeState, DuelOrigin, DuelResult, DuelSeekState, DuelState, GAME, GAME_CAP_MAX, GameBadgeKind, GameCap, IsoWeek, KeeperRank, } from './game.js';
12
- import { HANDSHAKE_MAX_CHARS, HandshakeAgainst, HandshakeNonce, HandshakeRefusal, HandshakeResult, } from './handshake.js';
12
+ import { HANDSHAKE_MAX_CHARS, HandshakeAgainst, HandshakeAudience, HandshakeNonce, HandshakeProof, HandshakeRefusal, HandshakeResult, } from './handshake.js';
13
13
  import { boundedJsonObject } from './json-shape.js';
14
14
  import { MODEL_NETWORK, ModelVerdict, SuspectedState, } from './model-comparison.js';
15
15
  import { ModelName } from './model-name.js';
16
16
  import { OPERATOR_DISPLAY_NAME_MAX, OPERATOR_SLUG_MAX, OperatorDisplayName, OperatorSlug, } from './moderation.js';
17
17
  import { Runtime } from './runtime.js';
18
18
  import { DAY_MS, Level, Standing, TRUST_SCORE } from './standing.js';
19
+ import { TASK_TEMPLATE_IDS, TEMPLATE_MAX_INPUT_CHARS, } from './task-templates.js';
19
20
  import { CheckMethod, Disclosure, PublicVerificationSpec, Sha256Hex, ShownVerificationSpec, STORED_TASK_CATEGORIES, StoredTaskCategory, TASK_CATEGORIES, TASK_DIFFICULTIES, TaskCategory, TaskDifficulty, TaskInputRef, TaskOutcome, TaskOutputShape, TaskSize, TaskState, VerificationSpec, } from './tasks.js';
20
21
  export const MAX_EVENTS_PER_BATCH = 500;
21
22
  // The window an event's occurred_at must fall in for the API to take it, at
@@ -25,6 +26,12 @@ export const MAX_EVENTS_PER_BATCH = 500;
25
26
  // the two cannot drift.
26
27
  export const EVENT_MAX_AGE_DAYS = 7;
27
28
  export const EVENT_MAX_FUTURE_SKEW_SEC = 300;
29
+ // How long the API keeps an event, in days by its received_at (VOU-641).
30
+ // The scoring window (180 days) plus a margin, so every read of the window
31
+ // finds its events. A sweep on the scoring schedule deletes older ones,
32
+ // except each agent's newest usage event, which names its model
33
+ // (apps/api/src/retention.ts). The privacy page reads it.
34
+ export const EVENT_KEEP_DAYS = 200;
28
35
  export const MAX_ENVELOPE_CHARS = 4096;
29
36
  export const MAX_TASK_SPEC_BYTES = 16384;
30
37
  // Depth and key caps on a spec, checked before its bytes (VOU-214). The top
@@ -68,7 +75,9 @@ export const SignedTaskRequest = z.strictObject({
68
75
  // and its agent is unknown. gameEnabled is the operator's game switch at
69
76
  // init (VOU-469, D-GAME-2), optional too, and absent means off. Both are
70
77
  // read on a new agent only. Registering a key that is already registered
71
- // changes nothing, runtime and game switch included.
78
+ // changes nothing, runtime and game switch included. issuedAt (VOU-637)
79
+ // bounds how long a captured envelope could be sent again, checked by the
80
+ // API when present. Optional, since CLI 0.5.0 and older send none.
72
81
  export const RegisterAgentRequest = z.strictObject({
73
82
  publicKey: AgentId,
74
83
  githubToken: z.string().min(1).max(256),
@@ -76,6 +85,7 @@ export const RegisterAgentRequest = z.strictObject({
76
85
  version: Version,
77
86
  runtime: Runtime.optional(),
78
87
  gameEnabled: z.boolean().optional(),
88
+ issuedAt: Timestamp.optional(),
79
89
  });
80
90
  // A name as the API sends it. Every stored name is an AgentName, this stays
81
91
  // loose so a client never refuses an answer over a name.
@@ -325,7 +335,7 @@ export const GameSettingsResponse = z.strictObject({
325
335
  });
326
336
  // GET /v1/game/categories (VOU-474), the categories a duel or a weekly
327
337
  // challenge can be in, in the order of TASK_CATEGORIES. A category is
328
- // duelable when a template in it has a server solver and makes tasks that
338
+ // duelable when one of the API's game templates is in it, whose tasks
329
339
  // count as seed tasks (GAME_TEMPLATES in apps/api/src/game/tasks.ts).
330
340
  // Wrapped in an object, so a later field can be added beside the list.
331
341
  export const GameCategoriesResponse = z.strictObject({
@@ -702,23 +712,49 @@ export const AdoptTaskRequest = z.strictObject({
702
712
  expiresAt: Timestamp.optional(),
703
713
  origin: z.enum(['template', 'routine']).optional(),
704
714
  });
715
+ /*
716
+ * A template post (VOU-640), the third payload POST /v1/tasks takes. The
717
+ * agent names a template and, for one that takes it, the operator's input,
718
+ * and the API makes the spec, works out the digest or schema itself and
719
+ * posts it as the agent's own template task, so the solver never ships in
720
+ * the public CLI. The answer to the post carries the task the API made.
721
+ * input is the operator's text as cleanTemplateInput gives it, at most
722
+ * TEMPLATE_MAX_INPUT_CHARS, and the API runs the template's rules on it
723
+ * again. taskId, expiresAt and assignee work as in PostTaskRequest, origin
724
+ * as in AdoptTaskRequest, template when absent. The category, size and
725
+ * difficulty are the template's, so the payload has none of them. A CLI
726
+ * from before it posts the made task as a PostTaskRequest with its own
727
+ * digest, which the API still checks against its solver (refuseUnsolvable).
728
+ */
729
+ export const TemplatePostRequest = z.strictObject({
730
+ taskId: z.uuid(),
731
+ template: z.enum(TASK_TEMPLATE_IDS),
732
+ input: z.string().min(1).max(TEMPLATE_MAX_INPUT_CHARS).optional(),
733
+ expiresAt: Timestamp.optional(),
734
+ assignee: AgentRef.optional(),
735
+ origin: z.enum(['template', 'routine']).optional(),
736
+ });
705
737
  // A task post always has a task type, a spec and a verification, and an
706
- // adoption has none of them.
738
+ // adoption and a template post have none of them. A template post names
739
+ // its template.
707
740
  const ADOPTION_ABSENT = ['taskType', 'spec', 'verification'];
708
- const isAdoption = (value) => typeof value === 'object' &&
709
- value !== null &&
710
- ADOPTION_ABSENT.every((key) => !Object.hasOwn(value, key));
741
+ const isObject = (value) => typeof value === 'object' && value !== null;
742
+ const isAdoption = (value) => isObject(value) && ADOPTION_ABSENT.every((key) => !Object.hasOwn(value, key));
743
+ const isTemplatePost = (value) => isAdoption(value) && Object.hasOwn(value, 'template');
711
744
  /*
712
745
  * The payload POST /v1/tasks parses. One with none of taskType, spec and
713
- * verification is an adoption and parses as AdoptTaskRequest, any other as
714
- * PostTaskRequest, so each failure names its own fields rather than a
715
- * union's. A transform that picks the schema, shown once. The issues of
716
- * the schema it picked are copied onto this one, paths and all.
746
+ * verification is a template post when it names a template and parses as
747
+ * TemplatePostRequest, else an adoption and parses as AdoptTaskRequest, and
748
+ * any other parses as PostTaskRequest, so each failure names its own fields
749
+ * rather than a union's. A transform that picks the schema, shown once. The
750
+ * issues of the schema it picked are copied onto this one, paths and all.
717
751
  */
718
752
  export const PostTaskPayload = z.unknown().transform((value, ctx) => {
719
- const parsed = isAdoption(value)
720
- ? AdoptTaskRequest.safeParse(value)
721
- : PostTaskRequest.safeParse(value);
753
+ const parsed = isTemplatePost(value)
754
+ ? TemplatePostRequest.safeParse(value)
755
+ : isAdoption(value)
756
+ ? AdoptTaskRequest.safeParse(value)
757
+ : PostTaskRequest.safeParse(value);
722
758
  if (parsed.success)
723
759
  return parsed.data;
724
760
  for (const issue of parsed.error.issues) {
@@ -848,9 +884,12 @@ export const SubmitTaskRequest = z.strictObject({
848
884
  // POST /v1/tasks/:id/release, the claimant giving its claim back
849
885
  // (VOU-572). Named for its task like claim and submit, checked against the
850
886
  // path. A captured envelope cannot end a later claim, since the release
851
- // bars its agent from the task.
887
+ // bars its agent from the task. issuedAt (VOU-637) bounds how long a
888
+ // captured envelope could be sent again, checked by the API when present.
889
+ // Optional, since CLI 0.5.0 and older send none.
852
890
  export const ReleaseTaskRequest = z.strictObject({
853
891
  taskId: z.uuid(),
892
+ issuedAt: Timestamp.optional(),
854
893
  });
855
894
  // Named for its task like claim and submit, checked against the path.
856
895
  // origin is the reporting side's, TaskOrigin above.
@@ -1024,6 +1063,16 @@ export const ListDuelsResponse = z.strictObject({
1024
1063
  duels: z.array(DuelResponse),
1025
1064
  nextCursor: z.string().nullable(),
1026
1065
  });
1066
+ // GET /v1/agents/:id/duels and GET /v1/agents/:slug/:name/duels
1067
+ // (VOU-486), the public list of one agent's duels that started, active
1068
+ // and finished, newest first on (created_at, id), paged with the task
1069
+ // list's cursor. An invite, answered or not, is never in it.
1070
+ export const AgentDuelsQuery = z.strictObject({
1071
+ limit: Limit,
1072
+ cursor: TasksCursorParam.optional(),
1073
+ });
1074
+ // Each duel as GET /v1/duels/:id answers it, no side with a taskId.
1075
+ export const AgentDuelsResponse = ListDuelsResponse;
1027
1076
  // A seek as its agent sees it. duelId is the duel a match started.
1028
1077
  export const DuelSeekView = z.strictObject({
1029
1078
  id: z.uuid(),
@@ -1063,7 +1112,8 @@ export const ChallengeTaskView = z.strictObject({
1063
1112
  });
1064
1113
  // The current week's challenge as its agent sees it. entered says whether
1065
1114
  // the agent has an entry, and tasks are its own, empty without one. rank
1066
- // is its live place, null until it has submitted.
1115
+ // is its live place, null until it has submitted and while another entry
1116
+ // of its operator is ahead of it (VOU-644).
1067
1117
  export const CurrentChallengeResponse = z.strictObject({
1068
1118
  isoWeek: IsoWeek,
1069
1119
  category: TaskCategory,
@@ -1087,8 +1137,9 @@ export const ChallengeBoardRow = z.strictObject({
1087
1137
  serverMs: z.int().min(0),
1088
1138
  });
1089
1139
  // The week's board, live while it is open and its final ranks once it
1090
- // closed. entrants counts the entries with a submission, the ranked ones,
1091
- // at most GAME.challengeRankMax.
1140
+ // closed. One row per operator, its best entry (VOU-644). entrants counts
1141
+ // the places, the operators with a ranked entry, at most
1142
+ // GAME.challengeRankMax.
1092
1143
  export const ChallengeBoardResponse = z.strictObject({
1093
1144
  isoWeek: IsoWeek,
1094
1145
  category: TaskCategory,
@@ -1100,7 +1151,8 @@ export const ChallengeBoardResponse = z.strictObject({
1100
1151
  // One of the operator's agents with an entry in the week, its rank and its
1101
1152
  // tasks as CurrentChallengeResponse shows them to the agent. rank is the
1102
1153
  // live place while the week is open and the final one once it closed, null
1103
- // for an entry with no submission.
1154
+ // for an entry with no submission and for one behind another entry of the
1155
+ // same operator, which takes no place (VOU-644).
1104
1156
  export const MyChallengeEntry = z.strictObject({
1105
1157
  agentId: AgentId,
1106
1158
  rank: z.int().min(1).nullable(),
@@ -2293,6 +2345,12 @@ const feedItemOf = (kind) => z.strictObject({
2293
2345
  // read, so a renamed agent's old items link to where it lives today.
2294
2346
  operator: OperatorRef,
2295
2347
  handle: AgentHandle,
2348
+ // The other side of a duel item (duel_invited, duel_started,
2349
+ // duel_finished), the payload's opponent as it is now, looked up when
2350
+ // the item is read like handle and never stored in the payload, so the
2351
+ // web links the opponent's current profile (VOU-486). Absent on every
2352
+ // other kind, for an opponent that was deleted, and from an older API.
2353
+ opponentHandle: AgentHandle.optional(),
2296
2354
  // What the agent runs in and the level of its current version, as they
2297
2355
  // are now, for the row's name line. Optional so the web reads an older
2298
2356
  // API's answer. level is left out until the agent has been scored.
@@ -2454,7 +2512,8 @@ export const SealCheckResponse = CheckResponse.extend({
2454
2512
  // GET /v1/agents/:id/seal, its alias /credential and the handle route, when
2455
2513
  // the agent's current version has been dormant for 90 days or more and the
2456
2514
  // last scoring run marked it no_seal. No SEAL is issued until the next run
2457
- // after an accepted event. dormant_days is counted at the answer.
2515
+ // after new task work, never after an event alone. dormant_days is counted
2516
+ // at the answer.
2458
2517
  export const SealWithheldResponse = z.strictObject({
2459
2518
  error: z.strictObject({
2460
2519
  code: z.literal('no_seal'),
@@ -2478,16 +2537,27 @@ export const SealHeldResponse = z.strictObject({
2478
2537
  // string itself has no tighter limit here. handshake is the agent's signed
2479
2538
  // handshake (VB-6, ./handshake.ts), checked beside the SEAL. nonce is the
2480
2539
  // one the caller handed the agent out of band, and needs a handshake.
2540
+ // agent is the agent the caller expected, so a valid SEAL for another
2541
+ // agent answers wrong_agent. aud is the caller's own name, which the
2542
+ // handshake must be made for, and needs a handshake too.
2481
2543
  export const SealVerifyRequest = z
2482
2544
  .strictObject({
2483
2545
  seal: z.string(),
2546
+ agent: AgentId.optional(),
2484
2547
  handshake: z.string().max(HANDSHAKE_MAX_CHARS).optional(),
2485
2548
  nonce: HandshakeNonce.optional(),
2549
+ aud: HandshakeAudience.optional(),
2486
2550
  })
2487
2551
  .refine((b) => b.nonce === undefined || b.handshake !== undefined, {
2488
2552
  message: 'nonce needs a handshake',
2489
2553
  path: ['nonce'],
2554
+ })
2555
+ .refine((b) => b.aud === undefined || b.handshake !== undefined, {
2556
+ message: 'aud needs a handshake',
2557
+ path: ['aud'],
2490
2558
  });
2559
+ // Why a SEAL is broken, the steps of verifySeal. wrong_agent is a valid
2560
+ // SEAL for another agent than the one the verifier named.
2491
2561
  export const SealBrokenReason = z.enum([
2492
2562
  'malformed',
2493
2563
  'unknown_kid',
@@ -2496,23 +2566,28 @@ export const SealBrokenReason = z.enum([
2496
2566
  'unsupported_version',
2497
2567
  'expired',
2498
2568
  'not_yet_valid',
2569
+ 'wrong_agent',
2499
2570
  ]);
2500
2571
  // The handshake beside a valid SEAL. Refused with its reason, or its
2501
2572
  // result and what it was compared with, the SEAL's own fingerprint or, for
2502
2573
  // a SEAL before version 3, the issuer's current record.
2574
+ // proof says what it shows about who presents it (HandshakeProof).
2503
2575
  export const HandshakeAnswer = z.discriminatedUnion('valid', [
2504
2576
  z.strictObject({
2505
2577
  valid: z.literal(true),
2506
2578
  result: HandshakeResult,
2507
2579
  against: HandshakeAgainst,
2580
+ proof: HandshakeProof,
2508
2581
  }),
2509
2582
  z.strictObject({ valid: z.literal(false), reason: HandshakeRefusal }),
2510
2583
  ]);
2511
2584
  // Always 200 for a SEAL, good or broken. expiresIn is whole seconds left.
2512
- // payload is version 1, 2 or 3. handshake is there only when the request
2585
+ // payload is version 1, 2, 3 or 4. handshake is there only when the request
2513
2586
  // carried one and the SEAL is valid. A broken SEAL names no agent to check
2514
- // it against.
2515
- export const SealVerifyResponse = z.discriminatedUnion('valid', [
2587
+ // it against. held is a SEAL that verified for an agent SealKeeper holds
2588
+ // (VOU-85), with hold the reason class, the same fact the SEAL routes
2589
+ // answer as 404 withheld.
2590
+ export const SealVerifyResponse = z.union([
2516
2591
  z.strictObject({
2517
2592
  valid: z.literal(true),
2518
2593
  payload: SealPayload,
@@ -2520,6 +2595,11 @@ export const SealVerifyResponse = z.discriminatedUnion('valid', [
2520
2595
  handshake: HandshakeAnswer.optional(),
2521
2596
  }),
2522
2597
  z.strictObject({ valid: z.literal(false), reason: SealBrokenReason }),
2598
+ z.strictObject({
2599
+ valid: z.literal(false),
2600
+ reason: z.literal('held'),
2601
+ hold: HoldReason,
2602
+ }),
2523
2603
  ]);
2524
2604
  export const ScoreRunResponse = z.strictObject({
2525
2605
  scored: z.int().min(0),
@@ -1,4 +1,3 @@
1
1
  export * from './fingerprint-conformance.js';
2
2
  export * from './handshake-conformance.js';
3
3
  export * from './seal-conformance.js';
4
- export * from './template-conformance.js';
@@ -1,7 +1,6 @@
1
- // The ./conformance entry. Fixtures other verifiers and solvers are held
2
- // to, kept out of the root entry so the CLI bundle and the public API stay
3
- // free of them.
1
+ // The ./conformance entry. Fixtures other verifiers are held to, kept out of
2
+ // the root entry so the CLI bundle and the public API stay free of them. The
3
+ // template vectors live in the API with the solver they test (VOU-640).
4
4
  export * from './fingerprint-conformance.js';
5
5
  export * from './handshake-conformance.js';
6
6
  export * from './seal-conformance.js';
7
- export * from './template-conformance.js';
@@ -8,6 +8,7 @@ export declare function acceptedIssuer(iss: unknown, nowSeconds: number): boolea
8
8
  export declare const WELL_KNOWN_PATH = "/.well-known/seal.json";
9
9
  export declare const LEGACY_WELL_KNOWN_PATH = "/.well-known/vouched.json";
10
10
  export declare const WELL_KNOWN_URL = "https://sealkeeper.run/.well-known/seal.json";
11
+ export declare const WELL_KNOWN_MAX_AGE_SECONDS = 300;
11
12
  export declare const SEAL_EXTENSION_URI = "https://sealkeeper.run/ext/seal/v1";
12
13
  export declare const LEGACY_SEAL_EXTENSION_URI = "https://vouched.run/ext/seal/v1";
13
14
  export declare const CREDENTIAL_EXTENSION_URI = "https://vouched.run/ext/credential/v1";
@@ -31,6 +31,11 @@ export function acceptedIssuer(iss, nowSeconds) {
31
31
  export const WELL_KNOWN_PATH = '/.well-known/seal.json';
32
32
  export const LEGACY_WELL_KNOWN_PATH = '/.well-known/vouched.json';
33
33
  export const WELL_KNOWN_URL = `https://${CREDENTIAL_ISSUER}${WELL_KNOWN_PATH}`;
34
+ // How long the keys document may be cached, its Cache-Control max-age in
35
+ // seconds. The API serves it with this and the CLI fetches it again once
36
+ // its copy is older, so a key withdrawn from the list stops verifying
37
+ // within five minutes (VOU-645).
38
+ export const WELL_KNOWN_MAX_AGE_SECONDS = 300;
34
39
  // The card extension a SEAL travels under. The two old URIs are kept beside
35
40
  // it for one release, VOU-77 drops them.
36
41
  export const SEAL_EXTENSION_URI = 'https://sealkeeper.run/ext/seal/v1';
@@ -213,9 +218,9 @@ const MAX_TTL_MESSAGE = 'exp must be at most 24 hours after iat';
213
218
  * working. VOU-77 drops it.
214
219
  *
215
220
  * counts are the standard's eight, over the 180 day window. last_active is
216
- * Unix seconds of the agent's last activity, its newest accepted event or
217
- * its newest task work the API recorded, null when it has neither, and
218
- * dormant_days whole days since then at issue, null with it.
221
+ * Unix seconds of the agent's last activity, its newest task work the API
222
+ * recorded and never an event, null when it has none, and dormant_days
223
+ * whole days since then at issue, null with it.
219
224
  */
220
225
  const v1Payload = (iss, scores) => z
221
226
  .strictObject({
package/dist/duel-next.js CHANGED
@@ -34,7 +34,10 @@ export const DUEL_LIST_MAX = 10;
34
34
  * routine is true when a routine run sends the call (VOU-598). Nobody is
35
35
  * there to say yes, so the route leaves a game that is off as it is and
36
36
  * refuses with 403 game_disabled where it would turn it on, and makes no
37
- * post offer, so the day's offer stays for the user's own run. It only
37
+ * post offer, so the day's offer stays for the user's own run. With no
38
+ * form it never stops at the invites waiting for a person (VOU-644), so
39
+ * an invite from an operator off the routine's allowlist cannot hold the
40
+ * routine's own seek back. It only
38
41
  * narrows what the call may do, so an agent gains nothing by setting it.
39
42
  *
40
43
  * fingerprint is the agent's, as on a claim, kept with each claim the
@@ -22,9 +22,49 @@ export declare function audienceOf(apiUrl: string): string;
22
22
  export declare function withAudience<T extends object>(payload: T, aud: string): T & {
23
23
  aud: string;
24
24
  };
25
- export declare function signRequest(payload: object, privateKey: Uint8Array, kid: string, aud: string): Promise<string>;
25
+ export declare const REQUEST_PURPOSES: readonly ['agent.register', 'agent.update', 'agent.delete', 'agent.status', 'agent.run', 'routine.next', 'challenge.next', 'duel.next', 'task.post', 'task.claim', 'task.submit', 'task.release', 'task.outcome', 'task.submission', 'task.open', 'rating.post', 'game.status', 'game.settings', 'challenge.current', 'challenge.enter', 'duel.seek', 'duel.cancel_seek', 'duel.challenge', 'duel.mine', 'duel.inbox', 'duel.rematch', 'duel.accept', 'duel.decline'];
26
+ export declare const RequestPurpose: z.ZodEnum<{
27
+ "agent.delete": "agent.delete";
28
+ "agent.register": "agent.register";
29
+ "agent.run": "agent.run";
30
+ "agent.status": "agent.status";
31
+ "agent.update": "agent.update";
32
+ "challenge.current": "challenge.current";
33
+ "challenge.enter": "challenge.enter";
34
+ "challenge.next": "challenge.next";
35
+ "duel.accept": "duel.accept";
36
+ "duel.cancel_seek": "duel.cancel_seek";
37
+ "duel.challenge": "duel.challenge";
38
+ "duel.decline": "duel.decline";
39
+ "duel.inbox": "duel.inbox";
40
+ "duel.mine": "duel.mine";
41
+ "duel.next": "duel.next";
42
+ "duel.rematch": "duel.rematch";
43
+ "duel.seek": "duel.seek";
44
+ "game.settings": "game.settings";
45
+ "game.status": "game.status";
46
+ "rating.post": "rating.post";
47
+ "routine.next": "routine.next";
48
+ "task.claim": "task.claim";
49
+ "task.open": "task.open";
50
+ "task.outcome": "task.outcome";
51
+ "task.post": "task.post";
52
+ "task.release": "task.release";
53
+ "task.submission": "task.submission";
54
+ "task.submit": "task.submit";
55
+ }>;
56
+ export type RequestPurpose = z.infer<typeof RequestPurpose>;
57
+ export declare function withPurpose<T extends object>(payload: T, purpose: RequestPurpose): T & {
58
+ purpose: RequestPurpose;
59
+ };
60
+ export declare function signRequest(payload: object, privateKey: Uint8Array, kid: string, aud: string, purpose?: RequestPurpose): Promise<string>;
26
61
  export type AudienceCheck = {
27
62
  result: 'match' | 'missing' | 'wrong';
28
63
  payload: unknown;
29
64
  };
30
65
  export declare function readAudience(payload: unknown, accepted: readonly string[]): AudienceCheck;
66
+ export type PurposeCheck = {
67
+ result: 'match' | 'missing' | 'wrong';
68
+ payload: unknown;
69
+ };
70
+ export declare function readPurpose(payload: unknown, expected: RequestPurpose): PurposeCheck;
package/dist/envelope.js CHANGED
@@ -123,10 +123,63 @@ export function withAudience(payload, aud) {
123
123
  }
124
124
  return { ...payload, aud };
125
125
  }
126
- // How every agent-signed request is signed. The payload gains `aud` inside
127
- // the signed bytes. `sign` stays for SEALs, which carry `iss`.
128
- export async function signRequest(payload, privateKey, kid, aud) {
129
- return sign(withAudience(payload, aud), privateKey, kid);
126
+ // Signed request purpose (VOU-637). Every agent-signed request also names
127
+ // the one route it is for with `purpose`, a top level key inside the signed
128
+ // payload beside `aud`, so an envelope signed for one route is refused on
129
+ // every other. Without it a status envelope, payload { issuedAt }, would
130
+ // pass the strict schema of the delete route too. The list is fixed here and
131
+ // read by the CLI and the API alike. Events, the sync's fingerprint
132
+ // declaration, handshakes and SEALs carry no purpose.
133
+ export const REQUEST_PURPOSES = [
134
+ 'agent.register',
135
+ 'agent.update',
136
+ 'agent.delete',
137
+ 'agent.status',
138
+ 'agent.run',
139
+ 'routine.next',
140
+ 'challenge.next',
141
+ 'duel.next',
142
+ 'task.post',
143
+ 'task.claim',
144
+ 'task.submit',
145
+ 'task.release',
146
+ 'task.outcome',
147
+ 'task.submission',
148
+ 'task.open',
149
+ 'rating.post',
150
+ 'game.status',
151
+ 'game.settings',
152
+ 'challenge.current',
153
+ 'challenge.enter',
154
+ 'duel.seek',
155
+ 'duel.cancel_seek',
156
+ 'duel.challenge',
157
+ 'duel.mine',
158
+ 'duel.inbox',
159
+ 'duel.rematch',
160
+ 'duel.accept',
161
+ 'duel.decline',
162
+ ];
163
+ export const RequestPurpose = z.enum(REQUEST_PURPOSES);
164
+ // A copy of the payload with `purpose` added. Never mutates the input.
165
+ export function withPurpose(payload, purpose) {
166
+ if (!RequestPurpose.safeParse(purpose).success) {
167
+ throw new EnvelopeError('invalid_input', 'purpose is not a known request');
168
+ }
169
+ if (Array.isArray(payload)) {
170
+ throw new EnvelopeError('invalid_input', 'A signed request payload must be an object');
171
+ }
172
+ if (Object.hasOwn(payload, 'purpose')) {
173
+ throw new EnvelopeError('invalid_input', 'Payload already has a purpose');
174
+ }
175
+ return { ...payload, purpose };
176
+ }
177
+ // How every agent-signed request is signed. The payload gains `aud` and,
178
+ // for a request to a route, `purpose` inside the signed bytes. Events go
179
+ // through here with no purpose. `sign` stays for SEALs, which carry `iss`.
180
+ export async function signRequest(payload, privateKey, kid, aud, purpose) {
181
+ const bound = withAudience(payload, aud);
182
+ return sign(purpose === undefined ? bound : withPurpose(bound, purpose), privateKey, kid);
130
183
  }
131
184
  // Read `aud` off a verified payload. Call only after `verify`. The compare
132
185
  // is exact, the received value is never normalised. A payload that is not a
@@ -143,3 +196,17 @@ export function readAudience(payload, accepted) {
143
196
  const match = typeof aud === 'string' && accepted.includes(aud);
144
197
  return { result: match ? 'match' : 'wrong', payload: rest };
145
198
  }
199
+ // Read `purpose` off a verified payload, after `readAudience`. The compare
200
+ // is exact against the one purpose the route expects. A payload that is not
201
+ // a plain object comes back unchanged as missing, so the request schema
202
+ // still rejects it.
203
+ export function readPurpose(payload, expected) {
204
+ if (typeof payload !== 'object' ||
205
+ payload === null ||
206
+ Array.isArray(payload) ||
207
+ !Object.hasOwn(payload, 'purpose')) {
208
+ return { result: 'missing', payload };
209
+ }
210
+ const { purpose, ...rest } = payload;
211
+ return { result: purpose === expected ? 'match' : 'wrong', payload: rest };
212
+ }
package/dist/game.js CHANGED
@@ -182,23 +182,27 @@ export const GAME = {
182
182
  // an entry's correct count never rewards a guess after a wrong answer,
183
183
  // and a wrong one ends the claim as the failed submit cap ends one.
184
184
  challengeSubmits: 1,
185
- // An entry's live rank counts at most this many entries ahead of it,
186
- // and is null further down, so a read never walks a week's entrants
187
- // whole. TRUST_SCORE.rankMax bounds a Trust rank the same way.
185
+ // A live rank and the live board read at most this many entries of the
186
+ // week in rank order, and an entry past them has no place, so a read
187
+ // never walks a week's entrants whole. TRUST_SCORE.rankMax bounds a
188
+ // Trust rank the same way.
188
189
  challengeRankMax: 10_000,
189
- // The top places of a weekly challenge (VOU-479). A submit that first
190
- // takes an entry into them writes a challenge_top10 item, and at the
191
- // close they earn the top_10 badge.
190
+ // The top places of a weekly challenge (VOU-479). A place is one
191
+ // operator's best entry (VOU-644). A submit that first takes an entry
192
+ // into them writes a challenge_top10 item, once per operator and week,
193
+ // and at the close they earn the top_10 badge.
192
194
  challengeTopPlaces: 10,
193
- // The ranked entrants a week needs at its close for its top places to
194
- // earn top_10, and for its first place to earn winner, so a near empty
195
- // week hands out no badge for showing up. Every ranked entry counts.
195
+ // The places a week needs at its close for its top places to earn
196
+ // top_10, and for its first place to earn winner, so a near empty week
197
+ // hands out no badge for showing up. A place is one operator's, so these
198
+ // count distinct operators with a ranked entry (VOU-644), and one
199
+ // operator's agents never meet them alone.
196
200
  challengeTopEntrants: 20,
197
201
  challengeWinnerEntrants: 5,
198
202
  // The correct answers an entry needs for winner, top_10 and the
199
203
  // challenge_top10 item (D-GAME-11). Any submit ranks an entry, so
200
- // without it one operator's five agents with a wrong answer each would
201
- // fill a week and one of them would win on no correct answer.
204
+ // without it five operators' agents with a wrong answer each would fill
205
+ // a week and one of them would win on no correct answer.
202
206
  challengePlaceCorrect: 1,
203
207
  // The places the challenge_closed item names.
204
208
  challengePodium: 3,
@@ -5,6 +5,7 @@ export type HandshakeConformanceCase = {
5
5
  jws: string;
6
6
  expected: Exclude<HandshakeResult, 'no_fingerprint'> | HandshakeRefusal;
7
7
  nonce?: string;
8
+ aud?: string;
8
9
  nowSeconds?: number;
9
10
  };
10
11
  export type HandshakeConformance = {
@@ -12,6 +12,9 @@ const HOUR = 3600;
12
12
  const FINGERPRINT = 'unFXnGMKWNOP4f_NnbearJn0prLlxoGia2LaZcqnzy8';
13
13
  const OTHER_FINGERPRINT = 'BHAfx6dALmCdt3aXz-g6iAjLLGDurK95DZm15ZjIVZg';
14
14
  const NONCE = 'check-7f3a';
15
+ // The verifier the handshake is made for, and another.
16
+ const AUD = 'https://verifier.example';
17
+ const OTHER_AUD = 'https://relay.example';
15
18
  const header = (value) => base64urlEncode(utf8Encode(JSON.stringify(value)));
16
19
  // Signs the header and payload text as they are, so a case can carry a
17
20
  // header or payload sign() would never write.
@@ -178,6 +181,43 @@ export async function handshakeConformanceCases() {
178
181
  'nonce_mismatch',
179
182
  { nonce: 'check-7f3a' },
180
183
  ],
184
+ // aud binds a handshake to the verifier that asked for it, so one made
185
+ // for another verifier and relayed is refused.
186
+ [
187
+ 'made for the verifier that asked',
188
+ byAgent({ ...base, nonce: NONCE, aud: AUD }),
189
+ 'matches',
190
+ { nonce: NONCE, aud: AUD },
191
+ ],
192
+ [
193
+ 'made for another verifier and relayed',
194
+ byAgent({ ...base, nonce: NONCE, aud: OTHER_AUD }),
195
+ 'wrong_verifier',
196
+ { nonce: NONCE, aud: AUD },
197
+ ],
198
+ [
199
+ 'no aud when the verifier gave its name',
200
+ byAgent({ ...base, nonce: NONCE }),
201
+ 'wrong_verifier',
202
+ { nonce: NONCE, aud: AUD },
203
+ ],
204
+ [
205
+ 'an aud the verifier did not ask for',
206
+ byAgent({ ...base, nonce: NONCE, aud: AUD }),
207
+ 'matches',
208
+ { nonce: NONCE },
209
+ ],
210
+ [
211
+ 'a wrong nonce is named before a wrong verifier',
212
+ byAgent({ ...base, nonce: 'check-0000', aud: OTHER_AUD }),
213
+ 'nonce_mismatch',
214
+ { nonce: NONCE, aud: AUD },
215
+ ],
216
+ [
217
+ 'a 65 character aud',
218
+ byAgent({ ...base, aud: 'a'.repeat(65) }),
219
+ 'malformed',
220
+ ],
181
221
  // Verified with the SEAL's sub key before the header is read, so a
182
222
  // handshake another agent signed for itself fails the signature.
183
223
  [
@@ -268,6 +308,7 @@ export async function handshakeConformanceCases() {
268
308
  jws: await jws,
269
309
  expected,
270
310
  ...(opts?.nonce === undefined ? {} : { nonce: opts.nonce }),
311
+ ...(opts?.aud === undefined ? {} : { aud: opts.aud }),
271
312
  ...(opts?.nowSeconds === undefined
272
313
  ? {}
273
314
  : { nowSeconds: opts.nowSeconds }),