From 4f2102b9b5ff4b690c5da3fdb770f7334b2be5de Mon Sep 17 00:00:00 2001 From: Mat Manna Date: Fri, 7 Aug 2026 18:18:02 -0400 Subject: [PATCH] fix: refine permissions and tests --- bun.lock | 88 ++++++++++ package-lock.json | 318 +++++++++++++++++++++--------------- src/api/routers/apiKeys.ts | 32 ++-- src/api/routers/channels.ts | 21 ++- src/app.ts | 156 ++++++++++++++---- src/db/queries.ts | 19 ++- src/web/navbar.tsx | 20 ++- tests/access.test.ts | 82 ++++++++++ tests/ephemeral.test.ts | 62 +++++++ tests/helpers.test.ts | 68 ++++++++ tests/queries.test.ts | 77 +++++++++ tests/sanitization.test.ts | 29 ++++ tests/schema-drift.test.ts | 36 ++++ 13 files changed, 818 insertions(+), 190 deletions(-) create mode 100644 tests/access.test.ts create mode 100644 tests/ephemeral.test.ts create mode 100644 tests/helpers.test.ts create mode 100644 tests/queries.test.ts create mode 100644 tests/sanitization.test.ts create mode 100644 tests/schema-drift.test.ts diff --git a/bun.lock b/bun.lock index f9c7ed8..0e8d0e5 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@orpc/client": "^1.14.7", "@orpc/openapi": "^1.14.7", "@orpc/server": "^1.14.7", + "@orpc/zod": "^1.14.7", "@preact/signals": "^2.10.0", "@slack/events-api": "^3.0.1", "@slack/web-api": "^7.18.0", @@ -32,6 +33,7 @@ "@preact/preset-vite": "^2.10.6", "@types/bun": "latest", "drizzle-kit": "^0.31.10", + "typescript": "^5.9.3", "vite": "^8.1.5", "wrangler": "^4.113.0", }, @@ -265,6 +267,8 @@ "@orpc/interop": ["@orpc/interop@1.14.7", "", {}, "sha512-2evzgNl6CMh5MZ3xHLHYX01siGZCQu9E6mcuPTSM3G7wQKG9ozvtGjAiH6dBWKfNUQRAavjJpQVoEW9LJ5VICA=="], + "@orpc/json-schema": ["@orpc/json-schema@1.14.15", "", { "dependencies": { "@orpc/contract": "1.14.15", "@orpc/interop": "1.14.15", "@orpc/openapi": "1.14.15", "@orpc/server": "1.14.15", "@orpc/shared": "1.14.15", "json-schema-typed": "^8.0.2" } }, "sha512-nwu1lL2aQJpdG0DvNYhSOTg9bJE2Wc1HQaIsCVZBjEbl/nP6Ek0lVGGf8b0cpkQ3dsoHkteQlb/hmqjp/YFqUA=="], + "@orpc/openapi": ["@orpc/openapi@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/contract": "1.14.7", "@orpc/interop": "1.14.7", "@orpc/openapi-client": "1.14.7", "@orpc/server": "1.14.7", "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-elTxq+tWqQvUaU3nsGQclsJ5qkxUJe3lZxKQyCqmrt7r3oG3NMa0W8efWzXVrxJ6zMIav1l5lTKHaU/QIv6NOw=="], "@orpc/openapi-client": ["@orpc/openapi-client@1.14.7", "", { "dependencies": { "@orpc/client": "1.14.7", "@orpc/contract": "1.14.7", "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7" } }, "sha512-TRNFa1q/4OkhygvRqzlH7AVfSR52xbBNSWVIvEeJoYX4lZvFEXqlSvyiejQpoR5gjhqDvFMe3OAUMCpKvYP0Kw=="], @@ -285,6 +289,8 @@ "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.7", "", { "dependencies": { "@orpc/shared": "1.14.7", "@orpc/standard-server": "1.14.7" } }, "sha512-5YzX7goqeg5cZv+R58SCZHKo0Jk9CiHgepzJYM5oQYoLbdqoxGC4yZG12sqwN/CTX7nZkE2nnbfk6l3+YD/reg=="], + "@orpc/zod": ["@orpc/zod@1.14.15", "", { "dependencies": { "@orpc/json-schema": "1.14.15", "@orpc/openapi": "1.14.15", "@orpc/shared": "1.14.15", "escape-string-regexp": "^5.0.0", "wildcard-match": "^5.1.4" }, "peerDependencies": { "@orpc/contract": "1.14.15", "@orpc/server": "1.14.15", "zod": ">=3.25.0" } }, "sha512-NumqXhbaOLGGCB/ynbx7imtxg4vlnu0hj3SZpc9GqN63ZSfq3UX6kWcjHNgJp12++DJ9EA1aXwyG2ZnA39vmcQ=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -1065,6 +1071,8 @@ "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="], + "workerd": ["workerd@1.20260722.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260722.1", "@cloudflare/workerd-darwin-arm64": "1.20260722.1", "@cloudflare/workerd-linux-64": "1.20260722.1", "@cloudflare/workerd-linux-arm64": "1.20260722.1", "@cloudflare/workerd-windows-64": "1.20260722.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ=="], "wrangler": ["wrangler@4.113.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260721.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260721.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260721.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA=="], @@ -1113,6 +1121,22 @@ "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@orpc/json-schema/@orpc/contract": ["@orpc/contract@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/shared": "1.14.15", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-quJuFpBlIPBl674AmBd9hgvkJMjrz0UtvyOx6r9dx6Yk9LWPEiyCbPJ4orGnvIl1xCXwF8mrFITKxGEqrJ4WMQ=="], + + "@orpc/json-schema/@orpc/interop": ["@orpc/interop@1.14.15", "", {}, "sha512-9wFrBfPUz5+eHjEQg0Xy7pwouPPFKFNk413vXN/VhXlKhb8mSxOitTfmlE0rHmcTFYaBuGadVvFt6IOZTZjJEA=="], + + "@orpc/json-schema/@orpc/openapi": ["@orpc/openapi@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/interop": "1.14.15", "@orpc/openapi-client": "1.14.15", "@orpc/server": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-GXwT+zxa13br4TzveFZGNMC6POeE62ZtCOM+CQRNLlQ8bScTwqsHU2TTpSnPkGr8h2obXi24XLpozvTpSJzpPg=="], + + "@orpc/json-schema/@orpc/server": ["@orpc/server@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/interop": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-aws-lambda": "1.14.15", "@orpc/standard-server-fastify": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-node": "1.14.15", "@orpc/standard-server-peer": "1.14.15", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-vtIku/3KLwm6HCThaDPT9n+RHkhMRBAhNLw3NQmfNN/fOC7sYudLTt9g0isfXHDG/OaHp+x1528niTOnRrOeVQ=="], + + "@orpc/json-schema/@orpc/shared": ["@orpc/shared@1.14.15", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-lECt2hPpe38nv2T3KpKgi8akCBXgL2KnJy8YslNgiP66WVrkdgV2UoxZU5BghJ3yCeE1KwQ+evv+JxdUuiJmXQ=="], + + "@orpc/zod/@orpc/contract": ["@orpc/contract@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/shared": "1.14.15", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-quJuFpBlIPBl674AmBd9hgvkJMjrz0UtvyOx6r9dx6Yk9LWPEiyCbPJ4orGnvIl1xCXwF8mrFITKxGEqrJ4WMQ=="], + + "@orpc/zod/@orpc/openapi": ["@orpc/openapi@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/interop": "1.14.15", "@orpc/openapi-client": "1.14.15", "@orpc/server": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "json-schema-typed": "^8.0.2", "rou3": "^0.7.12" } }, "sha512-GXwT+zxa13br4TzveFZGNMC6POeE62ZtCOM+CQRNLlQ8bScTwqsHU2TTpSnPkGr8h2obXi24XLpozvTpSJzpPg=="], + + "@orpc/zod/@orpc/shared": ["@orpc/shared@1.14.15", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-lECt2hPpe38nv2T3KpKgi8akCBXgL2KnJy8YslNgiP66WVrkdgV2UoxZU5BghJ3yCeE1KwQ+evv+JxdUuiJmXQ=="], + "@prefresh/vite/@rollup/pluginutils": ["@rollup/pluginutils@4.2.1", "", { "dependencies": { "estree-walker": "^2.0.1", "picomatch": "^2.2.2" } }, "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ=="], "@slack/events-api/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1215,6 +1239,40 @@ "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@orpc/json-schema/@orpc/contract/@orpc/client": ["@orpc/client@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-peer": "1.14.15" } }, "sha512-lA/JcS/QT4HjPFIc7QSomEuup6VGWaAqK7sn7xejKuYaSAPifBomaoVNzNnuHHrFhuasNpn6+Jok3VcQYX+pDg=="], + + "@orpc/json-schema/@orpc/openapi/@orpc/client": ["@orpc/client@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-peer": "1.14.15" } }, "sha512-lA/JcS/QT4HjPFIc7QSomEuup6VGWaAqK7sn7xejKuYaSAPifBomaoVNzNnuHHrFhuasNpn6+Jok3VcQYX+pDg=="], + + "@orpc/json-schema/@orpc/openapi/@orpc/openapi-client": ["@orpc/openapi-client@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-fP5kUqgYcY5GnxkZVqN8I4Rdm1rVG81K/QokQJ1RsXwRpSSLiPTHxZ0UFDCS33uZwyKFosDovySTTEFfKGrL6A=="], + + "@orpc/json-schema/@orpc/openapi/@orpc/standard-server": ["@orpc/standard-server@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15" } }, "sha512-Pd++Dgk8L4ozkAe8ish2612lYChjHJLqzNqJz6LwRD6LJJFU848oHhZz4+4FSwQod4p/z3IAEB1dnK8yOyLpVw=="], + + "@orpc/json-schema/@orpc/server/@orpc/client": ["@orpc/client@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-peer": "1.14.15" } }, "sha512-lA/JcS/QT4HjPFIc7QSomEuup6VGWaAqK7sn7xejKuYaSAPifBomaoVNzNnuHHrFhuasNpn6+Jok3VcQYX+pDg=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server": ["@orpc/standard-server@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15" } }, "sha512-Pd++Dgk8L4ozkAe8ish2612lYChjHJLqzNqJz6LwRD6LJJFU848oHhZz4+4FSwQod4p/z3IAEB1dnK8yOyLpVw=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-node": "1.14.15" } }, "sha512-Z1NwP4+Gxc4qBgAm+99HXjkI5N2xDYhUgFtdjBqUQozArpPI9fmtTR3zB3/ep6CO+uOhLIZ8SSy+/3edkYifgg=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-node": "1.14.15" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-3Gh5mEL0GQn4ev8juZqKb6yw0nu86Rr6LB3ksPqaKvBHMYc3pVlcvRz4Livna3amxPUe2HOywA39J8pC9+W0DQ=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15" } }, "sha512-5hw5+j4urgHtCs/Qq1IBTbWMswt8m+t3HqoUD72Z17tDvBhl/ESZo4Tv83791BoTOmUkW5xYvbxJozDG3cNNtg=="], + + "@orpc/json-schema/@orpc/server/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + + "@orpc/zod/@orpc/contract/@orpc/client": ["@orpc/client@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-peer": "1.14.15" } }, "sha512-lA/JcS/QT4HjPFIc7QSomEuup6VGWaAqK7sn7xejKuYaSAPifBomaoVNzNnuHHrFhuasNpn6+Jok3VcQYX+pDg=="], + + "@orpc/zod/@orpc/openapi/@orpc/client": ["@orpc/client@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-peer": "1.14.15" } }, "sha512-lA/JcS/QT4HjPFIc7QSomEuup6VGWaAqK7sn7xejKuYaSAPifBomaoVNzNnuHHrFhuasNpn6+Jok3VcQYX+pDg=="], + + "@orpc/zod/@orpc/openapi/@orpc/interop": ["@orpc/interop@1.14.15", "", {}, "sha512-9wFrBfPUz5+eHjEQg0Xy7pwouPPFKFNk413vXN/VhXlKhb8mSxOitTfmlE0rHmcTFYaBuGadVvFt6IOZTZjJEA=="], + + "@orpc/zod/@orpc/openapi/@orpc/openapi-client": ["@orpc/openapi-client@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-fP5kUqgYcY5GnxkZVqN8I4Rdm1rVG81K/QokQJ1RsXwRpSSLiPTHxZ0UFDCS33uZwyKFosDovySTTEFfKGrL6A=="], + + "@orpc/zod/@orpc/openapi/@orpc/server": ["@orpc/server@1.14.15", "", { "dependencies": { "@orpc/client": "1.14.15", "@orpc/contract": "1.14.15", "@orpc/interop": "1.14.15", "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-aws-lambda": "1.14.15", "@orpc/standard-server-fastify": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-node": "1.14.15", "@orpc/standard-server-peer": "1.14.15", "cookie": "^1.1.1" }, "peerDependencies": { "crossws": ">=0.3.4", "ws": ">=8.18.1" }, "optionalPeers": ["crossws", "ws"] }, "sha512-vtIku/3KLwm6HCThaDPT9n+RHkhMRBAhNLw3NQmfNN/fOC7sYudLTt9g0isfXHDG/OaHp+x1528niTOnRrOeVQ=="], + + "@orpc/zod/@orpc/openapi/@orpc/standard-server": ["@orpc/standard-server@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15" } }, "sha512-Pd++Dgk8L4ozkAe8ish2612lYChjHJLqzNqJz6LwRD6LJJFU848oHhZz4+4FSwQod4p/z3IAEB1dnK8yOyLpVw=="], + "@prefresh/vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "@slack/events-api/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1395,6 +1453,36 @@ "@cloudflare/vite-plugin/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + "@orpc/json-schema/@orpc/contract/@orpc/client/@orpc/standard-server": ["@orpc/standard-server@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15" } }, "sha512-Pd++Dgk8L4ozkAe8ish2612lYChjHJLqzNqJz6LwRD6LJJFU848oHhZz4+4FSwQod4p/z3IAEB1dnK8yOyLpVw=="], + + "@orpc/json-schema/@orpc/contract/@orpc/client/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/json-schema/@orpc/contract/@orpc/client/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + + "@orpc/json-schema/@orpc/openapi/@orpc/client/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/json-schema/@orpc/openapi/@orpc/client/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + + "@orpc/zod/@orpc/contract/@orpc/client/@orpc/standard-server": ["@orpc/standard-server@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15" } }, "sha512-Pd++Dgk8L4ozkAe8ish2612lYChjHJLqzNqJz6LwRD6LJJFU848oHhZz4+4FSwQod4p/z3IAEB1dnK8yOyLpVw=="], + + "@orpc/zod/@orpc/contract/@orpc/client/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/zod/@orpc/contract/@orpc/client/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + + "@orpc/zod/@orpc/openapi/@orpc/client/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/zod/@orpc/openapi/@orpc/client/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + + "@orpc/zod/@orpc/openapi/@orpc/server/@orpc/standard-server-aws-lambda": ["@orpc/standard-server-aws-lambda@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15", "@orpc/standard-server-node": "1.14.15" } }, "sha512-Z1NwP4+Gxc4qBgAm+99HXjkI5N2xDYhUgFtdjBqUQozArpPI9fmtTR3zB3/ep6CO+uOhLIZ8SSy+/3edkYifgg=="], + + "@orpc/zod/@orpc/openapi/@orpc/server/@orpc/standard-server-fastify": ["@orpc/standard-server-fastify@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-node": "1.14.15" }, "peerDependencies": { "fastify": ">=5.6.1" }, "optionalPeers": ["fastify"] }, "sha512-3Gh5mEL0GQn4ev8juZqKb6yw0nu86Rr6LB3ksPqaKvBHMYc3pVlcvRz4Livna3amxPUe2HOywA39J8pC9+W0DQ=="], + + "@orpc/zod/@orpc/openapi/@orpc/server/@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-QMsVZYx+eh6NpDIh0+kDcVoLQz6vb/L1Ll2n9IkiZ0GhYRCxKbqO9FHLKDzu1q88WAdqjCOcE1LSRUP4PUksUw=="], + + "@orpc/zod/@orpc/openapi/@orpc/server/@orpc/standard-server-node": ["@orpc/standard-server-node@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15", "@orpc/standard-server-fetch": "1.14.15" } }, "sha512-5hw5+j4urgHtCs/Qq1IBTbWMswt8m+t3HqoUD72Z17tDvBhl/ESZo4Tv83791BoTOmUkW5xYvbxJozDG3cNNtg=="], + + "@orpc/zod/@orpc/openapi/@orpc/server/@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.15", "", { "dependencies": { "@orpc/shared": "1.14.15", "@orpc/standard-server": "1.14.15" } }, "sha512-LM16QTPphzm8ok6PRmGhljvW9mNoObkjChsjSnYZHDaxHr4bnQOfmM1uo+3hRS+ENPol+poikNdJS8pM4LHgGg=="], + "wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], "wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], diff --git a/package-lock.json b/package-lock.json index b977c74..d84a81f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@orpc/openapi": "^1.14.7", "@orpc/server": "^1.14.7", "@orpc/zod": "^1.14.7", + "@posthog/react": "^1.10.3", "@preact/signals": "^2.10.0", "@slack/events-api": "^3.0.1", "@slack/web-api": "^7.18.0", @@ -21,6 +22,7 @@ "feed": "^5.2.1", "hono": "^4.12.30", "postgres": "^3.4.9", + "posthog-js": "^1.413.2", "preact": "^10.29.7", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -620,7 +622,7 @@ "version": "5.20260729.1", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260729.1.tgz", "integrity": "sha512-X5r/4y0gKMq/B72qkz/tEwNK4c3v2regT3to6Ia8qqC66E0+YIN/fU7x0JG6ej2rLxtU3TV3aVhfK9y8+jMMAw==", - "dev": true, + "devOptional": true, "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -636,12 +638,46 @@ }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -655,7 +691,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -672,7 +707,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -689,7 +723,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -706,7 +739,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -723,7 +755,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -740,7 +771,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -757,7 +787,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -774,7 +803,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -791,7 +819,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -808,7 +835,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -825,7 +851,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -842,7 +867,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -859,7 +883,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -876,7 +899,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -893,7 +915,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -910,7 +931,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -927,7 +947,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -944,7 +963,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -961,7 +979,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -978,7 +995,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -995,7 +1011,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1007,7 +1022,7 @@ }, "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { "version": "0.18.20", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1046,7 +1061,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1058,7 +1072,7 @@ }, "node_modules/@esbuild-kit/esm-loader": { "version": "2.6.5", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -1072,7 +1086,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1089,7 +1102,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1106,7 +1118,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1123,7 +1134,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1140,7 +1150,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1157,7 +1166,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1174,7 +1182,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,7 +1198,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1208,7 +1214,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1225,7 +1230,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1242,7 +1246,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1259,7 +1262,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1276,7 +1278,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1293,7 +1294,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1310,7 +1310,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1327,7 +1326,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1344,7 +1342,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1361,7 +1358,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1378,7 +1374,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1395,7 +1390,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1412,7 +1406,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1429,7 +1422,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1446,7 +1438,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1463,7 +1454,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1480,7 +1470,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1497,7 +1486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2145,7 +2133,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2390,7 +2377,6 @@ }, "node_modules/@oxc-project/types": { "version": "0.139.0", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2419,6 +2405,47 @@ "dev": true, "license": "MIT" }, + "node_modules/@posthog/browser-common": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz", + "integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.46.8", + "@posthog/types": "^1.402.0" + } + }, + "node_modules/@posthog/core": { + "version": "1.46.8", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.46.8.tgz", + "integrity": "sha512-WQTSwlFhsWk09xngTimHiTyhFU8QZHFZEGSm+6brY3acwYdvTLcTxpIhgl/qOCgn/XoqlCFTZqU1ldWLxNntfA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.401.1" + } + }, + "node_modules/@posthog/react": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.10.3.tgz", + "integrity": "sha512-Qu//fGQmVlX0B9kTA3LLg67e7AYLEmeuA0Bf1qSyUM0uUILcRQGjQezhNQPLYSTakOqvXEnl6fM2iQBF6Toxrw==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=16.8.0", + "posthog-js": ">=1.257.2", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@posthog/types": { + "version": "1.402.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.1.tgz", + "integrity": "sha512-4PZ9wMYI8m8AqJuZ9YR1IAHGVtSnYbBVBgTevrKpzZbcSe/OUwhEV2Ks//rJh/L8eMTU8R5DrD4D/hlrOwaAiQ==", + "license": "MIT" + }, "node_modules/@preact/preset-vite": { "version": "2.10.6", "dev": true, @@ -2526,7 +2553,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2543,7 +2569,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2560,7 +2585,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2577,7 +2601,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2594,7 +2617,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2611,7 +2633,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -2631,7 +2652,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -2651,7 +2671,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -2671,7 +2690,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -2689,7 +2707,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -2707,7 +2724,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -2727,7 +2743,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2744,7 +2759,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2760,7 +2774,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2772,7 +2785,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2783,7 +2795,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2797,7 +2808,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2814,7 +2824,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2957,7 +2966,6 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -3143,6 +3151,16 @@ "version": "1.2.7", "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "license": "MIT" @@ -3171,6 +3189,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3575,12 +3600,12 @@ }, "node_modules/buffer-from": { "version": "1.1.2", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/bun-types": { "version": "1.3.14", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3784,6 +3809,20 @@ "license": "MIT", "optional": true }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/css-select": { "version": "5.2.2", "dev": true, @@ -3810,6 +3849,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -3990,7 +4036,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4047,6 +4092,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "dev": true, @@ -4062,7 +4116,7 @@ }, "node_modules/drizzle-kit": { "version": "0.31.10", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -4081,7 +4135,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4098,7 +4151,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4115,7 +4167,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4132,7 +4183,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4149,7 +4199,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4166,7 +4215,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4183,7 +4231,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4200,7 +4247,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4217,7 +4263,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4234,7 +4279,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4251,7 +4295,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4268,7 +4311,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4285,7 +4327,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4302,7 +4343,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4319,7 +4359,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4336,7 +4375,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4353,7 +4391,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4370,7 +4407,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4387,7 +4423,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4404,7 +4439,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4421,7 +4455,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4438,7 +4471,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4455,7 +4487,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4472,7 +4503,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4489,7 +4519,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4506,7 +4535,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4520,7 +4548,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4773,7 +4801,7 @@ }, "node_modules/esbuild": { "version": "0.28.1", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -4942,7 +4970,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4967,6 +4994,12 @@ "pnpm": ">=10" } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "license": "MIT", @@ -5060,7 +5093,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5128,7 +5160,7 @@ }, "node_modules/get-tsconfig": { "version": "4.14.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -5462,7 +5494,6 @@ }, "node_modules/lightningcss": { "version": "1.33.0", - "dev": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -5495,7 +5526,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5516,7 +5546,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5537,7 +5566,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5558,7 +5586,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5579,7 +5606,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5600,7 +5626,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -5624,7 +5649,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -5646,7 +5670,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -5668,7 +5691,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -5692,7 +5714,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -5713,7 +5734,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6694,7 +6714,6 @@ }, "node_modules/nanoid": { "version": "3.3.16", - "dev": true, "funding": [ { "type": "github", @@ -6952,12 +6971,10 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6968,7 +6985,6 @@ }, "node_modules/postcss": { "version": "8.5.25", - "dev": true, "funding": [ { "type": "opencollective", @@ -7039,6 +7055,24 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.413.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.413.2.tgz", + "integrity": "sha512-LddTVMdYFd0jB+p5h3ZHcho9mWNjgKn9QySCQt74iISKrmC3b7KzW2Q7W7aOF6m/NwK7NubIHqLujCER64gSRw==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.4.0", + "@posthog/core": "^1.46.8", + "@posthog/types": "^1.402.1", + "core-js": "^3.49.0", + "dompurify": "^3.4.12", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0", + "web-vitals-soft-navs": "npm:web-vitals@6.0.0" + } + }, "node_modules/preact": { "version": "10.29.7", "license": "MIT", @@ -7099,6 +7133,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/radash": { "version": "12.1.1", "license": "MIT", @@ -7254,7 +7294,7 @@ }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -7269,7 +7309,6 @@ }, "node_modules/rolldown": { "version": "1.1.5", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.139.0", @@ -7556,7 +7595,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7564,7 +7602,7 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -7573,7 +7611,7 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7681,7 +7719,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.17", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -7725,7 +7762,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD", "optional": true }, @@ -7738,7 +7774,7 @@ }, "node_modules/tsx": { "version": "4.23.0", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -7990,7 +8026,6 @@ }, "node_modules/vite": { "version": "8.1.5", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -8080,6 +8115,19 @@ "vite": "5.x || 6.x || 7.x || 8.x" } }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz", + "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==", + "license": "Apache-2.0" + }, "node_modules/which-module": { "version": "2.0.1", "license": "ISC" @@ -8863,7 +8911,7 @@ }, "node_modules/ws": { "version": "8.21.0", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/src/api/routers/apiKeys.ts b/src/api/routers/apiKeys.ts index 2b13ac6..580fda0 100644 --- a/src/api/routers/apiKeys.ts +++ b/src/api/routers/apiKeys.ts @@ -6,6 +6,20 @@ import { apiKeys, apiKeyChannels, authUser } from "../../db/schema"; import { eq } from "drizzle-orm"; import { aliasedTable } from "drizzle-orm/alias"; import { getChannel as dbGetChannel } from "../../db/queries"; +import type { Channel } from "../../db/queries"; + +export function canCreateApiKeyForChannel( + channel: Pick, + slackId: string, + options: { isLockdown: boolean; isManager: boolean }, +): boolean { + return ( + options.isLockdown || + options.isManager || + channel.accessPermUsers.includes("*") || + channel.accessPermUsers.includes(slackId) + ); +} export const listApiKeys = authRequiredProcedure .route({ method: "GET", path: "/api-keys" }) @@ -65,17 +79,13 @@ export const createApiKey = authRequiredProcedure throw new ORPCError("NOT_FOUND", { message: `Channel ${channelId} not found`, }); - if (!isLockdown && !ch.accessPermUsers.includes(slackId)) { - const isManager = await isChannelManager( - channelId, - slackId, - context.slackToken, - ); - if (!isManager) { - throw new ORPCError("FORBIDDEN", { - message: `You don't have access to channel ${ch.name || channelId}`, - }); - } + const isManager = isLockdown + ? false + : await isChannelManager(channelId, slackId, context.slackToken); + if (!canCreateApiKeyForChannel(ch, slackId, { isLockdown, isManager })) { + throw new ORPCError("FORBIDDEN", { + message: `You don't have access to channel ${ch.name || channelId}`, + }); } } } diff --git a/src/api/routers/channels.ts b/src/api/routers/channels.ts index 522f6d9..ad65fd1 100644 --- a/src/api/routers/channels.ts +++ b/src/api/routers/channels.ts @@ -4,6 +4,7 @@ import { publicProcedure, authOrApiKeyProcedure, authRequiredProcedure, + isChannelManager, } from "../context"; import { getChannel as dbGetChannel, @@ -11,6 +12,7 @@ import { listEnabledChannels as dbListEnabledChannels, upsertChannel as dbUpsertChannel, } from "../../db/queries"; +import { canCreateApiKeyForChannel } from "./apiKeys"; const channelSchema = z.object({ id: z.string(), @@ -64,12 +66,21 @@ export const listByUser = authOrApiKeyProcedure const slackId = context.session?.user?.slackId; if (!slackId) return []; const channels = await dbListChannels(context.db); - return channels.filter( - (ch) => - ch.accessPermUsers.includes(slackId) || - ch.accessPermUsers.includes("*") || - ch.accessPermUsers.includes(slackId), + const isLockdown = context.lockdownUsers.includes(slackId); + const visible = await Promise.all( + channels.map(async (ch) => ({ + ch, + allowed: canCreateApiKeyForChannel(ch, slackId, { + isLockdown, + isManager: await isChannelManager( + ch.id, + slackId, + context.slackToken, + ), + }), + })), ); + return visible.filter(({ allowed }) => allowed).map(({ ch }) => ch); }); export const channelRouter = { diff --git a/src/app.ts b/src/app.ts index 5d74001..fc95ac8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,7 +26,7 @@ function getDbUrl(env: Env): string { return env.HYPERDRIVE_BINDING?.connectionString || env.DATABASE_URL; } -function resolveSlackMrkdwn(text: string): string { +export function resolveSlackMrkdwn(text: string): string { if (!text) return text; return text .replace(//g, "") @@ -43,7 +43,7 @@ function resolveSlackMrkdwn(text: string): string { .replace(/>/g, ">"); } -function extractMessageText(msg: any): string { +export function extractMessageText(msg: any): string { if (msg.blocks && msg.blocks.length > 0) { const parts: string[] = []; for (const block of msg.blocks) { @@ -225,20 +225,20 @@ function markForwarded(channelId: string, slackTs: string): boolean { return true; } -function isLinkOnly(text: string): boolean { +export function isLinkOnly(text: string): boolean { const trimmed = text.trim(); return /^https?:\/\/\S+$/.test(trimmed); } -function isMessageEmpty(text: string): boolean { +export function isMessageEmpty(text: string): boolean { return text.trim().length === 0; } -function hasChannelPing(text: string): boolean { +export function hasChannelPing(text: string): boolean { return //.test(text); } -async function shouldForward( +export async function shouldForward( msg: StoreMessage, db: any, ): Promise { @@ -267,7 +267,7 @@ async function shouldForward( return true; } -function isSlackPermalink(text: string): boolean { +export function isSlackPermalink(text: string): boolean { const trimmed = text.trim(); return ( /^https:\/\/hackclub\.slack\.com\/archives\/[A-Z0-9]+\/p\d+$/.test( @@ -281,7 +281,7 @@ function isSlackPermalink(text: string): boolean { // --- Feed access control --- -interface FeedIdentity { +export interface FeedIdentity { type: "session" | "apikey"; slackId: string; channelIds?: string[]; @@ -334,23 +334,43 @@ async function resolveFeedIdentity( return null; } -function canAccessFeed( +export function canAccessFeed( ch: StoreChannel, identity: FeedIdentity | null, ): boolean { - if (ch.accessPermUsers.includes("*")) return true; - if (!identity) return false; // API keys are scoped to specific channels if ( - identity.type === "apikey" && + identity?.type === "apikey" && identity.channelIds && identity.channelIds.length > 0 ) { return identity.channelIds.includes(ch.id); } + if (ch.accessPermUsers.includes("*")) return true; + if (!identity) return false; return ch.accessPermUsers.includes(identity.slackId); } +export function canApproveMessage({ + approvedPosters, + clickingUser, + messageUser, + isManager, + isLockdown, +}: { + approvedPosters: string[]; + clickingUser: string; + messageUser: string; + isManager: boolean; + isLockdown: boolean; +}): boolean { + if (isLockdown || isManager) return true; + if (approvedPosters.includes("poster") && clickingUser === messageUser) { + return true; + } + return approvedPosters.includes(clickingUser); +} + async function forwardToSubscribers( msg: StoreMessage, sourceChannel: StoreChannel, @@ -1683,6 +1703,57 @@ app.post("/interactions", async (c) => { const { channelId, messageTs, botMessageTs, apiOnly } = privateMeta; const channel = await q.getChannel(db, channelId); if (!channel || !channel.enabled) return; + const client = new WebClient(env.SLACK_BOT_TOKEN); + let approvalHistory: any = null; + const storedOriginal = ( + await q.getMessages(db, channelId, { limit: 200 }) + ).find((m) => m.slackTs === messageTs); + let approvalMessageUser = storedOriginal?.userId || ""; + if (!approvalMessageUser) { + try { + approvalHistory = await client.conversations.history({ + channel: channelId, + latest: messageTs, + limit: 1, + inclusive: true, + }); + approvalMessageUser = + (approvalHistory.messages?.[0] as any)?.user || ""; + } catch { + approvalMessageUser = ""; + } + } + + // The modal may have been opened before authorization completed. The + // submission is the final security boundary: reject unauthorized + // users before parsing uploads, saving metadata, or forwarding. + const clickingUser = cb.user?.id || ""; + const lockdownUsers = (env.LOCKDOWN_USERS || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + const isLockdown = lockdownUsers.includes(clickingUser); + const isManager = isLockdown + ? false + : await isChannelManager(channelId, clickingUser, slack); + if (!isManager && !isLockdown) { + if ( + !canApproveMessage({ + approvedPosters: channel.approvedPosters, + clickingUser, + messageUser: approvalMessageUser, + isManager: false, + isLockdown: false, + }) + ) { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: "❌ You don't have permission to approve this message.", + }); + return; + } + } let schema: any = null; try { @@ -1722,9 +1793,8 @@ app.post("/interactions", async (c) => { })() : ""; - const client = new WebClient(env.SLACK_BOT_TOKEN); try { - const history = await client.conversations.history({ + const history = approvalHistory || await client.conversations.history({ channel: channelId, latest: messageTs, limit: 1, @@ -2109,25 +2179,32 @@ app.post("/interactions", async (c) => { if (!isManager) { const msgs = await q.getMessages(db, channelId, { limit: 100 }); const originalMsg = msgs.find((m) => m.slackTs === messageTs); - if (originalMsg) { - let allowed = true; - if (ch.approvedPosters.length === 0) { - allowed = originalMsg.userId === clickingUser; - } else if (ch.approvedPosters.includes("poster")) { - allowed = - originalMsg.userId === clickingUser || - ch.approvedPosters.includes(clickingUser); - } else { - allowed = ch.approvedPosters.includes(clickingUser); - } - if (!allowed) { - await slack.chat.postEphemeral({ + let messageUser = originalMsg?.userId || ""; + if (!messageUser) { + try { + const history = await slack.conversations.history({ channel: channelId, - user: clickingUser, - text: "❌ You don't have permission to approve this message.", + latest: messageTs, + limit: 1, + inclusive: true, }); - return; - } + messageUser = (history.messages?.[0] as any)?.user || ""; + } catch { } + } + const allowed = canApproveMessage({ + approvedPosters: ch.approvedPosters, + clickingUser, + messageUser, + isManager: false, + isLockdown, + }); + if (!allowed) { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: "❌ You don't have permission to approve this message.", + }); + return; } } return; @@ -2157,6 +2234,23 @@ app.post("/interactions", async (c) => { return; } + if ( + !canApproveMessage({ + approvedPosters: ch.approvedPosters, + clickingUser, + messageUser: msg.user || "", + isManager, + isLockdown, + }) + ) { + await slack.chat.postEphemeral({ + channel: channelId, + user: clickingUser, + text: "❌ You don't have permission to approve this message.", + }); + return; + } + let userName = msg.user || ""; try { const u = await slack.users.info({ user: msg.user }); diff --git a/src/db/queries.ts b/src/db/queries.ts index bdd575a..3b75bc7 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -50,18 +50,20 @@ export interface BotAction { // --- Helpers --- -function toBool(v: unknown): boolean { +export function parseBool(v: unknown): boolean { return v === "1" || v === "true"; } +export const toBool = parseBool; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function rowToChannel(r: any): Channel { return { id: r.id, name: r.name, teamId: r.teamId, - enabled: toBool(r.enabled), - linkMode: toBool(r.linkMode), + enabled: parseBool(r.enabled), + linkMode: parseBool(r.linkMode), webhookUrl: r.webhookUrl, autoApproveUsers: r.autoApproveUsers ? r.autoApproveUsers.split(",").filter(Boolean) @@ -69,10 +71,13 @@ function rowToChannel(r: any): Channel { approvedPosters: r.approvedPosters ? r.approvedPosters.split(",").filter(Boolean) : [], - accessPermUsers: r.accessPermUsers - ? r.accessPermUsers.split(",").filter(Boolean) - : ["*"], - trackReplies: toBool(r.trackReplies), + // An explicitly empty value means restricted, not public. Only a null + // value (for legacy rows predating the column) gets the public default. + accessPermUsers: + r.accessPermUsers == null + ? ["*"] + : r.accessPermUsers.split(",").filter(Boolean), + trackReplies: parseBool(r.trackReplies), metadataSchema: r.metadataSchema, createdAt: r.createdAt, }; diff --git a/src/web/navbar.tsx b/src/web/navbar.tsx index 29f7818..61e9615 100644 --- a/src/web/navbar.tsx +++ b/src/web/navbar.tsx @@ -1,11 +1,19 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { authClient } from "../lib/auth-client"; +import ReactDOM from 'react-dom/client' +import './index.css' +import posthog from 'posthog-js' +import { PostHogProvider } from '@posthog/react' + + + function Navbar({ active }: { active: string }) { const { data: session, isPending } = authClient.useSession(); const [stars, setStars] = useState(null); + useEffect(() => { const cached = localStorage.getItem("gh_stars"); const cachedAt = localStorage.getItem("gh_stars_at"); @@ -25,6 +33,11 @@ function Navbar({ active }: { active: string }) { .catch(() => { }); }, []); + posthog.init('phc_BCfnEJYUncGL4otpd7a7MHdtRotSvn5t634cZcnnzbBq', { + api_host: 'https://u.moldycrust.pizza', + defaults: '2026-05-30', + }) + return ( + + + + + ); } diff --git a/tests/access.test.ts b/tests/access.test.ts new file mode 100644 index 0000000..f0abece --- /dev/null +++ b/tests/access.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { canAccessFeed, canApproveMessage } from "../src/app"; +import { canCreateApiKeyForChannel } from "../src/api/routers/apiKeys"; + +const feed = (users: string[]) => ({ id: "C1", name: "", teamId: "", enabled: true, linkMode: false, webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers: users, trackReplies: false, metadataSchema: "", createdAt: "" }); + +describe("feed access invariants", () => { + test("wildcard always allows", () => expect(canAccessFeed(feed(["*"]), null)).toBe(true)); + test("matching user allows and non-matching user denies", () => { + expect(canAccessFeed(feed(["U123"]), { type: "session", slackId: "U123" })).toBe(true); + expect(canAccessFeed(feed(["U123"]), { type: "session", slackId: "U999" })).toBe(false); + }); + test("null identity is denied for restricted feeds", () => expect(canAccessFeed(feed(["U123"]), null)).toBe(false)); + test("an unspecified access list denies everyone except higher-level manager/lockdown bypasses", () => { + const privateByDefault = feed([]); + expect(canAccessFeed(privateByDefault, null)).toBe(false); + expect(canAccessFeed(privateByDefault, { type: "session", slackId: "U123" })).toBe(false); + expect(canAccessFeed(privateByDefault, { type: "apikey", slackId: "U123" })).toBe(false); + // canAccessFeed only evaluates feed ACLs; channel-manager and lockdown + // authorization is intentionally handled by the command layer. + }); + test("scoped API keys only access their channels", () => { + expect(canAccessFeed(feed(["U999"]), { type: "apikey", slackId: "U123", channelIds: ["C1"] })).toBe(true); + expect(canAccessFeed({ ...feed(["*"]), id: "C2" }, { type: "apikey", slackId: "U123", channelIds: ["C1"] })).toBe(false); + }); + test("unscoped API keys use user-level access", () => { + expect(canAccessFeed(feed(["U123"]), { type: "apikey", slackId: "U123" })).toBe(true); + expect(canAccessFeed(feed(["U999"]), { type: "apikey", slackId: "U123" })).toBe(false); + }); + test("users without channel access cannot create scoped API keys", () => { + expect(canCreateApiKeyForChannel(feed([]), "U123", { isLockdown: false, isManager: false })).toBe(false); + expect(canCreateApiKeyForChannel(feed(["U999"]), "U123", { isLockdown: false, isManager: false })).toBe(false); + expect(canCreateApiKeyForChannel(feed(["U123"]), "U123", { isLockdown: false, isManager: false })).toBe(true); + expect(canCreateApiKeyForChannel(feed(["*"]), "U123", { isLockdown: false, isManager: false })).toBe(true); + expect(canCreateApiKeyForChannel(feed([]), "U123", { isLockdown: true, isManager: false })).toBe(true); + expect(canCreateApiKeyForChannel(feed([]), "U123", { isLockdown: false, isManager: true })).toBe(true); + }); + test("only listed approved posters can approve messages", () => { + const base = { + clickingUser: "U999", + messageUser: "U123", + isManager: false, + isLockdown: false, + }; + expect(canApproveMessage({ approvedPosters: ["U123"], ...base, clickingUser: "U123" })).toBe(true); + expect(canApproveMessage({ approvedPosters: ["U123"], ...base })).toBe(false); + expect(canApproveMessage({ approvedPosters: ["U123"], ...base, isLockdown: true })).toBe(true); + expect(canApproveMessage({ approvedPosters: ["U123"], ...base, isManager: true })).toBe(true); + }); + test("poster mode lets the message author approve their own message only", () => { + expect(canApproveMessage({ + approvedPosters: ["poster"], clickingUser: "U123", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(true); + expect(canApproveMessage({ + approvedPosters: ["poster"], clickingUser: "U999", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(false); + expect(canApproveMessage({ + approvedPosters: ["poster", "U999"], clickingUser: "U999", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(true); + }); + test("metadata modal submissions enforce the same approval rule before forwarding", () => { + expect(canApproveMessage({ + approvedPosters: ["U123"], clickingUser: "U999", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(false); + expect(canApproveMessage({ + approvedPosters: ["poster"], clickingUser: "U999", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(false); + expect(canApproveMessage({ + approvedPosters: ["poster"], clickingUser: "U123", messageUser: "U123", + isManager: false, isLockdown: false, + })).toBe(true); + expect(canApproveMessage({ + approvedPosters: [], clickingUser: "U999", messageUser: "U123", + isManager: false, isLockdown: true, + })).toBe(true); + }); +}); diff --git a/tests/ephemeral.test.ts b/tests/ephemeral.test.ts new file mode 100644 index 0000000..c89f888 --- /dev/null +++ b/tests/ephemeral.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const source = readFileSync(fileURLToPath(new URL("../src/app.ts", import.meta.url)), "utf8"); + +function callsFor(method: "postMessage" | "postEphemeral"): string[] { + const calls: string[] = []; + const needle = `chat.${method}(`; + let from = 0; + while (true) { + const start = source.indexOf(needle, from); + if (start < 0) break; + let depth = 0; + let quote = ""; + let escaped = false; + for (let i = start + needle.length - 1; i < source.length; i++) { + const ch = source[i]!; + if (quote) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === quote) quote = ""; + continue; + } + if (ch === '"' || ch === "'" || ch === "`") { quote = ch; continue; } + if (ch === "(") depth++; + if (ch === ")" && --depth === 0) { + calls.push(source.slice(start, i + 1)); + from = i + 1; + break; + } + } + } + return calls; +} + +describe("Slack response visibility policy", () => { + const messages = callsFor("postMessage"); + const ephemeral = callsFor("postEphemeral"); + + test("error text is never sent with public postMessage", () => { + expect(messages.filter((call) => call.includes("❌"))).toEqual([]); + }); + + test("every ephemeral call identifies its recipient", () => { + expect(ephemeral.length).toBeGreaterThan(0); + for (const call of ephemeral) expect(call).toMatch(/\buser\s*:/); + }); + + test("threadless postMessage calls are approved forwards", () => { + for (const call of messages) { + if (/\bthread_ts\s*:/.test(call)) continue; + expect(call).toMatch(/sub\.subscriberChannelId/); + } + }); + + test("all current public bot prompts and permalink replies are threaded", () => { + for (const call of messages) { + if (!call.includes("sub.subscriberChannelId")) expect(call).toMatch(/\bthread_ts\s*:/); + } + }); +}); diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts new file mode 100644 index 0000000..14c0aa3 --- /dev/null +++ b/tests/helpers.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { + canAccessFeed, + hasChannelPing, + isLinkOnly, + isMessageEmpty, + isSlackPermalink, + shouldForward, +} from "../src/app"; +import { generateApiKey, hashSecret, verifySecret } from "../src/lib/api-keys"; +import { parseBool, toBool } from "../src/db/queries"; + +const channel = (accessPermUsers = ["*"]) => ({ + id: "C1", name: "feed", teamId: "T1", enabled: true, linkMode: false, + webhookUrl: "", autoApproveUsers: [], approvedPosters: [], accessPermUsers, + trackReplies: false, metadataSchema: "", createdAt: "", +}); +const msg = (text: string) => ({ slackTs: "1.1", channelId: "C1", userId: "U1", userName: "u", text, timestamp: new Date().toISOString(), metadata: {} }); + +describe("pure helpers", () => { + test("recognizes Slack permalinks", () => { + expect(isSlackPermalink("https://hackclub.slack.com/archives/C1/p123")).toBe(true); + expect(isSlackPermalink("https://team.slack.com/archives/C1/p123")).toBe(true); + expect(isSlackPermalink("https://example.com/archives/C1/p123")).toBe(false); + }); + test("recognizes link-only text", () => { + expect(isLinkOnly(" https://example.com/a ")).toBe(true); + expect(isLinkOnly("read https://example.com/a")).toBe(false); + }); + test("recognizes empty messages", () => { + expect(isMessageEmpty("")).toBe(true); + expect(isMessageEmpty(" \n\t")).toBe(true); + expect(isMessageEmpty("")).toBe(false); + }); + test("documents ping behavior (raw Slack syntax only)", () => { + expect(hasChannelPing("")).toBe(true); + expect(hasChannelPing("here")).toBe(false); + }); + test("forward policy prioritizes permalinks and empty messages", async () => { + const db = {}; + expect(await shouldForward(msg("https://hackclub.slack.com/archives/C1/p123"), db)).toBe(true); + expect(await shouldForward(msg("https://example.com"), db)).toBe(false); + expect(await shouldForward(msg(" "), db)).toBe(true); + }); + test("enforces public and restricted feed access", () => { + expect(canAccessFeed(channel(), null)).toBe(true); + expect(canAccessFeed(channel(["U123"]), { type: "session", slackId: "U123" })).toBe(true); + expect(canAccessFeed(channel(["U123"]), { type: "session", slackId: "U999" })).toBe(false); + expect(canAccessFeed(channel(["U123"]), null)).toBe(false); + expect(canAccessFeed(channel(["U123"]), { type: "apikey", slackId: "U999", channelIds: ["C1"] })).toBe(true); + }); + test("parses persisted boolean representations without throwing", () => { + for (const value of ["0", "1", "true", "false", "public"]) { + expect(() => parseBool(value)).not.toThrow(); + expect(() => toBool(value)).not.toThrow(); + } + expect(parseBool("1")).toBe(true); + expect(parseBool("0")).toBe(false); + }); + test("generates and verifies API keys", async () => { + const key = generateApiKey(); + expect(key.fullKey.startsWith("ind_")).toBe(true); + expect(key.fullKey).toHaveLength(52); + expect(key.keyPrefix).toHaveLength(12); + expect(await verifySecret(key.fullKey, await key.secretHash)).toBe(true); + expect(await verifySecret(`${key.fullKey}x`, await hashSecret(key.fullKey))).toBe(false); + }); +}); diff --git a/tests/queries.test.ts b/tests/queries.test.ts new file mode 100644 index 0000000..308e598 --- /dev/null +++ b/tests/queries.test.ts @@ -0,0 +1,77 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import postgres from "postgres"; +import { getDb } from "../src/db"; +import * as q from "../src/db/queries"; + +const url = process.env.TEST_DATABASE_URL || "postgres://indigest:indigest@localhost:5433/indigest_test"; +const raw = postgres(url, { max: 1 }); +const db = getDb(url); +const suffix = `test-${Date.now()}`; +const sourceId = `C-${suffix}`; +const subscriberId = `C-sub-${suffix}`; + +const channel = (id: string, enabled = true): q.Channel => ({ id, name: id, teamId: "T1", enabled, linkMode: false, webhookUrl: "", autoApproveUsers: ["a", "b"], approvedPosters: [], accessPermUsers: ["*"], trackReplies: false, metadataSchema: "", createdAt: "" }); +const message = (slackTs: string, text: string, extra: Partial = {}): q.Message => ({ slackTs, channelId: sourceId, userId: "U1", userName: "Ada", text, timestamp: `2026-01-01T00:00:${slackTs.split(".")[1] || "00"}.000Z`, metadata: { ok: true }, ...extra }); + +beforeAll(async () => { + if (!process.env.TEST_DATABASE_URL) return; + await raw`select 1`; +}); +afterAll(async () => { + if (!process.env.TEST_DATABASE_URL) return; + await raw`delete from bot_actions where source_channel_id = ${sourceId}`; + await raw`delete from messages where channel_id = ${sourceId}`; + await raw`delete from subscriptions where subscriber_channel_id = ${subscriberId} or source_channel_id = ${sourceId}`; + await raw`delete from channels where id = ${sourceId}`; + await raw`delete from channels where id = ${subscriberId}`; + await raw.end(); +}); + +describe.skipIf(!process.env.TEST_DATABASE_URL)("database queries", () => { + test("channel CRUD and idempotent upsert", async () => { + await q.upsertChannel(db, channel(sourceId)); + await q.upsertChannel(db, { ...channel(sourceId), name: "updated", enabled: false }); + expect((await q.getChannel(db, sourceId))?.name).toBe("updated"); + expect((await q.listChannels(db)).some((c) => c.id === sourceId)).toBe(true); + expect((await q.listEnabledChannels(db)).some((c) => c.id === sourceId)).toBe(false); + }); + + test("message upsert, CRUD, filters, and count", async () => { + await q.upsertChannel(db, channel(sourceId)); + await q.upsertMessage(db, message("1.01", "first")); + await q.upsertMessage(db, message("2.02", "second", { userId: "U2", threadTs: "1.01", timestamp: "2026-01-02T00:00:00.000Z" })); + await q.upsertMessage(db, message("1.01", "updated")); + expect((await q.getMessageBySlackTs(db, sourceId, "1.01"))?.text).toBe("updated"); + expect((await q.getMessages(db, sourceId, { userId: "U2" })).length).toBe(1); + expect((await q.getMessages(db, sourceId, { threadTs: "1.01" })).length).toBe(1); + expect((await q.getMessages(db, sourceId, { after: "2026-01-02" })).length).toBe(1); + expect((await q.getMessages(db, sourceId, { before: "2026-01-01" })).length).toBe(1); + expect(await q.getMessageCount(db, sourceId, { userId: "U2" })).toBe((await q.getMessages(db, sourceId, { userId: "U2" })).length); + await q.deleteMessage(db, sourceId, "1.01"); + expect(await q.getMessageBySlackTs(db, sourceId, "1.01")).toBeNull(); + }); + + test("subscriptions, bot actions, and graph data", async () => { + await q.upsertChannel(db, channel(sourceId, true)); + await q.upsertChannel(db, channel(subscriberId, false)); + await q.addSubscription(db, subscriberId, sourceId); + await q.addSubscription(db, subscriberId, sourceId); + expect((await q.getSubscribersBySource(db, sourceId)).length).toBe(1); + expect((await q.getSubscriptionsBySubscriber(db, subscriberId)).length).toBe(1); + await q.addBotAction(db, { type: "pub", sourceChannelId: sourceId, sourceMessageTs: "1.1", botChannelId: subscriberId, botMessageTs: "3.3", createdAt: "" }); + expect((await q.getBotActionsBySource(db, sourceId, "1.1"))[0]?.type).toBe("pub"); + const graph = await q.getGraphData(db); + expect(graph.channels.some((c) => c.id === sourceId)).toBe(true); + expect(graph.subscriberChannels.some((c) => c.id === subscriberId)).toBe(true); + expect(graph.subscriptions.some((s) => s.sourceChannelId === sourceId)).toBe(true); + await q.removeSubscription(db, subscriberId, sourceId); + }); + + test("boolean and comma-separated values round-trip consistently", async () => { + await q.upsertChannel(db, { ...channel(sourceId), enabled: true, autoApproveUsers: ["a", "b"], accessPermUsers: ["*"] }); + const got = await q.getChannel(db, sourceId); + expect(got?.enabled).toBe(true); + expect(got?.autoApproveUsers).toEqual(["a", "b"]); + expect(got?.accessPermUsers).toEqual(["*"]); + }); +}); diff --git a/tests/sanitization.test.ts b/tests/sanitization.test.ts new file mode 100644 index 0000000..b80a35f --- /dev/null +++ b/tests/sanitization.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { extractMessageText, resolveSlackMrkdwn } from "../src/app"; + +describe("Slack text sanitization", () => { + test("removes broadcast mentions", () => { + expect(resolveSlackMrkdwn(" hello")).toBe(" hello"); + }); + + test("resolves channel and user labels", () => { + expect(resolveSlackMrkdwn("<#C123|general> <@U123|Ada>")).toBe("#general @Ada"); + }); + + test("resolves links and HTML entities", () => { + expect(resolveSlackMrkdwn(" & <x>")).toBe("Example & "); + }); + + test("extracts section and context block text", () => { + expect(extractMessageText({ blocks: [ + { type: "section", text: { type: "mrkdwn", text: "section" } }, + { type: "context", elements: [{ type: "mrkdwn", text: "context" }] }, + ], text: "fallback" })).toBe("section\ncontext"); + }); + + test("falls back safely for empty or malformed blocks", () => { + expect(extractMessageText({ blocks: [], text: "fallback" })).toBe("fallback"); + expect(extractMessageText({ blocks: [{ type: "divider" }] })).toBe(""); + expect(extractMessageText({})).toBe(""); + }); +}); diff --git a/tests/schema-drift.test.ts b/tests/schema-drift.test.ts new file mode 100644 index 0000000..944991b --- /dev/null +++ b/tests/schema-drift.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import postgres from "postgres"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import * as schema from "../src/db/schema"; + +const sql = postgres(process.env.TEST_DATABASE_URL || "postgres://indigest:indigest@localhost:5433/indigest_test", { max: 1 }); +const tables = [schema.channels, schema.messages, schema.subscriptions, schema.botActions, schema.apiKeys, schema.apiKeyChannels, schema.authUser, schema.authSession, schema.authAccount, schema.authVerification]; + +function kind(type: string): string { + if (type.includes("character") || type === "text") return "text"; + if (type.includes("integer") || type === "bigint" || type === "smallint") return "integer"; + if (type === "boolean") return "boolean"; + if (type.startsWith("timestamp")) return "timestamp"; + return type; +} + +describe.skipIf(!process.env.TEST_DATABASE_URL)("database schema drift", () => { + test("all Drizzle columns exist in PostgreSQL with matching types, and vice versa", async () => { + const dbRows = await sql<{ table_name: string; column_name: string; data_type: string }[]>` + select table_name, column_name, data_type + from information_schema.columns + where table_schema = 'public' + order by table_name, ordinal_position + `; + const actual = new Map(dbRows.map((r) => [`${r.table_name}.${r.column_name}`, kind(r.data_type)])); + const expected = new Map(); + for (const table of tables) { + const config = getTableConfig(table); + for (const column of config.columns) expected.set(`${config.name}.${column.name}`, kind(column.getSQLType())); + } + expect([...expected.keys()].filter((key) => !actual.has(key))).toEqual([]); + expect([...actual.keys()].filter((key) => expected.has(key) === false && tables.some((t) => getTableConfig(t).name === key.split(".")[0]))).toEqual([]); + const mismatches = [...expected].filter(([key, type]) => actual.has(key) && actual.get(key) !== type).map(([key, type]) => `${key}: expected ${type}, got ${actual.get(key)}`); + expect(mismatches).toEqual([]); + }); +}); -- 2.51.2