an app to share curated trails sidetrail.app
1

Configure Feed

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

tighten up validation

+399 -97
+14 -2
data/__tests__/helpers/eventual-consistency.ts
··· 124 124 const uri = `at://${did}/${collection}/${rkey}`; 125 125 const cid = generateCid(); 126 126 127 - const op: PdsOperation = { type: "create", collection, did, rkey, record }; 127 + const op: PdsOperation = { 128 + type: "create", 129 + collection, 130 + did, 131 + rkey, 132 + record: { $type: collection, ...record }, 133 + }; 128 134 if (insideAfterCallback) { 129 135 state.pdsOps.push(op); 130 136 } else { ··· 145 151 const uri = `at://${did}/${collection}/${opts.rkey}`; 146 152 const cid = generateCid(); 147 153 148 - const op: PdsOperation = { type: "update", collection, did, rkey: opts.rkey, record }; 154 + const op: PdsOperation = { 155 + type: "update", 156 + collection, 157 + did, 158 + rkey: opts.rkey, 159 + record: { $type: collection, ...record }, 160 + }; 149 161 if (insideAfterCallback) { 150 162 state.pdsOps.push(op); 151 163 } else {
+6 -3
data/__tests__/helpers/test-events.ts
··· 3 3 * Creates fake Jetstream events and processes them through the real ingester handler 4 4 */ 5 5 6 + import { cidForLex } from "@atproto/lex-cbor"; 6 7 import { handleEvent, type IngesterDb } from "../../../ingester/src/handler"; 7 8 import type { JetstreamEvent } from "../../../ingester/src/jetstream"; 8 9 import { getTestDb } from "./test-db"; ··· 15 16 return `test_${Date.now()}_${++rkeyCounter}`; 16 17 } 17 18 18 - function generateCid(): string { 19 - return `bafytest${++cidCounter}`; 19 + // Real CIDs: ingested records are validated against the lexicon, which 20 + // enforces cid format on strong refs 21 + async function generateCid(): Promise<string> { 22 + return (await cidForLex({ test: ++cidCounter })).toString(); 20 23 } 21 24 22 25 function generateTimeUs(): number { ··· 48 51 ): Promise<EmitResult> { 49 52 const db = getTestDb() as unknown as IngesterDb; 50 53 const rkey = existingRkey || generateRkey(); 51 - const cid = generateCid(); 54 + const cid = await generateCid(); 52 55 const operation = existingRkey ? "update" : "create"; 53 56 54 57 const event: JetstreamEvent = {
+7 -6
data/__tests__/helpers/tid.ts
··· 1 1 /** 2 2 * TID generator for tests 3 - * Generates unique TIDs for trail stops 3 + * 4 + * Must produce real TIDs: ingested records are validated against the lexicon, 5 + * which enforces tid format on stop ids and visitedStops. 4 6 */ 5 7 6 - let tidCounter = 0; 8 + import { TID } from "@atproto/common-web"; 7 9 8 10 /** 9 11 * Generate a unique TID for a trail stop 10 12 */ 11 13 export function generateTid(): string { 12 - return `tid_test_${Date.now()}_${++tidCounter}`; 14 + return TID.nextStr(); 13 15 } 14 16 15 17 /** 16 18 * Reset the TID counter (call in beforeEach) 19 + * No-op with real TIDs, kept for test setup compatibility. 17 20 */ 18 - export function resetTidCounter(): void { 19 - tidCounter = 0; 20 - } 21 + export function resetTidCounter(): void {}
+68 -17
data/__tests__/profiles.test.ts
··· 17 17 $type: "app.sidetrail.trail", 18 18 title: "Alice's First Trail", 19 19 description: "Her debut", 20 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 20 + stops: [ 21 + { tid: generateTid(), title: "Stop", content: "Content" }, 22 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 23 + ], 21 24 accentColor: "#ff0000", 22 25 backgroundColor: "#ffffff", 23 26 createdAt: now(), ··· 27 30 $type: "app.sidetrail.trail", 28 31 title: "Alice's Second Trail", 29 32 description: "Another one", 30 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 33 + stops: [ 34 + { tid: generateTid(), title: "Stop", content: "Content" }, 35 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 36 + ], 31 37 accentColor: "#00ff00", 32 38 backgroundColor: "#f0f0f0", 33 39 createdAt: now(), ··· 42 48 $type: "app.sidetrail.trail", 43 49 title: "Old Trail", 44 50 description: "First", 45 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 51 + stops: [ 52 + { tid: generateTid(), title: "Stop", content: "Content" }, 53 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 54 + ], 46 55 accentColor: "#111", 47 56 backgroundColor: "#eee", 48 57 createdAt: "2024-01-01T10:00:00Z", ··· 52 61 $type: "app.sidetrail.trail", 53 62 title: "New Trail", 54 63 description: "Last", 55 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 64 + stops: [ 65 + { tid: generateTid(), title: "Stop", content: "Content" }, 66 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 67 + ], 56 68 accentColor: "#222", 57 69 backgroundColor: "#ddd", 58 70 createdAt: "2024-06-01T10:00:00Z", ··· 69 81 $type: "app.sidetrail.trail", 70 82 title: "Popular Trail", 71 83 description: "Many walkers", 72 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 84 + stops: [ 85 + { tid: stopTid, title: "Stop", content: "Content" }, 86 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 87 + ], 73 88 accentColor: "#333", 74 89 backgroundColor: "#ccc", 75 90 createdAt: now(), ··· 94 109 $type: "app.sidetrail.trail", 95 110 title: "Alice's Trail", 96 111 description: "By Alice", 97 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 112 + stops: [ 113 + { tid: generateTid(), title: "Stop", content: "Content" }, 114 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 115 + ], 98 116 accentColor: "#aaa", 99 117 backgroundColor: "#bbb", 100 118 createdAt: now(), ··· 104 122 $type: "app.sidetrail.trail", 105 123 title: "Bob's Trail", 106 124 description: "By Bob", 107 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 125 + stops: [ 126 + { tid: generateTid(), title: "Stop", content: "Content" }, 127 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 128 + ], 108 129 accentColor: "#ccc", 109 130 backgroundColor: "#ddd", 110 131 createdAt: now(), ··· 126 147 $type: "app.sidetrail.trail", 127 148 title: "Completable Trail", 128 149 description: "Finish this", 129 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 150 + stops: [ 151 + { tid: generateTid(), title: "Stop", content: "Content" }, 152 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 153 + ], 130 154 accentColor: "#ff0000", 131 155 backgroundColor: "#ffffff", 132 156 createdAt: now(), ··· 149 173 $type: "app.sidetrail.trail", 150 174 title: "First Completed", 151 175 description: "Test", 152 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 176 + stops: [ 177 + { tid: generateTid(), title: "Stop", content: "Content" }, 178 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 179 + ], 153 180 accentColor: "#111", 154 181 backgroundColor: "#eee", 155 182 createdAt: now(), ··· 159 186 $type: "app.sidetrail.trail", 160 187 title: "Last Completed", 161 188 description: "Test", 162 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 189 + stops: [ 190 + { tid: generateTid(), title: "Stop", content: "Content" }, 191 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 192 + ], 163 193 accentColor: "#222", 164 194 backgroundColor: "#ddd", 165 195 createdAt: now(), ··· 187 217 $type: "app.sidetrail.trail", 188 218 title: "Re-completable", 189 219 description: "Completed twice", 190 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 220 + stops: [ 221 + { tid: generateTid(), title: "Stop", content: "Content" }, 222 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 223 + ], 191 224 accentColor: "#333", 192 225 backgroundColor: "#ccc", 193 226 createdAt: now(), ··· 217 250 $type: "app.sidetrail.trail", 218 251 title: "Soon Deleted", 219 252 description: "Will be orphaned", 220 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 253 + stops: [ 254 + { tid: generateTid(), title: "Stop", content: "Content" }, 255 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 256 + ], 221 257 accentColor: "#444", 222 258 backgroundColor: "#bbb", 223 259 createdAt: now(), ··· 247 283 $type: "app.sidetrail.trail", 248 284 title: "Alice's Published Trail", 249 285 description: "Created by Alice", 250 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 286 + stops: [ 287 + { tid: generateTid(), title: "Stop", content: "Content" }, 288 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 289 + ], 251 290 accentColor: "#ff0000", 252 291 backgroundColor: "#ffffff", 253 292 createdAt: now(), ··· 258 297 $type: "app.sidetrail.trail", 259 298 title: "Bob's Trail", 260 299 description: "Created by Bob", 261 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 300 + stops: [ 301 + { tid: generateTid(), title: "Stop", content: "Content" }, 302 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 303 + ], 262 304 accentColor: "#00ff00", 263 305 backgroundColor: "#f0f0f0", 264 306 createdAt: now(), ··· 287 329 $type: "app.sidetrail.trail", 288 330 title: "Uncomplete Trail", 289 331 description: "Remove completion", 290 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 332 + stops: [ 333 + { tid: generateTid(), title: "Stop", content: "Content" }, 334 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 335 + ], 291 336 accentColor: "#555", 292 337 backgroundColor: "#aaa", 293 338 createdAt: now(), ··· 324 369 $type: "app.sidetrail.trail", 325 370 title: "Ephemeral Trail", 326 371 description: "Will be deleted", 327 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 372 + stops: [ 373 + { tid: generateTid(), title: "Stop", content: "Content" }, 374 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 375 + ], 328 376 accentColor: "#666", 329 377 backgroundColor: "#999", 330 378 createdAt: now(), ··· 346 394 $type: "app.sidetrail.trail", 347 395 title: "Popular Trail", 348 396 description: "Many completers", 349 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 397 + stops: [ 398 + { tid: generateTid(), title: "Stop", content: "Content" }, 399 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 400 + ], 350 401 accentColor: "#777", 351 402 backgroundColor: "#888", 352 403 createdAt: now(),
+84 -21
data/__tests__/trails.test.ts
··· 72 72 $type: "app.sidetrail.trail", 73 73 title: "Lonely Trail", 74 74 description: "No one has walked this", 75 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 75 + stops: [ 76 + { tid: generateTid(), title: "Stop", content: "Content" }, 77 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 78 + ], 76 79 accentColor: "#111", 77 80 backgroundColor: "#eee", 78 81 createdAt: now(), ··· 96 99 $type: "app.sidetrail.trail", 97 100 title: "Popular Trail", 98 101 description: "Many people walking", 99 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 102 + stops: [ 103 + { tid: stopTid, title: "Stop", content: "Content" }, 104 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 105 + ], 100 106 accentColor: "#ff0000", 101 107 backgroundColor: "#ffffff", 102 108 createdAt: now(), ··· 167 173 $type: "app.sidetrail.trail", 168 174 title: "Activity Order Trail", 169 175 description: "Testing order", 170 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 176 + stops: [ 177 + { tid: stopTid, title: "Stop", content: "Content" }, 178 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 179 + ], 171 180 accentColor: "#00ff00", 172 181 backgroundColor: "#f0fff0", 173 182 createdAt: now(), ··· 259 268 $type: "app.sidetrail.trail", 260 269 title: "Walkers and Completers", 261 270 description: "Mixed activity", 262 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 271 + stops: [ 272 + { tid: stopTid, title: "Stop", content: "Content" }, 273 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 274 + ], 263 275 accentColor: "#ff00ff", 264 276 backgroundColor: "#fff0ff", 265 277 createdAt: now(), ··· 302 314 $type: "app.sidetrail.trail", 303 315 title: "IsYou Test", 304 316 description: "Testing current user marker", 305 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 317 + stops: [ 318 + { tid: stopTid, title: "Stop", content: "Content" }, 319 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 320 + ], 306 321 accentColor: "#123456", 307 322 backgroundColor: "#654321", 308 323 createdAt: now(), ··· 343 358 $type: "app.sidetrail.trail", 344 359 title: "Ephemeral Trail", 345 360 description: "Will be deleted", 346 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 361 + stops: [ 362 + { tid: stopTid, title: "Stop", content: "Content" }, 363 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 364 + ], 347 365 accentColor: "#aaa", 348 366 backgroundColor: "#bbb", 349 367 createdAt: now(), ··· 375 393 $type: "app.sidetrail.trail", 376 394 title: "Soon Gone", 377 395 description: "Will 404", 378 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 396 + stops: [ 397 + { tid: generateTid(), title: "Stop", content: "Content" }, 398 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 399 + ], 379 400 accentColor: "#ccc", 380 401 backgroundColor: "#ddd", 381 402 createdAt: now(), ··· 394 415 $type: "app.sidetrail.trail", 395 416 title: "Disappearing Trail", 396 417 description: "Walkers will be orphaned", 397 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 418 + stops: [ 419 + { tid: stopTid, title: "Stop", content: "Content" }, 420 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 421 + ], 398 422 accentColor: "#111", 399 423 backgroundColor: "#222", 400 424 createdAt: now(), ··· 425 449 $type: "app.sidetrail.trail", 426 450 title: "Completed Then Deleted", 427 451 description: "Completion will be orphaned", 428 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 452 + stops: [ 453 + { tid: generateTid(), title: "Stop", content: "Content" }, 454 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 455 + ], 429 456 accentColor: "#333", 430 457 backgroundColor: "#444", 431 458 createdAt: now(), ··· 451 478 $type: "app.sidetrail.trail", 452 479 title: "Badge Trail", 453 480 description: "Badge will disappear", 454 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 481 + stops: [ 482 + { tid: generateTid(), title: "Stop", content: "Content" }, 483 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 484 + ], 455 485 accentColor: "#abcdef", 456 486 backgroundColor: "#fedcba", 457 487 createdAt: now(), ··· 486 516 $type: "app.sidetrail.trail", 487 517 title: "Trail A - Old Activity", 488 518 description: "Walked a while ago", 489 - stops: [{ tid: stopTidA, title: "Stop", content: "Content" }], 519 + stops: [ 520 + { tid: stopTidA, title: "Stop", content: "Content" }, 521 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 522 + ], 490 523 accentColor: "#111", 491 524 backgroundColor: "#eee", 492 525 createdAt: now(), ··· 496 529 $type: "app.sidetrail.trail", 497 530 title: "Trail B - Recent Activity", 498 531 description: "Just walked", 499 - stops: [{ tid: stopTidB, title: "Stop", content: "Content" }], 532 + stops: [ 533 + { tid: stopTidB, title: "Stop", content: "Content" }, 534 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 535 + ], 500 536 accentColor: "#222", 501 537 backgroundColor: "#ddd", 502 538 createdAt: now(), ··· 537 573 $type: "app.sidetrail.trail", 538 574 title: "Trail A - One Walker", 539 575 description: "One person walking", 540 - stops: [{ tid: stopTidA, title: "Stop", content: "Content" }], 576 + stops: [ 577 + { tid: stopTidA, title: "Stop", content: "Content" }, 578 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 579 + ], 541 580 accentColor: "#111", 542 581 backgroundColor: "#eee", 543 582 createdAt: now(), ··· 547 586 $type: "app.sidetrail.trail", 548 587 title: "Trail B - Three Walkers", 549 588 description: "Three people walking", 550 - stops: [{ tid: stopTidB, title: "Stop", content: "Content" }], 589 + stops: [ 590 + { tid: stopTidB, title: "Stop", content: "Content" }, 591 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 592 + ], 551 593 accentColor: "#222", 552 594 backgroundColor: "#ddd", 553 595 createdAt: now(), ··· 603 645 $type: "app.sidetrail.trail", 604 646 title: "Trail A - Only Walk", 605 647 description: "Someone is walking", 606 - stops: [{ tid: stopTidA, title: "Stop", content: "Content" }], 648 + stops: [ 649 + { tid: stopTidA, title: "Stop", content: "Content" }, 650 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 651 + ], 607 652 accentColor: "#111", 608 653 backgroundColor: "#eee", 609 654 createdAt: now(), ··· 613 658 $type: "app.sidetrail.trail", 614 659 title: "Trail B - Walk Plus Completion", 615 660 description: "Someone walked and completed", 616 - stops: [{ tid: stopTidB, title: "Stop", content: "Content" }], 661 + stops: [ 662 + { tid: stopTidB, title: "Stop", content: "Content" }, 663 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 664 + ], 617 665 accentColor: "#222", 618 666 backgroundColor: "#ddd", 619 667 createdAt: now(), ··· 659 707 $type: "app.sidetrail.trail", 660 708 title: "Old Trail With Activity", 661 709 description: "Old but has activity", 662 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 710 + stops: [ 711 + { tid: stopTid, title: "Stop", content: "Content" }, 712 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 713 + ], 663 714 accentColor: "#111", 664 715 backgroundColor: "#eee", 665 716 createdAt: sixtyDaysAgo, ··· 671 722 $type: "app.sidetrail.trail", 672 723 title: "New Trail No Activity", 673 724 description: "Newer but no one has walked", 674 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 725 + stops: [ 726 + { tid: generateTid(), title: "Stop", content: "Content" }, 727 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 728 + ], 675 729 accentColor: "#222", 676 730 backgroundColor: "#ddd", 677 731 createdAt: oneDayAgo, ··· 703 757 $type: "app.sidetrail.trail", 704 758 title: "Trail A - Author Self-Walk", 705 759 description: "Author walked their own trail", 706 - stops: [{ tid: stopTidA, title: "Stop", content: "Content" }], 760 + stops: [ 761 + { tid: stopTidA, title: "Stop", content: "Content" }, 762 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 763 + ], 707 764 accentColor: "#111", 708 765 backgroundColor: "#eee", 709 766 createdAt: now(), ··· 713 770 $type: "app.sidetrail.trail", 714 771 title: "Trail B - Non-Author Walk", 715 772 description: "Someone else walked", 716 - stops: [{ tid: stopTidB, title: "Stop", content: "Content" }], 773 + stops: [ 774 + { tid: stopTidB, title: "Stop", content: "Content" }, 775 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 776 + ], 717 777 accentColor: "#222", 718 778 backgroundColor: "#ddd", 719 779 createdAt: now(), ··· 758 818 $type: "app.sidetrail.trail", 759 819 title: "Lonely Trail", 760 820 description: "Nobody here yet", 761 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 821 + stops: [ 822 + { tid: generateTid(), title: "Stop", content: "Content" }, 823 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 824 + ], 762 825 accentColor: "#555", 763 826 backgroundColor: "#666", 764 827 createdAt: now(),
+85 -23
data/__tests__/walking.actions.test.ts
··· 12 12 */ 13 13 14 14 import { describe, it, expect } from "vitest"; 15 - import { emit, ALICE, BOB, setCurrentUser, generateTid, captureInitialState } from "./helpers"; 15 + import { 16 + emit, 17 + ALICE, 18 + BOB, 19 + setCurrentUser, 20 + generateTid, 21 + captureInitialState, 22 + getTestDb, 23 + } from "./helpers"; 24 + import { trails } from "./helpers/test-db"; 16 25 import { 17 26 startWalk, 18 27 visitStop, ··· 63 72 }); 64 73 65 74 it("fails if trail has no stops", async () => { 66 - const trail = await emit("app.sidetrail.trail", ALICE.did, { 67 - $type: "app.sidetrail.trail", 68 - title: "Empty Trail", 69 - description: "No stops", 70 - stops: [], 71 - accentColor: "#ff0000", 72 - backgroundColor: "#ffffff", 73 - createdAt: now(), 75 + // A stop-less trail can no longer be ingested (rejected by lexicon 76 + // validation), but legacy rows could still exist in the index - 77 + // startWalk guards against them. Insert the row directly. 78 + const db = getTestDb(); 79 + const rkey = generateTid(); 80 + const uri = `at://${ALICE.did}/app.sidetrail.trail/${rkey}`; 81 + const cid = "bafyreib2rxk3rh6kzwq6kekiwkvrccdvx2dcztj47jyhgkamlpc5o4kf2u"; 82 + await db.insert(trails).values({ 83 + uri, 84 + cid, 85 + authorDid: ALICE.did, 86 + rkey, 87 + record: { 88 + $type: "app.sidetrail.trail", 89 + title: "Empty Trail", 90 + description: "No stops", 91 + stops: [], 92 + accentColor: "#ff0000", 93 + backgroundColor: "#ffffff", 94 + createdAt: now(), 95 + }, 96 + createdAt: new Date(), 74 97 }); 75 98 76 99 await captureInitialState(); 77 100 setCurrentUser(BOB); 78 101 79 - await expect(startWalk(trail.uri, trail.cid)).rejects.toThrow("Trail has no stops"); 102 + await expect(startWalk(uri, cid)).rejects.toThrow("Trail has no stops"); 80 103 }); 81 104 }); 82 105 ··· 171 194 $type: "app.sidetrail.trail", 172 195 title: "Completable Trail", 173 196 description: "Can be finished", 174 - stops: [{ tid: stop1, title: "Final Stop", content: "Done!" }], 197 + stops: [ 198 + { tid: stop1, title: "Final Stop", content: "Done!" }, 199 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 200 + ], 175 201 accentColor: "#ffd700", 176 202 backgroundColor: "#ffffff", 177 203 createdAt: now(), ··· 211 237 $type: "app.sidetrail.trail", 212 238 title: "Abandonable Trail", 213 239 description: "User might give up", 214 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 240 + stops: [ 241 + { tid: stop1, title: "Stop", content: "Content" }, 242 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 243 + ], 215 244 accentColor: "#ff6b6b", 216 245 backgroundColor: "#ffffff", 217 246 createdAt: now(), ··· 251 280 $type: "app.sidetrail.trail", 252 281 title: "Completed Trail", 253 282 description: "Already finished", 254 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 283 + stops: [ 284 + { tid: stop1, title: "Stop", content: "Content" }, 285 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 286 + ], 255 287 accentColor: "#00ff00", 256 288 backgroundColor: "#ffffff", 257 289 createdAt: now(), ··· 289 321 $type: "app.sidetrail.trail", 290 322 title: "Forgettable Trail", 291 323 description: "User wants to forget", 292 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 324 + stops: [ 325 + { tid: stop1, title: "Stop", content: "Content" }, 326 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 327 + ], 293 328 accentColor: "#purple", 294 329 backgroundColor: "#ffffff", 295 330 createdAt: now(), ··· 397 432 $type: "app.sidetrail.trail", 398 433 title: "Deletable Trail", 399 434 description: "Will be deleted", 400 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 435 + stops: [ 436 + { tid: stop1, title: "Stop", content: "Content" }, 437 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 438 + ], 401 439 accentColor: "#ff0000", 402 440 backgroundColor: "#ffffff", 403 441 createdAt: now(), ··· 433 471 $type: "app.sidetrail.trail", 434 472 title: "Completed Trail", 435 473 description: "Bob completed this", 436 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 474 + stops: [ 475 + { tid: stop1, title: "Stop", content: "Content" }, 476 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 477 + ], 437 478 accentColor: "#00ff00", 438 479 backgroundColor: "#ffffff", 439 480 createdAt: now(), ··· 463 504 $type: "app.sidetrail.trail", 464 505 title: "Trail with Both", 465 506 description: "Bob has walk and completion", 466 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 507 + stops: [ 508 + { tid: stop1, title: "Stop", content: "Content" }, 509 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 510 + ], 467 511 accentColor: "#0000ff", 468 512 backgroundColor: "#ffffff", 469 513 createdAt: now(), ··· 503 547 $type: "app.sidetrail.trail", 504 548 title: "Second Attempt Trail", 505 549 description: "Bob will abandon second attempt", 506 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 550 + stops: [ 551 + { tid: stop1, title: "Stop", content: "Content" }, 552 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 553 + ], 507 554 accentColor: "#ff6b6b", 508 555 backgroundColor: "#ffffff", 509 556 createdAt: now(), ··· 545 592 $type: "app.sidetrail.trail", 546 593 title: "Re-complete Trail", 547 594 description: "Bob will complete again", 548 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 595 + stops: [ 596 + { tid: stop1, title: "Stop", content: "Content" }, 597 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 598 + ], 549 599 accentColor: "#ffd700", 550 600 backgroundColor: "#ffffff", 551 601 createdAt: now(), ··· 587 637 $type: "app.sidetrail.trail", 588 638 title: "Forgettable Trail", 589 639 description: "Bob will forget everything", 590 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 640 + stops: [ 641 + { tid: stop1, title: "Stop", content: "Content" }, 642 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 643 + ], 591 644 accentColor: "#purple", 592 645 backgroundColor: "#ffffff", 593 646 createdAt: now(), ··· 633 686 $type: "app.sidetrail.trail", 634 687 title: "No Duplicate Test", 635 688 description: "Testing startWalk idempotency", 636 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 689 + stops: [ 690 + { tid: stop1, title: "Stop", content: "Content" }, 691 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 692 + ], 637 693 accentColor: "#ff0000", 638 694 backgroundColor: "#ffffff", 639 695 createdAt: now(), ··· 670 726 $type: "app.sidetrail.trail", 671 727 title: "Complete With Duplicates", 672 728 description: "Testing complete cleans up duplicates", 673 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 729 + stops: [ 730 + { tid: stop1, title: "Stop", content: "Content" }, 731 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 732 + ], 674 733 accentColor: "#00ff00", 675 734 backgroundColor: "#ffffff", 676 735 createdAt: now(), ··· 733 792 $type: "app.sidetrail.trail", 734 793 title: "Abandon With Duplicates", 735 794 description: "Testing abandon cleans up duplicates", 736 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 795 + stops: [ 796 + { tid: stop1, title: "Stop", content: "Content" }, 797 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 798 + ], 737 799 accentColor: "#0000ff", 738 800 backgroundColor: "#ffffff", 739 801 createdAt: now(),
+64 -16
data/__tests__/walking.test.ts
··· 68 68 $type: "app.sidetrail.trail", 69 69 title, 70 70 description: "Test", 71 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 71 + stops: [ 72 + { tid: generateTid(), title: "Stop", content: "Content" }, 73 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 74 + ], 72 75 accentColor: `#${title.replace(" ", "")}`, 73 76 backgroundColor: "#ffffff", 74 77 createdAt: now(), ··· 239 242 $type: "app.sidetrail.trail", 240 243 title: "Activity Order Trail", 241 244 description: "Test", 242 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 245 + stops: [ 246 + { tid: stopTid, title: "Stop", content: "Content" }, 247 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 248 + ], 243 249 accentColor: "#00ff00", 244 250 backgroundColor: "#f0fff0", 245 251 createdAt: now(), ··· 295 301 $type: "app.sidetrail.trail", 296 302 title: "Completable Trail", 297 303 description: "Finish this one", 298 - stops: [{ tid: stopTid, title: "Only Stop", content: "Done!" }], 304 + stops: [ 305 + { tid: stopTid, title: "Only Stop", content: "Done!" }, 306 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 307 + ], 299 308 accentColor: "#ffd700", 300 309 backgroundColor: "#fffde7", 301 310 createdAt: now(), ··· 329 338 $type: "app.sidetrail.trail", 330 339 title: "Re-completable", 331 340 description: "Can complete multiple times", 332 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 341 + stops: [ 342 + { tid: generateTid(), title: "Stop", content: "Content" }, 343 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 344 + ], 333 345 accentColor: "#ff00ff", 334 346 backgroundColor: "#fff0ff", 335 347 createdAt: now(), ··· 365 377 $type: "app.sidetrail.trail", 366 378 title: "Walk And Complete", 367 379 description: "Testing precedence", 368 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 380 + stops: [ 381 + { tid: stopTid, title: "Stop", content: "Content" }, 382 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 383 + ], 369 384 accentColor: "#0000ff", 370 385 backgroundColor: "#f0f0ff", 371 386 createdAt: now(), ··· 402 417 $type: "app.sidetrail.trail", 403 418 title: "Abandonable Trail", 404 419 description: "User might give up", 405 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 420 + stops: [ 421 + { tid: stopTid, title: "Stop", content: "Content" }, 422 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 423 + ], 406 424 accentColor: "#ff6b6b", 407 425 backgroundColor: "#fff5f5", 408 426 createdAt: now(), ··· 499 517 $type: "app.sidetrail.trail", 500 518 title: "Forgettable Trail", 501 519 description: "Remove all traces", 502 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 520 + stops: [ 521 + { tid: stopTid, title: "Stop", content: "Content" }, 522 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 523 + ], 503 524 accentColor: "#9c27b0", 504 525 backgroundColor: "#f3e5f5", 505 526 createdAt: now(), ··· 551 572 $type: "app.sidetrail.trail", 552 573 title: "Some Trail", 553 574 description: "Test", 554 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 575 + stops: [ 576 + { tid: generateTid(), title: "Stop", content: "Content" }, 577 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 578 + ], 555 579 accentColor: "#123", 556 580 backgroundColor: "#456", 557 581 createdAt: now(), ··· 581 605 $type: "app.sidetrail.trail", 582 606 title: "Trail", 583 607 description: "Test", 584 - stops: [{ tid: stopTid, title: "Stop", content: "Content" }], 608 + stops: [ 609 + { tid: stopTid, title: "Stop", content: "Content" }, 610 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 611 + ], 585 612 accentColor: "#abc", 586 613 backgroundColor: "#def", 587 614 createdAt: now(), ··· 622 649 $type: "app.sidetrail.trail", 623 650 title, 624 651 description: "Test", 625 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 652 + stops: [ 653 + { tid: generateTid(), title: "Stop", content: "Content" }, 654 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 655 + ], 626 656 accentColor: "#111", 627 657 backgroundColor: "#eee", 628 658 createdAt: now(), ··· 654 684 $type: "app.sidetrail.trail", 655 685 title: "Fallback Test", 656 686 description: "Test", 657 - stops: [{ tid: "s1", title: "Stop", content: "Content" }], 687 + stops: [ 688 + { tid: generateTid(), title: "Stop", content: "Content" }, 689 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 690 + ], 658 691 accentColor: "#000", 659 692 backgroundColor: "#fff", 660 693 createdAt: now(), ··· 693 726 $type: "app.sidetrail.trail", 694 727 title: "Trail With Duplicates", 695 728 description: "Testing deduplication", 696 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 729 + stops: [ 730 + { tid: stop1, title: "Stop", content: "Content" }, 731 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 732 + ], 697 733 accentColor: "#ff0000", 698 734 backgroundColor: "#ffffff", 699 735 createdAt: now(), ··· 731 767 $type: "app.sidetrail.trail", 732 768 title: "Duplicate Walk Test", 733 769 description: "Testing yourWalk dedup", 734 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 770 + stops: [ 771 + { tid: stop1, title: "Stop", content: "Content" }, 772 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 773 + ], 735 774 accentColor: "#00ff00", 736 775 backgroundColor: "#ffffff", 737 776 createdAt: now(), ··· 769 808 $type: "app.sidetrail.trail", 770 809 title: "Active Walkers Dedup", 771 810 description: "Testing walker dedup", 772 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 811 + stops: [ 812 + { tid: stop1, title: "Stop", content: "Content" }, 813 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 814 + ], 773 815 accentColor: "#0000ff", 774 816 backgroundColor: "#ffffff", 775 817 createdAt: now(), ··· 825 867 $type: "app.sidetrail.trail", 826 868 title: "Walkers List Dedup", 827 869 description: "Testing walkers dedup", 828 - stops: [{ tid: stop1, title: "Stop", content: "Content" }], 870 + stops: [ 871 + { tid: stop1, title: "Stop", content: "Content" }, 872 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 873 + ], 829 874 accentColor: "#ff00ff", 830 875 backgroundColor: "#ffffff", 831 876 createdAt: now(), ··· 907 952 $type: "app.sidetrail.trail", 908 953 title: "Badge Dedup Trail", 909 954 description: "Testing badge dedup", 910 - stops: [{ tid: generateTid(), title: "Stop", content: "Content" }], 955 + stops: [ 956 + { tid: generateTid(), title: "Stop", content: "Content" }, 957 + { tid: generateTid(), title: "Filler stop", content: "Filler content" }, 958 + ], 911 959 accentColor: "#badge1", 912 960 backgroundColor: "#ffffff", 913 961 createdAt: now(),
+18 -2
data/actions.ts
··· 357 357 const errors: string[] = []; 358 358 const inlineErrors: Record<string, string> = {}; 359 359 360 + // Length limits mirror the lexicon (lexicons/app/sidetrail/trail.json) - 361 + // records that exceed them would be rejected by our own ingester. 362 + const graphemes = (s: string) => [...new Intl.Segmenter().segment(s)].length; 363 + 360 364 if (!draftData.title.trim()) { 361 365 errors.push("trail needs a title"); 366 + } else if (graphemes(draftData.title) > 64) { 367 + errors.push("trail title is too long (max 64 characters)"); 362 368 } 363 369 364 370 if (!draftData.description.trim()) { 365 371 errors.push("trail needs a description"); 372 + } else if (graphemes(draftData.description) > 300) { 373 + errors.push("trail description is too long (max 300 characters)"); 366 374 } 367 375 368 376 if (draftData.stops.length < 2) { 369 377 errors.push("trails need at least 2 stops to be published"); 370 378 } 371 379 372 - if (draftData.stops.length > 12) { 373 - errors.push("trails can have a maximum of 12 stops"); 380 + if (draftData.stops.length > 24) { 381 + errors.push("trails can have a maximum of 24 stops"); 374 382 } 375 383 376 384 for (let i = 0; i < draftData.stops.length; i++) { ··· 379 387 380 388 if (!stop.title.trim()) { 381 389 stopErrors.push("needs a title"); 390 + } else if (graphemes(stop.title) > 128) { 391 + stopErrors.push("title is too long (max 128 characters)"); 382 392 } 383 393 384 394 if (!stop.content.trim()) { 385 395 stopErrors.push("needs content"); 396 + } else if (graphemes(stop.content) > 5000) { 397 + stopErrors.push("content is too long (max 5000 characters)"); 398 + } 399 + 400 + if (stop.buttonText && graphemes(stop.buttonText) > 64) { 401 + stopErrors.push("button text is too long (max 64 characters)"); 386 402 } 387 403 388 404 if (stopErrors.length > 0) {
+8
ingester/src/handler.ts
··· 4 4 import type { NodePgDatabase } from "drizzle-orm/node-postgres"; 5 5 import { trails, walks, completions, accounts } from "@sidetrail/db"; 6 6 import type { JetstreamEvent, AccountEvent } from "./jetstream.js"; 7 + import { validateRecord, type IndexedCollection } from "./lexicons.js"; 7 8 8 9 export const COLLECTIONS = [ 9 10 "app.sidetrail.trail", ··· 236 237 } 237 238 238 239 const record = commit.record as Record<string, unknown>; 240 + 241 + const validation = validateRecord(collection as IndexedCollection, record); 242 + if (!validation.success) { 243 + log(`Rejecting invalid ${collection} ${uri}: ${validation.reason}`); 244 + return; 245 + } 246 + 239 247 await ensureAccount(db, evt.did); 240 248 241 249 switch (collection) {
+38
ingester/src/lexicons.ts
··· 1 + import { jsonToLex } from "@atproto/lex-json"; 2 + import { main as trail } from "../../lib/lexicons/app/sidetrail/trail.defs"; 3 + import { main as walk } from "../../lib/lexicons/app/sidetrail/walk.defs"; 4 + import { main as completion } from "../../lib/lexicons/app/sidetrail/completion.defs"; 5 + 6 + // Runtime lexicon schemas for every collection we index. Records that fail 7 + // validation are rejected: PDSes don't reliably enforce third-party lexicons, 8 + // so validating is the indexer's responsibility. 9 + export const RECORD_SCHEMAS = { 10 + "app.sidetrail.trail": trail, 11 + "app.sidetrail.walk": walk, 12 + "app.sidetrail.completion": completion, 13 + } as const; 14 + 15 + export type IndexedCollection = keyof typeof RECORD_SCHEMAS; 16 + 17 + export function validateRecord( 18 + collection: IndexedCollection, 19 + record: unknown, 20 + ): { success: true } | { success: false; reason: string } { 21 + // Records arrive as JSON (jetstream, listRecords, jsonb storage), where CID 22 + // links are {"$link": ...} objects. Schemas validate lex values, so convert 23 + // first; a record that can't even be converted (e.g. malformed CID) is invalid. 24 + let lexValue: unknown; 25 + try { 26 + lexValue = jsonToLex(record as Parameters<typeof jsonToLex>[0]); 27 + } catch (err) { 28 + return { success: false, reason: `unparseable as lex: ${(err as Error).message}` }; 29 + } 30 + const result = RECORD_SCHEMAS[collection].validate(lexValue); 31 + if (result.success) return { success: true }; 32 + return { 33 + success: false, 34 + reason: result.error.issues 35 + .map((issue) => `${issue.code} at ${issue.path.join(".") || "(root)"}`) 36 + .join("; "), 37 + }; 38 + }
+7 -7
lexicons/app/sidetrail/trail.json
··· 32 32 "stops": { 33 33 "type": "array", 34 34 "minLength": 2, 35 - "maxLength": 12, 35 + "maxLength": 24, 36 36 "items": { 37 37 "type": "ref", 38 38 "ref": "#stop" ··· 66 66 }, 67 67 "title": { 68 68 "type": "string", 69 - "maxGraphemes": 64, 70 - "maxLength": 640, 69 + "maxGraphemes": 128, 70 + "maxLength": 1280, 71 71 "description": "stop title" 72 72 }, 73 73 "content": { 74 74 "type": "string", 75 - "maxGraphemes": 1000, 76 - "maxLength": 10000, 75 + "maxGraphemes": 5000, 76 + "maxLength": 50000, 77 77 "description": "stop content text" 78 78 }, 79 79 "buttonText": { 80 80 "type": "string", 81 - "maxGraphemes": 20, 82 - "maxLength": 200, 81 + "maxGraphemes": 64, 82 + "maxLength": 640, 83 83 "description": "custom button text (e.g., 'got it', 'done that')" 84 84 }, 85 85 "external": {