@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 +21 -6
- package/dist/api.js +55 -14
- package/dist/client-address.d.ts +6 -0
- package/dist/client-address.js +81 -0
- package/dist/db/agents.d.ts +85 -0
- package/dist/db/agents.js +20 -0
- package/dist/db/client.d.ts +90 -0
- package/dist/db/client.js +22 -1
- package/dist/db/migrator.d.ts +2 -0
- package/dist/db/migrator.js +10 -0
- package/dist/db/tasks.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/json-shape.d.ts +9 -0
- package/dist/json-shape.js +70 -0
- package/dist/moderation.d.ts +2 -1
- package/dist/moderation.js +168 -71
- package/dist/tasks.d.ts +2 -0
- package/dist/tasks.js +14 -4
- package/package.json +1 -1
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 =
|
|
333
|
-
|
|
334
|
-
|
|
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
|
+
}
|
package/dist/db/agents.d.ts
CHANGED
|
@@ -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`),
|
package/dist/db/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/db/migrator.d.ts
CHANGED
|
@@ -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>;
|
package/dist/db/migrator.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/moderation.d.ts
CHANGED
|
@@ -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;
|
package/dist/moderation.js
CHANGED
|
@@ -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
|
-
//
|
|
46
|
-
// letters
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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,
|
|
123
|
-
//
|
|
124
|
-
// dropped, then letter pairs that read
|
|
125
|
-
// folded. Open-AI, OPENAI and 0penai all
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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').
|
|
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.
|
|
408
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
12
|
-
|
|
13
|
-
|
|
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'),
|