better-supabase 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -46
- package/dist/access-sql-B0dF-SoF.js +181 -0
- package/dist/{actor-DaJjqD5R.d.ts → actor-CQTlc1Hh.d.ts} +1 -1
- package/dist/adapter-Bc1o3zyy.js +1106 -0
- package/dist/agents-CVHGd7It.d.ts +94 -0
- package/dist/ai-cache-CBWs1W0k.js +80 -0
- package/dist/ai-cache-DaDu_G5L.d.ts +64 -0
- package/dist/ai-chat-7amvXjmQ.d.ts +590 -0
- package/dist/ai-chat-B24UI962.js +743 -0
- package/dist/ai-files-CSPNswUY.js +319 -0
- package/dist/ai-sdk/agents/index.d.ts +74 -0
- package/dist/ai-sdk/agents/index.js +159 -0
- package/dist/ai-sdk/batches/index.d.ts +74 -0
- package/dist/ai-sdk/batches/index.js +180 -0
- package/dist/ai-sdk/cache/index.d.ts +38 -0
- package/dist/ai-sdk/cache/index.js +127 -0
- package/dist/ai-sdk/chat/index.d.ts +2 -0
- package/dist/ai-sdk/chat/index.js +246 -0
- package/dist/ai-sdk/embeddings/index.d.ts +3 -0
- package/dist/ai-sdk/embeddings/index.js +2 -0
- package/dist/ai-sdk/files/index.d.ts +61 -0
- package/dist/ai-sdk/files/index.js +111 -0
- package/dist/ai-sdk/index.d.ts +145 -0
- package/dist/ai-sdk/index.js +174 -0
- package/dist/ai-sdk/mcp/index.d.ts +102 -0
- package/dist/ai-sdk/mcp/index.js +362 -0
- package/dist/ai-sdk/memory/index.d.ts +67 -0
- package/dist/ai-sdk/memory/index.js +175 -0
- package/dist/ai-sdk/react/index.d.ts +2 -0
- package/dist/ai-sdk/react/index.js +3 -0
- package/dist/ai-sdk/react/server.d.ts +6 -0
- package/dist/ai-sdk/react/server.js +7 -0
- package/dist/ai-sdk/workflow/index.d.ts +249 -0
- package/dist/ai-sdk/workflow/index.js +575 -0
- package/dist/ai-sdk/workflow/react/index.d.ts +20 -0
- package/dist/ai-sdk/workflow/react/index.js +66 -0
- package/dist/ai-sdk/workflow/react/server.d.ts +10 -0
- package/dist/ai-sdk/workflow/react/server.js +12 -0
- package/dist/ai-tasks-Cd1HMFvB.d.ts +131 -0
- package/dist/announcements-CiyOs-xP.js +107 -0
- package/dist/announcements-ZRGnScWI.d.ts +72 -0
- package/dist/api-keys-rCGtKaZA.d.ts +156 -0
- package/dist/assistant-DwdpDH1q.d.ts +147 -0
- package/dist/astro/index.d.ts +86 -0
- package/dist/astro/index.js +93 -0
- package/dist/attributes-5dYul0u0.js +13 -0
- package/dist/auth-link-BjFPqwLz.d.ts +97 -0
- package/dist/auth-link-DrDX9KOT.js +114 -0
- package/dist/authorization-BFCqamMs.d.ts +172 -0
- package/dist/before-route-CnR0C0Jr.js +30 -0
- package/dist/bind-Bu_ZNa3W.d.ts +62 -0
- package/dist/bind-l4kAvYSv.js +97 -0
- package/dist/block-events-CrT8r7Fe.js +76 -0
- package/dist/block-fields-GFkxl4dP.js +49 -0
- package/dist/block-fields-SJ6fEf3o.d.ts +31 -0
- package/dist/block-helpers-CKtbFcRv.d.ts +37 -0
- package/dist/block-helpers-DWHxHsEQ.js +190 -0
- package/dist/block-hooks-BmjMyXTa.js +114 -0
- package/dist/block-hooks-plbZ0AbJ.d.ts +107 -0
- package/dist/{kit-transport-DJlHQUBU.js → block-transport-BLr-ZgLc.js} +11 -7
- package/dist/block-transport-DvaoPuZ9.d.ts +48 -0
- package/dist/blocks/agents/index.d.ts +3 -0
- package/dist/blocks/agents/index.js +123 -0
- package/dist/blocks/ai-cache/index.d.ts +3 -0
- package/dist/blocks/ai-cache/index.js +3 -0
- package/dist/blocks/ai-chat/index.d.ts +31 -0
- package/dist/blocks/ai-chat/index.js +5 -0
- package/dist/blocks/ai-chat/react/index.d.ts +77 -0
- package/dist/blocks/ai-chat/react/index.js +387 -0
- package/dist/blocks/ai-chat/react/server.d.ts +16 -0
- package/dist/blocks/ai-chat/react/server.js +14 -0
- package/dist/blocks/ai-files/index.d.ts +14 -0
- package/dist/blocks/ai-files/index.js +3 -0
- package/dist/blocks/ai-providers/index.d.ts +7 -0
- package/dist/blocks/ai-providers/index.js +211 -0
- package/dist/blocks/ai-tasks/index.d.ts +3 -0
- package/dist/blocks/ai-tasks/index.js +203 -0
- package/dist/blocks/announcements/index.d.ts +3 -0
- package/dist/blocks/announcements/index.js +3 -0
- package/dist/blocks/announcements/react/index.d.ts +31 -0
- package/dist/blocks/announcements/react/index.js +86 -0
- package/dist/blocks/announcements/react/server.d.ts +6 -0
- package/dist/blocks/announcements/react/server.js +7 -0
- package/dist/blocks/api-keys/index.d.ts +35 -0
- package/dist/blocks/api-keys/index.js +334 -0
- package/dist/blocks/attachments/index.d.ts +210 -0
- package/dist/blocks/attachments/index.js +222 -0
- package/dist/blocks/audit/index.d.ts +366 -0
- package/dist/blocks/audit/index.js +714 -0
- package/dist/blocks/billing/index.d.ts +265 -0
- package/dist/blocks/billing/index.js +400 -0
- package/dist/blocks/comments/index.d.ts +200 -0
- package/dist/blocks/comments/index.js +207 -0
- package/dist/blocks/connectors/index.d.ts +3 -0
- package/dist/blocks/connectors/index.js +164 -0
- package/dist/blocks/data-lifecycle/index.d.ts +251 -0
- package/dist/blocks/data-lifecycle/index.js +353 -0
- package/dist/blocks/entitlements/index.d.ts +14 -0
- package/dist/blocks/entitlements/index.js +25 -0
- package/dist/blocks/flags/index.d.ts +226 -0
- package/dist/blocks/flags/index.js +282 -0
- package/dist/blocks/inbox/index.d.ts +6 -0
- package/dist/blocks/inbox/index.js +499 -0
- package/dist/blocks/inbox/react/index.d.ts +2 -0
- package/dist/blocks/inbox/react/index.js +225 -0
- package/dist/blocks/inbox/react/server.d.ts +10 -0
- package/dist/blocks/inbox/react/server.js +12 -0
- package/dist/blocks/index.d.ts +65 -0
- package/dist/blocks/index.js +77 -0
- package/dist/blocks/jobs/index.d.ts +297 -0
- package/dist/blocks/jobs/index.js +1051 -0
- package/dist/blocks/knowledge/index.d.ts +3 -0
- package/dist/blocks/knowledge/index.js +3 -0
- package/dist/blocks/memory/index.d.ts +8 -0
- package/dist/blocks/memory/index.js +289 -0
- package/dist/blocks/notifications/index.d.ts +297 -0
- package/dist/blocks/notifications/index.js +425 -0
- package/dist/blocks/notifications/react/index.d.ts +44 -0
- package/dist/blocks/notifications/react/index.js +67 -0
- package/dist/blocks/notifications/react/server.d.ts +6 -0
- package/dist/blocks/notifications/react/server.js +7 -0
- package/dist/blocks/onboarding/index.d.ts +3 -0
- package/dist/blocks/onboarding/index.js +64 -0
- package/dist/blocks/onboarding/react/index.d.ts +28 -0
- package/dist/blocks/onboarding/react/index.js +78 -0
- package/dist/blocks/onboarding/react/server.d.ts +7 -0
- package/dist/blocks/onboarding/react/server.js +7 -0
- package/dist/blocks/organizations/index.d.ts +241 -0
- package/dist/blocks/organizations/index.js +400 -0
- package/dist/blocks/outbox/index.d.ts +3 -0
- package/dist/blocks/outbox/index.js +266 -0
- package/dist/blocks/profiles/index.d.ts +40 -0
- package/dist/blocks/profiles/index.js +28 -0
- package/dist/blocks/push/index.d.ts +185 -0
- package/dist/blocks/push/index.js +258 -0
- package/dist/blocks/settings/index.d.ts +89 -0
- package/dist/blocks/settings/index.js +104 -0
- package/dist/blocks/sso/index.d.ts +198 -0
- package/dist/blocks/sso/index.js +1297 -0
- package/dist/blocks/usage/index.d.ts +4 -0
- package/dist/blocks/usage/index.js +233 -0
- package/dist/blocks/waitlist/index.d.ts +117 -0
- package/dist/blocks/waitlist/index.js +132 -0
- package/dist/blocks/webhooks/index.d.ts +304 -0
- package/dist/{webhooks → blocks/webhooks}/index.js +468 -148
- package/dist/blocks/workflow-builder/index.d.ts +21 -0
- package/dist/blocks/workflow-builder/index.js +5 -0
- package/dist/blocks/workflow-builder/react/index.d.ts +47 -0
- package/dist/blocks/workflow-builder/react/index.js +119 -0
- package/dist/blocks/workflow-builder/react/server.d.ts +8 -0
- package/dist/blocks/workflow-builder/react/server.js +11 -0
- package/dist/blocks/workflows/index.d.ts +3 -0
- package/dist/blocks/workflows/index.js +3 -0
- package/dist/blocks/workflows/react/index.d.ts +47 -0
- package/dist/blocks/workflows/react/index.js +137 -0
- package/dist/blocks/workflows/react/server.d.ts +8 -0
- package/dist/blocks/workflows/react/server.js +11 -0
- package/dist/{index-SaTsEfGP.d.ts → bucket-Cl46E3jS.d.ts} +134 -91
- package/dist/{simplify-_0oUKqCd.js → build-BXIzE6Xv.js} +222 -124
- package/dist/{channel-FQW-CnGR.d.ts → channel-_2Mx1Oyu.d.ts} +2 -2
- package/dist/channels-CkgQ_X60.js +114 -0
- package/dist/chat-sdk/index.d.ts +303 -0
- package/dist/chat-sdk/index.js +733 -0
- package/dist/chat-sdk/react/index.d.ts +2 -0
- package/dist/chat-sdk/react/index.js +3 -0
- package/dist/chat-sdk/react/server.d.ts +3 -0
- package/dist/chat-sdk/react/server.js +2 -0
- package/dist/claims-DDBQT6BK.js +61 -0
- package/dist/{claims-BAf6c7is.js → claims-PqzCxaTD.js} +39 -2
- package/dist/cli/bin.js +1 -1
- package/dist/cli/{codemod-DqV7612m.js → codemod-Bl_tQpHn.js} +105 -2
- package/dist/cli/config-B9HkL8nF.js +2 -0
- package/dist/cli/config-DU7EJImt.js +49 -0
- package/dist/cli/config-NiPxAmUF.js +1077 -0
- package/dist/cli/{dist-xn4wlboc.js → dist-DRAVaE6w.js} +111 -1
- package/dist/cli/{doctor-C_A-KEAz.js → doctor-B4JOm_ox.js} +991 -317
- package/dist/cli/{env-DmKv_G4A.js → env-CJLbUVPB.js} +1 -1
- package/dist/cli/{env-jIFUADLY.js → env-qXgyMV6r.js} +2 -2
- package/dist/cli/fresh-import-BeYf9I3x.js +40 -0
- package/dist/cli/gen-BGLb_zou.js +3100 -0
- package/dist/cli/gen-DAH8FM2W.js +2 -0
- package/dist/cli/index.d.ts +44 -5
- package/dist/cli/index.js +7 -7
- package/dist/cli/{init-DMa6M7hV.js → init-BIIwclPg.js} +242 -27
- package/dist/cli/{introspect-BKlaIu_M.js → introspect-DNUTuNvb.js} +5 -5
- package/dist/cli/{io-k41_z-jT.js → io-DI1mTdSA.js} +12 -3
- package/dist/cli/{keys-ByKHnvP9.js → keys-BGCnnb-s.js} +8 -6
- package/dist/cli/{ohash-D7bG_EOb.js → ohash-BjQY1cBb.js} +1 -1
- package/dist/cli/{openapi-Cp5Fyta-.js → openapi-BlRJ18s3.js} +2 -2
- package/dist/cli/{project-B_GeXyHh.js → project-DZRaBaY7.js} +11 -6
- package/dist/cli/read-sets-CETOSFx4.js +223 -0
- package/dist/cli/{run-Bo_NLT5n.js → run-DNWxHynX.js} +33 -23
- package/dist/cli/{seed-CUlqWh-8.js → seed-DQzqT7LP.js} +8 -6
- package/dist/cli/{skills-LJJIhLFR.js → skills-QQs4q68J.js} +3 -2
- package/dist/cli/sql-D0GUtUPp.js +536 -0
- package/dist/cli/{config-CPVpXP5I.js → supabase-toml-B-bIukQF.js} +72 -942
- package/dist/cli/typegen-version-DeFbtzYI.js +8 -0
- package/dist/cli/{version-BCtlgDQx.js → version-hr88OZG0.js} +1 -1
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.js +11 -71
- package/dist/client/native/index.d.ts +228 -0
- package/dist/client/native/index.js +361 -0
- package/dist/client-CI0Z2zWX.js +277 -0
- package/dist/cloud-event-BnmD5fb9.js +46 -0
- package/dist/cloud-event-C1JCc1bH.d.ts +50 -0
- package/dist/{compiler-BXH_aZ7N.d.ts → compiler-DY_wkj4R.d.ts} +2 -2
- package/dist/compose-BIBNgAnq.js +53 -0
- package/dist/composite-Bt1YK3Ke.d.ts +146 -0
- package/dist/composite-DhE66P5u.js +130 -0
- package/dist/config/index.d.ts +6 -4
- package/dist/config/index.js +5 -5
- package/dist/{config-Bv7gvfzC.d.ts → config-D44Y7PAv.d.ts} +406 -214
- package/dist/{config-DreERlKo.js → config-Dn7F5k9A.js} +62 -14
- package/dist/connector-Dv8kK0MO.d.ts +107 -0
- package/dist/connectors-hkLCwyEz.d.ts +120 -0
- package/dist/context-BCrWe7ow.d.ts +237 -0
- package/dist/credentials/index.d.ts +31 -0
- package/dist/credentials/index.js +168 -0
- package/dist/cron-DVPUg5k3.js +145 -0
- package/dist/csv-CPAzEmB6.js +25 -0
- package/dist/db-exception-CnWcYPxL.js +15 -0
- package/dist/{define-cNKzqwL_.js → define-B-TVjC25.js} +784 -227
- package/dist/{define-Bp_BZa5h.d.ts → define-CJJkkr1-.d.ts} +353 -61
- package/dist/edge/index.d.ts +69 -11
- package/dist/edge/index.js +191 -75
- package/dist/elysia/index.d.ts +59 -0
- package/dist/elysia/index.js +62 -0
- package/dist/embeddings-4ZvFzjsU.js +127 -0
- package/dist/embeddings-C9KGQnXt.d.ts +68 -0
- package/dist/{refresh-CFPX0Qft.d.ts → ended-jLfvw8h-.d.ts} +20 -2
- package/dist/{entitlements-BiJZ4t49.d.ts → entitlements-DHrY2UDZ.d.ts} +16 -7
- package/dist/entitlements-DRl6ZJUm.js +24 -0
- package/dist/envelope-DqnYGRw7.js +47 -0
- package/dist/{errors-DtwXa6b8.js → errors-B11Duf73.js} +82 -13
- package/dist/errors-DLjJHPLK.js +44 -0
- package/dist/escape-like-DHfx6jRr.js +7 -0
- package/dist/escape-like-ImTK0ps_.d.ts +15 -0
- package/dist/eve/index.d.ts +220 -0
- package/dist/eve/index.js +504 -0
- package/dist/eve-gT2Fpjut.d.ts +151 -0
- package/dist/events/index.d.ts +4 -3
- package/dist/events/index.js +9 -50
- package/dist/events-DWDhx77e.d.ts +549 -0
- package/dist/executor-Bq28caBo.js +718 -0
- package/dist/executor-C-R4pucm.js +83 -0
- package/dist/executor-CaWigTyR.d.ts +148 -0
- package/dist/executor-D14tGopJ.d.ts +36 -0
- package/dist/{executor-Cghdstwu.d.ts → executor-DbvExCFC.d.ts} +7 -1
- package/dist/{sql-Coh6n4cC.js → executor-De1Of3m3.js} +210 -11
- package/dist/executor-DvsBbNBS.d.ts +261 -0
- package/dist/expo/index.d.ts +79 -0
- package/dist/expo/index.js +129 -0
- package/dist/expo-sqlite/index.d.ts +58 -0
- package/dist/expo-sqlite/index.js +104 -0
- package/dist/field-errors-BFOa7WhB.d.ts +99 -0
- package/dist/field-errors-VF1yb8yI.js +13 -0
- package/dist/framework-CONDyPYx.js +51 -0
- package/dist/framework-DuVb4Xa8.d.ts +25 -0
- package/dist/gateway-C1ssRHnf.d.ts +130 -0
- package/dist/graph-BGyLLFLX.js +265 -0
- package/dist/h3/index.d.ts +59 -0
- package/dist/h3/index.js +55 -0
- package/dist/h3/v1/index.d.ts +63 -0
- package/dist/h3/v1/index.js +49 -0
- package/dist/history-BZih6SFP.js +64 -0
- package/dist/hono/index.d.ts +56 -7
- package/dist/hono/index.js +97 -45
- package/dist/{react-C_s7kiNz.js → hooks-1BTCJsiw.js} +104 -133
- package/dist/http-Cv4T29xh.js +40 -0
- package/dist/http-DdYadv-o.js +126 -0
- package/dist/image-url-pIVlO6R1.js +41 -0
- package/dist/{impersonation-BgSHAYLf.js → impersonation-DcqaTBHx.js} +21 -6
- package/dist/inbox-i7E1ppo6.d.ts +384 -0
- package/dist/index-ACiNPN4Z.d.ts +74 -0
- package/dist/{index-DgILJRXj.d.ts → index-Bepi-Hgg.d.ts} +91 -28
- package/dist/index-BkWT_uBO.d.ts +51 -0
- package/dist/index-BpFYMsi0.d.ts +53 -0
- package/dist/{index-C7f8cOBM.d.ts → index-Bxih3l0u.d.ts} +2 -2
- package/dist/index-CaxrMD1A.d.ts +1 -0
- package/dist/index-ClmjK_o-.d.ts +55 -0
- package/dist/index-CtuakVRc.d.ts +267 -0
- package/dist/index-D48jVS-N.d.ts +246 -0
- package/dist/index-DDrYaUcq.d.ts +138 -0
- package/dist/index-DQh4VCc9.d.ts +98 -0
- package/dist/{live-D9W2M5aO.d.ts → index-DunNRdnT.d.ts} +136 -38
- package/dist/index.d.ts +65 -18
- package/dist/index.js +42 -14
- package/dist/invalidate-BBqhpxyB.d.ts +19 -0
- package/dist/json-CEnpTHlZ.js +16 -0
- package/dist/json-schema-CvER4kgT.js +502 -0
- package/dist/keys-4MnkWTTh.js +35 -0
- package/dist/kit-B5tid-q5.d.ts +60 -0
- package/dist/kit-Fm-Fv7q5.js +78 -0
- package/dist/knowledge-BXBbT0Tr.d.ts +176 -0
- package/dist/knowledge-CDmiMLew.js +255 -0
- package/dist/lint/index.d.ts +1 -1
- package/dist/lint/index.js +37 -0
- package/dist/list/index.d.ts +3 -2
- package/dist/list/index.js +1 -444
- package/dist/{index-fag8VJZ2.d.ts → list-query-BRt-zDF7.d.ts} +20 -12
- package/dist/list-query-gl808ac1.js +459 -0
- package/dist/{live-CdJBOc4f.js → live-CTAZQkwl.js} +99 -14
- package/dist/logger-DWVYUmwi.d.ts +18 -0
- package/dist/mcp/index.d.ts +38 -11
- package/dist/mcp/index.js +82 -46
- package/dist/mcp/sdk/index.d.ts +105 -0
- package/dist/mcp/sdk/index.js +126 -0
- package/dist/message-CaKlqdsX.js +219 -0
- package/dist/message-CfDoR0Y6.d.ts +94 -0
- package/dist/messages-B9-5887d.js +715 -0
- package/dist/messages-DYP0sUTe.d.ts +59 -0
- package/dist/mfa-Daak8ukG.d.ts +54 -0
- package/dist/modules-DpPwzC7E.d.ts +192 -0
- package/dist/modules-dxrVK3ID.js +228 -0
- package/dist/nestjs/index.d.ts +68 -0
- package/dist/nestjs/index.js +164 -0
- package/dist/next/client/index.d.ts +35 -0
- package/dist/next/client/index.js +36 -0
- package/dist/next/image/index.js +3 -24
- package/dist/next/index.d.ts +150 -51
- package/dist/next/index.js +261 -107
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.js +210 -0
- package/dist/node-headers-AO06VhqH.d.ts +24 -0
- package/dist/node-headers-C71zNQHe.js +55 -0
- package/dist/nuxt/index.d.ts +60 -0
- package/dist/nuxt/index.js +64 -0
- package/dist/onboarding-BZGHYJks.d.ts +62 -0
- package/dist/openapi/index.d.ts +1 -1
- package/dist/openapi/index.js +3 -3
- package/dist/orpc/index.d.ts +70 -11
- package/dist/orpc/index.js +73 -24
- package/dist/otel/index.d.ts +7 -7
- package/dist/otel/index.js +87 -57
- package/dist/outbox-Cq7pPYvl.d.ts +161 -0
- package/dist/{plugin-o144f322.d.ts → plugin-FGY8UhYm.d.ts} +30 -10
- package/dist/plugin-tfXW1-qO.js +45 -0
- package/dist/plugins/actor/index.d.ts +2 -2
- package/dist/plugins/actor/index.js +3 -3
- package/dist/plugins/rules/index.d.ts +11 -4
- package/dist/plugins/rules/index.js +40 -13
- package/dist/plugins/soft-delete/index.d.ts +5 -4
- package/dist/plugins/soft-delete/index.js +10 -5
- package/dist/plugins/tenant/index.d.ts +2 -2
- package/dist/plugins/tenant/index.js +18 -11
- package/dist/plugins/timestamps/index.d.ts +5 -3
- package/dist/plugins/timestamps/index.js +6 -4
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +10 -6
- package/dist/{pool-C9voM4Kw.d.ts → pool-CAu2nLrL.d.ts} +2 -2
- package/dist/postgres/index.d.ts +4 -4
- package/dist/postgres/index.js +1 -2
- package/dist/{postgrest-CruYKAvV.d.ts → postgrest-BLVdEnfe.d.ts} +13 -3
- package/dist/powersync/index.d.ts +43 -0
- package/dist/powersync/index.js +199 -0
- package/dist/powersync/react/index.d.ts +87 -0
- package/dist/powersync/react/index.js +108 -0
- package/dist/{problem-BW1KhzSG.js → problem-BreOl8b8.js} +14 -3
- package/dist/{problem-Blxe8S31.d.ts → problem-DD8LZFjc.d.ts} +21 -3
- package/dist/provider-BH5UWZJ6.d.ts +113 -0
- package/dist/provider-Di3sRutW.js +57 -0
- package/dist/query/index.d.ts +4 -2
- package/dist/query/index.js +4 -3
- package/dist/{query-CCbo44lG.js → query-BQ-NaZon.js} +141 -24
- package/dist/{queue-BYUCjr59.d.ts → queue-CLJurhdy.d.ts} +258 -83
- package/dist/react/index.d.ts +11 -7
- package/dist/react/index.js +7 -3
- package/dist/react/native/index.d.ts +116 -0
- package/dist/react/native/index.js +130 -0
- package/dist/react/server.d.ts +28 -10
- package/dist/react/server.js +22 -5
- package/dist/react/session.d.ts +2 -2
- package/dist/react-4ZqkP8da.js +79 -0
- package/dist/react-F80mF4-t.js +409 -0
- package/dist/react-router/index.d.ts +83 -0
- package/dist/react-router/index.js +89 -0
- package/dist/react-topic-seAWjL3_.js +38 -0
- package/dist/{read-set-DQFt1N_Y.js → read-set-DuKWC9Mj.js} +67 -25
- package/dist/realtime/index.d.ts +4 -5
- package/dist/realtime/index.js +144 -82
- package/dist/refusal-CeJWxmkH.js +61 -0
- package/dist/refusal-D1rgbzNq.d.ts +15 -0
- package/dist/replicas-CwrYp5Zj.js +94 -0
- package/dist/{view-Bn6HNKUe.js → resolve-D_IcnvFd.js} +248 -240
- package/dist/{impersonation-BmePsuxz.d.ts → resolve-KW8nCeL6.d.ts} +29 -69
- package/dist/{resource-mqfJW4YF.js → resource-BsvzCMlX.js} +4 -4
- package/dist/{resource-DNRFP7KR.d.ts → resource-D6Uc7Sk7.d.ts} +5 -5
- package/dist/respond-BUgsXNgk.js +91 -0
- package/dist/respond-DoBijysA.d.ts +93 -0
- package/dist/result-D7y2fT87.d.ts +282 -0
- package/dist/{result-BKiliPY7.js → result-DzUbqgdW.js} +27 -2
- package/dist/run-state-CPo-1X2B.d.ts +12 -0
- package/dist/run-state-CgBKzYMW.js +13 -0
- package/dist/search-CUWCRYKL.js +9 -0
- package/dist/server/index.d.ts +143 -22
- package/dist/server/index.js +46 -14
- package/dist/{respond-BR1fJWOB.d.ts → server-CeVeeXwz.d.ts} +224 -85
- package/dist/session-ChiXDZdZ.d.ts +72 -0
- package/dist/shared-CBxosKK3.js +75 -0
- package/dist/{shared-D1dRUrAg.js → shared-CxWmcyqB.js} +2 -1
- package/dist/shared-DDSjEGDR.d.ts +11 -0
- package/dist/sign-in-BCV5S2WZ.d.ts +49 -0
- package/dist/sign-in-Cb1mwv6X.js +86 -0
- package/dist/{signers-Cc6r98SW.d.ts → signers-4y62_VIi.d.ts} +13 -9
- package/dist/simplify-DDO0j0LI.js +83 -0
- package/dist/solid/index.d.ts +131 -0
- package/dist/solid/index.js +344 -0
- package/dist/solid-start/index.d.ts +75 -0
- package/dist/solid-start/index.js +58 -0
- package/dist/{spec-pins-C3oc-Tdp.js → spec-pins--j6mTok4.js} +8 -1
- package/dist/{spec-pins-CqorWDnO.d.ts → spec-pins-C89s6DyW.d.ts} +7 -0
- package/dist/sql/index.d.ts +313 -212
- package/dist/sql/index.js +34872 -6579
- package/dist/ssr/index.d.ts +6 -5
- package/dist/ssr/index.js +3 -3
- package/dist/{standard-D7r6dMUu.js → standard-BK0cqJ7y.js} +4 -3
- package/dist/{standard-J4YgMOLm.d.ts → standard-CYsm7xpT.d.ts} +1 -1
- package/dist/stats-D_AD9GGu.js +1123 -0
- package/dist/storage/index.d.ts +5 -5
- package/dist/storage/index.js +1027 -368
- package/dist/store-BdH5ruDx.js +66 -0
- package/dist/store-CGjgWu5n.d.ts +68 -0
- package/dist/streams/index.d.ts +76 -0
- package/dist/streams/index.js +77 -0
- package/dist/streams/redis/index.d.ts +52 -0
- package/dist/streams/redis/index.js +185 -0
- package/dist/stripe-BMj5LtsT.js +38 -0
- package/dist/stripe-D04h-QBT.d.ts +136 -0
- package/dist/{support-wHnK6zp-.js → support-BivNqSpK.js} +32 -11
- package/dist/{support-view-B8SlToQX.d.ts → support-view-33Oi07TT.d.ts} +1 -1
- package/dist/svelte/index.d.ts +114 -0
- package/dist/svelte/index.js +344 -0
- package/dist/sveltekit/index.d.ts +116 -0
- package/dist/sveltekit/index.js +106 -0
- package/dist/tags-B99_U9Pp.js +39 -0
- package/dist/tags-Cx-LnIQP.d.ts +30 -0
- package/dist/tanstack-db/index.d.ts +71 -0
- package/dist/tanstack-db/index.js +72 -0
- package/dist/tanstack-start/index.d.ts +75 -0
- package/dist/tanstack-start/index.js +95 -0
- package/dist/tee-CoEppCER.js +101 -0
- package/dist/{template-TxrGI5jR.js → template--jDkl3UP.js} +34 -13
- package/dist/template-CwO38Do8.d.ts +6 -0
- package/dist/temporal-BJGitm-h.js +79 -0
- package/dist/{temporal-required-CAt4Dc9Z.js → temporal-required-BVvW7SYJ.js} +3 -2
- package/dist/tenant-I1TEZ_BX.js +27 -0
- package/dist/tenant-icPeuUBN.d.ts +17 -0
- package/dist/testing/index.d.ts +315 -41
- package/dist/testing/index.js +1046 -79
- package/dist/timing-CyTQPubY.js +92 -0
- package/dist/ttl-Hr34gRu8.js +12 -0
- package/dist/{types-Dnf_wRDz.d.ts → types-6BdhkmZe.d.ts} +111 -45
- package/dist/types-BI-0Sl2v.d.ts +359 -0
- package/dist/types-C05bxW-W.d.ts +220 -0
- package/dist/types-DGs5L0VN.d.ts +192 -0
- package/dist/types-TaiEeBGA.d.ts +156 -0
- package/dist/usage-CrvrL2vw.d.ts +177 -0
- package/dist/user-change-XzvgafU4.js +130 -0
- package/dist/vault-CSO7ss5C.d.ts +50 -0
- package/dist/vercel-connect/index.d.ts +47 -0
- package/dist/vercel-connect/index.js +119 -0
- package/dist/{verify-Ce49ETPz.js → verify-DL41hbvY.js} +85 -13
- package/dist/verify-DPuWngD4.d.ts +172 -0
- package/dist/{version-DOYTYwHP.js → version-Bvv6aLjZ.js} +1 -1
- package/dist/view-BeFcRV6G.js +99 -0
- package/dist/{view-CbgObAeb.d.ts → view-DbxLNu8L.d.ts} +22 -5
- package/dist/vue/index.d.ts +141 -0
- package/dist/vue/index.js +362 -0
- package/dist/watch-DI34G86j.d.ts +60 -0
- package/dist/watch-Xuxrvxq-.js +122 -0
- package/dist/{wire-D2dT86vi.js → wire-DtZQ4sdE.js} +15 -5
- package/dist/workflow-builder-DJ1oHn8g.js +398 -0
- package/dist/workflow-sdk/builder/index.d.ts +85 -0
- package/dist/workflow-sdk/builder/index.js +250 -0
- package/dist/workflow-sdk/index.d.ts +14 -0
- package/dist/workflow-sdk/index.js +3 -0
- package/dist/workflow-sdk/world/index.d.ts +107 -0
- package/dist/workflow-sdk/world/index.js +702 -0
- package/dist/workflow-sdk-B3yLD6Br.d.ts +98 -0
- package/dist/workflow-sdk-C2WJ_G2O.js +173 -0
- package/dist/workflow-sdk-world-D-prir6O.js +1397 -0
- package/dist/workflows-B0j9WKPu.d.ts +185 -0
- package/dist/workflows-wu1WWBn7.js +216 -0
- package/package.json +461 -20
- package/schemas/ai-message-v1.json +258 -0
- package/schemas/config-v1.json +1074 -140
- package/schemas/doctor-report-v1.json +20 -1
- package/schemas/snapshot-v2.json +8 -0
- package/skills/better-supabase/SKILL.md +41 -12
- package/skills/better-supabase/references/frontend.md +96 -0
- package/skills/better-supabase/references/plugins.md +10 -6
- package/skills/better-supabase/references/troubleshooting.md +2 -1
- package/skills/better-supabase-api/SKILL.md +76 -22
- package/skills/better-supabase-api/references/adapters.md +207 -0
- package/skills/better-supabase-api/references/ai.md +125 -0
- package/skills/better-supabase-api/references/expo.md +65 -0
- package/skills/better-supabase-api/references/powersync.md +35 -0
- package/skills/better-supabase-api/references/streams-credentials.md +105 -0
- package/skills/better-supabase-auth/SKILL.md +47 -11
- package/skills/better-supabase-auth/references/authorization-providers.md +51 -0
- package/skills/better-supabase-testing/SKILL.md +3 -1
- package/dist/cli/config-dtoekM0v.js +0 -2
- package/dist/cli/gen-CIdSQZad.js +0 -2
- package/dist/cli/gen-CjElD1Vw.js +0 -2241
- package/dist/cli/read-sets-VQcPgxU9.js +0 -436
- package/dist/cli/sql-CVx-1O8G.js +0 -373
- package/dist/entitlements-CTEBm7IB.js +0 -20
- package/dist/events-aIO7vsIk.d.ts +0 -230
- package/dist/executor-BunPOO-k.d.ts +0 -402
- package/dist/executor-XycESQPH.js +0 -94
- package/dist/index-CWMDdEr4.d.ts +0 -50
- package/dist/index-DOESKG27.d.ts +0 -100
- package/dist/index-DmXSxbLi.d.ts +0 -62
- package/dist/index-Dp2VDwog.d.ts +0 -161
- package/dist/jobs/index.d.ts +0 -226
- package/dist/jobs/index.js +0 -1007
- package/dist/json-schema-m1fiwKgN.js +0 -208
- package/dist/kit-events-CTFDfSwb.js +0 -60
- package/dist/kit-transport-B82k1djG.d.ts +0 -33
- package/dist/kits-CGLLrNPd.js +0 -157
- package/dist/mfa-Cu96oJdw.d.ts +0 -30
- package/dist/notifications/index.d.ts +0 -162
- package/dist/notifications/index.js +0 -264
- package/dist/orgs/index.d.ts +0 -132
- package/dist/orgs/index.js +0 -245
- package/dist/permdock-sql-C2Inc6Xc.d.ts +0 -29
- package/dist/permdock-sql-DgOfeixi.js +0 -51
- package/dist/plugin-DwARMuQp.js +0 -59
- package/dist/server-B1mZ7CgQ.js +0 -660
- package/dist/stats-Bqob3WqT.js +0 -592
- package/dist/support-CEPwlw2G.d.ts +0 -89
- package/dist/template-mbvlysOo.d.ts +0 -6
- package/dist/temporal-CVlIV2DZ.js +0 -40
- package/dist/user-change-CHFEUro1.js +0 -49
- package/dist/webhooks/index.d.ts +0 -285
- package/skills/better-supabase-auth/references/permdock.md +0 -122
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { a as list, r as defineCliCommand } from "./command-CK14fP91.js";
|
|
2
2
|
import { n as CliError } from "./errors-4v4DTtda.js";
|
|
3
|
-
import {
|
|
3
|
+
import { i as writeIfChanged, t as display } from "./io-DI1mTdSA.js";
|
|
4
4
|
import { i as plain, r as painter } from "./style-C0NFdJRL.js";
|
|
5
|
-
import { t as VERSION } from "./version-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { t as VERSION } from "./version-hr88OZG0.js";
|
|
6
|
+
import { s as providerLabel, t as compiledReadSets } from "./read-sets-CETOSFx4.js";
|
|
7
|
+
import { s as stdinDatabaseUrl } from "./config-NiPxAmUF.js";
|
|
8
|
+
import { c as migrationCommand, d as schemaPaths, i as findRepoRoot, l as pgFunctionHooks, n as declarativeSchemasDir, p as tomlGet, r as diffEngine, s as httpHooks, u as readSupabaseToml } from "./supabase-toml-B-bIukQF.js";
|
|
8
9
|
import { t as byCodePoint } from "./compare-BmIWVEui.js";
|
|
9
|
-
import { A as
|
|
10
|
+
import { A as exposed, C as MetadataRejectedError, D as accessModule, E as AUTHORIZATION_RULES, F as qualified, I as tableObject, M as lineOf, N as permissionCatalogKeys, O as entitlementsModule, P as policyObject, S as configFor, _ as METADATA_GAPS_TEXT, a as renderFiles, c as loadSnapshot, j as exposedSchemas, k as catalogOf, l as managementTarget, p as snapshotFile, r as readMetadataSnapshot, u as openSource, v as CACHE_DIR } from "./gen-BGLb_zou.js";
|
|
10
11
|
import { n as withSpinner } from "./prompts-BNcNfAve.js";
|
|
11
12
|
import { t as connect } from "./db-DOBPt660.js";
|
|
12
13
|
import { dirname, resolve } from "node:path";
|
|
13
14
|
import { existsSync } from "node:fs";
|
|
14
15
|
import { glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
15
|
-
import { SQL_MODULES, contractSignature, customContracts, kitDeprecations, kitFileVersion, kitLayout, migrationOptionUses, moduleVersion, renderKit, sameKitFile } from "better-supabase/sql";
|
|
16
16
|
import { DEFAULT_ACTIVE_TENANT, tenantClaimPaths } from "better-supabase/config";
|
|
17
|
+
import { SQL_MODULES, contractSignature, customContracts, migrationOptionUses, moduleDeprecations, moduleEventTriggers, moduleFileVersion, moduleLayout, moduleVersion, renderModules, sameModuleFile, sharedRolesProblems } from "better-supabase/sql";
|
|
17
18
|
import { createHash } from "node:crypto";
|
|
18
19
|
import { defineBucket, parseSize } from "better-supabase/storage";
|
|
19
20
|
//#region src/cli/doctor/advisors.ts
|
|
@@ -46,6 +47,17 @@ function managementAdvisors(options) {
|
|
|
46
47
|
}
|
|
47
48
|
};
|
|
48
49
|
}
|
|
50
|
+
/** The `[api] schemas` list of a `config.toml`, when it names any. */
|
|
51
|
+
function apiSchemas(document) {
|
|
52
|
+
const value = document ? tomlGet(document, ["api", "schemas"]) : void 0;
|
|
53
|
+
if (!Array.isArray(value)) return [];
|
|
54
|
+
return value.filter((schema) => typeof schema === "string" && schema.trim() !== "");
|
|
55
|
+
}
|
|
56
|
+
const literal$1 = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
57
|
+
/** The read-only transaction splinter runs in, with `pgrst.db_schemas` set first. */
|
|
58
|
+
function splinterScript(sql, schemas = []) {
|
|
59
|
+
return `begin read only;\n${schemas.length > 0 ? `set local pgrst.db_schemas = ${literal$1(schemas.join(","))};\n` : ""}${sql}\n;\nrollback;`;
|
|
60
|
+
}
|
|
49
61
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
50
62
|
/** Downloads (once) and verifies the pinned `splinter.sql`. */
|
|
51
63
|
async function splinterSql(options) {
|
|
@@ -75,7 +87,7 @@ function splinterAdvisors(queryable, describe, options) {
|
|
|
75
87
|
let all;
|
|
76
88
|
const run = async () => {
|
|
77
89
|
const sql = await splinterSql(options);
|
|
78
|
-
return lintRows(await queryable.query(
|
|
90
|
+
return lintRows(await queryable.query(splinterScript(sql, options.schemas))).filter(isLint);
|
|
79
91
|
};
|
|
80
92
|
return {
|
|
81
93
|
describe: `${describe} (splinter ${SPLINTER_COMMIT.slice(0, 7)})`,
|
|
@@ -87,6 +99,153 @@ function splinterAdvisors(queryable, describe, options) {
|
|
|
87
99
|
};
|
|
88
100
|
}
|
|
89
101
|
//#endregion
|
|
102
|
+
//#region src/cli/doctor/advisor-rules.ts
|
|
103
|
+
/** One request per advisor category, shared by the advisor rules and the rules that defer to them. */
|
|
104
|
+
const fetched = /* @__PURE__ */ new WeakMap();
|
|
105
|
+
function lintsOf(advisors, category) {
|
|
106
|
+
let byCategory = fetched.get(advisors);
|
|
107
|
+
if (!byCategory) {
|
|
108
|
+
byCategory = /* @__PURE__ */ new Map();
|
|
109
|
+
fetched.set(advisors, byCategory);
|
|
110
|
+
}
|
|
111
|
+
let lints = byCategory.get(category);
|
|
112
|
+
if (!lints) {
|
|
113
|
+
lints = advisors.lints(category);
|
|
114
|
+
byCategory.set(category, lints);
|
|
115
|
+
}
|
|
116
|
+
return lints;
|
|
117
|
+
}
|
|
118
|
+
const ADVISOR_SEVERITY = {
|
|
119
|
+
ERROR: "error",
|
|
120
|
+
WARN: "warning",
|
|
121
|
+
INFO: "info"
|
|
122
|
+
};
|
|
123
|
+
function lintObject(lint) {
|
|
124
|
+
const { schema, name, type } = lint.metadata ?? {};
|
|
125
|
+
if (typeof schema !== "string" || typeof name !== "string") return void 0;
|
|
126
|
+
if (type === "table" || type === "view") return {
|
|
127
|
+
kind: "table",
|
|
128
|
+
schema,
|
|
129
|
+
name
|
|
130
|
+
};
|
|
131
|
+
if (type === "function") return {
|
|
132
|
+
kind: "function",
|
|
133
|
+
schema,
|
|
134
|
+
name
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function lintFinding(lint) {
|
|
138
|
+
const object = lintObject(lint);
|
|
139
|
+
return {
|
|
140
|
+
severity: ADVISOR_SEVERITY[lint.level] ?? "warning",
|
|
141
|
+
title: lint.title,
|
|
142
|
+
message: `${lint.detail.replaceAll("\\`", "`")} [${lint.name}]`,
|
|
143
|
+
target: object ? `${object.schema}.${object.name}` : lint.cache_key,
|
|
144
|
+
help: lint.remediation,
|
|
145
|
+
...object ? { object } : {}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** A rule that reports one category of Supabase's advisors (splinter) as findings. */
|
|
149
|
+
function advisorRule(code, category, title, description) {
|
|
150
|
+
return {
|
|
151
|
+
code,
|
|
152
|
+
severity: "warning",
|
|
153
|
+
title,
|
|
154
|
+
description,
|
|
155
|
+
async check(context) {
|
|
156
|
+
const { advisors } = context;
|
|
157
|
+
if (!advisors) return [];
|
|
158
|
+
if ("skipped" in advisors) return [{
|
|
159
|
+
severity: "info",
|
|
160
|
+
message: `Skipped the ${category} advisor: ${advisors.skipped}`
|
|
161
|
+
}];
|
|
162
|
+
try {
|
|
163
|
+
return (await lintsOf(advisors, category)).map(lintFinding);
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
return [{ message: `The ${category} advisor could not run (${advisors.describe}): ${cause instanceof Error ? cause.message : String(cause)}` }];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** The performance advisor rule the duplicate rules defer to. */
|
|
171
|
+
const PERFORMANCE_ADVISOR = "BS200";
|
|
172
|
+
/**
|
|
173
|
+
* The tables splinter reports `lint` for (`schema.table`), when the
|
|
174
|
+
* performance advisor runs in this run and its lints loaded. A rule that
|
|
175
|
+
* repeats the lint (BS207, BS216) drops its findings for these tables; with
|
|
176
|
+
* `undefined` (no advisors, a saved snapshot, an advisor error, or BS200 not
|
|
177
|
+
* in the run) it reports them itself.
|
|
178
|
+
*/
|
|
179
|
+
async function splinterTables(context, lint) {
|
|
180
|
+
const { advisors } = context;
|
|
181
|
+
if (!advisors || "skipped" in advisors) return void 0;
|
|
182
|
+
if (!context.codes?.includes(PERFORMANCE_ADVISOR)) return void 0;
|
|
183
|
+
let lints;
|
|
184
|
+
try {
|
|
185
|
+
lints = await lintsOf(advisors, "performance");
|
|
186
|
+
} catch {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
return new Set(lints.flatMap((entry) => {
|
|
190
|
+
if (entry.name !== lint) return [];
|
|
191
|
+
const object = lintObject(entry);
|
|
192
|
+
return object ? [`${object.schema}.${object.name}`] : [];
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/cli/doctor/cookies.ts
|
|
197
|
+
const TOKENS_ONLY = /\bencode\s*:\s*["'`]tokens-only["'`]/;
|
|
198
|
+
/** Imports that create the browser client, which writes the session cookie in the browser. */
|
|
199
|
+
const BROWSER_SIDE = /from\s+["']better-supabase\/client["']|\bcreateBrowserClient\s*\(/;
|
|
200
|
+
/** Imports that read or refresh the session cookie on the server. */
|
|
201
|
+
const SERVER_SIDE = /from\s+["']better-supabase\/(?:server|next|ssr|hono|orpc|edge|expo)["']|\bcreateServerClient\s*\(/;
|
|
202
|
+
function cookieSide(file) {
|
|
203
|
+
const browser = BROWSER_SIDE.test(file.text);
|
|
204
|
+
if (browser === SERVER_SIDE.test(file.text)) return void 0;
|
|
205
|
+
return browser ? "browser" : "server";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Files on one side of the session cookie that set `encode: 'tokens-only'`
|
|
209
|
+
* while files on the other side don't (BS412).
|
|
210
|
+
*/
|
|
211
|
+
function encodingDrift(context) {
|
|
212
|
+
const sides = context.sources.flatMap((file) => {
|
|
213
|
+
const side = cookieSide(file);
|
|
214
|
+
return side ? [{
|
|
215
|
+
file,
|
|
216
|
+
side,
|
|
217
|
+
tokensOnly: TOKENS_ONLY.test(file.text)
|
|
218
|
+
}] : [];
|
|
219
|
+
});
|
|
220
|
+
const tokensOnly = sides.filter((entry) => entry.tokensOnly);
|
|
221
|
+
if (tokensOnly.length === 0) return [];
|
|
222
|
+
const findings = [];
|
|
223
|
+
for (const side of ["browser", "server"]) {
|
|
224
|
+
const set = tokensOnly.find((entry) => entry.side === side);
|
|
225
|
+
if (!set) continue;
|
|
226
|
+
const other = side === "browser" ? "server" : "browser";
|
|
227
|
+
const unset = sides.filter((entry) => entry.side === other && !entry.tokensOnly);
|
|
228
|
+
if (unset.length === 0) continue;
|
|
229
|
+
const line = lineOf(set.file.text, TOKENS_ONLY) ?? 1;
|
|
230
|
+
findings.push({
|
|
231
|
+
message: `${set.file.path} writes the session cookie with \`encode: 'tokens-only'\`, but the ${other} side (${unset.map((entry) => entry.file.path).join(", ")}) uses the default \`user-and-tokens\`. Set the same \`encode\` on both sides.`,
|
|
232
|
+
target: `${set.file.path}:encode`,
|
|
233
|
+
location: {
|
|
234
|
+
file: set.file.path,
|
|
235
|
+
line
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return findings;
|
|
240
|
+
}
|
|
241
|
+
const COOKIE_RULES = [{
|
|
242
|
+
code: "BS412",
|
|
243
|
+
severity: "info",
|
|
244
|
+
title: "Session cookie encoding differs between server and browser",
|
|
245
|
+
description: "`@supabase/ssr` needs the same `cookies.encode` on the server and in the browser client. With `tokens-only` on one side only, the other side writes the user object back into the cookie, or reads a session without one and `session.user` throws.",
|
|
246
|
+
check: encodingDrift
|
|
247
|
+
}];
|
|
248
|
+
//#endregion
|
|
90
249
|
//#region src/cli/doctor/live.ts
|
|
91
250
|
/** Statements slower than this (ms, mean) with more calls than `SLOW_CALLS` are BS209. */
|
|
92
251
|
const SLOW_MEAN_MS = 50;
|
|
@@ -316,81 +475,61 @@ const LIVE_RULES = [
|
|
|
316
475
|
//#region src/cli/doctor/hooks.ts
|
|
317
476
|
/** Claims past this size make every request carry a large cookie and header. */
|
|
318
477
|
const HOOK_CLAIMS_LIMIT = 2048;
|
|
319
|
-
/** PermDock's default `supabase.hook.budget`, over `memberships` plus `attrs`. */
|
|
320
|
-
const PERMDOCK_CLAIMS_LIMIT = 1024;
|
|
321
478
|
/**
|
|
322
|
-
* The BS405 limits. With
|
|
323
|
-
* budget)
|
|
324
|
-
*
|
|
479
|
+
* The BS405 limits. With a provider hook budget, `doctor.claimsLimit` (else
|
|
480
|
+
* the budget's bytes) limits its claims and the whole token keeps 2048;
|
|
481
|
+
* without one, `doctor.claimsLimit` limits the whole token.
|
|
325
482
|
*/
|
|
326
483
|
function claimsLimits(context) {
|
|
327
484
|
const configured = context.config.doctor.claimsLimit;
|
|
328
|
-
|
|
485
|
+
const budget = providerHook(context)?.budget;
|
|
486
|
+
return budget ? {
|
|
329
487
|
token: HOOK_CLAIMS_LIMIT,
|
|
330
|
-
budget:
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
488
|
+
budget: {
|
|
489
|
+
bytes: configured ?? budget.bytes,
|
|
490
|
+
claims: budget.claims,
|
|
491
|
+
...budget.truncatedClaim ? { truncatedClaim: budget.truncatedClaim } : {}
|
|
492
|
+
}
|
|
493
|
+
} : { token: configured ?? HOOK_CLAIMS_LIMIT };
|
|
335
494
|
}
|
|
336
|
-
/** The
|
|
337
|
-
const
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
const
|
|
495
|
+
/** The module function that fills the `memberships` claim. */
|
|
496
|
+
const MODULE_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
|
|
497
|
+
const providerHook = (context) => context.config.authorization?.tokenHook;
|
|
498
|
+
const splitName = (qualified) => {
|
|
499
|
+
const dot = qualified.indexOf(".");
|
|
500
|
+
return {
|
|
501
|
+
schema: qualified.slice(0, dot),
|
|
502
|
+
name: qualified.slice(dot + 1)
|
|
503
|
+
};
|
|
504
|
+
};
|
|
341
505
|
const escapeRegExp$1 = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
342
506
|
/**
|
|
343
|
-
* The claims
|
|
344
|
-
*
|
|
345
|
-
* claim PermDock's hook really writes.
|
|
507
|
+
* The claims the provider's hook writes that a second writer would
|
|
508
|
+
* contradict, with the tenant claim it writes to.
|
|
346
509
|
*/
|
|
347
|
-
function ownedClaims(
|
|
348
|
-
|
|
349
|
-
const tenant = manifest?.rls?.tenantClaim ?? manifest?.tenantClaim ?? context.config.claims.tenant;
|
|
350
|
-
return [.../* @__PURE__ */ new Set([
|
|
351
|
-
"roles",
|
|
352
|
-
"user_role",
|
|
353
|
-
"memberships",
|
|
354
|
-
tenant,
|
|
355
|
-
context.config.claims.tenant
|
|
356
|
-
])];
|
|
510
|
+
function ownedClaims(hook) {
|
|
511
|
+
return [.../* @__PURE__ */ new Set([...hook.ownedClaims, ...hook.tenantClaim ? [hook.tenantClaim] : []])];
|
|
357
512
|
}
|
|
358
513
|
const quotedName = (schema, name) => `(?:"?${escapeRegExp$1(schema)}"?\\s*\\.\\s*)?"?${escapeRegExp$1(name)}"?`;
|
|
359
514
|
const createsFunction = (fn) => new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
|
|
360
|
-
/** The SQL file that
|
|
515
|
+
/** The SQL file that has the provider's hook marker and creates `fn`. */
|
|
361
516
|
function markerFileFor(context, fn) {
|
|
517
|
+
const marker = providerHook(context)?.markers?.hook;
|
|
518
|
+
if (marker === void 0) return void 0;
|
|
362
519
|
const creates = createsFunction(fn);
|
|
363
|
-
return (context.sqlFiles ?? []).find((file) =>
|
|
520
|
+
return (context.sqlFiles ?? []).find((file) => file.text.includes(marker) && creates.test(file.text));
|
|
364
521
|
}
|
|
365
|
-
/** Whether `fn` is the hook
|
|
366
|
-
function
|
|
367
|
-
const hook = context
|
|
368
|
-
if (hook
|
|
369
|
-
if (
|
|
370
|
-
return fn
|
|
522
|
+
/** Whether `fn` is the provider's hook: by its name or its marker file. */
|
|
523
|
+
function isProviderHook(context, fn) {
|
|
524
|
+
const hook = providerHook(context);
|
|
525
|
+
if (!hook) return false;
|
|
526
|
+
if (hook.function === `${fn.schema}.${fn.name}`) return true;
|
|
527
|
+
return markerFileFor(context, fn) !== void 0;
|
|
371
528
|
}
|
|
372
|
-
/**
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
*/
|
|
377
|
-
function registeredClaims(context) {
|
|
378
|
-
const claims = /* @__PURE__ */ new Map();
|
|
379
|
-
for (const claim of context.permdock?.manifest?.claims ?? []) if (claim.source !== "permdock") claims.set(claim.name, claim.source);
|
|
380
|
-
if (claims.size > 0) return claims;
|
|
381
|
-
const owned = /* @__PURE__ */ new Set([
|
|
382
|
-
...ownedClaims(context),
|
|
383
|
-
"memberships_truncated",
|
|
384
|
-
"attrs",
|
|
385
|
-
"authz_ver"
|
|
386
|
-
]);
|
|
387
|
-
for (const file of context.sqlFiles ?? []) for (const name of parseHookMarker(file.text)?.claims ?? []) if (!owned.has(name)) claims.set(name, void 0);
|
|
388
|
-
return claims;
|
|
389
|
-
}
|
|
390
|
-
/** Whether the hook body calls PermDock's generated hook (a wrapper around it). */
|
|
391
|
-
function wrapsPermdockHook(context, source) {
|
|
392
|
-
const hook = context.permdock?.manifest?.hook;
|
|
393
|
-
return hook !== void 0 && new RegExp(`${quotedName(hook.schema, hook.function)}\\s*\\(`, "i").test(source);
|
|
529
|
+
/** Whether the hook body calls the provider's hook (a wrapper around it). */
|
|
530
|
+
function wrapsProviderHook(hook, source) {
|
|
531
|
+
const { schema, name } = splitName(hook.function);
|
|
532
|
+
return new RegExp(`${quotedName(schema, name)}\\s*\\(`, "i").test(source);
|
|
394
533
|
}
|
|
395
534
|
/** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
|
|
396
535
|
function writtenClaims(source, claims) {
|
|
@@ -399,7 +538,7 @@ function writtenClaims(source, claims) {
|
|
|
399
538
|
return new RegExp(`jsonb_set\\s*\\([^;]*?'\\{\\s*(?:claims\\s*,\\s*)?${name}\\s*\\}'`, "i").test(source) || new RegExp(`jsonb_build_object\\s*\\([^;]*?'${name}'\\s*,`, "i").test(source);
|
|
400
539
|
});
|
|
401
540
|
}
|
|
402
|
-
const API_ROLES$
|
|
541
|
+
const API_ROLES$2 = ["authenticated", "anon"];
|
|
403
542
|
/** Hooks enabled in `config.toml`, matched with their introspected functions. */
|
|
404
543
|
function configuredHooks(context) {
|
|
405
544
|
if (!context.configToml) return [];
|
|
@@ -430,7 +569,7 @@ function grantProblems(fn) {
|
|
|
430
569
|
problems.push("supabase_auth_admin may not execute it");
|
|
431
570
|
fix.push(`grant execute on function ${signatureOf(fn)} to supabase_auth_admin;`);
|
|
432
571
|
}
|
|
433
|
-
const exposedTo = API_ROLES$
|
|
572
|
+
const exposedTo = API_ROLES$2.filter((role) => fn.execute.includes(role));
|
|
434
573
|
if (exposedTo.length > 0 || fn.publicExecute) {
|
|
435
574
|
const roles = [...exposedTo, ...fn.publicExecute ? ["public"] : []];
|
|
436
575
|
problems.push(`${roles.join(", ")} may execute it, so any client can call it through the API`);
|
|
@@ -443,18 +582,17 @@ function grantProblems(fn) {
|
|
|
443
582
|
}
|
|
444
583
|
const MIGRATIONS = /(?:^|\/)supabase\/migrations\//;
|
|
445
584
|
/**
|
|
446
|
-
* The file that grants `fn` to `supabase_auth_admin`: a
|
|
447
|
-
*
|
|
585
|
+
* The file that grants `fn` to `supabase_auth_admin`: a migration with the
|
|
586
|
+
* provider's grants marker, or under pg-delta, which carries grants, the declarative
|
|
448
587
|
* schema file that defines `fn`. `supabase db diff` drops grants, so the
|
|
449
588
|
* legacy engine only counts the migration.
|
|
450
589
|
*/
|
|
451
590
|
function grantsSourceFor(context, fn, engine) {
|
|
452
591
|
const grants = new RegExp(`grant\\s+execute\\s+on\\s+function\\s+${quotedName(fn.schema, fn.name)}\\s*\\([^)]*\\)\\s*to\\s+[^;]*\\bsupabase_auth_admin\\b`, "i");
|
|
453
592
|
const creates = createsFunction(fn);
|
|
454
|
-
|
|
593
|
+
const marker = providerHook(context)?.markers?.grants;
|
|
594
|
+
return (context.sqlFiles ?? []).find((file) => grants.test(file.text) && (marker !== void 0 && file.text.includes(marker) || engine === "pg-delta" && !MIGRATIONS.test(file.path) && creates.test(file.text)))?.path;
|
|
455
595
|
}
|
|
456
|
-
const PERMDOCK_GRANTS_COMMAND = "permdock supabase hook generate --grants-out supabase/migrations/<timestamp>_permdock_hook_grants.sql";
|
|
457
|
-
const permdockSchemaCommand = (file) => `permdock supabase hook generate --out ${file ?? "<the schema file that defines it>"}`;
|
|
458
596
|
/** Configured hook functions whose grants are wrong (BS404, `doctor --fix-grants`). */
|
|
459
597
|
function hookGrantProblems(context) {
|
|
460
598
|
const engine = diffEngine(context.configToml);
|
|
@@ -462,7 +600,8 @@ function hookGrantProblems(context) {
|
|
|
462
600
|
const { problems, fix } = grantProblems(fn);
|
|
463
601
|
if (problems.length === 0) return [];
|
|
464
602
|
const file = grantsSourceFor(context, fn, engine);
|
|
465
|
-
const
|
|
603
|
+
const provider = context.config.authorization;
|
|
604
|
+
const command = provider?.tokenHook?.grantsCommand;
|
|
466
605
|
return [{
|
|
467
606
|
hook: config.hook,
|
|
468
607
|
fn,
|
|
@@ -470,9 +609,10 @@ function hookGrantProblems(context) {
|
|
|
470
609
|
fix: file ? {
|
|
471
610
|
kind: "migration",
|
|
472
611
|
file
|
|
473
|
-
} :
|
|
474
|
-
kind: "
|
|
475
|
-
|
|
612
|
+
} : provider && isProviderHook(context, fn) ? {
|
|
613
|
+
kind: "provider",
|
|
614
|
+
name: providerLabel(provider),
|
|
615
|
+
...command ? { command } : {}
|
|
476
616
|
} : {
|
|
477
617
|
kind: "sql",
|
|
478
618
|
sql: fix
|
|
@@ -484,13 +624,13 @@ function hookGrantProblems(context) {
|
|
|
484
624
|
function grantFixText(fix, engine) {
|
|
485
625
|
switch (fix.kind) {
|
|
486
626
|
case "sql": return engine === "pg-delta" ? `Add to the schema file that defines the function, then run \`supabase db schema declarative sync\` (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}` : `Append to the migration \`supabase db diff\` wrote (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}`;
|
|
487
|
-
case "
|
|
627
|
+
case "provider": return fix.command ? `It is the hook of ${fix.name}, so let the provider write the grants: \`${fix.command}\`.` : `It is the hook of ${fix.name}, so let the provider write the grants.`;
|
|
488
628
|
case "migration": return engine === "pg-delta" ? `${fix.file} grants it, so the database is behind: run \`supabase db schema declarative sync\`, then apply the migrations (\`supabase migration up\`).` : `${fix.file} grants it, so the database is behind the migrations: apply them (\`supabase migration up\`).`;
|
|
489
629
|
default: return fix;
|
|
490
630
|
}
|
|
491
631
|
}
|
|
492
632
|
/** `doctor --fix-grants`: the SQL block for every BS404 problem, as one migration snippet. */
|
|
493
|
-
function hookGrantBlock(problems, engine = "
|
|
633
|
+
function hookGrantBlock(problems, engine = "pg-delta") {
|
|
494
634
|
if (problems.length === 0) return "-- Every configured Auth hook function has its grants.";
|
|
495
635
|
const lines = ["-- Auth hook grants (better-supabase doctor --fix-grants).", engine === "pg-delta" ? "-- Add this to the schema file that defines the function, then run `supabase db schema declarative sync`." : "-- `supabase db diff` does not carry function grants; append this to its migration."];
|
|
496
636
|
for (const { fn, hook, fix } of problems) {
|
|
@@ -499,8 +639,8 @@ function hookGrantBlock(problems, engine = "migra") {
|
|
|
499
639
|
case "sql":
|
|
500
640
|
lines.push("", `-- ${name}`, ...fix.sql);
|
|
501
641
|
break;
|
|
502
|
-
case "
|
|
503
|
-
lines.push("",
|
|
642
|
+
case "provider":
|
|
643
|
+
lines.push("", `-- ${name} is the hook of ${fix.name}${fix.command ? `: ${fix.command}` : ""}`);
|
|
504
644
|
break;
|
|
505
645
|
case "migration":
|
|
506
646
|
lines.push("", engine === "pg-delta" ? `-- ${name}: ${fix.file} grants it; run \`supabase db schema declarative sync\`, then \`supabase migration up\`.` : `-- ${name}: ${fix.file} grants it; run \`supabase migration up\`.`);
|
|
@@ -563,18 +703,19 @@ async function hookClaims(db, fn, userId) {
|
|
|
563
703
|
return isRecord(claims) ? claims : {};
|
|
564
704
|
});
|
|
565
705
|
}
|
|
566
|
-
async function hookClaimsSize(db, fn, userId) {
|
|
706
|
+
async function hookClaimsSize(db, fn, userId, budget) {
|
|
707
|
+
const budgetSql = budget && budget.claims.length > 0 ? budget.claims.map((claim) => `coalesce(octet_length((c -> ${literal(claim)})::text), 0)`).join(" + ") : "0";
|
|
708
|
+
const truncatedSql = budget?.truncatedClaim ? `c ->> ${literal(budget.truncatedClaim)} = 'true'` : "false";
|
|
567
709
|
return withHookEvent(db, userId, async () => {
|
|
568
710
|
const [row] = await db.query(`select octet_length(c::text) as bytes,
|
|
569
711
|
octet_length((c -> 'memberships')::text) as memberships,
|
|
570
|
-
|
|
571
|
-
|
|
712
|
+
${budgetSql} as budget,
|
|
713
|
+
${truncatedSql} as truncated
|
|
572
714
|
from (select ${hookCall(fn)} as c) h`);
|
|
573
|
-
const memberships = Number(row?.memberships ?? 0);
|
|
574
715
|
return {
|
|
575
716
|
bytes: Number(row?.bytes ?? 0),
|
|
576
|
-
memberships,
|
|
577
|
-
budget:
|
|
717
|
+
memberships: Number(row?.memberships ?? 0),
|
|
718
|
+
budget: Number(row?.budget ?? 0),
|
|
578
719
|
truncated: row?.truncated === true
|
|
579
720
|
};
|
|
580
721
|
});
|
|
@@ -632,7 +773,7 @@ const HOOK_RULES = [
|
|
|
632
773
|
code: "BS405",
|
|
633
774
|
severity: "warning",
|
|
634
775
|
title: "Custom access token hook shape",
|
|
635
|
-
description: `Auth runs the custom access token hook on every sign-in and refresh. It should be \`stable\` with \`set search_path = ''\`, and the claims it returns end up in every request's cookie and Authorization header. With \`--as <user id>\` doctor calls it for that user in a transaction that is rolled back. It warns when the whole token's claims pass ${HOOK_CLAIMS_LIMIT} bytes (\`doctor.claimsLimit\` without
|
|
776
|
+
description: `Auth runs the custom access token hook on every sign-in and refresh. It should be \`stable\` with \`set search_path = ''\`, and the claims it returns end up in every request's cookie and Authorization header. With \`--as <user id>\` doctor calls it for that user in a transaction that is rolled back. It warns when the whole token's claims pass ${HOOK_CLAIMS_LIMIT} bytes (\`doctor.claimsLimit\` without a provider budget). When the authorization provider's hook has a \`budget\`, it also warns when the budget's claims pass its bytes (or \`doctor.claimsLimit\`), measured with \`octet_length\` of each claim.`,
|
|
636
777
|
async check(context) {
|
|
637
778
|
const findings = [];
|
|
638
779
|
const limits = claimsLimits(context);
|
|
@@ -663,7 +804,7 @@ const HOOK_RULES = [
|
|
|
663
804
|
continue;
|
|
664
805
|
}
|
|
665
806
|
try {
|
|
666
|
-
const size = await hookClaimsSize(db, fn, context.hookUser);
|
|
807
|
+
const size = await hookClaimsSize(db, fn, context.hookUser, limits.budget);
|
|
667
808
|
if (size === void 0) {
|
|
668
809
|
findings.push({
|
|
669
810
|
severity: "info",
|
|
@@ -672,8 +813,8 @@ const HOOK_RULES = [
|
|
|
672
813
|
});
|
|
673
814
|
continue;
|
|
674
815
|
}
|
|
675
|
-
if (limits.budget !== void 0 && size.budget > limits.budget) findings.push({
|
|
676
|
-
message: `${signatureOf(fn)} returns ${size.budget} bytes of
|
|
816
|
+
if (limits.budget !== void 0 && size.budget > limits.budget.bytes) findings.push({
|
|
817
|
+
message: `${signatureOf(fn)} returns ${size.budget} bytes of ${limits.budget.claims.join(" and ")} for ${context.hookUser}, over the hook's budget of ${limits.budget.bytes}. Raise the provider's budget and \`doctor.claimsLimit\` together, or move claims out of the token.`,
|
|
677
818
|
target: `${signatureOf(fn)}:budget`,
|
|
678
819
|
object
|
|
679
820
|
});
|
|
@@ -684,7 +825,7 @@ const HOOK_RULES = [
|
|
|
684
825
|
});
|
|
685
826
|
if (size.truncated) findings.push({
|
|
686
827
|
severity: "info",
|
|
687
|
-
message: `${signatureOf(fn)} sets
|
|
828
|
+
message: `${signatureOf(fn)} sets ${limits.budget?.truncatedClaim ?? "its truncated claim"} for ${context.hookUser}: the token lists only some entries (memberships ${size.memberships} bytes). Server checks for this user need a database lookup.`,
|
|
688
829
|
target: `${signatureOf(fn)}:truncated`,
|
|
689
830
|
object
|
|
690
831
|
});
|
|
@@ -704,42 +845,29 @@ const HOOK_RULES = [
|
|
|
704
845
|
code: "BS407",
|
|
705
846
|
severity: "error",
|
|
706
847
|
title: "Two authorization hooks",
|
|
707
|
-
description: "
|
|
848
|
+
description: "When the authorization provider's hook (`authorization.tokenHook`) owns claims such as `memberships`, `roles` or the tenant claim, a custom access token hook that also calls `better_supabase.membership_claims`, or writes one of those claims itself, gives them a second source that drifts from the provider's. Use the provider's hook and drop the extra writes. Claims the provider's hook fills from other functions (`registeredClaims`, such as `features` from `better_supabase.feature_claims`) are not reported; a hook that wraps the provider's and writes one of those claims again is.",
|
|
708
849
|
check: (context) => {
|
|
709
|
-
const
|
|
850
|
+
const provider = context.config.authorization;
|
|
851
|
+
const hook = provider?.tokenHook;
|
|
852
|
+
if (!provider || !hook) return [];
|
|
853
|
+
const label = providerLabel(provider);
|
|
854
|
+
const registered = new Map((hook.registeredClaims ?? []).map((claim) => [claim.name, claim.function]));
|
|
855
|
+
const owned = ownedClaims(hook);
|
|
710
856
|
const findings = [];
|
|
711
|
-
if (project) {
|
|
712
|
-
findings.push(...project.problems.filter((problem) => problem.startsWith(project.manifestPath)).map((problem) => ({
|
|
713
|
-
severity: "info",
|
|
714
|
-
message: `Could not read PermDock's manifest: ${problem}`,
|
|
715
|
-
target: project.manifestPath
|
|
716
|
-
})));
|
|
717
|
-
if (project.config && !project.manifest && !project.problems.some((problem) => problem.startsWith(project.manifestPath))) findings.push({
|
|
718
|
-
severity: "info",
|
|
719
|
-
message: `${project.config} is present but ${project.manifestPath} is not, so doctor and the SQL kit can't see PermDock's hook, helpers and membership sources. Run \`permdock supabase inspect --out\`, and \`permdock supabase inspect --check\` in CI.`,
|
|
720
|
-
target: project.manifestPath
|
|
721
|
-
});
|
|
722
|
-
}
|
|
723
|
-
const registered = registeredClaims(context);
|
|
724
857
|
for (const { config, extras } of configuredHooks(context)) {
|
|
725
858
|
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
726
859
|
for (const fn of extras.functions) {
|
|
727
|
-
if (fn.source === void 0 ||
|
|
728
|
-
const
|
|
729
|
-
const wrapper =
|
|
730
|
-
const written = writtenClaims(fn.source, [...
|
|
731
|
-
if (!
|
|
732
|
-
const permdock = project ? permdockSource(project) : PERMDOCK_CALL.test(fn.source) ? "the hook body" : void 0;
|
|
733
|
-
if (!permdock) continue;
|
|
860
|
+
if (fn.source === void 0 || isProviderHook(context, fn)) continue;
|
|
861
|
+
const module = owned.includes("memberships") && MODULE_MEMBERSHIPS.test(fn.source);
|
|
862
|
+
const wrapper = wrapsProviderHook(hook, fn.source);
|
|
863
|
+
const written = writtenClaims(fn.source, [...owned, ...wrapper ? registered.keys() : []]);
|
|
864
|
+
if (!module && written.length === 0) continue;
|
|
734
865
|
const location = hookLocation(context, config.hook);
|
|
735
|
-
const what = [...
|
|
866
|
+
const what = [...module ? ["calls better_supabase.membership_claims"] : [], ...written.length > 0 ? [`writes ${written.join(", ")}`] : []].join(" and ");
|
|
736
867
|
const extra = written.filter((claim) => registered.has(claim));
|
|
737
|
-
const sources = extra.map((claim) => {
|
|
738
|
-
const source = registered.get(claim);
|
|
739
|
-
return source ? `${claim} from ${source}` : claim;
|
|
740
|
-
}).join(", ");
|
|
868
|
+
const sources = extra.map((claim) => `${claim} from ${registered.get(claim)}`).join(", ");
|
|
741
869
|
findings.push({
|
|
742
|
-
message: `${signatureOf(fn)} ${what},
|
|
870
|
+
message: `${signatureOf(fn)} ${what}, but the hook of ${label} (${hook.function}) owns those claims${extra.length > 0 ? ` (it already writes ${sources})` : ""}. Keep one source: use the provider's hook and remove these writes from yours.`,
|
|
743
871
|
target: signatureOf(fn),
|
|
744
872
|
object: {
|
|
745
873
|
kind: "function",
|
|
@@ -757,7 +885,7 @@ const HOOK_RULES = [
|
|
|
757
885
|
code: "BS410",
|
|
758
886
|
severity: "warning",
|
|
759
887
|
title: "HTTP auth hooks",
|
|
760
|
-
description: "Auth calls an `http://` or `https://` hook with a request signed by the Standard Webhooks secret in `secrets` (`v1,whsec_<base64>`, several joined with `|`). Doctor can't read the endpoint's code, so it reports each HTTP hook and checks what it can: the secret's format, that it comes from `env()` rather than the committed file, and that a non-local endpoint uses https. Verify the request in the endpoint with `authHook` from `better-supabase/webhooks`.",
|
|
888
|
+
description: "Auth calls an `http://` or `https://` hook with a request signed by the Standard Webhooks secret in `secrets` (`v1,whsec_<base64>`, several joined with `|`). Doctor can't read the endpoint's code, so it reports each HTTP hook and checks what it can: the secret's format, that it comes from `env()` rather than the committed file, and that a non-local endpoint uses https. Verify the request in the endpoint with `authHook` from `better-supabase/blocks/webhooks`.",
|
|
761
889
|
check: (context) => {
|
|
762
890
|
const toml = context.configToml;
|
|
763
891
|
if (!toml) return [];
|
|
@@ -767,7 +895,7 @@ const HOOK_RULES = [
|
|
|
767
895
|
const target = `[auth.hook.${hook}]`;
|
|
768
896
|
const findings = [{
|
|
769
897
|
severity: "info",
|
|
770
|
-
message: `${target} calls ${uri.origin}${uri.pathname} over HTTP. Doctor can't check its code; verify the signature there with authHook from better-supabase/webhooks.`,
|
|
898
|
+
message: `${target} calls ${uri.origin}${uri.pathname} over HTTP. Doctor can't check its code; verify the signature there with authHook from better-supabase/blocks/webhooks.`,
|
|
771
899
|
target,
|
|
772
900
|
...at
|
|
773
901
|
}];
|
|
@@ -808,7 +936,62 @@ const HOOK_RULES = [
|
|
|
808
936
|
}
|
|
809
937
|
];
|
|
810
938
|
//#endregion
|
|
811
|
-
//#region src/cli/doctor/
|
|
939
|
+
//#region src/cli/doctor/metadata.ts
|
|
940
|
+
/**
|
|
941
|
+
* Rules that read what a `GeneratorMetadata` document leaves out (policies,
|
|
942
|
+
* grants, indexes, triggers, buckets, the realtime publication, foreign key
|
|
943
|
+
* actions, multi-column constraints, role settings, hook functions). On a
|
|
944
|
+
* snapshot built from such a document they would report what is missing
|
|
945
|
+
* from the document, not from the database, so they skip with an info finding.
|
|
946
|
+
*/
|
|
947
|
+
const EXTRAS_RULES = /* @__PURE__ */ new Set([
|
|
948
|
+
"BS103",
|
|
949
|
+
"BS106",
|
|
950
|
+
"BS107",
|
|
951
|
+
"BS108",
|
|
952
|
+
"BS109",
|
|
953
|
+
"BS110",
|
|
954
|
+
"BS111",
|
|
955
|
+
"BS112",
|
|
956
|
+
"BS113",
|
|
957
|
+
"BS114",
|
|
958
|
+
"BS204",
|
|
959
|
+
"BS205",
|
|
960
|
+
"BS206",
|
|
961
|
+
"BS207",
|
|
962
|
+
"BS210",
|
|
963
|
+
"BS211",
|
|
964
|
+
"BS213",
|
|
965
|
+
"BS215",
|
|
966
|
+
"BS216",
|
|
967
|
+
"BS217",
|
|
968
|
+
"BS218",
|
|
969
|
+
"BS219",
|
|
970
|
+
"BS301",
|
|
971
|
+
"BS302",
|
|
972
|
+
"BS303",
|
|
973
|
+
"BS305",
|
|
974
|
+
"BS306",
|
|
975
|
+
"BS310",
|
|
976
|
+
"BS315",
|
|
977
|
+
"BS404",
|
|
978
|
+
"BS405",
|
|
979
|
+
"BS406",
|
|
980
|
+
"BS407"
|
|
981
|
+
]);
|
|
982
|
+
/** The info finding for a rule in `EXTRAS_RULES` that did not run, or `undefined` when it can. */
|
|
983
|
+
function skippedForMetadata(snapshot, rule, help) {
|
|
984
|
+
if (!snapshot.extras.fromMetadata || !EXTRAS_RULES.has(rule.code)) return void 0;
|
|
985
|
+
return {
|
|
986
|
+
code: rule.code,
|
|
987
|
+
severity: "info",
|
|
988
|
+
title: rule.title,
|
|
989
|
+
help,
|
|
990
|
+
message: `Skipped: the schema came from a GeneratorMetadata document (--metadata), which has no ${METADATA_GAPS_TEXT}. Run doctor against the database to run this check.`
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/cli/doctor/modules.ts
|
|
812
995
|
/** The non-empty string at a dotted path, as the tenant() plugin reads it. */
|
|
813
996
|
function claimAt(claims, path) {
|
|
814
997
|
let value = claims;
|
|
@@ -816,7 +999,7 @@ function claimAt(claims, path) {
|
|
|
816
999
|
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
817
1000
|
}
|
|
818
1001
|
function contractChecks(context) {
|
|
819
|
-
return customContracts(context.config.sql.
|
|
1002
|
+
return customContracts(context.config.sql.moduleNames, moduleLayout(context.config, context.config.sql.testsDir, [], void 0, void 0, permissionCatalogKeys(context))).flatMap((contract) => contract.functions.map((fn) => ({
|
|
820
1003
|
module: contract.module,
|
|
821
1004
|
schema: contract.schema,
|
|
822
1005
|
name: fn.name,
|
|
@@ -845,10 +1028,10 @@ from (
|
|
|
845
1028
|
limit 1
|
|
846
1029
|
) as hook
|
|
847
1030
|
) h`;
|
|
848
|
-
/** `rate-limit` in `sql.
|
|
1031
|
+
/** `rate-limit` in `sql.modules` without PostgREST's pre-request hook reaching `check_request()`. */
|
|
849
1032
|
async function rateLimitHook(context) {
|
|
850
1033
|
const db = context.database;
|
|
851
|
-
if (!context.config.sql.
|
|
1034
|
+
if (!context.config.sql.moduleNames.includes("rate-limit") || !db || "skipped" in db) return [];
|
|
852
1035
|
let rows;
|
|
853
1036
|
try {
|
|
854
1037
|
rows = await db.query(PRE_REQUEST_HOOK);
|
|
@@ -862,6 +1045,57 @@ async function rateLimitHook(context) {
|
|
|
862
1045
|
target: "authenticator pgrst.db_pre_request"
|
|
863
1046
|
}];
|
|
864
1047
|
}
|
|
1048
|
+
const DANGLING_AUDIT = `select a.target::oid::text as oid
|
|
1049
|
+
from better_supabase.audited_tables a
|
|
1050
|
+
where pg_catalog.to_regclass('better_supabase.audited_tables') is not null
|
|
1051
|
+
and not exists (select 1 from pg_catalog.pg_class c where c.oid = a.target::oid)
|
|
1052
|
+
order by 1`;
|
|
1053
|
+
/** `better_supabase.audited_tables` rows whose table no longer exists. */
|
|
1054
|
+
async function danglingAuditRegistrations(context) {
|
|
1055
|
+
const db = context.database;
|
|
1056
|
+
if (!context.config.sql.moduleNames.includes("audit") || !db || "skipped" in db) return [];
|
|
1057
|
+
let rows;
|
|
1058
|
+
try {
|
|
1059
|
+
rows = await db.query(DANGLING_AUDIT);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return [];
|
|
1062
|
+
}
|
|
1063
|
+
return rows.map((row) => ({
|
|
1064
|
+
message: `better_supabase.audited_tables registers the table with oid ${row.oid}, which no longer exists. It was dropped before the audit module's sql_drop event trigger was installed. Run \`better-supabase sql sync\` and apply the migration \`better-supabase sql data\` writes, which deletes such rows, or run \`delete from better_supabase.audited_tables where not exists (select 1 from pg_catalog.pg_class c where c.oid = target::oid)\`.`,
|
|
1065
|
+
target: `better_supabase.audited_tables ${row.oid}`
|
|
1066
|
+
}));
|
|
1067
|
+
}
|
|
1068
|
+
const EVENT_TRIGGER_FIX = "Event triggers belong to no schema, so a schema diff limited to some schemas (`supabase db schema declarative sync -s ...`) leaves them out of the migration. Run `better-supabase sql sync`, then `better-supabase sql data`: the module's data migration creates it.";
|
|
1069
|
+
/** Event triggers the `sql.modules` create that the database, or every migration, lacks. */
|
|
1070
|
+
async function missingEventTriggers(context) {
|
|
1071
|
+
const expected = moduleEventTriggers(context.config.sql.moduleNames);
|
|
1072
|
+
if (expected.length === 0) return [];
|
|
1073
|
+
const db = context.database;
|
|
1074
|
+
if (db && !("skipped" in db)) {
|
|
1075
|
+
let rows;
|
|
1076
|
+
try {
|
|
1077
|
+
rows = await db.query("select evtname as name from pg_catalog.pg_event_trigger");
|
|
1078
|
+
} catch {
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
const present = new Set(rows.map((row) => row.name));
|
|
1082
|
+
return expected.filter((trigger) => !present.has(trigger.name)).map((trigger) => ({
|
|
1083
|
+
message: `The database has no event trigger ${trigger.name}, which the ${trigger.module} module creates. ${EVENT_TRIGGER_FIX}`,
|
|
1084
|
+
target: trigger.name
|
|
1085
|
+
}));
|
|
1086
|
+
}
|
|
1087
|
+
const toml = context.configToml;
|
|
1088
|
+
if (toml === void 0) return [];
|
|
1089
|
+
const migrations = (context.sqlFiles ?? []).filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1090
|
+
if (migrations.length === 0) return [];
|
|
1091
|
+
return expected.filter((trigger) => {
|
|
1092
|
+
const pattern = new RegExp(`create\\s+event\\s+trigger\\s+${ident(trigger.name)}\\s`, "i");
|
|
1093
|
+
return !migrations.some((file) => pattern.test(file.text));
|
|
1094
|
+
}).map((trigger) => ({
|
|
1095
|
+
message: `No migration creates the event trigger ${trigger.name}, which the ${trigger.module} module creates, so a database built from the migrations lacks it. ${EVENT_TRIGGER_FIX}`,
|
|
1096
|
+
target: trigger.name
|
|
1097
|
+
}));
|
|
1098
|
+
}
|
|
865
1099
|
/** Custom-mode modules whose contract functions the database or SQL files don't have. */
|
|
866
1100
|
async function missingContracts(context) {
|
|
867
1101
|
const checks = contractChecks(context);
|
|
@@ -869,7 +1103,7 @@ async function missingContracts(context) {
|
|
|
869
1103
|
const db = context.database;
|
|
870
1104
|
const live = db && !("skipped" in db) ? db : void 0;
|
|
871
1105
|
if (!live) return checks.filter((check) => !declared(context, check)).map((check) => ({
|
|
872
|
-
message: `
|
|
1106
|
+
message: `sql.modules.${check.module} is in custom mode, but no SQL file declares ${check.schema}.${check.name}(${check.args}). Write it, or switch the module to adopt or managed.`,
|
|
873
1107
|
target: `${check.schema}.${check.name}`,
|
|
874
1108
|
object: {
|
|
875
1109
|
kind: "function",
|
|
@@ -888,8 +1122,8 @@ async function missingContracts(context) {
|
|
|
888
1122
|
} catch (cause) {
|
|
889
1123
|
return [{
|
|
890
1124
|
severity: "info",
|
|
891
|
-
message: `Reading the custom
|
|
892
|
-
target: "
|
|
1125
|
+
message: `Reading the custom module contract functions failed: ${errorText(cause)}`,
|
|
1126
|
+
target: "modules"
|
|
893
1127
|
}];
|
|
894
1128
|
}
|
|
895
1129
|
return checks.flatMap((check) => {
|
|
@@ -901,7 +1135,7 @@ async function missingContracts(context) {
|
|
|
901
1135
|
name: check.name
|
|
902
1136
|
};
|
|
903
1137
|
if (found.length === 0) return [{
|
|
904
|
-
message: `
|
|
1138
|
+
message: `sql.modules.${check.module} is in custom mode, but ${target}(${check.args}) doesn't exist. Write it, or switch the module to adopt or managed.`,
|
|
905
1139
|
target,
|
|
906
1140
|
object
|
|
907
1141
|
}];
|
|
@@ -921,8 +1155,8 @@ async function missingContracts(context) {
|
|
|
921
1155
|
}
|
|
922
1156
|
/** With the tenant claim as the active tenant, the hook must put it in the token. */
|
|
923
1157
|
async function missingTenantClaim(context) {
|
|
924
|
-
if (!context.config.sql.
|
|
925
|
-
if ((context.config.
|
|
1158
|
+
if (!context.config.sql.moduleNames.includes("tenant")) return [];
|
|
1159
|
+
if ((context.config.sql.modules.access?.activeTenant ?? DEFAULT_ACTIVE_TENANT) !== "claim") return [];
|
|
926
1160
|
const userId = context.hookUser;
|
|
927
1161
|
const db = context.database;
|
|
928
1162
|
if (!userId || !db || "skipped" in db || !db.session) return [];
|
|
@@ -941,7 +1175,7 @@ async function missingTenantClaim(context) {
|
|
|
941
1175
|
if (claims === void 0) continue;
|
|
942
1176
|
if (tenantClaimPaths(claim).some((path) => claimAt(claims, path) !== void 0)) continue;
|
|
943
1177
|
findings.push({
|
|
944
|
-
message: `${signatureOf(fn)} returns no \`${claim}\` claim for ${userId} (top level or app_metadata), and
|
|
1178
|
+
message: `${signatureOf(fn)} returns no \`${claim}\` claim for ${userId} (top level or app_metadata), and sql.modules.access.activeTenant is 'claim', so current_tenant_id() is null and the tenant() plugin rejects their requests. Write the claim in the hook, or set sql.modules.access.activeTenant to 'resolver' (with ServerOptions.tenant) or a profile column.`,
|
|
945
1179
|
target,
|
|
946
1180
|
object: {
|
|
947
1181
|
kind: "function",
|
|
@@ -989,12 +1223,12 @@ function lineOfUse(entry, text, policyTable) {
|
|
|
989
1223
|
offset += statement.length + 1;
|
|
990
1224
|
}
|
|
991
1225
|
}
|
|
992
|
-
/** SQL files and policies that still use a symbol a
|
|
1226
|
+
/** SQL files and policies that still use a symbol a SQL module deprecated or removed. */
|
|
993
1227
|
function deprecatedSymbols(context) {
|
|
994
1228
|
const configured = new Set(Object.values(context.config.claims));
|
|
995
|
-
const entries =
|
|
1229
|
+
const entries = moduleDeprecations().filter((entry) => context.config.sql.moduleNames.includes(entry.module) && !(entry.kind === "claim" && configured.has(entry.symbol)) && !((entry.kind === "table" || entry.kind === "column") && (context.config.sql.modules[entry.module]?.mode ?? "managed") !== "managed"));
|
|
996
1230
|
if (entries.length === 0) return [];
|
|
997
|
-
const files = (context.sqlFiles ?? []).filter((file) =>
|
|
1231
|
+
const files = (context.sqlFiles ?? []).filter((file) => !/(^|\/)supabase\/migrations\//.test(file.path) && moduleFileVersion(file.text) === void 0);
|
|
998
1232
|
const findings = [];
|
|
999
1233
|
for (const entry of entries) {
|
|
1000
1234
|
const status = entry.removed === void 0 ? `deprecated since ${entry.since}` : `removed in ${entry.removed}`;
|
|
@@ -1022,7 +1256,7 @@ function deprecatedSymbols(context) {
|
|
|
1022
1256
|
}
|
|
1023
1257
|
return findings;
|
|
1024
1258
|
}
|
|
1025
|
-
/**
|
|
1259
|
+
/** Module triggers and the equivalent triggers they replace. */
|
|
1026
1260
|
const EQUIVALENT_TRIGGERS = [{
|
|
1027
1261
|
trigger: "bs_updated_at",
|
|
1028
1262
|
call: "track_updated_at",
|
|
@@ -1032,7 +1266,7 @@ const EQUIVALENT_TRIGGERS = [{
|
|
|
1032
1266
|
call: "audit",
|
|
1033
1267
|
pattern: /audit/i
|
|
1034
1268
|
}];
|
|
1035
|
-
/** Tables where a
|
|
1269
|
+
/** Tables where a module trigger and an older trigger do the same work. */
|
|
1036
1270
|
function duplicateTriggers(context) {
|
|
1037
1271
|
return catalogOf(context).tables.flatMap((table) => EQUIVALENT_TRIGGERS.flatMap(({ trigger, call, pattern }) => {
|
|
1038
1272
|
if (!table.triggers.some((entry) => entry.name === trigger)) return [];
|
|
@@ -1043,72 +1277,418 @@ function duplicateTriggers(context) {
|
|
|
1043
1277
|
}));
|
|
1044
1278
|
}));
|
|
1045
1279
|
}
|
|
1046
|
-
/** `
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1280
|
+
/** `exempt` globs (`public.*_archive`): `*` matches any run of characters. */
|
|
1281
|
+
const globs = (value) => (Array.isArray(value) ? value : []).filter((entry) => typeof entry === "string").map((glob) => new RegExp(`^${glob.split("*").map(escapeRegExp).join(".*")}$`, "i"));
|
|
1282
|
+
/** The module schemas and the app tables SQL modules adopted, which the audit trigger skips. */
|
|
1283
|
+
function moduleOwned(context) {
|
|
1284
|
+
const schemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1285
|
+
const tables = /* @__PURE__ */ new Set();
|
|
1286
|
+
for (const module of Object.values(context.config.sql.modules)) {
|
|
1287
|
+
if (module?.schema !== void 0) schemas.add(module.schema);
|
|
1288
|
+
for (const table of Object.values(module?.tables ?? {})) {
|
|
1289
|
+
if (table === null) continue;
|
|
1290
|
+
tables.add(table.includes(".") ? table : `${module?.schema ?? "better_supabase"}.${table}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
schemas,
|
|
1295
|
+
tables
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/** Tables in `config.schemas` without the `bs_audit` trigger, when `audit` is in `sql.modules`. */
|
|
1299
|
+
function unauditedTables(context) {
|
|
1300
|
+
if (!context.config.sql.moduleNames.includes("audit")) return [];
|
|
1301
|
+
const exempt = globs(context.config.sql.modules["audit"]?.options?.["exempt"]);
|
|
1302
|
+
const owned = moduleOwned(context);
|
|
1303
|
+
return catalogOf(context).tables.filter((table) => table.kind === "table" && context.config.schemas.includes(table.schema) && !owned.schemas.has(table.schema) && !owned.tables.has(qualified(table)) && !exempt.some((pattern) => pattern.test(qualified(table))) && !table.triggers.some((trigger) => trigger.name === "bs_audit" || trigger.function.split(".").at(-1) === "audit_row_change")).map((table) => ({
|
|
1304
|
+
message: `${qualified(table)} has no audit trigger, so its inserts, updates and deletes are not in the audit log. Run \`select better_supabase.audit('${qualified(table)}')\` in a schema file, or list it in sql.modules.audit.options.exempt.`,
|
|
1305
|
+
target: qualified(table),
|
|
1306
|
+
object: tableObject(table)
|
|
1307
|
+
}));
|
|
1308
|
+
}
|
|
1309
|
+
/** RLS tables in `config.schemas` without a restrictive `session_active()` policy, when `sessions` is in `sql.modules`. */
|
|
1310
|
+
function sessionlessTables(context) {
|
|
1311
|
+
if (!context.config.sql.moduleNames.includes("sessions")) return [];
|
|
1312
|
+
const exclude = globs(context.config.sql.modules["sessions"]?.options?.["exclude"]);
|
|
1313
|
+
const owned = moduleOwned(context);
|
|
1314
|
+
return catalogOf(context).tables.filter((table) => table.kind === "table" && table.rls && context.config.schemas.includes(table.schema) && !owned.schemas.has(table.schema) && !owned.tables.has(qualified(table)) && !exclude.some((pattern) => pattern.test(qualified(table))) && !table.policies.some((policy) => !policy.permissive && `${policy.using ?? ""} ${policy.check ?? ""}`.includes("session_active()"))).map((table) => ({
|
|
1315
|
+
message: `${qualified(table)} has no restrictive session_active() policy, so a signed-out or revoked session keeps its access until the token expires. Set sql.modules.sessions.options.policies to true and run \`better-supabase sql sync\`, add the policy by hand, or list the table in sql.modules.sessions.options.exclude.`,
|
|
1316
|
+
target: qualified(table),
|
|
1317
|
+
object: tableObject(table)
|
|
1318
|
+
}));
|
|
1319
|
+
}
|
|
1320
|
+
/** `better_supabase` and every `modules.*.schema` that `[api] schemas` serves through the Data API. */
|
|
1321
|
+
function exposedModuleSchemas(context) {
|
|
1322
|
+
if (context.config.sql.moduleNames.length === 0) return [];
|
|
1323
|
+
const moduleSchemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1324
|
+
for (const module of Object.values(context.config.sql.modules)) if (module?.schema !== void 0) moduleSchemas.add(module.schema);
|
|
1325
|
+
return exposedSchemas(context).filter((schema) => moduleSchemas.has(schema)).map((schema) => ({
|
|
1326
|
+
message: `The Data API serves the module schema ${schema}, so its tables and internal helpers are reachable over REST and RPC. Remove it from [api] schemas in supabase/config.toml (and the dashboard's exposed schemas), set sql.modules.<module>.api to an exposed schema such as "api" so \`sql add\` writes security invoker entry points there, and call them with rpcTransport(supabase, { schema: "api" }).`,
|
|
1053
1327
|
target: schema
|
|
1054
1328
|
}));
|
|
1055
1329
|
}
|
|
1330
|
+
/**
|
|
1331
|
+
* supautils' `reserved_roles` on Supabase. Nobody but a superuser may alter,
|
|
1332
|
+
* drop or change the memberships of these.
|
|
1333
|
+
*/
|
|
1334
|
+
const RESERVED_ROLES = /* @__PURE__ */ new Set([
|
|
1335
|
+
"supabase_admin",
|
|
1336
|
+
"supabase_auth_admin",
|
|
1337
|
+
"supabase_storage_admin",
|
|
1338
|
+
"supabase_functions_admin",
|
|
1339
|
+
"supabase_read_only_user",
|
|
1340
|
+
"supabase_realtime_admin",
|
|
1341
|
+
"supabase_replication_admin",
|
|
1342
|
+
"supabase_etl_admin",
|
|
1343
|
+
"dashboard_user",
|
|
1344
|
+
"pgbouncer"
|
|
1345
|
+
]);
|
|
1346
|
+
/** Reserved roles whose settings (`alter role ... set`) supautils allows changing. */
|
|
1347
|
+
const SETTINGS_ONLY_ROLES = /* @__PURE__ */ new Set([
|
|
1348
|
+
"authenticator",
|
|
1349
|
+
"authenticated",
|
|
1350
|
+
"anon",
|
|
1351
|
+
"service_role"
|
|
1352
|
+
]);
|
|
1353
|
+
/** Comments and string literals blanked, newlines kept so offsets map to lines. */
|
|
1354
|
+
const sqlCode = (text) => text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, "").replaceAll(/'(?:[^']|'')*'/g, "''");
|
|
1355
|
+
const roleNames = (list) => list.split(",").map((name) => name.trim().replaceAll("\"", "").toLowerCase()).filter((name) => name !== "");
|
|
1356
|
+
/** Why a statement changes a reserved role, or undefined when it doesn't. */
|
|
1357
|
+
function reservedRoleChange(statement) {
|
|
1358
|
+
const alter = /^\s*(alter|drop)\s+(?:role|user)\s+(?:if\s+exists\s+)?("[^"]+"|\w+)\s*(\w*)/i.exec(statement);
|
|
1359
|
+
if (alter) {
|
|
1360
|
+
const role = roleNames(alter[2])[0];
|
|
1361
|
+
const verb = alter[1].toLowerCase();
|
|
1362
|
+
const next = alter[3].toLowerCase();
|
|
1363
|
+
if (RESERVED_ROLES.has(role)) return `${verb}s the reserved role ${role}`;
|
|
1364
|
+
if (SETTINGS_ONLY_ROLES.has(role) && (verb === "drop" || next !== "set" && next !== "reset" && next !== "in")) return `${verb}s the reserved role ${role}; only \`alter role ${role} set ...\` is allowed`;
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const membership = /^\s*(grant|revoke)\s+(?![\s\S]*\bon\b)([\w",\s]+?)\s+(?:to|from)\s+([\w",\s]+?)(?:\s+with\s+\w+\s+option)?\s*$/i.exec(statement);
|
|
1368
|
+
if (!membership) return void 0;
|
|
1369
|
+
const role = [...roleNames(membership[2]), ...roleNames(membership[3])].find((name) => RESERVED_ROLES.has(name));
|
|
1370
|
+
return role === void 0 ? void 0 : `${membership[1].toLowerCase()}s a membership of the reserved role ${role}`;
|
|
1371
|
+
}
|
|
1372
|
+
/** Where a statement ends, including statements inside `do` blocks and function bodies. */
|
|
1373
|
+
const STATEMENT_BOUNDARY = /;|\$\w*\$|\b(?:begin|then|else|loop)\b/gi;
|
|
1374
|
+
/** Statements in schema files and migrations that supautils rejects on Supabase. */
|
|
1375
|
+
function reservedRoles(context) {
|
|
1376
|
+
return (context.sqlFiles ?? []).flatMap((file) => {
|
|
1377
|
+
const code = sqlCode(file.text);
|
|
1378
|
+
const findings = [];
|
|
1379
|
+
let start = 0;
|
|
1380
|
+
for (const end of [...code.matchAll(STATEMENT_BOUNDARY), void 0]) {
|
|
1381
|
+
const until = end?.index ?? code.length;
|
|
1382
|
+
const statement = code.slice(start, until);
|
|
1383
|
+
const reason = reservedRoleChange(statement);
|
|
1384
|
+
if (reason !== void 0) {
|
|
1385
|
+
const at = start + statement.search(/\S/);
|
|
1386
|
+
const line = code.slice(0, at).split("\n").length;
|
|
1387
|
+
findings.push({
|
|
1388
|
+
message: `${file.path}:${String(line)} ${reason}. supautils rejects it on Supabase, so the migration fails on a hosted project; grant privileges to your own role instead.`,
|
|
1389
|
+
target: `${file.path}:${String(line)}`,
|
|
1390
|
+
location: {
|
|
1391
|
+
file: file.path,
|
|
1392
|
+
line
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
start = until + (end?.[0].length ?? 0);
|
|
1397
|
+
}
|
|
1398
|
+
return findings;
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1056
1401
|
function migrationOptions(context) {
|
|
1057
|
-
const
|
|
1058
|
-
return migrationOptionUses(context.config.
|
|
1402
|
+
const module = new Set(context.config.sql.moduleNames);
|
|
1403
|
+
return migrationOptionUses(context.config.sql.modules).filter((use) => module.has(use.module)).map((use) => ({
|
|
1059
1404
|
message: `${use.message} It exists to adopt an existing schema; remove it once your data matches the managed default.`,
|
|
1060
|
-
target: `
|
|
1405
|
+
target: `sql.modules.${use.module}.options.${use.option}`
|
|
1406
|
+
}));
|
|
1407
|
+
}
|
|
1408
|
+
/** A roles table shared by tenant and platform roles without `where` on both sides. */
|
|
1409
|
+
function sharedRoleTables(context) {
|
|
1410
|
+
if (context.config.sql.moduleNames.length === 0) return [];
|
|
1411
|
+
const access = accessModule(context);
|
|
1412
|
+
if (access.kind !== "provider") return [];
|
|
1413
|
+
let problems;
|
|
1414
|
+
try {
|
|
1415
|
+
problems = sharedRolesProblems(context.config.sql.moduleNames, moduleLayout(context.config, context.config.sql.testsDir, [], entitlementsModule(context), access.access, permissionCatalogKeys(context)));
|
|
1416
|
+
} catch {
|
|
1417
|
+
return [];
|
|
1418
|
+
}
|
|
1419
|
+
return problems.map(({ setting, message }) => ({
|
|
1420
|
+
message: `${message} Without it, ${setting.startsWith("sql.modules.tenant.") ? "an organization invitation, accept or role change can resolve a platform role" : "a platform invitation can resolve a tenant role"}.`,
|
|
1421
|
+
target: setting
|
|
1061
1422
|
}));
|
|
1062
1423
|
}
|
|
1063
|
-
const
|
|
1424
|
+
const MODULE_RULES = [
|
|
1064
1425
|
{
|
|
1065
1426
|
code: "BS307",
|
|
1066
1427
|
severity: "error",
|
|
1067
|
-
title: "Custom
|
|
1068
|
-
description: "A module in `
|
|
1428
|
+
title: "Custom SQL module without its contract",
|
|
1429
|
+
description: "A module in `sql.modules` uses `mode: 'custom'`, so the app writes its contract functions. One is missing or has another signature, and the modules and TypeScript APIs that call it fail at run time.",
|
|
1069
1430
|
check: missingContracts
|
|
1070
1431
|
},
|
|
1071
1432
|
{
|
|
1072
1433
|
code: "BS308",
|
|
1073
1434
|
severity: "warning",
|
|
1074
1435
|
title: "Tenant claim the hook does not write",
|
|
1075
|
-
description: "With the `tenant` module and `
|
|
1436
|
+
description: "With the `tenant` module and `sql.modules.access.activeTenant: 'claim'`, `current_tenant_id()` and the `tenant()` plugin read the tenant from the `claims.tenant` claim, at the top level or in `app_metadata`. With `--as <user id>` doctor calls the custom access token hook for that user and warns when the claims it returns have neither. The default source, `'resolver'`, takes the tenant from the request instead.",
|
|
1076
1437
|
check: missingTenantClaim
|
|
1077
1438
|
},
|
|
1078
1439
|
{
|
|
1079
1440
|
code: "BS309",
|
|
1080
1441
|
severity: "warning",
|
|
1081
|
-
title: "Deprecated SQL
|
|
1082
|
-
description: "A schema file or policy uses a function, table, column or claim that a
|
|
1442
|
+
title: "Deprecated SQL module symbol",
|
|
1443
|
+
description: "A schema file or policy uses a function, table, column or claim that a SQL module deprecated or removed. Deprecated symbols keep a wrapper for at least one minor release; removed ones fail at run time.",
|
|
1083
1444
|
check: deprecatedSymbols
|
|
1084
1445
|
},
|
|
1085
1446
|
{
|
|
1086
1447
|
code: "BS310",
|
|
1087
1448
|
severity: "warning",
|
|
1088
|
-
title: "Duplicate
|
|
1089
|
-
description: "A table has a
|
|
1449
|
+
title: "Duplicate module trigger",
|
|
1450
|
+
description: "A table has a module trigger (`bs_updated_at`, `bs_audit`) and another trigger that does the same work, so both run on every write.",
|
|
1090
1451
|
check: duplicateTriggers
|
|
1091
1452
|
},
|
|
1092
1453
|
{
|
|
1093
1454
|
code: "BS313",
|
|
1094
1455
|
severity: "warning",
|
|
1095
1456
|
title: "Rate limits not wired to PostgREST",
|
|
1096
|
-
description: "The `rate-limit` module is in `sql.
|
|
1457
|
+
description: "The `rate-limit` module is in `sql.modules`, but on the live database `pgrst.db_pre_request` for the `authenticator` role is unset or names a function that doesn't call `better_supabase.check_request()`, so Data API requests are never counted.",
|
|
1097
1458
|
check: rateLimitHook
|
|
1098
1459
|
},
|
|
1099
1460
|
{
|
|
1100
1461
|
code: "BS312",
|
|
1101
1462
|
severity: "error",
|
|
1102
|
-
title: "
|
|
1103
|
-
description: "`[api] schemas` in `config.toml` lists `better_supabase` or a `
|
|
1104
|
-
check:
|
|
1463
|
+
title: "Module schema exposed through the Data API",
|
|
1464
|
+
description: "`[api] schemas` in `config.toml` lists `better_supabase` or a `modules.*.schema`. The module schemas hold internal tables and helpers that are granted to `authenticated` for policies, so exposing them makes those callable over REST and RPC.",
|
|
1465
|
+
check: exposedModuleSchemas
|
|
1105
1466
|
},
|
|
1106
1467
|
{
|
|
1107
1468
|
code: "BS314",
|
|
1108
1469
|
severity: "warning",
|
|
1109
|
-
title: "Migration-only
|
|
1110
|
-
description: "A module in adopt mode sets an option that only exists to match an existing schema: plain invitation tokens, webhook secrets in a column, non-text webhook ids,
|
|
1470
|
+
title: "Migration-only module option",
|
|
1471
|
+
description: "A module in adopt mode sets an option that only exists to match an existing schema: plain invitation tokens, webhook secrets in a column, non-text webhook ids, a custom outbox source, or audit values mapped to an adopted log's. Remove it once the data matches the managed default.",
|
|
1111
1472
|
check: migrationOptions
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
code: "BS315",
|
|
1476
|
+
severity: "warning",
|
|
1477
|
+
title: "Table without an audit trigger",
|
|
1478
|
+
description: "The `audit` module is in `sql.modules`, and a table in `schemas` has no `bs_audit` trigger (or another trigger that calls `audit_row_change()`), so its changes are not in the audit log. The module's own schemas and the tables SQL modules adopt are skipped; `sql.modules.audit.options.exempt` lists more, as `schema.table` globs such as `public.*_archive`.",
|
|
1479
|
+
check: unauditedTables
|
|
1480
|
+
},
|
|
1481
|
+
{
|
|
1482
|
+
code: "BS319",
|
|
1483
|
+
severity: "error",
|
|
1484
|
+
title: "SQL alters a reserved role",
|
|
1485
|
+
description: "A schema file, SQL module or migration alters, drops or changes the memberships of a role supautils reserves on Supabase (`supabase_admin`, `supabase_auth_admin`, `supabase_storage_admin`, `pgbouncer` and the other platform roles). The statement fails on a hosted project. `authenticator`, `authenticated`, `anon` and `service_role` accept `alter role ... set` (for example `pgrst.db_pre_request` or `statement_timeout`) and nothing else.",
|
|
1486
|
+
check: reservedRoles
|
|
1487
|
+
},
|
|
1488
|
+
{
|
|
1489
|
+
code: "BS320",
|
|
1490
|
+
severity: "warning",
|
|
1491
|
+
title: "Table without the session policy",
|
|
1492
|
+
description: "The `sessions` module is in `sql.modules`, and a table with RLS in `schemas` has no restrictive policy that calls `better_supabase.session_active()`, so a token whose session was signed out, or whose user was banned or deleted, keeps reaching it until it expires. `sql.modules.sessions.options.policies` writes the policy on every table; `options.exclude` lists the tables to skip, as `schema.table` globs.",
|
|
1493
|
+
check: sessionlessTables
|
|
1494
|
+
},
|
|
1495
|
+
{
|
|
1496
|
+
code: "BS322",
|
|
1497
|
+
severity: "warning",
|
|
1498
|
+
title: "Audit registration of a dropped table",
|
|
1499
|
+
description: "The `audit` module is in `sql.modules`, and `better_supabase.audited_tables` on the live database has a row whose table no longer exists. A table dropped before the module's `sql_drop` event trigger was installed keeps its row; the module's data migration deletes such rows.",
|
|
1500
|
+
check: danglingAuditRegistrations
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
code: "BS323",
|
|
1504
|
+
severity: "warning",
|
|
1505
|
+
title: "Module event trigger missing",
|
|
1506
|
+
description: "A module in `sql.modules` creates an event trigger (`bs_audit_forget_dropped` for `audit`, `bs_ensure_rls` for `ensure-rls`) that the live database lacks, or, without a database, that no migration creates. Event triggers belong to no schema, so a schema diff limited to some schemas leaves them out; the module's data migration creates them.",
|
|
1507
|
+
check: missingEventTriggers
|
|
1508
|
+
},
|
|
1509
|
+
{
|
|
1510
|
+
code: "BS324",
|
|
1511
|
+
severity: "error",
|
|
1512
|
+
title: "Shared roles table without a role condition",
|
|
1513
|
+
description: "Under the `provider` model, the tenant module's `roleThrough` and the invitations module's `platformRoles.through` name the same roles table, and one of them has no `where` condition on `{row}`. That side then resolves a role id or key of the other kind: an organization invitation, accept or `update_member_role` can grant a platform role, or a platform invitation a tenant role. `sql sync` refuses the config; set `where` on both, such as `{row}.scope = 'organization'` and `{row}.scope = 'system'`.",
|
|
1514
|
+
check: sharedRoleTables
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
code: "BS325",
|
|
1518
|
+
severity: "warning",
|
|
1519
|
+
title: "Audit log readable only as the service role",
|
|
1520
|
+
description: "The `audit` module defaults `eventRoles` to `service_role` and `readPolicy` to false, so `audit.list()` under a user session is 403. Set `options.readPolicy: true` and include `authenticated` in `options.eventRoles` when the app lists the log as the signed-in user.",
|
|
1521
|
+
check: auditUserSession
|
|
1522
|
+
},
|
|
1523
|
+
{
|
|
1524
|
+
code: "BS326",
|
|
1525
|
+
severity: "warning",
|
|
1526
|
+
title: "Admin Auth call without a secret key in env files",
|
|
1527
|
+
description: "`deleteAccount` and `bs.admin()` need `SUPABASE_SECRET_KEY`. The env files doctor reads do not set it, so those calls fail at run time.",
|
|
1528
|
+
check: missingSecretKey
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
code: "BS327",
|
|
1532
|
+
severity: "warning",
|
|
1533
|
+
title: "aal2 required without MFA enabled",
|
|
1534
|
+
description: "The app calls `requireAal('aal2')` (or otherwise requires aal2), but `[auth.mfa.totp]` in `config.toml` does not enable enroll and verify, so no user can satisfy the check.",
|
|
1535
|
+
check: aal2WithoutMfa
|
|
1536
|
+
}
|
|
1537
|
+
];
|
|
1538
|
+
function auditUserSession(context) {
|
|
1539
|
+
if (!context.config.sql.moduleNames.includes("audit")) return [];
|
|
1540
|
+
const options = context.config.sql.modules["audit"]?.options ?? {};
|
|
1541
|
+
const roles = Array.isArray(options["eventRoles"]) ? options["eventRoles"].filter((role) => typeof role === "string") : ["service_role"];
|
|
1542
|
+
if (options["readPolicy"] === true && roles.includes("authenticated")) return [];
|
|
1543
|
+
if (!context.sources.some((file) => /\baudit\.(list|listAll)\s*\(/.test(file.text))) return [];
|
|
1544
|
+
return [{
|
|
1545
|
+
message: "sql.modules.audit leaves the log readable only as the service role (`eventRoles` defaults to service_role, `readPolicy` defaults to false), but the app calls audit.list() or audit.listAll() as a user. Set options.readPolicy to true and include authenticated in options.eventRoles.",
|
|
1546
|
+
target: "sql.modules.audit"
|
|
1547
|
+
}];
|
|
1548
|
+
}
|
|
1549
|
+
function missingSecretKey(context) {
|
|
1550
|
+
if (!context.sources.some((file) => /\bdeleteAccount\b|\.admin\s*\(|supabaseAdmin\b/.test(file.text))) return [];
|
|
1551
|
+
if (context.envFiles.some((file) => /^\s*(?:SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY)\s*=/m.test(file.text))) return [];
|
|
1552
|
+
return [{
|
|
1553
|
+
message: "The app calls deleteAccount or bs.admin(), which need SUPABASE_SECRET_KEY, but no env file doctor reads sets it. Add it to .env.local (and the hosted environments), listed without a value in .env.example.",
|
|
1554
|
+
target: "SUPABASE_SECRET_KEY"
|
|
1555
|
+
}];
|
|
1556
|
+
}
|
|
1557
|
+
function aal2WithoutMfa(context) {
|
|
1558
|
+
const toml = context.configToml;
|
|
1559
|
+
if (!toml) return [];
|
|
1560
|
+
if (!context.sources.some((file) => /requireAal\s*\(\s*['"]aal2['"]/.test(file.text))) return [];
|
|
1561
|
+
const enroll = tomlGet(toml.document, [
|
|
1562
|
+
"auth",
|
|
1563
|
+
"mfa",
|
|
1564
|
+
"totp",
|
|
1565
|
+
"enroll_enabled"
|
|
1566
|
+
]);
|
|
1567
|
+
const verify = tomlGet(toml.document, [
|
|
1568
|
+
"auth",
|
|
1569
|
+
"mfa",
|
|
1570
|
+
"totp",
|
|
1571
|
+
"verify_enabled"
|
|
1572
|
+
]);
|
|
1573
|
+
if (enroll === true && verify === true) return [];
|
|
1574
|
+
return [{
|
|
1575
|
+
message: "The app requires aal2, but [auth.mfa.totp] in config.toml does not set enroll_enabled and verify_enabled to true, so no user can satisfy the check.",
|
|
1576
|
+
target: toml.path,
|
|
1577
|
+
location: {
|
|
1578
|
+
file: toml.path,
|
|
1579
|
+
line: lineOf(toml.text, /^\[auth\.mfa\.totp\]/) ?? 1
|
|
1580
|
+
}
|
|
1581
|
+
}];
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region src/cli/doctor/pgdelta.ts
|
|
1585
|
+
/** Comments blanked out, so a commented-out statement never matches and line numbers stay. */
|
|
1586
|
+
function withoutComments(text) {
|
|
1587
|
+
return text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, (comment) => " ".repeat(comment.length));
|
|
1588
|
+
}
|
|
1589
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
1590
|
+
/**
|
|
1591
|
+
* The declarative schema files pg-delta loads, outside the SQL module files
|
|
1592
|
+
* better-supabase writes, when the project diffs with pg-delta.
|
|
1593
|
+
*/
|
|
1594
|
+
function pgdeltaSchemaFiles(context) {
|
|
1595
|
+
const toml = context.configToml;
|
|
1596
|
+
if (toml === void 0 || diffEngine(toml) !== "pg-delta") return [];
|
|
1597
|
+
const schemas = `${toml.dir}/schemas/`;
|
|
1598
|
+
return (context.sqlFiles ?? []).filter((file) => file.path.startsWith(schemas) && !/^-- @bs-module /m.test(file.text));
|
|
1599
|
+
}
|
|
1600
|
+
const BULK_GRANT = /\bgrant\b[^;]*?\bon\s+all\s+(tables|routines|functions|procedures|sequences)\s+in\s+schema\s+("?[\w$]+"?(?:\s*,\s*"?[\w$]+"?)*)\s+to\s+([^;]*)/gi;
|
|
1601
|
+
/** Grantees a bulk grant must not reach: the Data API roles and `public`. */
|
|
1602
|
+
const API_ROLES$1 = /\b(anon|authenticated|public)\b/i;
|
|
1603
|
+
/** `do` blocks; group 2 is the body. */
|
|
1604
|
+
const DO_BLOCK = /\bdo\s+(\$[\w]*\$)([\s\S]*?)\1/gi;
|
|
1605
|
+
const CATALOG = /\b(information_schema\.\w+|(?:pg_catalog\.)?pg_(?:class|tables|views|proc|namespace|attribute|trigger|constraint|index|type|policies|matviews))\b/i;
|
|
1606
|
+
const EXECUTES = /\b(execute|perform)\b/i;
|
|
1607
|
+
function bulkGrants(context) {
|
|
1608
|
+
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1609
|
+
const text = withoutComments(file.text);
|
|
1610
|
+
return [...text.matchAll(BULK_GRANT)].filter((match) => API_ROLES$1.test(match[3])).map((match) => ({
|
|
1611
|
+
message: `${file.path} grants on all ${match[1].toLowerCase()} in schema ${match[2]} at once. pg-delta gives a bulk grant no dependency position, so it can run after the per-object revokes and open functions and tables to anon and authenticated again. Grant each object by name next to its definition, or set Supabase default privileges (alter default privileges in schema ... grant ...) for objects created later.`,
|
|
1612
|
+
target: file.path,
|
|
1613
|
+
location: {
|
|
1614
|
+
file: file.path,
|
|
1615
|
+
line: lineAt(text, match.index)
|
|
1616
|
+
}
|
|
1617
|
+
}));
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
function catalogLoops(context) {
|
|
1621
|
+
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1622
|
+
const text = withoutComments(file.text);
|
|
1623
|
+
return [...text.matchAll(DO_BLOCK)].flatMap((match) => {
|
|
1624
|
+
const body = match[2];
|
|
1625
|
+
const catalog = CATALOG.exec(body);
|
|
1626
|
+
if (!catalog || !EXECUTES.test(body)) return [];
|
|
1627
|
+
return [{
|
|
1628
|
+
message: `${file.path} has a do block that reads ${catalog[1]} to create objects. pg-delta runs a do block with no dependency position, before the tables it looks for exist, so it creates nothing and the diff never sees its objects. Write the statements it would generate (one trigger, grant or policy per table) as static SQL, or call a per-table function such as better_supabase.audit('<table>') or track_updated_at('<table>') once per table.`,
|
|
1629
|
+
target: file.path,
|
|
1630
|
+
location: {
|
|
1631
|
+
file: file.path,
|
|
1632
|
+
line: lineAt(text, match.index)
|
|
1633
|
+
}
|
|
1634
|
+
}];
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([\w-]+)"?/gi;
|
|
1639
|
+
/**
|
|
1640
|
+
* Extensions a pg-delta schema file creates, SQL module files included, that
|
|
1641
|
+
* no migration creates. pg-delta can leave an extension that owns its own
|
|
1642
|
+
* schema (`pgmq`) out of the generated migration.
|
|
1643
|
+
*/
|
|
1644
|
+
function missingExtensions(context) {
|
|
1645
|
+
const toml = context.configToml;
|
|
1646
|
+
if (toml === void 0 || diffEngine(toml) !== "pg-delta") return [];
|
|
1647
|
+
const files = context.sqlFiles ?? [];
|
|
1648
|
+
const migrations = files.filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1649
|
+
if (migrations.length === 0) return [];
|
|
1650
|
+
const created = new Set(migrations.flatMap((file) => [...withoutComments(file.text).matchAll(CREATE_EXTENSION)].map((match) => match[1].toLowerCase())));
|
|
1651
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1652
|
+
return files.filter((file) => file.path.startsWith(`${toml.dir}/schemas/`)).flatMap((file) => {
|
|
1653
|
+
const text = withoutComments(file.text);
|
|
1654
|
+
return [...text.matchAll(CREATE_EXTENSION)].flatMap((match) => {
|
|
1655
|
+
const name = match[1].toLowerCase();
|
|
1656
|
+
if (created.has(name) || seen.has(name)) return [];
|
|
1657
|
+
seen.add(name);
|
|
1658
|
+
const module = /^-- @bs-module /m.test(file.text);
|
|
1659
|
+
return [{
|
|
1660
|
+
message: `${file.path} creates the extension ${name}, but no migration creates it. pg-delta can leave an extension that owns its own schema out of the generated migration, and a database built from the migrations then lacks it. ${module ? "Run `better-supabase sql sync`: it writes the extension into a migration that runs before the schema migration." : `Add \`create extension if not exists ${name};\` to a migration that runs before the one that uses it.`}`,
|
|
1661
|
+
target: file.path,
|
|
1662
|
+
location: {
|
|
1663
|
+
file: file.path,
|
|
1664
|
+
line: lineAt(text, match.index)
|
|
1665
|
+
}
|
|
1666
|
+
}];
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
/** Statements in declarative schema files that pg-delta can't order. */
|
|
1671
|
+
const PGDELTA_RULES = [
|
|
1672
|
+
{
|
|
1673
|
+
code: "BS317",
|
|
1674
|
+
severity: "warning",
|
|
1675
|
+
title: "Bulk grant in a pg-delta schema file",
|
|
1676
|
+
description: "A `grant ... on all tables|routines|sequences in schema ... to anon|authenticated|public` statement in `supabase/schemas` under pg-delta. pg-delta can't place it after the objects it covers or before the revokes that narrow them, so it can re-open functions and tables to `anon` and `authenticated`.",
|
|
1677
|
+
check: bulkGrants
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
code: "BS318",
|
|
1681
|
+
severity: "warning",
|
|
1682
|
+
title: "Catalog loop in a pg-delta schema file",
|
|
1683
|
+
description: "A `do` block in `supabase/schemas` under pg-delta that reads the catalog (`information_schema`, `pg_class`, ...) to create objects. pg-delta runs it before the tables exist, so it creates nothing.",
|
|
1684
|
+
check: catalogLoops
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
code: "BS321",
|
|
1688
|
+
severity: "warning",
|
|
1689
|
+
title: "Extension missing from the migrations under pg-delta",
|
|
1690
|
+
description: "A `create extension` in `supabase/schemas` (a SQL module's file included) under pg-delta that no migration repeats. pg-delta can leave an extension that owns its own schema, such as `pgmq`, out of the generated migration.",
|
|
1691
|
+
check: missingExtensions
|
|
1112
1692
|
}
|
|
1113
1693
|
];
|
|
1114
1694
|
//#endregion
|
|
@@ -1292,7 +1872,7 @@ function grantColumns(context) {
|
|
|
1292
1872
|
if (existing) return existing;
|
|
1293
1873
|
const created = {
|
|
1294
1874
|
helpers: /* @__PURE__ */ new Set(),
|
|
1295
|
-
|
|
1875
|
+
provider: false
|
|
1296
1876
|
};
|
|
1297
1877
|
byColumn.set(column, created);
|
|
1298
1878
|
return created;
|
|
@@ -1306,20 +1886,22 @@ function grantColumns(context) {
|
|
|
1306
1886
|
if (qualifiedColumn.test(body) || read.length === 1 && bare.test(body)) entry(qualified(table), column.name).helpers.add(helper);
|
|
1307
1887
|
}
|
|
1308
1888
|
}
|
|
1309
|
-
for (const deciding of context.
|
|
1889
|
+
for (const deciding of context.config.authorization?.decidingColumns ?? []) {
|
|
1310
1890
|
const dot = deciding.lastIndexOf(".");
|
|
1311
|
-
if (dot > 0) entry(deciding.slice(0, dot), deciding.slice(dot + 1)).
|
|
1891
|
+
if (dot > 0) entry(deciding.slice(0, dot), deciding.slice(dot + 1)).provider = true;
|
|
1312
1892
|
}
|
|
1313
1893
|
return columns;
|
|
1314
1894
|
}
|
|
1315
1895
|
/** Whether `role` may run `command` on rows of `table` at all: no RLS, or a permissive policy for it. */
|
|
1316
1896
|
const policyAllows = (table, command, role) => !table.rls || table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
|
|
1897
|
+
/** Whether `grant` reaches `role`: granted to it, or to PUBLIC, which every role inherits. */
|
|
1898
|
+
const grantsTo = (grant, role) => grant.role === role || grant.role === "PUBLIC";
|
|
1317
1899
|
/** The grant columns `role` may write with `command`, through a table-level or a column-level grant. */
|
|
1318
1900
|
function writableColumns(table, columns, command, role) {
|
|
1319
1901
|
if (!policyAllows(table, command, role)) return [];
|
|
1320
1902
|
const privilege = command.toUpperCase();
|
|
1321
|
-
if (table.grants.some((grant) => grant
|
|
1322
|
-
return columns.filter((column) => (table.columnGrants ?? []).some((grant) => grant
|
|
1903
|
+
if (table.grants.some((grant) => grantsTo(grant, role) && grant.privileges.includes(privilege))) return [...columns];
|
|
1904
|
+
return columns.filter((column) => (table.columnGrants ?? []).some((grant) => grantsTo(grant, role) && grant.column === column && grant.privileges.includes(privilege)));
|
|
1323
1905
|
}
|
|
1324
1906
|
/** PostgREST's default for `db-hoisted-tx-settings`. */
|
|
1325
1907
|
const DEFAULT_HOISTED = [
|
|
@@ -1389,24 +1971,28 @@ const RLS_RULES = [
|
|
|
1389
1971
|
code: "BS207",
|
|
1390
1972
|
severity: "warning",
|
|
1391
1973
|
title: "Several permissive policies for one command and role",
|
|
1392
|
-
description: "Postgres evaluates every permissive policy that applies and ORs them, so each extra policy adds its cost to every row. Merge them into one policy per command and role.
|
|
1393
|
-
check:
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1974
|
+
description: "Postgres evaluates every permissive policy that applies and ORs them, so each extra policy adds its cost to every row. Merge them into one policy per command and role. When the performance advisor (BS200) runs, splinter's `multiple_permissive_policies` reports a table it covers and this rule skips it; on a saved snapshot this rule reports every table.",
|
|
1975
|
+
check: async (context) => {
|
|
1976
|
+
const splinter = await splinterTables(context, "multiple_permissive_policies");
|
|
1977
|
+
return rlsTables(context).flatMap((table) => {
|
|
1978
|
+
if (splinter?.has(qualified(table))) return [];
|
|
1979
|
+
const overlaps = permissiveOverlaps(table);
|
|
1980
|
+
if (overlaps.length === 0) return [];
|
|
1981
|
+
const first = table.policies.find((policy) => policy.name === overlaps[0].policies[0]);
|
|
1982
|
+
const list = overlaps.map((overlap) => `${overlap.command} for ${overlap.role}: ${overlap.policies.join(", ")}`).join("; ");
|
|
1983
|
+
return [{
|
|
1984
|
+
message: `${qualified(table)} has several permissive policies per command and role (${list}). Postgres runs all of them for every row; merge each group into one policy.`,
|
|
1985
|
+
target: qualified(table),
|
|
1986
|
+
object: policyObject(table, first)
|
|
1987
|
+
}];
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1404
1990
|
},
|
|
1405
1991
|
{
|
|
1406
1992
|
code: "BS213",
|
|
1407
1993
|
severity: "warning",
|
|
1408
1994
|
title: "API roles can write the columns that grant access",
|
|
1409
|
-
description: "RLS helpers decide access from columns such as `memberships.user_id`, `memberships.role` or `contacts.customer_id`, and
|
|
1995
|
+
description: "RLS helpers decide access from columns such as `memberships.user_id`, `memberships.role` or `contacts.customer_id`, and an authorization provider lists them as `decidingColumns`. When `anon` or `authenticated` may insert or update one of them, and a policy lets them write the row, a user can grant themselves access. A table-level grant counts even after a column-level revoke.",
|
|
1410
1996
|
check: (context) => {
|
|
1411
1997
|
const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
|
|
1412
1998
|
return [...grantColumns(context)].flatMap(([name, byColumn]) => {
|
|
@@ -1421,18 +2007,24 @@ const RLS_RULES = [
|
|
|
1421
2007
|
const writable = writableColumns(table, columns, command, role);
|
|
1422
2008
|
if (writable.length === 0) continue;
|
|
1423
2009
|
writes.push(`${role} may ${command} ${writable.join(", ")}`);
|
|
1424
|
-
|
|
1425
|
-
|
|
2010
|
+
const privilege = command.toUpperCase();
|
|
2011
|
+
if (table.grants.some((grant) => grantsTo(grant, role) && grant.privileges.includes(privilege))) {
|
|
2012
|
+
tableLevel.push(command);
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
const fromPublic = (table.columnGrants ?? []).some((grant) => grant.role === "PUBLIC" && writable.includes(grant.column) && grant.privileges.includes(privilege));
|
|
2016
|
+
fix.push(`revoke ${command} (${writable.join(", ")}) on ${name} from ${fromPublic ? `${role}, public` : role};`);
|
|
1426
2017
|
}
|
|
1427
2018
|
if (tableLevel.length === 0) continue;
|
|
1428
|
-
|
|
2019
|
+
const fromPublic = table.grants.some((grant) => grant.role === "PUBLIC" && tableLevel.some((command) => grant.privileges.includes(command.toUpperCase())));
|
|
2020
|
+
fix.push(`revoke ${tableLevel.join(", ")} on ${name} from ${fromPublic ? `${role}, public` : role};`);
|
|
1429
2021
|
const rest = table.columns.map((column) => column.name).filter((column) => !byColumn.has(column));
|
|
1430
2022
|
if (rest.length > 0) fix.push(`grant ${tableLevel.map((command) => `${command} (${rest.join(", ")})`).join(", ")} on ${name} to ${role};`);
|
|
1431
2023
|
}
|
|
1432
2024
|
if (writes.length === 0) return [];
|
|
1433
2025
|
const helpers = [...new Set(columns.flatMap((column) => [...byColumn.get(column).helpers]))].sort();
|
|
1434
|
-
const
|
|
1435
|
-
const readers = [...helpers.length > 0 ? [`${helpers.join(", ")} read${helpers.length === 1 ? "s" : ""} them to decide access`] : [], ...
|
|
2026
|
+
const deciding = columns.filter((column) => byColumn.get(column).provider);
|
|
2027
|
+
const readers = [...helpers.length > 0 ? [`${helpers.join(", ")} read${helpers.length === 1 ? "s" : ""} them to decide access`] : [], ...deciding.length > 0 ? [`the authorization provider lists ${deciding.join(", ")} as deciding columns`] : []].join(", and ");
|
|
1436
2028
|
return [{
|
|
1437
2029
|
message: `${name}: ${writes.join("; ")}. ${readers}, so a user who writes them can grant themselves access. Revoke the columns and grant the rest:\n${fix.join("\n")}`,
|
|
1438
2030
|
target: name,
|
|
@@ -1474,7 +2066,7 @@ const RLS_RULES = [
|
|
|
1474
2066
|
//#region src/cli/doctor/policies.ts
|
|
1475
2067
|
const AUTH_ROLE = /\bauth"?\s*\.\s*"?role"?\s*\(\s*\)/i;
|
|
1476
2068
|
/** A check of who is calling: the JWT, the user id, or the session role. */
|
|
1477
|
-
const CALLER_CHECK = /\bauth"?\s*\.\s*"?(?:uid|jwt)"?\s*\(|request\.jwt|\b(?:current_user|session_user|current_role)\b|
|
|
2069
|
+
const CALLER_CHECK = /\bauth"?\s*\.\s*"?(?:uid|jwt)"?\s*\(|request\.jwt|\b(?:current_user|session_user|current_role)\b|has_organization_role|member_organization_ids/i;
|
|
1478
2070
|
const USER_METADATA = /\buser_metadata\b/i;
|
|
1479
2071
|
/** Privileges an API role never needs on a table. */
|
|
1480
2072
|
const NEEDLESS = [
|
|
@@ -1704,15 +2296,18 @@ const SCHEMA_DESIGN_RULES = [
|
|
|
1704
2296
|
code: "BS216",
|
|
1705
2297
|
severity: "info",
|
|
1706
2298
|
title: "Foreign key without an index",
|
|
1707
|
-
description: "Deletes and updates on the referenced table scan the referencing table for each row, and joins on the key can't use an index. Add an index that starts with the key's columns.
|
|
1708
|
-
check:
|
|
1709
|
-
|
|
1710
|
-
return
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
2299
|
+
description: "Deletes and updates on the referenced table scan the referencing table for each row, and joins on the key can't use an index. Add an index that starts with the key's columns. When the performance advisor (BS200) runs, splinter's `unindexed_foreign_keys` reports a table it covers and this rule skips it; on a saved snapshot this rule reports every table.",
|
|
2300
|
+
check: async (context) => {
|
|
2301
|
+
const splinter = await splinterTables(context, "unindexed_foreign_keys");
|
|
2302
|
+
return exposed(context).flatMap((table) => {
|
|
2303
|
+
if (table.kind !== "table" || splinter?.has(qualified(table))) return [];
|
|
2304
|
+
return unindexedForeignKeys(table).map((key) => ({
|
|
2305
|
+
message: `${qualified(table)}.${key.name} (${key.columns.join(", ")}) has no index that starts with its columns, so deletes on ${key.refSchema}.${key.refTable} scan ${qualified(table)}. Run \`create index on ${qualified(table)} (${key.columns.join(", ")});\`.`,
|
|
2306
|
+
target: `${qualified(table)}.${key.name}`,
|
|
2307
|
+
object: tableObject(table)
|
|
2308
|
+
}));
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
1716
2311
|
},
|
|
1717
2312
|
{
|
|
1718
2313
|
code: "BS217",
|
|
@@ -1819,6 +2414,27 @@ const SCHEMA_DESIGN_RULES = [
|
|
|
1819
2414
|
}];
|
|
1820
2415
|
}));
|
|
1821
2416
|
}
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
code: "BS222",
|
|
2420
|
+
severity: "warning",
|
|
2421
|
+
title: "Column that can exceed a JavaScript number",
|
|
2422
|
+
description: "`int8` and `numeric` decode as `number` by default, which matches `supabase gen types` but loses precision past 2^53 (or 15 to 17 significant digits). An `int8` identity or sequence keeps growing toward that limit, and `numeric` usually holds exact amounts. Set `codecs.int8` to `bigint` or `string`, or `codecs.numeric` to `string`.",
|
|
2423
|
+
check: (context) => {
|
|
2424
|
+
const { int8, numeric } = context.config.codecs;
|
|
2425
|
+
if (int8 !== "number" && numeric !== "number") return [];
|
|
2426
|
+
return exposed(context).flatMap((table) => {
|
|
2427
|
+
if (configFor(context.config.tables, table.schema, table.name)?.exclude) return [];
|
|
2428
|
+
const columns = table.columns.filter((column) => column.udt === "int8" ? int8 === "number" && !column.isArray && (column.identity !== null || (column.default?.includes("nextval(") ?? false)) : column.udt === "numeric" && numeric === "number");
|
|
2429
|
+
if (columns.length === 0) return [];
|
|
2430
|
+
const codecs = [...columns.some((column) => column.udt === "int8") ? ["`int8: \"bigint\"`"] : [], ...columns.some((column) => column.udt === "numeric") ? ["`numeric: \"string\"`"] : []];
|
|
2431
|
+
return [{
|
|
2432
|
+
message: `${qualified(table)} ${columns.map((column) => `${column.name} (${column.udt})`).join(", ")} decode as number and lose precision past 2^53. Set ${codecs.join(" and ")} in \`codecs\`.`,
|
|
2433
|
+
target: qualified(table),
|
|
2434
|
+
object: tableObject(table)
|
|
2435
|
+
}];
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
1822
2438
|
}
|
|
1823
2439
|
];
|
|
1824
2440
|
//#endregion
|
|
@@ -1827,31 +2443,40 @@ const DOCS_URL = "https://bettersupabase.com/docs/cli/doctor";
|
|
|
1827
2443
|
const writes = (policy) => policy.command === "all" || policy.command === "insert" || policy.command === "update" || policy.command === "delete";
|
|
1828
2444
|
const publicRoles = (policy) => policy.roles.length === 0 || policy.roles.some((role) => role === "public" || role === "anon");
|
|
1829
2445
|
const isTrue = (expression) => expression !== null && /^\(*\s*true\s*\)*$/i.test(expression.trim());
|
|
1830
|
-
/** The `sql.
|
|
1831
|
-
async function
|
|
1832
|
-
if (context.config.sql.
|
|
1833
|
-
const access =
|
|
2446
|
+
/** The `sql.modules` files as this release renders them, next to what is on disk. */
|
|
2447
|
+
async function moduleFiles(context) {
|
|
2448
|
+
if (context.config.sql.moduleNames.length === 0) return [];
|
|
2449
|
+
const access = accessModule(context);
|
|
1834
2450
|
if (access.kind === "invalid") return [];
|
|
1835
2451
|
const readSets = context.readSets;
|
|
1836
2452
|
const skipped = readSets !== void 0 && "skipped" in readSets;
|
|
1837
|
-
|
|
2453
|
+
let rendered;
|
|
2454
|
+
try {
|
|
2455
|
+
rendered = renderModules(context.config.sql.moduleNames, {
|
|
2456
|
+
...moduleLayout(context.config, context.config.sql.testsDir, skipped ? [] : readSets, entitlementsModule(context), access.kind === "provider" ? access.access : void 0, permissionCatalogKeys(context)),
|
|
2457
|
+
schemasDir: declarativeSchemasDir(context.configToml)
|
|
2458
|
+
});
|
|
2459
|
+
} catch {
|
|
2460
|
+
return [];
|
|
2461
|
+
}
|
|
2462
|
+
const files = rendered.filter((file) => !(skipped && file.module === "read-sets"));
|
|
1838
2463
|
return Promise.all(files.map(async (file) => {
|
|
1839
2464
|
const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
|
|
1840
|
-
const version =
|
|
2465
|
+
const version = moduleFileVersion(file.contents)?.version ?? 1;
|
|
1841
2466
|
return {
|
|
1842
2467
|
file,
|
|
1843
2468
|
current,
|
|
1844
|
-
behind: current !== void 0 && (
|
|
2469
|
+
behind: current !== void 0 && (moduleFileVersion(current)?.version ?? 1) < version
|
|
1845
2470
|
};
|
|
1846
2471
|
}));
|
|
1847
2472
|
}
|
|
1848
|
-
async function
|
|
2473
|
+
async function moduleVersionsBehind(context) {
|
|
1849
2474
|
const findings = [];
|
|
1850
2475
|
const upgrade = "Run `better-supabase sql upgrade`";
|
|
1851
|
-
for (const { file, current, behind } of await
|
|
2476
|
+
for (const { file, current, behind } of await moduleFiles(context)) {
|
|
1852
2477
|
if (!behind || current === void 0) continue;
|
|
1853
2478
|
findings.push({
|
|
1854
|
-
message: `${file.path} has ${file.module} version ${String(
|
|
2479
|
+
message: `${file.path} has ${file.module} version ${String(moduleFileVersion(current)?.version ?? 1)}; this release ships version ${String(moduleFileVersion(file.contents)?.version ?? 1)}. ${upgrade}, then \`${migrationCommand(context.configToml)}\`.`,
|
|
1855
2480
|
target: file.path,
|
|
1856
2481
|
location: {
|
|
1857
2482
|
file: file.path,
|
|
@@ -1860,21 +2485,21 @@ async function kitVersionsBehind(context) {
|
|
|
1860
2485
|
});
|
|
1861
2486
|
}
|
|
1862
2487
|
const db = context.database;
|
|
1863
|
-
if (!db || "skipped" in db || context.config.sql.
|
|
2488
|
+
if (!db || "skipped" in db || context.config.sql.moduleNames.length === 0) return findings;
|
|
1864
2489
|
let rows;
|
|
1865
2490
|
try {
|
|
1866
|
-
rows = await db.query("select name, version from better_supabase.
|
|
2491
|
+
rows = await db.query("select name, version from better_supabase.modules");
|
|
1867
2492
|
} catch {
|
|
1868
2493
|
return findings;
|
|
1869
2494
|
}
|
|
1870
2495
|
for (const row of rows) {
|
|
1871
2496
|
const module = SQL_MODULES[row.name];
|
|
1872
|
-
if (!module || !context.config.sql.
|
|
2497
|
+
if (!module || !context.config.sql.moduleNames.includes(row.name)) continue;
|
|
1873
2498
|
const version = moduleVersion(module);
|
|
1874
2499
|
if (row.version >= version) continue;
|
|
1875
2500
|
findings.push({
|
|
1876
2501
|
message: `The database has ${row.name} version ${String(row.version)}; this release ships version ${String(version)}. ${upgrade} and apply the migrations it prints.`,
|
|
1877
|
-
target: `better_supabase.
|
|
2502
|
+
target: `better_supabase.modules.${row.name}`
|
|
1878
2503
|
});
|
|
1879
2504
|
}
|
|
1880
2505
|
return findings;
|
|
@@ -1909,70 +2534,6 @@ function envEntries(file) {
|
|
|
1909
2534
|
}];
|
|
1910
2535
|
});
|
|
1911
2536
|
}
|
|
1912
|
-
const ADVISOR_SEVERITY = {
|
|
1913
|
-
ERROR: "error",
|
|
1914
|
-
WARN: "warning",
|
|
1915
|
-
INFO: "info"
|
|
1916
|
-
};
|
|
1917
|
-
function lintObject(lint) {
|
|
1918
|
-
const { schema, name, type } = lint.metadata ?? {};
|
|
1919
|
-
if (typeof schema !== "string" || typeof name !== "string") return void 0;
|
|
1920
|
-
if (type === "table" || type === "view") return {
|
|
1921
|
-
kind: "table",
|
|
1922
|
-
schema,
|
|
1923
|
-
name
|
|
1924
|
-
};
|
|
1925
|
-
if (type === "function") return {
|
|
1926
|
-
kind: "function",
|
|
1927
|
-
schema,
|
|
1928
|
-
name
|
|
1929
|
-
};
|
|
1930
|
-
}
|
|
1931
|
-
function lintFinding(lint) {
|
|
1932
|
-
const object = lintObject(lint);
|
|
1933
|
-
return {
|
|
1934
|
-
severity: ADVISOR_SEVERITY[lint.level] ?? "warning",
|
|
1935
|
-
title: lint.title,
|
|
1936
|
-
message: `${lint.detail.replaceAll("\\`", "`")} [${lint.name}]`,
|
|
1937
|
-
target: object ? `${object.schema}.${object.name}` : lint.cache_key,
|
|
1938
|
-
help: lint.remediation,
|
|
1939
|
-
...object ? { object } : {}
|
|
1940
|
-
};
|
|
1941
|
-
}
|
|
1942
|
-
function advisorRule(code, category, title, description, covered = () => false) {
|
|
1943
|
-
return {
|
|
1944
|
-
code,
|
|
1945
|
-
severity: "warning",
|
|
1946
|
-
title,
|
|
1947
|
-
description,
|
|
1948
|
-
async check(context) {
|
|
1949
|
-
const { advisors } = context;
|
|
1950
|
-
if (!advisors) return [];
|
|
1951
|
-
if ("skipped" in advisors) return [{
|
|
1952
|
-
severity: "info",
|
|
1953
|
-
message: `Skipped the ${category} advisor: ${advisors.skipped}`
|
|
1954
|
-
}];
|
|
1955
|
-
try {
|
|
1956
|
-
return (await advisors.lints(category)).filter((lint) => !covered(lint, context)).map(lintFinding);
|
|
1957
|
-
} catch (cause) {
|
|
1958
|
-
return [{ message: `The ${category} advisor could not run (${advisors.describe}): ${cause instanceof Error ? cause.message : String(cause)}` }];
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
};
|
|
1962
|
-
}
|
|
1963
|
-
/**
|
|
1964
|
-
* splinter lints doctor's own rules report for the same table:
|
|
1965
|
-
* `multiple_permissive_policies` (BS207) and `unindexed_foreign_keys` (BS216).
|
|
1966
|
-
*/
|
|
1967
|
-
function coveredByOwnRules(lint, context) {
|
|
1968
|
-
const code = lint.name === "multiple_permissive_policies" ? "BS207" : lint.name === "unindexed_foreign_keys" ? "BS216" : void 0;
|
|
1969
|
-
if (!code || !context.codes?.includes(code)) return false;
|
|
1970
|
-
const object = lintObject(lint);
|
|
1971
|
-
if (!object) return false;
|
|
1972
|
-
const table = exposed(context).find((candidate) => candidate.schema === object.schema && candidate.name === object.name);
|
|
1973
|
-
if (!table) return false;
|
|
1974
|
-
return code === "BS207" ? permissiveOverlaps(table).length > 0 : unindexedForeignKeys(table).length > 0;
|
|
1975
|
-
}
|
|
1976
2537
|
/**
|
|
1977
2538
|
* `db.x.aggregate(`, an `_sum:`/`_avg:`/`_min:`/`_max:` include or a list
|
|
1978
2539
|
* with `facetCounts: true`.
|
|
@@ -1980,11 +2541,12 @@ function coveredByOwnRules(lint, context) {
|
|
|
1980
2541
|
const AGGREGATE_USE = /\.aggregate\(|\b_(?:sum|avg|min|max)\s*:\s*\{|\bfacetCounts\s*:\s*true\b/;
|
|
1981
2542
|
/**
|
|
1982
2543
|
* A policy that reads memberships or the tenant claim: `is_member(...)`,
|
|
1983
|
-
* `current_tenant_id()`, `memberships
|
|
2544
|
+
* `current_tenant_id()`, `memberships`. The authorization provider's
|
|
2545
|
+
* functions (`authorization.requires`) count too.
|
|
1984
2546
|
*/
|
|
1985
|
-
const TENANT_HELPER = /member|tenant|current_org|org_id
|
|
2547
|
+
const TENANT_HELPER = /member|tenant|current_org|org_id/i;
|
|
1986
2548
|
/** A foreign key to the tenant table itself, whose own policies are usually read-only. */
|
|
1987
|
-
const TENANT_COLUMN = /^(?:
|
|
2549
|
+
const TENANT_COLUMN = /^(?:organization|organization|tenant|team|workspace|account)_id$/;
|
|
1988
2550
|
const COMMANDS = [
|
|
1989
2551
|
"select",
|
|
1990
2552
|
"insert",
|
|
@@ -2010,20 +2572,34 @@ const RULES = [
|
|
|
2010
2572
|
code: "BS106",
|
|
2011
2573
|
severity: "error",
|
|
2012
2574
|
title: "Table not granted to the Data API",
|
|
2013
|
-
description: "Supabase no longer grants new tables to anon and authenticated. Without a grant, every Data API request fails with 42501 before RLS runs. Tables in `expose` need the privileges listed there; other tables need `select` for authenticated.",
|
|
2575
|
+
description: "Supabase no longer grants new tables to anon and authenticated. Without a grant, every Data API request fails with 42501 before RLS runs. Tables in `expose` need the privileges listed there; other tables need `select` for authenticated. Tables marked `serviceRole` in `tables` must have no grants for either role.",
|
|
2014
2576
|
check: (context) => {
|
|
2015
2577
|
const auto = context.configToml ? tomlGet(context.configToml.document, ["api", "auto_expose_new_tables"]) : void 0;
|
|
2016
2578
|
const note = auto === false || auto === "false" ? " config.toml sets [api] auto_expose_new_tables = false, so new tables start without grants." : "";
|
|
2017
2579
|
return exposed(context).flatMap((table) => {
|
|
2018
|
-
|
|
2580
|
+
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2581
|
+
if (options?.exclude) return [];
|
|
2582
|
+
const granted = (role) => new Set(table.grants.filter((grant) => grant.role === role || grant.role === "PUBLIC").flatMap((grant) => grant.privileges.map((privilege) => privilege.toLowerCase())));
|
|
2583
|
+
if (options?.serviceRole) {
|
|
2584
|
+
const fromPublic = table.grants.some((grant) => grant.role === "PUBLIC" && grant.privileges.length > 0);
|
|
2585
|
+
return ["anon", "authenticated"].flatMap((role) => {
|
|
2586
|
+
const have = [...granted(role)];
|
|
2587
|
+
if (have.length === 0) return [];
|
|
2588
|
+
const from = fromPublic ? `${role}, public` : role;
|
|
2589
|
+
return [{
|
|
2590
|
+
message: `${qualified(table)} has \`serviceRole: true\` in \`tables\`, but ${role} has ${have.join(", ")} on it, so the Data API reaches it. Remove it from \`expose\` and run \`revoke all on table ${qualified(table)} from ${from};\`.`,
|
|
2591
|
+
target: `${qualified(table)}:${role}`,
|
|
2592
|
+
object: tableObject(table)
|
|
2593
|
+
}];
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2019
2596
|
const wanted = context.config.expose[qualified(table)] ?? context.config.expose[table.name] ?? {
|
|
2020
2597
|
anon: [],
|
|
2021
2598
|
authenticated: ["select"]
|
|
2022
2599
|
};
|
|
2023
|
-
const granted = (role) => new Set(table.grants.filter((grant) => grant.role === role || grant.role === "PUBLIC").flatMap((grant) => grant.privileges.map((privilege) => privilege.toLowerCase())));
|
|
2024
2600
|
return ["anon", "authenticated"].flatMap((role) => {
|
|
2025
2601
|
const have = granted(role);
|
|
2026
|
-
const missing = wanted[role].filter((privilege) => !have.has(privilege));
|
|
2602
|
+
const missing = wanted[role].filter((privilege) => !privilege.includes("(") && !have.has(privilege));
|
|
2027
2603
|
if (missing.length === 0) return [];
|
|
2028
2604
|
return [{
|
|
2029
2605
|
message: `${role} has no ${missing.join(", ")} on ${qualified(table)}, so the Data API answers 42501. Add it to \`expose\` and run \`better-supabase sql add grants\`, or run \`grant ${missing.join(", ")} on table ${qualified(table)} to ${role};\`.${note}`,
|
|
@@ -2041,20 +2617,27 @@ const RULES = [
|
|
|
2041
2617
|
description: "A tenant-scoped table with policies for some commands but not all four denies the rest silently: an update or delete then affects 0 rows without an error. Add the missing policies, or a restrictive one, so the intent is explicit, and test it with `expectTenantIsolation`.",
|
|
2042
2618
|
check: (context) => {
|
|
2043
2619
|
const column = context.config.plugins.tenant?.column;
|
|
2620
|
+
const providerFunctions = [...new Set((context.config.authorization?.requires ?? []).flatMap((entry) => [entry.function, entry.function.slice(entry.function.indexOf(".") + 1)]))];
|
|
2044
2621
|
const tables = exposed(context);
|
|
2045
2622
|
const roots = new Set(tables.flatMap((table) => table.foreignKeys.filter((key) => key.columns.length === 1 && (column === void 0 ? TENANT_COLUMN.test(key.columns[0]) : key.columns[0] === column)).map((key) => `${key.refSchema}.${key.refTable}`)));
|
|
2046
2623
|
return tables.flatMap((table) => {
|
|
2047
2624
|
if (table.kind !== "table" || !table.rls) return [];
|
|
2048
2625
|
if (roots.has(qualified(table))) return [];
|
|
2049
|
-
|
|
2050
|
-
|
|
2626
|
+
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2627
|
+
if (options?.exclude || options?.serviceRole) return [];
|
|
2628
|
+
const forAuthenticated = (policy) => policy.roles.some((role) => ["authenticated", "public"].includes(role));
|
|
2629
|
+
const granting = table.policies.filter((policy) => policy.permissive && forAuthenticated(policy));
|
|
2051
2630
|
if (granting.length === 0) return [];
|
|
2052
|
-
if (!(column !== void 0 && table.columns.some((entry) => entry.name === column) || granting.some((policy) =>
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2631
|
+
if (!(column !== void 0 && table.columns.some((entry) => entry.name === column) || granting.some((policy) => {
|
|
2632
|
+
const text = [
|
|
2633
|
+
policy.using ?? "",
|
|
2634
|
+
policy.check ?? "",
|
|
2635
|
+
...policy.functions ?? []
|
|
2636
|
+
].join(" ");
|
|
2637
|
+
return TENANT_HELPER.test(text) || providerFunctions.some((fn) => text.includes(fn));
|
|
2638
|
+
}))) return [];
|
|
2639
|
+
const denying = table.policies.filter((policy) => !policy.permissive && forAuthenticated(policy) && /^\s*false\s*$/i.test(policy.using ?? ""));
|
|
2640
|
+
const covered = new Set([...granting, ...denying].flatMap((policy) => policy.command === "all" ? COMMANDS : [policy.command]));
|
|
2058
2641
|
const missing = COMMANDS.filter((command) => !covered.has(command));
|
|
2059
2642
|
if (missing.length === 0) return [];
|
|
2060
2643
|
return [{
|
|
@@ -2069,14 +2652,14 @@ const RULES = [
|
|
|
2069
2652
|
code: "BS108",
|
|
2070
2653
|
severity: "error",
|
|
2071
2654
|
title: "Policy reads auth.mfa_factors directly",
|
|
2072
|
-
description: "`authenticated` has no access to `auth.mfa_factors`, so a policy that reads it fails every request with 42501. Check the factor in a `security definer` function such as `better_supabase.mfa_satisfied()` from the `mfa`
|
|
2655
|
+
description: "`authenticated` has no access to `auth.mfa_factors`, so a policy that reads it fails every request with 42501. Check the factor in a `security definer` function such as `better_supabase.mfa_satisfied()` from the `mfa` SQL module.",
|
|
2073
2656
|
check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => MFA_FACTORS.test(`${policy.using ?? ""} ${policy.check ?? ""}`)).map((policy) => ({
|
|
2074
2657
|
message: `Policy "${policy.name}" on ${qualified(table)} reads auth.mfa_factors, which authenticated can't select, so every request fails with 42501. Use \`(select better_supabase.mfa_satisfied())\` from \`better-supabase sql add mfa\` instead.`,
|
|
2075
2658
|
target: `${qualified(table)}.${policy.name}`,
|
|
2076
2659
|
object: policyObject(table, policy)
|
|
2077
2660
|
})))
|
|
2078
2661
|
},
|
|
2079
|
-
advisorRule("BS200", "performance", "Supabase performance advisor", "Findings from the Performance Advisor (splinter): unindexed foreign keys, auth calls re-evaluated per row in policies, multiple permissive policies, unused and duplicate indexes and more."
|
|
2662
|
+
advisorRule("BS200", "performance", "Supabase performance advisor", "Findings from the Performance Advisor (splinter): unindexed foreign keys, auth calls re-evaluated per row in policies, multiple permissive policies, unused and duplicate indexes and more."),
|
|
2080
2663
|
{
|
|
2081
2664
|
code: "BS204",
|
|
2082
2665
|
severity: "warning",
|
|
@@ -2147,7 +2730,9 @@ const RULES = [
|
|
|
2147
2730
|
path: bucket.path,
|
|
2148
2731
|
...bucket.public === void 0 ? {} : { public: bucket.public },
|
|
2149
2732
|
...bucket.fileSizeLimit === void 0 ? {} : { fileSizeLimit: bucket.fileSizeLimit },
|
|
2150
|
-
...bucket.allowedMimeTypes === void 0 ? {} : { allowedMimeTypes: bucket.allowedMimeTypes }
|
|
2733
|
+
...bucket.allowedMimeTypes === void 0 ? {} : { allowedMimeTypes: bucket.allowedMimeTypes },
|
|
2734
|
+
...bucket.versioning === void 0 ? {} : { versioning: bucket.versioning },
|
|
2735
|
+
...bucket.lifecycle === void 0 ? {} : { lifecycle: bucket.lifecycle }
|
|
2151
2736
|
});
|
|
2152
2737
|
const actual = catalogOf(context).buckets.find((entry) => entry.id === id);
|
|
2153
2738
|
const inDatabase = defined.drift(actual).map((drift) => ({
|
|
@@ -2204,13 +2789,13 @@ const RULES = [
|
|
|
2204
2789
|
{
|
|
2205
2790
|
code: "BS304",
|
|
2206
2791
|
severity: "warning",
|
|
2207
|
-
title: "SQL
|
|
2208
|
-
description: "A module listed in `sql.
|
|
2792
|
+
title: "SQL module files are out of date",
|
|
2793
|
+
description: "A module listed in `sql.modules` differs from the version in this release. Same check as `sql sync --check`.",
|
|
2209
2794
|
check: async (context) => {
|
|
2210
2795
|
const stale = [];
|
|
2211
|
-
for (const { file, current, behind } of await
|
|
2212
|
-
if (behind ||
|
|
2213
|
-
const edited = current === void 0 ? "" : ` If you edited it, move the change to \`
|
|
2796
|
+
for (const { file, current, behind } of await moduleFiles(context)) {
|
|
2797
|
+
if (behind || sameModuleFile(current, file.contents)) continue;
|
|
2798
|
+
const edited = current === void 0 ? "" : ` If you edited it, move the change to \`sql.modules.${file.module}\` in better-supabase.config.ts or to the module's SQL hooks: sync overwrites the file.`;
|
|
2214
2799
|
const next = file.kind === "data" ? "Run `better-supabase sql sync`, which also writes the rows into a migration, since a schema diff skips them." : `Run \`better-supabase sql sync\`, then \`${migrationCommand(context.configToml)}\`.`;
|
|
2215
2800
|
stale.push({
|
|
2216
2801
|
message: `${file.path} (${file.module}) is ${current === void 0 ? "missing" : "out of date"}. ${next}${edited}`,
|
|
@@ -2224,6 +2809,26 @@ const RULES = [
|
|
|
2224
2809
|
return stale;
|
|
2225
2810
|
}
|
|
2226
2811
|
},
|
|
2812
|
+
{
|
|
2813
|
+
code: "BS316",
|
|
2814
|
+
severity: "warning",
|
|
2815
|
+
title: "Legacy migra diff engine",
|
|
2816
|
+
description: "`supabase/config.toml` has no `[experimental.pgdelta] enabled = true`, so the Supabase CLI diffs `supabase/schemas` with migra, which drops grants, comments and security-invoker views and needs the stack stopped. New projects from `supabase init` use pg-delta.",
|
|
2817
|
+
check: (context) => {
|
|
2818
|
+
const toml = context.configToml;
|
|
2819
|
+
if (toml === void 0 || diffEngine(toml) === "pg-delta") return [];
|
|
2820
|
+
const schemas = `${toml.dir}/schemas/`;
|
|
2821
|
+
if (!(context.config.sql.moduleNames.length > 0 || (context.sqlFiles ?? []).some((file) => file.path.startsWith(schemas)))) return [];
|
|
2822
|
+
return [{
|
|
2823
|
+
message: `${toml.path} uses the legacy migra engine. Add \`[experimental.pgdelta]\` with \`enabled = true\`, remove \`[db.migrations] schema_paths\`, and create migrations with \`supabase db schema declarative sync\` instead of \`supabase db diff\`.`,
|
|
2824
|
+
target: toml.path,
|
|
2825
|
+
location: {
|
|
2826
|
+
file: toml.path,
|
|
2827
|
+
line: 1
|
|
2828
|
+
}
|
|
2829
|
+
}];
|
|
2830
|
+
}
|
|
2831
|
+
},
|
|
2227
2832
|
{
|
|
2228
2833
|
code: "BS305",
|
|
2229
2834
|
severity: "warning",
|
|
@@ -2263,9 +2868,9 @@ const RULES = [
|
|
|
2263
2868
|
{
|
|
2264
2869
|
code: "BS311",
|
|
2265
2870
|
severity: "warning",
|
|
2266
|
-
title: "SQL
|
|
2267
|
-
description: "A module file or `better_supabase.
|
|
2268
|
-
check:
|
|
2871
|
+
title: "SQL module behind its current version",
|
|
2872
|
+
description: "A module file or `better_supabase.modules` records an older module version than this release ships. Same check as `sql upgrade --check`.",
|
|
2873
|
+
check: moduleVersionsBehind
|
|
2269
2874
|
},
|
|
2270
2875
|
{
|
|
2271
2876
|
code: "BS401",
|
|
@@ -2372,9 +2977,11 @@ const RULES = [
|
|
|
2372
2977
|
...POLICY_RULES,
|
|
2373
2978
|
...SCHEMA_DESIGN_RULES,
|
|
2374
2979
|
...HOOK_RULES,
|
|
2375
|
-
...
|
|
2980
|
+
...COOKIE_RULES,
|
|
2981
|
+
...AUTHORIZATION_RULES,
|
|
2376
2982
|
...LIVE_RULES,
|
|
2377
|
-
...
|
|
2983
|
+
...MODULE_RULES,
|
|
2984
|
+
...PGDELTA_RULES
|
|
2378
2985
|
].sort((a, b) => byCodePoint(a.code, b.code));
|
|
2379
2986
|
const RULE_CODES = RULES.map((rule) => rule.code);
|
|
2380
2987
|
async function runRules(context, rules = RULES) {
|
|
@@ -2383,13 +2990,21 @@ async function runRules(context, rules = RULES) {
|
|
|
2383
2990
|
...context,
|
|
2384
2991
|
codes: rules.map((rule) => rule.code)
|
|
2385
2992
|
};
|
|
2386
|
-
for (const rule of rules)
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2993
|
+
for (const rule of rules) {
|
|
2994
|
+
const help = `${DOCS_URL}#${rule.code.toLowerCase()}`;
|
|
2995
|
+
const skipped = skippedForMetadata(context.snapshot, rule, help);
|
|
2996
|
+
if (skipped) {
|
|
2997
|
+
findings.push(skipped);
|
|
2998
|
+
continue;
|
|
2999
|
+
}
|
|
3000
|
+
for (const input of await rule.check(scoped)) findings.push({
|
|
3001
|
+
code: rule.code,
|
|
3002
|
+
severity: rule.severity,
|
|
3003
|
+
title: rule.title,
|
|
3004
|
+
help,
|
|
3005
|
+
...input
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
2393
3008
|
return findings;
|
|
2394
3009
|
}
|
|
2395
3010
|
//#endregion
|
|
@@ -2531,6 +3146,11 @@ const ARGS = {
|
|
|
2531
3146
|
description: "Check a saved snapshot instead of the database",
|
|
2532
3147
|
valueHint: "file"
|
|
2533
3148
|
},
|
|
3149
|
+
metadata: {
|
|
3150
|
+
type: "string",
|
|
3151
|
+
description: "Check a GeneratorMetadata document (- for stdin); checks it lacks data for are skipped",
|
|
3152
|
+
valueHint: "path|-"
|
|
3153
|
+
},
|
|
2534
3154
|
"db-url-stdin": {
|
|
2535
3155
|
type: "boolean",
|
|
2536
3156
|
description: "Read the connection string from stdin instead of $DATABASE_URL or the config"
|
|
@@ -2567,6 +3187,7 @@ const ARGS = {
|
|
|
2567
3187
|
const ENV_FILES = [
|
|
2568
3188
|
".env",
|
|
2569
3189
|
".env.local",
|
|
3190
|
+
".env.example",
|
|
2570
3191
|
".env.development",
|
|
2571
3192
|
".env.development.local",
|
|
2572
3193
|
".env.production",
|
|
@@ -2586,8 +3207,9 @@ async function sqlFiles(root, toml) {
|
|
|
2586
3207
|
text: await readFile(resolve(root, path), "utf8")
|
|
2587
3208
|
});
|
|
2588
3209
|
const schemas = await Promise.all((await schemaPaths(root, toml)).files.map(read));
|
|
2589
|
-
const
|
|
2590
|
-
const
|
|
3210
|
+
const migrationsPath = `${toml?.dir ?? "supabase"}/migrations`;
|
|
3211
|
+
const migrationsDir = resolve(root, migrationsPath);
|
|
3212
|
+
const migrations = existsSync(migrationsDir) ? (await readdir(migrationsDir)).filter((name) => name.endsWith(".sql")).sort().reverse().map((name) => `${migrationsPath}/${name}`) : [];
|
|
2591
3213
|
return [...schemas, ...await Promise.all(migrations.map(read))];
|
|
2592
3214
|
}
|
|
2593
3215
|
/** Files matching `doctor.sources`, skipping dependencies and build output. */
|
|
@@ -2607,6 +3229,17 @@ const escape = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
|
2607
3229
|
function locate(files, object) {
|
|
2608
3230
|
const name = `"?${escape(object.name)}"?`;
|
|
2609
3231
|
const qualifiedName = `(?:"?${escape(object.schema)}"?\\.)?${name}`;
|
|
3232
|
+
if (object.kind === "policy" && object.table !== void 0) {
|
|
3233
|
+
const onTable = new RegExp(`create\\s+policy\\s+${name}\\s+on\\s+(?:only\\s+)?(?:"?${escape(object.schema)}"?\\.)?"?${escape(object.table)}"?(?:\\s|;|$)`, "i");
|
|
3234
|
+
for (const file of files) {
|
|
3235
|
+
const match = onTable.exec(file.text);
|
|
3236
|
+
if (match) return {
|
|
3237
|
+
file: file.path,
|
|
3238
|
+
line: file.text.slice(0, match.index).split("\n").length
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
2610
3243
|
const pattern = object.kind === "table" ? new RegExp(`create\\s+(?:unlogged\\s+)?table\\s+(?:if\\s+not\\s+exists\\s+)?${qualifiedName}(?:\\s|\\(|$)`, "i") : object.kind === "function" ? new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${qualifiedName}\\s*\\(`, "i") : new RegExp(`create\\s+policy\\s+${name}`, "i");
|
|
2611
3244
|
for (const file of files) {
|
|
2612
3245
|
const line = lineOf(file.text, pattern);
|
|
@@ -2678,7 +3311,10 @@ function openLive(config, env, source, pg) {
|
|
|
2678
3311
|
advisors: {
|
|
2679
3312
|
describe: "database (splinter)",
|
|
2680
3313
|
async lints(category) {
|
|
2681
|
-
splinter ??= open().then((db) => splinterAdvisors(db.queryable, db.describe, {
|
|
3314
|
+
splinter ??= Promise.all([open(), readSupabaseToml(config.root).catch(() => void 0)]).then(([db, toml]) => splinterAdvisors(db.queryable, db.describe, {
|
|
3315
|
+
cacheDir,
|
|
3316
|
+
schemas: apiSchemas(toml?.document)
|
|
3317
|
+
}));
|
|
2682
3318
|
return (await splinter).lints(category);
|
|
2683
3319
|
}
|
|
2684
3320
|
},
|
|
@@ -2757,10 +3393,9 @@ async function runDoctor(config, args, env, options = {}) {
|
|
|
2757
3393
|
...options.advisors === void 0 ? {} : { advisors: options.advisors },
|
|
2758
3394
|
...options.database === void 0 ? {} : { database: options.database }
|
|
2759
3395
|
};
|
|
2760
|
-
const [snapshot, envFiles,
|
|
3396
|
+
const [snapshot, envFiles, [configToml, sql], gitignore, sources, readSets] = await Promise.all([
|
|
2761
3397
|
options.snapshot ?? loadSnapshot(config, env, source, pg, opened.open),
|
|
2762
3398
|
Promise.all(ENV_FILES.map((path) => readText(config.root, path))).then((files) => files.filter((file) => file !== void 0)),
|
|
2763
|
-
readPermdock(config.root, config.permdock),
|
|
2764
3399
|
readSupabaseToml(config.root).then(async (toml) => [toml, await sqlFiles(config.root, toml)]),
|
|
2765
3400
|
readText(config.root, ".gitignore"),
|
|
2766
3401
|
sourceFiles(config),
|
|
@@ -2772,7 +3407,6 @@ async function runDoctor(config, args, env, options = {}) {
|
|
|
2772
3407
|
const context = {
|
|
2773
3408
|
config,
|
|
2774
3409
|
snapshot,
|
|
2775
|
-
...permdock ? { permdock } : {},
|
|
2776
3410
|
sqlFiles: sql,
|
|
2777
3411
|
configToml,
|
|
2778
3412
|
envFiles,
|
|
@@ -2810,11 +3444,20 @@ async function runDoctor(config, args, env, options = {}) {
|
|
|
2810
3444
|
} : finding;
|
|
2811
3445
|
});
|
|
2812
3446
|
const out = args.out;
|
|
2813
|
-
const
|
|
3447
|
+
const fromRepo = format === "sarif" || format === "github";
|
|
3448
|
+
const repoRoot = findRepoRoot(config.root);
|
|
3449
|
+
const repoPath = (file) => display(repoRoot, resolve(config.root, file));
|
|
3450
|
+
const report = formatReport(fromRepo ? findings.map((finding) => finding.location ? {
|
|
3451
|
+
...finding,
|
|
3452
|
+
location: {
|
|
3453
|
+
...finding.location,
|
|
3454
|
+
file: repoPath(finding.location.file)
|
|
3455
|
+
}
|
|
3456
|
+
} : finding) : findings, {
|
|
2814
3457
|
format,
|
|
2815
3458
|
rules,
|
|
2816
3459
|
version: VERSION,
|
|
2817
|
-
fallbackFile: context.configToml?.path ?? "package.json",
|
|
3460
|
+
fallbackFile: repoPath(context.configToml?.path ?? "package.json"),
|
|
2818
3461
|
...options.paint && !out ? { paint: options.paint } : {}
|
|
2819
3462
|
});
|
|
2820
3463
|
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
@@ -2852,7 +3495,38 @@ const doctorCommand = defineCliCommand({
|
|
|
2852
3495
|
"ignore",
|
|
2853
3496
|
"explain"
|
|
2854
3497
|
],
|
|
2855
|
-
run: async (args, { config, env, io, signal }) => {
|
|
3498
|
+
run: async (args, { config, cwd, env, io, signal }) => {
|
|
3499
|
+
if (args.metadata !== void 0) {
|
|
3500
|
+
const conflicting = [
|
|
3501
|
+
...args.snapshot === void 0 ? [] : ["--snapshot"],
|
|
3502
|
+
...args["db-url-stdin"] === true ? ["--db-url-stdin"] : [],
|
|
3503
|
+
...args["project-ref"] === void 0 ? [] : ["--project-ref"],
|
|
3504
|
+
...args.stats === true ? ["--stats"] : [],
|
|
3505
|
+
...args.explain === void 0 ? [] : ["--explain"],
|
|
3506
|
+
...args.as === void 0 ? [] : ["--as"]
|
|
3507
|
+
];
|
|
3508
|
+
if (conflicting.length > 0) return {
|
|
3509
|
+
code: 2,
|
|
3510
|
+
error: `--metadata checks the document, not a database, so it can't be combined with ${conflicting.join(", ")}`
|
|
3511
|
+
};
|
|
3512
|
+
let snapshot;
|
|
3513
|
+
try {
|
|
3514
|
+
snapshot = await readMetadataSnapshot(args.metadata, cwd, io);
|
|
3515
|
+
} catch (cause) {
|
|
3516
|
+
if (cause instanceof MetadataRejectedError) return {
|
|
3517
|
+
code: 65,
|
|
3518
|
+
error: cause.message
|
|
3519
|
+
};
|
|
3520
|
+
throw cause;
|
|
3521
|
+
}
|
|
3522
|
+
const skipped = `reading a GeneratorMetadata document; set $DATABASE_URL, or pass --db-url-stdin or --project-ref, to check a database.`;
|
|
3523
|
+
return runDoctor(config, args, env, {
|
|
3524
|
+
snapshot,
|
|
3525
|
+
advisors: { skipped },
|
|
3526
|
+
database: { skipped },
|
|
3527
|
+
paint: painter(io.color)
|
|
3528
|
+
});
|
|
3529
|
+
}
|
|
2856
3530
|
const dbUrl = await stdinDatabaseUrl(args["db-url-stdin"], io);
|
|
2857
3531
|
return withSpinner(args.snapshot === void 0 ? io.prompts : void 0, "Checking the database", () => runDoctor(config, args, env, {
|
|
2858
3532
|
paint: painter(io.color),
|