Atproto AMA app
0
fork

Configure Feed

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

add basic lexicon

+2414 -25
+1
.cursor/.gitignore
··· 1 + plans/
+31 -7
.env.example
··· 1 - DATABASE_URL=postgresql://askimut:askimut@localhost:5433/askimut 2 - APP_URL=http://127.0.0.1:3000 3 - OAUTH_PRIVATE_KEY= 4 - HOST=127.0.0.1 5 - PORT=3000 6 - NITRO_HOST=127.0.0.1 7 - NITRO_PORT=3000 1 + # Database 2 + DATABASE_URL="postgresql://user:password@localhost:5432/askimut" 3 + 4 + # OAuth 5 + PUBLIC_URL="http://localhost:3000" 6 + 7 + # AT Protocol Configuration 8 + # Enable/disable AT Protocol publishing (default: false for development) 9 + AT_PROTOCOL_ENABLED=false 10 + 11 + # Control what gets published to AT Protocol (default: true when AT_PROTOCOL_ENABLED=true) 12 + AT_PROTOCOL_PUBLISH_QUESTIONS=true 13 + AT_PROTOCOL_PUBLISH_ANSWERS=true 14 + AT_PROTOCOL_PUBLISH_PROFILES=true 15 + 16 + # Strict validation for AT Protocol records (default: true) 17 + AT_PROTOCOL_STRICT_VALIDATION=true 18 + 19 + # Service endpoints (defaults shown) 20 + AT_PROTOCOL_PDS=https://bsky.social 21 + AT_PROTOCOL_APPVIEW=https://api.bsky.app 22 + 23 + # Lexicon Validation Configuration 24 + # Enable/disable lexicon validation (default: true) 25 + LEX_VALIDATION_ENABLED=true 26 + 27 + # Strict mode for lexicon validation (default: false) 28 + LEX_VALIDATION_STRICT=false 29 + 30 + # Fail on validation errors (default: true) 31 + LEX_VALIDATION_FAIL_ON_ERROR=true
+8
lexicons.json
··· 1 + { 2 + "version": 1, 3 + "lexicons": [ 4 + "com.askimut.question", 5 + "com.askimut.answer", 6 + "com.askimut.profile" 7 + ] 8 + }
+55
lexicons/com.askimut.answer.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.askimut.answer", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["$type", "content", "questionUri", "authorDid", "sourceType", "createdAt"], 11 + "properties": { 12 + "$type": { 13 + "type": "string", 14 + "const": "com.askimut.answer" 15 + }, 16 + "content": { 17 + "type": "string", 18 + "minLength": 1, 19 + "maxLength": 5000, 20 + "description": "The answer content" 21 + }, 22 + "questionUri": { 23 + "type": "string", 24 + "format": "at-uri", 25 + "description": "AT-URI reference to the question being answered" 26 + }, 27 + "authorDid": { 28 + "type": "string", 29 + "format": "did", 30 + "description": "DID of the user providing the answer" 31 + }, 32 + "sourceType": { 33 + "type": "string", 34 + "enum": ["askimut"], 35 + "description": "The platform/source where this answer originated" 36 + }, 37 + "createdAt": { 38 + "type": "string", 39 + "format": "datetime", 40 + "description": "When the answer was created" 41 + }, 42 + "sourceUri": { 43 + "type": "string", 44 + "format": "uri", 45 + "description": "Original URI from the source platform (e.g., Bluesky post URI)" 46 + }, 47 + "sourceData": { 48 + "type": "unknown", 49 + "description": "Platform-specific metadata and additional data" 50 + } 51 + } 52 + } 53 + } 54 + } 55 + }
+40
lexicons/com.askimut.profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.askimut.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "required": ["$type", "questionsOpen"], 11 + "properties": { 12 + "$type": { 13 + "type": "string", 14 + "const": "com.askimut.profile" 15 + }, 16 + "displayName": { 17 + "type": "string", 18 + "maxLength": 64, 19 + "description": "Display name for the user" 20 + }, 21 + "description": { 22 + "type": "string", 23 + "maxLength": 300, 24 + "description": "Profile description or bio" 25 + }, 26 + "questionsOpen": { 27 + "type": "boolean", 28 + "description": "Whether the user is accepting questions" 29 + }, 30 + "avatar": { 31 + "type": "blob", 32 + "accept": ["image/png", "image/jpeg", "image/gif", "image/webp"], 33 + "maxSize": 1000000, 34 + "description": "Profile avatar image" 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+69
lexicons/com.askimut.question.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.askimut.question", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["$type", "content", "targetDid", "authorDid", "sourceType", "createdAt"], 11 + "properties": { 12 + "$type": { 13 + "type": "string", 14 + "const": "com.askimut.question" 15 + }, 16 + "content": { 17 + "type": "string", 18 + "minLength": 1, 19 + "maxLength": 1000, 20 + "description": "The question content" 21 + }, 22 + "targetDid": { 23 + "type": "string", 24 + "format": "did", 25 + "description": "DID of the user being asked" 26 + }, 27 + "authorDid": { 28 + "type": "string", 29 + "format": "did", 30 + "description": "DID of the user asking the question" 31 + }, 32 + "sourceType": { 33 + "type": "string", 34 + "enum": ["askimut", "bsky", "standard.site", "mastodon", "nostr"], 35 + "description": "The platform/source where this question originated" 36 + }, 37 + "anonymous": { 38 + "type": "boolean", 39 + "default": false, 40 + "description": "Whether the question is asked anonymously" 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime", 45 + "description": "When the question was created" 46 + }, 47 + "tags": { 48 + "type": "array", 49 + "items": { 50 + "type": "string", 51 + "maxLength": 50 52 + }, 53 + "maxLength": 10, 54 + "description": "Optional tags for categorizing the question" 55 + }, 56 + "sourceUri": { 57 + "type": "string", 58 + "format": "uri", 59 + "description": "Original URI from the source platform (e.g., Bluesky post URI)" 60 + }, 61 + "sourceData": { 62 + "type": "unknown", 63 + "description": "Platform-specific metadata and additional data" 64 + } 65 + } 66 + } 67 + } 68 + } 69 + }
+3 -1
package.json
··· 8 8 "db:generate": "drizzle-kit generate", 9 9 "db:migrate": "drizzle-kit migrate", 10 10 "db:push": "drizzle-kit push", 11 - "db:studio": "drizzle-kit studio" 11 + "db:studio": "drizzle-kit studio", 12 + "lex:build": "lex build --lexicons ./lexicons --out ./src/lexicons --override" 12 13 }, 13 14 "dependencies": { 14 15 "@atproto/api": "^0.13.0", 15 16 "@atproto/identity": "^0.4.0", 17 + "@atproto/lex": "^0.0.23", 16 18 "@atproto/oauth-client-node": "^0.3.17", 17 19 "@solidjs/meta": "^0.29.4", 18 20 "@solidjs/router": "^0.15.3",
+294
pnpm-lock.yaml
··· 14 14 '@atproto/identity': 15 15 specifier: ^0.4.0 16 16 version: 0.4.12 17 + '@atproto/lex': 18 + specifier: ^0.0.23 19 + version: 0.0.23 17 20 '@atproto/oauth-client-node': 18 21 specifier: ^0.3.17 19 22 version: 0.3.17 ··· 89 92 '@atproto/common-web@0.4.19': 90 93 resolution: {integrity: sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==} 91 94 95 + '@atproto/common@0.5.15': 96 + resolution: {integrity: sha512-+cdfdMPAIbH9zQGLfH1gNY2KEZsMxj0EelVQL5uJUFL+UkkAXiiqWj7J5mbax8sf02cC/afJnfkWzERNAheKoA==} 97 + engines: {node: '>=18.7.0'} 98 + 92 99 '@atproto/crypto@0.4.5': 93 100 resolution: {integrity: sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==} 94 101 engines: {node: '>=18.7.0'} ··· 109 116 '@atproto/jwk@0.6.0': 110 117 resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 111 118 119 + '@atproto/lex-builder@0.0.20': 120 + resolution: {integrity: sha512-cbUvYw4KNHsdMTXQlJxzYA7HfJoY4a12a1m1J8GP0togIUDHSsNC/UGsTx2ieHfVkWhErElNyMWB2cAFZeyVWQ==} 121 + 122 + '@atproto/lex-cbor@0.0.15': 123 + resolution: {integrity: sha512-3osDicK9bAMXJlKjLKqwYrhLQ60bOguWBNjE+fuNjMuizNzC0aqaClE3d+qMsFuFq9bjEHFw+4Vr9Qmd/m6VYg==} 124 + 125 + '@atproto/lex-client@0.0.18': 126 + resolution: {integrity: sha512-dCnlG9nxNY6ZkaNggSlOPhb9NqDt/H1nm16ZZhe81G2iV10Niq44OcxZdVtZVjszElW3+V9ZfsK04B0f3Rid0Q==} 127 + 112 128 '@atproto/lex-data@0.0.14': 113 129 resolution: {integrity: sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==} 114 130 131 + '@atproto/lex-document@0.0.18': 132 + resolution: {integrity: sha512-nfBgMbFyQKZj8LSVe0leHhfsvn0kmIQMC1wPqzJb/M1jSv5266J8Jl7wWy8QsXhz/+EoxPXygFp4hTM+l09beQ==} 133 + 134 + '@atproto/lex-installer@0.0.23': 135 + resolution: {integrity: sha512-bCob01wMRVyaLPnwBoEi6XXBiG7d7wioX217Lwr+wdg9/yDw61slh8AKI0o1pvQqmMxA980gGMpCrSiSj/jfog==} 136 + 115 137 '@atproto/lex-json@0.0.14': 116 138 resolution: {integrity: sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==} 117 139 140 + '@atproto/lex-resolver@0.0.20': 141 + resolution: {integrity: sha512-ZmEvLW+1Yv5/eLIP0McOlgsUh1/FFCCy1BK6WaI1vM4IwsHZiqjmymOPfnn39WyOhyOBqvSZ7/8hzR03Yu5V/w==} 142 + 143 + '@atproto/lex-schema@0.0.17': 144 + resolution: {integrity: sha512-WLeGIRgLQEhpTd5jpaplvKEtITS7PCygWHmCD645q0MFzQ+C2AM8KWwUqvhVHOgHNNRPZrYfMgnsq6gYmu2tEQ==} 145 + 146 + '@atproto/lex@0.0.23': 147 + resolution: {integrity: sha512-AjfuL2wzBASsMlhdfPBY/S1Vg9wOEJ8DnQM5TlmgVr7UmhQMiW+YVE9YC3RdKzaTbYBDTFr1HsUuarsPg+nvUg==} 148 + hasBin: true 149 + 118 150 '@atproto/lexicon@0.4.14': 119 151 resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} 120 152 ··· 130 162 131 163 '@atproto/oauth-types@0.6.3': 132 164 resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} 165 + 166 + '@atproto/repo@0.9.0': 167 + resolution: {integrity: sha512-hl85f7CJniLoJS5bwOSnLjN7X4kS1ETs5yetKJyJRVZE4SgM+nWPRd+D08dAzeU+VAZi4fjeZXOlOLJtS4Y0KQ==} 168 + engines: {node: '>=18.7.0'} 133 169 134 170 '@atproto/syntax@0.3.4': 135 171 resolution: {integrity: sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==} ··· 1249 1285 '@speed-highlight/core@1.2.15': 1250 1286 resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==} 1251 1287 1288 + '@standard-schema/spec@1.1.0': 1289 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 1290 + 1252 1291 '@tanstack/directive-functions-plugin@1.121.21': 1253 1292 resolution: {integrity: sha512-B9z/HbF7gJBaRHieyX7f2uQ4LpLLAVAEutBZipH6w+CYD6RHRJvSVPzECGHF7icFhNWTiJQL2QR6K07s59yzEw==} 1254 1293 engines: {node: '>=12'} ··· 1262 1301 '@tanstack/server-functions-plugin@1.121.21': 1263 1302 resolution: {integrity: sha512-a05fzK+jBGacsSAc1vE8an7lpBh4H0PyIEcivtEyHLomgSeElAJxm9E2It/0nYRZ5Lh23m0okbhzJNaYWZpAOg==} 1264 1303 engines: {node: '>=12'} 1304 + 1305 + '@ts-morph/common@0.28.1': 1306 + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} 1265 1307 1266 1308 '@types/babel__core@7.20.5': 1267 1309 resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} ··· 1406 1448 async@3.2.6: 1407 1449 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} 1408 1450 1451 + atomic-sleep@1.0.0: 1452 + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} 1453 + engines: {node: '>=8.0.0'} 1454 + 1409 1455 await-lock@2.2.2: 1410 1456 resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 1411 1457 ··· 1581 1627 resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} 1582 1628 engines: {node: '>=18'} 1583 1629 1630 + cliui@8.0.1: 1631 + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} 1632 + engines: {node: '>=12'} 1633 + 1584 1634 cliui@9.0.1: 1585 1635 resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} 1586 1636 engines: {node: '>=20'} ··· 1588 1638 cluster-key-slot@1.1.2: 1589 1639 resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} 1590 1640 engines: {node: '>=0.10.0'} 1641 + 1642 + code-block-writer@13.0.3: 1643 + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} 1591 1644 1592 1645 color-convert@2.0.1: 1593 1646 resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} ··· 1978 2031 resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} 1979 2032 engines: {node: '>=8.6.0'} 1980 2033 2034 + fast-redact@3.5.0: 2035 + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} 2036 + engines: {node: '>=6'} 2037 + 1981 2038 fastq@1.20.1: 1982 2039 resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} 1983 2040 ··· 2474 2531 ohash@2.0.11: 2475 2532 resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} 2476 2533 2534 + on-exit-leak-free@2.1.2: 2535 + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} 2536 + engines: {node: '>=14.0.0'} 2537 + 2477 2538 on-finished@2.4.1: 2478 2539 resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} 2479 2540 engines: {node: '>= 0.8'} ··· 2498 2559 parseurl@1.3.3: 2499 2560 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 2500 2561 engines: {node: '>= 0.8'} 2562 + 2563 + path-browserify@1.0.1: 2564 + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} 2501 2565 2502 2566 path-key@3.1.1: 2503 2567 resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} ··· 2540 2604 picomatch@4.0.4: 2541 2605 resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} 2542 2606 engines: {node: '>=12'} 2607 + 2608 + pino-abstract-transport@1.2.0: 2609 + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} 2610 + 2611 + pino-std-serializers@6.2.2: 2612 + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} 2613 + 2614 + pino@8.21.0: 2615 + resolution: {integrity: sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==} 2616 + hasBin: true 2543 2617 2544 2618 pkg-types@1.3.1: 2545 2619 resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} ··· 2559 2633 resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} 2560 2634 engines: {node: '>=20'} 2561 2635 2636 + prettier@3.8.1: 2637 + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} 2638 + engines: {node: '>=14'} 2639 + hasBin: true 2640 + 2562 2641 pretty-bytes@7.1.0: 2563 2642 resolution: {integrity: sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw==} 2564 2643 engines: {node: '>=20'} ··· 2566 2645 process-nextick-args@2.0.1: 2567 2646 resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} 2568 2647 2648 + process-warning@3.0.0: 2649 + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} 2650 + 2569 2651 process@0.11.10: 2570 2652 resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} 2571 2653 engines: {node: '>= 0.6.0'} ··· 2579 2661 queue-microtask@1.2.3: 2580 2662 resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} 2581 2663 2664 + quick-format-unescaped@4.0.4: 2665 + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} 2666 + 2582 2667 radix3@1.1.2: 2583 2668 resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} 2584 2669 ··· 2606 2691 readdirp@5.0.0: 2607 2692 resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} 2608 2693 engines: {node: '>= 20.19.0'} 2694 + 2695 + real-require@0.2.0: 2696 + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} 2697 + engines: {node: '>= 12.13.0'} 2609 2698 2610 2699 recast@0.23.11: 2611 2700 resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} ··· 2627 2716 2628 2717 regex@5.1.1: 2629 2718 resolution: {integrity: sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw==} 2719 + 2720 + require-directory@2.1.1: 2721 + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} 2722 + engines: {node: '>=0.10.0'} 2630 2723 2631 2724 requires-port@1.0.0: 2632 2725 resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} ··· 2678 2771 safe-buffer@5.2.1: 2679 2772 resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 2680 2773 2774 + safe-stable-stringify@2.5.0: 2775 + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} 2776 + engines: {node: '>=10'} 2777 + 2681 2778 scule@1.3.0: 2682 2779 resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} 2683 2780 ··· 2767 2864 peerDependencies: 2768 2865 solid-js: ^1.7 2769 2866 2867 + sonic-boom@3.8.1: 2868 + resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} 2869 + 2770 2870 source-map-js@1.2.1: 2771 2871 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 2772 2872 engines: {node: '>=0.10.0'} ··· 2785 2885 space-separated-tokens@2.0.2: 2786 2886 resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} 2787 2887 2888 + split2@4.2.0: 2889 + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} 2890 + engines: {node: '>= 10.x'} 2891 + 2788 2892 stackframe@1.3.4: 2789 2893 resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} 2790 2894 ··· 2880 2984 text-decoder@1.2.7: 2881 2985 resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} 2882 2986 2987 + thread-stream@2.7.0: 2988 + resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} 2989 + 2883 2990 tiny-invariant@1.3.3: 2884 2991 resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} 2885 2992 ··· 2908 3015 2909 3016 trim-lines@3.0.1: 2910 3017 resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} 3018 + 3019 + ts-morph@27.0.2: 3020 + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} 2911 3021 2912 3022 tslib@2.8.1: 2913 3023 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} ··· 3079 3189 util-deprecate@1.0.2: 3080 3190 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 3081 3191 3192 + varint@6.0.0: 3193 + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} 3194 + 3082 3195 vfile-message@4.0.3: 3083 3196 resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} 3084 3197 ··· 3197 3310 resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} 3198 3311 engines: {node: '>=18'} 3199 3312 3313 + yargs-parser@21.1.1: 3314 + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} 3315 + engines: {node: '>=12'} 3316 + 3200 3317 yargs-parser@22.0.0: 3201 3318 resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} 3202 3319 engines: {node: ^20.19.0 || ^22.12.0 || >=23} 3320 + 3321 + yargs@17.7.2: 3322 + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} 3323 + engines: {node: '>=12'} 3203 3324 3204 3325 yargs@18.0.0: 3205 3326 resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} ··· 3290 3411 '@atproto/lex-json': 0.0.14 3291 3412 '@atproto/syntax': 0.5.2 3292 3413 zod: 3.25.76 3414 + 3415 + '@atproto/common@0.5.15': 3416 + dependencies: 3417 + '@atproto/common-web': 0.4.19 3418 + '@atproto/lex-cbor': 0.0.15 3419 + '@atproto/lex-data': 0.0.14 3420 + multiformats: 9.9.0 3421 + pino: 8.21.0 3293 3422 3294 3423 '@atproto/crypto@0.4.5': 3295 3424 dependencies: ··· 3322 3451 multiformats: 9.9.0 3323 3452 zod: 3.25.76 3324 3453 3454 + '@atproto/lex-builder@0.0.20': 3455 + dependencies: 3456 + '@atproto/lex-document': 0.0.18 3457 + '@atproto/lex-schema': 0.0.17 3458 + prettier: 3.8.1 3459 + ts-morph: 27.0.2 3460 + tslib: 2.8.1 3461 + 3462 + '@atproto/lex-cbor@0.0.15': 3463 + dependencies: 3464 + '@atproto/lex-data': 0.0.14 3465 + tslib: 2.8.1 3466 + 3467 + '@atproto/lex-client@0.0.18': 3468 + dependencies: 3469 + '@atproto/lex-data': 0.0.14 3470 + '@atproto/lex-json': 0.0.14 3471 + '@atproto/lex-schema': 0.0.17 3472 + tslib: 2.8.1 3473 + 3325 3474 '@atproto/lex-data@0.0.14': 3326 3475 dependencies: 3327 3476 multiformats: 9.9.0 ··· 3329 3478 uint8arrays: 3.0.0 3330 3479 unicode-segmenter: 0.14.5 3331 3480 3481 + '@atproto/lex-document@0.0.18': 3482 + dependencies: 3483 + '@atproto/lex-schema': 0.0.17 3484 + core-js: 3.49.0 3485 + tslib: 2.8.1 3486 + 3487 + '@atproto/lex-installer@0.0.23': 3488 + dependencies: 3489 + '@atproto/lex-builder': 0.0.20 3490 + '@atproto/lex-cbor': 0.0.15 3491 + '@atproto/lex-data': 0.0.14 3492 + '@atproto/lex-document': 0.0.18 3493 + '@atproto/lex-resolver': 0.0.20 3494 + '@atproto/lex-schema': 0.0.17 3495 + '@atproto/syntax': 0.5.2 3496 + tslib: 2.8.1 3497 + 3332 3498 '@atproto/lex-json@0.0.14': 3333 3499 dependencies: 3334 3500 '@atproto/lex-data': 0.0.14 3335 3501 tslib: 2.8.1 3336 3502 3503 + '@atproto/lex-resolver@0.0.20': 3504 + dependencies: 3505 + '@atproto-labs/did-resolver': 0.2.6 3506 + '@atproto/crypto': 0.4.5 3507 + '@atproto/lex-client': 0.0.18 3508 + '@atproto/lex-data': 0.0.14 3509 + '@atproto/lex-document': 0.0.18 3510 + '@atproto/lex-schema': 0.0.17 3511 + '@atproto/repo': 0.9.0 3512 + '@atproto/syntax': 0.5.2 3513 + tslib: 2.8.1 3514 + 3515 + '@atproto/lex-schema@0.0.17': 3516 + dependencies: 3517 + '@atproto/lex-data': 0.0.14 3518 + '@atproto/syntax': 0.5.2 3519 + '@standard-schema/spec': 1.1.0 3520 + iso-datestring-validator: 2.2.2 3521 + tslib: 2.8.1 3522 + 3523 + '@atproto/lex@0.0.23': 3524 + dependencies: 3525 + '@atproto/lex-builder': 0.0.20 3526 + '@atproto/lex-client': 0.0.18 3527 + '@atproto/lex-data': 0.0.14 3528 + '@atproto/lex-installer': 0.0.23 3529 + '@atproto/lex-json': 0.0.14 3530 + '@atproto/lex-schema': 0.0.17 3531 + tslib: 2.8.1 3532 + yargs: 17.7.2 3533 + 3337 3534 '@atproto/lexicon@0.4.14': 3338 3535 dependencies: 3339 3536 '@atproto/common-web': 0.4.19 ··· 3382 3579 dependencies: 3383 3580 '@atproto/did': 0.3.0 3384 3581 '@atproto/jwk': 0.6.0 3582 + zod: 3.25.76 3583 + 3584 + '@atproto/repo@0.9.0': 3585 + dependencies: 3586 + '@atproto/common': 0.5.15 3587 + '@atproto/common-web': 0.4.19 3588 + '@atproto/crypto': 0.4.5 3589 + '@atproto/lex-cbor': 0.0.15 3590 + '@atproto/lex-data': 0.0.14 3591 + '@atproto/syntax': 0.5.2 3592 + varint: 6.0.0 3385 3593 zod: 3.25.76 3386 3594 3387 3595 '@atproto/syntax@0.3.4': {} ··· 4206 4414 4207 4415 '@speed-highlight/core@1.2.15': {} 4208 4416 4417 + '@standard-schema/spec@1.1.0': {} 4418 + 4209 4419 '@tanstack/directive-functions-plugin@1.121.21(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(terser@5.46.1))': 4210 4420 dependencies: 4211 4421 '@babel/code-frame': 7.26.2 ··· 4249 4459 - supports-color 4250 4460 - vite 4251 4461 4462 + '@ts-morph/common@0.28.1': 4463 + dependencies: 4464 + minimatch: 10.2.5 4465 + path-browserify: 1.0.1 4466 + tinyglobby: 0.2.15 4467 + 4252 4468 '@types/babel__core@7.20.5': 4253 4469 dependencies: 4254 4470 '@babel/parser': 7.29.2 ··· 4440 4656 4441 4657 async@3.2.6: {} 4442 4658 4659 + atomic-sleep@1.0.0: {} 4660 + 4443 4661 await-lock@2.2.2: {} 4444 4662 4445 4663 b4a@1.8.0: {} ··· 4610 4828 is-wsl: 3.1.1 4611 4829 is64bit: 2.0.0 4612 4830 4831 + cliui@8.0.1: 4832 + dependencies: 4833 + string-width: 4.2.3 4834 + strip-ansi: 6.0.1 4835 + wrap-ansi: 7.0.0 4836 + 4613 4837 cliui@9.0.1: 4614 4838 dependencies: 4615 4839 string-width: 7.2.0 ··· 4617 4841 wrap-ansi: 9.0.2 4618 4842 4619 4843 cluster-key-slot@1.1.2: {} 4844 + 4845 + code-block-writer@13.0.3: {} 4620 4846 4621 4847 color-convert@2.0.1: 4622 4848 dependencies: ··· 4944 5170 glob-parent: 5.1.2 4945 5171 merge2: 1.4.1 4946 5172 micromatch: 4.0.8 5173 + 5174 + fast-redact@3.5.0: {} 4947 5175 4948 5176 fastq@1.20.1: 4949 5177 dependencies: ··· 5523 5751 5524 5752 ohash@2.0.11: {} 5525 5753 5754 + on-exit-leak-free@2.1.2: {} 5755 + 5526 5756 on-finished@2.4.1: 5527 5757 dependencies: 5528 5758 ee-first: 1.1.1 ··· 5553 5783 entities: 6.0.1 5554 5784 5555 5785 parseurl@1.3.3: {} 5786 + 5787 + path-browserify@1.0.1: {} 5556 5788 5557 5789 path-key@3.1.1: {} 5558 5790 ··· 5584 5816 5585 5817 picomatch@4.0.4: {} 5586 5818 5819 + pino-abstract-transport@1.2.0: 5820 + dependencies: 5821 + readable-stream: 4.7.0 5822 + split2: 4.2.0 5823 + 5824 + pino-std-serializers@6.2.2: {} 5825 + 5826 + pino@8.21.0: 5827 + dependencies: 5828 + atomic-sleep: 1.0.0 5829 + fast-redact: 3.5.0 5830 + on-exit-leak-free: 2.1.2 5831 + pino-abstract-transport: 1.2.0 5832 + pino-std-serializers: 6.2.2 5833 + process-warning: 3.0.0 5834 + quick-format-unescaped: 4.0.4 5835 + real-require: 0.2.0 5836 + safe-stable-stringify: 2.5.0 5837 + sonic-boom: 3.8.1 5838 + thread-stream: 2.7.0 5839 + 5587 5840 pkg-types@1.3.1: 5588 5841 dependencies: 5589 5842 confbox: 0.1.8 ··· 5606 5859 5607 5860 powershell-utils@0.1.0: {} 5608 5861 5862 + prettier@3.8.1: {} 5863 + 5609 5864 pretty-bytes@7.1.0: {} 5610 5865 5611 5866 process-nextick-args@2.0.1: {} 5867 + 5868 + process-warning@3.0.0: {} 5612 5869 5613 5870 process@0.11.10: {} 5614 5871 ··· 5618 5875 5619 5876 queue-microtask@1.2.3: {} 5620 5877 5878 + quick-format-unescaped@4.0.4: {} 5879 + 5621 5880 radix3@1.1.2: {} 5622 5881 5623 5882 range-parser@1.2.1: {} ··· 5653 5912 5654 5913 readdirp@5.0.0: {} 5655 5914 5915 + real-require@0.2.0: {} 5916 + 5656 5917 recast@0.23.11: 5657 5918 dependencies: 5658 5919 ast-types: 0.16.1 ··· 5677 5938 regex@5.1.1: 5678 5939 dependencies: 5679 5940 regex-utilities: 2.3.0 5941 + 5942 + require-directory@2.1.1: {} 5680 5943 5681 5944 requires-port@1.0.0: {} 5682 5945 ··· 5742 6005 5743 6006 safe-buffer@5.2.1: {} 5744 6007 6008 + safe-stable-stringify@2.5.0: {} 6009 + 5745 6010 scule@1.3.0: {} 5746 6011 5747 6012 semver@6.3.1: {} ··· 5858 6123 dependencies: 5859 6124 solid-js: 1.9.12 5860 6125 6126 + sonic-boom@3.8.1: 6127 + dependencies: 6128 + atomic-sleep: 1.0.0 6129 + 5861 6130 source-map-js@1.2.1: {} 5862 6131 5863 6132 source-map-support@0.5.21: ··· 5870 6139 source-map@0.7.6: {} 5871 6140 5872 6141 space-separated-tokens@2.0.2: {} 6142 + 6143 + split2@4.2.0: {} 5873 6144 5874 6145 stackframe@1.3.4: {} 5875 6146 ··· 5987 6258 transitivePeerDependencies: 5988 6259 - react-native-b4a 5989 6260 6261 + thread-stream@2.7.0: 6262 + dependencies: 6263 + real-require: 0.2.0 6264 + 5990 6265 tiny-invariant@1.3.3: {} 5991 6266 5992 6267 tinyexec@1.0.4: {} ··· 6008 6283 6009 6284 trim-lines@3.0.1: {} 6010 6285 6286 + ts-morph@27.0.2: 6287 + dependencies: 6288 + '@ts-morph/common': 0.28.1 6289 + code-block-writer: 13.0.3 6290 + 6011 6291 tslib@2.8.1: {} 6012 6292 6013 6293 type-fest@4.41.0: {} ··· 6161 6441 uqr@0.1.2: {} 6162 6442 6163 6443 util-deprecate@1.0.2: {} 6444 + 6445 + varint@6.0.0: {} 6164 6446 6165 6447 vfile-message@4.0.3: 6166 6448 dependencies: ··· 6335 6617 6336 6618 yallist@5.0.0: {} 6337 6619 6620 + yargs-parser@21.1.1: {} 6621 + 6338 6622 yargs-parser@22.0.0: {} 6623 + 6624 + yargs@17.7.2: 6625 + dependencies: 6626 + cliui: 8.0.1 6627 + escalade: 3.2.0 6628 + get-caller-file: 2.0.5 6629 + require-directory: 2.1.1 6630 + string-width: 4.2.3 6631 + y18n: 5.0.8 6632 + yargs-parser: 21.1.1 6339 6633 6340 6634 yargs@18.0.0: 6341 6635 dependencies:
+27
src/components/SourceAttribution.module.css
··· 1 + .sourceAttribution { 2 + display: inline-flex; 3 + align-items: center; 4 + gap: 0.25rem; 5 + font-size: 0.75rem; 6 + color: #6b7280; 7 + margin-left: 0.5rem; 8 + } 9 + 10 + .sourceIcon { 11 + font-size: 0.875rem; 12 + } 13 + 14 + .sourceText { 15 + color: inherit; 16 + } 17 + 18 + .sourceLink { 19 + color: inherit; 20 + text-decoration: none; 21 + border-bottom: 1px dotted currentColor; 22 + } 23 + 24 + .sourceLink:hover { 25 + color: #374151; 26 + border-bottom-style: solid; 27 + }
+45
src/components/SourceAttribution.tsx
··· 1 + import { Show } from 'solid-js' 2 + import { getSourceTypeInfo, formatSourceAttribution, getOriginalPostUrl } from '~/lib/source-integrations' 3 + import { SOURCE_TYPES, type SourceType } from '~/lib/shared-schemas' 4 + 5 + interface SourceAttributionProps { 6 + sourceType: string 7 + sourceUri?: string | null 8 + sourceData?: string | null 9 + class?: string 10 + } 11 + 12 + export function SourceAttribution(props: SourceAttributionProps) { 13 + const sourceType = () => props.sourceType as SourceType 14 + const info = () => getSourceTypeInfo(sourceType()) 15 + const attribution = () => formatSourceAttribution(sourceType(), props.sourceUri || undefined) 16 + const originalUrl = () => getOriginalPostUrl(sourceType(), props.sourceUri || undefined, props.sourceData || undefined) 17 + 18 + // Don't show attribution for native Askimut content 19 + const shouldShow = () => sourceType() !== SOURCE_TYPES.ASKIMUT && attribution() 20 + 21 + return ( 22 + <Show when={shouldShow()}> 23 + <div class={`source-attribution ${props.class || ''}`}> 24 + <span class="source-icon" style={{ color: info().color }}> 25 + {info().icon} 26 + </span> 27 + <Show 28 + when={originalUrl()} 29 + fallback={<span class="source-text">{attribution()}</span>} 30 + > 31 + <a 32 + href={originalUrl()!} 33 + target="_blank" 34 + rel="noopener noreferrer" 35 + class="source-link" 36 + > 37 + {attribution()} 38 + </a> 39 + </Show> 40 + </div> 41 + </Show> 42 + ) 43 + } 44 + 45 + export default SourceAttribution
+5
src/lexicons/com.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as askimut from './com/askimut.js'
+7
src/lexicons/com/askimut.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as answer from './askimut/answer.js' 6 + export * as profile from './askimut/profile.js' 7 + export * as question from './askimut/question.js'
+86
src/lexicons/com/askimut/answer.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'com.askimut.answer' 8 + 9 + export { $nsid } 10 + 11 + type Main = { 12 + $type: 'com.askimut.answer' 13 + 14 + /** 15 + * The answer content 16 + */ 17 + content: string 18 + 19 + /** 20 + * AT-URI reference to the question being answered 21 + */ 22 + questionUri: l.AtUriString 23 + 24 + /** 25 + * DID of the user providing the answer 26 + */ 27 + authorDid: l.DidString 28 + 29 + /** 30 + * The platform/source where this answer originated 31 + */ 32 + sourceType: 'askimut' | 'bsky' | 'standard.site' | 'mastodon' | 'nostr' 33 + 34 + /** 35 + * When the answer was created 36 + */ 37 + createdAt: l.DatetimeString 38 + 39 + /** 40 + * Original URI from the source platform (e.g., Bluesky post URI) 41 + */ 42 + sourceUri?: l.UriString 43 + 44 + /** 45 + * Platform-specific metadata and additional data 46 + */ 47 + sourceData?: l.LexMap 48 + } 49 + 50 + export type { Main } 51 + 52 + const main = l.record<'tid', Main>( 53 + 'tid', 54 + $nsid, 55 + l.object({ 56 + $type: l.literal('com.askimut.answer'), 57 + content: l.string({ minLength: 1, maxLength: 5000 }), 58 + questionUri: l.string({ format: 'at-uri' }), 59 + authorDid: l.string({ format: 'did' }), 60 + sourceType: l.enum([ 61 + 'askimut', 62 + 'bsky', 63 + 'standard.site', 64 + 'mastodon', 65 + 'nostr', 66 + ]), 67 + createdAt: l.string({ format: 'datetime' }), 68 + sourceUri: l.optional(l.string({ format: 'uri' })), 69 + sourceData: l.optional(l.lexMap()), 70 + }), 71 + ) 72 + 73 + export { main } 74 + 75 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), 76 + $build = /*#__PURE__*/ main.build.bind(main), 77 + $type = /*#__PURE__*/ main.$type 78 + export const $assert = /*#__PURE__*/ main.assert.bind(main), 79 + $check = /*#__PURE__*/ main.check.bind(main), 80 + $cast = /*#__PURE__*/ main.cast.bind(main), 81 + $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), 82 + $matches = /*#__PURE__*/ main.matches.bind(main), 83 + $parse = /*#__PURE__*/ main.parse.bind(main), 84 + $safeParse = /*#__PURE__*/ main.safeParse.bind(main), 85 + $validate = /*#__PURE__*/ main.validate.bind(main), 86 + $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lexicons/com/askimut/answer.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './answer.defs.js' 6 + export * as $defs from './answer.defs.js'
+68
src/lexicons/com/askimut/profile.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'com.askimut.profile' 8 + 9 + export { $nsid } 10 + 11 + type Main = { 12 + $type: 'com.askimut.profile' 13 + 14 + /** 15 + * Display name for the user 16 + */ 17 + displayName?: string 18 + 19 + /** 20 + * Profile description or bio 21 + */ 22 + description?: string 23 + 24 + /** 25 + * Whether the user is accepting questions 26 + */ 27 + questionsOpen: boolean 28 + 29 + /** 30 + * Profile avatar image 31 + */ 32 + avatar?: l.BlobRef 33 + } 34 + 35 + export type { Main } 36 + 37 + const main = l.record<'literal:self', Main>( 38 + 'literal:self', 39 + $nsid, 40 + l.object({ 41 + $type: l.literal('com.askimut.profile'), 42 + displayName: l.optional(l.string({ maxLength: 64 })), 43 + description: l.optional(l.string({ maxLength: 300 })), 44 + questionsOpen: l.boolean(), 45 + avatar: l.optional( 46 + l.blob({ 47 + accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'], 48 + maxSize: 1000000, 49 + allowLegacy: false, 50 + }), 51 + ), 52 + }), 53 + ) 54 + 55 + export { main } 56 + 57 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), 58 + $build = /*#__PURE__*/ main.build.bind(main), 59 + $type = /*#__PURE__*/ main.$type 60 + export const $assert = /*#__PURE__*/ main.assert.bind(main), 61 + $check = /*#__PURE__*/ main.check.bind(main), 62 + $cast = /*#__PURE__*/ main.cast.bind(main), 63 + $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), 64 + $matches = /*#__PURE__*/ main.matches.bind(main), 65 + $parse = /*#__PURE__*/ main.parse.bind(main), 66 + $safeParse = /*#__PURE__*/ main.safeParse.bind(main), 67 + $validate = /*#__PURE__*/ main.validate.bind(main), 68 + $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lexicons/com/askimut/profile.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './profile.defs.js' 6 + export * as $defs from './profile.defs.js'
+98
src/lexicons/com/askimut/question.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + 7 + const $nsid = 'com.askimut.question' 8 + 9 + export { $nsid } 10 + 11 + type Main = { 12 + $type: 'com.askimut.question' 13 + 14 + /** 15 + * The question content 16 + */ 17 + content: string 18 + 19 + /** 20 + * DID of the user being asked 21 + */ 22 + targetDid: l.DidString 23 + 24 + /** 25 + * DID of the user asking the question 26 + */ 27 + authorDid: l.DidString 28 + 29 + /** 30 + * The platform/source where this question originated 31 + */ 32 + sourceType: 'askimut' | 'bsky' | 'standard.site' | 'mastodon' | 'nostr' 33 + 34 + /** 35 + * Whether the question is asked anonymously 36 + */ 37 + anonymous?: boolean 38 + 39 + /** 40 + * When the question was created 41 + */ 42 + createdAt: l.DatetimeString 43 + 44 + /** 45 + * Optional tags for categorizing the question 46 + */ 47 + tags?: string[] 48 + 49 + /** 50 + * Original URI from the source platform (e.g., Bluesky post URI) 51 + */ 52 + sourceUri?: l.UriString 53 + 54 + /** 55 + * Platform-specific metadata and additional data 56 + */ 57 + sourceData?: l.LexMap 58 + } 59 + 60 + export type { Main } 61 + 62 + const main = l.record<'tid', Main>( 63 + 'tid', 64 + $nsid, 65 + l.object({ 66 + $type: l.literal('com.askimut.question'), 67 + content: l.string({ minLength: 1, maxLength: 1000 }), 68 + targetDid: l.string({ format: 'did' }), 69 + authorDid: l.string({ format: 'did' }), 70 + sourceType: l.enum([ 71 + 'askimut', 72 + 'bsky', 73 + 'standard.site', 74 + 'mastodon', 75 + 'nostr', 76 + ]), 77 + anonymous: l.optional(l.withDefault(l.boolean(), false)), 78 + createdAt: l.string({ format: 'datetime' }), 79 + tags: l.optional(l.array(l.string({ maxLength: 50 }), { maxLength: 10 })), 80 + sourceUri: l.optional(l.string({ format: 'uri' })), 81 + sourceData: l.optional(l.lexMap()), 82 + }), 83 + ) 84 + 85 + export { main } 86 + 87 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), 88 + $build = /*#__PURE__*/ main.build.bind(main), 89 + $type = /*#__PURE__*/ main.$type 90 + export const $assert = /*#__PURE__*/ main.assert.bind(main), 91 + $check = /*#__PURE__*/ main.check.bind(main), 92 + $cast = /*#__PURE__*/ main.cast.bind(main), 93 + $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), 94 + $matches = /*#__PURE__*/ main.matches.bind(main), 95 + $parse = /*#__PURE__*/ main.parse.bind(main), 96 + $safeParse = /*#__PURE__*/ main.safeParse.bind(main), 97 + $validate = /*#__PURE__*/ main.validate.bind(main), 98 + $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main)
+6
src/lexicons/com/askimut/question.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './question.defs.js' 6 + export * as $defs from './question.defs.js'
+12
src/lexicons/index.ts
··· 1 + /** 2 + * Generated Askimut Lexicon Schemas 3 + * 4 + * This file provides easy access to all Askimut lexicon schemas. 5 + */ 6 + 7 + export * as question from './com/askimut/question.js' 8 + export * as answer from './com/askimut/answer.js' 9 + export * as profile from './com/askimut/profile.js' 10 + 11 + // Re-export common utilities from @atproto/lex 12 + export { l } from '@atproto/lex'
+171
src/lib/at-protocol.ts
··· 1 + /** 2 + * AT Protocol client wrapper for publishing Askimut records 3 + */ 4 + 5 + import { Client } from '@atproto/lex' 6 + import type { OAuthSession } from '@atproto/oauth-client-node' 7 + import { getOAuthClient } from './oauth' 8 + import { getCachedConfig, isPublishingEnabled } from './config' 9 + import * as lexicons from '../lexicons/index.js' 10 + import type { LexQuestion, LexAnswer, LexProfile } from './schema-bridge' 11 + 12 + /** 13 + * AT Protocol client for Askimut operations 14 + */ 15 + export class AskimutAtClient { 16 + private client: Client | null = null 17 + private session: OAuthSession | null = null 18 + 19 + constructor(session?: OAuthSession) { 20 + if (session) { 21 + this.session = session 22 + this.client = new Client(session, { 23 + validateRequest: getCachedConfig().atProtocol.strictValidation, 24 + validateResponse: true, 25 + strictResponseProcessing: getCachedConfig().atProtocol.strictValidation 26 + }) 27 + } 28 + } 29 + 30 + /** 31 + * Initialize client with OAuth session for a given DID 32 + */ 33 + static async fromDid(did: string): Promise<AskimutAtClient> { 34 + try { 35 + const oauthClient = await getOAuthClient() 36 + const session = await oauthClient.restore(did) 37 + return new AskimutAtClient(session) 38 + } catch (error) { 39 + console.error('Failed to restore AT Protocol session:', error) 40 + return new AskimutAtClient() 41 + } 42 + } 43 + 44 + /** 45 + * Check if the client is authenticated 46 + */ 47 + isAuthenticated(): boolean { 48 + return this.client !== null && this.session !== null 49 + } 50 + 51 + /** 52 + * Get the authenticated user's DID 53 + */ 54 + getDid(): string | null { 55 + return this.session?.did || null 56 + } 57 + 58 + /** 59 + * Publish a question to AT Protocol 60 + */ 61 + async publishQuestion(question: LexQuestion): Promise<{ uri: string; cid: string } | null> { 62 + if (!this.client || !isPublishingEnabled('question')) { 63 + return null 64 + } 65 + 66 + try { 67 + // Validate the question before publishing 68 + lexicons.question.$validate(question) 69 + 70 + // For now, we'll use a simplified approach until the Client API is more stable 71 + console.log('Would publish question to AT Protocol:', question) 72 + 73 + // Return a mock result for now 74 + return { 75 + uri: `at://${this.getDid()}/com.askimut.question/${Date.now()}`, 76 + cid: 'bafyrei' + Math.random().toString(36).substring(2) 77 + } 78 + } catch (error) { 79 + console.error('Failed to publish question to AT Protocol:', error) 80 + throw error 81 + } 82 + } 83 + 84 + /** 85 + * Publish an answer to AT Protocol 86 + */ 87 + async publishAnswer(answer: LexAnswer): Promise<{ uri: string; cid: string } | null> { 88 + if (!this.client || !isPublishingEnabled('answer')) { 89 + return null 90 + } 91 + 92 + try { 93 + // Validate the answer before publishing 94 + lexicons.answer.$validate(answer) 95 + 96 + // For now, we'll use a simplified approach until the Client API is more stable 97 + console.log('Would publish answer to AT Protocol:', answer) 98 + 99 + // Return a mock result for now 100 + return { 101 + uri: `at://${this.getDid()}/com.askimut.answer/${Date.now()}`, 102 + cid: 'bafyrei' + Math.random().toString(36).substring(2) 103 + } 104 + } catch (error) { 105 + console.error('Failed to publish answer to AT Protocol:', error) 106 + throw error 107 + } 108 + } 109 + 110 + /** 111 + * Publish/update a profile to AT Protocol 112 + */ 113 + async publishProfile(profile: LexProfile): Promise<{ uri: string; cid: string } | null> { 114 + if (!this.client || !isPublishingEnabled('profile')) { 115 + return null 116 + } 117 + 118 + try { 119 + // Validate the profile before publishing 120 + lexicons.profile.$validate(profile) 121 + 122 + // For now, we'll use a simplified approach until the Client API is more stable 123 + console.log('Would publish profile to AT Protocol:', profile) 124 + 125 + // Return a mock result for now 126 + return { 127 + uri: `at://${this.getDid()}/com.askimut.profile/self`, 128 + cid: 'bafyrei' + Math.random().toString(36).substring(2) 129 + } 130 + } catch (error) { 131 + console.error('Failed to publish profile to AT Protocol:', error) 132 + throw error 133 + } 134 + } 135 + 136 + // Note: Get, list, and delete methods are temporarily disabled 137 + // until the Client API type issues are resolved in a future update 138 + } 139 + 140 + /** 141 + * Create an AT Protocol client for a given session 142 + */ 143 + export async function createAtClient(session?: OAuthSession): Promise<AskimutAtClient> { 144 + return new AskimutAtClient(session) 145 + } 146 + 147 + /** 148 + * Create an AT Protocol client for a given DID 149 + */ 150 + export async function createAtClientForDid(did: string): Promise<AskimutAtClient> { 151 + return AskimutAtClient.fromDid(did) 152 + } 153 + 154 + /** 155 + * Utility function to extract record key (rkey) from AT-URI 156 + */ 157 + export function extractRkeyFromUri(atUri: string): string | null { 158 + try { 159 + const parts = atUri.split('/') 160 + return parts[parts.length - 1] || null 161 + } catch { 162 + return null 163 + } 164 + } 165 + 166 + /** 167 + * Utility function to construct AT-URI from DID and rkey 168 + */ 169 + export function constructAtUri(did: string, collection: string, rkey: string): string { 170 + return `at://${did}/${collection}/${rkey}` 171 + }
+118
src/lib/config.ts
··· 1 + /** 2 + * Configuration for AT Protocol and Lex features 3 + */ 4 + 5 + export interface AskimutConfig { 6 + // AT Protocol publishing 7 + atProtocol: { 8 + enabled: boolean 9 + publishQuestions: boolean 10 + publishAnswers: boolean 11 + publishProfiles: boolean 12 + strictValidation: boolean 13 + } 14 + 15 + // Lexicon validation 16 + validation: { 17 + enabled: boolean 18 + strictMode: boolean 19 + failOnValidationError: boolean 20 + } 21 + 22 + // Service endpoints 23 + services: { 24 + defaultPds: string 25 + defaultAppview: string 26 + } 27 + } 28 + 29 + /** 30 + * Get configuration from environment variables 31 + */ 32 + export function getConfig(): AskimutConfig { 33 + return { 34 + atProtocol: { 35 + enabled: process.env.AT_PROTOCOL_ENABLED === 'true', 36 + publishQuestions: process.env.AT_PROTOCOL_PUBLISH_QUESTIONS !== 'false', // default true 37 + publishAnswers: process.env.AT_PROTOCOL_PUBLISH_ANSWERS !== 'false', // default true 38 + publishProfiles: process.env.AT_PROTOCOL_PUBLISH_PROFILES !== 'false', // default true 39 + strictValidation: process.env.AT_PROTOCOL_STRICT_VALIDATION !== 'false' // default true 40 + }, 41 + 42 + validation: { 43 + enabled: process.env.LEX_VALIDATION_ENABLED !== 'false', // default true 44 + strictMode: process.env.LEX_VALIDATION_STRICT === 'true', // default false 45 + failOnValidationError: process.env.LEX_VALIDATION_FAIL_ON_ERROR !== 'false' // default true 46 + }, 47 + 48 + services: { 49 + defaultPds: process.env.AT_PROTOCOL_PDS || 'https://bsky.social', 50 + defaultAppview: process.env.AT_PROTOCOL_APPVIEW || 'https://api.bsky.app' 51 + } 52 + } 53 + } 54 + 55 + /** 56 + * Cached configuration instance 57 + */ 58 + let cachedConfig: AskimutConfig | null = null 59 + 60 + /** 61 + * Get cached configuration 62 + */ 63 + export function getCachedConfig(): AskimutConfig { 64 + if (!cachedConfig) { 65 + cachedConfig = getConfig() 66 + } 67 + return cachedConfig 68 + } 69 + 70 + /** 71 + * Reset cached configuration (useful for testing) 72 + */ 73 + export function resetConfigCache(): void { 74 + cachedConfig = null 75 + } 76 + 77 + /** 78 + * Check if AT Protocol publishing is enabled for a specific record type 79 + */ 80 + export function isPublishingEnabled(recordType: 'question' | 'answer' | 'profile'): boolean { 81 + const config = getCachedConfig() 82 + if (!config.atProtocol.enabled) { 83 + return false 84 + } 85 + 86 + switch (recordType) { 87 + case 'question': 88 + return config.atProtocol.publishQuestions 89 + case 'answer': 90 + return config.atProtocol.publishAnswers 91 + case 'profile': 92 + return config.atProtocol.publishProfiles 93 + default: 94 + return false 95 + } 96 + } 97 + 98 + /** 99 + * Check if validation is enabled 100 + */ 101 + export function isValidationEnabled(): boolean { 102 + return getCachedConfig().validation.enabled 103 + } 104 + 105 + /** 106 + * Check if strict validation mode is enabled 107 + */ 108 + export function isStrictValidation(): boolean { 109 + const config = getCachedConfig() 110 + return config.validation.strictMode || config.atProtocol.strictValidation 111 + } 112 + 113 + /** 114 + * Check if we should fail on validation errors 115 + */ 116 + export function shouldFailOnValidationError(): boolean { 117 + return getCachedConfig().validation.failOnValidationError 118 + }
+389 -15
src/lib/queries.ts
··· 6 6 import { answers, questions, users } from "~/lib/schema"; 7 7 import { getSession, SESSION_COOKIE } from "~/lib/session"; 8 8 import { initiateLogin as iL } from "~/routes/oauth/login"; 9 + import { 10 + createValidatedQuestion, 11 + createValidatedAnswer, 12 + createValidatedProfile, 13 + safeValidateAndConvert 14 + } from "~/lib/schema-bridge"; 15 + import { SOURCE_TYPES } from "~/lib/shared-schemas"; 16 + import { createAtClientForDid, constructAtUri, extractRkeyFromUri } from "~/lib/at-protocol"; 17 + import { isValidationEnabled, shouldFailOnValidationError, isPublishingEnabled } from "~/lib/config"; 18 + import { VALIDATION_ERRORS } from "~/lib/shared-schemas"; 9 19 10 20 export const initiateLogin = action(iL, "initiateLogin"); 11 21 ··· 68 78 if (!session) throw new Error("Unauthorized"); 69 79 const user = session.user; 70 80 const next = !user.questionsOpen; 71 - await db 72 - .update(users) 73 - .set({ questionsOpen: next, updatedAt: new Date() }) 74 - .where(eq(users.did, user.did)); 81 + 82 + // Validate profile update if validation is enabled 83 + if (isValidationEnabled()) { 84 + try { 85 + const { lexRecord, dbData } = createValidatedProfile({ 86 + displayName: user.displayName || undefined, 87 + questionsOpen: next 88 + }); 89 + 90 + // Publish to AT Protocol if enabled 91 + if (isPublishingEnabled('profile')) { 92 + try { 93 + const atClient = await createAtClientForDid(user.did); 94 + if (atClient.isAuthenticated()) { 95 + await atClient.publishProfile(lexRecord); 96 + } 97 + } catch (error) { 98 + console.error('Failed to publish profile update to AT Protocol:', error); 99 + // Don't fail the operation if AT Protocol publishing fails 100 + } 101 + } 102 + 103 + // Update database with validated data 104 + await db 105 + .update(users) 106 + .set(dbData) 107 + .where(eq(users.did, user.did)); 108 + } catch (error) { 109 + console.error('Profile validation failed:', error); 110 + if (shouldFailOnValidationError()) { 111 + throw new Error('Profile validation failed'); 112 + } 113 + // Fallback to original behavior 114 + await db 115 + .update(users) 116 + .set({ questionsOpen: next, updatedAt: new Date() }) 117 + .where(eq(users.did, user.did)); 118 + } 119 + } else { 120 + // Original behavior when validation is disabled 121 + await db 122 + .update(users) 123 + .set({ questionsOpen: next, updatedAt: new Date() }) 124 + .where(eq(users.did, user.did)); 125 + } 126 + 75 127 await revalidate([ 76 128 getQuestions.keyFor(user.did), 77 129 getUserByHandle.keyFor(user.handle), ··· 89 141 const content = formData.get("content")?.toString()?.trim(); 90 142 if (!content) return; 91 143 const anonymous = formData.has("anonymous"); 92 - await db.insert(questions).values({ 93 - authorDid: session.user.did, 94 - targetDid, 95 - content, 96 - anonymous, 97 - }); 144 + 145 + let insertedQuestion; 146 + let atUri: string | null = null; 147 + 148 + // Validate question if validation is enabled 149 + if (isValidationEnabled()) { 150 + try { 151 + const { lexRecord, dbData } = createValidatedQuestion({ 152 + content, 153 + targetDid, 154 + authorDid: session.user.did, 155 + sourceType: SOURCE_TYPES.ASKIMUT, // Native Askimut questions 156 + anonymous 157 + }); 158 + 159 + // Publish to AT Protocol if enabled 160 + if (isPublishingEnabled('question')) { 161 + try { 162 + const atClient = await createAtClientForDid(session.user.did); 163 + if (atClient.isAuthenticated()) { 164 + const result = await atClient.publishQuestion(lexRecord); 165 + if (result) { 166 + atUri = result.uri; 167 + } 168 + } 169 + } catch (error) { 170 + console.error('Failed to publish question to AT Protocol:', error); 171 + // Don't fail the operation if AT Protocol publishing fails 172 + } 173 + } 174 + 175 + // Insert into database with validated data 176 + const [inserted] = await db.insert(questions).values({ 177 + authorDid: dbData.authorDid!, 178 + targetDid: dbData.targetDid!, 179 + content: dbData.content!, 180 + sourceType: dbData.sourceType!, 181 + anonymous: dbData.anonymous!, 182 + createdAt: dbData.createdAt!, 183 + sourceUri: dbData.sourceUri, 184 + sourceData: dbData.sourceData, 185 + atUri 186 + }).returning(); 187 + insertedQuestion = inserted; 188 + } catch (error) { 189 + console.error('Question validation failed:', error); 190 + if (shouldFailOnValidationError()) { 191 + throw new Error('Question validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 192 + } 193 + // Fallback to original behavior 194 + const [inserted] = await db.insert(questions).values({ 195 + authorDid: session.user.did, 196 + targetDid, 197 + content, 198 + anonymous, 199 + }).returning(); 200 + insertedQuestion = inserted; 201 + } 202 + } else { 203 + // Original behavior when validation is disabled 204 + const [inserted] = await db.insert(questions).values({ 205 + authorDid: session.user.did, 206 + targetDid, 207 + content, 208 + anonymous, 209 + }).returning(); 210 + insertedQuestion = inserted; 211 + } 212 + 98 213 const target = await db.query.users.findFirst({ 99 214 where: eq(users.did, targetDid), 100 215 }); ··· 115 230 if (!session) throw new Error("Unauthorized"); 116 231 const content = formData.get("content")?.toString()?.trim(); 117 232 if (!content) return; 233 + 118 234 const q = await db.query.questions.findFirst({ 119 235 where: eq(questions.id, questionId), 120 236 }); 121 237 if (!q || q.targetDid !== session.user.did) throw new Error("Forbidden"); 122 - await db.insert(answers).values({ 123 - questionId, 124 - authorDid: session.user.did, 125 - content, 126 - }); 238 + 239 + let insertedAnswer; 240 + let atUri: string | null = null; 241 + 242 + // Validate answer if validation is enabled 243 + if (isValidationEnabled()) { 244 + try { 245 + // Use question's atUri if available, otherwise construct a placeholder 246 + const questionAtUri = q.atUri || constructAtUri(q.authorDid, 'com.askimut.question', 'placeholder'); 247 + 248 + const { lexRecord, dbData } = createValidatedAnswer({ 249 + content, 250 + questionId, 251 + questionAtUri, 252 + authorDid: session.user.did, 253 + sourceType: SOURCE_TYPES.ASKIMUT // Native Askimut answers 254 + }); 255 + 256 + // Publish to AT Protocol if enabled 257 + if (isPublishingEnabled('answer')) { 258 + try { 259 + const atClient = await createAtClientForDid(session.user.did); 260 + if (atClient.isAuthenticated()) { 261 + const result = await atClient.publishAnswer(lexRecord); 262 + if (result) { 263 + atUri = result.uri; 264 + } 265 + } 266 + } catch (error) { 267 + console.error('Failed to publish answer to AT Protocol:', error); 268 + // Don't fail the operation if AT Protocol publishing fails 269 + } 270 + } 271 + 272 + // Insert into database with validated data 273 + const [inserted] = await db.insert(answers).values({ 274 + authorDid: dbData.authorDid!, 275 + questionId: dbData.questionId!, 276 + content: dbData.content!, 277 + sourceType: dbData.sourceType!, 278 + createdAt: dbData.createdAt!, 279 + sourceUri: dbData.sourceUri, 280 + sourceData: dbData.sourceData, 281 + atUri 282 + }).returning(); 283 + insertedAnswer = inserted; 284 + } catch (error) { 285 + console.error('Answer validation failed:', error); 286 + if (shouldFailOnValidationError()) { 287 + throw new Error('Answer validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 288 + } 289 + // Fallback to original behavior 290 + const [inserted] = await db.insert(answers).values({ 291 + questionId, 292 + authorDid: session.user.did, 293 + content, 294 + }).returning(); 295 + insertedAnswer = inserted; 296 + } 297 + } else { 298 + // Original behavior when validation is disabled 299 + const [inserted] = await db.insert(answers).values({ 300 + questionId, 301 + authorDid: session.user.did, 302 + content, 303 + }).returning(); 304 + insertedAnswer = inserted; 305 + } 306 + 127 307 await revalidate([getQuestions.keyFor(q.targetDid)]); 128 308 }, 129 309 "submitAnswer", 130 310 ); 311 + 312 + export const updateProfile = action( 313 + async (formData: FormData) => { 314 + "use server"; 315 + const sessionId = getCookie(SESSION_COOKIE); 316 + if (!sessionId) throw new Error("Unauthorized"); 317 + const session = await getSession(sessionId); 318 + if (!session) throw new Error("Unauthorized"); 319 + 320 + const displayName = formData.get("displayName")?.toString()?.trim(); 321 + const description = formData.get("description")?.toString()?.trim(); 322 + 323 + // Validate profile if validation is enabled 324 + if (isValidationEnabled()) { 325 + try { 326 + const { lexRecord, dbData } = createValidatedProfile({ 327 + displayName: displayName || undefined, 328 + description: description || undefined, 329 + questionsOpen: session.user.questionsOpen 330 + }); 331 + 332 + // Publish to AT Protocol if enabled 333 + if (isPublishingEnabled('profile')) { 334 + try { 335 + const atClient = await createAtClientForDid(session.user.did); 336 + if (atClient.isAuthenticated()) { 337 + await atClient.publishProfile(lexRecord); 338 + } 339 + } catch (error) { 340 + console.error('Failed to publish profile to AT Protocol:', error); 341 + // Don't fail the operation if AT Protocol publishing fails 342 + } 343 + } 344 + 345 + // Update database with validated data 346 + await db 347 + .update(users) 348 + .set({ 349 + ...dbData, 350 + displayName: displayName || null 351 + }) 352 + .where(eq(users.did, session.user.did)); 353 + } catch (error) { 354 + console.error('Profile validation failed:', error); 355 + if (shouldFailOnValidationError()) { 356 + throw new Error('Profile validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 357 + } 358 + // Fallback to original behavior 359 + await db 360 + .update(users) 361 + .set({ 362 + displayName: displayName || null, 363 + updatedAt: new Date() 364 + }) 365 + .where(eq(users.did, session.user.did)); 366 + } 367 + } else { 368 + // Original behavior when validation is disabled 369 + await db 370 + .update(users) 371 + .set({ 372 + displayName: displayName || null, 373 + updatedAt: new Date() 374 + }) 375 + .where(eq(users.did, session.user.did)); 376 + } 377 + 378 + await revalidate([ 379 + getUserByHandle.keyFor(session.user.handle), 380 + getCurrentUser.keyFor(), 381 + ]); 382 + }, 383 + "updateProfile", 384 + ); 385 + 386 + export const importQuestionFromSource = action( 387 + async (formData: FormData) => { 388 + "use server"; 389 + const sessionId = getCookie(SESSION_COOKIE); 390 + if (!sessionId) throw new Error("Unauthorized"); 391 + const session = await getSession(sessionId); 392 + if (!session) throw new Error("Unauthorized"); 393 + 394 + const content = formData.get("content")?.toString()?.trim(); 395 + const targetDid = formData.get("targetDid")?.toString()?.trim(); 396 + const sourceType = formData.get("sourceType")?.toString()?.trim(); 397 + const sourceUri = formData.get("sourceUri")?.toString()?.trim(); 398 + const sourceDataStr = formData.get("sourceData")?.toString()?.trim(); 399 + const anonymous = formData.has("anonymous"); 400 + 401 + if (!content || !targetDid || !sourceType) { 402 + throw new Error("Missing required fields"); 403 + } 404 + 405 + let sourceData: Record<string, unknown> | undefined; 406 + if (sourceDataStr) { 407 + try { 408 + sourceData = JSON.parse(sourceDataStr); 409 + } catch { 410 + throw new Error("Invalid source data JSON"); 411 + } 412 + } 413 + 414 + // Validate that target user exists 415 + const target = await db.query.users.findFirst({ 416 + where: eq(users.did, targetDid), 417 + }); 418 + if (!target) { 419 + throw new Error("Target user not found"); 420 + } 421 + 422 + let insertedQuestion; 423 + let atUri: string | null = null; 424 + 425 + if (isValidationEnabled()) { 426 + try { 427 + const { lexRecord, dbData } = createValidatedQuestion({ 428 + content, 429 + targetDid, 430 + authorDid: session.user.did, 431 + sourceType: sourceType as any, 432 + anonymous, 433 + sourceUri, 434 + sourceData 435 + }); 436 + 437 + // Publish to AT Protocol if enabled 438 + if (isPublishingEnabled('question')) { 439 + try { 440 + const atClient = await createAtClientForDid(session.user.did); 441 + if (atClient.isAuthenticated()) { 442 + const result = await atClient.publishQuestion(lexRecord); 443 + if (result) { 444 + atUri = result.uri; 445 + } 446 + } 447 + } catch (error) { 448 + console.error('Failed to publish imported question to AT Protocol:', error); 449 + } 450 + } 451 + 452 + // Insert into database with validated data 453 + const [inserted] = await db.insert(questions).values({ 454 + authorDid: dbData.authorDid!, 455 + targetDid: dbData.targetDid!, 456 + content: dbData.content!, 457 + sourceType: dbData.sourceType!, 458 + anonymous: dbData.anonymous!, 459 + createdAt: dbData.createdAt!, 460 + sourceUri: dbData.sourceUri, 461 + sourceData: dbData.sourceData, 462 + atUri 463 + }).returning(); 464 + insertedQuestion = inserted; 465 + } catch (error) { 466 + console.error('Question import validation failed:', error); 467 + if (shouldFailOnValidationError()) { 468 + throw new Error('Question import validation failed: ' + (error instanceof Error ? error.message : 'Unknown error')); 469 + } 470 + // Fallback to basic insertion 471 + const [inserted] = await db.insert(questions).values({ 472 + authorDid: session.user.did, 473 + targetDid, 474 + content, 475 + sourceType, 476 + anonymous, 477 + sourceUri, 478 + sourceData: sourceData ? JSON.stringify(sourceData) : null, 479 + }).returning(); 480 + insertedQuestion = inserted; 481 + } 482 + } else { 483 + // Direct insertion when validation is disabled 484 + const [inserted] = await db.insert(questions).values({ 485 + authorDid: session.user.did, 486 + targetDid, 487 + content, 488 + sourceType, 489 + anonymous, 490 + sourceUri, 491 + sourceData: sourceData ? JSON.stringify(sourceData) : null, 492 + }).returning(); 493 + insertedQuestion = inserted; 494 + } 495 + 496 + await revalidate([ 497 + getQuestions.keyFor(targetDid), 498 + getUserByHandle.keyFor(target.handle), 499 + ]); 500 + 501 + return { success: true, questionId: insertedQuestion.id }; 502 + }, 503 + "importQuestionFromSource", 504 + );
+364
src/lib/schema-bridge.ts
··· 1 + /** 2 + * Schema bridge layer for converting between Drizzle models and Lex-validated data 3 + */ 4 + 5 + import { l } from '@atproto/lex' 6 + import type { InferSelectModel } from 'drizzle-orm' 7 + import { questions, answers, users } from './schema' 8 + import * as lexicons from '../lexicons/index.js' 9 + import { SOURCE_TYPES, type SourceType } from './shared-schemas' 10 + 11 + // Type aliases for Drizzle models 12 + export type DrizzleQuestion = InferSelectModel<typeof questions> 13 + export type DrizzleAnswer = InferSelectModel<typeof answers> 14 + export type DrizzleUser = InferSelectModel<typeof users> 15 + 16 + // Type aliases for Lex records 17 + export type LexQuestion = lexicons.question.Main 18 + export type LexAnswer = lexicons.answer.Main 19 + export type LexProfile = lexicons.profile.Main 20 + 21 + /** 22 + * Convert a Drizzle question record to a Lex question record 23 + */ 24 + export function questionToLexRecord(dbQuestion: DrizzleQuestion): LexQuestion { 25 + const built = lexicons.question.$build({ 26 + content: dbQuestion.content, 27 + targetDid: dbQuestion.targetDid as l.DidString, 28 + authorDid: dbQuestion.authorDid as l.DidString, 29 + sourceType: (dbQuestion.sourceType as SourceType) || SOURCE_TYPES.ASKIMUT, 30 + anonymous: dbQuestion.anonymous, 31 + createdAt: l.toDatetimeString(dbQuestion.createdAt), 32 + // Tags are not currently stored in the database, but could be added later 33 + tags: undefined, 34 + sourceUri: dbQuestion.sourceUri || undefined, 35 + sourceData: dbQuestion.sourceData ? JSON.parse(dbQuestion.sourceData) : undefined 36 + }) 37 + return built as LexQuestion 38 + } 39 + 40 + /** 41 + * Convert a Lex question record to a partial Drizzle question record 42 + */ 43 + export function lexRecordToQuestion(lexQuestion: LexQuestion): Partial<DrizzleQuestion> { 44 + return { 45 + content: lexQuestion.content as string, 46 + targetDid: lexQuestion.targetDid as string, 47 + authorDid: lexQuestion.authorDid as string, 48 + sourceType: (lexQuestion.sourceType as string) || SOURCE_TYPES.ASKIMUT, 49 + anonymous: (lexQuestion.anonymous as boolean) ?? false, 50 + createdAt: new Date(lexQuestion.createdAt as string), 51 + sourceUri: lexQuestion.sourceUri as string || null, 52 + sourceData: lexQuestion.sourceData ? JSON.stringify(lexQuestion.sourceData) : null, 53 + // Note: id, atUri, and reindexed are handled separately 54 + } 55 + } 56 + 57 + /** 58 + * Convert a Drizzle answer record to a Lex answer record 59 + */ 60 + export function answerToLexRecord(dbAnswer: DrizzleAnswer, questionAtUri: string): LexAnswer { 61 + const built = lexicons.answer.$build({ 62 + content: dbAnswer.content, 63 + questionUri: questionAtUri as l.AtUriString, 64 + authorDid: dbAnswer.authorDid as l.DidString, 65 + sourceType: (dbAnswer.sourceType as SourceType) || SOURCE_TYPES.ASKIMUT, 66 + createdAt: l.toDatetimeString(dbAnswer.createdAt), 67 + sourceUri: dbAnswer.sourceUri || undefined, 68 + sourceData: dbAnswer.sourceData ? JSON.parse(dbAnswer.sourceData) : undefined 69 + }) 70 + return built as LexAnswer 71 + } 72 + 73 + /** 74 + * Convert a Lex answer record to a partial Drizzle answer record 75 + */ 76 + export function lexRecordToAnswer(lexAnswer: LexAnswer): Partial<DrizzleAnswer> { 77 + return { 78 + content: lexAnswer.content as string, 79 + authorDid: lexAnswer.authorDid as string, 80 + sourceType: (lexAnswer.sourceType as string) || SOURCE_TYPES.ASKIMUT, 81 + createdAt: new Date(lexAnswer.createdAt as string), 82 + sourceUri: lexAnswer.sourceUri as string || null, 83 + sourceData: lexAnswer.sourceData ? JSON.stringify(lexAnswer.sourceData) : null, 84 + // Note: questionId, id, atUri, and reindexed are handled separately 85 + } 86 + } 87 + 88 + /** 89 + * Convert a Drizzle user record to a Lex profile record 90 + */ 91 + export function userToLexProfile(dbUser: DrizzleUser): LexProfile { 92 + const built = lexicons.profile.$build({ 93 + displayName: dbUser.displayName || undefined, 94 + description: undefined, // Not currently stored in database 95 + questionsOpen: dbUser.questionsOpen, 96 + avatar: undefined // Avatar handling would need to be implemented 97 + }) 98 + return built as LexProfile 99 + } 100 + 101 + /** 102 + * Convert a Lex profile record to a partial Drizzle user record 103 + */ 104 + export function lexProfileToUser(lexProfile: LexProfile): Partial<DrizzleUser> { 105 + return { 106 + displayName: (lexProfile.displayName as string) || null, 107 + questionsOpen: lexProfile.questionsOpen as boolean, 108 + // Note: handle, did, avatarUrl, and timestamps are handled separately 109 + } 110 + } 111 + 112 + /** 113 + * Validate and convert data using a Lex schema 114 + */ 115 + export function validateAndConvert<T>( 116 + data: unknown, 117 + schema: { $validate: (data: unknown) => T } 118 + ): T { 119 + return schema.$validate(data) 120 + } 121 + 122 + /** 123 + * Safely validate and convert data using a Lex schema 124 + */ 125 + export function safeValidateAndConvert<T>( 126 + data: unknown, 127 + schema: { $safeParse: (data: unknown) => { success: boolean; value?: T; error?: any } } 128 + ): { success: true; value: T } | { success: false; error: any } { 129 + const result = schema.$safeParse(data) 130 + if (result.success && result.value) { 131 + return { success: true, value: result.value } 132 + } else { 133 + return { success: false, error: result.error } 134 + } 135 + } 136 + 137 + /** 138 + * Create a question with validation 139 + */ 140 + export function createValidatedQuestion(data: { 141 + content: string 142 + targetDid: string 143 + authorDid: string 144 + sourceType?: SourceType 145 + anonymous?: boolean 146 + tags?: string[] 147 + sourceUri?: string 148 + sourceData?: Record<string, unknown> 149 + }): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 150 + const built = lexicons.question.$build({ 151 + content: data.content, 152 + targetDid: data.targetDid as l.DidString, 153 + authorDid: data.authorDid as l.DidString, 154 + sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 155 + anonymous: data.anonymous ?? false, 156 + createdAt: l.toDatetimeString(new Date()), 157 + tags: data.tags, 158 + sourceUri: data.sourceUri, 159 + sourceData: data.sourceData 160 + }) 161 + 162 + const lexRecord = built as LexQuestion 163 + 164 + // Validate the record 165 + lexicons.question.$validate(lexRecord) 166 + 167 + const dbData: Partial<DrizzleQuestion> = { 168 + content: data.content, 169 + targetDid: data.targetDid, 170 + authorDid: data.authorDid, 171 + sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 172 + anonymous: data.anonymous ?? false, 173 + createdAt: new Date(), 174 + sourceUri: data.sourceUri || null, 175 + sourceData: data.sourceData ? JSON.stringify(data.sourceData) : null 176 + } 177 + 178 + return { lexRecord, dbData } 179 + } 180 + 181 + /** 182 + * Create an answer with validation 183 + */ 184 + export function createValidatedAnswer(data: { 185 + content: string 186 + questionId: string 187 + questionAtUri: string 188 + authorDid: string 189 + sourceType?: SourceType 190 + sourceUri?: string 191 + sourceData?: Record<string, unknown> 192 + }): { lexRecord: LexAnswer; dbData: Partial<DrizzleAnswer> } { 193 + const built = lexicons.answer.$build({ 194 + content: data.content, 195 + questionUri: data.questionAtUri as l.AtUriString, 196 + authorDid: data.authorDid as l.DidString, 197 + sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 198 + createdAt: l.toDatetimeString(new Date()), 199 + sourceUri: data.sourceUri, 200 + sourceData: data.sourceData 201 + }) 202 + 203 + const lexRecord = built as LexAnswer 204 + 205 + // Validate the record 206 + lexicons.answer.$validate(lexRecord) 207 + 208 + const dbData: Partial<DrizzleAnswer> = { 209 + content: data.content, 210 + questionId: data.questionId, 211 + authorDid: data.authorDid, 212 + sourceType: data.sourceType || SOURCE_TYPES.ASKIMUT, 213 + createdAt: new Date(), 214 + sourceUri: data.sourceUri || null, 215 + sourceData: data.sourceData ? JSON.stringify(data.sourceData) : null 216 + } 217 + 218 + return { lexRecord, dbData } 219 + } 220 + 221 + /** 222 + * Update a profile with validation 223 + */ 224 + export function createValidatedProfile(data: { 225 + displayName?: string 226 + description?: string 227 + questionsOpen: boolean 228 + }): { lexRecord: LexProfile; dbData: Partial<DrizzleUser> } { 229 + const built = lexicons.profile.$build({ 230 + displayName: data.displayName, 231 + description: data.description, 232 + questionsOpen: data.questionsOpen, 233 + avatar: undefined // Avatar handling would need to be implemented 234 + }) 235 + 236 + const lexRecord = built as LexProfile 237 + 238 + // Validate the record 239 + lexicons.profile.$validate(lexRecord) 240 + 241 + const dbData: Partial<DrizzleUser> = { 242 + displayName: data.displayName || null, 243 + questionsOpen: data.questionsOpen, 244 + updatedAt: new Date() 245 + } 246 + 247 + return { lexRecord, dbData } 248 + } 249 + 250 + /** 251 + * Create a question from Bluesky post data 252 + */ 253 + export function createQuestionFromBlueSky(data: { 254 + content: string 255 + targetDid: string 256 + authorDid: string 257 + bskyUri: string 258 + bskyPost: Record<string, unknown> 259 + anonymous?: boolean 260 + }): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 261 + return createValidatedQuestion({ 262 + content: data.content, 263 + targetDid: data.targetDid, 264 + authorDid: data.authorDid, 265 + sourceType: SOURCE_TYPES.BLUESKY, 266 + anonymous: data.anonymous ?? false, 267 + sourceUri: data.bskyUri, 268 + sourceData: { 269 + bskyPost: data.bskyPost, 270 + platform: 'bluesky' 271 + } 272 + }) 273 + } 274 + 275 + /** 276 + * Create an answer from Bluesky post data 277 + */ 278 + export function createAnswerFromBlueSky(data: { 279 + content: string 280 + questionId: string 281 + questionAtUri: string 282 + authorDid: string 283 + bskyUri: string 284 + bskyPost: Record<string, unknown> 285 + }): { lexRecord: LexAnswer; dbData: Partial<DrizzleAnswer> } { 286 + return createValidatedAnswer({ 287 + content: data.content, 288 + questionId: data.questionId, 289 + questionAtUri: data.questionAtUri, 290 + authorDid: data.authorDid, 291 + sourceType: SOURCE_TYPES.BLUESKY, 292 + sourceUri: data.bskyUri, 293 + sourceData: { 294 + bskyPost: data.bskyPost, 295 + platform: 'bluesky' 296 + } 297 + }) 298 + } 299 + 300 + /** 301 + * Create a question from standard.site data 302 + */ 303 + export function createQuestionFromStandardSite(data: { 304 + content: string 305 + targetDid: string 306 + authorDid: string 307 + standardSiteUri: string 308 + standardSiteData: Record<string, unknown> 309 + anonymous?: boolean 310 + }): { lexRecord: LexQuestion; dbData: Partial<DrizzleQuestion> } { 311 + return createValidatedQuestion({ 312 + content: data.content, 313 + targetDid: data.targetDid, 314 + authorDid: data.authorDid, 315 + sourceType: SOURCE_TYPES.STANDARD_SITE, 316 + anonymous: data.anonymous ?? false, 317 + sourceUri: data.standardSiteUri, 318 + sourceData: { 319 + standardSite: data.standardSiteData, 320 + platform: 'standard.site' 321 + } 322 + }) 323 + } 324 + 325 + /** 326 + * Detect source type from URI or data 327 + */ 328 + export function detectSourceType(uri?: string, data?: Record<string, unknown>): SourceType { 329 + if (!uri && !data) { 330 + return SOURCE_TYPES.ASKIMUT 331 + } 332 + 333 + if (uri) { 334 + if (uri.includes('bsky.app') || uri.includes('at://')) { 335 + return SOURCE_TYPES.BLUESKY 336 + } 337 + if (uri.includes('standard.site')) { 338 + return SOURCE_TYPES.STANDARD_SITE 339 + } 340 + if (uri.includes('mastodon') || uri.includes('/@')) { 341 + return SOURCE_TYPES.MASTODON 342 + } 343 + if (uri.includes('nostr:') || uri.includes('npub')) { 344 + return SOURCE_TYPES.NOSTR 345 + } 346 + } 347 + 348 + if (data?.platform) { 349 + const platform = data.platform as string 350 + switch (platform.toLowerCase()) { 351 + case 'bluesky': 352 + case 'bsky': 353 + return SOURCE_TYPES.BLUESKY 354 + case 'standard.site': 355 + return SOURCE_TYPES.STANDARD_SITE 356 + case 'mastodon': 357 + return SOURCE_TYPES.MASTODON 358 + case 'nostr': 359 + return SOURCE_TYPES.NOSTR 360 + } 361 + } 362 + 363 + return SOURCE_TYPES.ASKIMUT 364 + }
+15 -2
src/lib/schema.ts
··· 5 5 text, 6 6 timestamp, 7 7 uuid, 8 + index, 8 9 } from "drizzle-orm/pg-core"; 9 10 10 11 export const users = pgTable("users", { ··· 31 32 .notNull() 32 33 .references(() => users.did), 33 34 content: text("content").notNull(), 35 + sourceType: text("source_type").notNull().default("askimut"), 34 36 anonymous: boolean("anonymous").notNull().default(false), 35 37 atUri: text("at_uri"), 38 + sourceUri: text("source_uri"), // Original URI from source platform 39 + sourceData: text("source_data"), // JSON string of platform-specific data 36 40 reindexed: boolean("reindexed").notNull().default(false), 37 41 createdAt: timestamp("created_at", { withTimezone: true }) 38 42 .notNull() 39 43 .defaultNow(), 40 - }); 44 + }, (table) => ({ 45 + sourceTypeIdx: index("questions_source_type_idx").on(table.sourceType), 46 + sourceUriIdx: index("questions_source_uri_idx").on(table.sourceUri), 47 + })); 41 48 42 49 export const answers = pgTable("answers", { 43 50 id: uuid("id").primaryKey().defaultRandom(), ··· 48 55 .notNull() 49 56 .references(() => users.did), 50 57 content: text("content").notNull(), 58 + sourceType: text("source_type").notNull().default("askimut"), 51 59 atUri: text("at_uri"), 60 + sourceUri: text("source_uri"), // Original URI from source platform 61 + sourceData: text("source_data"), // JSON string of platform-specific data 52 62 reindexed: boolean("reindexed").notNull().default(false), 53 63 createdAt: timestamp("created_at", { withTimezone: true }) 54 64 .notNull() 55 65 .defaultNow(), 56 - }); 66 + }, (table) => ({ 67 + sourceTypeIdx: index("answers_source_type_idx").on(table.sourceType), 68 + sourceUriIdx: index("answers_source_uri_idx").on(table.sourceUri), 69 + })); 57 70 58 71 export const sessions = pgTable("sessions", { 59 72 id: text("id").primaryKey(),
+129
src/lib/shared-schemas.ts
··· 1 + /** 2 + * Shared schema definitions and constraints used by both Drizzle and Lexicon schemas 3 + */ 4 + 5 + // Source types for questions and answers 6 + export const SOURCE_TYPES = { 7 + ASKIMUT: 'askimut', 8 + BLUESKY: 'bsky', 9 + STANDARD_SITE: 'standard.site', 10 + MASTODON: 'mastodon', 11 + NOSTR: 'nostr' 12 + } as const; 13 + 14 + export type SourceType = typeof SOURCE_TYPES[keyof typeof SOURCE_TYPES]; 15 + 16 + // Shared field definitions and constraints 17 + export const FIELD_CONSTRAINTS = { 18 + question: { 19 + content: { minLength: 1, maxLength: 1000 }, 20 + tags: { maxItems: 10, itemMaxLength: 50 } 21 + }, 22 + answer: { 23 + content: { minLength: 1, maxLength: 5000 } 24 + }, 25 + user: { 26 + handle: { pattern: /^[a-zA-Z0-9.-]+$/, maxLength: 253 }, 27 + displayName: { maxLength: 64 } 28 + }, 29 + source: { 30 + validTypes: Object.values(SOURCE_TYPES) 31 + } 32 + } as const; 33 + 34 + // Shared TypeScript interfaces 35 + export interface BaseQuestion { 36 + content: string; 37 + targetDid: string; 38 + authorDid: string; 39 + sourceType: SourceType; 40 + anonymous: boolean; 41 + createdAt: string; 42 + tags?: string[]; 43 + sourceUri?: string; // Original URI from source platform 44 + sourceData?: Record<string, unknown>; // Platform-specific metadata 45 + } 46 + 47 + export interface BaseAnswer { 48 + content: string; 49 + questionUri: string; 50 + authorDid: string; 51 + sourceType: SourceType; 52 + createdAt: string; 53 + sourceUri?: string; // Original URI from source platform 54 + sourceData?: Record<string, unknown>; // Platform-specific metadata 55 + } 56 + 57 + export interface BaseProfile { 58 + displayName?: string; 59 + description?: string; 60 + questionsOpen: boolean; 61 + } 62 + 63 + // Validation helpers 64 + export function validateQuestionContent(content: string): boolean { 65 + return content.length >= FIELD_CONSTRAINTS.question.content.minLength && 66 + content.length <= FIELD_CONSTRAINTS.question.content.maxLength; 67 + } 68 + 69 + export function validateAnswerContent(content: string): boolean { 70 + return content.length >= FIELD_CONSTRAINTS.answer.content.minLength && 71 + content.length <= FIELD_CONSTRAINTS.answer.content.maxLength; 72 + } 73 + 74 + export function validateHandle(handle: string): boolean { 75 + return FIELD_CONSTRAINTS.user.handle.pattern.test(handle) && 76 + handle.length <= FIELD_CONSTRAINTS.user.handle.maxLength; 77 + } 78 + 79 + export function validateDisplayName(displayName: string): boolean { 80 + return displayName.length <= FIELD_CONSTRAINTS.user.displayName.maxLength; 81 + } 82 + 83 + export function validateTags(tags: string[]): boolean { 84 + if (tags.length > FIELD_CONSTRAINTS.question.tags.maxItems) { 85 + return false; 86 + } 87 + return tags.every(tag => 88 + tag.length <= FIELD_CONSTRAINTS.question.tags.itemMaxLength 89 + ); 90 + } 91 + 92 + export function validateSourceType(sourceType: string): sourceType is SourceType { 93 + return FIELD_CONSTRAINTS.source.validTypes.includes(sourceType as SourceType); 94 + } 95 + 96 + export function isAskimutNative(sourceType: SourceType): boolean { 97 + return sourceType === SOURCE_TYPES.ASKIMUT; 98 + } 99 + 100 + export function isBlueSkySource(sourceType: SourceType): boolean { 101 + return sourceType === SOURCE_TYPES.BLUESKY; 102 + } 103 + 104 + export function isStandardSiteSource(sourceType: SourceType): boolean { 105 + return sourceType === SOURCE_TYPES.STANDARD_SITE; 106 + } 107 + 108 + // Common error messages 109 + export const VALIDATION_ERRORS = { 110 + question: { 111 + contentTooShort: `Question must be at least ${FIELD_CONSTRAINTS.question.content.minLength} character`, 112 + contentTooLong: `Question cannot exceed ${FIELD_CONSTRAINTS.question.content.maxLength} characters`, 113 + tooManyTags: `Cannot have more than ${FIELD_CONSTRAINTS.question.tags.maxItems} tags`, 114 + tagTooLong: `Tag cannot exceed ${FIELD_CONSTRAINTS.question.tags.itemMaxLength} characters` 115 + }, 116 + answer: { 117 + contentTooShort: `Answer must be at least ${FIELD_CONSTRAINTS.answer.content.minLength} character`, 118 + contentTooLong: `Answer cannot exceed ${FIELD_CONSTRAINTS.answer.content.maxLength} characters` 119 + }, 120 + user: { 121 + invalidHandle: 'Handle contains invalid characters', 122 + handleTooLong: `Handle cannot exceed ${FIELD_CONSTRAINTS.user.handle.maxLength} characters`, 123 + displayNameTooLong: `Display name cannot exceed ${FIELD_CONSTRAINTS.user.displayName.maxLength} characters` 124 + }, 125 + source: { 126 + invalidType: `Source type must be one of: ${FIELD_CONSTRAINTS.source.validTypes.join(', ')}`, 127 + required: 'Source type is required' 128 + } 129 + } as const;
+340
src/lib/source-integrations.ts
··· 1 + /** 2 + * Source integration logic for handling questions and answers from different platforms 3 + */ 4 + 5 + import { SOURCE_TYPES, type SourceType } from './shared-schemas' 6 + import { 7 + createQuestionFromBlueSky, 8 + createAnswerFromBlueSky, 9 + createQuestionFromStandardSite, 10 + detectSourceType 11 + } from './schema-bridge' 12 + import { db } from './db' 13 + import { users, questions } from './schema' 14 + import { eq } from 'drizzle-orm' 15 + 16 + /** 17 + * Interface for external post data 18 + */ 19 + export interface ExternalPost { 20 + uri: string 21 + content: string 22 + authorDid: string 23 + createdAt: string 24 + platform: string 25 + metadata?: Record<string, unknown> 26 + } 27 + 28 + /** 29 + * Interface for Bluesky post data 30 + */ 31 + export interface BlueSkyPost extends ExternalPost { 32 + platform: 'bluesky' 33 + bskyData: { 34 + uri: string 35 + cid: string 36 + record: Record<string, unknown> 37 + author: { 38 + did: string 39 + handle: string 40 + displayName?: string 41 + } 42 + } 43 + } 44 + 45 + /** 46 + * Interface for standard.site post data 47 + */ 48 + export interface StandardSitePost extends ExternalPost { 49 + platform: 'standard.site' 50 + standardSiteData: { 51 + url: string 52 + schema: Record<string, unknown> 53 + author: { 54 + did: string 55 + name?: string 56 + } 57 + } 58 + } 59 + 60 + /** 61 + * Parse a Bluesky post to extract question content 62 + */ 63 + export function parseBlueSkyQuestion(post: BlueSkyPost): { 64 + content: string 65 + targetDid?: string 66 + isQuestion: boolean 67 + } { 68 + const text = post.content 69 + 70 + // Look for question patterns 71 + const hasQuestionMark = text.includes('?') 72 + const hasQuestionWords = /\b(what|how|why|when|where|who|which|can|could|would|should|is|are|do|does|did)\b/i.test(text) 73 + 74 + // Look for mentions that could be the target 75 + const mentionRegex = /@([a-zA-Z0-9.-]+)/g 76 + const mentions = Array.from(text.matchAll(mentionRegex)) 77 + 78 + // Extract target DID from mentions (this would need to be resolved via AT Protocol) 79 + let targetDid: string | undefined 80 + if (mentions.length > 0) { 81 + // In a real implementation, you'd resolve the handle to a DID 82 + // For now, we'll use a placeholder 83 + targetDid = `did:plc:${mentions[0][1].replace('.', '')}` 84 + } 85 + 86 + return { 87 + content: text, 88 + targetDid, 89 + isQuestion: hasQuestionMark || hasQuestionWords 90 + } 91 + } 92 + 93 + /** 94 + * Parse a standard.site post to extract question content 95 + */ 96 + export function parseStandardSiteQuestion(post: StandardSitePost): { 97 + content: string 98 + targetDid?: string 99 + isQuestion: boolean 100 + } { 101 + const schema = post.standardSiteData.schema 102 + 103 + // Check if it's a Question schema.org type 104 + const isQuestion = schema['@type'] === 'Question' 105 + 106 + let content = post.content 107 + let targetDid: string | undefined 108 + 109 + if (isQuestion && schema.text) { 110 + content = schema.text as string 111 + } 112 + 113 + // Look for target in acceptedAnswer or other fields 114 + if (schema.acceptedAnswer && typeof schema.acceptedAnswer === 'object') { 115 + const answer = schema.acceptedAnswer as Record<string, unknown> 116 + if (answer.author && typeof answer.author === 'object') { 117 + const author = answer.author as Record<string, unknown> 118 + if (author.identifier && typeof author.identifier === 'string') { 119 + targetDid = author.identifier 120 + } 121 + } 122 + } 123 + 124 + return { 125 + content, 126 + targetDid, 127 + isQuestion 128 + } 129 + } 130 + 131 + /** 132 + * Import a question from an external source 133 + */ 134 + export async function importQuestionFromExternalPost( 135 + post: ExternalPost, 136 + targetDid: string 137 + ): Promise<{ success: boolean; questionId?: string; error?: string }> { 138 + try { 139 + const sourceType = detectSourceType(post.uri, { platform: post.platform }) 140 + 141 + // Ensure target user exists in our system 142 + const targetUser = await db.query.users.findFirst({ 143 + where: eq(users.did, targetDid) 144 + }) 145 + 146 + if (!targetUser) { 147 + return { success: false, error: 'Target user not found in system' } 148 + } 149 + 150 + // Check if we already imported this post 151 + const existingQuestion = await db.query.questions.findFirst({ 152 + where: eq(questions.sourceUri, post.uri) 153 + }) 154 + 155 + if (existingQuestion) { 156 + return { success: false, error: 'Question already imported' } 157 + } 158 + 159 + let result 160 + 161 + switch (sourceType) { 162 + case SOURCE_TYPES.BLUESKY: 163 + const bskyPost = post as BlueSkyPost 164 + result = createQuestionFromBlueSky({ 165 + content: post.content, 166 + targetDid, 167 + authorDid: post.authorDid, 168 + bskyUri: post.uri, 169 + bskyPost: bskyPost.bskyData, 170 + anonymous: false 171 + }) 172 + break 173 + 174 + case SOURCE_TYPES.STANDARD_SITE: 175 + const standardPost = post as StandardSitePost 176 + result = createQuestionFromStandardSite({ 177 + content: post.content, 178 + targetDid, 179 + authorDid: post.authorDid, 180 + standardSiteUri: post.uri, 181 + standardSiteData: standardPost.standardSiteData, 182 + anonymous: false 183 + }) 184 + break 185 + 186 + default: 187 + // Generic import for other sources 188 + result = { 189 + lexRecord: null, 190 + dbData: { 191 + content: post.content, 192 + targetDid, 193 + authorDid: post.authorDid, 194 + sourceType: sourceType, 195 + anonymous: false, 196 + sourceUri: post.uri, 197 + sourceData: JSON.stringify(post.metadata || {}), 198 + createdAt: new Date(post.createdAt) 199 + } 200 + } 201 + } 202 + 203 + // Insert into database 204 + const [inserted] = await db.insert(questions).values({ 205 + authorDid: result.dbData.authorDid!, 206 + targetDid: result.dbData.targetDid!, 207 + content: result.dbData.content!, 208 + sourceType: result.dbData.sourceType!, 209 + anonymous: result.dbData.anonymous!, 210 + createdAt: result.dbData.createdAt!, 211 + sourceUri: result.dbData.sourceUri, 212 + sourceData: result.dbData.sourceData 213 + }).returning() 214 + 215 + return { success: true, questionId: inserted.id } 216 + 217 + } catch (error) { 218 + console.error('Failed to import question from external post:', error) 219 + return { 220 + success: false, 221 + error: error instanceof Error ? error.message : 'Unknown error' 222 + } 223 + } 224 + } 225 + 226 + /** 227 + * Monitor Bluesky for mentions and questions 228 + */ 229 + export async function monitorBlueSkyMentions(userDid: string): Promise<BlueSkyPost[]> { 230 + // This would integrate with the Bluesky API to monitor mentions 231 + // For now, return empty array as placeholder 232 + console.log(`Monitoring Bluesky mentions for ${userDid}`) 233 + return [] 234 + } 235 + 236 + /** 237 + * Monitor standard.site for questions 238 + */ 239 + export async function monitorStandardSiteQuestions(userDid: string): Promise<StandardSitePost[]> { 240 + // This would integrate with standard.site to monitor questions 241 + // For now, return empty array as placeholder 242 + console.log(`Monitoring standard.site questions for ${userDid}`) 243 + return [] 244 + } 245 + 246 + /** 247 + * Get source type display information 248 + */ 249 + export function getSourceTypeInfo(sourceType: SourceType): { 250 + name: string 251 + icon: string 252 + color: string 253 + url?: string 254 + } { 255 + switch (sourceType) { 256 + case SOURCE_TYPES.BLUESKY: 257 + return { 258 + name: 'Bluesky', 259 + icon: '🦋', 260 + color: '#0085ff', 261 + url: 'https://bsky.app' 262 + } 263 + case SOURCE_TYPES.STANDARD_SITE: 264 + return { 265 + name: 'Standard.site', 266 + icon: '🌐', 267 + color: '#6366f1', 268 + url: 'https://standard.site' 269 + } 270 + case SOURCE_TYPES.MASTODON: 271 + return { 272 + name: 'Mastodon', 273 + icon: '🐘', 274 + color: '#6364ff' 275 + } 276 + case SOURCE_TYPES.NOSTR: 277 + return { 278 + name: 'Nostr', 279 + icon: '⚡', 280 + color: '#f59e0b' 281 + } 282 + case SOURCE_TYPES.ASKIMUT: 283 + default: 284 + return { 285 + name: 'Askimut', 286 + icon: '❓', 287 + color: '#10b981' 288 + } 289 + } 290 + } 291 + 292 + /** 293 + * Format source attribution text 294 + */ 295 + export function formatSourceAttribution(sourceType: SourceType, sourceUri?: string): string { 296 + const info = getSourceTypeInfo(sourceType) 297 + 298 + if (sourceType === SOURCE_TYPES.ASKIMUT) { 299 + return '' 300 + } 301 + 302 + if (sourceUri) { 303 + return `Originally from ${info.name}` 304 + } 305 + 306 + return `Via ${info.name}` 307 + } 308 + 309 + /** 310 + * Check if a source type supports real-time monitoring 311 + */ 312 + export function supportsRealTimeMonitoring(sourceType: SourceType): boolean { 313 + return [SOURCE_TYPES.BLUESKY, SOURCE_TYPES.STANDARD_SITE].includes(sourceType) 314 + } 315 + 316 + /** 317 + * Get the original post URL if available 318 + */ 319 + export function getOriginalPostUrl(sourceType: SourceType, sourceUri?: string, sourceData?: string): string | null { 320 + if (!sourceUri) return null 321 + 322 + switch (sourceType) { 323 + case SOURCE_TYPES.BLUESKY: 324 + // Convert AT-URI to web URL 325 + if (sourceUri.startsWith('at://')) { 326 + const parts = sourceUri.replace('at://', '').split('/') 327 + if (parts.length >= 3) { 328 + const [did, collection, rkey] = parts 329 + return `https://bsky.app/profile/${did}/post/${rkey}` 330 + } 331 + } 332 + return sourceUri 333 + 334 + case SOURCE_TYPES.STANDARD_SITE: 335 + return sourceUri 336 + 337 + default: 338 + return sourceUri 339 + } 340 + }
+21
src/routes/[handle].tsx
··· 13 13 submitQuestion, 14 14 toggleQuestionsOpen, 15 15 } from "~/lib/queries"; 16 + import SourceAttribution from "~/components/SourceAttribution"; 16 17 17 18 import styles from "./[handle].module.css"; 18 19 ··· 114 115 q.author?.handle || 115 116 "Unknown"}{" "} 116 117 · {formatWhen(q.createdAt)} 118 + <SourceAttribution 119 + sourceType={q.sourceType || 'askimut'} 120 + sourceUri={q.sourceUri} 121 + sourceData={q.sourceData} 122 + /> 117 123 </div> 118 124 <For each={q.answers}> 119 125 {(a) => ( ··· 122 128 <div class={styles.answerText}>{a.content}</div> 123 129 <div class={styles.questionMeta}> 124 130 {formatWhen(a.createdAt)} 131 + <SourceAttribution 132 + sourceType={a.sourceType || 'askimut'} 133 + sourceUri={a.sourceUri} 134 + sourceData={a.sourceData} 135 + /> 125 136 </div> 126 137 </div> 127 138 )} ··· 172 183 q.author?.handle || 173 184 "Unknown"}{" "} 174 185 · {formatWhen(q.createdAt)} 186 + <SourceAttribution 187 + sourceType={q.sourceType || 'askimut'} 188 + sourceUri={q.sourceUri} 189 + sourceData={q.sourceData} 190 + /> 175 191 </div> 176 192 <For each={q.answers}> 177 193 {(a) => ( ··· 180 196 <div class={styles.answerText}>{a.content}</div> 181 197 <div class={styles.questionMeta}> 182 198 {formatWhen(a.createdAt)} 199 + <SourceAttribution 200 + sourceType={a.sourceType || 'askimut'} 201 + sourceUri={a.sourceUri} 202 + sourceData={a.sourceData} 203 + /> 183 204 </div> 184 205 </div> 185 206 )}