@sealkeeper/schema 0.4.5 → 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.
package/dist/api.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  export declare const MAX_EVENTS_PER_BATCH = 500;
3
+ export declare const EVENT_MAX_AGE_DAYS = 7;
4
+ export declare const EVENT_MAX_FUTURE_SKEW_SEC = 300;
3
5
  export declare const MAX_ENVELOPE_CHARS = 4096;
4
6
  export declare const MAX_TASK_SPEC_BYTES = 16384;
7
+ export declare const MAX_TASK_SPEC_DEPTH = 8;
8
+ export declare const MAX_TASK_SPEC_KEYS = 500;
5
9
  export declare const MAX_SUBMISSION_BYTES = 65536;
6
10
  export declare const MAX_TASK_ENVELOPE_CHARS = 131072;
7
11
  export declare const TASK_DEFAULT_TTL_HOURS = 24;
@@ -437,6 +441,7 @@ export declare const TaskResponse: z.ZodObject<{
437
441
  submittedAt: z.ZodNullable<z.ZodISODateTime>;
438
442
  verifiedAt: z.ZodNullable<z.ZodISODateTime>;
439
443
  expiresAt: z.ZodISODateTime;
444
+ seed: z.ZodBoolean;
440
445
  submission: z.ZodOptional<z.ZodString>;
441
446
  }, z.core.$strict>;
442
447
  export type TaskResponse = z.infer<typeof TaskResponse>;
@@ -522,6 +527,7 @@ export declare const TaskSubmissionResponse: z.ZodObject<{
522
527
  submittedAt: z.ZodNullable<z.ZodISODateTime>;
523
528
  verifiedAt: z.ZodNullable<z.ZodISODateTime>;
524
529
  expiresAt: z.ZodISODateTime;
530
+ seed: z.ZodBoolean;
525
531
  submission: z.ZodOptional<z.ZodString>;
526
532
  }, z.core.$strict>;
527
533
  reports: z.ZodObject<{
@@ -536,6 +542,12 @@ export declare const TaskSubmissionResponse: z.ZodObject<{
536
542
  }, z.core.$strict>;
537
543
  }, z.core.$strict>;
538
544
  export type TaskSubmissionResponse = z.infer<typeof TaskSubmissionResponse>;
545
+ export type TasksCursor = {
546
+ atMicros: string;
547
+ id: string;
548
+ };
549
+ export declare function encodeTasksCursor(cursor: TasksCursor): string;
550
+ export declare function decodeTasksCursor(raw: string): TasksCursor | null;
539
551
  export declare const ListTasksQuery: z.ZodObject<{
540
552
  state: z.ZodDefault<z.ZodEnum<{
541
553
  claimed: "claimed";
@@ -546,7 +558,14 @@ export declare const ListTasksQuery: z.ZodObject<{
546
558
  }>>;
547
559
  taskType: z.ZodOptional<z.ZodString>;
548
560
  assignee: z.ZodOptional<z.ZodString>;
561
+ poster: z.ZodOptional<z.ZodString>;
562
+ claimant: z.ZodOptional<z.ZodString>;
563
+ seed: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodPipe<z.ZodEnum<{
564
+ false: "false";
565
+ true: "true";
566
+ }>, z.ZodTransform<boolean, "false" | "true">>]>>;
549
567
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
568
+ cursor: z.ZodOptional<z.ZodPipe<z.ZodString, z.ZodTransform<TasksCursor, string>>>;
550
569
  }, z.core.$strict>;
551
570
  export type ListTasksQuery = z.infer<typeof ListTasksQuery>;
552
571
  export declare const ListTasksResponse: z.ZodObject<{
@@ -591,16 +610,12 @@ export declare const ListTasksResponse: z.ZodObject<{
591
610
  submittedAt: z.ZodNullable<z.ZodISODateTime>;
592
611
  verifiedAt: z.ZodNullable<z.ZodISODateTime>;
593
612
  expiresAt: z.ZodISODateTime;
613
+ seed: z.ZodBoolean;
594
614
  submission: z.ZodOptional<z.ZodString>;
595
615
  }, z.core.$strict>>;
616
+ nextCursor: z.ZodNullable<z.ZodString>;
596
617
  }, z.core.$strict>;
597
618
  export type ListTasksResponse = z.infer<typeof ListTasksResponse>;
