feat(privacy): implement data export and encryption with updated policy

Add ZIP-based user data download endpoint, enable AES-256 server-side S3 encryption for all stored documents and audiobooks. Rewrite privacy policy with detailed CCPA categories, service provider disclosures (Vercel/Neon/Railway), and user rights sections. Replace privacy modal with checkbox agreement flow.
This commit is contained in:
Richard R 2026-02-17 17:16:58 -07:00
parent 8dc836cbb7
commit eeeceb8e54
18 changed files with 1313 additions and 193 deletions

View file

@ -65,7 +65,6 @@ Controls development vs production behavior in client/server code paths.
- DOCX upload/conversion availability in upload UI
- Default provider/model behavior for first-run TTS config
- DeepInfra model picker restrictions when no API key is set
- Privacy modal wording (hosted-service vs local/dev wording)
- Dev-only destructive document actions in settings
### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT

View file

@ -26,7 +26,9 @@
"@aws-sdk/s3-request-presigner": "^3.987.0",
"@headlessui/react": "^2.2.9",
"@napi-rs/canvas": "^0.1.91",
"@types/archiver": "^7.0.0",
"@vercel/analytics": "^1.6.1",
"archiver": "^7.0.1",
"better-auth": "^1.4.18",
"better-sqlite3": "^12.6.2",
"cmpstr": "^3.2.1",

View file

