Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

@atp/lexicon

+3874 -109
+101 -12
common/dates.ts
··· 1 + // Time constants 1 2 export const SECOND = 1000; 2 3 export const MINUTE = SECOND * 60; 3 4 export const HOUR = MINUTE * 60; 4 5 export const DAY = HOUR * 24; 5 6 7 + // Characters that need escaping in regex 8 + const SEPARATORS_TO_ESCAPE = new Set([ 9 + "\\", 10 + "^", 11 + "$", 12 + ".", 13 + "|", 14 + "?", 15 + "*", 16 + "+", 17 + "(", 18 + ")", 19 + "[", 20 + "]", 21 + "{", 22 + "}", 23 + ]); 24 + 25 + // Helper Functions 26 + 27 + function getStringSeparator(dateString: string): string { 28 + const separator = /\D/.exec(dateString); 29 + return separator ? separator[0] : ""; 30 + } 31 + 32 + function getTimeStringSeparator(timeString: string): string { 33 + const matches = timeString.match(/([^Z+\-\d])(?=\d+\1)/); 34 + return Array.isArray(matches) ? matches[0] : ""; 35 + } 36 + 37 + // Validation Functions 38 + 39 + export function isValidDate(date: string, s = "-"): boolean { 40 + if (SEPARATORS_TO_ESCAPE.has(s)) { 41 + s = `\\${s}`; 42 + } 43 + 44 + const validator = new RegExp( 45 + `^(?!0{4}${s}0{2}${s}0{2})((?=[0-9]{4}${s}(((0[^2])|1[0-2])|02(?=${s}(([0-1][0-9])|2[0-8])))${s}[0-9]{2})|(?=((([13579][26])|([2468][048])|(0[48]))0{2})|([0-9]{2}((((0|[2468])[48])|[2468][048])|([13579][26])))${s}02${s}29))([0-9]{4})${s}(?!((0[469])|11)${s}31)((0[1,3-9]|1[0-2])|(02(?!${s}3)))${s}(0[1-9]|[1-2][0-9]|3[0-1])$`, 46 + ); 47 + return validator.test(date); 48 + } 49 + 50 + function isValidTime( 51 + timeWithOffset: string, 52 + s = ":", 53 + isTimezoneCheckOn = false, 54 + ): boolean { 55 + const validator = new RegExp( 56 + `^([0-1]|2(?=([0-3])|4${s}00))[0-9]${s}[0-5][0-9](${s}([0-5]|6(?=0))[0-9])?(\.[0-9]{1,9})?$`, 57 + ); 58 + 59 + if (!isTimezoneCheckOn || !/[Z+\-]/.test(timeWithOffset)) { 60 + return validator.test(timeWithOffset); 61 + } 62 + 63 + // Case we got time in Zulu tz 64 + if (/Z$/.test(timeWithOffset)) { 65 + return validator.test(timeWithOffset.replace("Z", "")); 66 + } 67 + 68 + const isPositiveTimezoneOffset = timeWithOffset.includes("+"); 69 + const [time, offset] = timeWithOffset.split(/[+-]/); 70 + 71 + return validator.test(time) && 72 + isValidZoneOffset( 73 + offset, 74 + isPositiveTimezoneOffset, 75 + getStringSeparator(offset), 76 + ); 77 + } 78 + 79 + function isValidZoneOffset( 80 + offset: string, 81 + isPositiveOffset: boolean, 82 + s = ":", 83 + ): boolean { 84 + const validator = new RegExp( 85 + isPositiveOffset 86 + ? `^(0(?!(2${s}4)|0${s}3)|1(?=([0-1]|2(?=${s}[04])|[34](?=${s}0))))([03469](?=${s}[03])|[17](?=${s}0)|2(?=${s}[04])|5(?=${s}[034])|8(?=${s}[04]))${s}([03](?=0)|4(?=5))[05]$` 87 + : `^(0(?=[^0])|1(?=[0-2]))([39](?=${s}[03])|[0-24-8](?=${s}00))${s}[03]0$`, 88 + ); 89 + return validator.test(offset); 90 + } 91 + 92 + export function isValidISODateString(dateString: string): boolean { 93 + const [date, timeWithOffset] = dateString.split("T"); 94 + const dateSeparator = getStringSeparator(date); 95 + const isDateValid = isValidDate(date, dateSeparator); 96 + 97 + if (!timeWithOffset) { 98 + return false; 99 + } 100 + 101 + const timeStringSeparator = getTimeStringSeparator(timeWithOffset); 102 + return isDateValid && isValidTime(timeWithOffset, timeStringSeparator, true); 103 + } 104 + 105 + // Utility Functions 106 + 6 107 export const lessThanAgoMs = (time: Date, range: number): boolean => { 7 108 return Date.now() < time.getTime() + range; 8 109 }; ··· 13 114 currentDate.setHours(currentDate.getHours() + hours); 14 115 return currentDate; 15 116 }; 16 - 17 - function isValidISODateString(dateString: string): boolean { 18 - // Basic format check: YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ssZ 19 - const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/; 20 - if (!isoRegex.test(dateString)) { 21 - return false; 22 - } 23 - 24 - // Check if the date is valid and matches the original string when parsed and converted back 25 - const date = new Date(dateString); 26 - return !isNaN(date.getTime()) && date.toISOString() === dateString; 27 - } 28 117 29 118 export function toSimplifiedISOSafe(dateStr: string): string { 30 119 const date = new Date(dateStr);
+1 -1
deno.json
··· 1 1 { 2 - "workspace": ["xrpc-server", "lex-cli", "common", "syntax", "xrpc"] 2 + "workspace": ["common", "syntax", "lexicon", "xrpc", "xrpc-server", "lex-cli"] 3 3 }
+12 -48
deno.lock
··· 43 43 "jsr:@zod/zod@^4.1.11": "4.1.11", 44 44 "jsr:@zod/zod@^4.1.5": "4.1.5", 45 45 "npm:@atproto/crypto@~0.4.4": "0.4.4", 46 - "npm:@atproto/lexicon@~0.4.11": "0.4.14", 47 - "npm:@atproto/lexicon@~0.4.14": "0.4.14", 48 - "npm:@atproto/lexicon@~0.5.1": "0.5.1", 49 46 "npm:@ipld/dag-cbor@^9.2.5": "9.2.5", 50 47 "npm:@types/node@*": "24.2.0", 51 48 "npm:cbor-x@*": "1.6.0", ··· 59 56 "npm:rate-limiter-flexible@^2.4.1": "2.4.2", 60 57 "npm:uint8arrays@3.0.0": "3.0.0", 61 58 "npm:uint8arrays@^5.1.0": "5.1.0", 62 - "npm:ws@^8.12.0": "8.18.3" 59 + "npm:ws@^8.12.0": "8.18.3", 60 + "npm:zod@^4.1.11": "4.1.11" 63 61 }, 64 62 "jsr": { 65 63 "@cliffy/ansi@1.0.0-rc.8": { ··· 198 196 } 199 197 }, 200 198 "npm": { 201 - "@atproto/common-web@0.4.3": { 202 - "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", 203 - "dependencies": [ 204 - "graphemer", 205 - "multiformats@9.9.0", 206 - "uint8arrays@3.0.0", 207 - "zod" 208 - ] 209 - }, 210 199 "@atproto/crypto@0.4.4": { 211 200 "integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==", 212 201 "dependencies": [ ··· 215 204 "uint8arrays@3.0.0" 216 205 ] 217 206 }, 218 - "@atproto/lexicon@0.4.14": { 219 - "integrity": "sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==", 220 - "dependencies": [ 221 - "@atproto/common-web", 222 - "@atproto/syntax", 223 - "iso-datestring-validator", 224 - "multiformats@9.9.0", 225 - "zod" 226 - ] 227 - }, 228 - "@atproto/lexicon@0.5.1": { 229 - "integrity": "sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==", 230 - "dependencies": [ 231 - "@atproto/common-web", 232 - "@atproto/syntax", 233 - "iso-datestring-validator", 234 - "multiformats@9.9.0", 235 - "zod" 236 - ] 237 - }, 238 - "@atproto/syntax@0.4.1": { 239 - "integrity": "sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==" 240 - }, 241 207 "@cbor-extract/cbor-extract-darwin-arm64@2.2.0": { 242 208 "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", 243 209 "os": ["darwin"], ··· 364 330 "get-port@7.1.0": { 365 331 "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==" 366 332 }, 367 - "graphemer@1.4.0": { 368 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 369 - }, 370 333 "hash.js@1.1.7": { 371 334 "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", 372 335 "dependencies": [ ··· 394 357 }, 395 358 "inherits@2.0.4": { 396 359 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 397 - }, 398 - "iso-datestring-validator@2.2.2": { 399 - "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 400 360 }, 401 361 "key-encoder@2.0.3": { 402 362 "integrity": "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==", ··· 466 426 "ws@8.18.3": { 467 427 "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" 468 428 }, 469 - "zod@3.25.76": { 470 - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 429 + "zod@4.1.11": { 430 + "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==" 471 431 } 472 432 }, 473 433 "remote": { ··· 508 468 "jsr:@std/path@^1.1.2", 509 469 "jsr:@ts-morph/ts-morph@26", 510 470 "jsr:@zod/zod@^4.1.5", 511 - "npm:@atproto/lexicon@~0.4.14", 512 471 "npm:prettier@^3.6.2" 513 472 ] 514 473 }, 474 + "lexicon": { 475 + "dependencies": [ 476 + "jsr:@std/assert@^1.0.14", 477 + "npm:multiformats@^13.4.1", 478 + "npm:zod@^4.1.11" 479 + ] 480 + }, 515 481 "syntax": { 516 482 "dependencies": [ 517 483 "jsr:@std/assert@^1.0.14" ··· 519 485 }, 520 486 "xrpc": { 521 487 "dependencies": [ 522 - "jsr:@zod/zod@^4.1.11", 523 - "npm:@atproto/lexicon@~0.5.1" 488 + "jsr:@zod/zod@^4.1.11" 524 489 ] 525 490 }, 526 491 "xrpc-server": { ··· 531 496 "jsr:@std/encoding@^1.0.10", 532 497 "jsr:@zod/zod@^4.0.17", 533 498 "npm:@atproto/crypto@~0.4.4", 534 - "npm:@atproto/lexicon@~0.4.11", 535 499 "npm:get-port@^7.1.0", 536 500 "npm:http-errors@2", 537 501 "npm:key-encoder@^2.0.3",
+1 -1
lex-cli/codegen/client.ts
··· 4 4 type SourceFile, 5 5 VariableDeclarationKind, 6 6 } from "ts-morph"; 7 - import { type LexiconDoc, Lexicons, type LexRecord } from "@atproto/lexicon"; 7 + import { type LexiconDoc, Lexicons, type LexRecord } from "@atp/lexicon"; 8 8 import { NSID } from "@atp/syntax"; 9 9 import type { GeneratedAPI } from "../types.ts"; 10 10 import { gen, lexiconsTs, utilTs } from "./common.ts";
+4 -4
lex-cli/codegen/common.ts
··· 3 3 type SourceFile, 4 4 VariableDeclarationKind, 5 5 } from "ts-morph"; 6 - import type { LexiconDoc } from "@atproto/lexicon"; 6 + import type { LexiconDoc } from "@atp/lexicon"; 7 7 import type { GeneratedFile } from "../types.ts"; 8 8 import type { CodeGenOptions } from "./util.ts"; 9 9 import { format, type Options as PrettierOptions } from "prettier"; ··· 21 21 ) => 22 22 gen(project, "/util.ts", (file) => { 23 23 file.replaceWithText(` 24 - import { type ValidationResult } from '@atproto/lexicon' 24 + import { type ValidationResult } from '@atp/lexicon' 25 25 26 26 export type OmitKey<T, K extends keyof T> = { 27 27 [K2 in keyof T as K2 extends K ? never : K2]: T[K2] ··· 152 152 .join(""); 153 153 }; 154 154 155 - //= import { type LexiconDoc, Lexicons } from '@atproto/lexicon' 155 + //= import { type LexiconDoc, Lexicons } from '@atp/lexicon' 156 156 file 157 157 .addImportDeclaration({ 158 - moduleSpecifier: "@atproto/lexicon", 158 + moduleSpecifier: "@atp/lexicon", 159 159 }) 160 160 .addNamedImports([ 161 161 { name: "LexiconDoc", isTypeOnly: true },
+4 -5
lex-cli/codegen/lex-gen.ts
··· 10 10 LexObject, 11 11 LexPrimitive, 12 12 LexToken, 13 - } from "@atproto/lexicon"; 13 + } from "@atp/lexicon"; 14 14 import { 15 15 type CodeGenOptions, 16 16 toCamelCase, 17 17 toScreamingSnakeCase, 18 18 toTitleCase, 19 19 } from "./util.ts"; 20 - import type { LexiconDoc } from "@atproto/lexicon"; 21 - import type { LexUserType } from "@atproto/lexicon"; 20 + import type { LexiconDoc, LexUserType } from "@atp/lexicon"; 22 21 23 22 interface Commentable { 24 23 addJsDoc: ({ description }: { description: string }) => JSDoc; ··· 191 190 def.type === "object" 192 191 ); 193 192 194 - //= import {BlobRef} from '@atproto/lexicon' 193 + //= import {BlobRef} from '@atp/lexicon' 195 194 if (needsBlobRef) { 196 195 file 197 196 .addImportDeclaration({ 198 - moduleSpecifier: "@atproto/lexicon", 197 + moduleSpecifier: "@atp/lexicon", 199 198 }) 200 199 .addNamedImports([{ name: "BlobRef" }]); 201 200 }
+1 -1
lex-cli/codegen/server.ts
··· 4 4 type SourceFile, 5 5 VariableDeclarationKind, 6 6 } from "ts-morph"; 7 - import { type LexiconDoc, Lexicons } from "@atproto/lexicon"; 7 + import { type LexiconDoc, Lexicons } from "@atp/lexicon"; 8 8 import { NSID } from "@atp/syntax"; 9 9 import type { GeneratedAPI } from "../types.ts"; 10 10 import { gen, lexiconsTs, utilTs } from "./common.ts";
+1 -1
lex-cli/codegen/util.ts
··· 1 - import type { LexiconDoc, LexUserType } from "@atproto/lexicon"; 1 + import type { LexiconDoc, LexUserType } from "@atp/lexicon"; 2 2 import { NSID } from "@atp/syntax"; 3 3 4 4 export interface CodeGenOptions {
-1
lex-cli/deno.json
··· 8 8 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 9 9 "@std/fs": "jsr:@std/fs@^1.0.19", 10 10 "@std/path": "jsr:@std/path@^1.1.2", 11 - "@atproto/lexicon": "npm:@atproto/lexicon@^0.4.14", 12 11 "prettier": "npm:prettier@^3.6.2", 13 12 "ts-morph": "jsr:@ts-morph/ts-morph@^26.0.0", 14 13 "zod": "jsr:@zod/zod@^4.1.5"
+1 -1
lex-cli/mdgen/index.ts
··· 1 1 import { readFileSync } from "@std/fs/unstable-read-file"; 2 2 import { writeFileSync } from "@std/fs/unstable-write-file"; 3 - import type { LexiconDoc } from "@atproto/lexicon"; 3 + import type { LexiconDoc } from "@atp/lexicon"; 4 4 5 5 const INSERT_START = [ 6 6 "<!-- START lex generated content. Please keep comment here to allow auto update -->",
+1 -1
lex-cli/util.ts
··· 8 8 import { readDirSync } from "@std/fs/unstable-read-dir"; 9 9 import { colors } from "@cliffy/ansi/colors"; 10 10 import { ZodError } from "zod"; 11 - import { type LexiconDoc, parseLexiconDoc } from "@atproto/lexicon"; 11 + import { type LexiconDoc, parseLexiconDoc } from "@atp/lexicon"; 12 12 import type { FileDiff, GeneratedAPI } from "./types.ts"; 13 13 14 14 type RecursiveZodError = {
+71
lexicon/blob-refs.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { z } from "zod"; 3 + import { check, ipldToJson, schema } from "@atp/common"; 4 + 5 + export const typedJsonBlobRef = z.strictObject({ 6 + $type: z.literal("blob"), 7 + ref: schema.cid, 8 + mimeType: z.string(), 9 + size: z.number(), 10 + }); 11 + export type TypedJsonBlobRef = z.infer<typeof typedJsonBlobRef>; 12 + 13 + export const untypedJsonBlobRef = z.strictObject({ 14 + cid: z.string(), 15 + mimeType: z.string(), 16 + }); 17 + export type UntypedJsonBlobRef = z.infer<typeof untypedJsonBlobRef>; 18 + 19 + export const jsonBlobRef = z.union([typedJsonBlobRef, untypedJsonBlobRef]); 20 + export type JsonBlobRef = z.infer<typeof jsonBlobRef>; 21 + 22 + export class BlobRef { 23 + public original: JsonBlobRef; 24 + 25 + constructor( 26 + public ref: CID, 27 + public mimeType: string, 28 + public size: number, 29 + original?: JsonBlobRef, 30 + ) { 31 + this.original = original ?? { 32 + $type: "blob", 33 + ref, 34 + mimeType, 35 + size, 36 + }; 37 + } 38 + 39 + static asBlobRef(obj: unknown): BlobRef | null { 40 + if (check.is(obj, jsonBlobRef)) { 41 + return BlobRef.fromJsonRef(obj); 42 + } 43 + return null; 44 + } 45 + 46 + static fromJsonRef(json: JsonBlobRef): BlobRef { 47 + if (check.is(json, typedJsonBlobRef)) { 48 + return new BlobRef(json.ref as CID, json.mimeType, json.size, json); 49 + } else { 50 + return new BlobRef(CID.parse(json.cid), json.mimeType, -1); 51 + } 52 + } 53 + 54 + ipld(): TypedJsonBlobRef { 55 + return { 56 + $type: "blob", 57 + ref: this.ref, 58 + mimeType: this.mimeType, 59 + size: this.size, 60 + }; 61 + } 62 + 63 + toJSON() { 64 + return ipldToJson(this.ipld()) as { 65 + $type: "blob"; 66 + ref: { $link: string }; 67 + mimeType: string; 68 + size: number; 69 + }; 70 + } 71 + }
+11
lexicon/deno.json
··· 1 + { 2 + "name": "@atp/lexicon", 3 + "version": "0.1.0-alpha.1", 4 + "exports": "./mod.ts", 5 + "license": "MIT", 6 + "imports": { 7 + "@std/assert": "jsr:@std/assert@^1.0.14", 8 + "multiformats": "npm:multiformats@^13.4.1", 9 + "zod": "npm:zod@^4.1.11" 10 + } 11 + }
+255
lexicon/lexicons.ts
··· 1 + import { 2 + InvalidLexiconError, 3 + isObj, 4 + LexiconDefNotFoundError, 5 + type LexiconDoc, 6 + type LexRecord, 7 + type LexUserType, 8 + ValidationError, 9 + type ValidationResult, 10 + } from "./types.ts"; 11 + import { toLexUri } from "./util.ts"; 12 + import { 13 + assertValidRecord, 14 + assertValidXrpcInput, 15 + assertValidXrpcMessage, 16 + assertValidXrpcOutput, 17 + assertValidXrpcParams, 18 + } from "./validation/index.ts"; 19 + import { object as validateObject } from "./validation/complex.ts"; 20 + 21 + /** 22 + * A collection of compiled lexicons. 23 + */ 24 + export class Lexicons implements Iterable<LexiconDoc> { 25 + docs: Map<string, LexiconDoc> = new Map(); 26 + defs: Map<string, LexUserType> = new Map(); 27 + 28 + constructor(docs?: Iterable<LexiconDoc>) { 29 + if (docs) { 30 + for (const doc of docs) { 31 + this.add(doc); 32 + } 33 + } 34 + } 35 + 36 + /** 37 + * @example clone a lexicon: 38 + * ```ts 39 + * const clone = new Lexicons(originalLexicon) 40 + * ``` 41 + * 42 + * @example get docs array: 43 + * ```ts 44 + * const docs = Array.from(lexicons) 45 + * ``` 46 + */ 47 + [Symbol.iterator](): Iterator<LexiconDoc> { 48 + return this.docs.values(); 49 + } 50 + 51 + /** 52 + * Add a lexicon doc. 53 + */ 54 + add(doc: LexiconDoc): void { 55 + const uri = toLexUri(doc.id); 56 + if (this.docs.has(uri)) { 57 + throw new Error(`${uri} has already been registered`); 58 + } 59 + 60 + // WARNING 61 + // mutates the object 62 + // -prf 63 + resolveRefUris(doc, uri); 64 + 65 + this.docs.set(uri, doc); 66 + for (const [defUri, def] of iterDefs(doc)) { 67 + this.defs.set(defUri, def); 68 + } 69 + } 70 + 71 + /** 72 + * Remove a lexicon doc. 73 + */ 74 + remove(uri: string) { 75 + uri = toLexUri(uri); 76 + const doc = this.docs.get(uri); 77 + if (!doc) { 78 + throw new Error(`Unable to remove "${uri}": does not exist`); 79 + } 80 + for (const [defUri, _def] of iterDefs(doc)) { 81 + this.defs.delete(defUri); 82 + } 83 + this.docs.delete(uri); 84 + } 85 + 86 + /** 87 + * Get a lexicon doc. 88 + */ 89 + get(uri: string): LexiconDoc | undefined { 90 + uri = toLexUri(uri); 91 + return this.docs.get(uri); 92 + } 93 + 94 + /** 95 + * Get a definition. 96 + */ 97 + getDef(uri: string): LexUserType | undefined { 98 + uri = toLexUri(uri); 99 + return this.defs.get(uri); 100 + } 101 + 102 + /** 103 + * Get a def, throw if not found. Throws on not found. 104 + */ 105 + getDefOrThrow<T extends LexUserType["type"] = LexUserType["type"]>( 106 + uri: string, 107 + types?: readonly T[], 108 + ): Extract<LexUserType, { type: T }>; 109 + getDefOrThrow( 110 + uri: string, 111 + types?: readonly LexUserType["type"][], 112 + ): LexUserType { 113 + const def = this.getDef(uri); 114 + if (!def) { 115 + throw new LexiconDefNotFoundError(`Lexicon not found: ${uri}`); 116 + } 117 + if (types && !types.includes(def.type)) { 118 + throw new InvalidLexiconError( 119 + `Not a ${types.join(" or ")} lexicon: ${uri}`, 120 + ); 121 + } 122 + return def; 123 + } 124 + 125 + /** 126 + * Validate a record or object. 127 + */ 128 + validate(lexUri: string, value: unknown): ValidationResult { 129 + if (!isObj(value)) { 130 + throw new ValidationError(`Value must be an object`); 131 + } 132 + 133 + const lexUriNormalized = toLexUri(lexUri); 134 + const def = this.getDefOrThrow(lexUriNormalized, ["record", "object"]); 135 + 136 + if (def.type === "record") { 137 + return validateObject(this, "Record", def.record, value); 138 + } else if (def.type === "object") { 139 + return validateObject(this, "Object", def, value); 140 + } else { 141 + // shouldn't happen 142 + throw new InvalidLexiconError("Definition must be a record or object"); 143 + } 144 + } 145 + 146 + /** 147 + * Validate a record and throw on any error. 148 + */ 149 + assertValidRecord(lexUri: string, value: unknown) { 150 + if (!isObj(value)) { 151 + throw new ValidationError(`Record must be an object`); 152 + } 153 + if (!("$type" in value)) { 154 + throw new ValidationError(`Record/$type must be a string`); 155 + } 156 + const { $type } = value; 157 + if (typeof $type !== "string") { 158 + throw new ValidationError(`Record/$type must be a string`); 159 + } 160 + 161 + const lexUriNormalized = toLexUri(lexUri); 162 + if (toLexUri($type) !== lexUriNormalized) { 163 + throw new ValidationError( 164 + `Invalid $type: must be ${lexUriNormalized}, got ${$type}`, 165 + ); 166 + } 167 + 168 + const def = this.getDefOrThrow(lexUriNormalized, ["record"]); 169 + return assertValidRecord(this, def as LexRecord, value); 170 + } 171 + 172 + /** 173 + * Validate xrpc query params and throw on any error. 174 + */ 175 + assertValidXrpcParams(lexUri: string, value: unknown) { 176 + lexUri = toLexUri(lexUri); 177 + const def = this.getDefOrThrow(lexUri, [ 178 + "query", 179 + "procedure", 180 + "subscription", 181 + ]); 182 + return assertValidXrpcParams(this, def, value); 183 + } 184 + 185 + /** 186 + * Validate xrpc input body and throw on any error. 187 + */ 188 + assertValidXrpcInput(lexUri: string, value: unknown) { 189 + lexUri = toLexUri(lexUri); 190 + const def = this.getDefOrThrow(lexUri, ["procedure"]); 191 + return assertValidXrpcInput(this, def, value); 192 + } 193 + 194 + /** 195 + * Validate xrpc output body and throw on any error. 196 + */ 197 + assertValidXrpcOutput(lexUri: string, value: unknown) { 198 + lexUri = toLexUri(lexUri); 199 + const def = this.getDefOrThrow(lexUri, ["query", "procedure"]); 200 + return assertValidXrpcOutput(this, def, value); 201 + } 202 + 203 + /** 204 + * Validate xrpc subscription message and throw on any error. 205 + */ 206 + assertValidXrpcMessage<T = unknown>(lexUri: string, value: unknown): T { 207 + lexUri = toLexUri(lexUri); 208 + const def = this.getDefOrThrow(lexUri, ["subscription"]); 209 + return assertValidXrpcMessage(this, def, value) as T; 210 + } 211 + 212 + /** 213 + * Resolve a lex uri given a ref 214 + */ 215 + resolveLexUri(lexUri: string, ref: string) { 216 + lexUri = toLexUri(lexUri); 217 + return toLexUri(ref, lexUri); 218 + } 219 + } 220 + 221 + function* iterDefs(doc: LexiconDoc): Generator<[string, LexUserType]> { 222 + for (const defId in doc.defs) { 223 + yield [`lex:${doc.id}#${defId}`, doc.defs[defId]]; 224 + if (defId === "main") { 225 + yield [`lex:${doc.id}`, doc.defs[defId]]; 226 + } 227 + } 228 + } 229 + 230 + // WARNING 231 + // this method mutates objects 232 + // -prf 233 + function resolveRefUris(obj: Record<string, unknown>, baseUri: string): object { 234 + for (const k in obj) { 235 + if (obj.type === "ref") { 236 + obj.ref = toLexUri(obj.ref as string, baseUri); 237 + } else if (obj.type === "union") { 238 + const refs = obj.refs as string[]; 239 + obj.refs = refs.map((ref: string) => toLexUri(ref, baseUri)); 240 + } else if (Array.isArray(obj[k])) { 241 + const arrayValue = obj[k] as unknown[]; 242 + obj[k] = arrayValue.map((item: unknown) => { 243 + if (typeof item === "string") { 244 + return item.startsWith("#") ? toLexUri(item, baseUri) : item; 245 + } else if (item && typeof item === "object") { 246 + return resolveRefUris(item as Record<string, unknown>, baseUri); 247 + } 248 + return item; 249 + }); 250 + } else if (obj[k] && typeof obj[k] === "object") { 251 + obj[k] = resolveRefUris(obj[k] as Record<string, unknown>, baseUri); 252 + } 253 + } 254 + return obj; 255 + }
+4
lexicon/mod.ts
··· 1 + export * from "./types.ts"; 2 + export * from "./lexicons.ts"; 3 + export * from "./blob-refs.ts"; 4 + export * from "./serialize.ts";
+94
lexicon/serialize.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { 3 + check, 4 + ipldToJson, 5 + type IpldValue, 6 + jsonToIpld, 7 + type JsonValue, 8 + } from "@atp/common"; 9 + import { BlobRef, jsonBlobRef } from "./blob-refs.ts"; 10 + 11 + export type LexValue = 12 + | IpldValue 13 + | BlobRef 14 + | Array<LexValue> 15 + | { [key: string]: LexValue }; 16 + 17 + export type RepoRecord = Record<string, LexValue>; 18 + 19 + // @NOTE avoiding use of check.is() here only because it makes 20 + // these implementations slow, and they often live in hot paths. 21 + 22 + export const lexToIpld = (val: LexValue): IpldValue => { 23 + // walk arrays 24 + if (Array.isArray(val)) { 25 + return val.map((item) => lexToIpld(item)); 26 + } 27 + // objects 28 + if (val && typeof val === "object") { 29 + // convert blobs, leaving the original encoding so that we don't change CIDs on re-encode 30 + if (val instanceof BlobRef) { 31 + return val.original; 32 + } 33 + // retain cids & bytes 34 + if (CID.asCID(val) || val instanceof Uint8Array) { 35 + return val; 36 + } 37 + // walk plain objects 38 + const toReturn: Record<string, IpldValue> = {}; 39 + for (const key of Object.keys(val)) { 40 + toReturn[key] = lexToIpld((val as Record<string, LexValue>)[key]); 41 + } 42 + return toReturn; 43 + } 44 + // pass through 45 + return val; 46 + }; 47 + 48 + export const ipldToLex = (val: IpldValue): LexValue => { 49 + // map arrays 50 + if (Array.isArray(val)) { 51 + return val.map((item) => ipldToLex(item)); 52 + } 53 + // objects 54 + if (val && typeof val === "object") { 55 + // convert blobs, using hints to avoid expensive is() check 56 + const obj = val as Record<string, unknown>; 57 + if ( 58 + (obj["$type"] === "blob" || 59 + (typeof obj["cid"] === "string" && 60 + typeof obj["mimeType"] === "string")) && 61 + check.is(val, jsonBlobRef) 62 + ) { 63 + return BlobRef.fromJsonRef(val); 64 + } 65 + // retain cids, bytes 66 + if (CID.asCID(val) || val instanceof Uint8Array) { 67 + return val; 68 + } 69 + // map plain objects 70 + const toReturn: Record<string, LexValue> = {}; 71 + for (const key of Object.keys(val)) { 72 + toReturn[key] = ipldToLex((val as Record<string, IpldValue>)[key]); 73 + } 74 + return toReturn; 75 + } 76 + // pass through 77 + return val; 78 + }; 79 + 80 + export const lexToJson = (val: LexValue): JsonValue => { 81 + return ipldToJson(lexToIpld(val)); 82 + }; 83 + 84 + export const stringifyLex = (val: LexValue): string => { 85 + return JSON.stringify(lexToJson(val)); 86 + }; 87 + 88 + export const jsonToLex = (val: JsonValue): LexValue => { 89 + return ipldToLex(jsonToIpld(val)); 90 + }; 91 + 92 + export const jsonStringToLex = (val: string): LexValue => { 93 + return jsonToLex(JSON.parse(val)); 94 + };
+34
lexicon/tests/general_test.ts
··· 1 + import { assertThrows } from "@std/assert"; 2 + import { Lexicons } from "../mod.ts"; 3 + import LexiconDocs from "./scaffolds/lexicons.ts"; 4 + import { assertEquals } from "@std/assert/equals"; 5 + 6 + const lex = new Lexicons(LexiconDocs); 7 + 8 + Deno.test("Adds schemas", () => { 9 + assertThrows(() => lex.add(LexiconDocs[0])); 10 + }); 11 + 12 + Deno.test("Correctly references all definitions", () => { 13 + assertEquals(lex.getDef("com.example.kitchenSink"), LexiconDocs[0].defs.main); 14 + assertEquals( 15 + lex.getDef("lex:com.example.kitchenSink"), 16 + LexiconDocs[0].defs.main, 17 + ); 18 + assertEquals( 19 + lex.getDef("com.example.kitchenSink#main"), 20 + LexiconDocs[0].defs.main, 21 + ); 22 + assertEquals( 23 + lex.getDef("lex:com.example.kitchenSink#main"), 24 + LexiconDocs[0].defs.main, 25 + ); 26 + assertEquals( 27 + lex.getDef("com.example.kitchenSink#object"), 28 + LexiconDocs[0].defs.object, 29 + ); 30 + assertEquals( 31 + lex.getDef("lex:com.example.kitchenSink#object"), 32 + LexiconDocs[0].defs.object, 33 + ); 34 + });
+848
lexicon/tests/record_test.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { Lexicons } from "../mod.ts"; 3 + import LexiconDocs from "./scaffolds/lexicons.ts"; 4 + import { assert, assertEquals, assertThrows } from "@std/assert"; 5 + 6 + const lex = new Lexicons(LexiconDocs); 7 + 8 + const passingSink = { 9 + $type: "com.example.kitchenSink", 10 + object: { 11 + object: { boolean: true }, 12 + array: ["one", "two"], 13 + boolean: true, 14 + integer: 123, 15 + string: "string", 16 + }, 17 + array: ["one", "two"], 18 + boolean: true, 19 + integer: 123, 20 + string: "string", 21 + bytes: new Uint8Array([0, 1, 2, 3]), 22 + cidLink: CID.parse( 23 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 24 + ), 25 + }; 26 + 27 + Deno.test("Passes valid schemas", () => { 28 + lex.assertValidRecord("com.example.kitchenSink", passingSink); 29 + }); 30 + 31 + Deno.test("Fails invalid input types", () => { 32 + assertThrows(() => { 33 + lex.assertValidRecord("com.example.kitchenSink", undefined); 34 + }, "Record must be an object"); 35 + assertThrows(() => { 36 + lex.assertValidRecord("com.example.kitchenSink", 1234); 37 + }, "Record must be an object"); 38 + assertThrows(() => { 39 + lex.assertValidRecord("com.example.kitchenSink", "string"); 40 + }, "Record must be an object"); 41 + }); 42 + 43 + Deno.test("Fails incorrect $type", () => { 44 + assertThrows(() => { 45 + lex.assertValidRecord("com.example.kitchenSink", {}); 46 + }, "Record/$type must be a string"); 47 + assertThrows(() => { 48 + lex.assertValidRecord("com.example.kitchenSink", { $type: "foo" }); 49 + }, "Invalid $type: must be lex:com.example.kitchenSink, got foo"); 50 + }); 51 + 52 + Deno.test("Fails missing required", () => { 53 + assertThrows(() => { 54 + lex.assertValidRecord("com.example.kitchenSink", { 55 + $type: "com.example.kitchenSink", 56 + array: ["one", "two"], 57 + boolean: true, 58 + integer: 123, 59 + string: "string", 60 + datetime: new Date().toISOString(), 61 + atUri: "at://did:web:example.com/com.example.test/self", 62 + did: "did:web:example.com", 63 + cid: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 64 + bytes: new Uint8Array([0, 1, 2, 3]), 65 + cidLink: CID.parse( 66 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 67 + ), 68 + }); 69 + }, "Record must have the property 'object'"); 70 + assertThrows(() => { 71 + lex.assertValidRecord("com.example.kitchenSink", { 72 + ...passingSink, 73 + object: undefined, 74 + }); 75 + }, "Record must have the property 'object'"); 76 + }); 77 + 78 + Deno.test("Fails incorrect types", () => { 79 + assertThrows(() => { 80 + lex.assertValidRecord("com.example.kitchenSink", { 81 + ...passingSink, 82 + object: { 83 + ...passingSink.object, 84 + object: { boolean: "1234" }, 85 + }, 86 + }); 87 + }, "Record/object/object/boolean must be a boolean"); 88 + assertThrows(() => { 89 + lex.assertValidRecord("com.example.kitchenSink", { 90 + ...passingSink, 91 + object: true, 92 + }); 93 + }, "Record/object must be an object"); 94 + assertThrows(() => { 95 + lex.assertValidRecord("com.example.kitchenSink", { 96 + ...passingSink, 97 + array: 1234, 98 + }); 99 + }, "Record/array must be an array"); 100 + assertThrows(() => { 101 + lex.assertValidRecord("com.example.kitchenSink", { 102 + ...passingSink, 103 + integer: true, 104 + }); 105 + }, "Record/integer must be an integer"); 106 + assertThrows(() => { 107 + lex.assertValidRecord("com.example.kitchenSink", { 108 + ...passingSink, 109 + string: {}, 110 + }); 111 + }, "Record/string must be a string"); 112 + assertThrows(() => { 113 + lex.assertValidRecord("com.example.kitchenSink", { 114 + ...passingSink, 115 + bytes: 1234, 116 + }); 117 + }, "Record/bytes must be a byte array"); 118 + assertThrows(() => { 119 + lex.assertValidRecord("com.example.kitchenSink", { 120 + ...passingSink, 121 + cidLink: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 122 + }); 123 + }, "Record/cidLink must be a CID"); 124 + }); 125 + 126 + Deno.test("Handles optional properties correctly", () => { 127 + lex.assertValidRecord("com.example.optional", { 128 + $type: "com.example.optional", 129 + }); 130 + }); 131 + 132 + Deno.test("Handles default properties correctly", () => { 133 + const result = lex.assertValidRecord("com.example.default", { 134 + $type: "com.example.default", 135 + object: {}, 136 + }); 137 + assertEquals(result, { 138 + $type: "com.example.default", 139 + boolean: false, 140 + integer: 0, 141 + string: "", 142 + object: { 143 + boolean: true, 144 + integer: 1, 145 + string: "x", 146 + }, 147 + }); 148 + assert(!("datetime" in (result as Record<string, unknown>))); 149 + }); 150 + 151 + Deno.test("Handles unions correctly", () => { 152 + lex.assertValidRecord("com.example.union", { 153 + $type: "com.example.union", 154 + unionOpen: { 155 + $type: "com.example.kitchenSink#object", 156 + object: { boolean: true }, 157 + array: ["one", "two"], 158 + boolean: true, 159 + integer: 123, 160 + string: "string", 161 + }, 162 + unionClosed: { 163 + $type: "com.example.kitchenSink#subobject", 164 + boolean: true, 165 + }, 166 + }); 167 + lex.assertValidRecord("com.example.union", { 168 + $type: "com.example.union", 169 + unionOpen: { 170 + $type: "com.example.other", 171 + }, 172 + unionClosed: { 173 + $type: "com.example.kitchenSink#subobject", 174 + boolean: true, 175 + }, 176 + }); 177 + assertThrows(() => { 178 + lex.assertValidRecord("com.example.union", { 179 + $type: "com.example.union", 180 + unionOpen: {}, 181 + unionClosed: {}, 182 + }); 183 + }, 'Record/unionOpen must be an object which includes the "$type" property'); 184 + assertThrows( 185 + () => { 186 + lex.assertValidRecord("com.example.union", { 187 + $type: "com.example.union", 188 + unionOpen: { 189 + $type: "com.example.other", 190 + }, 191 + unionClosed: { 192 + $type: "com.example.other", 193 + boolean: true, 194 + }, 195 + }); 196 + }, 197 + "Record/unionClosed $type must be one of lex:com.example.kitchenSink#object, lex:com.example.kitchenSink#subobject", 198 + ); 199 + }); 200 + 201 + Deno.test("Handles unknowns correctly", () => { 202 + lex.assertValidRecord("com.example.unknown", { 203 + $type: "com.example.unknown", 204 + unknown: { foo: "bar" }, 205 + }); 206 + assertThrows(() => { 207 + lex.assertValidRecord("com.example.unknown", { 208 + $type: "com.example.unknown", 209 + }); 210 + }, 'Record must have the property "unknown"'); 211 + }); 212 + 213 + Deno.test("Applies array length constraints", () => { 214 + lex.assertValidRecord("com.example.arrayLength", { 215 + $type: "com.example.arrayLength", 216 + array: [1, 2, 3], 217 + }); 218 + assertThrows(() => { 219 + lex.assertValidRecord("com.example.arrayLength", { 220 + $type: "com.example.arrayLength", 221 + array: [1], 222 + }); 223 + }, "Record/array must not have fewer than 2 elements"); 224 + assertThrows(() => { 225 + lex.assertValidRecord("com.example.arrayLength", { 226 + $type: "com.example.arrayLength", 227 + array: [1, 2, 3, 4, 5], 228 + }); 229 + }, "Record/array must not have more than 4 elements"); 230 + }); 231 + 232 + Deno.test("Applies array item constraints", () => { 233 + assertThrows(() => { 234 + lex.assertValidRecord("com.example.arrayLength", { 235 + $type: "com.example.arrayLength", 236 + array: [1, "2", 3], 237 + }); 238 + }, "Record/array/1 must be an integer"); 239 + assertThrows(() => { 240 + lex.assertValidRecord("com.example.arrayLength", { 241 + $type: "com.example.arrayLength", 242 + array: [1, undefined, 3], 243 + }); 244 + }, "Record/array/1 must be an integer"); 245 + }); 246 + 247 + Deno.test("Applies boolean const constraint", () => { 248 + lex.assertValidRecord("com.example.boolConst", { 249 + $type: "com.example.boolConst", 250 + boolean: false, 251 + }); 252 + assertThrows(() => { 253 + lex.assertValidRecord("com.example.boolConst", { 254 + $type: "com.example.boolConst", 255 + boolean: true, 256 + }); 257 + }, "Record/boolean must be false"); 258 + }); 259 + 260 + Deno.test("Applies integer range constraint", () => { 261 + lex.assertValidRecord("com.example.integerRange", { 262 + $type: "com.example.integerRange", 263 + integer: 2, 264 + }); 265 + assertThrows(() => { 266 + lex.assertValidRecord("com.example.integerRange", { 267 + $type: "com.example.integerRange", 268 + integer: 1, 269 + }); 270 + }, "Record/integer can not be less than 2"); 271 + assertThrows(() => { 272 + lex.assertValidRecord("com.example.integerRange", { 273 + $type: "com.example.integerRange", 274 + integer: 5, 275 + }); 276 + }, "Record/integer can not be greater than 4"); 277 + }); 278 + 279 + Deno.test("Applies integer enum constraint", () => { 280 + lex.assertValidRecord("com.example.integerEnum", { 281 + $type: "com.example.integerEnum", 282 + integer: 2, 283 + }); 284 + assertThrows(() => { 285 + lex.assertValidRecord("com.example.integerEnum", { 286 + $type: "com.example.integerEnum", 287 + integer: 0, 288 + }); 289 + }, "Record/integer must be one of (1|2)"); 290 + }); 291 + 292 + Deno.test("Applies integer const constraint", () => { 293 + lex.assertValidRecord("com.example.integerConst", { 294 + $type: "com.example.integerConst", 295 + integer: 0, 296 + }); 297 + assertThrows(() => { 298 + lex.assertValidRecord("com.example.integerConst", { 299 + $type: "com.example.integerConst", 300 + integer: 1, 301 + }); 302 + }, "Record/integer must be 0"); 303 + }); 304 + 305 + Deno.test("Applies integer whole-number constraint", () => { 306 + assertThrows(() => { 307 + lex.assertValidRecord("com.example.integerRange", { 308 + $type: "com.example.integerRange", 309 + integer: 2.5, 310 + }); 311 + }, "Record/integer must be an integer"); 312 + }); 313 + 314 + Deno.test("Applies string length constraint", () => { 315 + // Shorter than two UTF8 characters 316 + assertThrows(() => { 317 + lex.assertValidRecord("com.example.stringLength", { 318 + $type: "com.example.stringLength", 319 + string: "", 320 + }); 321 + }, "Record/string must not be shorter than 2 characters"); 322 + assertThrows(() => { 323 + lex.assertValidRecord("com.example.stringLength", { 324 + $type: "com.example.stringLength", 325 + string: "a", 326 + }); 327 + }, "Record/string must not be shorter than 2 characters"); 328 + 329 + // Two to four UTF8 characters 330 + lex.assertValidRecord("com.example.stringLength", { 331 + $type: "com.example.stringLength", 332 + string: "ab", 333 + }); 334 + lex.assertValidRecord("com.example.stringLength", { 335 + $type: "com.example.stringLength", 336 + string: "\u0301", // Combining acute accent (2 bytes) 337 + }); 338 + lex.assertValidRecord("com.example.stringLength", { 339 + $type: "com.example.stringLength", 340 + string: "a\u0301", // 'a' + combining acute accent (1 + 2 bytes = 3 bytes) 341 + }); 342 + lex.assertValidRecord("com.example.stringLength", { 343 + $type: "com.example.stringLength", 344 + string: "aé", // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes 345 + }); 346 + lex.assertValidRecord("com.example.stringLength", { 347 + $type: "com.example.stringLength", 348 + string: "abc", 349 + }); 350 + lex.assertValidRecord("com.example.stringLength", { 351 + $type: "com.example.stringLength", 352 + string: "一", // CJK character (3 bytes) 353 + }); 354 + lex.assertValidRecord("com.example.stringLength", { 355 + $type: "com.example.stringLength", 356 + string: "\uD83D", // Unpaired high surrogate (3 bytes) 357 + }); 358 + lex.assertValidRecord("com.example.stringLength", { 359 + $type: "com.example.stringLength", 360 + string: "abcd", 361 + }); 362 + lex.assertValidRecord("com.example.stringLength", { 363 + $type: "com.example.stringLength", 364 + string: "éé", // 'é' + 'é' (2 + 2 bytes = 4 bytes) 365 + }); 366 + lex.assertValidRecord("com.example.stringLength", { 367 + $type: "com.example.stringLength", 368 + string: "aaé", // 1 + 1 + 2 = 4 bytes 369 + }); 370 + lex.assertValidRecord("com.example.stringLength", { 371 + $type: "com.example.stringLength", 372 + string: "👋", // 4 bytes 373 + }); 374 + 375 + assertThrows(() => { 376 + lex.assertValidRecord("com.example.stringLength", { 377 + $type: "com.example.stringLength", 378 + string: "abcde", 379 + }); 380 + }, "Record/string must not be longer than 4 characters"); 381 + assertThrows(() => { 382 + lex.assertValidRecord("com.example.stringLength", { 383 + $type: "com.example.stringLength", 384 + string: "a\u0301\u0301", // 1 + (2 * 2) = 5 bytes 385 + }); 386 + }, "Record/string must not be longer than 4 characters"); 387 + assertThrows(() => { 388 + lex.assertValidRecord("com.example.stringLength", { 389 + $type: "com.example.stringLength", 390 + string: "\uD83D\uD83D", // Two unpaired high surrogates (3 * 2 = 6 bytes) 391 + }); 392 + }, "Record/string must not be longer than 4 characters"); 393 + assertThrows(() => { 394 + lex.assertValidRecord("com.example.stringLength", { 395 + $type: "com.example.stringLength", 396 + string: "ééé", // 2 + 2 + 2 bytes = 6 bytes 397 + }); 398 + }, "Record/string must not be longer than 4 characters"); 399 + assertThrows(() => { 400 + lex.assertValidRecord("com.example.stringLength", { 401 + $type: "com.example.stringLength", 402 + string: "👋a", // 4 + 1 bytes = 5 bytes 403 + }); 404 + }, "Record/string must not be longer than 4 characters"); 405 + assertThrows(() => { 406 + lex.assertValidRecord("com.example.stringLength", { 407 + $type: "com.example.stringLength", 408 + string: "👨👨", // 4 + 4 = 8 bytes 409 + }); 410 + }, "Record/string must not be longer than 4 characters"); 411 + assertThrows(() => { 412 + lex.assertValidRecord("com.example.stringLength", { 413 + $type: "com.example.stringLength", 414 + string: "👨‍👩‍👧‍👧", // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes 415 + }); 416 + }, "Record/string must not be longer than 4 characters"); 417 + }); 418 + 419 + Deno.test("Applies string length constraint (no minLength)", () => { 420 + // Shorter than two UTF8 characters 421 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 422 + $type: "com.example.stringLengthNoMinLength", 423 + string: "", 424 + }); 425 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 426 + $type: "com.example.stringLengthNoMinLength", 427 + string: "a", 428 + }); 429 + 430 + // Two to four UTF8 characters 431 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 432 + $type: "com.example.stringLengthNoMinLength", 433 + string: "ab", 434 + }); 435 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 436 + $type: "com.example.stringLengthNoMinLength", 437 + string: "\u0301", // Combining acute accent (2 bytes) 438 + }); 439 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 440 + $type: "com.example.stringLengthNoMinLength", 441 + string: "a\u0301", // 'a' + combining acute accent (1 + 2 bytes = 3 bytes) 442 + }); 443 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 444 + $type: "com.example.stringLengthNoMinLength", 445 + string: "aé", // 'a' (1 byte) + 'é' (2 bytes) = 3 bytes 446 + }); 447 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 448 + $type: "com.example.stringLengthNoMinLength", 449 + string: "abc", 450 + }); 451 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 452 + $type: "com.example.stringLengthNoMinLength", 453 + string: "一", // CJK character (3 bytes) 454 + }); 455 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 456 + $type: "com.example.stringLengthNoMinLength", 457 + string: "\uD83D", // Unpaired high surrogate (3 bytes) 458 + }); 459 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 460 + $type: "com.example.stringLengthNoMinLength", 461 + string: "abcd", 462 + }); 463 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 464 + $type: "com.example.stringLengthNoMinLength", 465 + string: "éé", // 'é' + 'é' (2 + 2 bytes = 4 bytes) 466 + }); 467 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 468 + $type: "com.example.stringLengthNoMinLength", 469 + string: "aaé", // 1 + 1 + 2 = 4 bytes 470 + }); 471 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 472 + $type: "com.example.stringLengthNoMinLength", 473 + string: "👋", // 4 bytes 474 + }); 475 + 476 + assertThrows(() => { 477 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 478 + $type: "com.example.stringLengthNoMinLength", 479 + string: "abcde", 480 + }); 481 + }, "Record/string must not be longer than 4 characters"); 482 + assertThrows(() => { 483 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 484 + $type: "com.example.stringLengthNoMinLength", 485 + string: "a\u0301\u0301", // 1 + (2 * 2) = 5 bytes 486 + }); 487 + }, "Record/string must not be longer than 4 characters"); 488 + assertThrows(() => { 489 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 490 + $type: "com.example.stringLengthNoMinLength", 491 + string: "\uD83D\uD83D", // Two unpaired high surrogates (3 * 2 = 6 bytes) 492 + }); 493 + }, "Record/string must not be longer than 4 characters"); 494 + assertThrows(() => { 495 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 496 + $type: "com.example.stringLengthNoMinLength", 497 + string: "ééé", // 2 + 2 + 2 bytes = 6 bytes 498 + }); 499 + }, "Record/string must not be longer than 4 characters"); 500 + assertThrows(() => { 501 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 502 + $type: "com.example.stringLengthNoMinLength", 503 + string: "👋a", // 4 + 1 bytes = 5 bytes 504 + }); 505 + }, "Record/string must not be longer than 4 characters"); 506 + assertThrows(() => { 507 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 508 + $type: "com.example.stringLengthNoMinLength", 509 + string: "👨👨", // 4 + 4 = 8 bytes 510 + }); 511 + }, "Record/string must not be longer than 4 characters"); 512 + assertThrows(() => { 513 + lex.assertValidRecord("com.example.stringLengthNoMinLength", { 514 + $type: "com.example.stringLengthNoMinLength", 515 + string: "👨‍👩‍👧‍👧", // 4 emojis × 4 bytes + 3 ZWJs × 3 bytes = 25 bytes 516 + }); 517 + }, "Record/string must not be longer than 4 characters"); 518 + }); 519 + 520 + Deno.test("Applies grapheme string length constraint", () => { 521 + // Shorter than two graphemes 522 + assertThrows(() => { 523 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 524 + $type: "com.example.stringLengthGrapheme", 525 + string: "", 526 + }); 527 + }, "Record/string must not be shorter than 2 graphemes"); 528 + assertThrows(() => { 529 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 530 + $type: "com.example.stringLengthGrapheme", 531 + string: "\u0301\u0301\u0301", // Three combining acute accents 532 + }); 533 + }, "Record/string must not be shorter than 2 graphemes"); 534 + assertThrows(() => { 535 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 536 + $type: "com.example.stringLengthGrapheme", 537 + string: "a", 538 + }); 539 + }, "Record/string must not be shorter than 2 graphemes"); 540 + assertThrows(() => { 541 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 542 + $type: "com.example.stringLengthGrapheme", 543 + string: "a\u0301\u0301\u0301\u0301", // 'á́́́' ('a' with four combining acute accents) 544 + }); 545 + }, "Record/string must not be shorter than 2 graphemes"); 546 + assertThrows(() => { 547 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 548 + $type: "com.example.stringLengthGrapheme", 549 + string: "5\uFE0F", // '5️' with emoji presentation 550 + }); 551 + }, "Record/string must not be shorter than 2 graphemes"); 552 + assertThrows(() => { 553 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 554 + $type: "com.example.stringLengthGrapheme", 555 + string: "👨‍👩‍👧‍👧", 556 + }); 557 + }, "Record/string must not be shorter than 2 graphemes"); 558 + 559 + // Two to four graphemes 560 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 561 + $type: "com.example.stringLengthGrapheme", 562 + string: "ab", 563 + }); 564 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 565 + $type: "com.example.stringLengthGrapheme", 566 + string: "a\u0301b", // 'áb' with combining accent 567 + }); 568 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 569 + $type: "com.example.stringLengthGrapheme", 570 + string: "a\u0301b\u0301", // 'áb́' 571 + }); 572 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 573 + $type: "com.example.stringLengthGrapheme", 574 + string: "😀😀", 575 + }); 576 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 577 + $type: "com.example.stringLengthGrapheme", 578 + string: "12👨‍👩‍👧‍👧", 579 + }); 580 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 581 + $type: "com.example.stringLengthGrapheme", 582 + string: "abcd", 583 + }); 584 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 585 + $type: "com.example.stringLengthGrapheme", 586 + string: "a\u0301b\u0301c\u0301d\u0301", // 'áb́ćd́' 587 + }); 588 + 589 + // Longer than four graphemes 590 + assertThrows(() => { 591 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 592 + $type: "com.example.stringLengthGrapheme", 593 + string: "abcde", 594 + }); 595 + }, "Record/string must not be longer than 4 graphemes"); 596 + assertThrows(() => { 597 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 598 + $type: "com.example.stringLengthGrapheme", 599 + string: "a\u0301b\u0301c\u0301d\u0301e\u0301", // 'áb́ćd́é' 600 + }); 601 + }, "Record/string must not be longer than 4 graphemes"); 602 + assertThrows(() => { 603 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 604 + $type: "com.example.stringLengthGrapheme", 605 + string: "😀😀😀😀😀", 606 + }); 607 + }, "Record/string must not be longer than 4 graphemes"); 608 + assertThrows(() => { 609 + lex.assertValidRecord("com.example.stringLengthGrapheme", { 610 + $type: "com.example.stringLengthGrapheme", 611 + string: "ab😀de", 612 + }); 613 + }, "Record/string must not be longer than 4 graphemes"); 614 + }); 615 + 616 + Deno.test("Applies string enum constraint", () => { 617 + lex.assertValidRecord("com.example.stringEnum", { 618 + $type: "com.example.stringEnum", 619 + string: "a", 620 + }); 621 + assertThrows(() => { 622 + lex.assertValidRecord("com.example.stringEnum", { 623 + $type: "com.example.stringEnum", 624 + string: "c", 625 + }); 626 + }, "Record/string must be one of (a|b)"); 627 + }); 628 + 629 + Deno.test("Applies string const constraint", () => { 630 + lex.assertValidRecord("com.example.stringConst", { 631 + $type: "com.example.stringConst", 632 + string: "a", 633 + }); 634 + assertThrows(() => { 635 + lex.assertValidRecord("com.example.stringConst", { 636 + $type: "com.example.stringConst", 637 + string: "b", 638 + }); 639 + }, "Record/string must be a"); 640 + }); 641 + 642 + Deno.test("Applies datetime formatting constraint", () => { 643 + for ( 644 + const datetime of [ 645 + "2022-12-12T00:50:36.809Z", 646 + "2022-12-12T00:50:36Z", 647 + "2022-12-12T00:50:36.8Z", 648 + "2022-12-12T00:50:36.80Z", 649 + "2022-12-12T00:50:36+00:00", 650 + "2022-12-12T00:50:36.8+00:00", 651 + "2022-12-11T19:50:36-05:00", 652 + "2022-12-11T19:50:36.8-05:00", 653 + "2022-12-11T19:50:36.80-05:00", 654 + "2022-12-11T19:50:36.809-05:00", 655 + ] 656 + ) { 657 + lex.assertValidRecord("com.example.datetime", { 658 + $type: "com.example.datetime", 659 + datetime, 660 + }); 661 + } 662 + assertThrows( 663 + () => { 664 + lex.assertValidRecord("com.example.datetime", { 665 + $type: "com.example.datetime", 666 + datetime: "bad date", 667 + }); 668 + }, 669 + "Record/datetime must be an valid atproto datetime (both RFC-3339 and ISO-8601)", 670 + ); 671 + }); 672 + 673 + Deno.test("Applies uri formatting constraint", () => { 674 + for ( 675 + const uri of [ 676 + "https://example.com", 677 + "https://example.com/with/path", 678 + "https://example.com/with/path?and=query", 679 + "at://bsky.social", 680 + "did:example:test", 681 + ] 682 + ) { 683 + lex.assertValidRecord("com.example.uri", { 684 + $type: "com.example.uri", 685 + uri, 686 + }); 687 + } 688 + assertThrows(() => { 689 + lex.assertValidRecord("com.example.uri", { 690 + $type: "com.example.uri", 691 + uri: "not a uri", 692 + }); 693 + }, "Record/uri must be a uri"); 694 + }); 695 + 696 + Deno.test("Applies at-uri formatting constraint", () => { 697 + lex.assertValidRecord("com.example.atUri", { 698 + $type: "com.example.atUri", 699 + atUri: "at://did:web:example.com/com.example.test/self", 700 + }); 701 + assertThrows(() => { 702 + lex.assertValidRecord("com.example.atUri", { 703 + $type: "com.example.atUri", 704 + atUri: "http://not-atproto.com", 705 + }); 706 + }, "Record/atUri must be a valid at-uri"); 707 + }); 708 + 709 + Deno.test("Applies did formatting constraint", () => { 710 + lex.assertValidRecord("com.example.did", { 711 + $type: "com.example.did", 712 + did: "did:web:example.com", 713 + }); 714 + lex.assertValidRecord("com.example.did", { 715 + $type: "com.example.did", 716 + did: "did:plc:12345678abcdefghijklmnop", 717 + }); 718 + 719 + assertThrows(() => { 720 + lex.assertValidRecord("com.example.did", { 721 + $type: "com.example.did", 722 + did: "bad did", 723 + }); 724 + }, "Record/did must be a valid did"); 725 + assertThrows(() => { 726 + lex.assertValidRecord("com.example.did", { 727 + $type: "com.example.did", 728 + did: "did:short", 729 + }); 730 + }, "Record/did must be a valid did"); 731 + }); 732 + 733 + Deno.test("Applies handle formatting constraint", () => { 734 + lex.assertValidRecord("com.example.handle", { 735 + $type: "com.example.handle", 736 + handle: "test.bsky.social", 737 + }); 738 + lex.assertValidRecord("com.example.handle", { 739 + $type: "com.example.handle", 740 + handle: "bsky.test", 741 + }); 742 + 743 + assertThrows(() => { 744 + lex.assertValidRecord("com.example.handle", { 745 + $type: "com.example.handle", 746 + handle: "bad handle", 747 + }); 748 + }, "Record/handle must be a valid handle"); 749 + assertThrows(() => { 750 + lex.assertValidRecord("com.example.handle", { 751 + $type: "com.example.handle", 752 + handle: "-bad-.test", 753 + }); 754 + }, "Record/handle must be a valid handle"); 755 + }); 756 + 757 + Deno.test("Applies at-identifier formatting constraint", () => { 758 + lex.assertValidRecord("com.example.atIdentifier", { 759 + $type: "com.example.atIdentifier", 760 + atIdentifier: "bsky.test", 761 + }); 762 + lex.assertValidRecord("com.example.atIdentifier", { 763 + $type: "com.example.atIdentifier", 764 + atIdentifier: "did:plc:12345678abcdefghijklmnop", 765 + }); 766 + 767 + assertThrows(() => { 768 + lex.assertValidRecord("com.example.atIdentifier", { 769 + $type: "com.example.atIdentifier", 770 + atIdentifier: "bad id", 771 + }); 772 + }, "Record/atIdentifier must be a valid did or a handle"); 773 + assertThrows(() => { 774 + lex.assertValidRecord("com.example.atIdentifier", { 775 + $type: "com.example.atIdentifier", 776 + atIdentifier: "-bad-.test", 777 + }); 778 + }, "Record/atIdentifier must be a valid did or a handle"); 779 + }); 780 + 781 + Deno.test("Applies nsid formatting constraint", () => { 782 + lex.assertValidRecord("com.example.nsid", { 783 + $type: "com.example.nsid", 784 + nsid: "com.atproto.test", 785 + }); 786 + lex.assertValidRecord("com.example.nsid", { 787 + $type: "com.example.nsid", 788 + nsid: "app.bsky.nested.test", 789 + }); 790 + 791 + assertThrows(() => { 792 + lex.assertValidRecord("com.example.nsid", { 793 + $type: "com.example.nsid", 794 + nsid: "bad nsid", 795 + }); 796 + }, "Record/nsid must be a valid nsid"); 797 + assertThrows(() => { 798 + lex.assertValidRecord("com.example.nsid", { 799 + $type: "com.example.nsid", 800 + nsid: "com.bad-.foo", 801 + }); 802 + }, "Record/nsid must be a valid nsid"); 803 + }); 804 + 805 + Deno.test("Applies cid formatting constraint", () => { 806 + lex.assertValidRecord("com.example.cid", { 807 + $type: "com.example.cid", 808 + cid: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 809 + }); 810 + assertThrows(() => { 811 + lex.assertValidRecord("com.example.cid", { 812 + $type: "com.example.cid", 813 + cid: "abapsdofiuwrpoiasdfuaspdfoiu", 814 + }); 815 + }, "Record/cid must be a cid string"); 816 + }); 817 + 818 + Deno.test("Applies language formatting constraint", () => { 819 + lex.assertValidRecord("com.example.language", { 820 + $type: "com.example.language", 821 + language: "en-US-boont", 822 + }); 823 + assertThrows(() => { 824 + lex.assertValidRecord("com.example.language", { 825 + $type: "com.example.language", 826 + language: "not-a-language-", 827 + }); 828 + }, "Record/language must be a well-formed BCP 47 language tag"); 829 + }); 830 + 831 + Deno.test("Applies bytes length constraints", () => { 832 + lex.assertValidRecord("com.example.byteLength", { 833 + $type: "com.example.byteLength", 834 + bytes: new Uint8Array([1, 2, 3]), 835 + }); 836 + assertThrows(() => 837 + lex.assertValidRecord("com.example.byteLength", { 838 + $type: "com.example.byteLength", 839 + bytes: new Uint8Array([1]), 840 + }) 841 + ); 842 + assertThrows(() => 843 + lex.assertValidRecord("com.example.byteLength", { 844 + $type: "com.example.byteLength", 845 + bytes: new Uint8Array([1, 2, 3, 4, 5]), 846 + }) 847 + ); 848 + });
+545
lexicon/tests/scaffolds/lexicons.ts
··· 1 + import type { LexiconDoc } from "../../mod.ts"; 2 + 3 + const lexicons: LexiconDoc[] = [ 4 + { 5 + lexicon: 1, 6 + id: "com.example.kitchenSink", 7 + defs: { 8 + main: { 9 + type: "record", 10 + description: "A record", 11 + key: "tid", 12 + record: { 13 + type: "object", 14 + required: [ 15 + "object", 16 + "array", 17 + "boolean", 18 + "integer", 19 + "string", 20 + "bytes", 21 + "cidLink", 22 + ], 23 + properties: { 24 + object: { type: "ref", ref: "#object" }, 25 + array: { type: "array", items: { type: "string" } }, 26 + boolean: { type: "boolean" }, 27 + integer: { type: "integer" }, 28 + string: { type: "string" }, 29 + bytes: { type: "bytes" }, 30 + cidLink: { type: "cid-link" }, 31 + }, 32 + }, 33 + }, 34 + object: { 35 + type: "object", 36 + required: ["object", "array", "boolean", "integer", "string"], 37 + properties: { 38 + object: { type: "ref", ref: "#subobject" }, 39 + array: { type: "array", items: { type: "string" } }, 40 + boolean: { type: "boolean" }, 41 + integer: { type: "integer" }, 42 + string: { type: "string" }, 43 + }, 44 + }, 45 + subobject: { 46 + type: "object", 47 + required: ["boolean"], 48 + properties: { 49 + boolean: { type: "boolean" }, 50 + }, 51 + }, 52 + }, 53 + }, 54 + { 55 + lexicon: 1, 56 + id: "com.example.query", 57 + defs: { 58 + main: { 59 + type: "query", 60 + description: "A query", 61 + parameters: { 62 + type: "params", 63 + required: ["boolean", "integer"], 64 + properties: { 65 + boolean: { type: "boolean" }, 66 + integer: { type: "integer" }, 67 + string: { type: "string" }, 68 + array: { type: "array", items: { type: "string" } }, 69 + def: { type: "integer", default: 0 }, 70 + }, 71 + }, 72 + output: { 73 + encoding: "application/json", 74 + schema: { type: "ref", ref: "com.example.kitchenSink#object" }, 75 + }, 76 + }, 77 + }, 78 + }, 79 + { 80 + lexicon: 1, 81 + id: "com.example.procedure", 82 + defs: { 83 + main: { 84 + type: "procedure", 85 + description: "A procedure", 86 + parameters: { 87 + type: "params", 88 + required: ["boolean", "integer"], 89 + properties: { 90 + boolean: { type: "boolean" }, 91 + integer: { type: "integer" }, 92 + string: { type: "string" }, 93 + array: { type: "array", items: { type: "string" } }, 94 + }, 95 + }, 96 + input: { 97 + encoding: "application/json", 98 + schema: { type: "ref", ref: "com.example.kitchenSink#object" }, 99 + }, 100 + output: { 101 + encoding: "application/json", 102 + schema: { type: "ref", ref: "com.example.kitchenSink#object" }, 103 + }, 104 + }, 105 + }, 106 + }, 107 + { 108 + lexicon: 1, 109 + id: "com.example.optional", 110 + defs: { 111 + main: { 112 + type: "record", 113 + record: { 114 + type: "object", 115 + properties: { 116 + object: { type: "ref", ref: "com.example.kitchenSink#object" }, 117 + array: { type: "array", items: { type: "string" } }, 118 + boolean: { type: "boolean" }, 119 + integer: { type: "integer" }, 120 + string: { type: "string" }, 121 + }, 122 + }, 123 + }, 124 + }, 125 + }, 126 + { 127 + lexicon: 1, 128 + id: "com.example.default", 129 + defs: { 130 + main: { 131 + type: "record", 132 + record: { 133 + type: "object", 134 + required: ["boolean"], 135 + properties: { 136 + boolean: { type: "boolean", default: false }, 137 + integer: { type: "integer", default: 0 }, 138 + string: { type: "string", default: "" }, 139 + object: { type: "ref", ref: "#object" }, 140 + }, 141 + }, 142 + }, 143 + object: { 144 + type: "object", 145 + properties: { 146 + boolean: { type: "boolean", default: true }, 147 + integer: { type: "integer", default: 1 }, 148 + string: { type: "string", default: "x" }, 149 + }, 150 + }, 151 + }, 152 + }, 153 + { 154 + lexicon: 1, 155 + id: "com.example.union", 156 + defs: { 157 + main: { 158 + type: "record", 159 + description: "A record", 160 + key: "tid", 161 + record: { 162 + type: "object", 163 + required: ["unionOpen", "unionClosed"], 164 + properties: { 165 + unionOpen: { 166 + type: "union", 167 + refs: [ 168 + "com.example.kitchenSink#object", 169 + "com.example.kitchenSink#subobject", 170 + ], 171 + }, 172 + unionClosed: { 173 + type: "union", 174 + closed: true, 175 + refs: [ 176 + "com.example.kitchenSink#object", 177 + "com.example.kitchenSink#subobject", 178 + ], 179 + }, 180 + }, 181 + }, 182 + }, 183 + }, 184 + }, 185 + { 186 + lexicon: 1, 187 + id: "com.example.unknown", 188 + defs: { 189 + main: { 190 + type: "record", 191 + description: "A record", 192 + key: "tid", 193 + record: { 194 + type: "object", 195 + required: ["unknown"], 196 + properties: { 197 + unknown: { type: "unknown" }, 198 + optUnknown: { type: "unknown" }, 199 + }, 200 + }, 201 + }, 202 + }, 203 + }, 204 + { 205 + lexicon: 1, 206 + id: "com.example.arrayLength", 207 + defs: { 208 + main: { 209 + type: "record", 210 + record: { 211 + type: "object", 212 + properties: { 213 + array: { 214 + type: "array", 215 + minLength: 2, 216 + maxLength: 4, 217 + items: { type: "integer" }, 218 + }, 219 + }, 220 + }, 221 + }, 222 + }, 223 + }, 224 + { 225 + lexicon: 1, 226 + id: "com.example.boolConst", 227 + defs: { 228 + main: { 229 + type: "record", 230 + record: { 231 + type: "object", 232 + properties: { 233 + boolean: { 234 + type: "boolean", 235 + const: false, 236 + }, 237 + }, 238 + }, 239 + }, 240 + }, 241 + }, 242 + { 243 + lexicon: 1, 244 + id: "com.example.integerRange", 245 + defs: { 246 + main: { 247 + type: "record", 248 + record: { 249 + type: "object", 250 + properties: { 251 + integer: { 252 + type: "integer", 253 + minimum: 2, 254 + maximum: 4, 255 + }, 256 + }, 257 + }, 258 + }, 259 + }, 260 + }, 261 + { 262 + lexicon: 1, 263 + id: "com.example.integerEnum", 264 + defs: { 265 + main: { 266 + type: "record", 267 + record: { 268 + type: "object", 269 + properties: { 270 + integer: { 271 + type: "integer", 272 + enum: [1, 2], 273 + }, 274 + }, 275 + }, 276 + }, 277 + }, 278 + }, 279 + { 280 + lexicon: 1, 281 + id: "com.example.integerConst", 282 + defs: { 283 + main: { 284 + type: "record", 285 + record: { 286 + type: "object", 287 + properties: { 288 + integer: { 289 + type: "integer", 290 + const: 0, 291 + }, 292 + }, 293 + }, 294 + }, 295 + }, 296 + }, 297 + { 298 + lexicon: 1, 299 + id: "com.example.stringLength", 300 + defs: { 301 + main: { 302 + type: "record", 303 + record: { 304 + type: "object", 305 + properties: { 306 + string: { 307 + type: "string", 308 + minLength: 2, 309 + maxLength: 4, 310 + }, 311 + }, 312 + }, 313 + }, 314 + }, 315 + }, 316 + { 317 + lexicon: 1, 318 + id: "com.example.stringLengthNoMinLength", 319 + defs: { 320 + main: { 321 + type: "record", 322 + record: { 323 + type: "object", 324 + properties: { 325 + string: { 326 + type: "string", 327 + maxLength: 4, 328 + }, 329 + }, 330 + }, 331 + }, 332 + }, 333 + }, 334 + { 335 + lexicon: 1, 336 + id: "com.example.stringLengthGrapheme", 337 + defs: { 338 + main: { 339 + type: "record", 340 + record: { 341 + type: "object", 342 + properties: { 343 + string: { 344 + type: "string", 345 + minGraphemes: 2, 346 + maxGraphemes: 4, 347 + }, 348 + }, 349 + }, 350 + }, 351 + }, 352 + }, 353 + { 354 + lexicon: 1, 355 + id: "com.example.stringEnum", 356 + defs: { 357 + main: { 358 + type: "record", 359 + record: { 360 + type: "object", 361 + properties: { 362 + string: { 363 + type: "string", 364 + enum: ["a", "b"], 365 + }, 366 + }, 367 + }, 368 + }, 369 + }, 370 + }, 371 + { 372 + lexicon: 1, 373 + id: "com.example.stringConst", 374 + defs: { 375 + main: { 376 + type: "record", 377 + record: { 378 + type: "object", 379 + properties: { 380 + string: { 381 + type: "string", 382 + const: "a", 383 + }, 384 + }, 385 + }, 386 + }, 387 + }, 388 + }, 389 + { 390 + lexicon: 1, 391 + id: "com.example.datetime", 392 + defs: { 393 + main: { 394 + type: "record", 395 + record: { 396 + type: "object", 397 + properties: { 398 + datetime: { type: "string", format: "datetime" }, 399 + }, 400 + }, 401 + }, 402 + }, 403 + }, 404 + { 405 + lexicon: 1, 406 + id: "com.example.uri", 407 + defs: { 408 + main: { 409 + type: "record", 410 + record: { 411 + type: "object", 412 + properties: { 413 + uri: { type: "string", format: "uri" }, 414 + }, 415 + }, 416 + }, 417 + }, 418 + }, 419 + { 420 + lexicon: 1, 421 + id: "com.example.atUri", 422 + defs: { 423 + main: { 424 + type: "record", 425 + record: { 426 + type: "object", 427 + properties: { 428 + atUri: { type: "string", format: "at-uri" }, 429 + }, 430 + }, 431 + }, 432 + }, 433 + }, 434 + { 435 + lexicon: 1, 436 + id: "com.example.did", 437 + defs: { 438 + main: { 439 + type: "record", 440 + record: { 441 + type: "object", 442 + properties: { 443 + did: { type: "string", format: "did" }, 444 + }, 445 + }, 446 + }, 447 + }, 448 + }, 449 + { 450 + lexicon: 1, 451 + id: "com.example.handle", 452 + defs: { 453 + main: { 454 + type: "record", 455 + record: { 456 + type: "object", 457 + properties: { 458 + handle: { type: "string", format: "handle" }, 459 + }, 460 + }, 461 + }, 462 + }, 463 + }, 464 + { 465 + lexicon: 1, 466 + id: "com.example.atIdentifier", 467 + defs: { 468 + main: { 469 + type: "record", 470 + record: { 471 + type: "object", 472 + properties: { 473 + atIdentifier: { type: "string", format: "at-identifier" }, 474 + }, 475 + }, 476 + }, 477 + }, 478 + }, 479 + { 480 + lexicon: 1, 481 + id: "com.example.nsid", 482 + defs: { 483 + main: { 484 + type: "record", 485 + record: { 486 + type: "object", 487 + properties: { 488 + nsid: { type: "string", format: "nsid" }, 489 + }, 490 + }, 491 + }, 492 + }, 493 + }, 494 + { 495 + lexicon: 1, 496 + id: "com.example.cid", 497 + defs: { 498 + main: { 499 + type: "record", 500 + record: { 501 + type: "object", 502 + properties: { 503 + cid: { type: "string", format: "cid" }, 504 + }, 505 + }, 506 + }, 507 + }, 508 + }, 509 + { 510 + lexicon: 1, 511 + id: "com.example.language", 512 + defs: { 513 + main: { 514 + type: "record", 515 + record: { 516 + type: "object", 517 + properties: { 518 + language: { type: "string", format: "language" }, 519 + }, 520 + }, 521 + }, 522 + }, 523 + }, 524 + { 525 + lexicon: 1, 526 + id: "com.example.byteLength", 527 + defs: { 528 + main: { 529 + type: "record", 530 + record: { 531 + type: "object", 532 + properties: { 533 + bytes: { 534 + type: "bytes", 535 + minLength: 2, 536 + maxLength: 4, 537 + }, 538 + }, 539 + }, 540 + }, 541 + }, 542 + }, 543 + ]; 544 + 545 + export default lexicons;
+227
lexicon/tests/validation_test.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { type LexiconDoc, Lexicons, parseLexiconDoc } from "../mod.ts"; 3 + import LexiconDocs from "./scaffolds/lexicons.ts"; 4 + import { assert, assertEquals, assertFalse, assertThrows } from "@std/assert"; 5 + 6 + const lex = new Lexicons(LexiconDocs); 7 + 8 + Deno.test("Validates records correctly", () => { 9 + { 10 + const res = lex.validate("com.example.kitchenSink", { 11 + $type: "com.example.kitchenSink", 12 + object: { 13 + object: { boolean: true }, 14 + array: ["one", "two"], 15 + boolean: true, 16 + integer: 123, 17 + string: "string", 18 + }, 19 + array: ["one", "two"], 20 + boolean: true, 21 + integer: 123, 22 + string: "string", 23 + datetime: new Date().toISOString(), 24 + atUri: "at://did:web:example.com/com.example.test/self", 25 + did: "did:web:example.com", 26 + cid: "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 27 + bytes: new Uint8Array([0, 1, 2, 3]), 28 + cidLink: CID.parse( 29 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 30 + ), 31 + }); 32 + assert(res.success); 33 + } 34 + { 35 + const res = lex.validate("com.example.kitchenSink", {}); 36 + assertFalse(res.success); 37 + if (res.success) throw new Error("Asserted"); 38 + assertEquals(res.error?.message, 'Record must have the property "object"'); 39 + } 40 + }); 41 + Deno.test("Validates objects correctly", () => { 42 + { 43 + const res = lex.validate("com.example.kitchenSink#object", { 44 + object: { boolean: true }, 45 + array: ["one", "two"], 46 + boolean: true, 47 + integer: 123, 48 + string: "string", 49 + }); 50 + assert(res.success); 51 + } 52 + { 53 + const res = lex.validate("com.example.kitchenSink#object", {}); 54 + assertFalse(res.success); 55 + if (res.success) throw new Error("Asserted"); 56 + assertEquals(res.error?.message, 'Object must have the property "object"'); 57 + } 58 + }); 59 + Deno.test("fails when a required property is missing", () => { 60 + const schema = { 61 + lexicon: 1, 62 + id: "com.example.kitchenSink", 63 + defs: { 64 + test: { 65 + type: "object", 66 + required: ["foo"], 67 + properties: {}, 68 + }, 69 + }, 70 + }; 71 + assertThrows(() => { 72 + parseLexiconDoc(schema); 73 + }, 'Required field \\"foo\\" not defined'); 74 + }); 75 + Deno.test("allows unknown fields to be present", () => { 76 + const schema = { 77 + lexicon: 1, 78 + id: "com.example.unknownFields", 79 + defs: { 80 + test: { 81 + type: "object", 82 + properties: {}, 83 + foo: 3, 84 + }, 85 + }, 86 + }; 87 + 88 + assert(parseLexiconDoc(schema)); 89 + }); 90 + Deno.test("fails lexicon parsing when uri is invalid", () => { 91 + const schema: LexiconDoc = { 92 + lexicon: 1, 93 + id: "com.example.invalidUri", 94 + defs: { 95 + main: { 96 + type: "object", 97 + properties: { 98 + test: { type: "ref", ref: "com.example.invalid#test#test" }, 99 + }, 100 + }, 101 + }, 102 + }; 103 + 104 + assertThrows(() => { 105 + new Lexicons([schema]); 106 + }, "Uri can only have one hash segment"); 107 + }); 108 + Deno.test("fails validation when ref uri has multiple hash segments", () => { 109 + const schema: LexiconDoc = { 110 + lexicon: 1, 111 + id: "com.example.invalidUri", 112 + defs: { 113 + main: { 114 + type: "object", 115 + properties: { 116 + test: { type: "integer" }, 117 + }, 118 + }, 119 + object: { 120 + type: "object", 121 + required: ["test"], 122 + properties: { 123 + test: { 124 + type: "union", 125 + refs: ["com.example.invalidUri"], 126 + }, 127 + }, 128 + }, 129 + }, 130 + }; 131 + const lexicons = new Lexicons([schema]); 132 + assertThrows(() => { 133 + lexicons.validate("com.example.invalidUri#object", { 134 + test: { 135 + $type: "com.example.invalidUri#main#main", 136 + test: 123, 137 + }, 138 + }); 139 + }, "Uri can only have one hash segment"); 140 + }); 141 + Deno.test("union handles both implicit and explicit #main", () => { 142 + const schemas: LexiconDoc[] = [ 143 + { 144 + lexicon: 1, 145 + id: "com.example.implicitMain", 146 + defs: { 147 + main: { 148 + type: "object", 149 + required: ["test"], 150 + properties: { 151 + test: { type: "string" }, 152 + }, 153 + }, 154 + }, 155 + }, 156 + { 157 + lexicon: 1, 158 + id: "com.example.testImplicitMain", 159 + defs: { 160 + main: { 161 + type: "object", 162 + required: ["union"], 163 + properties: { 164 + union: { 165 + type: "union", 166 + refs: ["com.example.implicitMain"], 167 + }, 168 + }, 169 + }, 170 + }, 171 + }, 172 + { 173 + lexicon: 1, 174 + id: "com.example.testExplicitMain", 175 + defs: { 176 + main: { 177 + type: "object", 178 + required: ["union"], 179 + properties: { 180 + union: { 181 + type: "union", 182 + refs: ["com.example.implicitMain#main"], 183 + }, 184 + }, 185 + }, 186 + }, 187 + }, 188 + ]; 189 + 190 + const lexicon = new Lexicons(schemas); 191 + 192 + let result = lexicon.validate("com.example.testImplicitMain", { 193 + union: { 194 + $type: "com.example.implicitMain", 195 + test: 123, 196 + }, 197 + }); 198 + assertFalse(result.success); 199 + assertEquals(result["error"]?.message, "Object/union/test must be a string"); 200 + 201 + result = lexicon.validate("com.example.testImplicitMain", { 202 + union: { 203 + $type: "com.example.implicitMain#main", 204 + test: 123, 205 + }, 206 + }); 207 + assertFalse(result.success); 208 + assertEquals(result["error"]?.message, "Object/union/test must be a string"); 209 + 210 + result = lexicon.validate("com.example.testExplicitMain", { 211 + union: { 212 + $type: "com.example.implicitMain", 213 + test: 123, 214 + }, 215 + }); 216 + assertFalse(result.success); 217 + assertEquals(result["error"]?.message, "Object/union/test must be a string"); 218 + 219 + result = lexicon.validate("com.example.testExplicitMain", { 220 + union: { 221 + $type: "com.example.implicitMain#main", 222 + test: 123, 223 + }, 224 + }); 225 + assertFalse(result.success); 226 + assertEquals(result["error"]?.message, "Object/union/test must be a string"); 227 + });
+136
lexicon/tests/xrpc_test.ts
··· 1 + import { Lexicons } from "../mod.ts"; 2 + import LexiconDocs from "./scaffolds/lexicons.ts"; 3 + import { assertEquals, assertThrows } from "@std/assert"; 4 + 5 + const lex = new Lexicons(LexiconDocs); 6 + 7 + Deno.test("Passes valid inputs", () => { 8 + lex.assertValidXrpcInput("com.example.procedure", { 9 + object: { boolean: true }, 10 + array: ["one", "two"], 11 + boolean: true, 12 + float: 123.45, 13 + integer: 123, 14 + string: "string", 15 + }); 16 + }); 17 + 18 + Deno.test("Validates the input", () => { 19 + // dont need to check this extensively since it's the same logic as tested in record validation 20 + assertThrows(() => { 21 + lex.assertValidXrpcInput("com.example.procedure", { 22 + object: { boolean: "string" }, 23 + array: ["one", "two"], 24 + boolean: true, 25 + float: 123.45, 26 + integer: 123, 27 + string: "string", 28 + }); 29 + }, "Input/object/boolean must be a boolean"); 30 + assertThrows(() => { 31 + lex.assertValidXrpcInput("com.example.procedure", {}); 32 + }, 'Input must have the property "object"'); 33 + }); 34 + 35 + Deno.test("Passes valid outputs", () => { 36 + lex.assertValidXrpcOutput("com.example.query", { 37 + object: { boolean: true }, 38 + array: ["one", "two"], 39 + boolean: true, 40 + float: 123.45, 41 + integer: 123, 42 + string: "string", 43 + }); 44 + lex.assertValidXrpcOutput("com.example.procedure", { 45 + object: { boolean: true }, 46 + array: ["one", "two"], 47 + boolean: true, 48 + float: 123.45, 49 + integer: 123, 50 + string: "string", 51 + }); 52 + }); 53 + 54 + Deno.test("Validates the output", () => { 55 + // dont need to check this extensively since it's the same logic as tested in record validation 56 + assertThrows(() => { 57 + lex.assertValidXrpcOutput("com.example.query", { 58 + object: { boolean: "string" }, 59 + array: ["one", "two"], 60 + boolean: true, 61 + float: 123.45, 62 + integer: 123, 63 + string: "string", 64 + }); 65 + }, "Output/object/boolean must be a boolean"); 66 + assertThrows(() => { 67 + lex.assertValidXrpcOutput("com.example.procedure", {}); 68 + }, 'Output must have the property "object"'); 69 + }); 70 + 71 + Deno.test("Passes valid parameters", () => { 72 + const queryResult = lex.assertValidXrpcParams("com.example.query", { 73 + boolean: true, 74 + integer: 123, 75 + string: "string", 76 + array: ["x", "y"], 77 + }); 78 + assertEquals(queryResult, { 79 + boolean: true, 80 + integer: 123, 81 + string: "string", 82 + array: ["x", "y"], 83 + def: 0, 84 + }); 85 + const paramResult = lex.assertValidXrpcParams("com.example.procedure", { 86 + boolean: true, 87 + integer: 123, 88 + string: "string", 89 + array: ["x", "y"], 90 + def: 1, 91 + }); 92 + assertEquals(paramResult, { 93 + boolean: true, 94 + integer: 123, 95 + string: "string", 96 + array: ["x", "y"], 97 + def: 1, 98 + }); 99 + }); 100 + 101 + Deno.test("Handles required correctly", () => { 102 + lex.assertValidXrpcParams("com.example.query", { 103 + boolean: true, 104 + integer: 123, 105 + }); 106 + assertThrows(() => { 107 + lex.assertValidXrpcParams("com.example.query", { 108 + boolean: true, 109 + }); 110 + }, 'Params must have the property "integer"'); 111 + assertThrows(() => { 112 + lex.assertValidXrpcParams("com.example.query", { 113 + boolean: true, 114 + integer: undefined, 115 + }); 116 + }, 'Params must have the property "integer"'); 117 + }); 118 + 119 + Deno.test("Validates parameter types", () => { 120 + assertThrows(() => { 121 + lex.assertValidXrpcParams("com.example.query", { 122 + boolean: "string", 123 + integer: 123, 124 + string: "string", 125 + }); 126 + }, "boolean must be a boolean"); 127 + assertThrows(() => { 128 + lex.assertValidXrpcParams("com.example.query", { 129 + boolean: true, 130 + float: 123.45, 131 + integer: 123, 132 + string: "string", 133 + array: "x", 134 + }); 135 + }, "array must be an array"); 136 + });
+509
lexicon/types.ts
··· 1 + import { z } from "zod"; 2 + import { validateLanguage } from "@atp/common"; 3 + import { isValidNsid } from "@atp/syntax"; 4 + import { requiredPropertiesRefinement } from "./util.ts"; 5 + 6 + export const languageSchema = z 7 + .string() 8 + .refine(validateLanguage, "Invalid BCP47 language tag"); 9 + 10 + export const lexLang = z.record(languageSchema, z.string().optional()); 11 + 12 + export type LexLang = z.infer<typeof lexLang>; 13 + 14 + // primitives 15 + // = 16 + 17 + export const lexBoolean = z.object({ 18 + type: z.literal("boolean"), 19 + description: z.string().optional(), 20 + default: z.boolean().optional(), 21 + const: z.boolean().optional(), 22 + }); 23 + export type LexBoolean = z.infer<typeof lexBoolean>; 24 + 25 + export const lexInteger = z.object({ 26 + type: z.literal("integer"), 27 + description: z.string().optional(), 28 + default: z.number().int().optional(), 29 + minimum: z.number().int().optional(), 30 + maximum: z.number().int().optional(), 31 + enum: z.number().int().array().optional(), 32 + const: z.number().int().optional(), 33 + }); 34 + export type LexInteger = z.infer<typeof lexInteger>; 35 + 36 + export const lexStringFormat = z.enum([ 37 + "datetime", 38 + "uri", 39 + "at-uri", 40 + "did", 41 + "handle", 42 + "at-identifier", 43 + "nsid", 44 + "cid", 45 + "language", 46 + "tid", 47 + "record-key", 48 + ]); 49 + export type LexStringFormat = z.infer<typeof lexStringFormat>; 50 + 51 + export const lexString = z.object({ 52 + type: z.literal("string"), 53 + format: lexStringFormat.optional(), 54 + description: z.string().optional(), 55 + default: z.string().optional(), 56 + minLength: z.number().int().optional(), 57 + maxLength: z.number().int().optional(), 58 + minGraphemes: z.number().int().optional(), 59 + maxGraphemes: z.number().int().optional(), 60 + enum: z.string().array().optional(), 61 + const: z.string().optional(), 62 + knownValues: z.string().array().optional(), 63 + }); 64 + export type LexString = z.infer<typeof lexString>; 65 + 66 + export const lexUnknown = z.object({ 67 + type: z.literal("unknown"), 68 + description: z.string().optional(), 69 + }); 70 + export type LexUnknown = z.infer<typeof lexUnknown>; 71 + 72 + export const lexPrimitive = z.discriminatedUnion("type", [ 73 + lexBoolean, 74 + lexInteger, 75 + lexString, 76 + lexUnknown, 77 + ]); 78 + export type LexPrimitive = z.infer<typeof lexPrimitive>; 79 + 80 + // ipld types 81 + // = 82 + 83 + export const lexBytes = z.object({ 84 + type: z.literal("bytes"), 85 + description: z.string().optional(), 86 + maxLength: z.number().optional(), 87 + minLength: z.number().optional(), 88 + }); 89 + export type LexBytes = z.infer<typeof lexBytes>; 90 + 91 + export const lexCidLink = z.object({ 92 + type: z.literal("cid-link"), 93 + description: z.string().optional(), 94 + }); 95 + export type LexCidLink = z.infer<typeof lexCidLink>; 96 + 97 + export const lexIpldType = z.discriminatedUnion("type", [lexBytes, lexCidLink]); 98 + export type LexIpldType = z.infer<typeof lexIpldType>; 99 + 100 + // references 101 + // = 102 + 103 + export const lexRef = z.object({ 104 + type: z.literal("ref"), 105 + description: z.string().optional(), 106 + ref: z.string(), 107 + }); 108 + export type LexRef = z.infer<typeof lexRef>; 109 + 110 + export const lexRefUnion = z.object({ 111 + type: z.literal("union"), 112 + description: z.string().optional(), 113 + refs: z.string().array(), 114 + closed: z.boolean().optional(), 115 + }); 116 + export type LexRefUnion = z.infer<typeof lexRefUnion>; 117 + 118 + export const lexRefVariant = z.discriminatedUnion("type", [ 119 + lexRef, 120 + lexRefUnion, 121 + ]); 122 + export type LexRefVariant = z.infer<typeof lexRefVariant>; 123 + 124 + // blobs 125 + // = 126 + 127 + export const lexBlob = z.object({ 128 + type: z.literal("blob"), 129 + description: z.string().optional(), 130 + accept: z.string().array().optional(), 131 + maxSize: z.number().optional(), 132 + }); 133 + export type LexBlob = z.infer<typeof lexBlob>; 134 + 135 + // complex types 136 + // = 137 + 138 + export const lexArray = z.object({ 139 + type: z.literal("array"), 140 + description: z.string().optional(), 141 + items: z.discriminatedUnion("type", [ 142 + // lexPrimitive 143 + lexBoolean, 144 + lexInteger, 145 + lexString, 146 + lexUnknown, 147 + // lexIpldType 148 + lexBytes, 149 + lexCidLink, 150 + // lexRefVariant 151 + lexRef, 152 + lexRefUnion, 153 + // other 154 + lexBlob, 155 + ]), 156 + minLength: z.number().int().optional(), 157 + maxLength: z.number().int().optional(), 158 + }); 159 + export type LexArray = z.infer<typeof lexArray>; 160 + 161 + export const lexPrimitiveArray = z.object({ 162 + ...lexArray.shape, 163 + items: lexPrimitive, 164 + }); 165 + 166 + export type LexPrimitiveArray = z.infer<typeof lexPrimitiveArray>; 167 + 168 + export const lexToken = z.object({ 169 + type: z.literal("token"), 170 + description: z.string().optional(), 171 + }); 172 + export type LexToken = z.infer<typeof lexToken>; 173 + 174 + export const lexObject = z 175 + .object({ 176 + type: z.literal("object"), 177 + description: z.string().optional(), 178 + required: z.string().array().optional(), 179 + nullable: z.string().array().optional(), 180 + properties: z.record( 181 + z.string(), 182 + z.discriminatedUnion("type", [ 183 + lexArray, 184 + 185 + // lexPrimitive 186 + lexBoolean, 187 + lexInteger, 188 + lexString, 189 + lexUnknown, 190 + // lexIpldType 191 + lexBytes, 192 + lexCidLink, 193 + // lexRefVariant 194 + lexRef, 195 + lexRefUnion, 196 + // other 197 + lexBlob, 198 + ]), 199 + ), 200 + }) 201 + .superRefine(requiredPropertiesRefinement); 202 + export type LexObject = z.infer<typeof lexObject>; 203 + 204 + // permissions 205 + // = 206 + 207 + const lexPermission = z.intersection( 208 + z.object({ 209 + type: z.literal("permission"), 210 + resource: z.string().min(1), 211 + }), 212 + z.record( 213 + z.string(), 214 + z 215 + .union([ 216 + z.array(z.union([z.string(), z.number().int(), z.boolean()])), 217 + 218 + z.boolean(), 219 + z.number().int(), 220 + z.string(), 221 + ]) 222 + .optional(), 223 + ), 224 + ); 225 + 226 + export type LexPermission = z.infer<typeof lexPermission>; 227 + 228 + export const lexPermissionSet = z.object({ 229 + type: z.literal("permission-set"), 230 + description: z.string().optional(), 231 + title: z.string().optional(), 232 + "title:lang": lexLang.optional(), 233 + detail: z.string().optional(), 234 + "detail:lang": lexLang.optional(), 235 + permissions: z.array(lexPermission), 236 + }); 237 + 238 + export type LexPermissionSet = z.infer<typeof lexPermissionSet>; 239 + 240 + // xrpc 241 + // = 242 + 243 + export const lexXrpcParameters = z 244 + .object({ 245 + type: z.literal("params"), 246 + description: z.string().optional(), 247 + required: z.string().array().optional(), 248 + properties: z.record( 249 + z.string(), 250 + z.discriminatedUnion("type", [ 251 + lexPrimitiveArray, 252 + 253 + // lexPrimitive 254 + lexBoolean, 255 + lexInteger, 256 + lexString, 257 + lexUnknown, 258 + ]), 259 + ), 260 + }) 261 + .superRefine(requiredPropertiesRefinement); 262 + export type LexXrpcParameters = z.infer<typeof lexXrpcParameters>; 263 + 264 + export const lexXrpcBody = z.object({ 265 + description: z.string().optional(), 266 + encoding: z.string(), 267 + // @NOTE using discriminatedUnion with a refined schema requires zod >= 4 268 + schema: z.union([lexRefVariant, lexObject]).optional(), 269 + }); 270 + export type LexXrpcBody = z.infer<typeof lexXrpcBody>; 271 + 272 + export const lexXrpcSubscriptionMessage = z.object({ 273 + description: z.string().optional(), 274 + // @NOTE using discriminatedUnion with a refined schema requires zod >= 4 275 + schema: z.union([lexRefVariant, lexObject]).optional(), 276 + }); 277 + export type LexXrpcSubscriptionMessage = z.infer< 278 + typeof lexXrpcSubscriptionMessage 279 + >; 280 + 281 + export const lexXrpcError = z.object({ 282 + name: z.string(), 283 + description: z.string().optional(), 284 + }); 285 + export type LexXrpcError = z.infer<typeof lexXrpcError>; 286 + 287 + export const lexXrpcQuery = z.object({ 288 + type: z.literal("query"), 289 + description: z.string().optional(), 290 + parameters: lexXrpcParameters.optional(), 291 + output: lexXrpcBody.optional(), 292 + errors: lexXrpcError.array().optional(), 293 + }); 294 + export type LexXrpcQuery = z.infer<typeof lexXrpcQuery>; 295 + 296 + export const lexXrpcProcedure = z.object({ 297 + type: z.literal("procedure"), 298 + description: z.string().optional(), 299 + parameters: lexXrpcParameters.optional(), 300 + input: lexXrpcBody.optional(), 301 + output: lexXrpcBody.optional(), 302 + errors: lexXrpcError.array().optional(), 303 + }); 304 + export type LexXrpcProcedure = z.infer<typeof lexXrpcProcedure>; 305 + 306 + export const lexXrpcSubscription = z.object({ 307 + type: z.literal("subscription"), 308 + description: z.string().optional(), 309 + parameters: lexXrpcParameters.optional(), 310 + message: lexXrpcSubscriptionMessage.optional(), 311 + errors: lexXrpcError.array().optional(), 312 + }); 313 + export type LexXrpcSubscription = z.infer<typeof lexXrpcSubscription>; 314 + 315 + // database 316 + // = 317 + 318 + export const lexRecord = z.object({ 319 + type: z.literal("record"), 320 + description: z.string().optional(), 321 + key: z.string().optional(), 322 + record: lexObject, 323 + }); 324 + export type LexRecord = z.infer<typeof lexRecord>; 325 + 326 + // core 327 + // = 328 + 329 + // We need to use `z.custom` here because 330 + // lexXrpcProperty and lexObject are refined 331 + // `z.union` would work, but it's too slow 332 + // see #915 for details 333 + export const lexUserType = z.custom< 334 + | LexRecord 335 + | LexPermissionSet 336 + | LexXrpcQuery 337 + | LexXrpcProcedure 338 + | LexXrpcSubscription 339 + | LexBlob 340 + | LexArray 341 + | LexToken 342 + | LexObject 343 + | LexBoolean 344 + | LexInteger 345 + | LexString 346 + | LexBytes 347 + | LexCidLink 348 + | LexUnknown 349 + >( 350 + (val) => { 351 + if (!val || typeof val !== "object") { 352 + return false; 353 + } 354 + 355 + const obj = val as Record<string, unknown>; 356 + 357 + if (obj["type"] === undefined) { 358 + return false; 359 + } 360 + 361 + try { 362 + switch (obj["type"]) { 363 + case "record": 364 + lexRecord.parse(val); 365 + return true; 366 + 367 + case "permission-set": 368 + lexPermissionSet.parse(val); 369 + return true; 370 + 371 + case "query": 372 + lexXrpcQuery.parse(val); 373 + return true; 374 + case "procedure": 375 + lexXrpcProcedure.parse(val); 376 + return true; 377 + case "subscription": 378 + lexXrpcSubscription.parse(val); 379 + return true; 380 + 381 + case "blob": 382 + lexBlob.parse(val); 383 + return true; 384 + 385 + case "array": 386 + lexArray.parse(val); 387 + return true; 388 + case "token": 389 + lexToken.parse(val); 390 + return true; 391 + case "object": 392 + lexObject.parse(val); 393 + return true; 394 + 395 + case "boolean": 396 + lexBoolean.parse(val); 397 + return true; 398 + case "integer": 399 + lexInteger.parse(val); 400 + return true; 401 + case "string": 402 + lexString.parse(val); 403 + return true; 404 + case "bytes": 405 + lexBytes.parse(val); 406 + return true; 407 + case "cid-link": 408 + lexCidLink.parse(val); 409 + return true; 410 + case "unknown": 411 + lexUnknown.parse(val); 412 + return true; 413 + 414 + default: 415 + return false; 416 + } 417 + } catch { 418 + return false; 419 + } 420 + }, 421 + { 422 + error: (val) => { 423 + if (!val || typeof val !== "object") { 424 + return "Must be an object"; 425 + } 426 + 427 + if (val["type"] === undefined) { 428 + return "Must have a type"; 429 + } 430 + 431 + if (typeof val["type"] !== "string") { 432 + return "Type property must be a string"; 433 + } 434 + 435 + return `Invalid type: ${ 436 + val["type"] 437 + } must be one of: record, query, procedure, subscription, blob, array, token, object, boolean, integer, string, bytes, cid-link, unknown`; 438 + }, 439 + }, 440 + ); 441 + export type LexUserType = z.infer<typeof lexUserType>; 442 + 443 + export const lexiconDoc = z 444 + .object({ 445 + lexicon: z.literal(1), 446 + id: z.string().refine(isValidNsid, { 447 + message: "Must be a valid NSID", 448 + }), 449 + revision: z.number().optional(), 450 + description: z.string().optional(), 451 + defs: z.record(z.string(), lexUserType), 452 + }) 453 + .refine( 454 + (doc) => { 455 + for (const [defId, def] of Object.entries(doc.defs)) { 456 + if ( 457 + defId !== "main" && 458 + (def.type === "record" || 459 + def.type === "permission-set" || 460 + def.type === "procedure" || 461 + def.type === "query" || 462 + def.type === "subscription") 463 + ) { 464 + return false; 465 + } 466 + } 467 + return true; 468 + }, 469 + { 470 + message: 471 + `Records, permission sets, procedures, queries, and subscriptions must be the main definition.`, 472 + }, 473 + ); 474 + export type LexiconDoc = z.infer<typeof lexiconDoc>; 475 + 476 + // helpers 477 + // = 478 + 479 + export function isValidLexiconDoc(v: unknown): v is LexiconDoc { 480 + return lexiconDoc.safeParse(v).success; 481 + } 482 + 483 + export function isObj<V>(v: V): v is V & object { 484 + return v != null && typeof v === "object"; 485 + } 486 + 487 + export type DiscriminatedObject = { $type: string }; 488 + export function isDiscriminatedObject(v: unknown): v is DiscriminatedObject { 489 + return isObj(v) && "$type" in v && typeof v.$type === "string"; 490 + } 491 + 492 + export function parseLexiconDoc(v: unknown): LexiconDoc { 493 + lexiconDoc.parse(v); 494 + return v as LexiconDoc; 495 + } 496 + 497 + export type ValidationResult<V = unknown> = 498 + | { 499 + success: true; 500 + value: V; 501 + } 502 + | { 503 + success: false; 504 + error: ValidationError; 505 + }; 506 + 507 + export class ValidationError extends Error {} 508 + export class InvalidLexiconError extends Error {} 509 + export class LexiconDefNotFoundError extends Error {}
+58
lexicon/util.ts
··· 1 + import { z } from "zod"; 2 + 3 + export function toLexUri(str: string, baseUri?: string): string { 4 + if (str.split("#").length > 2) { 5 + throw new Error("Uri can only have one hash segment"); 6 + } 7 + 8 + if (str.startsWith("lex:")) { 9 + return str; 10 + } 11 + if (str.startsWith("#")) { 12 + if (!baseUri) { 13 + throw new Error(`Unable to resolve uri without anchor: ${str}`); 14 + } 15 + return `${baseUri}${str}`; 16 + } 17 + return `lex:${str}`; 18 + } 19 + 20 + export function requiredPropertiesRefinement< 21 + ObjectType extends { 22 + required?: string[]; 23 + properties?: Record<string, unknown>; 24 + }, 25 + >(object: ObjectType, ctx: z.RefinementCtx) { 26 + // Required fields check 27 + if (object.required === undefined) { 28 + return; 29 + } 30 + 31 + if (!Array.isArray(object.required)) { 32 + ctx.addIssue({ 33 + code: z.ZodIssueCode.invalid_type, 34 + received: typeof object.required, 35 + expected: "array", 36 + }); 37 + return; 38 + } 39 + 40 + if (object.properties === undefined) { 41 + if (object.required.length > 0) { 42 + ctx.addIssue({ 43 + code: z.ZodIssueCode.custom, 44 + message: `Required fields defined but no properties defined`, 45 + }); 46 + } 47 + return; 48 + } 49 + 50 + for (const field of object.required) { 51 + if (object.properties[field] === undefined) { 52 + ctx.addIssue({ 53 + code: z.ZodIssueCode.custom, 54 + message: `Required field "${field}" not defined`, 55 + }); 56 + } 57 + } 58 + }
+16
lexicon/validation/blob.ts
··· 1 + import { BlobRef } from "../blob-refs.ts"; 2 + import { ValidationError, type ValidationResult } from "../types.ts"; 3 + 4 + export function blob( 5 + path: string, 6 + value: unknown, 7 + ): ValidationResult { 8 + // check 9 + if (!value || !(value instanceof BlobRef)) { 10 + return { 11 + success: false, 12 + error: new ValidationError(`${path} should be a blob ref`), 13 + }; 14 + } 15 + return { success: true, value }; 16 + }
+212
lexicon/validation/complex.ts
··· 1 + import type { Lexicons } from "../lexicons.ts"; 2 + import { 3 + isDiscriminatedObject, 4 + isObj, 5 + type LexArray, 6 + type LexRefVariant, 7 + type LexUserType, 8 + ValidationError, 9 + type ValidationResult, 10 + } from "../types.ts"; 11 + import { toLexUri } from "../util.ts"; 12 + import { blob } from "./blob.ts"; 13 + import { validate as validatePrimitive } from "./primitives.ts"; 14 + 15 + export function validate( 16 + lexicons: Lexicons, 17 + path: string, 18 + def: LexUserType, 19 + value: unknown, 20 + ): ValidationResult { 21 + switch (def.type) { 22 + case "object": 23 + return object(lexicons, path, def, value); 24 + case "array": 25 + return array(lexicons, path, def, value); 26 + case "blob": 27 + return blob(path, value); 28 + default: 29 + return validatePrimitive(path, def, value); 30 + } 31 + } 32 + 33 + export function array( 34 + lexicons: Lexicons, 35 + path: string, 36 + def: LexArray, 37 + value: unknown, 38 + ): ValidationResult { 39 + // type 40 + if (!Array.isArray(value)) { 41 + return { 42 + success: false, 43 + error: new ValidationError(`${path} must be an array`), 44 + }; 45 + } 46 + 47 + // maxLength 48 + if (typeof def.maxLength === "number") { 49 + if ((value as Array<unknown>).length > def.maxLength) { 50 + return { 51 + success: false, 52 + error: new ValidationError( 53 + `${path} must not have more than ${def.maxLength} elements`, 54 + ), 55 + }; 56 + } 57 + } 58 + 59 + // minLength 60 + if (typeof def.minLength === "number") { 61 + if ((value as Array<unknown>).length < def.minLength) { 62 + return { 63 + success: false, 64 + error: new ValidationError( 65 + `${path} must not have fewer than ${def.minLength} elements`, 66 + ), 67 + }; 68 + } 69 + } 70 + 71 + // items 72 + const itemsDef = def.items; 73 + for (let i = 0; i < (value as Array<unknown>).length; i++) { 74 + const itemValue = value[i]; 75 + const itemPath = `${path}/${i}`; 76 + const res = validateOneOf(lexicons, itemPath, itemsDef, itemValue); 77 + if (!res.success) { 78 + return res; 79 + } 80 + } 81 + 82 + return { success: true, value }; 83 + } 84 + 85 + export function object( 86 + lexicons: Lexicons, 87 + path: string, 88 + def: LexUserType, 89 + value: unknown, 90 + ): ValidationResult { 91 + // type 92 + if (!isObj(value)) { 93 + return { 94 + success: false, 95 + error: new ValidationError(`${path} must be an object`), 96 + }; 97 + } 98 + 99 + // properties 100 + let resultValue = value as Record<string, unknown>; 101 + if ("properties" in def && def.properties != null) { 102 + for (const key in def.properties) { 103 + const keyValue = resultValue[key]; 104 + if (keyValue === null && def.nullable?.includes(key)) { 105 + continue; 106 + } 107 + const propDef = def.properties[key]; 108 + if (keyValue === undefined && !def.required?.includes(key)) { 109 + // Fast path for non-required undefined props. 110 + if ( 111 + propDef.type === "integer" || 112 + propDef.type === "boolean" || 113 + propDef.type === "string" 114 + ) { 115 + if (propDef.default === undefined) { 116 + continue; 117 + } 118 + } else { 119 + // Other types have no defaults. 120 + continue; 121 + } 122 + } 123 + const propPath = `${path}/${key}`; 124 + const validated = validateOneOf(lexicons, propPath, propDef, keyValue); 125 + const propValue = validated.success ? validated.value : keyValue; 126 + 127 + // Return error for bad validation, giving required rule precedence 128 + if (propValue === undefined) { 129 + if (def.required?.includes(key)) { 130 + return { 131 + success: false, 132 + error: new ValidationError( 133 + `${path} must have the property "${key}"`, 134 + ), 135 + }; 136 + } 137 + } else { 138 + if (!validated.success) { 139 + return validated; 140 + } 141 + } 142 + 143 + // Adjust value based on e.g. applied defaults, cloning shallowly if there was a changed value 144 + if (propValue !== keyValue) { 145 + if (resultValue === value) { 146 + // Lazy shallow clone 147 + resultValue = { ...value }; 148 + } 149 + resultValue[key] = propValue; 150 + } 151 + } 152 + } 153 + 154 + return { success: true, value: resultValue }; 155 + } 156 + 157 + export function validateOneOf( 158 + lexicons: Lexicons, 159 + path: string, 160 + def: LexRefVariant | LexUserType, 161 + value: unknown, 162 + mustBeObj = false, // this is the only type constraint we need currently (used by xrpc body schema validators) 163 + ): ValidationResult { 164 + let concreteDef: LexUserType; 165 + 166 + if (def.type === "union") { 167 + if (!isDiscriminatedObject(value)) { 168 + return { 169 + success: false, 170 + error: new ValidationError( 171 + `${path} must be an object which includes the "$type" property`, 172 + ), 173 + }; 174 + } 175 + if (!refsContainType(def.refs, value.$type)) { 176 + if (def.closed) { 177 + return { 178 + success: false, 179 + error: new ValidationError( 180 + `${path} $type must be one of ${def.refs.join(", ")}`, 181 + ), 182 + }; 183 + } 184 + return { success: true, value }; 185 + } else { 186 + concreteDef = lexicons.getDefOrThrow(value.$type); 187 + } 188 + } else if (def.type === "ref") { 189 + concreteDef = lexicons.getDefOrThrow(def.ref); 190 + } else { 191 + concreteDef = def; 192 + } 193 + 194 + return mustBeObj 195 + ? object(lexicons, path, concreteDef, value) 196 + : validate(lexicons, path, concreteDef, value); 197 + } 198 + 199 + // to avoid bugs like #0189 this needs to handle both 200 + // explicit and implicit #main 201 + const refsContainType = (refs: string[], type: string) => { 202 + const lexUri = toLexUri(type); 203 + if (refs.includes(lexUri)) { 204 + return true; 205 + } 206 + 207 + if (lexUri.endsWith("#main")) { 208 + return refs.includes(lexUri.slice(0, -5)); 209 + } else { 210 + return !lexUri.includes("#") && refs.includes(`${lexUri}#main`); 211 + } 212 + };
+155
lexicon/validation/formats.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { isValidISODateString, validateLanguage } from "@atp/common"; 3 + import { 4 + ensureValidAtUri, 5 + ensureValidDid, 6 + ensureValidHandle, 7 + ensureValidRecordKey, 8 + isValidNsid, 9 + isValidTid, 10 + } from "@atp/syntax"; 11 + import { ValidationError, type ValidationResult } from "../types.ts"; 12 + 13 + export function datetime(path: string, value: string): ValidationResult { 14 + try { 15 + if (!isValidISODateString(value)) { 16 + throw new Error(); 17 + } 18 + } catch { 19 + return { 20 + success: false, 21 + error: new ValidationError( 22 + `${path} must be an valid atproto datetime (both RFC-3339 and ISO-8601)`, 23 + ), 24 + }; 25 + } 26 + return { success: true, value }; 27 + } 28 + 29 + export function uri(path: string, value: string): ValidationResult { 30 + const isUri = value.match(/^\w+:(?:\/\/)?[^\s/][^\s]*$/) !== null; 31 + if (!isUri) { 32 + return { 33 + success: false, 34 + error: new ValidationError(`${path} must be a uri`), 35 + }; 36 + } 37 + return { success: true, value }; 38 + } 39 + 40 + export function atUri(path: string, value: string): ValidationResult { 41 + try { 42 + ensureValidAtUri(value); 43 + } catch { 44 + return { 45 + success: false, 46 + error: new ValidationError(`${path} must be a valid at-uri`), 47 + }; 48 + } 49 + 50 + return { success: true, value }; 51 + } 52 + 53 + export function did(path: string, value: string): ValidationResult { 54 + try { 55 + ensureValidDid(value); 56 + } catch { 57 + return { 58 + success: false, 59 + error: new ValidationError(`${path} must be a valid did`), 60 + }; 61 + } 62 + 63 + return { success: true, value }; 64 + } 65 + 66 + export function handle(path: string, value: string): ValidationResult { 67 + try { 68 + ensureValidHandle(value); 69 + } catch { 70 + return { 71 + success: false, 72 + error: new ValidationError(`${path} must be a valid handle`), 73 + }; 74 + } 75 + 76 + return { success: true, value }; 77 + } 78 + 79 + export function atIdentifier(path: string, value: string): ValidationResult { 80 + // We can discriminate based on the "did:" prefix 81 + if (value.startsWith("did:")) { 82 + const didResult = did(path, value); 83 + if (didResult.success) return didResult; 84 + } else { 85 + const handleResult = handle(path, value); 86 + if (handleResult.success) return handleResult; 87 + } 88 + 89 + return { 90 + success: false, 91 + error: new ValidationError(`${path} must be a valid did or a handle`), 92 + }; 93 + } 94 + 95 + export function nsid(path: string, value: string): ValidationResult { 96 + if (isValidNsid(value)) { 97 + return { 98 + success: true, 99 + value, 100 + }; 101 + } else { 102 + return { 103 + success: false, 104 + error: new ValidationError(`${path} must be a valid nsid`), 105 + }; 106 + } 107 + } 108 + 109 + export function cid(path: string, value: string): ValidationResult { 110 + try { 111 + CID.parse(value); 112 + } catch { 113 + return { 114 + success: false, 115 + error: new ValidationError(`${path} must be a cid string`), 116 + }; 117 + } 118 + return { success: true, value }; 119 + } 120 + 121 + // The language format validates well-formed BCP 47 language tags: https://www.rfc-editor.org/info/bcp47 122 + export function language(path: string, value: string): ValidationResult { 123 + if (validateLanguage(value)) { 124 + return { success: true, value }; 125 + } 126 + return { 127 + success: false, 128 + error: new ValidationError( 129 + `${path} must be a well-formed BCP 47 language tag`, 130 + ), 131 + }; 132 + } 133 + 134 + export function tid(path: string, value: string): ValidationResult { 135 + if (isValidTid(value)) { 136 + return { success: true, value }; 137 + } 138 + 139 + return { 140 + success: false, 141 + error: new ValidationError(`${path} must be a valid TID`), 142 + }; 143 + } 144 + 145 + export function recordKey(path: string, value: string): ValidationResult { 146 + try { 147 + ensureValidRecordKey(value); 148 + } catch { 149 + return { 150 + success: false, 151 + error: new ValidationError(`${path} must be a valid Record Key`), 152 + }; 153 + } 154 + return { success: true, value }; 155 + }
+84
lexicon/validation/index.ts
··· 1 + import type { Lexicons } from "../lexicons.ts"; 2 + import type { 3 + LexRecord, 4 + LexRefVariant, 5 + LexUserType, 6 + LexXrpcProcedure, 7 + LexXrpcQuery, 8 + LexXrpcSubscription, 9 + } from "../types.ts"; 10 + import { object, validateOneOf } from "./complex.ts"; 11 + import { params } from "./xrpc.ts"; 12 + 13 + export function assertValidRecord( 14 + lexicons: Lexicons, 15 + def: LexRecord, 16 + value: unknown, 17 + ) { 18 + const res = object(lexicons, "Record", def.record, value); 19 + if (!res.success) throw res.error; 20 + return res.value; 21 + } 22 + 23 + export function assertValidXrpcParams( 24 + lexicons: Lexicons, 25 + def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription, 26 + value: unknown, 27 + ) { 28 + if (def.parameters) { 29 + const res = params(lexicons, "Params", def.parameters, value); 30 + if (!res.success) throw res.error; 31 + return res.value; 32 + } 33 + } 34 + 35 + export function assertValidXrpcInput( 36 + lexicons: Lexicons, 37 + def: LexXrpcProcedure, 38 + value: unknown, 39 + ) { 40 + if (def.input?.schema) { 41 + // loop: all input schema definitions 42 + return assertValidOneOf(lexicons, "Input", def.input.schema, value, true); 43 + } 44 + } 45 + 46 + export function assertValidXrpcOutput( 47 + lexicons: Lexicons, 48 + def: LexXrpcProcedure | LexXrpcQuery, 49 + value: unknown, 50 + ) { 51 + if (def.output?.schema) { 52 + // loop: all output schema definitions 53 + return assertValidOneOf(lexicons, "Output", def.output.schema, value, true); 54 + } 55 + } 56 + 57 + export function assertValidXrpcMessage( 58 + lexicons: Lexicons, 59 + def: LexXrpcSubscription, 60 + value: unknown, 61 + ) { 62 + if (def.message?.schema) { 63 + // loop: all output schema definitions 64 + return assertValidOneOf( 65 + lexicons, 66 + "Message", 67 + def.message.schema, 68 + value, 69 + true, 70 + ); 71 + } 72 + } 73 + 74 + function assertValidOneOf( 75 + lexicons: Lexicons, 76 + path: string, 77 + def: LexRefVariant | LexUserType, 78 + value: unknown, 79 + mustBeObj = false, 80 + ) { 81 + const res = validateOneOf(lexicons, path, def, value, mustBeObj); 82 + if (!res.success) throw res.error; 83 + return res.value; 84 + }
+406
lexicon/validation/primitives.ts
··· 1 + import { CID } from "multiformats/cid"; 2 + import { graphemeLen, utf8Len } from "@atp/common"; 3 + import { 4 + type LexBoolean, 5 + type LexBytes, 6 + type LexInteger, 7 + type LexString, 8 + type LexUserType, 9 + ValidationError, 10 + type ValidationResult, 11 + } from "../types.ts"; 12 + import * as formats from "./formats.ts"; 13 + 14 + export function validate( 15 + path: string, 16 + def: LexUserType, 17 + value: unknown, 18 + ): ValidationResult { 19 + switch (def.type) { 20 + case "boolean": 21 + return boolean(path, def, value); 22 + case "integer": 23 + return integer(path, def, value); 24 + case "string": 25 + return string(path, def, value); 26 + case "bytes": 27 + return bytes(path, def, value); 28 + case "cid-link": 29 + return cidLink(path, value); 30 + case "unknown": 31 + return unknown(path, value); 32 + default: 33 + return { 34 + success: false, 35 + error: new ValidationError(`Unexpected lexicon type: ${def.type}`), 36 + }; 37 + } 38 + } 39 + 40 + function boolean( 41 + path: string, 42 + def: LexUserType, 43 + value: unknown, 44 + ): ValidationResult { 45 + def = def as LexBoolean; 46 + 47 + // type 48 + const type = typeof value; 49 + if (type === "undefined") { 50 + if (typeof def.default === "boolean") { 51 + return { success: true, value: def.default }; 52 + } 53 + return { 54 + success: false, 55 + error: new ValidationError(`${path} must be a boolean`), 56 + }; 57 + } else if (type !== "boolean") { 58 + return { 59 + success: false, 60 + error: new ValidationError(`${path} must be a boolean`), 61 + }; 62 + } 63 + 64 + // const 65 + if (typeof def.const === "boolean") { 66 + if (value !== def.const) { 67 + return { 68 + success: false, 69 + error: new ValidationError(`${path} must be ${def.const}`), 70 + }; 71 + } 72 + } 73 + 74 + return { success: true, value }; 75 + } 76 + 77 + function integer( 78 + path: string, 79 + def: LexUserType, 80 + value: unknown, 81 + ): ValidationResult { 82 + def = def as LexInteger; 83 + 84 + // type 85 + const type = typeof value; 86 + if (type === "undefined") { 87 + if (typeof def.default === "number") { 88 + return { success: true, value: def.default }; 89 + } 90 + return { 91 + success: false, 92 + error: new ValidationError(`${path} must be an integer`), 93 + }; 94 + } else if (!Number.isInteger(value)) { 95 + return { 96 + success: false, 97 + error: new ValidationError(`${path} must be an integer`), 98 + }; 99 + } 100 + 101 + // const 102 + if (typeof def.const === "number") { 103 + if (value !== def.const) { 104 + return { 105 + success: false, 106 + error: new ValidationError(`${path} must be ${def.const}`), 107 + }; 108 + } 109 + } 110 + 111 + // enum 112 + if (Array.isArray(def.enum)) { 113 + if (!def.enum.includes(value as number)) { 114 + return { 115 + success: false, 116 + error: new ValidationError( 117 + `${path} must be one of (${def.enum.join("|")})`, 118 + ), 119 + }; 120 + } 121 + } 122 + 123 + // maximum 124 + if (typeof def.maximum === "number") { 125 + if ((value as number) > def.maximum) { 126 + return { 127 + success: false, 128 + error: new ValidationError( 129 + `${path} can not be greater than ${def.maximum}`, 130 + ), 131 + }; 132 + } 133 + } 134 + 135 + // minimum 136 + if (typeof def.minimum === "number") { 137 + if ((value as number) < def.minimum) { 138 + return { 139 + success: false, 140 + error: new ValidationError( 141 + `${path} can not be less than ${def.minimum}`, 142 + ), 143 + }; 144 + } 145 + } 146 + 147 + return { success: true, value }; 148 + } 149 + 150 + function string( 151 + path: string, 152 + def: LexUserType, 153 + value: unknown, 154 + ): ValidationResult { 155 + def = def as LexString; 156 + 157 + // type 158 + if (typeof value === "undefined") { 159 + if (typeof def.default === "string") { 160 + return { success: true, value: def.default }; 161 + } 162 + return { 163 + success: false, 164 + error: new ValidationError(`${path} must be a string`), 165 + }; 166 + } else if (typeof value !== "string") { 167 + return { 168 + success: false, 169 + error: new ValidationError(`${path} must be a string`), 170 + }; 171 + } 172 + 173 + // const 174 + if (typeof def.const === "string") { 175 + if (value !== def.const) { 176 + return { 177 + success: false, 178 + error: new ValidationError(`${path} must be ${def.const}`), 179 + }; 180 + } 181 + } 182 + 183 + // enum 184 + if (Array.isArray(def.enum)) { 185 + if (!def.enum.includes(value as string)) { 186 + return { 187 + success: false, 188 + error: new ValidationError( 189 + `${path} must be one of (${def.enum.join("|")})`, 190 + ), 191 + }; 192 + } 193 + } 194 + 195 + // maxLength and minLength 196 + if (typeof def.minLength === "number" || typeof def.maxLength === "number") { 197 + // If the JavaScript string length * 3 is below the maximum limit, 198 + // its UTF8 length (which <= .length * 3) will also be below. 199 + if (typeof def.minLength === "number" && value.length * 3 < def.minLength) { 200 + return { 201 + success: false, 202 + error: new ValidationError( 203 + `${path} must not be shorter than ${def.minLength} characters`, 204 + ), 205 + }; 206 + } 207 + 208 + // If the JavaScript string length * 3 is within the maximum limit, 209 + // its UTF8 length (which <= .length * 3) will also be within. 210 + // When there's no minimal length, this lets us skip the UTF8 length check. 211 + let canSkipUtf8LenChecks = false; 212 + if ( 213 + typeof def.minLength === "undefined" && 214 + typeof def.maxLength === "number" && 215 + value.length * 3 <= def.maxLength 216 + ) { 217 + canSkipUtf8LenChecks = true; 218 + } 219 + 220 + if (!canSkipUtf8LenChecks) { 221 + const len = utf8Len(value); 222 + 223 + if (typeof def.maxLength === "number") { 224 + if (len > def.maxLength) { 225 + return { 226 + success: false, 227 + error: new ValidationError( 228 + `${path} must not be longer than ${def.maxLength} characters`, 229 + ), 230 + }; 231 + } 232 + } 233 + 234 + if (typeof def.minLength === "number") { 235 + if (len < def.minLength) { 236 + return { 237 + success: false, 238 + error: new ValidationError( 239 + `${path} must not be shorter than ${def.minLength} characters`, 240 + ), 241 + }; 242 + } 243 + } 244 + } 245 + } 246 + 247 + // maxGraphemes and minGraphemes 248 + if ( 249 + typeof def.maxGraphemes === "number" || 250 + typeof def.minGraphemes === "number" 251 + ) { 252 + let needsMaxGraphemesCheck = false; 253 + let needsMinGraphemesCheck = false; 254 + 255 + if (typeof def.maxGraphemes === "number") { 256 + if (value.length <= def.maxGraphemes) { 257 + // If the JavaScript string length (UTF-16) is within the maximum limit, 258 + // its grapheme length (which <= .length) will also be within. 259 + needsMaxGraphemesCheck = false; 260 + } else { 261 + needsMaxGraphemesCheck = true; 262 + } 263 + } 264 + 265 + if (typeof def.minGraphemes === "number") { 266 + if (value.length < def.minGraphemes) { 267 + // If the JavaScript string length (UTF-16) is below the minimal limit, 268 + // its grapheme length (which <= .length) will also be below. 269 + // Fail early. 270 + return { 271 + success: false, 272 + error: new ValidationError( 273 + `${path} must not be shorter than ${def.minGraphemes} graphemes`, 274 + ), 275 + }; 276 + } else { 277 + needsMinGraphemesCheck = true; 278 + } 279 + } 280 + 281 + if (needsMaxGraphemesCheck || needsMinGraphemesCheck) { 282 + const len = graphemeLen(value); 283 + 284 + if (typeof def.maxGraphemes === "number") { 285 + if (len > def.maxGraphemes) { 286 + return { 287 + success: false, 288 + error: new ValidationError( 289 + `${path} must not be longer than ${def.maxGraphemes} graphemes`, 290 + ), 291 + }; 292 + } 293 + } 294 + 295 + if (typeof def.minGraphemes === "number") { 296 + if (len < def.minGraphemes) { 297 + return { 298 + success: false, 299 + error: new ValidationError( 300 + `${path} must not be shorter than ${def.minGraphemes} graphemes`, 301 + ), 302 + }; 303 + } 304 + } 305 + } 306 + } 307 + 308 + if (typeof def.format === "string") { 309 + switch (def.format) { 310 + case "datetime": 311 + return formats.datetime(path, value); 312 + case "uri": 313 + return formats.uri(path, value); 314 + case "at-uri": 315 + return formats.atUri(path, value); 316 + case "did": 317 + return formats.did(path, value); 318 + case "handle": 319 + return formats.handle(path, value); 320 + case "at-identifier": 321 + return formats.atIdentifier(path, value); 322 + case "nsid": 323 + return formats.nsid(path, value); 324 + case "cid": 325 + return formats.cid(path, value); 326 + case "language": 327 + return formats.language(path, value); 328 + case "tid": 329 + return formats.tid(path, value); 330 + case "record-key": 331 + return formats.recordKey(path, value); 332 + } 333 + } 334 + 335 + return { success: true, value }; 336 + } 337 + 338 + function bytes( 339 + path: string, 340 + def: LexUserType, 341 + value: unknown, 342 + ): ValidationResult { 343 + def = def as LexBytes; 344 + 345 + if (!value || !(value instanceof Uint8Array)) { 346 + return { 347 + success: false, 348 + error: new ValidationError(`${path} must be a byte array`), 349 + }; 350 + } 351 + 352 + // maxLength 353 + if (typeof def.maxLength === "number") { 354 + if (value.byteLength > def.maxLength) { 355 + return { 356 + success: false, 357 + error: new ValidationError( 358 + `${path} must not be larger than ${def.maxLength} bytes`, 359 + ), 360 + }; 361 + } 362 + } 363 + 364 + // minLength 365 + if (typeof def.minLength === "number") { 366 + if (value.byteLength < def.minLength) { 367 + return { 368 + success: false, 369 + error: new ValidationError( 370 + `${path} must not be smaller than ${def.minLength} bytes`, 371 + ), 372 + }; 373 + } 374 + } 375 + 376 + return { success: true, value }; 377 + } 378 + 379 + function cidLink( 380 + path: string, 381 + value: unknown, 382 + ): ValidationResult { 383 + if (CID.asCID(value) === null) { 384 + return { 385 + success: false, 386 + error: new ValidationError(`${path} must be a CID`), 387 + }; 388 + } 389 + 390 + return { success: true, value }; 391 + } 392 + 393 + function unknown( 394 + path: string, 395 + value: unknown, 396 + ): ValidationResult { 397 + // type 398 + if (!value || typeof value !== "object") { 399 + return { 400 + success: false, 401 + error: new ValidationError(`${path} must be an object`), 402 + }; 403 + } 404 + 405 + return { success: true, value }; 406 + }
+52
lexicon/validation/xrpc.ts
··· 1 + import type { Lexicons } from "../lexicons.ts"; 2 + import { 3 + type LexXrpcParameters, 4 + ValidationError, 5 + type ValidationResult, 6 + } from "../types.ts"; 7 + import { array } from "./complex.ts"; 8 + import * as PrimitiveValidators from "./primitives.ts"; 9 + 10 + export function params( 11 + lexicons: Lexicons, 12 + path: string, 13 + def: LexXrpcParameters, 14 + val: unknown, 15 + ): ValidationResult<Record<string, unknown>> { 16 + // type 17 + const value = val && typeof val === "object" ? val : {}; 18 + 19 + const requiredProps = new Set(def.required ?? []); 20 + 21 + // properties 22 + let resultValue = value as Record<string, unknown>; 23 + if (typeof def.properties === "object") { 24 + for (const key in def.properties) { 25 + const propDef = def.properties[key]; 26 + const validated = propDef.type === "array" 27 + ? array(lexicons, key, propDef, resultValue[key]) 28 + : PrimitiveValidators.validate(key, propDef, resultValue[key]); 29 + const propValue = validated.success ? validated.value : resultValue[key]; 30 + const propIsUndefined = typeof propValue === "undefined"; 31 + // Return error for bad validation, giving required rule precedence 32 + if (propIsUndefined && requiredProps.has(key)) { 33 + return { 34 + success: false, 35 + error: new ValidationError(`${path} must have the property "${key}"`), 36 + }; 37 + } else if (!propIsUndefined && !validated.success) { 38 + return validated; 39 + } 40 + // Adjust value based on e.g. applied defaults, cloning shallowly if there was a changed value 41 + if (propValue !== resultValue[key]) { 42 + if (resultValue === value) { 43 + // Lazy shallow clone 44 + resultValue = { ...value }; 45 + } 46 + resultValue[key] = propValue; 47 + } 48 + } 49 + } 50 + 51 + return { success: true, value: resultValue }; 52 + }
-1
xrpc-server/deno.json
··· 5 5 "license": "MIT", 6 6 "imports": { 7 7 "@atproto/crypto": "npm:@atproto/crypto@^0.4.4", 8 - "@atproto/lexicon": "npm:@atproto/lexicon@^0.4.11", 9 8 "@std/assert": "jsr:@std/assert@^1.0.14", 10 9 "@std/cbor": "jsr:@std/cbor@^0.1.8", 11 10 "@std/encoding": "jsr:@std/encoding@^1.0.10",
+1 -2
xrpc-server/lexicon/index.ts
··· 1 1 import { Hono } from "hono"; 2 - import { Lexicons } from "@atproto/lexicon"; 2 + import { type LexiconDoc, Lexicons } from "@atp/lexicon"; 3 3 import type { Context, Next } from "hono"; 4 - import type { LexiconDoc } from "@atproto/lexicon"; 5 4 6 5 export function createServer(lexicons?: LexiconDoc[]) { 7 6 const routes = new Hono();
+1 -1
xrpc-server/mod.ts
··· 22 22 * @example Basic server setup with a simple endpoint 23 23 * ```ts 24 24 * import { createServer } from "jsr:@atp/xrpc-server"; 25 - * import type { LexiconDoc } from "@atproto/lexicon"; 25 + * import type { LexiconDoc } from "@atp/lexicon"; 26 26 * 27 27 * const lexicons: LexiconDoc[] = [{ 28 28 * lexicon: 1,
+1 -1
xrpc-server/server.ts
··· 6 6 type LexXrpcProcedure, 7 7 type LexXrpcQuery, 8 8 type LexXrpcSubscription, 9 - } from "@atproto/lexicon"; 9 + } from "@atp/lexicon"; 10 10 import { 11 11 excludeErrorResult, 12 12 InternalServerError,
+6 -6
xrpc-server/stream/types.ts
··· 33 33 t?: string; 34 34 }; 35 35 36 - export const messageFrameHeader = z.object({ 36 + export const messageFrameHeader = z.strictObject({ 37 37 op: z.literal(FrameType.Message), // Frame op 38 38 t: z.string().optional(), // Message body type discriminator 39 - }).strict() as z.ZodType<MessageFrameHeader>; 39 + }) as z.ZodType<MessageFrameHeader>; 40 40 41 41 /** 42 42 * Header for error frames. ··· 47 47 op: FrameType.Error; 48 48 }; 49 49 50 - export const errorFrameHeader = z.object({ 50 + export const errorFrameHeader = z.strictObject({ 51 51 op: z.literal(FrameType.Error), 52 - }).strict() as z.ZodType<ErrorFrameHeader>; 52 + }) as z.ZodType<ErrorFrameHeader>; 53 53 54 54 /** 55 55 * Base type for error frame bodies. ··· 74 74 message?: string; 75 75 }; 76 76 77 - export const errorFrameBody = z.object({ 77 + export const errorFrameBody = z.strictObject({ 78 78 error: z.string(), // Error code 79 79 message: z.string().optional(), // Error message 80 - }).strict() as z.ZodType<ErrorFrameBodyBase>; 80 + }) as z.ZodType<ErrorFrameBodyBase>; 81 81 82 82 /** 83 83 * Union type for all frame headers.
+1 -1
xrpc-server/tests/auth_test.ts
··· 1 1 import { MINUTE } from "@atp/common"; 2 2 import { Secp256k1Keypair } from "@atproto/crypto"; 3 - import type { LexiconDoc } from "@atproto/lexicon"; 3 + import type { LexiconDoc } from "@atp/lexicon"; 4 4 import { XrpcClient, XRPCError } from "@atp/xrpc"; 5 5 import * as xrpcServer from "../mod.ts"; 6 6
+1 -1
xrpc-server/tests/bodies_test.ts
··· 1 1 import { cidForCbor } from "@atp/common"; 2 2 import { randomBytes } from "@atproto/crypto"; 3 - import type { LexiconDoc } from "@atproto/lexicon"; 3 + import type { LexiconDoc } from "@atp/lexicon"; 4 4 import { ResponseType, XrpcClient, XRPCError } from "@atp/xrpc"; 5 5 import * as xrpcServer from "../mod.ts"; 6 6 import { logger } from "../logger.ts";
+1 -1
xrpc-server/tests/errors_test.ts
··· 1 - import type { LexiconDoc } from "@atproto/lexicon"; 1 + import type { LexiconDoc } from "@atp/lexicon"; 2 2 import { XrpcClient, XRPCError, XRPCInvalidResponseError } from "@atp/xrpc"; 3 3 import * as xrpcServer from "../mod.ts"; 4 4 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/ipld_test.ts
··· 1 1 import { CID } from "multiformats/cid"; 2 - import type { LexiconDoc } from "@atproto/lexicon"; 2 + import type { LexiconDoc } from "@atp/lexicon"; 3 3 import { XrpcClient } from "@atp/xrpc"; 4 4 import * as xrpcServer from "../mod.ts"; 5 5 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/parameters_test.ts
··· 1 - import type { LexiconDoc } from "@atproto/lexicon"; 1 + import type { LexiconDoc } from "@atp/lexicon"; 2 2 import { XrpcClient } from "@atp/xrpc"; 3 3 import * as xrpcServer from "../mod.ts"; 4 4 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/procedures_test.ts
··· 1 - import type { LexiconDoc } from "@atproto/lexicon"; 1 + import type { LexiconDoc } from "@atp/lexicon"; 2 2 import { XrpcClient } from "@atp/xrpc"; 3 3 import * as xrpcServer from "../mod.ts"; 4 4 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/queries_test.ts
··· 1 - import type { LexiconDoc } from "@atproto/lexicon"; 1 + import type { LexiconDoc } from "@atp/lexicon"; 2 2 import { XrpcClient } from "@atp/xrpc"; 3 3 import * as xrpcServer from "../mod.ts"; 4 4 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/rate-limiter_test.ts
··· 1 1 import { MINUTE } from "@atp/common"; 2 - import type { LexiconDoc } from "@atproto/lexicon"; 2 + import type { LexiconDoc } from "@atp/lexicon"; 3 3 import { XrpcClient } from "@atp/xrpc"; 4 4 import * as xrpcServer from "../mod.ts"; 5 5 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/responses_test.ts
··· 1 1 import { byteIterableToStream } from "@atp/common"; 2 - import type { LexiconDoc } from "@atproto/lexicon"; 2 + import type { LexiconDoc } from "@atp/lexicon"; 3 3 import { XrpcClient } from "@atp/xrpc"; 4 4 import * as xrpcServer from "../mod.ts"; 5 5 import { closeServer, createServer } from "./_util.ts";
+1 -1
xrpc-server/tests/subscriptions_test.ts
··· 1 1 import { WebSocket, type WebSocketServer } from "ws"; 2 2 import { wait } from "@atp/common"; 3 - import type { LexiconDoc } from "@atproto/lexicon"; 3 + import type { LexiconDoc } from "@atp/lexicon"; 4 4 import { 5 5 byFrame, 6 6 ErrorFrame,
+8 -8
xrpc-server/util.ts
··· 1 - import type { 2 - Lexicons, 3 - LexXrpcProcedure, 4 - LexXrpcQuery, 5 - LexXrpcSubscription, 6 - } from "@atproto/lexicon"; 7 - import { jsonToLex } from "@atproto/lexicon"; 1 + import { 2 + jsonToLex, 3 + type Lexicons, 4 + type LexXrpcBody, 5 + type LexXrpcProcedure, 6 + type LexXrpcQuery, 7 + type LexXrpcSubscription, 8 + } from "@atp/lexicon"; 8 9 import { 9 10 InternalServerError, 10 11 InvalidRequestError, ··· 20 21 RouteOptions, 21 22 } from "./types.ts"; 22 23 import type { Context, HonoRequest } from "hono"; 23 - import type { LexXrpcBody } from "@atproto/lexicon"; 24 24 import { createDecoders, MaxSizeChecker } from "@atp/common"; 25 25 26 26 function assert(condition: unknown, message?: string): asserts condition {
+1 -1
xrpc/client.ts
··· 1 - import { type LexiconDoc, Lexicons, ValidationError } from "@atproto/lexicon"; 1 + import { type LexiconDoc, Lexicons, ValidationError } from "@atp/lexicon"; 2 2 import { 3 3 buildFetchHandler, 4 4 type FetchHandler,
-1
xrpc/deno.json
··· 4 4 "exports": "./mod.ts", 5 5 "license": "MIT", 6 6 "imports": { 7 - "@atproto/lexicon": "npm:@atproto/lexicon@^0.5.1", 8 7 "zod": "jsr:@zod/zod@^4.1.11" 9 8 }, 10 9 "lint": {
+1 -1
xrpc/types.ts
··· 1 1 import { z } from "zod"; 2 - import type { ValidationError } from "@atproto/lexicon"; 2 + import type { ValidationError } from "@atp/lexicon"; 3 3 4 4 export type QueryParams = Record<string, unknown>; 5 5 export type HeadersMap = Record<string, string | undefined>;
+1 -1
xrpc/util.ts
··· 3 3 type LexXrpcProcedure, 4 4 type LexXrpcQuery, 5 5 stringifyLex, 6 - } from "@atproto/lexicon"; 6 + } from "@atp/lexicon"; 7 7 import { 8 8 type CallOptions, 9 9 type ErrorResponseBody,