a tiny atproto lexicon validator
1import { test } from "node:test";
2import assert from "node:assert/strict";
3import TinyLex from "./tinylex.js";
4
5/** @returns {(v: unknown) => boolean} */
6const validator = (/** @type {any} */ def) => {
7 const schema = new TinyLex()
8 .add({ lexicon: 1, id: "test.x", defs: { main: def } })
9 .schema("test.x");
10 return (v) => !schema["~standard"].validate(v).issues;
11};
12
13// ---------------------------------------------------------------------------
14// datetime format
15// ---------------------------------------------------------------------------
16
17const dt = validator({ type: "string", format: "datetime" });
18
19test("datetime: accepts valid RFC 3339", () => {
20 assert(dt("2025-01-01T00:00:00Z"));
21 assert(dt("2025-01-01T00:00:00.123Z"));
22 assert(dt("2025-01-01T00:00:00.123456789Z"));
23 assert(dt("2025-01-01T00:00:00+05:00"));
24 assert(dt("2025-01-01T00:00:00-08:00"));
25 assert(dt("2024-02-29T00:00:00Z"));
26});
27
28test("datetime: rejects malformed strings", () => {
29 assert(!dt(""));
30 assert(!dt("2025-01-01"));
31 assert(!dt("2025-01-01 00:00:00Z"));
32 assert(!dt("2025-01-01T00:00:00"));
33 assert(!dt("2025/01/01T00:00:00Z"));
34});
35
36test("datetime: rejects out-of-range date components", () => {
37 assert(!dt("2025-13-01T00:00:00Z"));
38 assert(!dt("2025-00-01T00:00:00Z"));
39 assert(!dt("2025-01-32T00:00:00Z"));
40 assert(!dt("2025-01-00T00:00:00Z"));
41});
42
43test("datetime: rejects out-of-range times", () => {
44 assert(!dt("2025-01-01T25:00:00Z"));
45 assert(!dt("2025-01-01T00:60:00Z"));
46 assert(!dt("2025-01-01T00:00:60Z"));
47});
48
49test("datetime: rejects -00:00 timezone", () => {
50 assert(!dt("2025-01-01T00:00:00-00:00"));
51});
52
53// ---------------------------------------------------------------------------
54// other formats
55// ---------------------------------------------------------------------------
56
57test("handle format", () => {
58 const v = validator({ type: "string", format: "handle" });
59 assert(v("alice.bsky.social"));
60 assert(v("a.b"));
61 assert(v("foo-bar.example.com"));
62 assert(!v("alice"));
63 assert(!v("-alice.bsky.social"));
64 assert(!v("alice-.bsky.social"));
65 assert(!v("alice..bsky.social"));
66 assert(!v("a".repeat(254) + ".com"));
67});
68
69test("did format", () => {
70 const v = validator({ type: "string", format: "did" });
71 assert(v("did:plc:abc123"));
72 assert(v("did:web:example.com"));
73 assert(!v("plc:abc"));
74 assert(!v("did:plc:"));
75});
76
77test("at-identifier format", () => {
78 const v = validator({ type: "string", format: "at-identifier" });
79 assert(v("did:plc:abc123"));
80 assert(v("alice.bsky.social"));
81 assert(!v("not_valid"));
82});
83
84// ---------------------------------------------------------------------------
85// type dispatch — one happy + sad path per type
86// ---------------------------------------------------------------------------
87
88test("null", () => {
89 const v = validator({ type: "null" });
90 assert(v(null));
91 assert(!v(undefined));
92 assert(!v(0));
93});
94
95test("boolean", () => {
96 const v = validator({ type: "boolean" });
97 assert(v(true) && v(false));
98 assert(!v("true"));
99
100 const c = validator({ type: "boolean", const: true });
101 assert(c(true) && !c(false));
102
103 const e = validator({ type: "boolean", enum: [true] });
104 assert(e(true) && !e(false));
105});
106
107test("integer", () => {
108 const v = validator({ type: "integer" });
109 assert(v(0) && v(-5) && v(42));
110 assert(!v(1.5) && !v("1") && !v(null));
111
112 const r = validator({ type: "integer", minimum: 0, maximum: 10 });
113 assert(r(0) && r(10));
114 assert(!r(-1) && !r(11));
115
116 const c = validator({ type: "integer", const: 7 });
117 assert(c(7) && !c(8));
118
119 const e = validator({ type: "integer", enum: [1, 2, 3] });
120 assert(e(2) && !e(4));
121});
122
123test("string", () => {
124 const v = validator({ type: "string" });
125 assert(v("") && v("hello"));
126 assert(!v(123));
127
128 const l = validator({ type: "string", minLength: 2, maxLength: 4 });
129 assert(l("ab") && l("abcd"));
130 assert(!l("a") && !l("abcde"));
131
132 const c = validator({ type: "string", const: "x" });
133 assert(c("x") && !c("y"));
134
135 const e = validator({ type: "string", enum: ["a", "b"] });
136 assert(e("a") && !e("c"));
137});
138
139test("bytes", () => {
140 const v = validator({ type: "bytes" });
141 assert(v({ $bytes: "aGVsbG8=" }));
142 assert(!v({ $bytes: "not!base64!" }));
143 assert(!v({ $bytes: 123 }));
144 assert(!v(null));
145});
146
147test("cid-link", () => {
148 const v = validator({ type: "cid-link" });
149 assert(v({ $link: "bafyabc123" }));
150 assert(!v({ $link: "!!invalid" }));
151 assert(!v({}));
152});
153
154test("blob", () => {
155 const v = validator({ type: "blob" });
156 assert(v({ $type: "blob", mimeType: "image/png", size: 100, ref: { $link: "bafy123" } }));
157 assert(!v({ $type: "blob" }));
158 assert(!v(null));
159
160 const accept = validator({ type: "blob", accept: ["image/*"] });
161 assert(accept({ $type: "blob", mimeType: "image/png", size: 1, ref: { $link: "bafy1" } }));
162 assert(!accept({ $type: "blob", mimeType: "text/plain", size: 1, ref: { $link: "bafy1" } }));
163
164 const maxSize = validator({ type: "blob", maxSize: 100 });
165 assert(maxSize({ $type: "blob", mimeType: "x/y", size: 50, ref: { $link: "bafy1" } }));
166 assert(!maxSize({ $type: "blob", mimeType: "x/y", size: 200, ref: { $link: "bafy1" } }));
167});
168
169test("array", () => {
170 const v = validator({ type: "array", items: { type: "integer" } });
171 assert(v([]) && v([1, 2, 3]));
172 assert(!v([1, "two"]) && !v("not array"));
173
174 const l = validator({ type: "array", items: { type: "string" }, minLength: 1, maxLength: 2 });
175 assert(l(["a"]) && l(["a", "b"]));
176 assert(!l([]) && !l(["a", "b", "c"]));
177});
178
179test("object", () => {
180 const v = validator({
181 type: "object",
182 required: ["x"],
183 properties: { x: { type: "integer" }, y: { type: "string" } },
184 });
185 assert(v({ x: 1 }));
186 assert(v({ x: 1, y: "hi" }));
187 assert(!v({}));
188 assert(!v({ x: "not int" }));
189 assert(!v(null));
190 assert(!v([]));
191
192 const n = validator({
193 type: "object",
194 required: ["x"],
195 nullable: ["x"],
196 properties: { x: { type: "integer" } },
197 });
198 assert(n({ x: null }));
199
200 const nn = validator({ type: "object", properties: { x: { type: "integer" } } });
201 assert(!nn({ x: null }));
202});
203
204test("params", () => {
205 const v = validator({
206 type: "params",
207 required: ["limit"],
208 properties: { limit: { type: "integer" }, cursor: { type: "string" } },
209 });
210 assert(v({ limit: 10 }));
211 assert(!v({}));
212 assert(!v({ limit: "ten" }));
213});
214
215test("unknown type", () => {
216 const v = validator({ type: "unknown" });
217 assert(v({ anything: "goes" }));
218 assert(!v(null));
219 assert(!v([]));
220 assert(!v({ $link: "x" }));
221 assert(!v({ $bytes: "x" }));
222});
223
224test("record: requires $type matching the lexicon NSID", () => {
225 const v = validator({
226 type: "record",
227 record: { type: "object", required: ["x"], properties: { x: { type: "integer" } } },
228 });
229 assert(v({ $type: "test.x", x: 1 }));
230 assert(v({ $type: "test.x#main", x: 1 }));
231 assert(!v({ x: 1 }));
232 assert(!v({ $type: "test.other", x: 1 }));
233 assert(!v({ $type: "test.x" }));
234 assert(!v(null));
235 assert(!v([]));
236});
237
238test("token has no data representation", () => {
239 const v = validator({ type: "token" });
240 assert(!v(null) && !v("anything"));
241});
242
243test("unknown schema type fails gracefully", () => {
244 const v = validator({ type: "not-a-real-type" });
245 assert(!v("anything"));
246});
247
248// ---------------------------------------------------------------------------
249// ref + union (need a real catalog)
250// ---------------------------------------------------------------------------
251
252test("ref resolves through catalog", () => {
253 const catalog = new TinyLex().add({
254 lexicon: 1,
255 id: "test.ref",
256 defs: {
257 main: { type: "object", required: ["n"], properties: { n: { type: "ref", ref: "#count" } } },
258 count: { type: "integer", minimum: 0 },
259 },
260 });
261 const schema = catalog.schema("test.ref");
262 assert(!schema["~standard"].validate({ n: 5 }).issues);
263 assert(schema["~standard"].validate({ n: -1 }).issues);
264});
265
266test("union: open accepts unknown $type", () => {
267 const catalog = new TinyLex().add({
268 lexicon: 1,
269 id: "test.u",
270 defs: {
271 main: { type: "union", refs: ["#a"] },
272 a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } },
273 },
274 });
275 const schema = catalog.schema("test.u");
276 assert(!schema["~standard"].validate({ $type: "test.u#a", x: 1 }).issues);
277 assert(!schema["~standard"].validate({ $type: "com.other#thing" }).issues);
278 assert(schema["~standard"].validate({ $type: "test.u#a", x: "bad" }).issues);
279});
280
281test("union: closed rejects unknown $type", () => {
282 const catalog = new TinyLex().add({
283 lexicon: 1,
284 id: "test.cu",
285 defs: {
286 main: { type: "union", closed: true, refs: ["#a"] },
287 a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } },
288 },
289 });
290 const schema = catalog.schema("test.cu");
291 assert(!schema["~standard"].validate({ $type: "test.cu#a", x: 1 }).issues);
292 assert(schema["~standard"].validate({ $type: "com.other#thing" }).issues);
293});