a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
99
fork

Configure Feed

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

feat(lexicon-doc): support strict blob validation in RecordValidator

Mary b36d1a80 94065a15

+141 -6
+5
.changeset/strict-blob-record-validator.md
··· 1 + --- 2 + '@atcute/lexicon-doc': minor 3 + --- 4 + 5 + support strict blob validation in RecordValidator
+115
packages/lexicons/lexicon-doc/lib/validations.test.ts
··· 545 545 expect(result).toBe(false); 546 546 }); 547 547 }); 548 + 549 + describe('strict mode', () => { 550 + test('validates blob size in strict mode', () => { 551 + const validator = new RecordValidator(docs, 'com.example.profile'); 552 + 553 + const input = { 554 + key: 'self', 555 + object: { 556 + $type: 'com.example.profile', 557 + displayName: 'alice', 558 + avatar: { 559 + $type: 'blob', 560 + ref: { $link: 'bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m' }, 561 + mimeType: 'image/png', 562 + size: 50000, 563 + }, 564 + }, 565 + }; 566 + 567 + // passes without strict (constraints are inert) 568 + expect(validator.is(input)).toBe(true); 569 + 570 + // passes with strict (50000 < 1000000 maxSize) 571 + expect(validator.is(input, { strict: true })).toBe(true); 572 + }); 573 + 574 + test('rejects blob exceeding maxSize in strict mode', () => { 575 + const validator = new RecordValidator(docs, 'com.example.profile'); 576 + 577 + const input = { 578 + key: 'self', 579 + object: { 580 + $type: 'com.example.profile', 581 + avatar: { 582 + $type: 'blob', 583 + ref: { $link: 'bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m' }, 584 + mimeType: 'image/png', 585 + size: 2000000, 586 + }, 587 + }, 588 + }; 589 + 590 + // passes without strict 591 + expect(validator.is(input)).toBe(true); 592 + 593 + // fails with strict (2000000 > 1000000 maxSize) 594 + expect(validator.is(input, { strict: true })).toBe(false); 595 + }); 596 + 597 + test('rejects blob with wrong MIME type in strict mode', () => { 598 + const validator = new RecordValidator(docs, 'com.example.profile'); 599 + 600 + const input = { 601 + key: 'self', 602 + object: { 603 + $type: 'com.example.profile', 604 + avatar: { 605 + $type: 'blob', 606 + ref: { $link: 'bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m' }, 607 + mimeType: 'video/mp4', 608 + size: 50000, 609 + }, 610 + }, 611 + }; 612 + 613 + // passes without strict 614 + expect(validator.is(input)).toBe(true); 615 + 616 + // fails with strict (video/mp4 doesn't match image/*) 617 + expect(validator.is(input, { strict: true })).toBe(false); 618 + }); 619 + 620 + test('rejects legacy blobs in strict mode', () => { 621 + const validator = new RecordValidator(docs, 'com.example.profile'); 622 + 623 + const input = { 624 + key: 'self', 625 + object: { 626 + $type: 'com.example.profile', 627 + avatar: { 628 + cid: 'bafkreidjmlrsggn2shrihfyp4iwlmxdp4dso7iqbkhfrpq6ahm22obop34', 629 + mimeType: 'image/jpeg', 630 + }, 631 + }, 632 + }; 633 + 634 + // passes without strict (legacy blobs are transformed) 635 + expect(validator.is(input)).toBe(true); 636 + 637 + // fails with strict (legacy blobs rejected) 638 + expect(validator.is(input, { strict: true })).toBe(false); 639 + }); 640 + 641 + test('try() reports strict validation issues', () => { 642 + const validator = new RecordValidator(docs, 'com.example.profile'); 643 + 644 + const result = validator.try( 645 + { 646 + key: 'self', 647 + object: { 648 + $type: 'com.example.profile', 649 + avatar: { 650 + $type: 'blob', 651 + ref: { $link: 'bafyreihvzsz6wxhv5idsmsjfbx5jdmfrqx3h4oqw2vvxpwzcdpavqzkp4m' }, 652 + mimeType: 'image/png', 653 + size: 2000000, 654 + }, 655 + }, 656 + }, 657 + { strict: true }, 658 + ); 659 + 660 + expect(result.ok).toBe(false); 661 + }); 662 + }); 548 663 });
+21 -6
packages/lexicons/lexicon-doc/lib/validations.ts
··· 50 50 this.#validator = validator; 51 51 } 52 52 53 - is(input: RecordValidatorInput): boolean { 54 - return v.is(this.#validator, input); 53 + is(input: RecordValidatorInput, options?: v.ValidationOptions): boolean { 54 + return v.is(this.#validator, input, options); 55 55 } 56 56 57 - try(input: RecordValidatorInput): v.ValidationResult<RecordValidatorInput> { 58 - return v.safeParse(this.#validator, input); 57 + try(input: RecordValidatorInput, options?: v.ValidationOptions): v.ValidationResult<RecordValidatorInput> { 58 + return v.safeParse(this.#validator, input, options); 59 59 } 60 60 61 - parse(input: RecordValidatorInput): RecordValidatorInput { 62 - return v.parse(this.#validator, input); 61 + parse(input: RecordValidatorInput, options?: v.ValidationOptions): RecordValidatorInput { 62 + return v.parse(this.#validator, input, options); 63 63 } 64 64 } 65 65 ··· 381 381 382 382 assertRefine(path, refineLexBlob(spec)); 383 383 384 + const { accept, maxSize } = spec; 385 + const constraints: v.BaseConstraint<any>[] = []; 386 + 387 + if (maxSize !== undefined) { 388 + constraints.push(v.blobSize(maxSize)); 389 + } 390 + 391 + if (accept !== undefined && accept.length > 0 && !accept.includes('*/*')) { 392 + constraints.push(v.blobAccept(accept)); 393 + } 394 + 384 395 let schema: v.BaseSchema = v.blob(); 396 + 397 + if (constraints.length > 0) { 398 + schema = v.constrain(schema, constraints as any); 399 + } 385 400 386 401 cell = eager(schema); 387 402 ctx.cache.set(spec, cell);