598
- export type TasksCursor = {
599
- atMicros: string;
600
- id: string;
601
- };
602
- export declare function encodeTasksCursor(cursor: TasksCursor): string;
603
- export declare function decodeTasksCursor(raw: string): TasksCursor | null;
604
619
  export declare const TaskBoardQuery: z.ZodObject<{
605
620
  taskType: z.ZodOptional<z.ZodString>;
606
621
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
package/dist/api.js CHANGED
@@ -6,13 +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';
9
10
  import { OPERATOR_DISPLAY_NAME_MAX, OPERATOR_SLUG_MAX, OperatorDisplayName, OperatorSlug, } from './moderation.js';
10
11
  import { Runtime } from './runtime.js';
11
12
  import { Level, Standing } from './standing.js';
12
13
  import { PublicVerificationSpec, Sha256Hex, ShownVerificationSpec, TaskOutcome, TaskState, VerificationSpec, } from './tasks.js';
13
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;
14
22
  export const MAX_ENVELOPE_CHARS = 4096;
15
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;
16
30
  // Measured as the UTF-8 bytes of the submission's JSON encoding, which is
17
31
  // what gets signed. Counting chars would let escapes and multi-byte text
18
32
  // grow the envelope past its cap.
@@ -329,9 +343,11 @@ export const EventsBatchResponse = z.strictObject({
329
343
  accepted: z.int().min(0),
330
344
  duplicates: z.int().min(0),
331
345
  });
332
- const TaskSpec = z
333
- .record(z.string(), z.unknown())
334
- .refine((spec) => utf8Encode(JSON.stringify(spec)).length <= MAX_TASK_SPEC_BYTES, `spec must be at most ${MAX_TASK_SPEC_BYTES} bytes`);
346
+ const TaskSpec = boundedJsonObject('spec', {
347
+ maxDepth: MAX_TASK_SPEC_DEPTH,
348
+ maxKeys: MAX_TASK_SPEC_KEYS,
349
+ maxBytes: MAX_TASK_SPEC_BYTES,
350
+ });
335
351
  // An agent a request names, by its id or its handle slug/name, the slug
336
352
  // a HandleSlug and the name an AgentName, so a malformed handle is refused
337
353
  // at the edge. An id never holds a slash, so the two cannot be confused.
@@ -392,6 +408,10 @@ export const TaskResponse = z.strictObject({
392
408
  submittedAt: Timestamp.nullable(),
393
409
  verifiedAt: Timestamp.nullable(),
394
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(),
395
415
  // Present only in responses to the poster or the claimant.
396
416
  submission: z.string().optional(),
397
417
  });
@@ -433,17 +453,6 @@ export const TaskSubmissionResponse = z.strictObject({
433
453
  task: TaskResponse,
434
454
  reports: TaskReports,
435
455
  });
436
- // state open leaves addressed tasks out unless assignee is given. assignee
437
- // keeps the tasks addressed to that agent, in the state asked for.
438
- export const ListTasksQuery = z.strictObject({
439
- state: TaskState.default('open'),
440
- taskType: TaskType.optional(),
441
- assignee: AgentId.optional(),
442
- limit: Limit,
443
- });
444
- export const ListTasksResponse = z.strictObject({
445
- tasks: z.array(TaskResponse),
446
- });
447
456
  const tasksKeyset = keysetCursor(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
448
457
  export function encodeTasksCursor(cursor) {
449
458
  return tasksKeyset.encode({ micros: cursor.atMicros, id: cursor.id });
@@ -454,6 +463,38 @@ export function decodeTasksCursor(raw) {
454
463
  return c && { atMicros: c.micros, id: c.id };
455
464
  }
456
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.
457
498
  // GET /v1/tasks/board. Open tasks, newest first, addressed ones included
458
499
  // with their assignee.
459
500
  export const TaskBoardQuery = z.strictObject({
@@ -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
+ }
@@ -259,6 +259,91 @@ export declare const agents: import("drizzle-orm/pg-core").PgTableWithColumns<{
259
259
  identity: undefined;
260
260
  generated: undefined;
261
261
  }, {}, {}>;
262
+ eventCount: import("drizzle-orm/pg-core").PgColumn<{
263
+ name: "event_count";
264
+ tableName: "agents";
265
+ dataType: "number";
266
+ columnType: "PgBigInt53";
267
+ data: number;
268
+ driverParam: string | number;
269
+ notNull: true;
270
+ hasDefault: true;
271
+ isPrimaryKey: false;
272
+ isAutoincrement: false;
273
+ hasRuntimeDefault: false;
274
+ enumValues: undefined;
275
+ baseColumn: never;
276
+ identity: undefined;
277
+ generated: undefined;
278
+ }, {}, {}>;
279
+ sessionCount: import("drizzle-orm/pg-core").PgColumn<{
280
+ name: "session_count";
281
+ tableName: "agents";
282
+ dataType: "number";
283
+ columnType: "PgBigInt53";
284
+ data: number;
285
+ driverParam: string | number;
286
+ notNull: true;
287
+ hasDefault: true;
288
+ isPrimaryKey: false;
289
+ isAutoincrement: false;
290
+ hasRuntimeDefault: false;
291
+ enumValues: undefined;
292
+ baseColumn: never;
293
+ identity: undefined;
294
+ generated: undefined;
295
+ }, {}, {}>;
296
+ toolCallCount: import("drizzle-orm/pg-core").PgColumn<{
297
+ name: "tool_call_count";
298
+ tableName: "agents";
299
+ dataType: "number";
300
+ columnType: "PgBigInt53";
301
+ data: number;
302
+ driverParam: string | number;
303
+ notNull: true;
304
+ hasDefault: true;
305
+ isPrimaryKey: false;
306
+ isAutoincrement: false;
307
+ hasRuntimeDefault: false;
308
+ enumValues: undefined;
309
+ baseColumn: never;
310
+ identity: undefined;
311
+ generated: undefined;
312
+ }, {}, {}>;
313
+ incidentCount: import("drizzle-orm/pg-core").PgColumn<{
314
+ name: "incident_count";
315
+ tableName: "agents";
316
+ dataType: "number";
317
+ columnType: "PgBigInt53";
318
+ data: number;
319
+ driverParam: string | number;
320
+ notNull: true;
321
+ hasDefault: true;
322
+ isPrimaryKey: false;
323
+ isAutoincrement: false;
324
+ hasRuntimeDefault: false;
325
+ enumValues: undefined;
326
+ baseColumn: never;
327
+ identity: undefined;
328
+ generated: undefined;
329
+ }, {}, {}>;
330
+ lastSeenAt: import("drizzle-orm/pg-core").PgColumn<{
331
+ name: "last_seen_at";
332
+ tableName: "agents";
333
+ dataType: "date";
334
+ columnType: "PgTimestamp";
335
+ data: Date;
336
+ driverParam: string;
337
+ notNull: false;
338
+ hasDefault: false;
339
+ isPrimaryKey: false;
340
+ isAutoincrement: false;
341
+ hasRuntimeDefault: false;
342
+ enumValues: undefined;
343
+ baseColumn: never;
344
+ identity: undefined;
345
+ generated: undefined;
346
+ }, {}, {}>;
262
347
  };
263
348
  dialect: 'pg';
264
349
  }>;
package/dist/db/agents.js CHANGED
@@ -62,6 +62,26 @@ export const agents = pgTable('agents', {
62
62
  // (VOU-121). Null for agents registered before.
63
63
  termsVersion: text('terms_version'),
64
64
  privacyVersion: text('privacy_version'),
65
+ // Live counts of the agent's events, all time, and the newest
66
+ // received_at, for the agent answers (VOU-207). The database keeps them.
67
+ // The events_count_agent_events trigger (migration 0038) adds each
68
+ // insert statement's accepted rows, so a replay skipped by ON CONFLICT
69
+ // adds nothing, and the application never writes them. Reading them is
70
+ // one primary key read however many events the agent has sent. Events
71
+ // are append only and deleted only with their agent, so nothing ever
72
+ // takes one away. bigint, since an agent at the ingest cap sends
73
+ // millions a day.
74
+ eventCount: bigint('event_count', { mode: 'number' }).notNull().default(0),
75
+ sessionCount: bigint('session_count', { mode: 'number' })
76
+ .notNull()
77
+ .default(0),
78
+ toolCallCount: bigint('tool_call_count', { mode: 'number' })
79
+ .notNull()
80
+ .default(0),
81
+ incidentCount: bigint('incident_count', { mode: 'number' })
82
+ .notNull()
83
+ .default(0),
84
+ lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
65
85
  ...timestamps,
66
86
  }, (t) => [
67
87
  check('agents_id_length', sql `length(${t.id}) = 43`),
@@ -1,6 +1,11 @@
1
+ import type { Logger } from 'drizzle-orm/logger';
1
2
  import postgres from 'postgres';
2
3
  export type DbOptions = {
3
4
  max?: number;
5
+ statementTimeoutMs?: number;
6
+ lockTimeoutMs?: number;
7
+ idleInTxTimeoutMs?: number;
8
+ logger?: Logger;
4
9
  };
5
10
  export declare function createDb(url: string, options?: DbOptions): import("drizzle-orm/postgres-js").PostgresJsDatabase<{
6
11
  operators: import("drizzle-orm/pg-core").PgTableWithColumns<{
@@ -492,6 +497,91 @@ export declare function createDb(url: string, options?: DbOptions): import("driz
492
497
  identity: undefined;
493
498
  generated: undefined;
494
499
  }, {}, {}>;
500
+ eventCount: import("drizzle-orm/pg-core").PgColumn<{
501
+ name: "event_count";
502
+ tableName: "agents";
503
+ dataType: "number";
504
+ columnType: "PgBigInt53";
505
+ data: number;
506
+ driverParam: string | number;
507
+ notNull: true;
508
+ hasDefault: true;
509
+ isPrimaryKey: false;
510
+ isAutoincrement: false;
511
+ hasRuntimeDefault: false;
512
+ enumValues: undefined;
513
+ baseColumn: never;
514
+ identity: undefined;
515
+ generated: undefined;
516
+ }, {}, {}>;
517
+ sessionCount: import("drizzle-orm/pg-core").PgColumn<{
518
+ name: "session_count";
519
+ tableName: "agents";
520
+ dataType: "number";
521
+ columnType: "PgBigInt53";
522
+ data: number;
523
+ driverParam: string | number;
524
+ notNull: true;
525
+ hasDefault: true;
526
+ isPrimaryKey: false;
527
+ isAutoincrement: false;
528
+ hasRuntimeDefault: false;
529
+ enumValues: undefined;
530
+ baseColumn: never;
531
+ identity: undefined;
532
+ generated: undefined;
533
+ }, {}, {}>;
534
+ toolCallCount: import("drizzle-orm/pg-core").PgColumn<{
535
+ name: "tool_call_count";
536
+ tableName: "agents";
537
+ dataType: "number";
538
+ columnType: "PgBigInt53";
539
+ data: number;
540
+ driverParam: string | number;
541
+ notNull: true;
542
+ hasDefault: true;
543
+ isPrimaryKey: false;
544
+ isAutoincrement: false;
545
+ hasRuntimeDefault: false;
546
+ enumValues: undefined;
547
+ baseColumn: never;
548
+ identity: undefined;
549
+ generated: undefined;
550
+ }, {}, {}>;
551
+ incidentCount: import("drizzle-orm/pg-core").PgColumn<{
552
+ name: "incident_count";
553
+ tableName: "agents";
554
+ dataType: "number";
555
+ columnType: "PgBigInt53";
556
+ data: number;
557
+ driverParam: string | number;
558
+ notNull: true;
559
+ hasDefault: true;
560
+ isPrimaryKey: false;
561
+ isAutoincrement: false;
562
+ hasRuntimeDefault: false;
563
+ enumValues: undefined;
564
+ baseColumn: never;
565
+ identity: undefined;
566
+ generated: undefined;
567
+ }, {}, {}>;
568
+ lastSeenAt: import("drizzle-orm/pg-core").PgColumn<{
569
+ name: "last_seen_at";
570
+ tableName: "agents";
571
+ dataType: "date";
572
+ columnType: "PgTimestamp";
573
+ data: Date;
574
+ driverParam: string;
575
+ notNull: false;
576
+ hasDefault: false;
577
+ isPrimaryKey: false;
578
+ isAutoincrement: false;
579
+ hasRuntimeDefault: false;
580
+ enumValues: undefined;
581
+ baseColumn: never;
582
+ identity: undefined;
583
+ generated: undefined;
584
+ }, {}, {}>;
495
585
  };
496
586
  dialect: 'pg';
497
587
  }>;
package/dist/db/client.js CHANGED
@@ -36,5 +36,26 @@ const schema = {
36
36
  export function createDb(url, options = {}) {
37
37
  const [conn, pgOptions] = pgArgs(url);
38
38
  const max = options.max === undefined ? {} : { max: options.max };
39
- return drizzle(postgres(conn, { ...pgOptions, ...max }), { schema });
39
+ const connection = timeoutParameters(options);
40
+ const logger = options.logger === undefined ? {} : { logger: options.logger };
41
+ return drizzle(postgres(conn, {
42
+ ...pgOptions,
43
+ ...max,
44
+ ...(Object.keys(connection).length > 0 ? { connection } : {}),
45
+ }), { schema, ...logger });
46
+ }
47
+ // The postgres.js connection parameters for the timeouts that are set. A
48
+ // bare number is read by Postgres as milliseconds.
49
+ function timeoutParameters(options) {
50
+ const params = {};
51
+ if (options.statementTimeoutMs !== undefined) {
52
+ params.statement_timeout = options.statementTimeoutMs;
53
+ }
54
+ if (options.lockTimeoutMs !== undefined) {
55
+ params.lock_timeout = options.lockTimeoutMs;
56
+ }
57
+ if (options.idleInTxTimeoutMs !== undefined) {
58
+ params.idle_in_transaction_session_timeout = options.idleInTxTimeoutMs;
59
+ }
60
+ return params;
40
61
  }
@@ -1,4 +1,6 @@
1
1
  export declare const migrationsFolder: string;
2
+ export declare const MIGRATION_LOCK_TIMEOUT_MS = 5000;
3
+ export declare const MIGRATION_STATEMENT_TIMEOUT_MS = 120000;
2
4
  export declare function runMigrations(databaseUrl: string, { onNotice }?: {
3
5
  onNotice?: (message: string) => void;
4
6
  }): Promise<void>;
@@ -8,6 +8,12 @@ import postgres from 'postgres';
8
8
  import { pgArgs } from './url.js';
9
9
  // Resolves to packages/schema/drizzle from both src/db and dist/db.
10
10
  export const migrationsFolder = fileURLToPath(new URL('../../drizzle', import.meta.url));
11
+ // A migration that waits on a lock, such as an ALTER TABLE queued behind the
12
+ // scoring run's transaction, fails after MIGRATION_LOCK_TIMEOUT_MS instead of
13
+ // waiting with no bound and blocking every query queued behind it. One
14
+ // statement may run for MIGRATION_STATEMENT_TIMEOUT_MS (VOU-205).
15
+ export const MIGRATION_LOCK_TIMEOUT_MS = 5_000;
16
+ export const MIGRATION_STATEMENT_TIMEOUT_MS = 120_000;
11
17
  // onNotice receives the text of each notice a migration raises, such as
12
18
  // the operators 0025 gave a suffixed slug. Silent when left out.
13
19
  export async function runMigrations(databaseUrl, { onNotice } = {}) {
@@ -15,6 +21,10 @@ export async function runMigrations(databaseUrl, { onNotice } = {}) {
15
21
  const sql = postgres(connUrl, {
16
22
  ...options,
17
23
  max: 1,
24
+ connection: {
25
+ lock_timeout: MIGRATION_LOCK_TIMEOUT_MS,
26
+ statement_timeout: MIGRATION_STATEMENT_TIMEOUT_MS,
27
+ },
18
28
  onnotice: (notice) => onNotice?.(notice.message),
19
29
  });
20
30
  try {
package/dist/db/tasks.js CHANGED
@@ -69,6 +69,12 @@ export const tasks = pgTable('tasks', {
69
69
  index('tasks_poster_agent_id_idx').on(t.posterAgentId),
70
70
  // The claim cap per claimant, activity counts, scoring and evidence.
71
71
  index('tasks_claimant_agent_id_idx').on(t.claimantAgentId),
72
+ // GET /v1/tasks?poster= and seed=, which is the seed agent's poster id,
73
+ // oldest first and paged on (posted_at, id) (VOU-208).
74
+ index('tasks_poster_posted_at_idx').on(t.posterAgentId, t.postedAt, t.id),
75
+ // GET /v1/tasks?claimant=, a CLI's own claims and submissions, oldest
76
+ // first and paged on (posted_at, id) (VOU-208).
77
+ index('tasks_claimant_posted_at_idx').on(t.claimantAgentId, t.postedAt, t.id),
72
78
  // GET /v1/tasks?state=open and the board, oldest or newest first. The
73
79
  // open pool filters out addressed tasks on top of it.
74
80
  index('tasks_open_posted_at_idx')
package/dist/index.d.ts CHANGED
@@ -3,11 +3,13 @@ export * from './agent-name.js';
3
3
  export * from './api.js';
4
4
  export * from './badge.js';
5
5
  export * from './base64url.js';
6
+ export * from './client-address.js';
6
7
  export * from './credential.js';
7
8
  export * from './dimensions.js';
8
9
  export * from './envelope.js';
9
10
  export * from './events.js';
10
11
  export * from './goal.js';
12
+ export * from './json-shape.js';
11
13
  export * from './moderation.js';
12
14
  export * from './operator-domains.js';
13
15
  export * from './policy.js';
package/dist/index.js CHANGED
@@ -3,11 +3,13 @@ export * from './agent-name.js';
3
3
  export * from './api.js';
4
4
  export * from './badge.js';
5
5
  export * from './base64url.js';
6
+ export * from './client-address.js';
6
7
  export * from './credential.js';
7
8
  export * from './dimensions.js';
8
9
  export * from './envelope.js';
9
10
  export * from './events.js';
10
11
  export * from './goal.js';
12
+ export * from './json-shape.js';
11
13
  export * from './moderation.js';
12
14
  export * from './operator-domains.js';
13
15
  export * from './policy.js';
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod';
2
+ export type JsonShapeLimits = {
3
+ readonly maxDepth: number;
4
+ readonly maxKeys: number;
5
+ readonly maxBytes: number;
6
+ };
7
+ export type JsonShapeIssue = 'too_deep' | 'too_many_keys';
8
+ export declare function jsonShapeIssue(value: unknown, limits: Pick<JsonShapeLimits, 'maxDepth' | 'maxKeys'>): JsonShapeIssue | null;
9
+ export declare function boundedJsonObject(name: string, limits: JsonShapeLimits): z.ZodRecord<z.ZodString, z.ZodUnknown>;
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { utf8Encode } from './base64url.js';
3
+ /*
4
+ * The first depth or key count limit a JSON value breaks, or null when it
5
+ * keeps to both.
6
+ *
7
+ * Walks with its own stack rather than recursing, so a value nested
8
+ * thousands deep cannot overflow the call stack, and stops at the first
9
+ * breach. Run it before JSON.stringify on anything a caller sent.
10
+ * stringify recurses and throws RangeError past about 7,700 levels, and
11
+ * indented output grows with the square of the depth (VOU-214).
12
+ */
13
+ export function jsonShapeIssue(value, limits) {
14
+ const stack = [];
15
+ const push = (v, depth) => {
16
+ if (typeof v === 'object' && v !== null)
17
+ stack.push({ node: v, depth });
18
+ };
19
+ push(value, 1);
20
+ let keys = 0;
21
+ for (let top = stack.pop(); top !== undefined; top = stack.pop()) {
22
+ if (top.depth > limits.maxDepth)
23
+ return 'too_deep';
24
+ let children;
25
+ if (Array.isArray(top.node)) {
26
+ children = top.node;
27
+ }
28
+ else {
29
+ children = Object.values(top.node);
30
+ keys += children.length;
31
+ if (keys > limits.maxKeys)
32
+ return 'too_many_keys';
33
+ }
34
+ for (const child of children)
35
+ push(child, top.depth + 1);
36
+ }
37
+ return null;
38
+ }
39
+ /*
40
+ * A JSON object held to the limits. The depth and key checks run first, so
41
+ * the byte check only ever stringifies a value that cannot throw, and a
42
+ * breach is a Zod issue (a 400 at the API), never a RangeError (a 500).
43
+ * superRefine is used rather than refine so each breach gets its own
44
+ * message and the byte check can be skipped once one is found.
45
+ */
46
+ export function boundedJsonObject(name, limits) {
47
+ return z.record(z.string(), z.unknown()).superRefine((value, ctx) => {
48
+ const issue = jsonShapeIssue(value, limits);
49
+ if (issue === 'too_deep') {
50
+ ctx.addIssue({
51
+ code: 'custom',
52
+ message: `${name} must nest at most ${limits.maxDepth} levels deep`,
53
+ });
54
+ return;
55
+ }
56
+ if (issue === 'too_many_keys') {
57
+ ctx.addIssue({
58
+ code: 'custom',
59
+ message: `${name} must have at most ${limits.maxKeys} keys`,
60
+ });
61
+ return;
62
+ }
63
+ if (utf8Encode(JSON.stringify(value)).length > limits.maxBytes) {
64
+ ctx.addIssue({
65
+ code: 'custom',
66
+ message: `${name} must be at most ${limits.maxBytes} bytes`,
67
+ });
68
+ }
69
+ });
70
+ }
@@ -15,12 +15,13 @@ export declare const PROTECTED_PREFIXES: readonly string[];
15
15
  export declare function protectedNameOf(text: string): string | null;
16
16
  export declare const RESERVED_WORDS: readonly string[];
17
17
  export declare function isReservedWord(text: string): boolean;
18
- export type NameRefusalCode = 'name_protected' | 'name_reserved';
18
+ export type NameRefusalCode = 'name_protected' | 'name_reserved' | 'name_mixed_script';
19
19
  export type NameRefusal = {
20
20
  code: NameRefusalCode;
21
21
  message: string;
22
22
  };
23
23
  export declare function moderateSlug(slug: string): NameRefusal | null;
24
+ export declare function mixedScriptOf(text: string): string | null;
24
25
  export declare function moderateDisplayName(name: string): NameRefusal | null;
25
26
  export declare function slugBase(login: string): string;
26
27
  export declare const suffixedSlug: (base: string, n: number) => string;
@@ -42,65 +42,101 @@ export const OperatorDisplayName = z
42
42
  .refine((name) => LETTER_OR_DIGIT.test(name), 'Use at least one letter or digit');
43
43
  // ---------------------------------------------------------------------------
44
44
  // Normalisation
45
- // Letters from other scripts that read as a Latin letter, and a few Latin
46
- // letters NFKD does not split into a base and a mark.
47
- const HOMOGLYPHS = {
48
- // Cyrillic
49
- а: 'a',
50
- в: 'b',
51
- с: 'c',
52
- ԁ: 'd',
53
- е: 'e',
54
- ё: 'e',
55
- һ: 'h',
56
- н: 'h',
57
- і: 'i',
58
- ї: 'i',
59
- ј: 'j',
60
- к: 'k',
61
- ӏ: 'l',
62
- м: 'm',
63
- п: 'n',
64
- о: 'o',
65
- р: 'p',
66
- ԛ: 'q',
67
- г: 'r',
68
- ѕ: 's',
69
- т: 't',
70
- у: 'y',
71
- ш: 'w',
72
- ԝ: 'w',
73
- х: 'x',
74
- // Greek
75
- α: 'a',
76
- β: 'b',
77
- ϲ: 'c',
78
- ε: 'e',
79
- η: 'n',
80
- ι: 'i',
81
- κ: 'k',
82
- μ: 'u',
83
- ν: 'v',
84
- ο: 'o',
85
- ρ: 'p',
86
- τ: 't',
87
- υ: 'u',
88
- χ: 'x',
89
- ω: 'w',
90
- // Latin letters without a decomposition
91
- ı: 'i',
92
- ȷ: 'j',
93
- ł: 'l',
94
- ø: 'o',
95
- đ: 'd',
96
- ħ: 'h',
97
- ŧ: 't',
98
- ß: 'ss',
99
- æ: 'ae',
100
- œ: 'oe',
101
- ɡ: 'g',
102
- ɑ: 'a',
103
- // Digits and symbols used as letters
45
+ // Lookalikes, folded to the Latin letter they read as. Each row is a Latin
46
+ // letter and the letters of other scripts that read as it, in the order
47
+ // Greek, Cyrillic, Armenian, Cherokee, Canadian syllabics, Lisu, Latin,
48
+ // Coptic, Tifinagh. The letters come from Unicode's confusables.txt, one
49
+ // letter to one Latin letter, for these scripts only, plus the Latin small
50
+ // capitals and a few Latin letters NFKD does not split into a base and a
51
+ // mark. Coptic alfa and eie (Ⲁ, Ⲉ) are added by eye, they are not in the
52
+ // list. Letters NFKC or
53
+ // NFKD turn into another letter (the lunate sigma, the rho symbol, ё) are
54
+ // left out, since they never reach the tables.
55
+ function byLetter(rows) {
56
+ const table = {};
57
+ for (const [latin, lookalikes] of Object.entries(rows)) {
58
+ for (const ch of lookalikes) {
59
+ if (ch in table)
60
+ throw new Error(`${ch} is in two lookalike rows`);
61
+ table[ch] = latin;
62
+ }
63
+ }
64
+ return table;
65
+ }
66
+ // Capitals, and letters of scripts without case, that read as a Latin
67
+ // capital. Folded before a letter is lowercased, since lowercasing first
68
+ // would turn Greek capital Nu into nu, which reads as v, and a Cherokee
69
+ // capital into a Cherokee small letter. So capital Nu is n here and small
70
+ // nu is v in SMALL_LOOKALIKES.
71
+ const CAPITAL_LOOKALIKES = byLetter({
72
+ a: 'ΑАᎪᗅꓮⲀ',
73
+ b: 'ΒВЬᏏᏴᗷꓐⲂ',
74
+ c: 'СᏟꓚⲤ',
75
+ d: 'ᎠᏧᗞᗪꓓ',
76
+ e: 'ΕЕᎬꓰⲈⴹ',
77
+ f: 'Ϝᖴꓝ',
78
+ g: 'ԌᏀᏳꓖ',
79
+ h: 'ΗНҺᎻᏂᕼꓧⲎ',
80
+ i: 'ΙІᎥ',
81
+ j: 'ͿЈᎫᒍꓙ',
82
+ k: 'ΚКᏦꓗⲔ',
83
+ l: 'ӀᏞᒪꓡꓲǀⲒⳐⵏ',
84
+ m: 'ΜϺМᎷᗰꓟⲘ',
85
+ n: 'Ν𑪾ꓠⲚ',
86
+ o: 'ΟОՕꓳⲞⵔ',
87
+ p: 'ΡРᏢᑭꓑⲢⳎ',
88
+ q: 'Ԛⵕ',
89
+ r: 'ᎡᏒᖇꓣ',
90
+ s: 'ЅՏᏕᏚꓢ',
91
+ t: 'ΤТᎢꓔⲦ',
92
+ u: 'Սᑌꓴ',
93
+ v: 'ѴᏙᐯꓦ',
94
+ w: 'ԜᎳᏔꓪ',
95
+ x: 'ΧХꓫⲬⵝ',
96
+ y: 'ΥУҮᎩᎽꓬ',
97
+ z: 'ΖᏃ𑪼ꓜ',
98
+ });
99
+ // Small letters that read as a Latin letter. Cherokee small letters are the
100
+ // Cherokee capitals above, smaller, so they are added from that table.
101
+ const SMALL_LOOKALIKES = byLetter({
102
+ a: 'αаɑᴀⲁ',
103
+ b: 'βвᑲᖯʙⲃ',
104
+ c: 'сᲃᴄⲥ',
105
+ d: 'ԁᑯꓒđᴅ',
106
+ e: 'εеҽᴇⲉ',
107
+ f: 'քꜰ',
108
+ g: 'ցɡɢ',
109
+ h: 'һнհħʜⲏ',
110
+ i: 'ιіꙇւıɪɩⲓ',
111
+ j: 'ϳјյȷᴊ',
112
+ k: 'κкᴋⲕ',
113
+ l: 'ӏłʟⳑ',
114
+ m: 'мᴍ',
115
+ n: 'ηпոռɴⲛ',
116
+ o: 'οоᲂօøᴏⲟϭ',
117
+ p: 'ρϸрᴘⲣⳏ',
118
+ q: 'ԛգզꞯ',
119
+ r: 'ᴦгʀⲅ',
120
+ s: 'ѕꜱ',
121
+ t: 'τтŧᴛⲧ',
122
+ u: 'μυսᴜ',
123
+ v: 'νѵᴠ',
124
+ w: 'ωшԝѡաᴡꟺⲽ',
125
+ x: 'χхᕁᕽⲭ',
126
+ y: 'уүʏⲩ',
127
+ z: 'ᴢ',
128
+ ss: 'ß',
129
+ ae: 'æ',
130
+ oe: 'œ',
131
+ });
132
+ for (const [ch, latin] of Object.entries(CAPITAL_LOOKALIKES)) {
133
+ const small = ch.toLowerCase();
134
+ if (small !== ch && /\p{Script=Cherokee}/u.test(ch)) {
135
+ SMALL_LOOKALIKES[small] = latin;
136
+ }
137
+ }
138
+ // Digits and symbols used as letters.
139
+ const DIGIT_SWAPS = {
104
140
  '0': 'o',
105
141
  '1': 'l',
106
142
  '3': 'e',
@@ -113,20 +149,30 @@ const HOMOGLYPHS = {
113
149
  '|': 'l',
114
150
  '!': 'l',
115
151
  };
152
+ // One letter folded. A capital by the capital it looks like, else the
153
+ // letter lowercased and folded by the small letter it looks like.
154
+ function foldLetter(ch) {
155
+ const capital = CAPITAL_LOOKALIKES[ch];
156
+ if (capital !== undefined)
157
+ return capital;
158
+ const small = ch.toLowerCase();
159
+ return SMALL_LOOKALIKES[small] ?? DIGIT_SWAPS[small] ?? small;
160
+ }
116
161
  // i and l look alike in many fonts (GoogIe with a capital I), so both fold
117
- // to l. Run after HOMOGLYPHS, so a Cyrillic і, a Greek ι or a dotless ı,
118
- // which fold to i there, fold on to l as well.
162
+ // to l. Run after the lookalikes, so a Cyrillic і, a Greek ι or a dotless
163
+ // ı, which fold to i there, fold on to l as well.
119
164
  const I_AS_L = /i/g;
120
165
  const MARKS = /\p{M}/gu;
121
166
  const NOT_LETTER_OR_DIGIT = /[^\p{L}\p{N}]/gu;
122
- // The form two names are compared in. NFKC, lowercase, accents dropped,
123
- // homoglyphs and digit swaps folded, everything but letters and digits
124
- // dropped, then letter pairs that read as one letter (rn as m, vv as w)
125
- // folded. Open-AI, OPENAI and 0penai all give the same key. Letters of
126
- // scripts with no Latin lookalike are kept, so a display name in another
127
- // script still has a key of its own.
167
+ // The form two names are compared in. NFKC, accents dropped, lookalikes of
168
+ // other scripts and digit swaps folded by shape and the rest lowercased,
169
+ // everything but letters and digits dropped, then letter pairs that read
170
+ // as one letter (rn as m, vv as w) folded. Open-AI, OPENAI and 0penai all
171
+ // give the same key, and so does OPENAI with a Greek capital Omicron.
172
+ // Letters of scripts with no Latin lookalike are kept, so a display name
173
+ // in another script still has a key of its own.
128
174
  export function normaliseName(text) {
129
- const folded = Array.from(text.normalize('NFKC').toLowerCase().normalize('NFKD').replace(MARKS, ''), (ch) => HOMOGLYPHS[ch] ?? ch)
175
+ const folded = Array.from(text.normalize('NFKC').normalize('NFKD').replace(MARKS, ''), foldLetter)
130
176
  .join('')
131
177
  .replace(I_AS_L, 'l');
132
178
  return folded
@@ -403,11 +449,62 @@ export function moderateSlug(slug) {
403
449
  }
404
450
  return protectedRefusal(slug);
405
451
  }
452
+ // Scripts a name in Latin letters may mix with, the mixes Unicode's highly
453
+ // restrictive profile (UTS 39) allows. Han, Hiragana, Katakana, Hangul and
454
+ // Bopomofo, plus Common and Inherited, which hold letters every script
455
+ // shares. Any other script mixed with Latin is how a lookalike hides (Nvidia
456
+ // with a Greek capital Nu, Google with Armenian o, OpenAI with a Coptic O),
457
+ // so it is refused outright, whatever the tables above fold. A script not
458
+ // listed here is refused by default, so a new lookalike script needs no row.
459
+ // A name written in any one script alone is fine.
460
+ const MIXES_WITH_LATIN = /[\p{Script=Latin}\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Bopomofo}\p{Script=Common}\p{Script=Inherited}]/u;
461
+ // Names for the refusal message, for the scripts with the most Latin
462
+ // lookalikes. Any other script is "letters of another script".
463
+ const SCRIPT_NAMES = [
464
+ ['Greek letters', /\p{Script=Greek}/u],
465
+ ['Cyrillic letters', /\p{Script=Cyrillic}/u],
466
+ ['Armenian letters', /\p{Script=Armenian}/u],
467
+ ['Cherokee letters', /\p{Script=Cherokee}/u],
468
+ ['Lisu letters', /\p{Script=Lisu}/u],
469
+ ['Canadian syllabics', /\p{Script=Canadian_Aboriginal}/u],
470
+ ['Coptic letters', /\p{Script=Coptic}/u],
471
+ ['Tifinagh letters', /\p{Script=Tifinagh}/u],
472
+ ];
473
+ const LATIN = /\p{Script=Latin}/u;
474
+ const LETTER = /\p{L}/u;
475
+ // The micro sign and the ohm sign. NFKC turns them into Greek mu and
476
+ // Omega, and the ohm sign is Greek already, so they are dropped before the
477
+ // script check. 10kΩ Audio is a unit, not a Greek letter.
478
+ const UNIT_SIGNS = /[\u00B5\u2126]/gu;
479
+ // The script a text mixes with Latin letters, as the refusal names it, or
480
+ // null when it mixes none it may not. Read after NFKC, so fullwidth and
481
+ // styled Latin letters count as Latin.
482
+ export function mixedScriptOf(text) {
483
+ const letters = Array.from(text.replace(UNIT_SIGNS, '').normalize('NFKC')).filter((ch) => LETTER.test(ch));
484
+ if (!letters.some((ch) => LATIN.test(ch)))
485
+ return null;
486
+ const other = letters.find((ch) => !MIXES_WITH_LATIN.test(ch));
487
+ if (other === undefined)
488
+ return null;
489
+ const named = SCRIPT_NAMES.find(([, pattern]) => pattern.test(other));
490
+ return named ? named[0] : 'letters of another script';
491
+ }
406
492
  // The refusal for an operator display name, or null when it may be used.
407
- // The shape is OperatorDisplayName's job. Whether another operator holds
408
- // the same key is the API's check against display_name_key.
493
+ // The shape is OperatorDisplayName's job. Protected names first, then Latin
494
+ // mixed with a script it may not mix with. Whether another operator holds
495
+ // the same key is the API's check against display_name_key. Slugs need no
496
+ // script check, OperatorSlug allows a to z, digits and hyphens only.
409
497
  export function moderateDisplayName(name) {
410
- return protectedRefusal(name);
498
+ const refusal = protectedRefusal(name);
499
+ if (refusal)
500
+ return refusal;
501
+ const script = mixedScriptOf(name);
502
+ return script === null
503
+ ? null
504
+ : {
505
+ code: 'name_mixed_script',
506
+ message: `${name} mixes Latin letters with ${script}, so it can pass for another name. Use one script.`,
507
+ };
411
508
  }
412
509
  // ---------------------------------------------------------------------------
413
510
  // Default slugs
package/dist/tasks.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  export declare const Sha256Hex: z.ZodString;
3
3
  export declare const JSON_SCHEMA_MAX_BYTES = 16384;
4
+ export declare const JSON_SCHEMA_MAX_DEPTH = 20;
5
+ export declare const JSON_SCHEMA_MAX_KEYS = 500;
4
6
  export declare const HashVerification: z.ZodObject<{
5
7
  kind: z.ZodLiteral<"hash">;
6
8
  sha256: z.ZodString;
package/dist/tasks.js CHANGED
@@ -1,16 +1,26 @@
1
1
  import { z } from 'zod';
2
- import { utf8Encode } from './base64url.js';
2
+ import { boundedJsonObject } from './json-shape.js';
3
3
  export const Sha256Hex = z.string().regex(/^[0-9a-f]{64}$/);
4
4
  export const JSON_SCHEMA_MAX_BYTES = 16384;
5
+ // Depth and key caps on a jsonSchema, checked before its bytes (VOU-214).
6
+ // The depth counts JSON levels, the top level object is 1. The API's schema
7
+ // check allows 8 schema levels (MAX_SCHEMA_DEPTH in apps/api), and a
8
+ // subschema under properties, allOf or an items list sits two JSON levels
9
+ // below its parent, so an accepted schema reaches about 17. 20 refuses none
10
+ // of them and still stops a deep bracket bomb.
11
+ export const JSON_SCHEMA_MAX_DEPTH = 20;
12
+ export const JSON_SCHEMA_MAX_KEYS = 500;
5
13
  export const HashVerification = z.strictObject({
6
14
  kind: z.literal('hash'),
7
15
  sha256: Sha256Hex,
8
16
  });
9
17
  export const SchemaVerification = z.strictObject({
10
18
  kind: z.literal('schema'),
11
- jsonSchema: z
12
- .record(z.string(), z.unknown())
13
- .refine((schema) => utf8Encode(JSON.stringify(schema)).length <= JSON_SCHEMA_MAX_BYTES, `jsonSchema must be at most ${JSON_SCHEMA_MAX_BYTES} bytes`),
19
+ jsonSchema: boundedJsonObject('jsonSchema', {
20
+ maxDepth: JSON_SCHEMA_MAX_DEPTH,
21
+ maxKeys: JSON_SCHEMA_MAX_KEYS,
22
+ maxBytes: JSON_SCHEMA_MAX_BYTES,
23
+ }),
14
24
  });
15
25
  export const CounterpartyVerification = z.strictObject({
16
26
  kind: z.literal('counterparty'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealkeeper/schema",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "private": false,
5
5
  "description": "Zod schemas, event taxonomy and signing helpers for SealKeeper.",
6
6
  "license": "Apache-2.0",