Barazo lexicon schemas and TypeScript types barazo.forum
1
fork

Configure Feed

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

feat(lexicons): add facets, langs fields and vote record type (#27)

Add optional `facets` (richtext annotations) and `langs` (BCP 47
language tags) fields to topic.post and topic.reply schemas, following
the Bluesky convention from app.bsky.feed.post.

Add new `forum.barazo.interaction.vote` record type for directional
voting (separate from reactions). Uses knownValues: ["up"] so "down"
can be added later without breaking change.

Changes:
- Vendor app.bsky.richtext.facet lexicon for facets reference
- Add facets + langs to post.json and reply.json (optional)
- Create interaction/vote.json with subject, direction, community
- Add vote to authForumAccess permission set
- Create Zod schemas for facet and vote validation
- Update all exports (generated types, Zod, LEXICON_IDS)
- Add 40 new tests (175 total, up from 135)

All changes are additive (non-breaking).

Fixes barazo-forum/barazo-workspace#30

authored by

Guido X Jansen and committed by
GitHub
6d06634b ad8fa16b

+825 -26
+73
lexicons/app/bsky/richtext/facet.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.richtext.facet", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "description": "Annotation of a sub-string within rich text.", 8 + "required": ["index", "features"], 9 + "properties": { 10 + "index": { 11 + "type": "ref", 12 + "ref": "#byteSlice" 13 + }, 14 + "features": { 15 + "type": "array", 16 + "items": { 17 + "type": "union", 18 + "refs": ["#mention", "#link", "#tag"] 19 + } 20 + } 21 + } 22 + }, 23 + "mention": { 24 + "type": "object", 25 + "description": "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", 26 + "required": ["did"], 27 + "properties": { 28 + "did": { 29 + "type": "string", 30 + "format": "did" 31 + } 32 + } 33 + }, 34 + "link": { 35 + "type": "object", 36 + "description": "Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.", 37 + "required": ["uri"], 38 + "properties": { 39 + "uri": { 40 + "type": "string", 41 + "format": "uri" 42 + } 43 + } 44 + }, 45 + "tag": { 46 + "type": "object", 47 + "description": "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", 48 + "required": ["tag"], 49 + "properties": { 50 + "tag": { 51 + "type": "string", 52 + "maxLength": 640, 53 + "maxGraphemes": 64 54 + } 55 + } 56 + }, 57 + "byteSlice": { 58 + "type": "object", 59 + "description": "Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text.", 60 + "required": ["byteStart", "byteEnd"], 61 + "properties": { 62 + "byteStart": { 63 + "type": "integer", 64 + "minimum": 0 65 + }, 66 + "byteEnd": { 67 + "type": "integer", 68 + "minimum": 0 69 + } 70 + } 71 + } 72 + } 73 + }
+1
lexicons/forum/barazo/authForumAccess.json
··· 15 15 "forum.barazo.topic.post", 16 16 "forum.barazo.topic.reply", 17 17 "forum.barazo.interaction.reaction", 18 + "forum.barazo.interaction.vote", 18 19 "forum.barazo.actor.preferences" 19 20 ] 20 21 }
+38
lexicons/forum/barazo/interaction/vote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "forum.barazo.interaction.vote", 4 + "description": "A directional vote on a forum topic or reply. Votes are quantitative (ranking); reactions are expressive (emoji-style).", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "description": "Record containing a directional vote on a forum topic or reply.", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": ["subject", "direction", "community", "createdAt"], 13 + "properties": { 14 + "subject": { 15 + "type": "ref", 16 + "ref": "com.atproto.repo.strongRef", 17 + "description": "The topic or reply being voted on." 18 + }, 19 + "direction": { 20 + "type": "string", 21 + "knownValues": ["up"], 22 + "description": "Vote direction. Start upvote-only; 'down' can be added later without breaking change." 23 + }, 24 + "community": { 25 + "type": "string", 26 + "format": "did", 27 + "description": "DID of the community where this vote was cast. Immutable origin identifier." 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "Client-declared timestamp when this vote was originally created." 33 + } 34 + } 35 + } 36 + } 37 + } 38 + }
+17
lexicons/forum/barazo/topic/post.json
··· 51 51 }, 52 52 "description": "Topic tags. Lowercase alphanumeric + hyphens." 53 53 }, 54 + "facets": { 55 + "type": "array", 56 + "description": "Annotations of text (mentions, URLs, hashtags, etc).", 57 + "items": { 58 + "type": "ref", 59 + "ref": "app.bsky.richtext.facet" 60 + } 61 + }, 62 + "langs": { 63 + "type": "array", 64 + "maxLength": 3, 65 + "items": { 66 + "type": "string", 67 + "format": "language" 68 + }, 69 + "description": "BCP 47 language tags indicating the primary language(s) of the content." 70 + }, 54 71 "labels": { 55 72 "type": "union", 56 73 "description": "Self-label values for content maturity (e.g., sexual, nudity, graphic-media).",
+17
lexicons/forum/barazo/topic/reply.json
··· 37 37 "format": "did", 38 38 "description": "DID of the community where this reply was created. Immutable origin identifier." 39 39 }, 40 + "facets": { 41 + "type": "array", 42 + "description": "Annotations of text (mentions, URLs, hashtags, etc).", 43 + "items": { 44 + "type": "ref", 45 + "ref": "app.bsky.richtext.facet" 46 + } 47 + }, 48 + "langs": { 49 + "type": "array", 50 + "maxLength": 3, 51 + "items": { 52 + "type": "string", 53 + "format": "language" 54 + }, 55 + "description": "BCP 47 language tags indicating the primary language(s) of the content." 56 + }, 40 57 "labels": { 41 58 "type": "union", 42 59 "description": "Self-label values for content maturity.",
+2
src/generated/index.ts
··· 3 3 * The XRPC server/client wrappers generated by lex-cli are replaced with 4 4 * direct type re-exports since we use @atproto/api for PDS interactions. 5 5 */ 6 + export * as AppBskyRichtextFacet from "./types/app/bsky/richtext/facet.js"; 6 7 export * as ComAtprotoLabelDefs from "./types/com/atproto/label/defs.js"; 7 8 export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 8 9 export * as ForumBarazoActorPreferences from "./types/forum/barazo/actor/preferences.js"; 9 10 export * as ForumBarazoDefs from "./types/forum/barazo/defs.js"; 10 11 export * as ForumBarazoInteractionReaction from "./types/forum/barazo/interaction/reaction.js"; 12 + export * as ForumBarazoInteractionVote from "./types/forum/barazo/interaction/vote.js"; 11 13 export * as ForumBarazoTopicPost from "./types/forum/barazo/topic/post.js"; 12 14 export * as ForumBarazoTopicReply from "./types/forum/barazo/topic/reply.js"; 13 15 export { schemas, validate } from "./lexicons.js";
+191 -26
src/generated/lexicons.ts
··· 10 10 import { type $Typed, is$typed, maybe$typed } from './util.js' 11 11 12 12 export const schemaDict = { 13 + AppBskyRichtextFacet: { 14 + lexicon: 1, 15 + id: 'app.bsky.richtext.facet', 16 + defs: { 17 + main: { 18 + type: 'object', 19 + description: 'Annotation of a sub-string within rich text.', 20 + required: ['index', 'features'], 21 + properties: { 22 + index: { 23 + type: 'ref', 24 + ref: 'lex:app.bsky.richtext.facet#byteSlice', 25 + }, 26 + features: { 27 + type: 'array', 28 + items: { 29 + type: 'union', 30 + refs: [ 31 + 'lex:app.bsky.richtext.facet#mention', 32 + 'lex:app.bsky.richtext.facet#link', 33 + 'lex:app.bsky.richtext.facet#tag', 34 + ], 35 + }, 36 + }, 37 + }, 38 + }, 39 + mention: { 40 + type: 'object', 41 + description: 42 + "Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.", 43 + required: ['did'], 44 + properties: { 45 + did: { 46 + type: 'string', 47 + format: 'did', 48 + }, 49 + }, 50 + }, 51 + link: { 52 + type: 'object', 53 + description: 54 + 'Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.', 55 + required: ['uri'], 56 + properties: { 57 + uri: { 58 + type: 'string', 59 + format: 'uri', 60 + }, 61 + }, 62 + }, 63 + tag: { 64 + type: 'object', 65 + description: 66 + "Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').", 67 + required: ['tag'], 68 + properties: { 69 + tag: { 70 + type: 'string', 71 + maxLength: 640, 72 + maxGraphemes: 64, 73 + }, 74 + }, 75 + }, 76 + byteSlice: { 77 + type: 'object', 78 + description: 79 + 'Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text.', 80 + required: ['byteStart', 'byteEnd'], 81 + properties: { 82 + byteStart: { 83 + type: 'integer', 84 + minimum: 0, 85 + }, 86 + byteEnd: { 87 + type: 'integer', 88 + minimum: 0, 89 + }, 90 + }, 91 + }, 92 + }, 93 + }, 13 94 ComAtprotoLabelDefs: { 14 95 lexicon: 1, 15 96 id: 'com.atproto.label.defs', ··· 210 291 }, 211 292 }, 212 293 }, 294 + ForumBarazoAuthForumAccess: { 295 + lexicon: 1, 296 + id: 'forum.barazo.authForumAccess', 297 + description: 298 + 'Permission set for Barazo forum access. Grants ability to create topics, replies, and reactions, and manage user preferences.', 299 + defs: { 300 + main: { 301 + type: 'permission-set', 302 + title: 'Barazo Forum', 303 + detail: 304 + 'Create topics, replies, and reactions. Manage your forum preferences.', 305 + permissions: [ 306 + { 307 + type: 'permission', 308 + resource: 'repo', 309 + collection: [ 310 + 'forum.barazo.topic.post', 311 + 'forum.barazo.topic.reply', 312 + 'forum.barazo.interaction.reaction', 313 + 'forum.barazo.interaction.vote', 314 + 'forum.barazo.actor.preferences', 315 + ], 316 + }, 317 + ], 318 + }, 319 + }, 320 + }, 213 321 ForumBarazoActorPreferences: { 214 322 lexicon: 1, 215 323 id: 'forum.barazo.actor.preferences', ··· 288 396 }, 289 397 }, 290 398 }, 291 - ForumBarazoAuthForumAccess: { 292 - lexicon: 1, 293 - id: 'forum.barazo.authForumAccess', 294 - description: 295 - 'Permission set for Barazo forum access. Grants ability to create topics, replies, and reactions, and manage user preferences.', 296 - defs: { 297 - main: { 298 - type: 'permission-set', 299 - title: 'Barazo Forum', 300 - detail: 301 - 'Create topics, replies, and reactions. Manage your forum preferences.', 302 - permissions: [ 303 - { 304 - type: 'permission', 305 - resource: 'repo', 306 - collection: [ 307 - 'forum.barazo.topic.post', 308 - 'forum.barazo.topic.reply', 309 - 'forum.barazo.interaction.reaction', 310 - 'forum.barazo.actor.preferences', 311 - ], 312 - }, 313 - ], 314 - }, 315 - }, 316 - }, 317 399 ForumBarazoDefs: { 318 400 lexicon: 1, 319 401 id: 'forum.barazo.defs', ··· 365 447 }, 366 448 }, 367 449 }, 450 + ForumBarazoInteractionVote: { 451 + lexicon: 1, 452 + id: 'forum.barazo.interaction.vote', 453 + description: 454 + 'A directional vote on a forum topic or reply. Votes are quantitative (ranking); reactions are expressive (emoji-style).', 455 + defs: { 456 + main: { 457 + type: 'record', 458 + description: 459 + 'Record containing a directional vote on a forum topic or reply.', 460 + key: 'tid', 461 + record: { 462 + type: 'object', 463 + required: ['subject', 'direction', 'community', 'createdAt'], 464 + properties: { 465 + subject: { 466 + type: 'ref', 467 + ref: 'lex:com.atproto.repo.strongRef', 468 + description: 'The topic or reply being voted on.', 469 + }, 470 + direction: { 471 + type: 'string', 472 + knownValues: ['up'], 473 + description: 474 + "Vote direction. Start upvote-only; 'down' can be added later without breaking change.", 475 + }, 476 + community: { 477 + type: 'string', 478 + format: 'did', 479 + description: 480 + 'DID of the community where this vote was cast. Immutable origin identifier.', 481 + }, 482 + createdAt: { 483 + type: 'string', 484 + format: 'datetime', 485 + description: 486 + 'Client-declared timestamp when this vote was originally created.', 487 + }, 488 + }, 489 + }, 490 + }, 491 + }, 492 + }, 368 493 ForumBarazoTopicPost: { 369 494 lexicon: 1, 370 495 id: 'forum.barazo.topic.post', ··· 420 545 }, 421 546 description: 'Topic tags. Lowercase alphanumeric + hyphens.', 422 547 }, 548 + facets: { 549 + type: 'array', 550 + description: 551 + 'Annotations of text (mentions, URLs, hashtags, etc).', 552 + items: { 553 + type: 'ref', 554 + ref: 'lex:app.bsky.richtext.facet', 555 + }, 556 + }, 557 + langs: { 558 + type: 'array', 559 + maxLength: 3, 560 + items: { 561 + type: 'string', 562 + format: 'language', 563 + }, 564 + description: 565 + 'BCP 47 language tags indicating the primary language(s) of the content.', 566 + }, 423 567 labels: { 424 568 type: 'union', 425 569 description: ··· 481 625 description: 482 626 'DID of the community where this reply was created. Immutable origin identifier.', 483 627 }, 628 + facets: { 629 + type: 'array', 630 + description: 631 + 'Annotations of text (mentions, URLs, hashtags, etc).', 632 + items: { 633 + type: 'ref', 634 + ref: 'lex:app.bsky.richtext.facet', 635 + }, 636 + }, 637 + langs: { 638 + type: 'array', 639 + maxLength: 3, 640 + items: { 641 + type: 'string', 642 + format: 'language', 643 + }, 644 + description: 645 + 'BCP 47 language tags indicating the primary language(s) of the content.', 646 + }, 484 647 labels: { 485 648 type: 'union', 486 649 description: 'Self-label values for content maturity.', ··· 530 693 } 531 694 532 695 export const ids = { 696 + AppBskyRichtextFacet: 'app.bsky.richtext.facet', 533 697 ComAtprotoLabelDefs: 'com.atproto.label.defs', 534 698 ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', 535 699 ForumBarazoActorPreferences: 'forum.barazo.actor.preferences', 536 700 ForumBarazoAuthForumAccess: 'forum.barazo.authForumAccess', 537 701 ForumBarazoDefs: 'forum.barazo.defs', 538 702 ForumBarazoInteractionReaction: 'forum.barazo.interaction.reaction', 703 + ForumBarazoInteractionVote: 'forum.barazo.interaction.vote', 539 704 ForumBarazoTopicPost: 'forum.barazo.topic.post', 540 705 ForumBarazoTopicReply: 'forum.barazo.topic.reply', 541 706 } as const
+97
src/generated/types/app/bsky/richtext/facet.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + 13 + const is$typed = _is$typed, 14 + validate = _validate 15 + const id = 'app.bsky.richtext.facet' 16 + 17 + /** Annotation of a sub-string within rich text. */ 18 + export interface Main { 19 + $type?: 'app.bsky.richtext.facet' 20 + index: ByteSlice 21 + features: ($Typed<Mention> | $Typed<Link> | $Typed<Tag> | { $type: string })[] 22 + } 23 + 24 + const hashMain = 'main' 25 + 26 + export function isMain<V>(v: V) { 27 + return is$typed(v, id, hashMain) 28 + } 29 + 30 + export function validateMain<V>(v: V) { 31 + return validate<Main & V>(v, id, hashMain) 32 + } 33 + 34 + /** Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID. */ 35 + export interface Mention { 36 + $type?: 'app.bsky.richtext.facet#mention' 37 + did: string 38 + } 39 + 40 + const hashMention = 'mention' 41 + 42 + export function isMention<V>(v: V) { 43 + return is$typed(v, id, hashMention) 44 + } 45 + 46 + export function validateMention<V>(v: V) { 47 + return validate<Mention & V>(v, id, hashMention) 48 + } 49 + 50 + /** Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. */ 51 + export interface Link { 52 + $type?: 'app.bsky.richtext.facet#link' 53 + uri: string 54 + } 55 + 56 + const hashLink = 'link' 57 + 58 + export function isLink<V>(v: V) { 59 + return is$typed(v, id, hashLink) 60 + } 61 + 62 + export function validateLink<V>(v: V) { 63 + return validate<Link & V>(v, id, hashLink) 64 + } 65 + 66 + /** Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). */ 67 + export interface Tag { 68 + $type?: 'app.bsky.richtext.facet#tag' 69 + tag: string 70 + } 71 + 72 + const hashTag = 'tag' 73 + 74 + export function isTag<V>(v: V) { 75 + return is$typed(v, id, hashTag) 76 + } 77 + 78 + export function validateTag<V>(v: V) { 79 + return validate<Tag & V>(v, id, hashTag) 80 + } 81 + 82 + /** Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. */ 83 + export interface ByteSlice { 84 + $type?: 'app.bsky.richtext.facet#byteSlice' 85 + byteStart: number 86 + byteEnd: number 87 + } 88 + 89 + const hashByteSlice = 'byteSlice' 90 + 91 + export function isByteSlice<V>(v: V) { 92 + return is$typed(v, id, hashByteSlice) 93 + } 94 + 95 + export function validateByteSlice<V>(v: V) { 96 + return validate<ByteSlice & V>(v, id, hashByteSlice) 97 + }
+44
src/generated/types/forum/barazo/interaction/vote.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons.js' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util.js' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'forum.barazo.interaction.vote' 17 + 18 + export interface Main { 19 + $type: 'forum.barazo.interaction.vote' 20 + subject: ComAtprotoRepoStrongRef.Main 21 + /** Vote direction. Start upvote-only; 'down' can be added later without breaking change. */ 22 + direction: 'up' | (string & {}) 23 + /** DID of the community where this vote was cast. Immutable origin identifier. */ 24 + community: string 25 + /** Client-declared timestamp when this vote was originally created. */ 26 + createdAt: string 27 + [k: string]: unknown 28 + } 29 + 30 + const hashMain = 'main' 31 + 32 + export function isMain<V>(v: V) { 33 + return is$typed(v, id, hashMain) 34 + } 35 + 36 + export function validateMain<V>(v: V) { 37 + return validate<Main & V>(v, id, hashMain, true) 38 + } 39 + 40 + export { 41 + type Main as Record, 42 + isMain as isRecord, 43 + validateMain as validateRecord, 44 + }
+5
src/generated/types/forum/barazo/topic/post.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util.js' 12 + import type * as AppBskyRichtextFacet from '../../../app/bsky/richtext/facet.js' 12 13 import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 13 14 14 15 const is$typed = _is$typed, ··· 29 30 category: string 30 31 /** Topic tags. Lowercase alphanumeric + hyphens. */ 31 32 tags?: string[] 33 + /** Annotations of text (mentions, URLs, hashtags, etc). */ 34 + facets?: AppBskyRichtextFacet.Main[] 35 + /** BCP 47 language tags indicating the primary language(s) of the content. */ 36 + langs?: string[] 32 37 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 33 38 /** Client-declared timestamp when this post was originally created. */ 34 39 createdAt: string
+5
src/generated/types/forum/barazo/topic/reply.ts
··· 10 10 type OmitKey, 11 11 } from '../../../../util.js' 12 12 import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 + import type * as AppBskyRichtextFacet from '../../../app/bsky/richtext/facet.js' 13 14 import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 14 15 15 16 const is$typed = _is$typed, ··· 26 27 parent: ComAtprotoRepoStrongRef.Main 27 28 /** DID of the community where this reply was created. Immutable origin identifier. */ 28 29 community: string 30 + /** Annotations of text (mentions, URLs, hashtags, etc). */ 31 + facets?: AppBskyRichtextFacet.Main[] 32 + /** BCP 47 language tags indicating the primary language(s) of the content. */ 33 + langs?: string[] 29 34 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 30 35 /** Client-declared timestamp when this reply was originally created. */ 31 36 createdAt: string
+5
src/index.ts
··· 12 12 ForumBarazoTopicPost, 13 13 ForumBarazoTopicReply, 14 14 ForumBarazoInteractionReaction, 15 + ForumBarazoInteractionVote, 15 16 ForumBarazoActorPreferences, 16 17 ComAtprotoRepoStrongRef, 17 18 ComAtprotoLabelDefs, ··· 26 27 topicPostSchema, 27 28 topicReplySchema, 28 29 reactionSchema, 30 + voteSchema, 29 31 actorPreferencesSchema, 30 32 crossPostConfigSchema, 31 33 strongRefSchema, 32 34 selfLabelsSchema, 33 35 selfLabelSchema, 36 + facetSchema, 34 37 type TopicPostInput, 35 38 type TopicReplyInput, 36 39 type ReactionInput, 40 + type VoteInput, 37 41 type ActorPreferencesInput, 38 42 } from './validation/index.js' 39 43 ··· 42 46 TopicPost: 'forum.barazo.topic.post', 43 47 TopicReply: 'forum.barazo.topic.reply', 44 48 Reaction: 'forum.barazo.interaction.reaction', 49 + Vote: 'forum.barazo.interaction.vote', 45 50 ActorPreferences: 'forum.barazo.actor.preferences', 46 51 AuthForumAccess: 'forum.barazo.authForumAccess', 47 52 } as const
+22
src/validation/facet.ts
··· 1 + import { z } from 'zod' 2 + 3 + /** Byte slice for facet positioning (UTF-8 byte offsets). */ 4 + const byteSliceSchema = z.object({ 5 + byteStart: z.number().int().nonnegative(), 6 + byteEnd: z.number().int().nonnegative(), 7 + }) 8 + 9 + /** Facet feature: mention, link, or tag. */ 10 + const facetFeatureSchema = z.looseObject({}) 11 + 12 + /** 13 + * Zod schema for app.bsky.richtext.facet objects. 14 + * 15 + * Validates structural shape (index + features array). Feature contents 16 + * are validated permissively since new feature types may be added to the 17 + * AT Protocol standard. 18 + */ 19 + export const facetSchema = z.object({ 20 + index: byteSliceSchema, 21 + features: z.array(facetFeatureSchema).min(1), 22 + })
+2
src/validation/index.ts
··· 1 1 export { topicPostSchema, type TopicPostInput } from './topic-post.js' 2 2 export { topicReplySchema, type TopicReplyInput } from './topic-reply.js' 3 3 export { reactionSchema, type ReactionInput } from './reaction.js' 4 + export { voteSchema, type VoteInput } from './vote.js' 4 5 export { 5 6 actorPreferencesSchema, 6 7 crossPostConfigSchema, ··· 8 9 } from './actor-preferences.js' 9 10 export { strongRefSchema } from './strong-ref.js' 10 11 export { selfLabelsSchema, selfLabelSchema } from './self-labels.js' 12 + export { facetSchema } from './facet.js'
+3
src/validation/topic-post.ts
··· 1 1 import { z } from 'zod' 2 2 import { selfLabelsSchema } from './self-labels.js' 3 + import { facetSchema } from './facet.js' 3 4 4 5 /** DID format regex (simplified, catches obvious malformed DIDs). */ 5 6 const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/ ··· 20 21 community: z.string().regex(didRegex), 21 22 category: z.string().min(1).max(640), 22 23 tags: z.array(z.string().min(1).max(300)).max(5).optional(), 24 + facets: z.array(facetSchema).optional(), 25 + langs: z.array(z.string().min(1)).max(3).optional(), 23 26 labels: selfLabelsSchema.optional(), 24 27 createdAt: z.iso.datetime(), 25 28 })
+3
src/validation/topic-reply.ts
··· 1 1 import { z } from 'zod' 2 2 import { strongRefSchema } from './strong-ref.js' 3 3 import { selfLabelsSchema } from './self-labels.js' 4 + import { facetSchema } from './facet.js' 4 5 5 6 const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/ 6 7 ··· 15 16 root: strongRefSchema, 16 17 parent: strongRefSchema, 17 18 community: z.string().regex(didRegex), 19 + facets: z.array(facetSchema).optional(), 20 + langs: z.array(z.string().min(1)).max(3).optional(), 18 21 labels: selfLabelsSchema.optional(), 19 22 createdAt: z.iso.datetime(), 20 23 })
+20
src/validation/vote.ts
··· 1 + import { z } from 'zod' 2 + import { strongRefSchema } from './strong-ref.js' 3 + 4 + const didRegex = /^did:[a-z]+:[a-zA-Z0-9._:%-]+$/ 5 + 6 + /** 7 + * Zod schema for forum.barazo.interaction.vote records. 8 + * 9 + * Directional votes are quantitative (ranking), separate from reactions 10 + * (expressive, emoji-style). Uses knownValues for direction so 'down' 11 + * can be added later without a breaking change. 12 + */ 13 + export const voteSchema = z.object({ 14 + subject: strongRefSchema, 15 + direction: z.string().min(1), 16 + community: z.string().regex(didRegex), 17 + createdAt: z.iso.datetime(), 18 + }) 19 + 20 + export type VoteInput = z.input<typeof voteSchema>
+64
tests/backward-compatibility.test.ts
··· 24 24 topicPostSchema, 25 25 topicReplySchema, 26 26 reactionSchema, 27 + voteSchema, 27 28 actorPreferencesSchema, 28 29 } from '../src/validation/index.js' 29 30 import { 30 31 ForumBarazoTopicPost, 31 32 ForumBarazoTopicReply, 32 33 ForumBarazoInteractionReaction, 34 + ForumBarazoInteractionVote, 33 35 ForumBarazoActorPreferences, 34 36 } from '../src/generated/index.js' 35 37 import { ··· 39 41 topicReplyFull, 40 42 reactionMinimal, 41 43 reactionFull, 44 + voteMinimal, 45 + voteFull, 42 46 actorPreferencesMinimal, 43 47 actorPreferencesFull, 44 48 } from './fixtures/baseline-records.js' ··· 86 90 'community', 87 91 'category', 88 92 'tags', 93 + 'facets', 94 + 'langs', 89 95 'labels', 90 96 'createdAt', 91 97 ], ··· 98 104 'root', 99 105 'parent', 100 106 'community', 107 + 'facets', 108 + 'langs', 101 109 'labels', 102 110 'createdAt', 103 111 ], ··· 106 114 requiredFields: ['subject', 'type', 'community', 'createdAt'], 107 115 allProperties: ['subject', 'type', 'community', 'createdAt'], 108 116 }, 117 + 'forum.barazo.interaction.vote': { 118 + requiredFields: ['subject', 'direction', 'community', 'createdAt'], 119 + allProperties: ['subject', 'direction', 'community', 'createdAt'], 120 + }, 109 121 'forum.barazo.actor.preferences': { 110 122 requiredFields: ['maturityLevel', 'updatedAt'], 111 123 allProperties: [ ··· 158 170 }) 159 171 }) 160 172 173 + describe('forum.barazo.interaction.vote', () => { 174 + it('validates minimal baseline record', () => { 175 + const result = voteSchema.safeParse(voteMinimal) 176 + expect(result.success).toBe(true) 177 + }) 178 + 179 + it('validates full baseline record', () => { 180 + const result = voteSchema.safeParse(voteFull) 181 + expect(result.success).toBe(true) 182 + }) 183 + }) 184 + 161 185 describe('forum.barazo.actor.preferences', () => { 162 186 it('validates minimal baseline record', () => { 163 187 const result = actorPreferencesSchema.safeParse(actorPreferencesMinimal) ··· 222 246 }) 223 247 }) 224 248 249 + describe('forum.barazo.interaction.vote', () => { 250 + it('validates minimal baseline record', () => { 251 + const result = ForumBarazoInteractionVote.validateRecord( 252 + withType(voteMinimal, 'forum.barazo.interaction.vote') 253 + ) 254 + expect(result.success).toBe(true) 255 + }) 256 + 257 + it('validates full baseline record', () => { 258 + const result = ForumBarazoInteractionVote.validateRecord( 259 + withType(voteFull, 'forum.barazo.interaction.vote') 260 + ) 261 + expect(result.success).toBe(true) 262 + }) 263 + }) 264 + 225 265 describe('forum.barazo.actor.preferences', () => { 226 266 it('validates minimal baseline record', () => { 227 267 const result = ForumBarazoActorPreferences.validateRecord( ··· 263 303 name: 'forum.barazo.interaction.reaction', 264 304 path: 'forum/barazo/interaction/reaction.json', 265 305 snapshot: SCHEMA_SNAPSHOTS['forum.barazo.interaction.reaction'], 306 + }, 307 + { 308 + name: 'forum.barazo.interaction.vote', 309 + path: 'forum/barazo/interaction/vote.json', 310 + snapshot: SCHEMA_SNAPSHOTS['forum.barazo.interaction.vote'], 266 311 }, 267 312 { 268 313 name: 'forum.barazo.actor.preferences', ··· 359 404 expect(result.success).toBe(true) 360 405 }) 361 406 407 + it('lexicon validator accepts vote with extra fields', () => { 408 + const record = withType({ ...voteMinimal, futureField: 'new' }, 'forum.barazo.interaction.vote') 409 + const result = ForumBarazoInteractionVote.validateRecord(record) 410 + expect(result.success).toBe(true) 411 + }) 412 + 362 413 it('lexicon validator accepts actor.preferences with extra fields', () => { 363 414 const record = withType( 364 415 { ...actorPreferencesMinimal, futureField: { nested: true } }, ··· 383 434 community: 'string', 384 435 category: 'string', 385 436 tags: 'array', 437 + facets: 'array', 438 + langs: 'array', 386 439 labels: 'union', 387 440 createdAt: 'string', 388 441 }, ··· 395 448 root: 'ref', 396 449 parent: 'ref', 397 450 community: 'string', 451 + facets: 'array', 452 + langs: 'array', 398 453 labels: 'union', 399 454 createdAt: 'string', 400 455 }, ··· 404 459 types: { 405 460 subject: 'ref', 406 461 type: 'string', 462 + community: 'string', 463 + createdAt: 'string', 464 + }, 465 + }, 466 + 'forum.barazo.interaction.vote': { 467 + path: 'forum/barazo/interaction/vote.json', 468 + types: { 469 + subject: 'ref', 470 + direction: 'string', 407 471 community: 'string', 408 472 createdAt: 'string', 409 473 },
+12
tests/fixtures/baseline-records.ts
··· 82 82 // Reaction has no optional fields beyond the required ones 83 83 export const reactionFull = { ...reactionMinimal } 84 84 85 + // ── interaction.vote ─────────────────────────────────────────── 86 + 87 + export const voteMinimal = { 88 + subject: VALID_STRONG_REF, 89 + direction: 'up', 90 + community: VALID_DID, 91 + createdAt: VALID_DATETIME, 92 + } 93 + 94 + // Vote has no optional fields beyond the required ones 95 + export const voteFull = { ...voteMinimal } 96 + 85 97 // ── actor.preferences ─────────────────────────────────────────────── 86 98 87 99 export const actorPreferencesMinimal = {
+111
tests/lexicon-schemas.test.ts
··· 116 116 const tags = props['tags'] as Record<string, unknown> 117 117 expect(tags['maxLength']).toBe(5) 118 118 }) 119 + 120 + it('has optional facets array referencing app.bsky.richtext.facet', () => { 121 + const defs = schema['defs'] as Record<string, unknown> 122 + const main = defs['main'] as Record<string, unknown> 123 + const record = main['record'] as Record<string, unknown> 124 + const required = record['required'] as string[] 125 + expect(required).not.toContain('facets') 126 + const props = record['properties'] as Record<string, unknown> 127 + const facets = props['facets'] as Record<string, unknown> 128 + expect(facets['type']).toBe('array') 129 + const items = facets['items'] as Record<string, unknown> 130 + expect(items['ref']).toBe('app.bsky.richtext.facet') 131 + }) 132 + 133 + it('has optional langs array with max 3 language items', () => { 134 + const defs = schema['defs'] as Record<string, unknown> 135 + const main = defs['main'] as Record<string, unknown> 136 + const record = main['record'] as Record<string, unknown> 137 + const required = record['required'] as string[] 138 + expect(required).not.toContain('langs') 139 + const props = record['properties'] as Record<string, unknown> 140 + const langs = props['langs'] as Record<string, unknown> 141 + expect(langs['type']).toBe('array') 142 + expect(langs['maxLength']).toBe(3) 143 + const items = langs['items'] as Record<string, unknown> 144 + expect(items['format']).toBe('language') 145 + }) 119 146 }) 120 147 121 148 describe('forum.barazo.topic.reply lexicon', () => { ··· 148 175 expect(parent['type']).toBe('ref') 149 176 expect(parent['ref']).toBe('com.atproto.repo.strongRef') 150 177 }) 178 + 179 + it('has optional facets array referencing app.bsky.richtext.facet', () => { 180 + const defs = schema['defs'] as Record<string, unknown> 181 + const main = defs['main'] as Record<string, unknown> 182 + const record = main['record'] as Record<string, unknown> 183 + const required = record['required'] as string[] 184 + expect(required).not.toContain('facets') 185 + const props = record['properties'] as Record<string, unknown> 186 + const facets = props['facets'] as Record<string, unknown> 187 + expect(facets['type']).toBe('array') 188 + const items = facets['items'] as Record<string, unknown> 189 + expect(items['ref']).toBe('app.bsky.richtext.facet') 190 + }) 191 + 192 + it('has optional langs array with max 3 language items', () => { 193 + const defs = schema['defs'] as Record<string, unknown> 194 + const main = defs['main'] as Record<string, unknown> 195 + const record = main['record'] as Record<string, unknown> 196 + const required = record['required'] as string[] 197 + expect(required).not.toContain('langs') 198 + const props = record['properties'] as Record<string, unknown> 199 + const langs = props['langs'] as Record<string, unknown> 200 + expect(langs['type']).toBe('array') 201 + expect(langs['maxLength']).toBe(3) 202 + }) 151 203 }) 152 204 153 205 describe('forum.barazo.interaction.reaction lexicon', () => { ··· 180 232 }) 181 233 }) 182 234 235 + describe('forum.barazo.interaction.vote lexicon', () => { 236 + let schema: Record<string, unknown> 237 + 238 + it('loads successfully', async () => { 239 + schema = (await loadJson(join(LEXICONS_DIR, 'interaction/vote.json'))) as Record< 240 + string, 241 + unknown 242 + > 243 + expect(schema['id']).toBe('forum.barazo.interaction.vote') 244 + }) 245 + 246 + it('defines a record with key type tid', () => { 247 + const defs = schema['defs'] as Record<string, unknown> 248 + const main = defs['main'] as Record<string, unknown> 249 + expect(main['type']).toBe('record') 250 + expect(main['key']).toBe('tid') 251 + }) 252 + 253 + it('has required fields: subject, direction, community, createdAt', () => { 254 + const defs = schema['defs'] as Record<string, unknown> 255 + const main = defs['main'] as Record<string, unknown> 256 + const record = main['record'] as Record<string, unknown> 257 + const required = record['required'] as string[] 258 + expect(required).toEqual( 259 + expect.arrayContaining(['subject', 'direction', 'community', 'createdAt']) 260 + ) 261 + }) 262 + 263 + it('subject uses strongRef', () => { 264 + const defs = schema['defs'] as Record<string, unknown> 265 + const main = defs['main'] as Record<string, unknown> 266 + const record = main['record'] as Record<string, unknown> 267 + const props = record['properties'] as Record<string, unknown> 268 + const subject = props['subject'] as Record<string, unknown> 269 + expect(subject['type']).toBe('ref') 270 + expect(subject['ref']).toBe('com.atproto.repo.strongRef') 271 + }) 272 + 273 + it('direction uses knownValues with up', () => { 274 + const defs = schema['defs'] as Record<string, unknown> 275 + const main = defs['main'] as Record<string, unknown> 276 + const record = main['record'] as Record<string, unknown> 277 + const props = record['properties'] as Record<string, unknown> 278 + const direction = props['direction'] as Record<string, unknown> 279 + expect(direction['knownValues']).toEqual(['up']) 280 + }) 281 + 282 + it('community field uses DID format', () => { 283 + const defs = schema['defs'] as Record<string, unknown> 284 + const main = defs['main'] as Record<string, unknown> 285 + const record = main['record'] as Record<string, unknown> 286 + const props = record['properties'] as Record<string, unknown> 287 + const community = props['community'] as Record<string, unknown> 288 + expect(community['type']).toBe('string') 289 + expect(community['format']).toBe('did') 290 + }) 291 + }) 292 + 183 293 describe('forum.barazo.authForumAccess lexicon', () => { 184 294 let schema: Record<string, unknown> 185 295 ··· 220 330 expect(collections).toContain('forum.barazo.topic.post') 221 331 expect(collections).toContain('forum.barazo.topic.reply') 222 332 expect(collections).toContain('forum.barazo.interaction.reaction') 333 + expect(collections).toContain('forum.barazo.interaction.vote') 223 334 expect(collections).toContain('forum.barazo.actor.preferences') 224 335 }) 225 336 })
+15
tests/type-generation.test.ts
··· 3 3 ForumBarazoTopicPost, 4 4 ForumBarazoTopicReply, 5 5 ForumBarazoInteractionReaction, 6 + ForumBarazoInteractionVote, 6 7 ForumBarazoActorPreferences, 7 8 LEXICON_IDS, 8 9 schemas, ··· 25 26 expect(ForumBarazoInteractionReaction.validateRecord).toBeTypeOf('function') 26 27 }) 27 28 29 + it('exports ForumBarazoInteractionVote with Record type and validators', () => { 30 + expect(ForumBarazoInteractionVote.isRecord).toBeTypeOf('function') 31 + expect(ForumBarazoInteractionVote.validateRecord).toBeTypeOf('function') 32 + }) 33 + 28 34 it('exports ForumBarazoActorPreferences with Record type and validators', () => { 29 35 expect(ForumBarazoActorPreferences.isRecord).toBeTypeOf('function') 30 36 expect(ForumBarazoActorPreferences.validateRecord).toBeTypeOf('function') ··· 44 50 expect(LEXICON_IDS.Reaction).toBe('forum.barazo.interaction.reaction') 45 51 }) 46 52 53 + it('has correct Vote ID', () => { 54 + expect(LEXICON_IDS.Vote).toBe('forum.barazo.interaction.vote') 55 + }) 56 + 47 57 it('has correct ActorPreferences ID', () => { 48 58 expect(LEXICON_IDS.ActorPreferences).toBe('forum.barazo.actor.preferences') 49 59 }) ··· 64 74 expect(schemaIds).toContain('forum.barazo.topic.post') 65 75 expect(schemaIds).toContain('forum.barazo.topic.reply') 66 76 expect(schemaIds).toContain('forum.barazo.interaction.reaction') 77 + expect(schemaIds).toContain('forum.barazo.interaction.vote') 67 78 expect(schemaIds).toContain('forum.barazo.actor.preferences') 68 79 expect(schemaIds).toContain('forum.barazo.authForumAccess') 69 80 }) ··· 80 91 81 92 it('maps ForumBarazoInteractionReaction correctly', () => { 82 93 expect(ids.ForumBarazoInteractionReaction).toBe('forum.barazo.interaction.reaction') 94 + }) 95 + 96 + it('maps ForumBarazoInteractionVote correctly', () => { 97 + expect(ids.ForumBarazoInteractionVote).toBe('forum.barazo.interaction.vote') 83 98 }) 84 99 85 100 it('maps ForumBarazoActorPreferences correctly', () => {
+78
tests/zod-validation.test.ts
··· 3 3 topicPostSchema, 4 4 topicReplySchema, 5 5 reactionSchema, 6 + voteSchema, 6 7 actorPreferencesSchema, 7 8 } from '../src/validation/index.js' 8 9 ··· 31 32 ...validPost, 32 33 contentFormat: 'markdown' as const, 33 34 tags: ['test', 'poc'], 35 + facets: [ 36 + { 37 + index: { byteStart: 0, byteEnd: 5 }, 38 + features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:abc123' }], 39 + }, 40 + ], 41 + langs: ['en', 'nl'], 34 42 labels: { values: [{ val: 'sexual' }] }, 35 43 } 36 44 expect(topicPostSchema.safeParse(full).success).toBe(true) ··· 85 93 86 94 it('rejects invalid contentFormat', () => { 87 95 expect(topicPostSchema.safeParse({ ...validPost, contentFormat: 'html' }).success).toBe(false) 96 + }) 97 + 98 + it('accepts post with facets', () => { 99 + const withFacets = { 100 + ...validPost, 101 + facets: [ 102 + { 103 + index: { byteStart: 0, byteEnd: 10 }, 104 + features: [{ $type: 'app.bsky.richtext.facet#link', uri: 'https://example.com' }], 105 + }, 106 + ], 107 + } 108 + expect(topicPostSchema.safeParse(withFacets).success).toBe(true) 109 + }) 110 + 111 + it('rejects facets with invalid byteSlice', () => { 112 + const badFacets = { 113 + ...validPost, 114 + facets: [{ index: { byteStart: -1, byteEnd: 5 }, features: [{ $type: 'test' }] }], 115 + } 116 + expect(topicPostSchema.safeParse(badFacets).success).toBe(false) 117 + }) 118 + 119 + it('accepts post with langs', () => { 120 + const withLangs = { ...validPost, langs: ['en', 'nl'] } 121 + expect(topicPostSchema.safeParse(withLangs).success).toBe(true) 122 + }) 123 + 124 + it('rejects more than 3 langs', () => { 125 + const tooManyLangs = { ...validPost, langs: ['en', 'nl', 'de', 'fr'] } 126 + expect(topicPostSchema.safeParse(tooManyLangs).success).toBe(false) 88 127 }) 89 128 90 129 it('rejects labels with more than 10 values', () => { ··· 169 208 subject: { uri: 'not-an-at-uri', cid: 'test' }, 170 209 }).success 171 210 ).toBe(false) 211 + }) 212 + }) 213 + 214 + describe('voteSchema', () => { 215 + const validVote = { 216 + subject: VALID_STRONG_REF, 217 + direction: 'up', 218 + community: VALID_DID, 219 + createdAt: VALID_DATETIME, 220 + } 221 + 222 + it('accepts a valid vote', () => { 223 + expect(voteSchema.safeParse(validVote).success).toBe(true) 224 + }) 225 + 226 + it('accepts custom direction values (knownValues is open)', () => { 227 + expect(voteSchema.safeParse({ ...validVote, direction: 'down' }).success).toBe(true) 228 + }) 229 + 230 + it('rejects empty direction string', () => { 231 + expect(voteSchema.safeParse({ ...validVote, direction: '' }).success).toBe(false) 232 + }) 233 + 234 + it('rejects invalid subject (not a strongRef)', () => { 235 + expect( 236 + voteSchema.safeParse({ 237 + ...validVote, 238 + subject: { uri: 'not-an-at-uri', cid: 'test' }, 239 + }).success 240 + ).toBe(false) 241 + }) 242 + 243 + it('rejects invalid community DID', () => { 244 + expect(voteSchema.safeParse({ ...validVote, community: 'not-a-did' }).success).toBe(false) 245 + }) 246 + 247 + it('rejects missing required fields', () => { 248 + expect(voteSchema.safeParse({}).success).toBe(false) 249 + expect(voteSchema.safeParse({ subject: VALID_STRONG_REF }).success).toBe(false) 172 250 }) 173 251 }) 174 252