@ -25,9 +25,15 @@ importers:
'@napi-rs/canvas':
specifier: ^0.1.91
version: 0.1.91
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
'@vercel/analytics':
specifier: ^1.6.1
version: 1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
archiver:
specifier: ^7.0.1
version: 7.0.1
better-auth:
specifier: ^1.4.18
version: 1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -905,6 +911,10 @@ packages:
cpu: [x64]
os: [win32]
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -1069,6 +1079,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
@ -1377,6 +1391,9 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/archiver@7.0.0':
resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
@ -1424,6 +1441,9 @@ packages:
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@ -1620,6 +1640,10 @@ packages:
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
engines: {node: '>=14.6'}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@ -1637,10 +1661,22 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@ -1648,6 +1684,14 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
archiver@7.0.1:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
@ -1697,6 +1741,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
attr-accept@2.2.5:
resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==}
engines: {node: '>=4'}
@ -1713,12 +1760,28 @@ packages:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
b4a@1.7.5:
resolution: {integrity: sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==}
peerDependencies:
react-native-b4a: '*'
peerDependenciesMeta:
react-native-b4a:
optional: true
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
bare-abort-controller: '*'
peerDependenciesMeta:
bare-abort-controller:
optional: true
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -1819,12 +1882,19 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@ -1906,6 +1976,10 @@ packages:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
compromise@14.14.5:
resolution: {integrity: sha512-9qWxpOWo4crzvbdxAYDTwO6z0WljXwi6mL7CqCjAXKn7QtFijmSj7fCyAqGWldCVT2zNboMvg4kNL06drMg2Vw==}
engines: {node: '>=12.0.0'}
@ -1923,6 +1997,15 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
crc32-stream@6.0.0:
resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==}
engines: {node: '>= 14'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -2134,10 +2217,16 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
efrt@2.7.0:
resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==}
engines: {node: '>=12.0.0'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@ -2346,6 +2435,17 @@ packages:
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
@ -2359,6 +2459,9 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@ -2427,6 +2530,10 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@ -2480,6 +2587,11 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@ -2497,6 +2609,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grad-school@0.0.5:
resolution: {integrity: sha512-rXunEHF9M9EkMydTBux7+IryYXEZinRk6g8OBOGDBzo/qWJjhTxy86i5q7lQYpCLHN8Sqv1XX3OIOc7ka2gtvQ==}
engines: {node: '>=8.0.0'}
@ -2641,6 +2756,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@ -2684,6 +2803,10 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
@ -2721,6 +2844,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
@ -2769,6 +2895,10 @@ packages:
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
engines: {node: '>=0.10'}
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@ -2806,6 +2936,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
@ -2978,6 +3111,10 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@5.1.6:
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
engines: {node: '>=10'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@ -2985,6 +3122,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@ -3116,6 +3257,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@ -3140,6 +3284,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
path-scurry@1.11.1:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-webpack@0.0.3:
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
@ -3301,6 +3449,10 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@ -3402,6 +3554,13 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdir-glob@1.1.3:
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@ -3531,6 +3690,10 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
@ -3562,6 +3725,17 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
streamx@2.23.0:
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@ -3594,6 +3768,14 @@ packages:
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@ -3659,6 +3841,12 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@ -3809,6 +3997,14 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -3820,6 +4016,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@ -4732,6 +4932,15 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
string-width-cjs: string-width@4.2.3
strip-ansi: 7.1.2
strip-ansi-cjs: strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@ -4848,6 +5057,9 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@pkgjs/parseargs@0.11.0':
optional: true
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
@ -5280,6 +5492,10 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/archiver@7.0.0':
dependencies:
'@types/readdir-glob': 1.1.5
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 20.19.33
@ -5330,6 +5546,10 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 20.19.33
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@ -5495,6 +5715,10 @@ snapshots:
'@xmldom/xmldom@0.9.8': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@ -5514,10 +5738,16 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
any-promise@1.3.0: {}
anymatch@3.1.3:
@ -5525,6 +5755,29 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
archiver-utils@5.0.2:
dependencies:
glob: 10.5.0
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.23
normalize-path: 3.0.0
readable-stream: 4.7.0
archiver@7.0.1:
dependencies:
archiver-utils: 5.0.2
async: 3.2.6
buffer-crc32: 1.0.0
readable-stream: 4.7.0
readdir-glob: 1.1.3
tar-stream: 3.1.7
zip-stream: 6.0.1
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
arg@5.0.2: {}
argparse@2.0.1: {}
@ -5602,6 +5855,8 @@ snapshots:
async-function@1.0.0: {}
async@3.2.6: {}
attr-accept@2.2.5: {}
available-typed-arrays@1.0.7:
@ -5612,10 +5867,14 @@ snapshots:
axobject-query@4.1.0: {}
b4a@1.7.5: {}
bail@2.0.2: {}
balanced-match@1.0.2: {}
bare-events@2.8.2: {}
base64-js@1.5.1: {}
better-auth@1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@ -5683,6 +5942,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
buffer-crc32@1.0.0: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@ -5690,6 +5951,11 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@ -5766,6 +6032,14 @@ snapshots:
commander@4.1.1: {}
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
crc32-stream: 6.0.0
is-stream: 2.0.1
normalize-path: 3.0.0
readable-stream: 4.7.0
compromise@14.14.5:
dependencies:
efrt: 2.7.0
@ -5785,6 +6059,13 @@ snapshots:
core-util-is@1.0.3: {}
crc-32@1.2.2: {}
crc32-stream@6.0.0:
dependencies:
crc-32: 1.2.2
readable-stream: 4.7.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -5910,8 +6191,12 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
eastasianwidth@0.2.0: {}
efrt@2.7.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
empty-module@0.0.2: {}
@ -6328,6 +6613,16 @@ snapshots:
d: 1.0.2
es5-ext: 0.10.64
event-target-shim@5.0.1: {}
events-universal@1.0.1:
dependencies:
bare-events: 2.8.2
transitivePeerDependencies:
- bare-abort-controller
events@3.3.0: {}
expand-template@2.0.3: {}
ext@1.7.0:
@ -6338,6 +6633,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-fifo@1.3.2: {}
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -6413,6 +6710,11 @@ snapshots:
dependencies:
is-callable: 1.2.7
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fs-constants@1.0.0: {}
fsevents@2.3.2:
@ -6474,6 +6776,15 @@ snapshots:
dependencies:
is-glob: 4.0.3
glob@10.5.0:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 9.0.5
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
globals@14.0.0: {}
globalthis@1.0.4:
@ -6487,6 +6798,8 @@ snapshots:
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
grad-school@0.0.5: {}
has-bigints@1.1.0: {}
@ -6644,6 +6957,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@ -6684,6 +6999,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-stream@2.0.1: {}
is-string@1.1.1:
dependencies:
call-bound: 1.0.4
@ -6725,6 +7042,12 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jiti@1.21.7: {}
jose@6.1.3: {}
@ -6771,6 +7094,10 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@ -6806,6 +7133,8 @@ snapshots:
dependencies:
js-tokens: 4.0.0
lru-cache@10.4.3: {}
lru-cache@11.2.6: {}
make-cancellable-promise@1.3.2: {}
@ -7179,12 +7508,18 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
minimatch@5.1.6:
dependencies:
brace-expansion: 2.0.2
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.8: {}
minipass@7.1.2: {}
mkdirp-classic@0.5.3: {}
ms@2.1.3: {}
@ -7315,6 +7650,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parent-module@1.0.1:
@ -7339,6 +7676,11 @@ snapshots:
path-parse@1.0.7: {}
path-scurry@1.11.1:
dependencies:
lru-cache: 10.4.3
minipass: 7.1.2
path-webpack@0.0.3: {}
path2d@0.2.2:
@ -7481,6 +7823,8 @@ snapshots:
process-nextick-args@2.0.1: {}
process@0.11.10: {}
progress@2.0.3: {}
prop-types@15.8.1:
@ -7610,6 +7954,18 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdir-glob@1.1.3:
dependencies:
minimatch: 5.1.6
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@ -7817,6 +8173,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
@ -7845,6 +8203,27 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
streamx@2.23.0:
dependencies:
events-universal: 1.0.1
fast-fifo: 1.3.2
text-decoder: 1.2.7
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@5.1.2:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@ -7908,6 +8287,14 @@ snapshots:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-json-comments@2.0.1: {}
@ -7992,6 +8379,21 @@ snapshots:
inherits: 2.0.4
readable-stream: 3.6.2
tar-stream@3.1.7:
dependencies:
b4a: 1.7.5
fast-fifo: 1.3.2
streamx: 2.23.0
transitivePeerDependencies:
- bare-abort-controller
- react-native-b4a
text-decoder@1.2.7:
dependencies:
b4a: 1.7.5
transitivePeerDependencies:
- react-native-b4a
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@ -8216,12 +8618,30 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@8.1.0:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
wrappy@1.0.2: {}
xtend@4.0.2: {}
yocto-queue@0.1.0: {}
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2
compress-commons: 6.0.2
readable-stream: 4.7.0
zod@4.3.6: {}
zwitch@2.0.4: {}

View file

@ -347,7 +347,10 @@ export default async function LandingPage({
{/* ── HERO ───────────────────── */}
<section className="landing-hero">
<div className="landing-hero-badge">
Document Reader + TTS
{process.env.RICHARDRDEV_PRODUCTION === 'true'
? "Official OpenReader Instance"
: "Open Source Document Reader"
}
</div>
<h1>
Your documents, <span>read&nbsp;aloud</span>
@ -360,7 +363,7 @@ export default async function LandingPage({
<div className="landing-hero-actions">
<Link href="/app" className="landing-btn landing-btn-accent">
Open App
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4" /></svg>
</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>

View file

@ -2,8 +2,6 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth-config';
export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader WebUI',
description:
@ -18,7 +16,8 @@ export const metadata: Metadata = {
};
export default async function PrivacyPage() {
const authEnabled = isAuthEnabled();
const effectiveDate = 'February 17, 2026';
const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server';
const proto = hdrs.get('x-forwarded-proto') ?? 'https';
@ -139,48 +138,152 @@ export default async function PrivacyPage() {
<div className="privacy-body">
<h1>Privacy &amp; <span>Data Usage</span></h1>
<p className="privacy-subtitle">
How OpenReader WebUI handles your data when hosted at this instance.
Effective Date: {effectiveDate}
</p>
<div className="privacy-highlight">
This OpenReader instance is hosted at <strong>{origin}</strong>.
The operator of this service can access data that reaches the service.
The operator of this service is responsible for handling your information.
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Stored in your browser (IndexedDB)</div>
<ul>
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
<div className="privacy-highlight">
<strong>OpenReader does not sell your personal information.</strong> We do not sell data to data brokers or third parties.
We use data solely to provide and improve the reading experience.
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Sent to this service</div>
<ul>
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent) and optional alignment audio/text</li>
</ul>
<div className="space-y-8">
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
1. Information We Collect (CCPA Categories)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We collect information that identifies, relates to, describes, references, or is reasonably capable of being associated with you (&quot;<strong>Personal Information</strong>&quot;).
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Categories Collected</div>
<ul className="space-y-3">
<li>
<strong className="text-foreground">Identifiers:</strong> Email address, IP address, unique personal identifier (session token), and account name.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Authentication, security, providing service.</div>
</li>
<li>
<strong className="text-foreground">Customer Records:</strong> Uploaded documents (PDF, EPUB), reading progress, bookmarks, and preferences.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Providing core reading functionality.</div>
</li>
<li>
<strong className="text-foreground">Internet Activity:</strong> Browsing history within the app, interaction with features (Analytics).
<div className="text-xs text-muted-foreground mt-1">Source: Automatic collection. Purpose: Debugging, performance optimization.</div>
</li>
</ul>
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
2. How We Use Your Information
</h2>
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90">
<li>To provide, support, and personalize the OpenReader application.</li>
<li>To process your uploaded documents for display and text-to-speech conversion.</li>
<li>To maintain the safety, security, and integrity of our service.</li>
<li>To debug and repair errors that impair existing intended functionality.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
3. Sharing &amp; Selling
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
<strong>We do not sell your personal information.</strong>
</p>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We may &quot;share&quot; (as defined by CPRA for cross-context behavioral advertising) anonymous usage data with analytics providers solely to improve our app. You can opt-out of this sharing via the Cookie Banner, and Global Privacy Control (GPC) signals are automatically honored.
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Service Providers (Sub-processors)</div>
{process.env.RICHARDRDEV_PRODUCTION === 'true' ? (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Vercel:</strong> Hosting, Edge Functions &amp; Analytics</li>
<li className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li>
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li>
</ul>
) : (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Hosting Provider:</strong> Application Hosting &amp; Logs</li>
<li className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li>
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Optional)</li>
</ul>
)}
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
4. Your Rights (CCPA/CPRA)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
You have the following rights regarding your personal information:
</p>
<div className="privacy-card landing-panel">
<ul className="space-y-2">
<li><strong>Right to Know:</strong> You may request details about the categories and specific pieces of personal information we have collected.</li>
<li><strong>Right to Delete:</strong> You may request deletion of your personal information (via &quot;Delete Account&quot; in Settings).</li>
<li><strong>Right to Correct:</strong> You may update your account information in Settings.</li>
<li><strong>Right to Opt-Out:</strong> We do not sell data. You may opt-out of analytics &quot;sharing&quot; via our Cookie Banner.</li>
<li><strong>Right to Non-Discrimination:</strong> We will not discriminate against you for exercising your privacy rights.</li>
</ul>
</div>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2">
<strong>How to Exercise Your Rights:</strong>
</p>
<ul className="list-disc pl-5 text-sm text-foreground/90">
<li><strong>Export Data:</strong> Use the &quot;Export My Data&quot; button in Settings to download your account metadata plus object-storage-backed document and audiobook files.</li>
<li><strong>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</li>
</ul>
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
5. Data Retention
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We retain your account data and uploaded files only for as long as you maintain an account.
Uploaded documents are stored <strong>encrypted at rest (AES-256)</strong>.
Upon account deletion, all data is permanently removed from our active databases and storage buckets immediately.
</p>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
6. Contact Us
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
If you have questions or concerns about this Privacy Policy, please contact the instance administrator via the repository:
</p>
<a
href="https://github.com/richardr1126/OpenReader-WebUI/issues"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline font-medium"
>
OpenReader WebUI Issues
</a>
</section>
</div>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Stored on this service</div>
<ul>
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Auth disabled &mdash; no account or session tables</li>
)}
</ul>
</div>
<p className="privacy-note">
This site uses Vercel Analytics to collect anonymous usage data.
For maximum privacy, self-host OpenReader using the{' '}
<p className="privacy-note mt-12 pt-8 border-t border-border">
For maximum privacy, you can self-host OpenReader using the{' '}
<a
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
target="_blank"
@ -191,7 +294,7 @@ export default async function PrivacyPage() {
</p>
<Link href="/?redirect=false" className="privacy-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4"/></svg>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4" /></svg>
Back to home
</Link>
</div>

View file

@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from 'next/server';
import { PassThrough, Readable } from 'stream';
import { auth } from '@/lib/server/auth';
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema';
import { and, desc, eq, inArray } from 'drizzle-orm';
import archiver from 'archiver';
import { appendUserExportArchive } from '@/lib/server/user-export';
import { getDocumentBlobStream } from '@/lib/server/documents-blobstore';
import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks-blobstore';
import { isS3Configured } from '@/lib/server/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Export storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
if (!auth) {
return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 });
}
const session = await auth.api.getSession({
headers: req.headers,
});
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const [
prefs,
progress,
ttsUsage,
userDocs,
userAudiobooks,
] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db
.select()
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, userId))
.orderBy(desc(userDocumentProgress.updatedAt)),
db
.select()
.from(userTtsChars)
.where(eq(userTtsChars.userId, userId))
.orderBy(desc(userTtsChars.date)),
db
.select()
.from(documents)
.where(eq(documents.userId, userId))
.orderBy(desc(documents.lastModified)),
db
.select()
.from(audiobooks)
.where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)),
]);
const archive = archiver('zip', {
zlib: { level: 0 },
forceZip64: true,
});
const output = new PassThrough();
archive.pipe(output);
archive.on('warning', (warning) => {
if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('User export warning:', warning);
}
});
archive.on('error', (error) => {
output.destroy(error);
});
const bookIds = userAudiobooks.map((book: typeof audiobooks.$inferSelect) => book.id);
const allChapters = bookIds.length > 0
? await db
.select()
.from(audiobookChapters)
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, bookIds)))
: [];
const onAbort = () => {
archive.abort();
output.destroy(new Error('Export request aborted'));
};
req.signal.addEventListener('abort', onAbort, { once: true });
(async () => {
try {
const exportedAtIso = new Date().toISOString();
const profileData = {
user: session.user,
session: session.session,
exportedAt: exportedAtIso,
};
await appendUserExportArchive({
archive,
userId,
exportedAtIso,
profileData,
preferences: prefs[0] ?? null,
readingHistory: progress,
ttsUsage,
documents: userDocs,
audiobooks: userAudiobooks,
audiobookChapters: allChapters,
getDocumentBlobStream: async (documentId: string) => getDocumentBlobStream(documentId, testNamespace),
listAudiobookObjects: async (bookId: string, ownerId: string) => listAudiobookObjects(bookId, ownerId, testNamespace),
getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) =>
getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace),
});
if (!req.signal.aborted) {
await archive.finalize();
}
} catch (error) {
console.error('Export generation failed:', error);
archive.abort();
output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive'));
} finally {
req.signal.removeEventListener('abort', onAbort);
if (req.signal.aborted) {
output.destroy(new Error('Export request aborted'));
}
}
})();
return new NextResponse(Readable.toWeb(output) as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="openreader-data-${userId.slice(0, 8)}.zip"`,
'Cache-Control': 'no-store',
},
});
}

View file

@ -1,8 +1,9 @@
import "./globals.css";
import { ReactNode } from "react";
import { Metadata } from "next";
import { Analytics } from "@vercel/analytics/next";
import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
const figtree = Figtree({
subsets: ["latin"],
@ -47,7 +48,8 @@ export default function RootLayout({ children }: { children: ReactNode }) {
</head>
<body className="antialiased">
{children}
<Analytics />
<CookieConsentBanner />
<ConsentAwareAnalytics />
</body>
</html>
);

View file

@ -0,0 +1,33 @@
'use client';
import { useEffect, useState } from 'react';
import { Analytics } from '@vercel/analytics/next';
import { CONSENT_CHANGED_EVENT, disableAnalytics, getConsentState } from '@/lib/analytics';
export function ConsentAwareAnalytics() {
const [enabled, setEnabled] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
const sync = () => {
const allowAnalytics = getConsentState() !== 'declined';
setEnabled(allowAnalytics);
if (!allowAnalytics) {
disableAnalytics();
}
setReady(true);
};
sync();
window.addEventListener(CONSENT_CHANGED_EVENT, sync);
window.addEventListener('storage', sync);
return () => {
window.removeEventListener(CONSENT_CHANGED_EVENT, sync);
window.removeEventListener('storage', sync);
};
}, []);
if (!ready || !enabled) return null;
return <Analytics />;
}

View file

@ -0,0 +1,73 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/analytics';
import { Transition } from '@headlessui/react';
export function CookieConsentBanner() {
const [show, setShow] = useState(false);
useEffect(() => {
// Check consent on mount
const consent = getConsentState();
if (consent === 'undecided') {
// Small delay to prevent layout thrashing on load
const timer = setTimeout(() => setShow(true), 1000);
return () => clearTimeout(timer);
}
}, []);
const handleAccept = () => {
setConsentState('accepted');
setShow(false);
};
const handleDecline = () => {
setConsentState('declined');
setShow(false);
};
if (!show) return null;
return (
<Transition
as="div"
show={show}
enter="transition ease-out duration-300 transform"
enterFrom="translate-y-full opacity-0"
enterTo="translate-y-0 opacity-100"
leave="transition ease-in duration-200 transform"
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-full opacity-0"
className="fixed bottom-0 left-0 right-0 z-[60] p-4 md:p-6"
>
<div className="mx-auto max-w-5xl rounded-xl border border-offbase bg-base p-5 shadow-2xl md:flex md:items-center md:justify-between md:gap-8">
<div className="mb-4 md:mb-0">
<h3 className="mb-2 text-lg font-bold">
🍪 We use cookies
</h3>
<p className="text-sm leading-relaxed text-foreground/90">
We use strictly necessary cookies for authentication and optional cookies for anonymous analytics
to improve the app. See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
</p>
</div>
<div className="flex flex-col gap-3 min-w-fit sm:flex-row">
<button
onClick={handleDecline}
className="whitespace-nowrap rounded-lg px-4 py-2.5 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
>
Decline Non-Essential
</button>
<button
onClick={handleAccept}
className="whitespace-nowrap rounded-lg bg-accent px-6 py-2.5 text-sm font-bold text-background hover:bg-secondary-accent shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-transform hover:scale-[1.02]"
>
Accept All
</button>
</div>
</div>
</Transition>
);
}

View file

@ -11,120 +11,37 @@ import {
} from '@headlessui/react';
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
interface PrivacyModalProps {
onAccept?: () => void;
authEnabled?: boolean;
}
function PrivacyModalBody({
origin,
authEnabled,
}: {
origin: string;
authEnabled: boolean;
}) {
if (!isDev) {
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service operator visibility</div>
<div className="mt-2">
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
of this service can access data that reaches the service.
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this service</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent) and optional alignment audio/text</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this service</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Auth disabled: no account/session tables</li>
)}
</ul>
</div>
<div className="text-xs text-muted">
This site uses Vercel Analytics to collect anonymous usage data. For maximum privacy, use self-hosted mode.
</div>
</div>
);
}
function PrivacyModalBody({ origin }: { origin: string }) {
return (
<div className="mt-4 space-y-4 text-sm text-foreground/90">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Server owner visibility</div>
<div className="mt-2">
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
of this server can access data that reaches the server.
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service Operator</div>
<div className="mt-1">
This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>.
</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Document and preview cache</li>
<li>Settings + privacy acceptance</li>
<li>Reading progress (local fallback)</li>
</ul>
</div>
<p className="leading-relaxed">
We value your privacy. This application uses strictly necessary cookies for authentication
and anonymous analytics to improve performance. Your documents are stored securely and encrypted at rest.
</p>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this server</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded files + metadata (PDF/EPUB/HTML; DOCX converted server-side)</li>
<li>TTS text + settings (optional custom API key/base URL)</li>
<li>Request metadata (IP/user agent; device ID if rate limiting is enabled) and optional alignment audio/text</li>
</ul>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this server</div>
<ul className="mt-2 list-disc space-y-1 pl-5">
<li>Uploaded docs, metadata, and preview images</li>
<li>Generated audiobooks and temporary TTS cache</li>
{authEnabled ? (
<li>Account/session data, synced preferences/progress, and rate-limit counters</li>
) : (
<li>Auth disabled: no account/session tables</li>
)}
</ul>
</div>
<div className="text-xs text-muted">
Tip: If you are behind a reverse proxy, the proxy operator may also have access to request logs.
</div>
<p className="leading-relaxed">
For full details on data collection, processing, and your rights, please review our complete Privacy Policy.
</p>
</div>
);
}
export function PrivacyModal({ onAccept, authEnabled = false }: PrivacyModalProps) {
export function PrivacyModal({ onAccept }: PrivacyModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [origin, setOrigin] = useState('');
const [agreed, setAgreed] = useState(false);
const checkPrivacyAccepted = useCallback(async () => {
const config = await getAppConfig();
@ -187,19 +104,43 @@ export function PrivacyModal({ onAccept, authEnabled = false }: PrivacyModalProp
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<PrivacyModalBody origin={origin} />
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleAccept}
>
I Understand
</Button>
<div className="mt-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
<div className="flex h-6 items-center">
<input
id="privacy-agree"
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base"
/>
</div>
<div className="text-sm leading-6">
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
I have read and agree to the
</label>{' '}
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
Privacy Policy
</a>
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
disabled={!agreed}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out enabled:hover:scale-[1.04]"
onClick={handleAccept}
>
Continue
</Button>
</div>
</div>
</DialogPanel>
</TransitionChild>
@ -221,7 +162,7 @@ export function showPrivacyModal(options?: { authEnabled?: boolean }): void {
document.body.appendChild(container);
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const authEnabled = Boolean(options?.authEnabled);
void options;
// Import React and render the popup
import('react-dom/client').then(({ createRoot }) => {
@ -277,7 +218,7 @@ export function showPrivacyModal(options?: { authEnabled?: boolean }): void {
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} authEnabled={authEnabled} />
<PrivacyModalBody origin={origin} />
<div className="mt-6 flex justify-end">
<Button

View file

@ -845,7 +845,22 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div>
</div>
<div className="space-y-3 pt-2">
<div className="space-y-2">
{session?.user && (
<Button
onClick={() => {
window.open('/api/user/export', '_blank');
}}
className="w-full flex flex-col items-center justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
>
<span>Export My Data</span>
<span className="text-xs text-muted font-normal mt-0.5">Download metadata, uploaded documents, and generated audiobooks (ZIP)</span>
</Button>
)}
{session?.user && !session.user.isAnonymous ? (
<>
<Button
@ -859,7 +874,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button>
<div className="pt-4 border-t border-offbase">
<h4 className="text-sm font-medium text-red-500 mb-2">Danger Zone</h4>
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
className="w-full justify-center rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm

View file

@ -1184,22 +1184,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
if (!estimate || estimate.sentenceIndex !== sentenceIndex) return;
if (!visualPageChangeHandlerRef.current) return;
const duration = this.duration();
if (!duration || !Number.isFinite(duration)) return;
const duration = this.duration();
if (!duration || !Number.isFinite(duration)) return;
const delayMs = duration * estimate.fraction * 1000;
if (delayMs <= 0 || delayMs >= duration * 1000) return;
const delayMs = duration * estimate.fraction * 1000;
if (delayMs <= 0 || delayMs >= duration * 1000) return;
if (pageTurnTimeoutRef.current) {
clearTimeout(pageTurnTimeoutRef.current);
}
if (pageTurnTimeoutRef.current) {
clearTimeout(pageTurnTimeoutRef.current);
}
pageTurnTimeoutRef.current = setTimeout(() => {
if (!isPlaying) return;
const currentEstimate = pageTurnEstimateRef.current;
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
visualPageChangeHandlerRef.current?.(currentEstimate.location);
}, delayMs);
pageTurnTimeoutRef.current = setTimeout(() => {
if (!isPlaying) return;
const currentEstimate = pageTurnEstimateRef.current;
if (!currentEstimate || currentEstimate.sentenceIndex !== sentenceIndex) return;
visualPageChangeHandlerRef.current?.(currentEstimate.location);
}, delayMs);
},
onplay: function (this: Howl) {
setIsProcessing(false);
@ -1308,26 +1308,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
this.unload();
setIsPlaying(false);
toast.error('Audio loading failed after retries. Moving to next sentence...', {
id: 'audio-load-error',
duration: 2000,
});
toast.error('Audio loading failed after retries. Moving to next sentence...', {
id: 'audio-load-error',
duration: 2000,
});
advance();
}
},
onend: function (this: Howl) {
clearRateWatchdog();
this.unload();
setActiveHowl(null);
if (pageTurnTimeoutRef.current) {
clearTimeout(pageTurnTimeoutRef.current);
pageTurnTimeoutRef.current = null;
}
if (isPlaying) {
advance();
}
},
advance();
}
},
onend: function (this: Howl) {
clearRateWatchdog();
this.unload();
setActiveHowl(null);
if (pageTurnTimeoutRef.current) {
clearTimeout(pageTurnTimeoutRef.current);
pageTurnTimeoutRef.current = null;
}
if (isPlaying) {
advance();
}
},
onstop: function (this: Howl) {
clearRateWatchdog();
setIsProcessing(false);

76
src/lib/analytics.ts Normal file
View file

@ -0,0 +1,76 @@
'use client';
/**
* Analytics Consent Management
*
* Handles state for the Cookie Consent Banner and Vercel Analytics opt-out.
*/
const CONSENT_KEY = 'cookie-consent';
export const CONSENT_CHANGED_EVENT = 'openreader:consentChanged';
export type ConsentState = 'undecided' | 'accepted' | 'declined';
export function getConsentState(): ConsentState {
if (typeof window === 'undefined') return 'undecided';
// Check for Global Privacy Control (GPC)
if (window.navigator?.globalPrivacyControl) {
return 'declined';
}
const val = localStorage.getItem(CONSENT_KEY);
if (val === 'accepted' || val === 'declined') return val;
return 'undecided';
}
export function setConsentState(state: 'accepted' | 'declined') {
if (typeof window === 'undefined') return;
// GPC must be treated as an opt-out signal regardless of prior/local choice.
const effectiveState = window.navigator?.globalPrivacyControl ? 'declined' : state;
localStorage.setItem(CONSENT_KEY, effectiveState);
window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT));
// Apply the choice immediately
if (effectiveState === 'declined') {
disableAnalytics();
} else {
enableAnalytics();
}
}
declare global {
interface Navigator {
globalPrivacyControl?: boolean;
}
interface Window {
va?: {
disable?: () => void;
enable?: () => void;
} | ((...args: unknown[]) => void);
}
}
/**
* Opt-out of Vercel Analytics.
* This sets the flag that @vercel/analytics respects.
*/
export function disableAnalytics() {
if (typeof window === 'undefined') return;
const va = window.va;
if (va && typeof va === 'object' && typeof va.disable === 'function') {
va.disable();
}
}
/**
* Re-enable Vercel Analytics (Opt-in).
*/
export function enableAnalytics() {
if (typeof window === 'undefined') return;
const va = window.va;
if (va && typeof va === 'object' && typeof va.enable === 'function') {
va.enable();
}
}

View file

@ -20,6 +20,14 @@ export type AudiobookBlobObject = {
eTag: string | null;
};
export type AudiobookBlobBody =
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
| Uint8Array
| ArrayBuffer
| ArrayBufferView
| { transformToByteArray: () => Promise<Uint8Array> };
function sanitizeNamespace(namespace: string | null): string | null {
if (!namespace) return null;
if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null;
@ -173,12 +181,17 @@ export async function getAudiobookObjectBuffer(bookId: string, userId: string, f
return bodyToBuffer(res.Body);
}
export async function getAudiobookObjectStream(bookId: string, userId: string, fileName: string, namespace: string | null): Promise<unknown> {
export async function getAudiobookObjectStream(
bookId: string,
userId: string,
fileName: string,
namespace: string | null,
): Promise<AudiobookBlobBody> {
const cfg = getS3Config();
const client = getS3Client();
const key = audiobookKey(bookId, userId, fileName, namespace);
const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key }));
return res.Body;
return res.Body as AudiobookBlobBody;
}
export async function putAudiobookObject(
@ -199,6 +212,7 @@ export async function putAudiobookObject(
Key: key,
Body: body,
ContentType: contentType,
ServerSideEncryption: 'AES256',
...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}),
}),
);

View file

@ -117,6 +117,7 @@ export async function putDocumentPreviewBuffer(
Key: key,
Body: bytes,
ContentType: DOCUMENT_PREVIEW_CONTENT_TYPE,
ServerSideEncryption: 'AES256',
...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}),
}),
);

View file

@ -22,6 +22,14 @@ function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream {
return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function';
}
export type DocumentBlobBody =
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
| Uint8Array
| ArrayBuffer
| ArrayBufferView
| { transformToByteArray: () => Promise<Uint8Array> };
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
@ -87,6 +95,7 @@ export async function presignPut(
Key: key,
ContentType: normalizedType,
IfNoneMatch: '*',
ServerSideEncryption: 'AES256',
});
const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 });
@ -95,6 +104,7 @@ export async function presignPut(
headers: {
'Content-Type': normalizedType,
'If-None-Match': '*',
'x-amz-server-side-encryption': 'AES256',
},
};
}
@ -146,6 +156,19 @@ export async function getDocumentBlob(id: string, namespace: string | null): Pro
return bodyToBuffer(res.Body);
}
export async function getDocumentBlobStream(id: string, namespace: string | null): Promise<DocumentBlobBody> {
const cfg = getS3Config();
const client = getS3Client();
const key = documentKey(id, namespace);
const res = await client.send(
new GetObjectCommand({
Bucket: cfg.bucket,
Key: key,
}),
);
return res.Body as DocumentBlobBody;
}
export async function presignGet(
id: string,
namespace: string | null,
@ -180,6 +203,7 @@ export async function putDocumentBlob(
Body: body,
ContentType: contentType,
IfNoneMatch: '*',
ServerSideEncryption: 'AES256',
}),
);
}

View file

@ -0,0 +1,244 @@
import type { Archiver } from 'archiver';
import { Readable } from 'stream';
import type { ReadableStream as NodeReadableStream } from 'stream/web';
import { decodeChapterFileName } from '@/lib/server/audiobook';
export type ExportBlobBody =
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
| Uint8Array
| ArrayBuffer
| ArrayBufferView
| { transformToByteArray: () => Promise<Uint8Array> };
type ExportIssueScope = 'document' | 'audiobook' | 'audiobook_list';
type ExportIssue = {
scope: ExportIssueScope;
id: string;
fileName?: string;
message: string;
};
type ExportDocument = {
id: string;
name: string;
[key: string]: unknown;
};
type ExportAudiobook = {
id: string;
[key: string]: unknown;
};
type ExportAudiobookChapter = {
bookId: string;
[key: string]: unknown;
};
type ExportAudiobookObject = {
fileName: string;
[key: string]: unknown;
};
export type AppendUserExportArchiveInput = {
archive: Archiver;
userId: string;
exportedAtIso: string;
profileData: unknown;
preferences: unknown | null;
readingHistory: unknown[];
ttsUsage: unknown[];
documents: ExportDocument[];
audiobooks: ExportAudiobook[];
audiobookChapters: ExportAudiobookChapter[];
getDocumentBlobStream: (documentId: string) => Promise<ExportBlobBody>;
listAudiobookObjects: (bookId: string, userId: string) => Promise<ExportAudiobookObject[]>;
getAudiobookObjectStream: (bookId: string, userId: string, fileName: string) => Promise<ExportBlobBody>;
};
function isNodeReadableStream(value: unknown): value is Readable {
return value instanceof Readable;
}
function isWebReadableStream(value: unknown): value is ReadableStream<Uint8Array> {
return !!value && typeof value === 'object' && 'getReader' in value && typeof (value as ReadableStream<Uint8Array>).getReader === 'function';
}
function stripControlChars(value: string): string {
return value.replace(/[\u0000-\u001F\u007F]/g, '');
}
function toSafePathSegment(value: string, fallback: string): string {
const stripped = stripControlChars(String(value ?? ''));
const withoutSlashes = stripped.replace(/[\/\\]/g, '_').trim();
const withoutTraversal = withoutSlashes.replace(/\.\.+/g, '_');
const collapsed = withoutTraversal.replace(/\s+/g, ' ').replace(/\.+$/, '').slice(0, 240);
return collapsed.length > 0 ? collapsed : fallback;
}
function normalizeErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error.length > 0) return error;
return 'Unknown error';
}
function appendJson(archive: Archiver, name: string, data: unknown): void {
archive.append(JSON.stringify(data, null, 2), { name });
}
async function bodyToNodeReadable(body: ExportBlobBody): Promise<Readable> {
if (isNodeReadableStream(body)) return body;
if (isWebReadableStream(body)) {
return Readable.fromWeb(body as unknown as NodeReadableStream);
}
if (body instanceof Uint8Array) {
return Readable.from([body]);
}
if (ArrayBuffer.isView(body)) {
return Readable.from([new Uint8Array(body.buffer, body.byteOffset, body.byteLength)]);
}
if (body instanceof ArrayBuffer) {
return Readable.from([new Uint8Array(body)]);
}
if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) {
const bytes = await body.transformToByteArray();
return Readable.from([bytes]);
}
throw new Error('Unsupported blob body type');
}
export function isPersistedAudiobookExportFileName(fileName: string): boolean {
if (fileName === 'audiobook.meta.json') return true;
if (fileName === 'complete.mp3' || fileName === 'complete.m4b') return true;
if (/^complete\.(mp3|m4b)\.manifest\.json$/i.test(fileName)) return true;
return decodeChapterFileName(fileName) !== null;
}
export async function appendUserExportArchive(input: AppendUserExportArchiveInput): Promise<void> {
const {
archive,
userId,
exportedAtIso,
profileData,
preferences,
readingHistory,
ttsUsage,
documents,
audiobooks,
audiobookChapters,
getDocumentBlobStream,
listAudiobookObjects,
getAudiobookObjectStream,
} = input;
const issues: ExportIssue[] = [];
let documentFilesExported = 0;
let audiobookFilesExported = 0;
appendJson(archive, 'profile.json', profileData);
if (preferences) {
appendJson(archive, 'preferences.json', preferences);
}
appendJson(archive, 'reading_history.json', readingHistory);
appendJson(archive, 'tts_usage.json', ttsUsage);
appendJson(archive, 'library_documents.json', documents);
const chaptersByBookId = new Map<string, ExportAudiobookChapter[]>();
for (const chapter of audiobookChapters) {
const existing = chaptersByBookId.get(chapter.bookId) ?? [];
existing.push(chapter);
chaptersByBookId.set(chapter.bookId, existing);
}
const audiobooksWithChapters = audiobooks.map((book) => ({
...book,
chapters: chaptersByBookId.get(book.id) ?? [],
}));
appendJson(archive, 'library_audiobooks.json', audiobooksWithChapters);
for (const doc of documents) {
const documentId = toSafePathSegment(doc.id, 'document');
const fileName = toSafePathSegment(doc.name || `${doc.id}.bin`, `${documentId}.bin`);
const entryName = `files/documents/${documentId}/${fileName}`;
try {
const body = await getDocumentBlobStream(doc.id);
const stream = await bodyToNodeReadable(body);
archive.append(stream, { name: entryName });
documentFilesExported += 1;
} catch (error) {
issues.push({
scope: 'document',
id: doc.id,
fileName,
message: normalizeErrorMessage(error),
});
}
}
for (const book of audiobooks) {
let objects: ExportAudiobookObject[] = [];
try {
objects = await listAudiobookObjects(book.id, userId);
} catch (error) {
issues.push({
scope: 'audiobook_list',
id: book.id,
message: normalizeErrorMessage(error),
});
continue;
}
const persisted = objects
.filter((object) => typeof object.fileName === 'string' && isPersistedAudiobookExportFileName(object.fileName))
.sort((a, b) => String(a.fileName).localeCompare(String(b.fileName)));
for (const object of persisted) {
const safeBookId = toSafePathSegment(book.id, 'book');
const safeFileName = toSafePathSegment(object.fileName, 'file.bin');
const entryName = `files/audiobooks/${safeBookId}/${safeFileName}`;
try {
const body = await getAudiobookObjectStream(book.id, userId, object.fileName);
const stream = await bodyToNodeReadable(body);
archive.append(stream, { name: entryName });
audiobookFilesExported += 1;
} catch (error) {
issues.push({
scope: 'audiobook',
id: book.id,
fileName: object.fileName,
message: normalizeErrorMessage(error),
});
}
}
}
const manifest = {
formatVersion: 2,
exportedAt: exportedAtIso,
userId,
scope: 'owned',
counts: {
documentsMetadata: documents.length,
audiobooksMetadata: audiobooks.length,
audiobookChaptersMetadata: audiobookChapters.length,
documentFiles: documentFilesExported,
audiobookFiles: audiobookFilesExported,
issues: issues.length,
},
includes: {
metadata: true,
documentFiles: true,
audiobookFiles: true,
filesystemSources: false,
},
};
appendJson(archive, 'export_manifest.json', manifest);
if (issues.length > 0) {
appendJson(archive, 'export_issues.json', issues);
}
}

View file

@ -177,6 +177,15 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
// Mock the TTS API so tests don't hit the real TTS service.
await ensureTtsRouteMock(page);
// Pre-seed consent to prevent the cookie banner from blocking interactions.
await page.addInitScript(() => {
try {
window.localStorage.setItem('cookie-consent', 'accepted');
} catch {
// ignore storage errors in restricted contexts
}
});
// If auth is enabled, establish an anonymous session BEFORE navigation.
// This keeps each test self-contained (no shared storageState) while ensuring
// server routes that require auth don't intermittently 401 during app startup.
@ -194,9 +203,14 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
// Privacy modal should come first in onboarding.
// Be tolerant if it's already accepted (e.g., reused context).
const privacyBtn = page.getByRole('button', { name: 'I Understand' });
const privacyBtn = page.getByRole('button', { name: /Continue|I Understand/i });
try {
await expect(privacyBtn).toBeVisible({ timeout: 5000 });
const privacyAgree = page.locator('#privacy-agree');
if ((await privacyAgree.count()) > 0) {
await privacyAgree.check();
}
await expect(privacyBtn).toBeEnabled({ timeout: 5000 });
await privacyBtn.click();
// HeadlessUI keeps dialogs in the DOM during leave transitions; "hidden" is enough
// (we mainly need to ensure it no longer blocks pointer events).
@ -205,6 +219,12 @@ export async function setupTest(page: Page, testInfo?: TestInfo) {
// ignore
}
// Fallback: if the banner still appears, dismiss it before continuing.
const cookieAcceptBtn = page.getByRole('button', { name: 'Accept All' });
if (await cookieAcceptBtn.isVisible().catch(() => false)) {
await cookieAcceptBtn.click();
}
// Settings modal should appear after privacy acceptance on first visit.
const saveBtn = page.getByRole('button', { name: 'Save' });
await expect(saveBtn).toBeVisible({ timeout: 10000 });
@ -488,7 +508,7 @@ export async function waitForDocumentListHintPersist(page: Page, expected: boole
req.onerror = () => reject(req.error);
});
const db = await openDb();
const readConfig = () => new Promise<any>((resolve, reject) => {
const readConfig = () => new Promise<unknown>((resolve, reject) => {
const tx = db.transaction(['app-config'], 'readonly');
const store = tx.objectStore('app-config');
const getReq = store.get('singleton');
@ -497,10 +517,11 @@ export async function waitForDocumentListHintPersist(page: Page, expected: boole
});
const item = await readConfig();
db.close();
if (!item || typeof item.documentListState !== 'object') return false;
const state = item.documentListState;
if (!state || typeof state.showHint !== 'boolean') return false;
return state.showHint === exp;
if (!item || typeof item !== 'object') return false;
const state = (item as { documentListState?: unknown }).documentListState;
if (!state || typeof state !== 'object') return false;
const showHint = (state as { showHint?: unknown }).showHint;
return typeof showHint === 'boolean' && showHint === exp;
} catch {
return false;
}