better-supabase 0.8.0 → 0.9.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 +144 -83
- package/bin/better-supabase.js +14 -2
- package/dist/access-sql-DUSSMaC3.js +201 -0
- package/dist/actor-BAhb-d1S.d.ts +104 -0
- package/dist/actor-KHDOKGb1.js +151 -0
- package/dist/adapter-CEjmtazZ.js +1984 -0
- package/dist/agents-BCNhIxVZ.d.ts +111 -0
- package/dist/ai-cache-Dps7aRu1.d.ts +70 -0
- package/dist/ai-cache-zeUL94pB.js +84 -0
- package/dist/ai-chat-3GBSlFR4.d.ts +608 -0
- package/dist/ai-files-D2_9YLjF.js +331 -0
- package/dist/ai-sdk/agents/index.d.ts +154 -10
- package/dist/ai-sdk/agents/index.js +172 -17
- package/dist/ai-sdk/batches/index.d.ts +4 -4
- package/dist/ai-sdk/batches/index.js +5 -5
- package/dist/ai-sdk/cache/index.d.ts +2 -2
- package/dist/ai-sdk/cache/index.js +1 -1
- package/dist/ai-sdk/chat/index.d.ts +1 -1
- package/dist/ai-sdk/chat/index.js +7 -6
- package/dist/ai-sdk/embeddings/index.d.ts +3 -3
- package/dist/ai-sdk/embeddings/index.js +1 -2
- package/dist/ai-sdk/files/index.d.ts +2 -2
- package/dist/ai-sdk/files/index.js +4 -4
- package/dist/ai-sdk/index.d.ts +7 -6
- package/dist/ai-sdk/index.js +4 -3
- package/dist/ai-sdk/mcp/index.d.ts +4 -4
- package/dist/ai-sdk/mcp/index.js +6 -6
- package/dist/ai-sdk/memory/index.d.ts +5 -2
- package/dist/ai-sdk/memory/index.js +2 -2
- package/dist/ai-sdk/react/index.js +1 -2
- package/dist/ai-sdk/workflow/index.d.ts +5 -4
- package/dist/ai-sdk/workflow/index.js +8 -8
- package/dist/ai-tasks-Ih5oHfyj.d.ts +157 -0
- package/dist/announcements-BHCzraR6.js +128 -0
- package/dist/api-CFjuYKFY.js +680 -0
- package/dist/api-TG3jE5aO.d.ts +411 -0
- package/dist/api-keys-BEQatEUW.js +318 -0
- package/dist/api-keys-BYoGCM9p.d.ts +205 -0
- package/dist/app-tables-BGl5_U2x.js +584 -0
- package/dist/approvals-a2KmioBh.d.ts +124 -0
- package/dist/arazzo/index.d.ts +5 -5
- package/dist/arazzo/index.js +6 -6
- package/dist/assistant-Bf2cA6RO.d.ts +148 -0
- package/dist/astro/index.d.ts +9 -8
- package/dist/astro/index.js +10 -7
- package/dist/asyncapi/index.d.ts +4 -4
- package/dist/asyncapi/index.js +6 -6
- package/dist/attachments-DAj2Yp95.js +264 -0
- package/dist/attributes-D4mA7WhI.js +38 -0
- package/dist/audit-log-BMMuGPdP.js +786 -0
- package/dist/auth-CdBKt0CS.js +20 -0
- package/dist/auth-reply-DUCwb8YO.js +31 -0
- package/dist/auth-reply-Dj124sbX.d.ts +11 -0
- package/dist/authorization-DpRfs6Wx.d.ts +1412 -0
- package/dist/authorization-hooks-DkmrnDX7.d.ts +214 -0
- package/dist/authorizer-DH7DjkL3.js +126 -0
- package/dist/base64-BmeCBgGo.js +42 -0
- package/dist/batch-limit-DKVQDz4o.js +5 -0
- package/dist/before-route-B98js9m_.js +30 -0
- package/dist/better-result/index.d.ts +5 -5
- package/dist/better-result/index.js +1 -1
- package/dist/bind-a7sUgvf4.d.ts +70 -0
- package/dist/block-env-CBd_SoL5.js +29 -0
- package/dist/block-env-Dxl7rsI0.d.ts +10 -0
- package/dist/block-events-DD9Q_NUz.js +96 -0
- package/dist/block-fields-5UA84ZUg.d.ts +55 -0
- package/dist/block-fields-ffD6Gxe4.js +139 -0
- package/dist/block-helpers-C86hPEjI.d.ts +37 -0
- package/dist/block-helpers-CfG_MvDR.js +264 -0
- package/dist/block-hooks-BLPWD3Jt.d.ts +107 -0
- package/dist/block-hooks-BsXncUlZ.js +114 -0
- package/dist/block-transport-DaGGXjIw.d.ts +70 -0
- package/dist/block-transport-G4t1umrR.js +85 -0
- package/dist/blocks/access-requests/index.d.ts +98 -0
- package/dist/blocks/access-requests/index.js +99 -0
- package/dist/blocks/agents/index.d.ts +21 -3
- package/dist/blocks/agents/index.js +64 -18
- package/dist/blocks/ai-cache/index.d.ts +7 -2
- package/dist/blocks/ai-cache/index.js +15 -3
- package/dist/blocks/ai-chat/index.d.ts +22 -3
- package/dist/blocks/ai-chat/index.js +29 -3
- package/dist/blocks/ai-chat/react/index.d.ts +12 -46
- package/dist/blocks/ai-chat/react/index.js +43 -335
- package/dist/blocks/ai-chat/solid/index.d.ts +34 -0
- package/dist/blocks/ai-chat/solid/index.js +41 -0
- package/dist/blocks/ai-chat/svelte/index.d.ts +34 -0
- package/dist/blocks/ai-chat/svelte/index.js +41 -0
- package/dist/blocks/ai-chat/vue/index.d.ts +34 -0
- package/dist/blocks/ai-chat/vue/index.js +41 -0
- package/dist/blocks/ai-files/index.d.ts +10 -3
- package/dist/blocks/ai-files/index.js +16 -3
- package/dist/blocks/ai-providers/index.d.ts +18 -3
- package/dist/blocks/ai-providers/index.js +40 -15
- package/dist/blocks/ai-tasks/index.d.ts +9 -2
- package/dist/blocks/ai-tasks/index.js +70 -21
- package/dist/blocks/announcements/index.d.ts +18 -3
- package/dist/blocks/announcements/index.js +27 -3
- package/dist/blocks/announcements/react/index.d.ts +4 -14
- package/dist/blocks/announcements/react/index.js +22 -70
- package/dist/blocks/announcements/solid/index.d.ts +13 -0
- package/dist/blocks/announcements/solid/index.js +14 -0
- package/dist/blocks/announcements/svelte/index.d.ts +13 -0
- package/dist/blocks/announcements/svelte/index.js +14 -0
- package/dist/blocks/announcements/vue/index.d.ts +13 -0
- package/dist/blocks/announcements/vue/index.js +14 -0
- package/dist/blocks/api-keys/index.d.ts +24 -7
- package/dist/blocks/api-keys/index.js +29 -294
- package/dist/blocks/api-keys/react/index.d.ts +29 -0
- package/dist/blocks/api-keys/react/index.js +43 -0
- package/dist/blocks/api-keys/react/server.d.ts +7 -0
- package/dist/blocks/api-keys/react/server.js +7 -0
- package/dist/blocks/api-keys/solid/index.d.ts +13 -0
- package/dist/blocks/api-keys/solid/index.js +14 -0
- package/dist/blocks/api-keys/svelte/index.d.ts +13 -0
- package/dist/blocks/api-keys/svelte/index.js +14 -0
- package/dist/blocks/api-keys/vue/index.d.ts +13 -0
- package/dist/blocks/api-keys/vue/index.js +14 -0
- package/dist/blocks/approvals/index.d.ts +22 -0
- package/dist/blocks/approvals/index.js +189 -0
- package/dist/blocks/attachments/index.d.ts +18 -227
- package/dist/blocks/attachments/index.js +26 -242
- package/dist/blocks/audit/index.d.ts +72 -365
- package/dist/blocks/audit/index.js +91 -714
- package/dist/blocks/audit/react/index.d.ts +26 -0
- package/dist/blocks/audit/react/index.js +44 -0
- package/dist/blocks/audit/react/server.d.ts +7 -0
- package/dist/blocks/audit/react/server.js +7 -0
- package/dist/blocks/audit/solid/index.d.ts +13 -0
- package/dist/blocks/audit/solid/index.js +14 -0
- package/dist/blocks/audit/svelte/index.d.ts +13 -0
- package/dist/blocks/audit/svelte/index.js +14 -0
- package/dist/blocks/audit/vue/index.d.ts +13 -0
- package/dist/blocks/audit/vue/index.js +14 -0
- package/dist/blocks/billing/index.d.ts +37 -12
- package/dist/blocks/billing/index.js +43 -12
- package/dist/blocks/billing/react/index.d.ts +34 -0
- package/dist/blocks/billing/react/index.js +36 -0
- package/dist/blocks/billing/react/server.d.ts +7 -0
- package/dist/blocks/billing/react/server.js +7 -0
- package/dist/blocks/billing/solid/index.d.ts +15 -0
- package/dist/blocks/billing/solid/index.js +14 -0
- package/dist/blocks/billing/svelte/index.d.ts +15 -0
- package/dist/blocks/billing/svelte/index.js +14 -0
- package/dist/blocks/billing/vue/index.d.ts +15 -0
- package/dist/blocks/billing/vue/index.js +14 -0
- package/dist/blocks/bookings/index.d.ts +36 -0
- package/dist/blocks/bookings/index.js +28 -0
- package/dist/blocks/bookings/react/index.d.ts +31 -0
- package/dist/blocks/bookings/react/index.js +59 -0
- package/dist/blocks/bookings/react/server.d.ts +6 -0
- package/dist/blocks/bookings/react/server.js +7 -0
- package/dist/blocks/bookings/solid/index.d.ts +13 -0
- package/dist/blocks/bookings/solid/index.js +14 -0
- package/dist/blocks/bookings/svelte/index.d.ts +13 -0
- package/dist/blocks/bookings/svelte/index.js +14 -0
- package/dist/blocks/bookings/vue/index.d.ts +13 -0
- package/dist/blocks/bookings/vue/index.js +14 -0
- package/dist/blocks/bulk/index.d.ts +142 -0
- package/dist/blocks/bulk/index.js +154 -0
- package/dist/blocks/calendar/index.d.ts +20 -0
- package/dist/blocks/calendar/index.js +33 -0
- package/dist/blocks/calendar/react/index.d.ts +26 -0
- package/dist/blocks/calendar/react/index.js +58 -0
- package/dist/blocks/calendar/react/server.d.ts +6 -0
- package/dist/blocks/calendar/react/server.js +7 -0
- package/dist/blocks/calendar/solid/index.d.ts +13 -0
- package/dist/blocks/calendar/solid/index.js +14 -0
- package/dist/blocks/calendar/svelte/index.d.ts +13 -0
- package/dist/blocks/calendar/svelte/index.js +14 -0
- package/dist/blocks/calendar/vue/index.d.ts +13 -0
- package/dist/blocks/calendar/vue/index.js +14 -0
- package/dist/blocks/catalog/index.d.ts +224 -0
- package/dist/blocks/catalog/index.js +292 -0
- package/dist/blocks/collections/index.d.ts +184 -0
- package/dist/blocks/collections/index.js +208 -0
- package/dist/blocks/comments/index.d.ts +19 -166
- package/dist/blocks/comments/index.js +26 -207
- package/dist/blocks/comments/react/index.d.ts +27 -0
- package/dist/blocks/comments/react/index.js +45 -0
- package/dist/blocks/comments/react/server.d.ts +7 -0
- package/dist/blocks/comments/react/server.js +7 -0
- package/dist/blocks/comments/solid/index.d.ts +13 -0
- package/dist/blocks/comments/solid/index.js +14 -0
- package/dist/blocks/comments/svelte/index.d.ts +13 -0
- package/dist/blocks/comments/svelte/index.js +14 -0
- package/dist/blocks/comments/vue/index.d.ts +13 -0
- package/dist/blocks/comments/vue/index.js +14 -0
- package/dist/blocks/connectors/index.d.ts +21 -3
- package/dist/blocks/connectors/index.js +53 -18
- package/dist/blocks/custom-fields/index.d.ts +62 -0
- package/dist/blocks/custom-fields/index.js +85 -0
- package/dist/blocks/data-lifecycle/index.d.ts +114 -9
- package/dist/blocks/data-lifecycle/index.js +98 -12
- package/dist/blocks/deals/index.d.ts +262 -0
- package/dist/blocks/deals/index.js +380 -0
- package/dist/blocks/documents/index.d.ts +17 -0
- package/dist/blocks/documents/index.js +118 -0
- package/dist/blocks/domains/index.d.ts +117 -0
- package/dist/blocks/domains/index.js +118 -0
- package/dist/blocks/drive/index.d.ts +296 -0
- package/dist/blocks/drive/index.js +483 -0
- package/dist/blocks/employees/index.d.ts +205 -0
- package/dist/blocks/employees/index.js +289 -0
- package/dist/blocks/engagements/index.d.ts +125 -0
- package/dist/blocks/engagements/index.js +143 -0
- package/dist/blocks/entitlements/index.d.ts +40 -4
- package/dist/blocks/entitlements/index.js +14 -24
- package/dist/blocks/entitlements/react/index.d.ts +24 -0
- package/dist/blocks/entitlements/react/index.js +35 -0
- package/dist/blocks/entitlements/react/server.d.ts +6 -0
- package/dist/blocks/entitlements/react/server.js +7 -0
- package/dist/blocks/entitlements/solid/index.d.ts +13 -0
- package/dist/blocks/entitlements/solid/index.js +14 -0
- package/dist/blocks/entitlements/svelte/index.d.ts +13 -0
- package/dist/blocks/entitlements/svelte/index.js +14 -0
- package/dist/blocks/entitlements/vue/index.d.ts +13 -0
- package/dist/blocks/entitlements/vue/index.js +14 -0
- package/dist/blocks/flags/index.d.ts +61 -187
- package/dist/blocks/flags/index.js +65 -253
- package/dist/blocks/forms/index.d.ts +302 -0
- package/dist/blocks/forms/index.js +341 -0
- package/dist/blocks/forms/react/index.d.ts +16 -0
- package/dist/blocks/forms/react/index.js +25 -0
- package/dist/blocks/forms/react/server.d.ts +6 -0
- package/dist/blocks/forms/react/server.js +7 -0
- package/dist/blocks/forms/solid/index.d.ts +13 -0
- package/dist/blocks/forms/solid/index.js +14 -0
- package/dist/blocks/forms/svelte/index.d.ts +13 -0
- package/dist/blocks/forms/svelte/index.js +14 -0
- package/dist/blocks/forms/vue/index.d.ts +13 -0
- package/dist/blocks/forms/vue/index.js +14 -0
- package/dist/blocks/hiring/index.d.ts +154 -0
- package/dist/blocks/hiring/index.js +180 -0
- package/dist/blocks/identities/index.d.ts +83 -0
- package/dist/blocks/identities/index.js +103 -0
- package/dist/blocks/imports/index.d.ts +91 -0
- package/dist/blocks/imports/index.js +123 -0
- package/dist/blocks/inbox/index.d.ts +22 -3
- package/dist/blocks/inbox/index.js +77 -4
- package/dist/blocks/inbox/react/index.d.ts +3 -2
- package/dist/blocks/inbox/react/index.js +55 -199
- package/dist/blocks/inbox/react/server.d.ts +2 -1
- package/dist/blocks/inbox/solid/index.d.ts +27 -0
- package/dist/blocks/inbox/solid/index.js +32 -0
- package/dist/blocks/inbox/svelte/index.d.ts +27 -0
- package/dist/blocks/inbox/svelte/index.js +32 -0
- package/dist/blocks/inbox/vue/index.d.ts +27 -0
- package/dist/blocks/inbox/vue/index.js +32 -0
- package/dist/blocks/index.d.ts +9 -64
- package/dist/blocks/index.js +191 -55
- package/dist/blocks/inventory/index.d.ts +181 -0
- package/dist/blocks/inventory/index.js +219 -0
- package/dist/blocks/invoices/index.d.ts +154 -0
- package/dist/blocks/invoices/index.js +216 -0
- package/dist/blocks/jobs/index.d.ts +156 -33
- package/dist/blocks/jobs/index.js +675 -261
- package/dist/blocks/knowledge/index.d.ts +10 -3
- package/dist/blocks/knowledge/index.js +17 -3
- package/dist/blocks/leave/index.d.ts +241 -0
- package/dist/blocks/leave/index.js +209 -0
- package/dist/blocks/memory/index.d.ts +9 -4
- package/dist/blocks/memory/index.js +48 -12
- package/dist/blocks/metrics/index.d.ts +174 -0
- package/dist/blocks/metrics/index.js +217 -0
- package/dist/blocks/mfa/index.d.ts +7 -0
- package/dist/blocks/mfa/index.js +12 -0
- package/dist/blocks/mfa/react/index.d.ts +30 -0
- package/dist/blocks/mfa/react/index.js +45 -0
- package/dist/blocks/mfa/react/server.d.ts +9 -0
- package/dist/blocks/mfa/react/server.js +12 -0
- package/dist/blocks/mfa/solid/index.d.ts +20 -0
- package/dist/blocks/mfa/solid/index.js +23 -0
- package/dist/blocks/mfa/svelte/index.d.ts +20 -0
- package/dist/blocks/mfa/svelte/index.js +23 -0
- package/dist/blocks/mfa/vue/index.d.ts +20 -0
- package/dist/blocks/mfa/vue/index.js +23 -0
- package/dist/blocks/notifications/index.d.ts +342 -25
- package/dist/blocks/notifications/index.js +733 -104
- package/dist/blocks/notifications/react/index.d.ts +7 -32
- package/dist/blocks/notifications/react/index.js +23 -49
- package/dist/blocks/notifications/react/server.d.ts +2 -1
- package/dist/blocks/notifications/solid/index.d.ts +13 -0
- package/dist/blocks/notifications/solid/index.js +14 -0
- package/dist/blocks/notifications/svelte/index.d.ts +13 -0
- package/dist/blocks/notifications/svelte/index.js +14 -0
- package/dist/blocks/notifications/vue/index.d.ts +13 -0
- package/dist/blocks/notifications/vue/index.js +14 -0
- package/dist/blocks/number-sequences/index.d.ts +39 -0
- package/dist/blocks/number-sequences/index.js +38 -0
- package/dist/blocks/oauth-server/index.d.ts +228 -0
- package/dist/blocks/oauth-server/index.js +309 -0
- package/dist/blocks/onboarding/index.d.ts +8 -3
- package/dist/blocks/onboarding/index.js +17 -63
- package/dist/blocks/onboarding/react/index.d.ts +3 -12
- package/dist/blocks/onboarding/react/index.js +18 -60
- package/dist/blocks/onboarding/react/server.d.ts +2 -2
- package/dist/blocks/onboarding/react/server.js +2 -2
- package/dist/blocks/onboarding/solid/index.d.ts +13 -0
- package/dist/blocks/onboarding/solid/index.js +14 -0
- package/dist/blocks/onboarding/svelte/index.d.ts +13 -0
- package/dist/blocks/onboarding/svelte/index.js +14 -0
- package/dist/blocks/onboarding/vue/index.d.ts +13 -0
- package/dist/blocks/onboarding/vue/index.js +14 -0
- package/dist/blocks/orders/index.d.ts +264 -0
- package/dist/blocks/orders/index.js +589 -0
- package/dist/blocks/organizations/index.d.ts +31 -238
- package/dist/blocks/organizations/index.js +49 -392
- package/dist/blocks/organizations/react/index.d.ts +108 -0
- package/dist/blocks/organizations/react/index.js +113 -0
- package/dist/blocks/organizations/react/server.d.ts +11 -0
- package/dist/blocks/organizations/react/server.js +15 -0
- package/dist/blocks/organizations/solid/index.d.ts +34 -0
- package/dist/blocks/organizations/solid/index.js +32 -0
- package/dist/blocks/organizations/svelte/index.d.ts +34 -0
- package/dist/blocks/organizations/svelte/index.js +32 -0
- package/dist/blocks/organizations/vue/index.d.ts +34 -0
- package/dist/blocks/organizations/vue/index.js +32 -0
- package/dist/blocks/outbox/index.d.ts +12 -3
- package/dist/blocks/outbox/index.js +85 -46
- package/dist/blocks/parties/index.d.ts +181 -0
- package/dist/blocks/parties/index.js +258 -0
- package/dist/blocks/pipelines/index.d.ts +189 -0
- package/dist/blocks/pipelines/index.js +204 -0
- package/dist/blocks/profiles/index.d.ts +13 -8
- package/dist/blocks/profiles/index.js +19 -6
- package/dist/blocks/promotions/index.d.ts +125 -0
- package/dist/blocks/promotions/index.js +123 -0
- package/dist/blocks/push/index.d.ts +48 -28
- package/dist/blocks/push/index.js +45 -16
- package/dist/blocks/quotes/index.d.ts +152 -0
- package/dist/blocks/quotes/index.js +209 -0
- package/dist/blocks/rank/index.d.ts +71 -0
- package/dist/blocks/rank/index.js +53 -0
- package/dist/blocks/reactions/index.d.ts +18 -0
- package/dist/blocks/reactions/index.js +26 -0
- package/dist/blocks/reactions/react/index.d.ts +23 -0
- package/dist/blocks/reactions/react/index.js +46 -0
- package/dist/blocks/reactions/react/server.d.ts +6 -0
- package/dist/blocks/reactions/react/server.js +7 -0
- package/dist/blocks/reactions/solid/index.d.ts +13 -0
- package/dist/blocks/reactions/solid/index.js +14 -0
- package/dist/blocks/reactions/svelte/index.d.ts +13 -0
- package/dist/blocks/reactions/svelte/index.js +14 -0
- package/dist/blocks/reactions/vue/index.d.ts +13 -0
- package/dist/blocks/reactions/vue/index.js +14 -0
- package/dist/blocks/relations/index.d.ts +180 -0
- package/dist/blocks/relations/index.js +254 -0
- package/dist/blocks/reminders/index.d.ts +177 -0
- package/dist/blocks/reminders/index.js +171 -0
- package/dist/blocks/reviews/index.d.ts +148 -0
- package/dist/blocks/reviews/index.js +139 -0
- package/dist/blocks/rules/index.d.ts +103 -0
- package/dist/blocks/rules/index.js +54 -0
- package/dist/blocks/rules/react/index.d.ts +34 -0
- package/dist/blocks/rules/react/index.js +55 -0
- package/dist/blocks/rules/react/server.d.ts +6 -0
- package/dist/blocks/rules/react/server.js +7 -0
- package/dist/blocks/rules/solid/index.d.ts +13 -0
- package/dist/blocks/rules/solid/index.js +14 -0
- package/dist/blocks/rules/svelte/index.d.ts +13 -0
- package/dist/blocks/rules/svelte/index.js +14 -0
- package/dist/blocks/rules/vue/index.d.ts +13 -0
- package/dist/blocks/rules/vue/index.js +14 -0
- package/dist/blocks/saved-views/index.d.ts +17 -0
- package/dist/blocks/saved-views/index.js +29 -0
- package/dist/blocks/saved-views/react/index.d.ts +34 -0
- package/dist/blocks/saved-views/react/index.js +57 -0
- package/dist/blocks/saved-views/react/server.d.ts +7 -0
- package/dist/blocks/saved-views/react/server.js +7 -0
- package/dist/blocks/saved-views/solid/index.d.ts +13 -0
- package/dist/blocks/saved-views/solid/index.js +14 -0
- package/dist/blocks/saved-views/svelte/index.d.ts +13 -0
- package/dist/blocks/saved-views/svelte/index.js +14 -0
- package/dist/blocks/saved-views/vue/index.d.ts +13 -0
- package/dist/blocks/saved-views/vue/index.js +14 -0
- package/dist/blocks/search/index.d.ts +31 -0
- package/dist/blocks/search/index.js +61 -0
- package/dist/blocks/search/react/index.d.ts +36 -0
- package/dist/blocks/search/react/index.js +49 -0
- package/dist/blocks/search/react/server.d.ts +7 -0
- package/dist/blocks/search/react/server.js +8 -0
- package/dist/blocks/search/solid/index.d.ts +13 -0
- package/dist/blocks/search/solid/index.js +14 -0
- package/dist/blocks/search/svelte/index.d.ts +13 -0
- package/dist/blocks/search/svelte/index.js +14 -0
- package/dist/blocks/search/vue/index.d.ts +13 -0
- package/dist/blocks/search/vue/index.js +14 -0
- package/dist/blocks/service-principals/index.d.ts +126 -0
- package/dist/blocks/service-principals/index.js +107 -0
- package/dist/blocks/sessions/index.d.ts +7 -0
- package/dist/blocks/sessions/index.js +14 -0
- package/dist/blocks/sessions/react/index.d.ts +31 -0
- package/dist/blocks/sessions/react/index.js +51 -0
- package/dist/blocks/sessions/react/server.d.ts +9 -0
- package/dist/blocks/sessions/react/server.js +11 -0
- package/dist/blocks/sessions/solid/index.d.ts +20 -0
- package/dist/blocks/sessions/solid/index.js +23 -0
- package/dist/blocks/sessions/svelte/index.d.ts +20 -0
- package/dist/blocks/sessions/svelte/index.js +23 -0
- package/dist/blocks/sessions/vue/index.d.ts +20 -0
- package/dist/blocks/sessions/vue/index.js +23 -0
- package/dist/blocks/settings/index.d.ts +20 -86
- package/dist/blocks/settings/index.js +28 -102
- package/dist/blocks/settings/react/index.d.ts +25 -0
- package/dist/blocks/settings/react/index.js +37 -0
- package/dist/blocks/settings/react/server.d.ts +7 -0
- package/dist/blocks/settings/react/server.js +7 -0
- package/dist/blocks/settings/solid/index.d.ts +13 -0
- package/dist/blocks/settings/solid/index.js +14 -0
- package/dist/blocks/settings/svelte/index.d.ts +13 -0
- package/dist/blocks/settings/svelte/index.js +14 -0
- package/dist/blocks/settings/vue/index.d.ts +13 -0
- package/dist/blocks/settings/vue/index.js +14 -0
- package/dist/blocks/share-links/index.d.ts +99 -0
- package/dist/blocks/share-links/index.js +99 -0
- package/dist/blocks/shared-signals/index.d.ts +395 -0
- package/dist/blocks/shared-signals/index.js +880 -0
- package/dist/blocks/signing-keys/index.d.ts +29 -0
- package/dist/blocks/signing-keys/index.js +161 -0
- package/dist/blocks/sla/index.d.ts +114 -0
- package/dist/blocks/sla/index.js +122 -0
- package/dist/blocks/sso/index.d.ts +169 -147
- package/dist/blocks/sso/index.js +258 -321
- package/dist/blocks/state-machine/index.d.ts +105 -0
- package/dist/blocks/state-machine/index.js +116 -0
- package/dist/blocks/tags/index.d.ts +20 -0
- package/dist/blocks/tags/index.js +28 -0
- package/dist/blocks/tags/react/index.d.ts +28 -0
- package/dist/blocks/tags/react/index.js +47 -0
- package/dist/blocks/tags/react/server.d.ts +7 -0
- package/dist/blocks/tags/react/server.js +7 -0
- package/dist/blocks/tags/solid/index.d.ts +13 -0
- package/dist/blocks/tags/solid/index.js +14 -0
- package/dist/blocks/tags/svelte/index.d.ts +13 -0
- package/dist/blocks/tags/svelte/index.js +14 -0
- package/dist/blocks/tags/vue/index.d.ts +13 -0
- package/dist/blocks/tags/vue/index.js +14 -0
- package/dist/blocks/targets/index.d.ts +82 -0
- package/dist/blocks/targets/index.js +73 -0
- package/dist/blocks/teams/index.d.ts +61 -0
- package/dist/blocks/teams/index.js +83 -0
- package/dist/blocks/templates/index.d.ts +362 -0
- package/dist/blocks/templates/index.js +483 -0
- package/dist/blocks/time-tracking/index.d.ts +250 -0
- package/dist/blocks/time-tracking/index.js +317 -0
- package/dist/blocks/trash/index.d.ts +78 -0
- package/dist/blocks/trash/index.js +79 -0
- package/dist/blocks/undo/index.d.ts +9 -0
- package/dist/blocks/undo/index.js +14 -0
- package/dist/blocks/undo/react/index.d.ts +17 -0
- package/dist/blocks/undo/react/index.js +24 -0
- package/dist/blocks/undo/react/server.d.ts +7 -0
- package/dist/blocks/undo/react/server.js +7 -0
- package/dist/blocks/undo/solid/index.d.ts +13 -0
- package/dist/blocks/undo/solid/index.js +14 -0
- package/dist/blocks/undo/svelte/index.d.ts +13 -0
- package/dist/blocks/undo/svelte/index.js +14 -0
- package/dist/blocks/undo/vue/index.d.ts +13 -0
- package/dist/blocks/undo/vue/index.js +14 -0
- package/dist/blocks/usage/index.d.ts +10 -4
- package/dist/blocks/usage/index.js +16 -233
- package/dist/blocks/usage/react/index.d.ts +23 -0
- package/dist/blocks/usage/react/index.js +38 -0
- package/dist/blocks/usage/react/server.d.ts +7 -0
- package/dist/blocks/usage/react/server.js +7 -0
- package/dist/blocks/usage/solid/index.d.ts +13 -0
- package/dist/blocks/usage/solid/index.js +14 -0
- package/dist/blocks/usage/svelte/index.d.ts +13 -0
- package/dist/blocks/usage/svelte/index.js +14 -0
- package/dist/blocks/usage/vue/index.d.ts +13 -0
- package/dist/blocks/usage/vue/index.js +14 -0
- package/dist/blocks/versions/index.d.ts +165 -0
- package/dist/blocks/versions/index.js +283 -0
- package/dist/blocks/waitlist/index.d.ts +57 -19
- package/dist/blocks/waitlist/index.js +46 -12
- package/dist/blocks/webhooks/index.d.ts +269 -137
- package/dist/blocks/webhooks/index.js +752 -179
- package/dist/blocks/work-items/index.d.ts +208 -0
- package/dist/blocks/work-items/index.js +305 -0
- package/dist/blocks/workflow-builder/index.d.ts +26 -6
- package/dist/blocks/workflow-builder/index.js +30 -5
- package/dist/blocks/workflow-builder/react/index.d.ts +4 -19
- package/dist/blocks/workflow-builder/react/index.js +45 -89
- package/dist/blocks/workflow-builder/solid/index.d.ts +20 -0
- package/dist/blocks/workflow-builder/solid/index.js +23 -0
- package/dist/blocks/workflow-builder/svelte/index.d.ts +20 -0
- package/dist/blocks/workflow-builder/svelte/index.js +23 -0
- package/dist/blocks/workflow-builder/vue/index.d.ts +20 -0
- package/dist/blocks/workflow-builder/vue/index.js +23 -0
- package/dist/blocks/workflows/index.d.ts +20 -3
- package/dist/blocks/workflows/index.js +27 -3
- package/dist/blocks/workflows/react/index.d.ts +5 -20
- package/dist/blocks/workflows/react/index.js +41 -113
- package/dist/blocks/workflows/solid/index.d.ts +20 -0
- package/dist/blocks/workflows/solid/index.js +23 -0
- package/dist/blocks/workflows/svelte/index.d.ts +20 -0
- package/dist/blocks/workflows/svelte/index.js +23 -0
- package/dist/blocks/workflows/vue/index.d.ts +20 -0
- package/dist/blocks/workflows/vue/index.js +23 -0
- package/dist/blocks-D2WQ63PI.js +84 -0
- package/dist/bookings-0XnhHNLM.js +181 -0
- package/dist/build-Cm0T5MVG.js +1815 -0
- package/dist/build-Cx08LOT7.js +689 -0
- package/dist/calendar-DcFrpSOy.js +801 -0
- package/dist/channel-D-LHCY11.d.ts +78 -0
- package/dist/channels-C-GCoNLd.js +114 -0
- package/dist/chat-sdk/index.d.ts +131 -7
- package/dist/chat-sdk/index.js +154 -6
- package/dist/chat-sdk/react/index.d.ts +2 -1
- package/dist/chat-sdk/react/index.js +1 -2
- package/dist/chat-sdk/react/server.d.ts +2 -1
- package/dist/chat-sdk/react/server.js +1 -2
- package/dist/cli/add-B0zoJUYX.js +651 -0
- package/dist/cli/add-Bd5M6uEZ.js +75 -0
- package/dist/cli/add-CT6p5psO.js +1 -0
- package/dist/cli/adopt-C9jl8z0h.js +1 -0
- package/dist/cli/adopt-CebmlHL-.js +393 -0
- package/dist/cli/agent-CCt_aXpJ.js +176 -0
- package/dist/cli/apply-Dxk8AUNU.js +302 -0
- package/dist/cli/bin.js +40 -20
- package/dist/cli/blocks-Bzz326nn.js +1 -0
- package/dist/cli/blocks-Dq25D80b.js +124 -0
- package/dist/cli/cache-Cx6qlRDR.js +502 -0
- package/dist/cli/catalog-Di15TkM2.js +187 -0
- package/dist/cli/codemod-woL4cVe0.js +985 -0
- package/dist/cli/command-CK9Cn8HV.js +836 -0
- package/dist/cli/completion-BO5UK6bG.js +1 -0
- package/dist/cli/completion-CWoDmFZr.js +205 -0
- package/dist/cli/completion-J3dqnNoW.js +30 -0
- package/dist/cli/config-C1d-mbUA.js +1 -0
- package/dist/cli/config-CLHpTk9Y.js +1421 -0
- package/dist/cli/config-MuKY9kUj.js +39 -0
- package/dist/cli/config-edit-Ct1lXe8a.js +603 -0
- package/dist/cli/db-DT1bGB02.js +78 -0
- package/dist/cli/design-edit-B-WsnhUF.js +175 -0
- package/dist/cli/detect-DYDUBLU4.js +84 -0
- package/dist/cli/dist-D_VbPAMR.js +129 -0
- package/dist/cli/dist-DuiMMRbJ.js +1112 -0
- package/dist/cli/doctor-SGw36h1D.js +3806 -0
- package/dist/cli/emit-ZAYeh4Yn.js +1042 -0
- package/dist/cli/env-CVXvJoLP.js +142 -0
- package/dist/cli/env-D3gZ-OHj.js +1 -0
- package/dist/cli/env-DJ4ZaJpe.js +1 -0
- package/dist/cli/env-DMw_MBe5.js +996 -0
- package/dist/cli/errors-Caiz_0so.js +98 -0
- package/dist/cli/fastest-levenshtein-dhRRvrM2.js +120 -0
- package/dist/cli/files-DDvAwTko.js +963 -0
- package/dist/cli/gen-B344S1Oy.js +304 -0
- package/dist/cli/guards-CUlDNYZs.js +8 -0
- package/dist/cli/index.d.ts +258 -129
- package/dist/cli/index.js +13 -10
- package/dist/cli/init-B1dL-RZL.js +393 -0
- package/dist/cli/introspect-BPcFi8ow.js +51 -0
- package/dist/cli/io-BiPX-_J1.js +37 -0
- package/dist/cli/jsonc-C8M0EhHB.js +1 -0
- package/dist/cli/keys-jsxCNJdz.js +106 -0
- package/dist/cli/metadata-CxBZo4KM.js +138 -0
- package/dist/cli/module-migration-YFFdWC5X.js +592 -0
- package/dist/cli/moved-D9OT_Uyy.js +152 -0
- package/dist/cli/parse-DE59XyWt.js +170 -0
- package/dist/cli/preset-DmcC4nWE.js +129 -0
- package/dist/cli/prompts-Bvg_DBpe.js +1137 -0
- package/dist/cli/redact-PjNswZOs.js +35 -0
- package/dist/cli/rolldown-runtime-kJ_i4yPo.js +26 -0
- package/dist/cli/run-CM_2g6KQ.js +684 -0
- package/dist/cli/run-DaHfUUO9.js +1539 -0
- package/dist/cli/scaffold-CFdDq36-.js +898 -0
- package/dist/cli/scaffold-Dsjr7sOT.js +1 -0
- package/dist/cli/seed-DldDdXWO.js +113 -0
- package/dist/cli/seed-DtRFysBe.js +1 -0
- package/dist/cli/shell-Btv_4Yhe.js +10 -0
- package/dist/cli/skills-v34NDb2D.js +121 -0
- package/dist/cli/snapshot-DAxLi4hO.js +347 -0
- package/dist/cli/source-args-0I9zylYy.js +47 -0
- package/dist/cli/spec-CaaSOYZS.js +7501 -0
- package/dist/cli/spec-De0cfceR.js +1 -0
- package/dist/cli/sql-0I1EQ3JG.js +2 -0
- package/dist/cli/sql-DDLWwzh9.js +326 -0
- package/dist/cli/stack-status-CRanl-CN.js +456 -0
- package/dist/cli/style-2JfIPVMQ.js +29 -0
- package/dist/cli/supabase-toml-DjLgBYEo.js +1153 -0
- package/dist/cli/sync-DFx-6t5n.js +88 -0
- package/dist/cli/topics-GJuIDwlY.js +30 -0
- package/dist/cli/trace-VbdO6bcN.js +7 -0
- package/dist/cli/typegen--TfvOPLh.js +494 -0
- package/dist/cli/typegen-version-BNBPI4Eu.js +9 -0
- package/dist/cli/validate-BfWNKvxy.js +7522 -0
- package/dist/cli/version-DNRW4DAP.js +4 -0
- package/dist/cli/write-BS6F3Yvz.js +180 -0
- package/dist/client/index.d.ts +7 -4
- package/dist/client/index.js +152 -6
- package/dist/client/native/index.d.ts +71 -4
- package/dist/client/native/index.js +65 -6
- package/dist/client-01MAuqzW.js +137 -0
- package/dist/client-B7QVIrJS.d.ts +263 -0
- package/dist/client-B9CqzgPO.js +75 -0
- package/dist/client-BAcILZOf.d.ts +122 -0
- package/dist/client-BOT2KVIp.js +128 -0
- package/dist/client-BUGIS__1.js +1051 -0
- package/dist/client-BVZtq_rQ.d.ts +34 -0
- package/dist/client-BYaOGCOG.js +84 -0
- package/dist/client-BfgwocyT.js +155 -0
- package/dist/client-BpS8a6Fm.js +51 -0
- package/dist/client-BxnLb9bz.d.ts +58 -0
- package/dist/client-BzCv4kKE.js +65 -0
- package/dist/client-C-2ajnhM.js +89 -0
- package/dist/client-C5mUEb2n.js +55 -0
- package/dist/client-C9QrWrnt.js +209 -0
- package/dist/client-CFdMJ1h9.d.ts +277 -0
- package/dist/client-CHmjaI0j.d.ts +103 -0
- package/dist/client-CWs3X4g1.js +120 -0
- package/dist/client-Cdedx_lw.js +438 -0
- package/dist/client-CiGjS6us.js +169 -0
- package/dist/client-CilSKXNu.js +288 -0
- package/dist/client-ClZ0D-if.d.ts +162 -0
- package/dist/client-Cmensu7l.d.ts +419 -0
- package/dist/client-Co6BPmvr.d.ts +108 -0
- package/dist/client-CoVGPi0c.js +133 -0
- package/dist/client-CvITjt-9.js +310 -0
- package/dist/client-CzI4QpG3.d.ts +131 -0
- package/dist/client-D5CZ2a34.d.ts +455 -0
- package/dist/client-DAHsEOVV.d.ts +155 -0
- package/dist/client-DG7FwgZD.js +104 -0
- package/dist/client-DIrCmOXz.d.ts +157 -0
- package/dist/client-DNq8xYb-.js +52 -0
- package/dist/client-DdfSIZ3M.d.ts +107 -0
- package/dist/client-DjTRlN3a.d.ts +68 -0
- package/dist/client-Ezlbskjq.js +60 -0
- package/dist/client-LLmgiCwJ.d.ts +135 -0
- package/dist/client-Lx3mlvKO.d.ts +100 -0
- package/dist/client-PGRk3DGz.js +98 -0
- package/dist/client-Tc47rlSe.d.ts +350 -0
- package/dist/client-VvY0K3wr.d.ts +59 -0
- package/dist/client-WAdEBBrH.d.ts +65 -0
- package/dist/client-WHeWTNVL.d.ts +130 -0
- package/dist/client-WgrPIHH-.js +213 -0
- package/dist/client-ZTtSV3l8.js +40 -0
- package/dist/client-b2_GjzzD.js +438 -0
- package/dist/client-ckXfdy1k.js +38 -0
- package/dist/client-e-hFkLUf.d.ts +34 -0
- package/dist/client-hyP_nS5R.d.ts +218 -0
- package/dist/client-i8glKhxh.js +67 -0
- package/dist/client-iDLLvGR4.d.ts +52 -0
- package/dist/client-pxjbgOz6.js +376 -0
- package/dist/client-xkSCp9hN.d.ts +101 -0
- package/dist/client-ysNShuJZ.d.ts +39 -0
- package/dist/cloud-event-5rCEzIgK.d.ts +64 -0
- package/dist/codecs-Bm46sVxr.d.ts +215 -0
- package/dist/codecs-YAekuQ08.js +358 -0
- package/dist/codegen-DeoSt1mi.d.ts +1571 -0
- package/dist/comments-D7sbd0xG.js +230 -0
- package/dist/commerce/index.d.ts +211 -0
- package/dist/commerce/index.js +517 -0
- package/dist/compiler-BVxepAY0.d.ts +14 -0
- package/dist/compose-Djntdacs.js +53 -0
- package/dist/composite-M6zPs1Xm.js +130 -0
- package/dist/composite-djtGy6bz.d.ts +67 -0
- package/dist/config/index.d.ts +7 -5
- package/dist/config/index.js +7 -4
- package/dist/config-BA1K4OPk.js +557 -0
- package/dist/config-C4MozTD7.js +3892 -0
- package/dist/connector-Cp8CqssJ.d.ts +107 -0
- package/dist/connectors-CjD-R2ks.d.ts +141 -0
- package/dist/context-1sSWcd7_.d.ts +365 -0
- package/dist/context-B8_p3MXo.js +11 -0
- package/dist/context-BDDgjoxz.js +41 -0
- package/dist/context-C-bHOtoD.js +5 -0
- package/dist/context-D6ZMKc2L.js +19 -0
- package/dist/context-wDKxSl9x.js +9 -0
- package/dist/controller-D6NyNrlZ.js +79 -0
- package/dist/controller-D845qKh2.d.ts +46 -0
- package/dist/credentials/index.d.ts +15 -5
- package/dist/credentials/index.js +16 -8
- package/dist/cron-CUaKTjWC.js +145 -0
- package/dist/cron-route-CoL-By8Z.js +24 -0
- package/dist/csv-CVfXqaAu.js +89 -0
- package/dist/csv-D3eKxV1T.d.ts +28 -0
- package/dist/define-B2ln373W.js +1896 -0
- package/dist/define-DxHHbNEb.d.ts +1005 -0
- package/dist/define-block-B6Gjs57G.js +52 -0
- package/dist/define-block-CLZMEFeK.d.ts +256 -0
- package/dist/delegation-Dt8hMJWu.js +140 -0
- package/dist/delivery-B1mKaOen.js +30 -0
- package/dist/delivery-BeHgwS3k.d.ts +69 -0
- package/dist/documents-CWn2mtYf.d.ts +96 -0
- package/dist/edge/index.d.ts +16 -14
- package/dist/edge/index.js +24 -18
- package/dist/elysia/index.d.ts +1 -1
- package/dist/elysia/index.js +2 -2
- package/dist/email-BcmbIWOX.d.ts +42 -0
- package/dist/embeddings-C2S14foD.d.ts +78 -0
- package/dist/embeddings-CVeOoufZ.js +128 -0
- package/dist/ended-nIdGCHxk.d.ts +70 -0
- package/dist/entitlements-BvU5dLpW.js +45 -0
- package/dist/entitlements-CbzCKalR.d.ts +53 -0
- package/dist/env/index.d.ts +1 -1
- package/dist/env/index.js +3 -3
- package/dist/envelope-CHYk5MXU.js +90 -0
- package/dist/errors-8i6tSICm.js +301 -0
- package/dist/errors-PIpcoe4z.js +44 -0
- package/dist/escape-like-AUEPn-C5.d.ts +15 -0
- package/dist/eve/index.d.ts +5 -5
- package/dist/eve/index.js +9 -9
- package/dist/eve-DUF70Vgi.d.ts +151 -0
- package/dist/event-calendar-DJXXMJ3x.d.ts +217 -0
- package/dist/event-map-Dy2vKHai.js +19 -0
- package/dist/events/index.d.ts +4 -4
- package/dist/events/index.js +10 -7
- package/dist/events-CTreRrgP.d.ts +800 -0
- package/dist/events-xbpHkcK9.js +82 -0
- package/dist/executor-BBHgjUJu.d.ts +39 -0
- package/dist/executor-BINiCFNp.d.ts +95 -0
- package/dist/executor-BXSHYyW2.js +735 -0
- package/dist/executor-D1kYY5BA.js +206 -0
- package/dist/executor-D3K3tycZ.js +147 -0
- package/dist/executor-DKrZr3AC.d.ts +174 -0
- package/dist/executor-DoE-5iM8.d.ts +272 -0
- package/dist/expo/index.d.ts +4 -4
- package/dist/expo/index.js +6 -6
- package/dist/expo-sqlite/index.d.ts +3 -3
- package/dist/expo-sqlite/index.js +2 -2
- package/dist/field-errors-CWXN_h_E.d.ts +113 -0
- package/dist/field-formats-Cif5gr7c.js +102 -0
- package/dist/field-formats-DCvOQlOy.d.ts +39 -0
- package/dist/filter-evaluate-C0ULNfS7.js +214 -0
- package/dist/filter-evaluate-CjcFq0RT.d.ts +34 -0
- package/dist/filter-query-B3lhckKX.js +518 -0
- package/dist/filter-query-BKmRYrTE.d.ts +121 -0
- package/dist/flags-D_D1jAHB.d.ts +230 -0
- package/dist/flags-XAOsdNAc.js +279 -0
- package/dist/flags-sdk/index.d.ts +143 -0
- package/dist/flags-sdk/index.js +175 -0
- package/dist/form-definition-sagoefQr.d.ts +95 -0
- package/dist/format-CNwSjXUU.d.ts +70 -0
- package/dist/framework-DG5Bv39u.d.ts +26 -0
- package/dist/framework-INbjMZX1.js +51 -0
- package/dist/function-contract-B4c0XuTU.d.ts +21 -0
- package/dist/function-contract-DmkBg19r.js +5 -0
- package/dist/gateway-Cup00l0w.d.ts +109 -0
- package/dist/gateway-options-ClvvfuuU.d.ts +29 -0
- package/dist/gateway-options-DcLcWL5_.js +27 -0
- package/dist/graph-Cs28bOlK.js +360 -0
- package/dist/groups-CxonH3u5.js +16 -0
- package/dist/h3/index.d.ts +7 -3
- package/dist/h3/index.js +3 -3
- package/dist/h3/v1/index.d.ts +1 -1
- package/dist/h3/v1/index.js +2 -2
- package/dist/hono/index.d.ts +24 -10
- package/dist/hono/index.js +50 -22
- package/dist/http-BF-V0CUF.js +25 -0
- package/dist/impersonation-CDeCzZP6.js +62 -0
- package/dist/impersonation-D5chOkrO.d.ts +25 -0
- package/dist/inbox-3ql4phyr.d.ts +400 -0
- package/dist/index-BIi8i6BO.d.ts +97 -0
- package/dist/index-BkzNUPId.d.ts +131 -0
- package/dist/index-BlqrGvb3.d.ts +227 -0
- package/dist/index-Brhm22eL.d.ts +138 -0
- package/dist/index-Bvw9r94Q.d.ts +245 -0
- package/dist/index-CETRk4YO.d.ts +82 -0
- package/dist/index-CrZitvSC.d.ts +258 -0
- package/dist/index-DfVXx4Ia.d.ts +202 -0
- package/dist/index-DygICr8c.d.ts +206 -0
- package/dist/index-Ej8XQ9w9.d.ts +58 -0
- package/dist/index-jhqhynmh.d.ts +58 -0
- package/dist/index.d.ts +15 -15
- package/dist/index.js +15 -14
- package/dist/iso4217-A8ev-CNW.js +176 -0
- package/dist/json-schema-CpYINXG8.js +548 -0
- package/dist/jws-35a8VhFV.d.ts +60 -0
- package/dist/jws-milwSg7E.js +191 -0
- package/dist/kit-D5S7KNFe.d.ts +66 -0
- package/dist/kit-MUK34-ZG.js +104 -0
- package/dist/knowledge-BXQ2-V0a.d.ts +187 -0
- package/dist/knowledge-CJ_Mm3ic.js +272 -0
- package/dist/lint/index.js +9 -9
- package/dist/list/index.d.ts +4 -2
- package/dist/list/index.js +4 -2
- package/dist/list-query-CBXNfJt4.js +549 -0
- package/dist/list-query-PvP3pPGQ.d.ts +194 -0
- package/dist/live-DwDreq1n.js +239 -0
- package/dist/mcp/index.d.ts +24 -7
- package/dist/mcp/index.js +76 -55
- package/dist/mcp/sdk/index.d.ts +6 -6
- package/dist/mcp/sdk/index.js +7 -7
- package/dist/messages-Cx4ttLJJ.js +493 -0
- package/dist/metric-definition-95HFoW1V.d.ts +70 -0
- package/dist/metric-definition-BjxVn4R5.js +117 -0
- package/dist/mfa-DtflCXEt.d.ts +30 -0
- package/dist/model-lUafmeBQ.d.ts +311 -0
- package/dist/model-messages-CTu-6kBu.js +206 -0
- package/dist/money-95Rff2vT.js +185 -0
- package/dist/money-DFpWzQvk.d.ts +76 -0
- package/dist/money-codec-C_-K5ZUU.js +41 -0
- package/dist/nestjs/index.d.ts +2 -2
- package/dist/nestjs/index.js +2 -2
- package/dist/next/index.d.ts +26 -16
- package/dist/next/index.js +17 -16
- package/dist/node/index.d.ts +2 -2
- package/dist/node/index.js +3 -3
- package/dist/nuxt/index.d.ts +2 -2
- package/dist/nuxt/index.js +3 -2
- package/dist/onboarding-BZvLLPaY.js +68 -0
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +2 -2
- package/dist/operations-C9-UcReN.d.ts +6 -0
- package/dist/operations-CsIfMED7.js +29 -0
- package/dist/optional-peer-Dx8TaYXw.js +29 -0
- package/dist/options-B0FcEKtH.js +5 -0
- package/dist/options-B5BU2Yg7.js +11 -0
- package/dist/options-B5woz53h.js +5 -0
- package/dist/options-B7EJaOX-.js +5 -0
- package/dist/options-B9q8CROT.js +9 -0
- package/dist/options-BA6jV6py.js +10 -0
- package/dist/options-BAW2ZlZ8.js +8 -0
- package/dist/options-BMjcVuaI.js +82 -0
- package/dist/options-BOOEzor9.js +5 -0
- package/dist/options-BQ07tTYr.js +11 -0
- package/dist/options-BZsoPJGX.js +5 -0
- package/dist/options-BesyRBnM.js +16 -0
- package/dist/options-Bf96aVk5.js +10 -0
- package/dist/options-BlOPivzq.js +8 -0
- package/dist/options-Blg8lAkz.js +9 -0
- package/dist/options-Bn4cu0gt.js +5 -0
- package/dist/options-BnoJOwXK.js +8 -0
- package/dist/options-Bp7sgKvl.js +30 -0
- package/dist/options-BqO9GYig.js +311 -0
- package/dist/options-BxcFLL1G.js +8 -0
- package/dist/options-C1HR4UZd.js +13 -0
- package/dist/options-C239TLRF.js +14 -0
- package/dist/options-C2HesT4A.js +9 -0
- package/dist/options-C3EzJATk.js +10 -0
- package/dist/options-C3hZ1fbC.js +10 -0
- package/dist/options-C6rEL6U5.js +8 -0
- package/dist/options-CAzVf-1T.js +8 -0
- package/dist/options-CGKSUFUq.js +5 -0
- package/dist/options-CGjGE8xS.js +12 -0
- package/dist/options-CK5WiFwG.js +11 -0
- package/dist/options-CN7PHkhU.js +8 -0
- package/dist/options-CTZNpb2n.js +5 -0
- package/dist/options-CUqQ9pm7.js +9 -0
- package/dist/options-CVSWgZpY.js +8 -0
- package/dist/options-CXFz0xxS.js +8 -0
- package/dist/options-CZk_fbbg.js +9 -0
- package/dist/options-C_pb1Nd3.js +5 -0
- package/dist/options-CbSOj53r.d.ts +52 -0
- package/dist/options-ChD15QxJ.js +9 -0
- package/dist/options-CkScQRJR.js +12 -0
- package/dist/options-CljDnQt2.js +25 -0
- package/dist/options-CmraJhd9.js +5 -0
- package/dist/options-CoX6VKGb.js +8 -0
- package/dist/options-CrC78Tee.js +5 -0
- package/dist/options-Ctac2R0Q.js +11 -0
- package/dist/options-CyD7OObW.js +17 -0
- package/dist/options-D1blDihM.js +9 -0
- package/dist/options-D2Gd6VfS.js +8 -0
- package/dist/options-D2d1fFuw.js +11 -0
- package/dist/options-D40CQzji.js +8 -0
- package/dist/options-D7f2VKzj.js +25 -0
- package/dist/options-DARkpk2P.js +8 -0
- package/dist/options-DCduTMST.js +17 -0
- package/dist/options-DDzggjN7.js +8 -0
- package/dist/options-DMR68VAC.js +5 -0
- package/dist/options-DNKyN5uq.js +20 -0
- package/dist/options-DTDxFT6J.js +13 -0
- package/dist/options-DWpz3V3F.js +8 -0
- package/dist/options-DXqcJyOP.js +10 -0
- package/dist/options-DYBQ973I.js +8 -0
- package/dist/options-Dh4wu9Ks.js +15 -0
- package/dist/options-Dmg1Da9P.js +25 -0
- package/dist/options-DsUgamiy.js +5 -0
- package/dist/options-EsUh744F.js +13 -0
- package/dist/options-Fz6xqu-b.js +5 -0
- package/dist/options-HSv8R6h5.js +13 -0
- package/dist/options-I8zVkB_y.js +8 -0
- package/dist/options-MHhjOoWz.js +5 -0
- package/dist/options-MLxMkd5B.js +11 -0
- package/dist/options-OJ5zmgr8.js +10 -0
- package/dist/options-QKP9FFcK.js +8 -0
- package/dist/options-SViK_2x2.js +12 -0
- package/dist/options-XgfTctbI.js +5 -0
- package/dist/options-_9arpsX9.js +11 -0
- package/dist/options-eBPsk4WI.js +11 -0
- package/dist/options-kLKILdIu.js +10 -0
- package/dist/options-n22NK3PL.js +8 -0
- package/dist/options-oeAq4Ho5.js +12 -0
- package/dist/options-qU8vgxhO.js +18 -0
- package/dist/options-vXMBEWSH.js +5 -0
- package/dist/options-wFnK3qWl.js +9 -0
- package/dist/options-xVAy3UsV.js +9 -0
- package/dist/organizations-Cw7om8uk.js +398 -0
- package/dist/orpc/index.d.ts +26 -10
- package/dist/orpc/index.js +54 -33
- package/dist/otel/index.d.ts +3 -3
- package/dist/otel/index.js +42 -7
- package/dist/outbox-BTeqjbum.d.ts +181 -0
- package/dist/overlay/index.d.ts +1 -1
- package/dist/overlay/index.js +3 -3
- package/dist/payments-h45bswA-.js +16 -0
- package/dist/pg-error-BV_X8Isq.js +26 -0
- package/dist/plugin-CDLJRFFg.d.ts +190 -0
- package/dist/plugins/actor/index.d.ts +1 -1
- package/dist/plugins/actor/index.js +1 -1
- package/dist/plugins/rules/index.d.ts +2 -2
- package/dist/plugins/rules/index.js +2 -2
- package/dist/plugins/soft-delete/index.d.ts +3 -3
- package/dist/plugins/soft-delete/index.js +4 -4
- package/dist/plugins/tenant/index.d.ts +4 -3
- package/dist/plugins/tenant/index.js +3 -3
- package/dist/plugins/timestamps/index.d.ts +1 -1
- package/dist/plugins/timestamps/index.js +1 -1
- package/dist/plugins/validation/index.d.ts +2 -2
- package/dist/plugins/validation/index.js +3 -3
- package/dist/pool-CwOCsX9e.d.ts +114 -0
- package/dist/pool-errors-GMjlCN0d.js +27 -0
- package/dist/ports-Ca63W4cW.d.ts +285 -0
- package/dist/ports-DAdQyjZz.d.ts +86 -0
- package/dist/ports-DGK3KTDH.js +133 -0
- package/dist/postgres/index.d.ts +28 -6
- package/dist/postgres/index.js +54 -23
- package/dist/postgrest-Cw9Bs5gm.d.ts +53 -0
- package/dist/powersync/index.d.ts +4 -4
- package/dist/powersync/index.js +4 -4
- package/dist/powersync/react/index.d.ts +8 -3
- package/dist/powersync/react/index.js +11 -3
- package/dist/preset/index.d.ts +352 -0
- package/dist/preset/index.js +564 -0
- package/dist/problem-CEjyhV5T.d.ts +76 -0
- package/dist/problem-pIiL3WbE.js +159 -0
- package/dist/provider-9gBq9W6k.d.ts +113 -0
- package/dist/provider-DKh2xFZb.js +57 -0
- package/dist/push-token-D6REHMMw.js +16 -0
- package/dist/push-token-asrmi7-6.d.ts +24 -0
- package/dist/query/index.d.ts +2 -2
- package/dist/range-DwpWe8e7.js +7 -0
- package/dist/range-p5V4rBhZ.d.ts +8 -0
- package/dist/rank-keys-DcPMt_Jk.js +60 -0
- package/dist/rank-result-DS8IoLup.js +18 -0
- package/dist/rank-result-Dngpt2n5.d.ts +14 -0
- package/dist/rate-limit--QSiFA6D.js +325 -0
- package/dist/rate-limit-BtbddDnE.js +49 -0
- package/dist/rate-limit-C88MEAOm.d.ts +41 -0
- package/dist/ratelimit-headers-Bsxx0Tj2.js +62 -0
- package/dist/react/index.d.ts +9 -9
- package/dist/react/index.js +3 -4
- package/dist/react/native/index.d.ts +1 -1
- package/dist/react/native/index.js +2 -2
- package/dist/react/server.d.ts +10 -10
- package/dist/react/server.js +3 -3
- package/dist/react/session.d.ts +2 -2
- package/dist/react-CKXUkwPx.js +410 -0
- package/dist/react-controller-Xn-IU44p.js +40 -0
- package/dist/react-env-Cc7Vf6eb.js +13 -0
- package/dist/react-router/index.d.ts +6 -6
- package/dist/react-router/index.js +7 -7
- package/dist/read-set-GEb-i0On.js +314 -0
- package/dist/realtime/index.d.ts +8 -4
- package/dist/realtime/index.js +5 -327
- package/dist/realtime-status-D2dw1hOE.js +12 -0
- package/dist/realtime-status-DcsO00Gw.d.ts +8 -0
- package/dist/refusal-br4Ma9Cf.js +61 -0
- package/dist/refusal-oD3ISM9h.d.ts +18 -0
- package/dist/replicas-BvIL4_jL.js +94 -0
- package/dist/resolve-CAR9oaKW.js +869 -0
- package/dist/resolve-w9EpHzuM.d.ts +266 -0
- package/dist/resource-B4ka8dDH.d.ts +106 -0
- package/dist/resource-C8r5xGqY.js +463 -0
- package/dist/resource-DO4gbVi5.js +91 -0
- package/dist/resource-execute-D0yQ-8WR.d.ts +12 -0
- package/dist/resource-types-D2vEQVXI.d.ts +141 -0
- package/dist/resources-BklF-rjz.d.ts +7 -0
- package/dist/resources-BlZdLsWQ.js +64 -0
- package/dist/respond-C7I8kLK_.d.ts +93 -0
- package/dist/respond-DdhZ9Wbn.js +95 -0
- package/dist/result-BlgnMB0H.js +128 -0
- package/dist/result-Cj3yWhjH.d.ts +298 -0
- package/dist/rrule-zBh3CA1r.js +290 -0
- package/dist/rules-B0-3Iosu.js +261 -0
- package/dist/run-watch-CQRdpy-f.js +53 -0
- package/dist/sales-lines-BAk8In-Z.js +41 -0
- package/dist/sales-lines-C6gafAwg.d.ts +28 -0
- package/dist/sales-shared-DAAGwQwN.d.ts +8 -0
- package/dist/sales-shared-SblIgCgI.js +22 -0
- package/dist/scope-DgWjuXUw.js +90 -0
- package/dist/search-B6h03cAx.js +106 -0
- package/dist/search-BtoWY6n_.js +9 -0
- package/dist/server/index.d.ts +157 -51
- package/dist/server/index.js +295 -20
- package/dist/server-types-OohhIQb2.d.ts +737 -0
- package/dist/session-DiWHtYrO.d.ts +95 -0
- package/dist/settings-BXFn9zpS.js +90 -0
- package/dist/shared-BmMcMoW9.js +89 -0
- package/dist/shared-DbO3Dfms.js +14 -0
- package/dist/shared-as0EG51f.js +60 -0
- package/dist/sign-in-D0xN6E_l.js +360 -0
- package/dist/signatures-B41ApWXA.js +145 -0
- package/dist/signatures-BMdJ7neI.d.ts +43 -0
- package/dist/signers-CXt_wA9S.d.ts +385 -0
- package/dist/signing-keys-Bq9mMCTh.d.ts +90 -0
- package/dist/solid/index.d.ts +8 -8
- package/dist/solid/index.js +3 -8
- package/dist/solid-controller-DLsmrczT.js +33 -0
- package/dist/solid-controller-DqlvYFZa.d.ts +11 -0
- package/dist/solid-start/index.d.ts +8 -12
- package/dist/solid-start/index.js +8 -7
- package/dist/spec/index.d.ts +25 -5
- package/dist/spec/index.js +37 -4
- package/dist/spec-pins-Dqpml2dZ.d.ts +84 -0
- package/dist/spec-pins-S4PymRxp.js +60 -0
- package/dist/sql/index.d.ts +189 -28
- package/dist/sql/index.js +74328 -26488
- package/dist/sql-DJrXG-3Z.js +422 -0
- package/dist/sso-BDva_-4y.js +217 -0
- package/dist/sso-BN0LnMqE.d.ts +131 -0
- package/dist/ssr/index.d.ts +5 -6
- package/dist/ssr/index.js +4 -4
- package/dist/standard-B5fe2gnW.js +25 -0
- package/dist/standard-C3_AAF3g.d.ts +7 -0
- package/dist/stats-D6vkb89f.js +1128 -0
- package/dist/storage/index.d.ts +3 -3
- package/dist/storage/index.js +14 -10
- package/dist/storage-api-Cx-1iWBd.d.ts +582 -0
- package/dist/store-B5qYKsTh.d.ts +68 -0
- package/dist/streams/index.d.ts +4 -4
- package/dist/streams/index.js +4 -4
- package/dist/streams/redis/index.d.ts +1 -1
- package/dist/streams/redis/index.js +4 -4
- package/dist/stripe/index.d.ts +16 -0
- package/dist/stripe/index.js +14 -0
- package/dist/stripe-NVcNOhHy.d.ts +140 -0
- package/dist/stripe-XZrSmDTf.js +32 -0
- package/dist/subject-readers-DiePmaYP.js +46 -0
- package/dist/subject-readers-fxP5wu0s.d.ts +21 -0
- package/dist/subjects-z8VWA_lF.d.ts +69 -0
- package/dist/subscribers-Cq--DL1E.js +34 -0
- package/dist/supabase-mcp-DomZ-amK.js +28 -0
- package/dist/support-BsKkvA92.js +522 -0
- package/dist/support-view-BbcZR7Io.d.ts +17 -0
- package/dist/svelte/index.d.ts +7 -7
- package/dist/svelte/index.js +2 -2
- package/dist/svelte-controller-CDAujo-_.js +50 -0
- package/dist/svelte-controller-DxxHEHxD.d.ts +10 -0
- package/dist/sveltekit/index.d.ts +11 -13
- package/dist/sveltekit/index.js +9 -9
- package/dist/table-schema-pQlER3jj.js +283 -0
- package/dist/tags-Dw8vKkcI.d.ts +30 -0
- package/dist/tags-PaG0Ns_9.js +84 -0
- package/dist/tanstack-db/index.d.ts +2 -2
- package/dist/tanstack-db/index.js +1 -1
- package/dist/tanstack-db/supabase/index.d.ts +5 -6
- package/dist/tanstack-db/supabase/index.js +180 -9
- package/dist/tanstack-start/index.d.ts +7 -7
- package/dist/tanstack-start/index.js +6 -6
- package/dist/tax-DDZUB_oc.d.ts +273 -0
- package/dist/tax-D_kSCdWU.js +140 -0
- package/dist/tee-Bsisfd7t.js +101 -0
- package/dist/template-DdgYjIZ5.js +123 -0
- package/dist/temporal-CB5Fpo5d.js +79 -0
- package/dist/temporal-required-DqRzr7i0.js +20 -0
- package/dist/tenant-B-fiItlJ.js +27 -0
- package/dist/tenant-DdmVhsff.d.ts +17 -0
- package/dist/tenant-naming-Bsj3Uvap.js +27 -0
- package/dist/testing/index.d.ts +235 -32
- package/dist/testing/index.js +1132 -32
- package/dist/timing-BMCWgpdZ.js +92 -0
- package/dist/topic-ByDz9edV.js +542 -0
- package/dist/topic-cMS7UtM1.d.ts +359 -0
- package/dist/types-6oiy6rIa.d.ts +205 -0
- package/dist/types-CdP0z5tS.d.ts +564 -0
- package/dist/types-D0W4fnsc.d.ts +162 -0
- package/dist/types-R6syp1el.js +29 -0
- package/dist/types-YfwpIE-d.d.ts +357 -0
- package/dist/types-pNY_UpDs.d.ts +240 -0
- package/dist/types-vaoP9S9_.d.ts +635 -0
- package/dist/undo-CEGIRkPA.js +44 -0
- package/dist/undo-DGo0skyO.d.ts +43 -0
- package/dist/usage-CdlfuS3i.js +250 -0
- package/dist/usage-DM2rzczj.d.ts +202 -0
- package/dist/vault-tOkPLnMB.d.ts +50 -0
- package/dist/vercel-connect/index.d.ts +1 -1
- package/dist/vercel-connect/index.js +4 -4
- package/dist/verify-D63A2ALE.d.ts +142 -0
- package/dist/verify-EQcC3Khp.js +126 -0
- package/dist/version-D87ZteKy.js +4 -0
- package/dist/view-DZ63nqwt.js +105 -0
- package/dist/view-Dv0A949a.d.ts +90 -0
- package/dist/vue/index.d.ts +8 -8
- package/dist/vue/index.js +3 -16
- package/dist/vue-controller-C9tFzqAc.js +32 -0
- package/dist/vue-controller-HPfMTDa2.d.ts +11 -0
- package/dist/watch-DDuWYv0Y.d.ts +84 -0
- package/dist/watch-DT_B3CnJ.js +139 -0
- package/dist/watch-topic-EFpOIIDE.d.ts +28 -0
- package/dist/watch-topic-miuymrli.js +57 -0
- package/dist/wire-DEY0ajW9.js +45 -0
- package/dist/workflow-builder-OrglbXUh.js +681 -0
- package/dist/workflow-sdk/builder/index.d.ts +46 -15
- package/dist/workflow-sdk/builder/index.js +69 -21
- package/dist/workflow-sdk/index.d.ts +75 -2
- package/dist/workflow-sdk/index.js +83 -3
- package/dist/workflow-sdk/world/index.d.ts +29 -4
- package/dist/workflow-sdk/world/index.js +137 -59
- package/dist/workflow-sdk-BZ6qsRlY.js +243 -0
- package/dist/workflow-sdk-q8cLwW1K.d.ts +161 -0
- package/dist/workflow-sdk-world-DPMdBfzn.js +2103 -0
- package/dist/workflows-C-OvZpeg.js +282 -0
- package/dist/workflows-DFQeXDAI.d.ts +243 -0
- package/dist/yjs/index.d.ts +67 -0
- package/dist/yjs/index.js +212 -0
- package/package.json +788 -46
- package/schemas/cli-commands-v1.json +220 -0
- package/schemas/cli-result-v1.json +138 -0
- package/schemas/config-v1.json +4147 -726
- package/schemas/doctor-report-v1.json +23 -0
- package/schemas/snapshot-v2.json +271 -0
- package/skills/better-supabase/SKILL.md +11 -4
- package/skills/better-supabase/references/plugins.md +2 -2
- package/skills/better-supabase/references/ui-recipes.md +18652 -0
- package/skills/better-supabase-api/SKILL.md +14 -14
- package/skills/better-supabase-api/references/adapters.md +12 -2
- package/skills/better-supabase-api/references/ai.md +4 -4
- package/skills/better-supabase-api/references/expo.md +1 -1
- package/skills/better-supabase-api/references/powersync.md +1 -1
- package/skills/better-supabase-api/references/streams-credentials.md +8 -5
- package/skills/better-supabase-auth/SKILL.md +18 -11
- package/skills/better-supabase-auth/references/authorization-providers.md +5 -5
- package/skills/better-supabase-testing/SKILL.md +2 -2
- package/testing/act-cases.json +370 -0
- package/dist/access-sql-CrSbK1q-.js +0 -181
- package/dist/actor-CQTlc1Hh.d.ts +0 -43
- package/dist/adapter-CN45ibbV.js +0 -1691
- package/dist/agents-BY_LQXtQ.d.ts +0 -94
- package/dist/ai-cache-Dfgk8nBT.js +0 -81
- package/dist/ai-cache-DrUMMgp6.d.ts +0 -64
- package/dist/ai-chat-CVLRWbtS.js +0 -744
- package/dist/ai-chat-DXkMy74O.d.ts +0 -590
- package/dist/ai-files-CioLu5du.js +0 -320
- package/dist/ai-tasks-BRQ6iQLc.d.ts +0 -131
- package/dist/announcements-Bouy0Ski.d.ts +0 -72
- package/dist/announcements-p_BFofiR.js +0 -107
- package/dist/api-Dnf2q-Ht.d.ts +0 -370
- package/dist/api-DtHFC037.js +0 -676
- package/dist/api-keys-C8BlF3L2.d.ts +0 -156
- package/dist/assistant-DBlFDZsi.d.ts +0 -147
- package/dist/attributes-5dYul0u0.js +0 -13
- package/dist/authorization-BFCqamMs.d.ts +0 -172
- package/dist/authorizer-ayxUDXXP.js +0 -126
- package/dist/base64-D2gsioCY.js +0 -42
- package/dist/before-route-CnR0C0Jr.js +0 -30
- package/dist/bind-ButSzrHe.js +0 -97
- package/dist/bind-CQ5mPyX1.d.ts +0 -62
- package/dist/block-events-DTxU5tfr.js +0 -91
- package/dist/block-fields-CroxUuF2.js +0 -49
- package/dist/block-fields-D1pfrcIY.d.ts +0 -31
- package/dist/block-helpers-C0Xj8War.d.ts +0 -37
- package/dist/block-helpers-Cy3pB96I.js +0 -222
- package/dist/block-hooks-BPNMIlLl.js +0 -114
- package/dist/block-hooks-DaOVC7SC.d.ts +0 -107
- package/dist/block-transport-bAKh-dGc.js +0 -85
- package/dist/block-transport-inD4fFq-.d.ts +0 -70
- package/dist/build-hPwk3_X5.js +0 -1296
- package/dist/build-meLuYGri.js +0 -689
- package/dist/channel-_2Mx1Oyu.d.ts +0 -70
- package/dist/channels-CkgQ_X60.js +0 -114
- package/dist/cli/add-1dc5wM5k.js +0 -69
- package/dist/cli/bin.d.ts +0 -1
- package/dist/cli/cache-CIfA-jBV.js +0 -483
- package/dist/cli/codemod-JVlTQQG2.js +0 -491
- package/dist/cli/command-DV-StG1x.js +0 -507
- package/dist/cli/config-C2Pz2_4A.js +0 -52
- package/dist/cli/config-DpbF8ML3.js +0 -2
- package/dist/cli/config-M-WzL9O2.js +0 -1175
- package/dist/cli/db-BkcfOlyB.js +0 -53
- package/dist/cli/diff-D1jGdb3Y.js +0 -752
- package/dist/cli/dist-DBPdlPYK.js +0 -1112
- package/dist/cli/dist-DRAVaE6w.js +0 -842
- package/dist/cli/dist-DreZ24Sz.js +0 -129
- package/dist/cli/doctor-VcDtuusV.js +0 -3686
- package/dist/cli/emit-BHHfwUoT.js +0 -1136
- package/dist/cli/env-B--LMhEe.js +0 -514
- package/dist/cli/env-vzBG90Um.js +0 -2
- package/dist/cli/env-zHJtMIBo.js +0 -116
- package/dist/cli/errors-CqTLXe2M.js +0 -72
- package/dist/cli/fastest-levenshtein-CF7BCi9l.js +0 -120
- package/dist/cli/files-BVupXPIC.js +0 -34
- package/dist/cli/gen-DmVj3u4L.js +0 -322
- package/dist/cli/glue-C1UE8gQN.js +0 -468
- package/dist/cli/init-CVZ3s5kl.js +0 -258
- package/dist/cli/introspect-BToGNfm-.js +0 -81
- package/dist/cli/io-CFzJVJ68.js +0 -37
- package/dist/cli/jsonc-BUniUpOk.js +0 -2
- package/dist/cli/keys-CJhtIxJ3.js +0 -85
- package/dist/cli/metadata-DSRKCaf-.js +0 -133
- package/dist/cli/openapi-DWa4Ir1q.js +0 -60
- package/dist/cli/project-DZRaBaY7.js +0 -75
- package/dist/cli/prompts-BdeVmVpg.js +0 -2
- package/dist/cli/prompts-DZJB1nTw.js +0 -1142
- package/dist/cli/read-sets-DjJrm8Wj.js +0 -223
- package/dist/cli/rolldown-runtime-CMFfr-1z.js +0 -26
- package/dist/cli/run-DA6_lg3w.js +0 -389
- package/dist/cli/run-DuN1MX-7.js +0 -705
- package/dist/cli/scaffold-5DenHSxF.js +0 -292
- package/dist/cli/scaffold-CwQUMFRb.js +0 -2
- package/dist/cli/seed-CzBmrGON.js +0 -96
- package/dist/cli/seed-DOmit9Hq.js +0 -2
- package/dist/cli/skills-CnO8QFS7.js +0 -139
- package/dist/cli/snapshot-Bstk6ulj.js +0 -287
- package/dist/cli/source-args-BYfq6imV.js +0 -31
- package/dist/cli/spec-CzosTYm3.js +0 -7438
- package/dist/cli/spec-T_0Bcyw7.js +0 -2
- package/dist/cli/sql-BKOOQAX3.js +0 -517
- package/dist/cli/sql-DJrNDxQm.js +0 -3
- package/dist/cli/sql-layout-D6cphHT3.js +0 -69
- package/dist/cli/style-CB3vxgh0.js +0 -29
- package/dist/cli/supabase-toml-B-bIukQF.js +0 -1095
- package/dist/cli/typegen-Demi5-VQ.js +0 -486
- package/dist/cli/typegen-version-DeFbtzYI.js +0 -8
- package/dist/cli/validate-Ca13ktgz.js +0 -7522
- package/dist/cli/version-B4k0aUxS.js +0 -4
- package/dist/client-wxeTQFtB.js +0 -310
- package/dist/cloud-event-Dune0KI7.d.ts +0 -64
- package/dist/compiler-V1qvcST1.d.ts +0 -14
- package/dist/compose-BHej1acw.js +0 -53
- package/dist/composite-CWAJTRBF.d.ts +0 -146
- package/dist/composite-zTe0TCqj.js +0 -130
- package/dist/config-DlKvtt95.js +0 -540
- package/dist/connector-CySQEFcW.d.ts +0 -107
- package/dist/connectors--PGhmiu0.d.ts +0 -120
- package/dist/context-DsV2SueA.d.ts +0 -246
- package/dist/context-nPDvQ-Zl.js +0 -41
- package/dist/cron-D25Ukpxy.js +0 -145
- package/dist/cron-route-D2AN9SA1.js +0 -24
- package/dist/csv-BZnHA1Py.js +0 -25
- package/dist/define-CFQcvM20.js +0 -1949
- package/dist/define-DmcfoNXB.d.ts +0 -1005
- package/dist/embeddings-4ZvFzjsU.js +0 -127
- package/dist/embeddings-B4vhS-dY.d.ts +0 -68
- package/dist/ended-CcvcRqEd.d.ts +0 -69
- package/dist/entitlements-BYFf0Cdh.js +0 -24
- package/dist/entitlements-Ds8qoYZ-.d.ts +0 -51
- package/dist/envelope-DqnYGRw7.js +0 -47
- package/dist/errors-2M6CKMP4.js +0 -271
- package/dist/errors-rZ7VIqp6.js +0 -44
- package/dist/escape-like-DDWoHzh_.d.ts +0 -15
- package/dist/eve-CoAIcCkq.d.ts +0 -151
- package/dist/events-D1BOCll8.d.ts +0 -551
- package/dist/executor-B-fD7DLt.js +0 -735
- package/dist/executor-B_Hfobee.js +0 -162
- package/dist/executor-C-R4pucm.js +0 -83
- package/dist/executor-CpxLPCiz.d.ts +0 -36
- package/dist/executor-DfEtf0cq.d.ts +0 -164
- package/dist/executor-Drzu3dBK.d.ts +0 -261
- package/dist/executor-ngjKL_Pe.d.ts +0 -30
- package/dist/field-errors-DIe9XYWL.d.ts +0 -109
- package/dist/format-DTXdu8ya.d.ts +0 -70
- package/dist/framework-148t1m3C.d.ts +0 -25
- package/dist/framework-3ey3QmrO.js +0 -51
- package/dist/function-contract-BcZenXMO.d.ts +0 -8
- package/dist/gateway-S9vicgeo.d.ts +0 -130
- package/dist/graph-BGyLLFLX.js +0 -265
- package/dist/hooks-Bhza9Hnr.js +0 -277
- package/dist/http-sbmBe5d2.js +0 -25
- package/dist/impersonation-DcqaTBHx.js +0 -148
- package/dist/inbox-Cd1KCEBL.d.ts +0 -384
- package/dist/index-B72Rvv_j.d.ts +0 -89
- package/dist/index-BCHR4Ucl.d.ts +0 -300
- package/dist/index-BUK7jUzR.d.ts +0 -82
- package/dist/index-Ba1SI-uO.d.ts +0 -1331
- package/dist/index-BfxOaOYs.d.ts +0 -245
- package/dist/index-CicIyqOe.d.ts +0 -55
- package/dist/index-D69h4QqD.d.ts +0 -74
- package/dist/index-DG2SUXDQ.d.ts +0 -58
- package/dist/index-DoNVLHOU.d.ts +0 -206
- package/dist/index-R274XFlg.d.ts +0 -138
- package/dist/index-_4R4b01v.d.ts +0 -257
- package/dist/json-schema-CT4tvrkA.js +0 -548
- package/dist/kit-BhHr9T9v.js +0 -104
- package/dist/kit-W99LCPBS.d.ts +0 -66
- package/dist/knowledge-CAReFpRA.d.ts +0 -176
- package/dist/knowledge-DnHtb64A.js +0 -256
- package/dist/list-query-D3JQeEL7.d.ts +0 -164
- package/dist/list-query-gl808ac1.js +0 -459
- package/dist/live-CTAZQkwl.js +0 -257
- package/dist/messages-CJoHHCWT.js +0 -514
- package/dist/mfa-SZc9YYyb.d.ts +0 -54
- package/dist/model-OgBxvk3N.d.ts +0 -300
- package/dist/model-messages-Cxw_Up4f.js +0 -206
- package/dist/modules-DksjZyMW.js +0 -77
- package/dist/modules-DpPwzC7E.d.ts +0 -192
- package/dist/onboarding-LZvlK1P4.d.ts +0 -62
- package/dist/optional-peer-D1pi-skw.js +0 -24
- package/dist/outbox-DZfAvfzC.d.ts +0 -161
- package/dist/plugin-CPK2HsbY.d.ts +0 -190
- package/dist/pool-gkhbGp2E.d.ts +0 -89
- package/dist/postgrest-BUqLag2Q.d.ts +0 -53
- package/dist/problem-CJvFeGu7.js +0 -126
- package/dist/problem-TvGdxO1t.d.ts +0 -73
- package/dist/provider-DxRTex4x.js +0 -57
- package/dist/provider-ib9wzUDz.d.ts +0 -113
- package/dist/rate-limit-Bw7HgmFj.js +0 -260
- package/dist/react-Bmpo59D7.js +0 -409
- package/dist/react-topic-seAWjL3_.js +0 -38
- package/dist/read-set-DAeWhqPf.js +0 -314
- package/dist/refusal-CFI8-5nf.d.ts +0 -18
- package/dist/refusal-K78A1Vmq.js +0 -61
- package/dist/replicas-CwrYp5Zj.js +0 -94
- package/dist/resolve-Bvgf3ut_.js +0 -786
- package/dist/resolve-MxXVWyhd.d.ts +0 -173
- package/dist/resource--vTrBe6I.d.ts +0 -103
- package/dist/resource-BqNqlYg9.js +0 -91
- package/dist/resource-DA811u_4.js +0 -463
- package/dist/resource-execute-rbKvuCsY.d.ts +0 -12
- package/dist/resource-types-CqB6GWGT.d.ts +0 -141
- package/dist/resources-BVYPnuLo.js +0 -56
- package/dist/resources-DZSk8tXJ.d.ts +0 -7
- package/dist/respond-Cw-zanCm.d.ts +0 -93
- package/dist/respond-volM3ByS.js +0 -91
- package/dist/result-BchM1XZn.d.ts +0 -288
- package/dist/result-DEqHCMKP.js +0 -128
- package/dist/scope-CDHcS7dE.js +0 -90
- package/dist/search-CUWCRYKL.js +0 -9
- package/dist/server-types-CrAmZ8jv.d.ts +0 -614
- package/dist/session-ChiXDZdZ.d.ts +0 -72
- package/dist/shared-CBxosKK3.js +0 -75
- package/dist/shared-DHjhfRdV.js +0 -60
- package/dist/shared-DTneRoxi.js +0 -14
- package/dist/sign-in-D_yoP0PF.js +0 -86
- package/dist/signatures-BRS_p3WD.d.ts +0 -34
- package/dist/signatures-DsWv_bYR.js +0 -103
- package/dist/signers-cpdmZYrv.d.ts +0 -166
- package/dist/spec-pins-CwzDY25w.d.ts +0 -44
- package/dist/spec-pins-DsLfUH7l.js +0 -40
- package/dist/sql-CwuqaQ4Y.js +0 -388
- package/dist/standard-DkzZUneg.js +0 -25
- package/dist/standard-lmkbOtPB.d.ts +0 -7
- package/dist/stats-DhU78Ctf.js +0 -1127
- package/dist/storage-api-CB7Zl1Ci.d.ts +0 -582
- package/dist/store-DsIYEBnv.d.ts +0 -68
- package/dist/stripe-D04h-QBT.d.ts +0 -136
- package/dist/stripe-v-Y2lmop.js +0 -32
- package/dist/supabase-mcp-Cpn5A4i-.js +0 -28
- package/dist/support-D1pFFouF.js +0 -350
- package/dist/support-view-scqMgtOo.d.ts +0 -17
- package/dist/table-schema-htM_SbCW.js +0 -286
- package/dist/tags-BO9LjtWG.d.ts +0 -30
- package/dist/tee-Cb98Vn3b.js +0 -101
- package/dist/template-lPVh-EwW.js +0 -123
- package/dist/temporal-BJGitm-h.js +0 -79
- package/dist/temporal-required-BtynQtM-.js +0 -20
- package/dist/tenant-CM368LXb.d.ts +0 -17
- package/dist/tenant-I1TEZ_BX.js +0 -27
- package/dist/timing-CyTQPubY.js +0 -92
- package/dist/types-6cYK9pbJ.js +0 -29
- package/dist/types-Bb_4-vdW.d.ts +0 -220
- package/dist/types-CD6yCDx3.d.ts +0 -359
- package/dist/types-CbOUc5IH.d.ts +0 -156
- package/dist/types-CoE5Zwy4.d.ts +0 -348
- package/dist/types-DSJ3-m9X.d.ts +0 -192
- package/dist/types-D__3EN0q.d.ts +0 -425
- package/dist/usage-D0NNUFfr.d.ts +0 -177
- package/dist/vault-BpmApxxW.d.ts +0 -50
- package/dist/verify-C91Wyp40.d.ts +0 -142
- package/dist/verify-CJbYfADA.js +0 -124
- package/dist/version-Cq8gfDbd.js +0 -4
- package/dist/view-D5ryq3gz.js +0 -99
- package/dist/view-TpXEUcVN.d.ts +0 -87
- package/dist/watch-BcvtAHOd.js +0 -122
- package/dist/watch-COzRWvVN.d.ts +0 -74
- package/dist/wire-Df3EWkG8.js +0 -45
- package/dist/workflow-builder-CHqHpAVt.js +0 -399
- package/dist/workflow-sdk-C2WJ_G2O.js +0 -173
- package/dist/workflow-sdk-DTZyrvNo.d.ts +0 -98
- package/dist/workflow-sdk-world-CMV1ahFW.js +0 -1399
- package/dist/workflows-CcpCBfIe.d.ts +0 -185
- package/dist/workflows-IvxzG2cl.js +0 -217
|
@@ -0,0 +1,3806 @@
|
|
|
1
|
+
import { a as list, p as commandMeta, r as defineCliCommand } from "./command-CK9Cn8HV.js";
|
|
2
|
+
import { i as plain } from "./style-2JfIPVMQ.js";
|
|
3
|
+
import { t as VERSION } from "./version-DNRW4DAP.js";
|
|
4
|
+
import { n as CliError, r as errorText } from "./errors-Caiz_0so.js";
|
|
5
|
+
import { a as sameText, t as display } from "./io-BiPX-_J1.js";
|
|
6
|
+
import { t as CACHE_DIR } from "./cache-Cx6qlRDR.js";
|
|
7
|
+
import { t as byCodePoint } from "./compare-BmIWVEui.js";
|
|
8
|
+
import { h as satisfies, m as peerVersions, t as MetadataRejectedError } from "./typegen--TfvOPLh.js";
|
|
9
|
+
import { c as configFor, s as notNullTag } from "./emit-ZAYeh4Yn.js";
|
|
10
|
+
import { A as renderFiles, C as policyObject, D as advisorRule, E as workflowOf, O as splinterTables, S as permissionCatalogKeys, T as tableObject, _ as withoutStrings, a as METADATA_GAPS_TEXT, b as exposedSchemas, c as accessModule, d as RLS_RULES, f as appliesTo, g as policyHelpers, h as functionObject, l as entitlementsModule, m as functionBody, p as escape$2, s as AUTHORIZATION_RULES, u as COMMANDS$1, v as catalogOf, w as qualified, x as lineOf, y as exposed } from "./run-DaHfUUO9.js";
|
|
11
|
+
import { D as providerLabel, a as migrationModules, i as migrationHolds, r as latestMigration, t as MODULES_SUFFIX, v as migrationsDir, x as compiledReadSets, y as moduleLayoutFor } from "./module-migration-YFFdWC5X.js";
|
|
12
|
+
import { l as stdinDatabaseUrl } from "./config-CLHpTk9Y.js";
|
|
13
|
+
import { a as findRepoRoot, c as httpHooks, d as readSupabaseToml, f as schemaPaths, h as tomlGet, i as diffEngine, l as migrationCommand, n as declarativeSchemasDir, p as sqlWorkflow, u as pgFunctionHooks } from "./supabase-toml-DjLgBYEo.js";
|
|
14
|
+
import { t as connect } from "./db-DT1bGB02.js";
|
|
15
|
+
import { c as isSqliteLiteDriver, n as managementTarget, o as snapshotFile, p as request, r as openSource, s as liteDriverOf, t as loadSnapshot } from "./snapshot-DAxLi4hO.js";
|
|
16
|
+
import { n as applyPlan, o as insideRoot, r as changeLines } from "./files-DDvAwTko.js";
|
|
17
|
+
import { t as isRecord } from "./guards-CUlDNYZs.js";
|
|
18
|
+
import { i as sourceFlags, n as SOURCE_ARGS } from "./source-args-0I9zylYy.js";
|
|
19
|
+
import { i as refuseWithMetadata, r as readMetadataSnapshot } from "./metadata-CxBZo4KM.js";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { dirname, resolve } from "node:path";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
24
|
+
import { SECRET_COLUMNS, SQL_MODULES, adoptedTables, contractSignature, customContracts, dataExportPlan, excludedColumns, expiredFlagsQuery, migrationOptionUses, moduleDeprecations, moduleEventTriggers, moduleFileVersion, moduleLayout, moduleVersion, renderModules, sameModuleFile, sameTenantColumn, sameTenantTrigger, sharedRolesProblems, subjectColumns } from "better-supabase/sql";
|
|
25
|
+
import { DEFAULT_ACTIVE_TENANT, tenantClaimPaths } from "better-supabase/config";
|
|
26
|
+
import { defineBucket, parseSize } from "better-supabase/storage";
|
|
27
|
+
//#region src/cli/doctor/adoption.ts
|
|
28
|
+
const tagText = (notNull) => notNull === true ? "@notNull" : `@notNull(${notNull.join(", ")})`;
|
|
29
|
+
const sameNotNull = (a, b) => a === true || b === true ? a === b : a !== void 0 && a.length === b.length && a.every((name) => b.includes(name));
|
|
30
|
+
/** Checks for adopted schemas: config the SQL could carry, and references and exports the modules leave open. */
|
|
31
|
+
const ADOPTION_RULES = [
|
|
32
|
+
{
|
|
33
|
+
code: "BS338",
|
|
34
|
+
severity: "info",
|
|
35
|
+
title: "functions.notNull entry a comment tag could replace",
|
|
36
|
+
description: "`gen` reads `@notNull` and `@notNull(a, b)` from a function's comment. A `functions.<name>.notNull` entry for a function in the read schemas can move to the comment, next to the SQL it describes. A config entry wins over the tag.",
|
|
37
|
+
check: (context) => {
|
|
38
|
+
const { config } = context;
|
|
39
|
+
return catalogOf(context).functions.flatMap((fn) => {
|
|
40
|
+
const notNull = configFor(config.functions, fn.schema, fn.name)?.notNull;
|
|
41
|
+
if (notNull === void 0 || !config.schemas.includes(fn.schema)) return [];
|
|
42
|
+
const name = `${fn.schema}.${fn.name}`;
|
|
43
|
+
const tag = notNullTag(fn.comment);
|
|
44
|
+
const wanted = tagText(notNull);
|
|
45
|
+
let message;
|
|
46
|
+
if (sameNotNull(tag, notNull)) message = `functions.${fn.name}.notNull matches the ${wanted} tag on ${name}(${fn.signature}). Remove the config entry.`;
|
|
47
|
+
else if (tag === void 0) message = `functions.${fn.name}.notNull can move to the function: add \`${wanted}\` to the comment on ${name}(${fn.signature}) (\`comment on function ${name}(${fn.signature}) is '${wanted}';\`), then remove the config entry.`;
|
|
48
|
+
else message = `functions.${fn.name}.notNull (${wanted}) differs from the ${tagText(tag)} tag on ${name}(${fn.signature}), and the config entry wins. Make them agree, then remove the config entry.`;
|
|
49
|
+
return [{
|
|
50
|
+
message,
|
|
51
|
+
target: `${name}(${fn.signature})`
|
|
52
|
+
}];
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
code: "BS339",
|
|
58
|
+
severity: "warning",
|
|
59
|
+
title: "expose excludes a column the table doesn't have",
|
|
60
|
+
description: "`sql sync` turns `select(*, -col)` in `expose` into the table's declared columns minus the excluded ones. An excluded column the table no longer has excludes nothing, and a renamed secret column is then granted under its new name. Remove the exclusion, or exclude the new name.",
|
|
61
|
+
check: (context) => {
|
|
62
|
+
const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
|
|
63
|
+
return Object.entries(context.config.expose).flatMap(([key, roles]) => {
|
|
64
|
+
const table = tables.get(key.includes(".") ? key : `public.${key}`);
|
|
65
|
+
if (table === void 0) return [];
|
|
66
|
+
const columns = new Set(table.columns.map((column) => column.name));
|
|
67
|
+
return [...new Set([
|
|
68
|
+
...roles.anon,
|
|
69
|
+
...roles.authenticated,
|
|
70
|
+
...roles.serviceRole
|
|
71
|
+
].flatMap((privilege) => excludedColumns(privilege)).filter((column) => !columns.has(column)))].map((column) => ({
|
|
72
|
+
message: `expose.${key} excludes "${column}", which ${qualified(table)} doesn't have, so it excludes nothing. Remove "-${column}", or exclude the column's new name.`,
|
|
73
|
+
target: `${qualified(table)}.${column}`
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
code: "BS340",
|
|
80
|
+
severity: "info",
|
|
81
|
+
title: "Tenant foreign key without a same-tenant trigger",
|
|
82
|
+
description: "A foreign key checks that the referenced row exists, not that it belongs to the row's tenant. Doctor lists each single-column key between two tables that both have the tenant column, when the key doesn't include that column and the table has no `bs_same_tenant_<column>` trigger. List the reference in `blocks.tenant.options.sameTenant`, or set `sameTenant: { auto: true }` to derive every one from the schema files.",
|
|
83
|
+
check: (context) => {
|
|
84
|
+
if (!context.config.blockNames.includes("tenant")) return [];
|
|
85
|
+
const tenant = sameTenantColumn(moduleLayout(context.config));
|
|
86
|
+
const { tables } = catalogOf(context);
|
|
87
|
+
const tenantTables = new Set(tables.filter((table) => table.columns.some((column) => column.name === tenant)).map(qualified));
|
|
88
|
+
return tables.filter((table) => context.config.schemas.includes(table.schema) && tenantTables.has(qualified(table))).flatMap((table) => {
|
|
89
|
+
const triggers = new Set(table.triggers.map((trigger) => trigger.name));
|
|
90
|
+
return table.foreignKeys.flatMap((key) => {
|
|
91
|
+
const [column] = key.columns;
|
|
92
|
+
if (column === void 0 || key.columns.length !== 1 || column === tenant || !tenantTables.has(`${key.refSchema}.${key.refTable}`) || triggers.has(sameTenantTrigger(column))) return [];
|
|
93
|
+
return [{
|
|
94
|
+
message: `${qualified(table)}.${column} references ${key.refSchema}.${key.refTable}, and both have ${tenant}, but no trigger checks that the referenced row has the same ${tenant}. Add it to blocks.tenant.options.sameTenant, or set sameTenant: { auto: true }.`,
|
|
95
|
+
target: `${qualified(table)}.${column}`
|
|
96
|
+
}];
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
code: "BS341",
|
|
103
|
+
severity: "warning",
|
|
104
|
+
title: "Data export includes a column that looks like a secret",
|
|
105
|
+
description: "A data export writes every column of each table it reads, except the ones in `omit`. Doctor reports columns of the exported tables, including the ones `autoTables` finds, whose name matches `token`, `secret`, `_hash`, `password`, `api_key` or `license_key` and that no `omit` and no `omitPattern` leaves out. Add the column to the table's `omit`, keep the table out of exports with `export: false` or `autoTables.exclude`, or set `blocks.data-lifecycle.options.omitPattern`.",
|
|
106
|
+
check: (context) => {
|
|
107
|
+
const plan = dataExportPlan(moduleLayout(context.config), context.config.blockNames);
|
|
108
|
+
if (plan === void 0) return [];
|
|
109
|
+
const secret = new RegExp(SECRET_COLUMNS, "i");
|
|
110
|
+
const omitted = patternOf(plan.omitPattern);
|
|
111
|
+
const tables = catalogOf(context).tables;
|
|
112
|
+
const byName = new Map(tables.map((table) => [qualified(table), table]));
|
|
113
|
+
const exported = [...plan.tables.map((entry) => ({
|
|
114
|
+
table: byName.get(entry.name),
|
|
115
|
+
omit: entry.omit
|
|
116
|
+
})), ...autoExported(plan, tables)];
|
|
117
|
+
const findings = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const { table, omit } of exported) {
|
|
119
|
+
if (table === void 0) continue;
|
|
120
|
+
for (const column of table.columns) {
|
|
121
|
+
const target = `${qualified(table)}.${column.name}`;
|
|
122
|
+
if (!secret.test(column.name) || omit.includes(column.name) || omitted?.test(column.name) === true || findings.has(target)) continue;
|
|
123
|
+
findings.set(target, {
|
|
124
|
+
message: `Data exports of ${qualified(table)} include ${column.name}, which looks like a secret. Add it to the table's omit, keep the table out of exports, or set blocks.data-lifecycle.options.omitPattern.`,
|
|
125
|
+
target
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...findings.values()];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
];
|
|
133
|
+
const NO_OMIT = [];
|
|
134
|
+
/** The tables `autoTables` adds to exports, which omit nothing. */
|
|
135
|
+
function autoExported(plan, tables) {
|
|
136
|
+
const { auto } = plan;
|
|
137
|
+
if (auto === void 0) return [];
|
|
138
|
+
return tables.filter((table) => auto.schemas.includes(table.schema) && !auto.excluded(qualified(table)) && table.columns.some((column) => auto.columns.includes(column.name))).map((table) => ({
|
|
139
|
+
table,
|
|
140
|
+
omit: NO_OMIT
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
/** `omitPattern` as a JavaScript regex; undefined when off or not valid there. */
|
|
144
|
+
function patternOf(pattern) {
|
|
145
|
+
if (pattern === void 0) return;
|
|
146
|
+
try {
|
|
147
|
+
return new RegExp(pattern, "i");
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/cli/doctor/cookies.ts
|
|
154
|
+
const TOKENS_ONLY = /\bencode\s*:\s*["'`]tokens-only["'`]/;
|
|
155
|
+
/** Imports that create the browser client, which writes the session cookie in the browser. */
|
|
156
|
+
const BROWSER_SIDE = /from\s+["']better-supabase\/client["']|\bcreateBrowserClient\s*\(/;
|
|
157
|
+
/** Imports that read or refresh the session cookie on the server. */
|
|
158
|
+
const SERVER_SIDE = /from\s+["']better-supabase\/(?:server|next|ssr|hono|orpc|edge|expo)["']|\bcreateServerClient\s*\(/;
|
|
159
|
+
function cookieSide(file) {
|
|
160
|
+
const browser = BROWSER_SIDE.test(file.text);
|
|
161
|
+
if (browser === SERVER_SIDE.test(file.text)) return;
|
|
162
|
+
return browser ? "browser" : "server";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Files on one side of the session cookie that set `encode: 'tokens-only'`
|
|
166
|
+
* while files on the other side don't (BS412).
|
|
167
|
+
*/
|
|
168
|
+
function encodingDrift(context) {
|
|
169
|
+
const sides = context.sources.flatMap((file) => {
|
|
170
|
+
const side = cookieSide(file);
|
|
171
|
+
return side ? [{
|
|
172
|
+
file,
|
|
173
|
+
side,
|
|
174
|
+
tokensOnly: TOKENS_ONLY.test(file.text)
|
|
175
|
+
}] : [];
|
|
176
|
+
});
|
|
177
|
+
const tokensOnly = sides.filter((entry) => entry.tokensOnly);
|
|
178
|
+
if (tokensOnly.length === 0) return [];
|
|
179
|
+
const findings = [];
|
|
180
|
+
for (const side of ["browser", "server"]) {
|
|
181
|
+
const set = tokensOnly.find((entry) => entry.side === side);
|
|
182
|
+
if (!set) continue;
|
|
183
|
+
const other = side === "browser" ? "server" : "browser";
|
|
184
|
+
const unset = sides.filter((entry) => entry.side === other && !entry.tokensOnly);
|
|
185
|
+
if (unset.length === 0) continue;
|
|
186
|
+
const line = lineOf(set.file.text, TOKENS_ONLY) ?? 1;
|
|
187
|
+
findings.push({
|
|
188
|
+
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.`,
|
|
189
|
+
target: `${set.file.path}:encode`,
|
|
190
|
+
location: {
|
|
191
|
+
file: set.file.path,
|
|
192
|
+
line
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return findings;
|
|
197
|
+
}
|
|
198
|
+
const COOKIE_RULES = [{
|
|
199
|
+
code: "BS412",
|
|
200
|
+
severity: "info",
|
|
201
|
+
title: "Session cookie encoding differs between server and browser",
|
|
202
|
+
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.",
|
|
203
|
+
check: encodingDrift
|
|
204
|
+
}];
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/cli/doctor/dependencies.ts
|
|
207
|
+
const DEPENDENCY_RULES = [{
|
|
208
|
+
code: "BS601",
|
|
209
|
+
severity: "warning",
|
|
210
|
+
title: "Peer outside its range",
|
|
211
|
+
description: "An optional peer of better-supabase is installed at a version outside the range better-supabase declares. With strict peer checks the install fails, and the subpath that needs the peer can break at runtime.",
|
|
212
|
+
check: (context) => (context.peers ?? []).flatMap((peer) => peer.installed !== void 0 && satisfies(peer.installed, peer.range) === false ? [{
|
|
213
|
+
message: `${peer.name}@${peer.installed} is installed, but better-supabase needs ${peer.range}. Install a version in that range, or remove ${peer.name} if nothing imports it.`,
|
|
214
|
+
target: peer.name
|
|
215
|
+
}] : [])
|
|
216
|
+
}];
|
|
217
|
+
//#endregion
|
|
218
|
+
//#region src/cli/doctor/flags.ts
|
|
219
|
+
/** Flags past their `expires_at` extra column that aren't archived. */
|
|
220
|
+
async function expiredFlags(context) {
|
|
221
|
+
const db = context.database;
|
|
222
|
+
if (!db || "skipped" in db) return [];
|
|
223
|
+
let query;
|
|
224
|
+
try {
|
|
225
|
+
query = expiredFlagsQuery(moduleLayout(context.config), context.config.blockNames);
|
|
226
|
+
} catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
if (!query) return [];
|
|
230
|
+
let rows;
|
|
231
|
+
try {
|
|
232
|
+
rows = await db.query(query);
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
return rows.map((row) => ({
|
|
237
|
+
message: `Flag "${row.key}" expired on ${row.expires_at}${row.owner ? ` (owner: ${row.owner})` : ""}. Remove its checks from the code, then archive it with \`flags.save("${row.key}", { archived: true })\`, or move \`expires_at\` when it stays.`,
|
|
238
|
+
target: `flags:${row.key}`
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
const FLAG_RULES = [{
|
|
242
|
+
code: "BS336",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
title: "Flag past its expiry",
|
|
245
|
+
description: "The flags table has an `expires_at` column (`blocks.flags.options.extraColumns`), and a flag on the live database that isn't archived has an `expires_at` in the past. A release flag that outlives its rollout keeps a dead branch in the code. The finding names the `owner` column when the table has one.",
|
|
246
|
+
check: expiredFlags
|
|
247
|
+
}];
|
|
248
|
+
//#endregion
|
|
249
|
+
//#region src/cli/doctor/reserved-roles.ts
|
|
250
|
+
/**
|
|
251
|
+
* supautils' `reserved_roles` on Supabase. Nobody but a superuser may alter,
|
|
252
|
+
* drop or change the memberships of these.
|
|
253
|
+
*/
|
|
254
|
+
const RESERVED_ROLES = /* @__PURE__ */ new Set([
|
|
255
|
+
"supabase_admin",
|
|
256
|
+
"supabase_auth_admin",
|
|
257
|
+
"supabase_storage_admin",
|
|
258
|
+
"supabase_functions_admin",
|
|
259
|
+
"supabase_read_only_user",
|
|
260
|
+
"supabase_realtime_admin",
|
|
261
|
+
"supabase_replication_admin",
|
|
262
|
+
"supabase_etl_admin",
|
|
263
|
+
"dashboard_user",
|
|
264
|
+
"pgbouncer"
|
|
265
|
+
]);
|
|
266
|
+
/** Reserved roles whose settings (`alter role ... set`) supautils allows changing. */
|
|
267
|
+
const SETTINGS_ONLY_ROLES = /* @__PURE__ */ new Set([
|
|
268
|
+
"authenticator",
|
|
269
|
+
"authenticated",
|
|
270
|
+
"anon",
|
|
271
|
+
"service_role"
|
|
272
|
+
]);
|
|
273
|
+
/**
|
|
274
|
+
* Roles that Supabase's own services log in as. Their statements (PostgREST's
|
|
275
|
+
* schema cache query, Auth and Storage migrations) aren't the app's.
|
|
276
|
+
*/
|
|
277
|
+
const PLATFORM_ROLES = /* @__PURE__ */ new Set([...RESERVED_ROLES, "authenticator"]);
|
|
278
|
+
/** Comments and string literals blanked, newlines kept so offsets map to lines. */
|
|
279
|
+
const sqlCode = (text) => text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, "").replaceAll(/'(?:[^']|'')*'/g, "''");
|
|
280
|
+
const roleNames = (list) => list.split(",").map((name) => name.trim().replaceAll("\"", "").toLowerCase()).filter((name) => name !== "");
|
|
281
|
+
/** Why a statement changes a reserved role, or undefined when it doesn't. */
|
|
282
|
+
function reservedRoleChange(statement) {
|
|
283
|
+
const alter = /^\s*(alter|drop)\s+(?:role|user)\s+(?:if\s+exists\s+)?("[^"]+"|\w+)\s*(\w*)/i.exec(statement);
|
|
284
|
+
if (alter) {
|
|
285
|
+
const role = roleNames(alter[2])[0];
|
|
286
|
+
const verb = alter[1].toLowerCase();
|
|
287
|
+
const next = alter[3].toLowerCase();
|
|
288
|
+
if (RESERVED_ROLES.has(role)) return `${verb}s the reserved role ${role}`;
|
|
289
|
+
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`;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
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);
|
|
293
|
+
if (!membership) return;
|
|
294
|
+
const role = [...roleNames(membership[2]), ...roleNames(membership[3])].find((name) => RESERVED_ROLES.has(name));
|
|
295
|
+
return role === void 0 ? void 0 : `${membership[1].toLowerCase()}s a membership of the reserved role ${role}`;
|
|
296
|
+
}
|
|
297
|
+
/** Where a statement ends, including statements inside `do` blocks and function bodies. */
|
|
298
|
+
const STATEMENT_BOUNDARY = /;|\$\w*\$|\b(?:begin|then|else|loop)\b/gi;
|
|
299
|
+
/** Statements in schema files and migrations that supautils rejects on Supabase. */
|
|
300
|
+
function reservedRoles(context) {
|
|
301
|
+
return (context.sqlFiles ?? []).flatMap((file) => {
|
|
302
|
+
const code = sqlCode(file.text);
|
|
303
|
+
const findings = [];
|
|
304
|
+
let start = 0;
|
|
305
|
+
for (const end of [...code.matchAll(STATEMENT_BOUNDARY), void 0]) {
|
|
306
|
+
const until = end?.index ?? code.length;
|
|
307
|
+
const statement = code.slice(start, until);
|
|
308
|
+
const reason = reservedRoleChange(statement);
|
|
309
|
+
if (reason !== void 0) {
|
|
310
|
+
const at = start + statement.search(/\S/);
|
|
311
|
+
const line = code.slice(0, at).split("\n").length;
|
|
312
|
+
findings.push({
|
|
313
|
+
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.`,
|
|
314
|
+
target: `${file.path}:${String(line)}`,
|
|
315
|
+
location: {
|
|
316
|
+
file: file.path,
|
|
317
|
+
line
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
start = until + (end?.[0].length ?? 0);
|
|
322
|
+
}
|
|
323
|
+
return findings;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/cli/doctor/live.ts
|
|
328
|
+
/** Statements slower than this (ms, mean) with more calls than `SLOW_CALLS` are BS209. */
|
|
329
|
+
const SLOW_MEAN_MS = 50;
|
|
330
|
+
const SLOW_CALLS = 1e3;
|
|
331
|
+
/** Rows `--explain` reads, like a `findMany()` under Supabase's default `max_rows`. */
|
|
332
|
+
const EXPLAIN_LIMIT = 1e3;
|
|
333
|
+
const escape$1 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
334
|
+
const ident$1 = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
335
|
+
const literal$1 = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
336
|
+
const oneLine = (query, max = 160) => {
|
|
337
|
+
const flat = query.replaceAll(/\s+/g, " ").trim();
|
|
338
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
339
|
+
};
|
|
340
|
+
const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
341
|
+
const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
342
|
+
/** The schema `pg_stat_statements` lives in, when the extension is installed. */
|
|
343
|
+
async function statementsView(db) {
|
|
344
|
+
const [row] = await db.query(`select n.nspname as schema from pg_extension e join pg_namespace n on n.oid = e.extnamespace where e.extname = 'pg_stat_statements'`);
|
|
345
|
+
return row ? `${ident$1(row.schema)}.pg_stat_statements` : void 0;
|
|
346
|
+
}
|
|
347
|
+
/** The first exposed table a statement names, to locate it in the SQL files. */
|
|
348
|
+
function tableIn(query, tables) {
|
|
349
|
+
return tables.find((table) => {
|
|
350
|
+
const schema = escape$1(table.schema);
|
|
351
|
+
const name = escape$1(table.name);
|
|
352
|
+
return new RegExp(`(?:"${schema}"|\\b${schema})\\.(?:"${name}"|${name}\\b)`).test(query);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const live = (context) => context.database;
|
|
356
|
+
/**
|
|
357
|
+
* Function calls and self time so far in this transaction. The view can
|
|
358
|
+
* still hold unflushed counts from earlier transactions, so callers diff two
|
|
359
|
+
* readings.
|
|
360
|
+
*/
|
|
361
|
+
async function functionTimes(db) {
|
|
362
|
+
const rows = await db.query(`select schemaname || '.' || funcname as name, calls, self_time from pg_stat_xact_user_functions`);
|
|
363
|
+
return new Map(rows.map((row) => [row.name, {
|
|
364
|
+
calls: Number(row.calls),
|
|
365
|
+
selfTime: Number(row.self_time)
|
|
366
|
+
}]));
|
|
367
|
+
}
|
|
368
|
+
/** Node types, timings and loops of an `EXPLAIN (ANALYZE, FORMAT JSON)` plan; never rows. */
|
|
369
|
+
function summarizePlan(output) {
|
|
370
|
+
const nodes = [];
|
|
371
|
+
const perRowSubPlans = [];
|
|
372
|
+
let initPlans = 0;
|
|
373
|
+
const walk = (node) => {
|
|
374
|
+
const loops = node["Actual Loops"] ?? 1;
|
|
375
|
+
const ms = (node["Actual Total Time"] ?? 0).toFixed(2);
|
|
376
|
+
const on = node["Relation Name"] ? ` on ${node["Relation Name"]}` : "";
|
|
377
|
+
const sub = node["Subplan Name"] ? `${node["Subplan Name"]}: ` : "";
|
|
378
|
+
nodes.push(`${sub}${node["Node Type"]}${on} ${ms} ms ×${loops}`);
|
|
379
|
+
if (node["Parent Relationship"] === "InitPlan") initPlans += 1;
|
|
380
|
+
if (node["Parent Relationship"] === "SubPlan" && loops > 1) perRowSubPlans.push(`${sub}${node["Node Type"]}${on} ×${loops}`);
|
|
381
|
+
for (const child of node.Plans ?? []) walk(child);
|
|
382
|
+
};
|
|
383
|
+
walk(output.Plan);
|
|
384
|
+
return {
|
|
385
|
+
nodes,
|
|
386
|
+
initPlans,
|
|
387
|
+
perRowSubPlans,
|
|
388
|
+
executionMs: output["Execution Time"] ?? 0
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
const ROLE = /^[a-z_][a-z0-9_]*$/;
|
|
392
|
+
async function explainTable(db, table, claims) {
|
|
393
|
+
const role = typeof claims["role"] === "string" ? claims["role"] : "anon";
|
|
394
|
+
if (!ROLE.test(role)) throw new Error(`Invalid role "${role}" in the claims`);
|
|
395
|
+
await db.query("begin");
|
|
396
|
+
try {
|
|
397
|
+
await db.query(`set local statement_timeout = '30s'`);
|
|
398
|
+
let tracked = true;
|
|
399
|
+
await db.query("savepoint bs_track");
|
|
400
|
+
try {
|
|
401
|
+
await db.query(`set local track_functions = 'all'`);
|
|
402
|
+
await db.query("release savepoint bs_track");
|
|
403
|
+
} catch {
|
|
404
|
+
tracked = false;
|
|
405
|
+
await db.query("rollback to savepoint bs_track");
|
|
406
|
+
}
|
|
407
|
+
await db.query(`select set_config('request.jwt.claims', ${literal$1(JSON.stringify(claims))}, true)`);
|
|
408
|
+
const before = tracked ? await functionTimes(db) : /* @__PURE__ */ new Map();
|
|
409
|
+
await db.query(`set local role ${ident$1(role)}`);
|
|
410
|
+
const [row] = await db.query(`explain (analyze, buffers, format json) select * from ${ident$1(table.schema)}.${ident$1(table.name)} limit ${EXPLAIN_LIMIT}`);
|
|
411
|
+
const raw = row?.["QUERY PLAN"];
|
|
412
|
+
const plan = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
413
|
+
if (!plan?.[0]) throw new Error("EXPLAIN returned no plan");
|
|
414
|
+
const functions = tracked ? [...await functionTimes(db)].map(([name, after]) => {
|
|
415
|
+
const start = before.get(name);
|
|
416
|
+
return {
|
|
417
|
+
name,
|
|
418
|
+
calls: after.calls - (start?.calls ?? 0),
|
|
419
|
+
selfTime: after.selfTime - (start?.selfTime ?? 0)
|
|
420
|
+
};
|
|
421
|
+
}).filter((fn) => fn.calls > 0).sort((a, b) => b.selfTime - a.selfTime) : void 0;
|
|
422
|
+
return {
|
|
423
|
+
summary: summarizePlan(plan[0]),
|
|
424
|
+
functions
|
|
425
|
+
};
|
|
426
|
+
} finally {
|
|
427
|
+
await db.query("rollback");
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function resolveTable(context, name) {
|
|
431
|
+
return name.includes(".") ? catalogOf(context).tables.find((table) => qualified(table) === name) : exposed(context).find((table) => table.name === name);
|
|
432
|
+
}
|
|
433
|
+
const LIVE_RULES = [
|
|
434
|
+
{
|
|
435
|
+
code: "BS208",
|
|
436
|
+
severity: "warning",
|
|
437
|
+
title: "Queries spill to temporary files",
|
|
438
|
+
description: "Sorts and hashes that exceed `work_mem` write temporary files, which is slow. Reads `pg_stat_database` and, when installed, the top statements from `pg_stat_statements`. When every statement that spilled ran as a Supabase service role (PostgREST's schema cache query as `authenticator`, Auth or Storage) or came from better-supabase's own catalog reads (`introspect`, `gen`, `doctor`), there is no finding.",
|
|
439
|
+
async check(context) {
|
|
440
|
+
const db = live(context);
|
|
441
|
+
if (!db || "skipped" in db) return [];
|
|
442
|
+
try {
|
|
443
|
+
const [stats] = await db.query(`select temp_files, temp_bytes, current_setting('work_mem') as work_mem from pg_stat_database where datname = current_database()`);
|
|
444
|
+
const files = Number(stats?.temp_files ?? 0);
|
|
445
|
+
if (files === 0) return [];
|
|
446
|
+
let top = "";
|
|
447
|
+
const view = await statementsView(db);
|
|
448
|
+
if (view) {
|
|
449
|
+
const spilled = await db.query(`select s.query, s.temp_blks_written, r.rolname as role from ${view} s left join pg_roles r on r.oid = s.userid where s.temp_blks_written > 0 order by s.temp_blks_written desc limit 50`).catch(() => []);
|
|
450
|
+
const statements = spilled.filter((statement) => !statement.query.startsWith("/* better-supabase */") && (statement.role === null || !PLATFORM_ROLES.has(statement.role))).slice(0, 5);
|
|
451
|
+
if (spilled.length > 0 && statements.length === 0) return [];
|
|
452
|
+
if (statements.length > 0) top = ` Top statements by temporary blocks written: ${statements.map((statement) => `${oneLine(statement.query, 100)} (${statement.temp_blks_written} blocks)`).join("; ")}.`;
|
|
453
|
+
}
|
|
454
|
+
return [{
|
|
455
|
+
message: `${files} temporary files (${megabytes(Number(stats?.temp_bytes ?? 0))}) since the statistics were reset; work_mem is ${stats?.work_mem}. Add indexes so large sorts go away, or raise work_mem for the role that runs them.${top}`,
|
|
456
|
+
target: "pg_stat_database:temp_files"
|
|
457
|
+
}];
|
|
458
|
+
} catch (cause) {
|
|
459
|
+
return [{
|
|
460
|
+
severity: "info",
|
|
461
|
+
message: `Could not read pg_stat_database (${db.describe}): ${errorText(cause)}`
|
|
462
|
+
}];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
code: "BS209",
|
|
468
|
+
severity: "warning",
|
|
469
|
+
title: "Slow frequent statements",
|
|
470
|
+
description: `Statements from \`pg_stat_statements\` with a mean time above ${SLOW_MEAN_MS} ms and more than ${SLOW_CALLS} calls. Runs with \`--stats\`.`,
|
|
471
|
+
async check(context) {
|
|
472
|
+
if (!context.stats) return [];
|
|
473
|
+
const db = live(context);
|
|
474
|
+
if (!db) return [];
|
|
475
|
+
if ("skipped" in db) return [{
|
|
476
|
+
severity: "info",
|
|
477
|
+
message: `Skipped --stats: ${db.skipped}`
|
|
478
|
+
}];
|
|
479
|
+
try {
|
|
480
|
+
const view = await statementsView(db);
|
|
481
|
+
if (!view) return [{
|
|
482
|
+
severity: "info",
|
|
483
|
+
message: "pg_stat_statements is not installed, so --stats has nothing to read. Run `create extension pg_stat_statements with schema extensions;`."
|
|
484
|
+
}];
|
|
485
|
+
const statements = await db.query(`select queryid::text as queryid, query, calls, mean_exec_time from ${view} where calls > ${SLOW_CALLS} and mean_exec_time > ${SLOW_MEAN_MS} order by total_exec_time desc limit 20`);
|
|
486
|
+
const tables = exposed(context);
|
|
487
|
+
return statements.map((statement) => {
|
|
488
|
+
const table = tableIn(statement.query, tables);
|
|
489
|
+
return {
|
|
490
|
+
message: `${Number(statement.mean_exec_time).toFixed(1)} ms mean over ${statement.calls} calls: ${oneLine(statement.query)}`,
|
|
491
|
+
target: `pg_stat_statements:${statement.queryid}`,
|
|
492
|
+
...table ? { object: tableObject(table) } : {}
|
|
493
|
+
};
|
|
494
|
+
});
|
|
495
|
+
} catch (cause) {
|
|
496
|
+
return [{
|
|
497
|
+
severity: "info",
|
|
498
|
+
message: `Could not read pg_stat_statements (${db.describe}): ${errorText(cause)}`
|
|
499
|
+
}];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
code: "BS225",
|
|
505
|
+
severity: "info",
|
|
506
|
+
title: "pg_stat_statements is not installed",
|
|
507
|
+
description: "`pg_stat_statements` records the time and calls of each statement, which `--stats` (BS209), BS208 and the Supabase query performance report read. Without it there is no record of which queries are slow. Needs a database; saved snapshots skip it.",
|
|
508
|
+
async check(context) {
|
|
509
|
+
const db = live(context);
|
|
510
|
+
if (!db || "skipped" in db) return [];
|
|
511
|
+
if (context.stats && context.codes?.includes("BS209")) return [];
|
|
512
|
+
try {
|
|
513
|
+
if (await statementsView(db)) return [];
|
|
514
|
+
} catch {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
return [{
|
|
518
|
+
message: `pg_stat_statements is not installed on the ${db.describe}, so nothing records which statements are slow. Run \`create extension if not exists pg_stat_statements with schema extensions;\` in a migration.`,
|
|
519
|
+
target: "extension:pg_stat_statements"
|
|
520
|
+
}];
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
code: "BS212",
|
|
525
|
+
severity: "info",
|
|
526
|
+
title: "RLS plan for a table",
|
|
527
|
+
description: `\`--explain <tables>\` runs \`EXPLAIN (ANALYZE, BUFFERS)\` on \`select * from <table> limit ${EXPLAIN_LIMIT}\` as the given claims, in a transaction that is rolled back. It reports node types, timings and loops, never rows, and warns when a policy SubPlan runs once per row.`,
|
|
528
|
+
async check(context) {
|
|
529
|
+
const request = context.explain;
|
|
530
|
+
if (!request) return [];
|
|
531
|
+
const db = live(context);
|
|
532
|
+
if (!db || "skipped" in db || !db.session) return [{
|
|
533
|
+
severity: "warning",
|
|
534
|
+
message: `--explain needs a direct database connection (local stack, $DATABASE_URL, $SUPABASE_DB_URL or --db-url-stdin)${db && "skipped" in db ? `: ${db.skipped}` : "."}`
|
|
535
|
+
}];
|
|
536
|
+
const who = typeof request.claims["sub"] === "string" ? `${String(request.claims["role"] ?? "anon")} ${request.claims["sub"]}` : String(request.claims["role"] ?? "anon");
|
|
537
|
+
const findings = [];
|
|
538
|
+
for (const name of request.tables) {
|
|
539
|
+
const table = resolveTable(context, name);
|
|
540
|
+
if (!table) {
|
|
541
|
+
findings.push({
|
|
542
|
+
severity: "warning",
|
|
543
|
+
message: `--explain: no table "${name}" in the exposed schemas; qualify tables in other schemas, like better_supabase.memberships.`
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const { summary, functions } = await explainTable(db, table, request.claims);
|
|
549
|
+
const helpers = functions ? functions.length > 0 ? ` Function time: ${functions.map((fn) => {
|
|
550
|
+
const self = fn.selfTime;
|
|
551
|
+
const share = summary.executionMs > 0 ? Math.round(self / summary.executionMs * 100) : 0;
|
|
552
|
+
return `${fn.name} ${self.toFixed(2)} ms over ${plural$1(fn.calls, "call")} (${share}%)`;
|
|
553
|
+
}).join(", ")}.` : " No tracked function ran; inlined SQL functions are part of the plan." : " Function times need `track_functions`, which this role may not set; showing the plan only.";
|
|
554
|
+
const perRow = summary.perRowSubPlans.length > 0 ? ` Per-row SubPlans: ${summary.perRowSubPlans.join(", ")}; wrap the policy's function calls in \`(select ...)\` so they run once as an InitPlan.` : "";
|
|
555
|
+
findings.push({
|
|
556
|
+
...summary.perRowSubPlans.length > 0 ? { severity: "warning" } : {},
|
|
557
|
+
message: `${qualified(table)} as ${who}: ${summary.executionMs.toFixed(2)} ms, ${plural$1(summary.initPlans, "InitPlan")}. Plan: ${summary.nodes.join(" > ")}.${perRow}${helpers}`,
|
|
558
|
+
target: `${qualified(table)}:explain`,
|
|
559
|
+
object: tableObject(table)
|
|
560
|
+
});
|
|
561
|
+
} catch (cause) {
|
|
562
|
+
findings.push({
|
|
563
|
+
severity: "warning",
|
|
564
|
+
message: `--explain ${qualified(table)} failed: ${errorText(cause)}`,
|
|
565
|
+
target: `${qualified(table)}:explain`
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return findings;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
];
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region src/cli/doctor/hooks.ts
|
|
575
|
+
/** Claims past this size make every request carry a large cookie and header. */
|
|
576
|
+
const HOOK_CLAIMS_LIMIT = 2048;
|
|
577
|
+
/**
|
|
578
|
+
* The BS405 limits. With a provider hook budget, `doctor.claimsLimit` (else
|
|
579
|
+
* the budget's bytes) limits its claims and the whole token keeps 2048;
|
|
580
|
+
* without one, `doctor.claimsLimit` limits the whole token.
|
|
581
|
+
*/
|
|
582
|
+
function claimsLimits(context) {
|
|
583
|
+
const configured = context.config.doctor.claimsLimit;
|
|
584
|
+
const budget = providerHook(context)?.budget;
|
|
585
|
+
return budget ? {
|
|
586
|
+
token: HOOK_CLAIMS_LIMIT,
|
|
587
|
+
budget: {
|
|
588
|
+
bytes: configured ?? budget.bytes,
|
|
589
|
+
claims: budget.claims,
|
|
590
|
+
...budget.truncatedClaim ? { truncatedClaim: budget.truncatedClaim } : {}
|
|
591
|
+
}
|
|
592
|
+
} : { token: configured ?? HOOK_CLAIMS_LIMIT };
|
|
593
|
+
}
|
|
594
|
+
/** The module function that fills the `memberships` claim. */
|
|
595
|
+
const MODULE_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
|
|
596
|
+
const providerHook = (context) => context.config.authorization?.tokenHook;
|
|
597
|
+
const splitName = (qualified) => {
|
|
598
|
+
const dot = qualified.indexOf(".");
|
|
599
|
+
return {
|
|
600
|
+
schema: qualified.slice(0, dot),
|
|
601
|
+
name: qualified.slice(dot + 1)
|
|
602
|
+
};
|
|
603
|
+
};
|
|
604
|
+
const escapeRegExp$1 = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
605
|
+
/**
|
|
606
|
+
* The claims the provider's hook writes that a second writer would
|
|
607
|
+
* contradict, with the tenant claim it writes to.
|
|
608
|
+
*/
|
|
609
|
+
function ownedClaims(hook) {
|
|
610
|
+
return [.../* @__PURE__ */ new Set([...hook.ownedClaims, ...hook.tenantClaim ? [hook.tenantClaim] : []])];
|
|
611
|
+
}
|
|
612
|
+
const quotedName = (schema, name) => `(?:"?${escapeRegExp$1(schema)}"?\\s*\\.\\s*)?"?${escapeRegExp$1(name)}"?`;
|
|
613
|
+
const createsFunction = (fn) => new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
|
|
614
|
+
/** The SQL file that has the provider's hook marker and creates `fn`. */
|
|
615
|
+
function markerFileFor(context, fn) {
|
|
616
|
+
const marker = providerHook(context)?.markers?.hook;
|
|
617
|
+
if (marker === void 0) return;
|
|
618
|
+
const creates = createsFunction(fn);
|
|
619
|
+
return (context.sqlFiles ?? []).find((file) => file.text.includes(marker) && creates.test(file.text));
|
|
620
|
+
}
|
|
621
|
+
/** Whether `fn` is the provider's hook: by its name or its marker file. */
|
|
622
|
+
function isProviderHook(context, fn) {
|
|
623
|
+
const hook = providerHook(context);
|
|
624
|
+
if (!hook) return false;
|
|
625
|
+
if (hook.function === `${fn.schema}.${fn.name}`) return true;
|
|
626
|
+
return markerFileFor(context, fn) !== void 0;
|
|
627
|
+
}
|
|
628
|
+
/** Whether the hook body calls the provider's hook (a wrapper around it). */
|
|
629
|
+
function wrapsProviderHook(hook, source) {
|
|
630
|
+
const { schema, name } = splitName(hook.function);
|
|
631
|
+
return new RegExp(`${quotedName(schema, name)}\\s*\\(`, "i").test(source);
|
|
632
|
+
}
|
|
633
|
+
/** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
|
|
634
|
+
function writtenClaims(source, claims) {
|
|
635
|
+
return claims.filter((claim) => {
|
|
636
|
+
const name = escapeRegExp$1(claim);
|
|
637
|
+
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);
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
const API_ROLES$1 = ["authenticated", "anon"];
|
|
641
|
+
/** Hooks enabled in `config.toml`, matched with their introspected functions. */
|
|
642
|
+
function configuredHooks(context) {
|
|
643
|
+
if (!context.configToml) return [];
|
|
644
|
+
const introspected = context.snapshot.extras.hooks ?? [];
|
|
645
|
+
return pgFunctionHooks(context.configToml.document).map((config) => ({
|
|
646
|
+
config,
|
|
647
|
+
extras: introspected.find((hook) => hook.hook === config.hook && hook.schema === config.schema && hook.name === config.name)
|
|
648
|
+
}));
|
|
649
|
+
}
|
|
650
|
+
function hookLocation(context, hook) {
|
|
651
|
+
if (!context.configToml) return;
|
|
652
|
+
const line = lineOf(context.configToml.text, new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`));
|
|
653
|
+
return line ? {
|
|
654
|
+
file: context.configToml.path,
|
|
655
|
+
line
|
|
656
|
+
} : void 0;
|
|
657
|
+
}
|
|
658
|
+
const signatureOf = (fn) => `${fn.schema}.${fn.name}(${fn.signature})`;
|
|
659
|
+
/** What is wrong with who may call a hook function, and the SQL that fixes it. */
|
|
660
|
+
function grantProblems(fn) {
|
|
661
|
+
const problems = [];
|
|
662
|
+
const fix = [];
|
|
663
|
+
if (!fn.schemaUsage.includes("supabase_auth_admin")) {
|
|
664
|
+
problems.push(`supabase_auth_admin has no usage on schema ${fn.schema}`);
|
|
665
|
+
fix.push(`grant usage on schema ${fn.schema} to supabase_auth_admin;`);
|
|
666
|
+
}
|
|
667
|
+
if (!fn.execute.includes("supabase_auth_admin")) {
|
|
668
|
+
problems.push("supabase_auth_admin may not execute it");
|
|
669
|
+
fix.push(`grant execute on function ${signatureOf(fn)} to supabase_auth_admin;`);
|
|
670
|
+
}
|
|
671
|
+
const exposedTo = API_ROLES$1.filter((role) => fn.execute.includes(role));
|
|
672
|
+
if (exposedTo.length > 0 || fn.publicExecute) {
|
|
673
|
+
const roles = [...exposedTo, ...fn.publicExecute ? ["public"] : []];
|
|
674
|
+
problems.push(`${roles.join(", ")} may execute it, so any client can call it through the API`);
|
|
675
|
+
fix.push(`revoke execute on function ${signatureOf(fn)} from authenticated, anon, public;`);
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
problems,
|
|
679
|
+
fix
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const MIGRATIONS = /(?:^|\/)supabase\/migrations\//;
|
|
683
|
+
/**
|
|
684
|
+
* The file that grants `fn` to `supabase_auth_admin`: a migration with the
|
|
685
|
+
* provider's grants marker; under pg-delta, which carries grants, the
|
|
686
|
+
* declarative schema file that defines `fn`; and without declarative schemas,
|
|
687
|
+
* the migration that defines it. `supabase db diff` drops grants, so migra
|
|
688
|
+
* only counts the marked migration.
|
|
689
|
+
*/
|
|
690
|
+
function grantsSourceFor(context, fn, workflow) {
|
|
691
|
+
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");
|
|
692
|
+
const creates = createsFunction(fn);
|
|
693
|
+
const marker = providerHook(context)?.markers?.grants;
|
|
694
|
+
return (context.sqlFiles ?? []).find((file) => grants.test(file.text) && (marker !== void 0 && file.text.includes(marker) || workflow === "pg-delta" && !MIGRATIONS.test(file.path) && creates.test(file.text) || workflow === "migrations" && MIGRATIONS.test(file.path) && creates.test(file.text)))?.path;
|
|
695
|
+
}
|
|
696
|
+
/** Configured hook functions whose grants are wrong (BS404, `doctor --fix-grants`). */
|
|
697
|
+
function hookGrantProblems(context) {
|
|
698
|
+
const workflow = workflowOf(context);
|
|
699
|
+
return configuredHooks(context).flatMap(({ config, extras }) => (extras?.functions ?? []).flatMap((fn) => {
|
|
700
|
+
const { problems, fix } = grantProblems(fn);
|
|
701
|
+
if (problems.length === 0) return [];
|
|
702
|
+
const file = grantsSourceFor(context, fn, workflow);
|
|
703
|
+
const provider = context.config.authorization;
|
|
704
|
+
const command = provider?.tokenHook?.grantsCommand;
|
|
705
|
+
return [{
|
|
706
|
+
hook: config.hook,
|
|
707
|
+
fn,
|
|
708
|
+
problems,
|
|
709
|
+
fix: file ? {
|
|
710
|
+
kind: "migration",
|
|
711
|
+
file
|
|
712
|
+
} : provider && isProviderHook(context, fn) ? {
|
|
713
|
+
kind: "provider",
|
|
714
|
+
name: providerLabel(provider),
|
|
715
|
+
...command ? { command } : {}
|
|
716
|
+
} : {
|
|
717
|
+
kind: "sql",
|
|
718
|
+
sql: fix
|
|
719
|
+
}
|
|
720
|
+
}];
|
|
721
|
+
}));
|
|
722
|
+
}
|
|
723
|
+
/** The sentence that tells the reader how to apply `fix`. */
|
|
724
|
+
function grantFixText(fix, workflow) {
|
|
725
|
+
switch (fix.kind) {
|
|
726
|
+
case "sql": return `${grantFixPlace(workflow)} (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}`;
|
|
727
|
+
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.`;
|
|
728
|
+
case "migration": return workflow === "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\`).`;
|
|
729
|
+
default: return fix;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/** Where the grants go under `workflow`. */
|
|
733
|
+
function grantFixPlace(workflow) {
|
|
734
|
+
switch (workflow) {
|
|
735
|
+
case "pg-delta": return "Add to the schema file that defines the function, then run `supabase db schema declarative sync`";
|
|
736
|
+
case "migra": return "Append to the migration `supabase db diff` wrote";
|
|
737
|
+
case "migrations": return "Add to a new migration (`supabase migration new auth_hook_grants`), then apply it (`supabase migration up`)";
|
|
738
|
+
default: return workflow;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/** `doctor --fix-grants`: the SQL block for every BS404 problem, as one migration snippet. */
|
|
742
|
+
function hookGrantBlock(problems, workflow = "pg-delta") {
|
|
743
|
+
if (problems.length === 0) return "-- Every configured Auth hook function has its grants.";
|
|
744
|
+
const lines = ["-- Auth hook grants (better-supabase doctor --fix-grants).", workflow === "migra" ? "-- `supabase db diff` does not carry function grants; append this to its migration." : `-- ${grantFixPlace(workflow)}.`];
|
|
745
|
+
for (const { fn, hook, fix } of problems) {
|
|
746
|
+
const name = `${signatureOf(fn)} ([auth.hook.${hook}])`;
|
|
747
|
+
switch (fix.kind) {
|
|
748
|
+
case "sql":
|
|
749
|
+
lines.push("", `-- ${name}`, ...fix.sql);
|
|
750
|
+
break;
|
|
751
|
+
case "provider":
|
|
752
|
+
lines.push("", `-- ${name} is the hook of ${fix.name}${fix.command ? `: ${fix.command}` : ""}`);
|
|
753
|
+
break;
|
|
754
|
+
case "migration":
|
|
755
|
+
lines.push("", workflow === "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\`.`);
|
|
756
|
+
break;
|
|
757
|
+
default: return fix;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return lines.join("\n");
|
|
761
|
+
}
|
|
762
|
+
const emptySearchPath = (fn) => {
|
|
763
|
+
const value = fn.settings["search_path"];
|
|
764
|
+
return value === "" || value === "\"\"" || value === "''";
|
|
765
|
+
};
|
|
766
|
+
/** Auth's issuer shape (`{url}/auth/v1`); doctor has no project URL to put in it. */
|
|
767
|
+
const DOCTOR_ISSUER = "https://doctor.invalid/auth/v1";
|
|
768
|
+
/**
|
|
769
|
+
* Runs the hook as Auth does for `userId` (as `supabase_auth_admin` when the
|
|
770
|
+
* connecting role may switch to it), in a transaction that is rolled back.
|
|
771
|
+
* Undefined when `auth.users` has no such user.
|
|
772
|
+
*/
|
|
773
|
+
async function withHookEvent(db, userId, run) {
|
|
774
|
+
await db.query("begin");
|
|
775
|
+
try {
|
|
776
|
+
await db.query(`set local statement_timeout = '10s'`);
|
|
777
|
+
const [user] = await db.query(`select set_config('better_supabase.hook_event', jsonb_build_object(
|
|
778
|
+
'user_id', u.id,
|
|
779
|
+
'authentication_method', 'password',
|
|
780
|
+
'claims', jsonb_build_object(
|
|
781
|
+
'aud', 'authenticated', 'role', 'authenticated', 'sub', u.id,
|
|
782
|
+
'email', coalesce(u.email, ''), 'phone', coalesce(u.phone, ''),
|
|
783
|
+
'app_metadata', coalesce(u.raw_app_meta_data, '{}'::jsonb),
|
|
784
|
+
'user_metadata', coalesce(u.raw_user_meta_data, '{}'::jsonb),
|
|
785
|
+
'aal', 'aal1', 'session_id', gen_random_uuid(),
|
|
786
|
+
'is_anonymous', coalesce(u.is_anonymous, false),
|
|
787
|
+
'amr', jsonb_build_array(jsonb_build_object(
|
|
788
|
+
'method', 'password', 'timestamp', extract(epoch from now())::int8
|
|
789
|
+
)),
|
|
790
|
+
'iss', ${literal$1(DOCTOR_ISSUER)},
|
|
791
|
+
'iat', extract(epoch from now())::int8,
|
|
792
|
+
'exp', extract(epoch from now())::int8 + 3600
|
|
793
|
+
)
|
|
794
|
+
)::text, true) as event
|
|
795
|
+
from auth.users u where u.id = ${literal$1(userId)}::uuid`);
|
|
796
|
+
if (!user?.event) return;
|
|
797
|
+
const [role] = await db.query(`select case when exists(select 1 from pg_roles where rolname = 'supabase_auth_admin')
|
|
798
|
+
then pg_has_role('supabase_auth_admin', 'member') else false end as member`);
|
|
799
|
+
if (role?.member) await db.query("set local role supabase_auth_admin");
|
|
800
|
+
return await run();
|
|
801
|
+
} finally {
|
|
802
|
+
await db.query("rollback");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const hookCall = (fn) => `${ident$1(fn.schema)}.${ident$1(fn.name)}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims'`;
|
|
806
|
+
/** The claims the hook returns for `userId`, or undefined without that user. */
|
|
807
|
+
async function hookClaims(db, fn, userId) {
|
|
808
|
+
return withHookEvent(db, userId, async () => {
|
|
809
|
+
const [row] = await db.query(`select ${hookCall(fn)} as claims`);
|
|
810
|
+
const claims = typeof row?.claims === "string" ? JSON.parse(row.claims) : row?.claims;
|
|
811
|
+
return isRecord(claims) ? claims : {};
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
async function hookClaimsSize(db, fn, userId, budget) {
|
|
815
|
+
const budgetSql = budget && budget.claims.length > 0 ? budget.claims.map((claim) => `coalesce(octet_length((c -> ${literal$1(claim)})::text), 0)`).join(" + ") : "0";
|
|
816
|
+
const truncatedSql = budget?.truncatedClaim ? `c ->> ${literal$1(budget.truncatedClaim)} = 'true'` : "false";
|
|
817
|
+
return withHookEvent(db, userId, async () => {
|
|
818
|
+
const [row] = await db.query(`select octet_length(c::text) as bytes,
|
|
819
|
+
octet_length((c -> 'memberships')::text) as memberships,
|
|
820
|
+
${budgetSql} as budget,
|
|
821
|
+
${truncatedSql} as truncated
|
|
822
|
+
from (select ${hookCall(fn)} as c) h`);
|
|
823
|
+
return {
|
|
824
|
+
bytes: Number(row?.bytes ?? 0),
|
|
825
|
+
memberships: Number(row?.memberships ?? 0),
|
|
826
|
+
budget: Number(row?.budget ?? 0),
|
|
827
|
+
truncated: row?.truncated === true
|
|
828
|
+
};
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const LOCAL_HOSTS = /* @__PURE__ */ new Set([
|
|
832
|
+
"localhost",
|
|
833
|
+
"127.0.0.1",
|
|
834
|
+
"[::1]",
|
|
835
|
+
"host.docker.internal"
|
|
836
|
+
]);
|
|
837
|
+
/** The `secrets = ...` line of `[auth.hook.<hook>]` reads an `env()` value. */
|
|
838
|
+
function secretsFromEnv(text, hook) {
|
|
839
|
+
const start = text.search(new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`, "m"));
|
|
840
|
+
if (start < 0) return false;
|
|
841
|
+
const rest = text.slice(start).split("\n").slice(1);
|
|
842
|
+
const end = rest.findIndex((line) => /^\s*\[/.test(line));
|
|
843
|
+
const section = (end === -1 ? rest : rest.slice(0, end)).join("\n");
|
|
844
|
+
return /^\s*secrets\s*=\s*["']env\(/m.test(section);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* What is wrong with one Standard Webhooks secret (`v1,whsec_<base64>`), the
|
|
848
|
+
* format Auth signs HTTP hook requests with. Never quotes the secret.
|
|
849
|
+
*/
|
|
850
|
+
function secretProblem(secret) {
|
|
851
|
+
const match = /^v1,whsec_([A-Za-z0-9+/]+={0,2})$/.exec(secret.trim());
|
|
852
|
+
if (!match) return "is not in the `v1,whsec_<base64>` format";
|
|
853
|
+
const bytes = atob(match[1]).length;
|
|
854
|
+
return bytes < 24 || bytes > 64 ? `decodes to ${bytes} bytes; Standard Webhooks secrets are 24 to 64` : void 0;
|
|
855
|
+
}
|
|
856
|
+
const HOOK_RULES = [
|
|
857
|
+
{
|
|
858
|
+
code: "BS404",
|
|
859
|
+
severity: "error",
|
|
860
|
+
title: "Auth hook function grants",
|
|
861
|
+
description: "Auth calls Postgres hook functions as `supabase_auth_admin`, which needs usage on the schema and execute on the function. Nobody else should be able to call them: a client calling the custom access token hook through the API sees what it adds for any user id.",
|
|
862
|
+
check: (context) => [...configuredHooks(context).flatMap(({ config, extras }) => {
|
|
863
|
+
if (!extras || extras.functions.length > 0) return [];
|
|
864
|
+
const location = hookLocation(context, config.hook);
|
|
865
|
+
return [{
|
|
866
|
+
message: `[auth.hook.${config.hook}] calls ${config.schema}.${config.name}, which does not exist. Sign-ins fail until it does.`,
|
|
867
|
+
target: `[auth.hook.${config.hook}]`,
|
|
868
|
+
...location ? { location } : {}
|
|
869
|
+
}];
|
|
870
|
+
}), ...hookGrantProblems(context).map(({ hook, fn, problems, fix }) => ({
|
|
871
|
+
message: `${signatureOf(fn)} ([auth.hook.${hook}]): ${problems.join("; ")}. ${grantFixText(fix, workflowOf(context))}`,
|
|
872
|
+
target: signatureOf(fn),
|
|
873
|
+
object: {
|
|
874
|
+
kind: "function",
|
|
875
|
+
schema: fn.schema,
|
|
876
|
+
name: fn.name
|
|
877
|
+
}
|
|
878
|
+
}))]
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
code: "BS405",
|
|
882
|
+
severity: "warning",
|
|
883
|
+
title: "Custom access token hook shape",
|
|
884
|
+
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.`,
|
|
885
|
+
async check(context) {
|
|
886
|
+
const findings = [];
|
|
887
|
+
const limits = claimsLimits(context);
|
|
888
|
+
for (const { config, extras } of configuredHooks(context)) {
|
|
889
|
+
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
890
|
+
for (const fn of extras.functions) {
|
|
891
|
+
const object = {
|
|
892
|
+
kind: "function",
|
|
893
|
+
schema: fn.schema,
|
|
894
|
+
name: fn.name
|
|
895
|
+
};
|
|
896
|
+
const problems = [];
|
|
897
|
+
if (fn.volatility !== "stable") problems.push(`it is ${fn.volatility}; declare it \`stable\``);
|
|
898
|
+
if (!emptySearchPath(fn)) problems.push("it has no `set search_path = ''`; add it and qualify every table");
|
|
899
|
+
if (problems.length > 0) findings.push({
|
|
900
|
+
message: `${signatureOf(fn)}: ${problems.join("; ")}.`,
|
|
901
|
+
target: signatureOf(fn),
|
|
902
|
+
object
|
|
903
|
+
});
|
|
904
|
+
if (!context.hookUser) continue;
|
|
905
|
+
const db = context.database;
|
|
906
|
+
if (!db || "skipped" in db || !db.session) {
|
|
907
|
+
findings.push({
|
|
908
|
+
severity: "info",
|
|
909
|
+
message: `Measuring the hook's claims needs a direct database connection (local stack, $DATABASE_URL, $SUPABASE_DB_URL or --db-url-stdin)${db && "skipped" in db ? `: ${db.skipped}` : "."}`,
|
|
910
|
+
target: `${signatureOf(fn)}:claims`
|
|
911
|
+
});
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
const size = await hookClaimsSize(db, fn, context.hookUser, limits.budget);
|
|
916
|
+
if (size === void 0) {
|
|
917
|
+
findings.push({
|
|
918
|
+
severity: "info",
|
|
919
|
+
message: `--as ${context.hookUser}: no such user in auth.users, so the hook was not called.`,
|
|
920
|
+
target: `${signatureOf(fn)}:claims`
|
|
921
|
+
});
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (limits.budget !== void 0 && size.budget > limits.budget.bytes) findings.push({
|
|
925
|
+
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.`,
|
|
926
|
+
target: `${signatureOf(fn)}:budget`,
|
|
927
|
+
object
|
|
928
|
+
});
|
|
929
|
+
if (size.bytes > limits.token) findings.push({
|
|
930
|
+
message: `${signatureOf(fn)} returns ${size.bytes} bytes of claims for ${context.hookUser} (limit ${limits.token}, memberships ${size.memberships}). Every request carries them twice (cookie and header); keep ids and roles in the token and look up the rest.`,
|
|
931
|
+
target: `${signatureOf(fn)}:claims`,
|
|
932
|
+
object
|
|
933
|
+
});
|
|
934
|
+
if (size.truncated) findings.push({
|
|
935
|
+
severity: "info",
|
|
936
|
+
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.`,
|
|
937
|
+
target: `${signatureOf(fn)}:truncated`,
|
|
938
|
+
object
|
|
939
|
+
});
|
|
940
|
+
} catch (cause) {
|
|
941
|
+
findings.push({
|
|
942
|
+
message: `Calling ${signatureOf(fn)} for ${context.hookUser} failed: ${errorText(cause)}`,
|
|
943
|
+
target: `${signatureOf(fn)}:claims`,
|
|
944
|
+
object
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return findings;
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
code: "BS407",
|
|
954
|
+
severity: "error",
|
|
955
|
+
title: "Two authorization hooks",
|
|
956
|
+
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.",
|
|
957
|
+
check: (context) => {
|
|
958
|
+
const provider = context.config.authorization;
|
|
959
|
+
const hook = provider?.tokenHook;
|
|
960
|
+
if (!provider || !hook) return [];
|
|
961
|
+
const label = providerLabel(provider);
|
|
962
|
+
const registered = new Map((hook.registeredClaims ?? []).map((claim) => [claim.name, claim.function]));
|
|
963
|
+
const owned = ownedClaims(hook);
|
|
964
|
+
const findings = [];
|
|
965
|
+
for (const { config, extras } of configuredHooks(context)) {
|
|
966
|
+
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
967
|
+
for (const fn of extras.functions) {
|
|
968
|
+
if (fn.source === void 0 || isProviderHook(context, fn)) continue;
|
|
969
|
+
const module = owned.includes("memberships") && MODULE_MEMBERSHIPS.test(fn.source);
|
|
970
|
+
const wrapper = wrapsProviderHook(hook, fn.source);
|
|
971
|
+
const written = writtenClaims(fn.source, [...owned, ...wrapper ? registered.keys() : []]);
|
|
972
|
+
if (!module && written.length === 0) continue;
|
|
973
|
+
const location = hookLocation(context, config.hook);
|
|
974
|
+
const what = [...module ? ["calls better_supabase.membership_claims"] : [], ...written.length > 0 ? [`writes ${written.join(", ")}`] : []].join(" and ");
|
|
975
|
+
const extra = written.filter((claim) => registered.has(claim));
|
|
976
|
+
const sources = extra.map((claim) => `${claim} from ${registered.get(claim)}`).join(", ");
|
|
977
|
+
findings.push({
|
|
978
|
+
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.`,
|
|
979
|
+
target: signatureOf(fn),
|
|
980
|
+
object: {
|
|
981
|
+
kind: "function",
|
|
982
|
+
schema: fn.schema,
|
|
983
|
+
name: fn.name
|
|
984
|
+
},
|
|
985
|
+
...location ? { location } : {}
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return findings;
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
code: "BS410",
|
|
994
|
+
severity: "warning",
|
|
995
|
+
title: "HTTP auth hooks",
|
|
996
|
+
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`.",
|
|
997
|
+
check: (context) => {
|
|
998
|
+
const toml = context.configToml;
|
|
999
|
+
if (!toml) return [];
|
|
1000
|
+
return httpHooks(toml.document).flatMap(({ hook, uri, secrets }) => {
|
|
1001
|
+
const location = hookLocation(context, hook);
|
|
1002
|
+
const at = location ? { location } : {};
|
|
1003
|
+
const target = `[auth.hook.${hook}]`;
|
|
1004
|
+
const findings = [{
|
|
1005
|
+
severity: "info",
|
|
1006
|
+
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.`,
|
|
1007
|
+
target,
|
|
1008
|
+
...at
|
|
1009
|
+
}];
|
|
1010
|
+
if (uri.protocol === "http:" && !LOCAL_HOSTS.has(uri.hostname)) findings.push({
|
|
1011
|
+
message: `${target} sends claims and its signature to ${uri.host} over plain http. Use https.`,
|
|
1012
|
+
target,
|
|
1013
|
+
...at
|
|
1014
|
+
});
|
|
1015
|
+
const fromEnv = secretsFromEnv(toml.text, hook);
|
|
1016
|
+
if (secrets === void 0 || secrets.trim() === "") {
|
|
1017
|
+
findings.push({
|
|
1018
|
+
severity: "error",
|
|
1019
|
+
message: `${target} has no \`secrets\`, so Auth can't sign its requests. Set \`secrets = "env(AUTH_HOOK_SECRET)"\` with a \`v1,whsec_<base64>\` value.`,
|
|
1020
|
+
target,
|
|
1021
|
+
...at
|
|
1022
|
+
});
|
|
1023
|
+
return findings;
|
|
1024
|
+
}
|
|
1025
|
+
if (!fromEnv) findings.push({
|
|
1026
|
+
message: `${target} has its secret written in ${toml.path}, which is committed. Move it to an env file and read it with \`secrets = "env(AUTH_HOOK_SECRET)"\`.`,
|
|
1027
|
+
target,
|
|
1028
|
+
...at
|
|
1029
|
+
});
|
|
1030
|
+
if (fromEnv && secrets.trim().startsWith("env(")) return findings;
|
|
1031
|
+
const problems = secrets.split("|").flatMap((secret, index) => {
|
|
1032
|
+
const problem = secretProblem(secret);
|
|
1033
|
+
return problem ? [`secret ${index + 1} ${problem}`] : [];
|
|
1034
|
+
});
|
|
1035
|
+
if (problems.length > 0) findings.push({
|
|
1036
|
+
severity: "error",
|
|
1037
|
+
message: `${target}: ${problems.join("; ")}. Auth rejects the config; generate one with \`openssl rand -base64 32\` and prefix it with \`v1,whsec_\`.`,
|
|
1038
|
+
target,
|
|
1039
|
+
...at
|
|
1040
|
+
});
|
|
1041
|
+
return findings;
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
];
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/cli/doctor/lite.ts
|
|
1048
|
+
const RPC_CALL = /(?:\.|\$)rpc\s*\(|\brpcTransport\s*\(/;
|
|
1049
|
+
const MISSING_FEATURES = [{
|
|
1050
|
+
feature: "Realtime",
|
|
1051
|
+
pattern: /from\s+["']better-supabase\/realtime["']|\.channel\s*\(|\.removeChannel\s*\(/
|
|
1052
|
+
}, {
|
|
1053
|
+
feature: "Edge Functions",
|
|
1054
|
+
pattern: /\.functions\.invoke\s*\(|\bfunctions\(\)\.invoke\s*\(/
|
|
1055
|
+
}];
|
|
1056
|
+
/** The `[db] driver` of a Supabase Lite project, or `undefined` for any other project. */
|
|
1057
|
+
function liteDriver(context) {
|
|
1058
|
+
try {
|
|
1059
|
+
return liteDriverOf(context.configToml);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
function rpcOnSqlite(context) {
|
|
1065
|
+
const driver = liteDriver(context);
|
|
1066
|
+
if (driver === void 0 || !isSqliteLiteDriver(driver)) return [];
|
|
1067
|
+
return context.sources.flatMap((file) => {
|
|
1068
|
+
const line = lineOf(file.text, RPC_CALL);
|
|
1069
|
+
if (line === void 0) return [];
|
|
1070
|
+
return [{
|
|
1071
|
+
message: `${file.path} calls a Postgres function, but Supabase Lite's ${driver} driver has no rpc(), so the call returns an unsupported error. Switch [db] driver to "pglite" or "postgres" in supabase/config.toml, or replace the function with table queries.`,
|
|
1072
|
+
target: `${file.path}:rpc`,
|
|
1073
|
+
location: {
|
|
1074
|
+
file: file.path,
|
|
1075
|
+
line
|
|
1076
|
+
}
|
|
1077
|
+
}];
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
function missingLiteFeature(context) {
|
|
1081
|
+
if (liteDriver(context) === void 0) return [];
|
|
1082
|
+
return context.sources.flatMap((file) => MISSING_FEATURES.flatMap(({ feature, pattern }) => {
|
|
1083
|
+
const line = lineOf(file.text, pattern);
|
|
1084
|
+
if (line === void 0) return [];
|
|
1085
|
+
return [{
|
|
1086
|
+
message: `${file.path} uses ${feature}, which Supabase Lite does not implement on any driver. Run the full Supabase stack for this feature, or move the project off Lite with \`supabase lite upgrade\`.`,
|
|
1087
|
+
target: `${file.path}:${feature}`,
|
|
1088
|
+
location: {
|
|
1089
|
+
file: file.path,
|
|
1090
|
+
line
|
|
1091
|
+
}
|
|
1092
|
+
}];
|
|
1093
|
+
}));
|
|
1094
|
+
}
|
|
1095
|
+
const LITE_RULES = [{
|
|
1096
|
+
code: "BS328",
|
|
1097
|
+
severity: "warning",
|
|
1098
|
+
title: "rpc() on a Supabase Lite SQLite driver",
|
|
1099
|
+
description: "`[db] driver` in `config.toml` is `sqlite-postgres` or `sqlite`, which have no `rpc()`, but the app calls a Postgres function. Those calls return an `unsupported` error.",
|
|
1100
|
+
check: rpcOnSqlite
|
|
1101
|
+
}, {
|
|
1102
|
+
code: "BS329",
|
|
1103
|
+
severity: "warning",
|
|
1104
|
+
title: "Feature Supabase Lite does not implement",
|
|
1105
|
+
description: "`config.toml` sets `[db] driver`, so the project runs on Supabase Lite, but the app uses Realtime or Edge Functions, which Lite does not implement.",
|
|
1106
|
+
check: missingLiteFeature
|
|
1107
|
+
}];
|
|
1108
|
+
//#endregion
|
|
1109
|
+
//#region src/cli/doctor/metadata.ts
|
|
1110
|
+
/**
|
|
1111
|
+
* Rules that read what a `GeneratorMetadata` document leaves out (policies,
|
|
1112
|
+
* grants, indexes, triggers, buckets, the realtime publication, foreign key
|
|
1113
|
+
* actions, multi-column constraints, role settings, hook functions). On a
|
|
1114
|
+
* snapshot built from such a document they would report what is missing
|
|
1115
|
+
* from the document, not from the database, so they skip with an info finding.
|
|
1116
|
+
*/
|
|
1117
|
+
const EXTRAS_RULES = /* @__PURE__ */ new Set([
|
|
1118
|
+
"BS103",
|
|
1119
|
+
"BS106",
|
|
1120
|
+
"BS107",
|
|
1121
|
+
"BS108",
|
|
1122
|
+
"BS109",
|
|
1123
|
+
"BS110",
|
|
1124
|
+
"BS111",
|
|
1125
|
+
"BS112",
|
|
1126
|
+
"BS113",
|
|
1127
|
+
"BS114",
|
|
1128
|
+
"BS115",
|
|
1129
|
+
"BS116",
|
|
1130
|
+
"BS117",
|
|
1131
|
+
"BS204",
|
|
1132
|
+
"BS205",
|
|
1133
|
+
"BS206",
|
|
1134
|
+
"BS207",
|
|
1135
|
+
"BS210",
|
|
1136
|
+
"BS211",
|
|
1137
|
+
"BS224",
|
|
1138
|
+
"BS213",
|
|
1139
|
+
"BS215",
|
|
1140
|
+
"BS216",
|
|
1141
|
+
"BS217",
|
|
1142
|
+
"BS218",
|
|
1143
|
+
"BS219",
|
|
1144
|
+
"BS301",
|
|
1145
|
+
"BS302",
|
|
1146
|
+
"BS303",
|
|
1147
|
+
"BS305",
|
|
1148
|
+
"BS306",
|
|
1149
|
+
"BS337",
|
|
1150
|
+
"BS338",
|
|
1151
|
+
"BS340",
|
|
1152
|
+
"BS310",
|
|
1153
|
+
"BS315",
|
|
1154
|
+
"BS404",
|
|
1155
|
+
"BS405",
|
|
1156
|
+
"BS406",
|
|
1157
|
+
"BS407"
|
|
1158
|
+
]);
|
|
1159
|
+
/** The info finding for a rule in `EXTRAS_RULES` that did not run, or `undefined` when it can. */
|
|
1160
|
+
function skippedForMetadata(snapshot, rule, help) {
|
|
1161
|
+
if (!snapshot.extras.fromMetadata || !EXTRAS_RULES.has(rule.code)) return;
|
|
1162
|
+
return {
|
|
1163
|
+
code: rule.code,
|
|
1164
|
+
severity: "info",
|
|
1165
|
+
title: rule.title,
|
|
1166
|
+
help,
|
|
1167
|
+
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.`
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
//#endregion
|
|
1171
|
+
//#region src/cli/doctor/module-files.ts
|
|
1172
|
+
/** The `blocks` files as this release renders them, next to what is on disk. */
|
|
1173
|
+
async function moduleFiles(context) {
|
|
1174
|
+
if (context.config.blockNames.length === 0 && Object.keys(context.config.appTables).length === 0) return [];
|
|
1175
|
+
const access = accessModule(context);
|
|
1176
|
+
if (access.kind === "invalid") return [];
|
|
1177
|
+
const readSets = context.readSets;
|
|
1178
|
+
const skipped = readSets !== void 0 && "skipped" in readSets;
|
|
1179
|
+
let rendered;
|
|
1180
|
+
try {
|
|
1181
|
+
rendered = renderModules(context.config.blockNames, await moduleLayoutFor(context.config, {
|
|
1182
|
+
names: context.config.blockNames,
|
|
1183
|
+
testsDir: context.config.sql.testsDir,
|
|
1184
|
+
readSets: skipped ? [] : readSets,
|
|
1185
|
+
entitlementsProvider: entitlementsModule(context),
|
|
1186
|
+
accessProvider: access.kind === "provider" ? access.access : void 0,
|
|
1187
|
+
permissionCatalog: permissionCatalogKeys(context)
|
|
1188
|
+
}));
|
|
1189
|
+
} catch {
|
|
1190
|
+
return [];
|
|
1191
|
+
}
|
|
1192
|
+
const files = rendered.filter((file) => !(skipped && file.module === "read-sets"));
|
|
1193
|
+
if (workflowOf(context) === "migrations") {
|
|
1194
|
+
const latest = await latestMigration(context.config, MODULES_SUFFIX);
|
|
1195
|
+
const versions = migrationModules(latest?.text ?? "");
|
|
1196
|
+
const path = latest?.path ?? `${migrationsDir(context.config)}/<stamp>_better_supabase_modules.sql`;
|
|
1197
|
+
return Promise.all(files.map(async (file) => {
|
|
1198
|
+
if (file.kind === "test") return fileState(context, file);
|
|
1199
|
+
const installed = versions.get(file.module);
|
|
1200
|
+
return {
|
|
1201
|
+
file,
|
|
1202
|
+
path,
|
|
1203
|
+
installed,
|
|
1204
|
+
current: latest === void 0 ? void 0 : migrationHolds(latest.text, file.contents) ? file.contents : latest.text,
|
|
1205
|
+
behind: file.kind === "schema" && installed !== void 0 && installed < (moduleFileVersion(file.contents)?.version ?? 1)
|
|
1206
|
+
};
|
|
1207
|
+
}));
|
|
1208
|
+
}
|
|
1209
|
+
return Promise.all(files.map((file) => fileState(context, file)));
|
|
1210
|
+
}
|
|
1211
|
+
async function fileState(context, file) {
|
|
1212
|
+
const current = await readFile(resolve(context.config.root, file.path), "utf-8").catch(() => void 0);
|
|
1213
|
+
const installed = current === void 0 ? void 0 : moduleFileVersion(current)?.version ?? 1;
|
|
1214
|
+
return {
|
|
1215
|
+
file,
|
|
1216
|
+
path: file.path,
|
|
1217
|
+
current,
|
|
1218
|
+
installed,
|
|
1219
|
+
behind: installed !== void 0 && installed < (moduleFileVersion(file.contents)?.version ?? 1)
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
//#endregion
|
|
1223
|
+
//#region src/cli/doctor/modules.ts
|
|
1224
|
+
/** The non-empty string at a dotted path, as the tenant() plugin reads it. */
|
|
1225
|
+
function claimAt(claims, path) {
|
|
1226
|
+
let value = claims;
|
|
1227
|
+
for (const segment of path.split(".")) value = isRecord(value) ? value[segment] : void 0;
|
|
1228
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
1229
|
+
}
|
|
1230
|
+
function contractChecks(context) {
|
|
1231
|
+
return customContracts(context.config.blockNames, moduleLayout(context.config, context.config.sql.testsDir, [], void 0, void 0, permissionCatalogKeys(context))).flatMap((contract) => contract.functions.map((fn) => ({
|
|
1232
|
+
module: contract.module,
|
|
1233
|
+
schema: contract.schema,
|
|
1234
|
+
name: fn.name,
|
|
1235
|
+
args: contractSignature(fn, contract.idType),
|
|
1236
|
+
returns: fn.returns.replaceAll("{id}", contract.idType)
|
|
1237
|
+
})));
|
|
1238
|
+
}
|
|
1239
|
+
const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1240
|
+
const ident = (name) => `"?${escapeRegExp(name)}"?`;
|
|
1241
|
+
/** Whether a `create function schema.name(` appears in the SQL files. */
|
|
1242
|
+
function declared(context, check) {
|
|
1243
|
+
const pattern = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${ident(check.schema)}\\.${ident(check.name)}\\s*\\(`, "i");
|
|
1244
|
+
return (context.sqlFiles ?? []).some((file) => pattern.test(file.text));
|
|
1245
|
+
}
|
|
1246
|
+
const normalize$1 = (types) => types.replaceAll(/\s+/g, " ").replaceAll(/\s*,\s*/g, ", ").trim();
|
|
1247
|
+
const PRE_REQUEST_HOOK = `select h.hook,
|
|
1248
|
+
coalesce(pg_catalog.strpos(pg_catalog.pg_get_functiondef(pg_catalog.to_regproc(h.hook)), 'better_supabase.check_request') > 0, false) as calls
|
|
1249
|
+
from (
|
|
1250
|
+
select (
|
|
1251
|
+
select pg_catalog.split_part(setting, '=', 2)
|
|
1252
|
+
from pg_catalog.pg_db_role_setting s
|
|
1253
|
+
join pg_catalog.pg_roles r on r.oid = s.setrole
|
|
1254
|
+
cross join lateral pg_catalog.unnest(s.setconfig) setting
|
|
1255
|
+
where r.rolname = 'authenticator' and s.setdatabase = 0
|
|
1256
|
+
and setting like 'pgrst.db_pre_request=%'
|
|
1257
|
+
limit 1
|
|
1258
|
+
) as hook
|
|
1259
|
+
) h`;
|
|
1260
|
+
/** `rate-limit` in `blocks` without PostgREST's pre-request hook reaching `check_request()`. */
|
|
1261
|
+
async function rateLimitHook(context) {
|
|
1262
|
+
const db = context.database;
|
|
1263
|
+
if (!context.config.blockNames.includes("rate-limit") || context.config.blocks["rate-limit"]?.options?.["preRequest"] === false || !db || "skipped" in db) return [];
|
|
1264
|
+
let rows;
|
|
1265
|
+
try {
|
|
1266
|
+
rows = await db.query(PRE_REQUEST_HOOK);
|
|
1267
|
+
} catch {
|
|
1268
|
+
return [];
|
|
1269
|
+
}
|
|
1270
|
+
const [row] = rows;
|
|
1271
|
+
if (!row || row.hook === "better_supabase.check_request" || row.calls) return [];
|
|
1272
|
+
return [{
|
|
1273
|
+
message: row.hook === null ? "pgrst.db_pre_request isn't set for authenticator, so better_supabase.check_request() never runs. Apply the migration `better-supabase sql data` writes, which sets it." : `pgrst.db_pre_request is ${row.hook}, which doesn't call better_supabase.check_request(), so rate limits never apply. Call it from ${row.hook}.`,
|
|
1274
|
+
target: "authenticator pgrst.db_pre_request"
|
|
1275
|
+
}];
|
|
1276
|
+
}
|
|
1277
|
+
const DANGLING_AUDIT = `select a.target::oid::text as oid
|
|
1278
|
+
from better_supabase.audited_tables a
|
|
1279
|
+
where pg_catalog.to_regclass('better_supabase.audited_tables') is not null
|
|
1280
|
+
and not exists (select 1 from pg_catalog.pg_class c where c.oid = a.target::oid)
|
|
1281
|
+
order by 1`;
|
|
1282
|
+
/** `better_supabase.audited_tables` rows whose table no longer exists. */
|
|
1283
|
+
async function danglingAuditRegistrations(context) {
|
|
1284
|
+
const db = context.database;
|
|
1285
|
+
if (!context.config.blockNames.includes("audit") || !db || "skipped" in db) return [];
|
|
1286
|
+
let rows;
|
|
1287
|
+
try {
|
|
1288
|
+
rows = await db.query(DANGLING_AUDIT);
|
|
1289
|
+
} catch {
|
|
1290
|
+
return [];
|
|
1291
|
+
}
|
|
1292
|
+
return rows.map((row) => ({
|
|
1293
|
+
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)\`.`,
|
|
1294
|
+
target: `better_supabase.audited_tables ${row.oid}`
|
|
1295
|
+
}));
|
|
1296
|
+
}
|
|
1297
|
+
/** Tables and columns adopt-mode modules use that the live database lacks. */
|
|
1298
|
+
async function missingAdoptedColumns(context) {
|
|
1299
|
+
const db = context.database;
|
|
1300
|
+
if (!db || "skipped" in db) return [];
|
|
1301
|
+
const tables = adoptedTables(context.config.blocks);
|
|
1302
|
+
if (tables.length === 0) return [];
|
|
1303
|
+
const names = tables.map((table) => `(${literal$1(table.schema)}, ${literal$1(table.table)})`).join(", ");
|
|
1304
|
+
let rows;
|
|
1305
|
+
try {
|
|
1306
|
+
rows = await db.query(`select table_schema as schema, table_name as name, column_name as column
|
|
1307
|
+
from information_schema.columns
|
|
1308
|
+
where (table_schema, table_name) in (${names})`);
|
|
1309
|
+
} catch {
|
|
1310
|
+
return [];
|
|
1311
|
+
}
|
|
1312
|
+
return tables.flatMap((table) => {
|
|
1313
|
+
const target = `${table.schema}.${table.table}`;
|
|
1314
|
+
const object = {
|
|
1315
|
+
kind: "table",
|
|
1316
|
+
schema: table.schema,
|
|
1317
|
+
name: table.table
|
|
1318
|
+
};
|
|
1319
|
+
const present = new Set(rows.filter((row) => row.schema === table.schema && row.name === table.table).map((row) => row.column));
|
|
1320
|
+
if (present.size === 0) return [{
|
|
1321
|
+
message: `blocks.${table.module} is in adopt mode and maps its ${table.logical} table to ${target}, which the database does not have. Create it, or point blocks.${table.module}.tables.${table.logical} at the existing table.`,
|
|
1322
|
+
target,
|
|
1323
|
+
object
|
|
1324
|
+
}];
|
|
1325
|
+
return Object.entries(table.columns).filter(([, column]) => !present.has(column)).map(([logical, column]) => ({
|
|
1326
|
+
message: `blocks.${table.module} is in adopt mode and uses ${target}.${column} (${table.logical}.${logical}), but the table has no such column. Map blocks.${table.module}.columns.${table.logical}.${logical} to the column that holds it, or to null when the module allows that.`,
|
|
1327
|
+
target: `${target}.${column}`,
|
|
1328
|
+
object
|
|
1329
|
+
}));
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
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.";
|
|
1333
|
+
/** Event triggers the `blocks` create that the database, or every migration, lacks. */
|
|
1334
|
+
async function missingEventTriggers(context) {
|
|
1335
|
+
const expected = moduleEventTriggers(context.config.blockNames);
|
|
1336
|
+
if (expected.length === 0) return [];
|
|
1337
|
+
const db = context.database;
|
|
1338
|
+
if (db && !("skipped" in db)) {
|
|
1339
|
+
let rows;
|
|
1340
|
+
try {
|
|
1341
|
+
rows = await db.query("select evtname as name from pg_catalog.pg_event_trigger");
|
|
1342
|
+
} catch {
|
|
1343
|
+
return [];
|
|
1344
|
+
}
|
|
1345
|
+
const present = new Set(rows.map((row) => row.name));
|
|
1346
|
+
return expected.filter((trigger) => !present.has(trigger.name)).map((trigger) => ({
|
|
1347
|
+
message: `The database has no event trigger ${trigger.name}, which the ${trigger.module} module creates. ${EVENT_TRIGGER_FIX}`,
|
|
1348
|
+
target: trigger.name
|
|
1349
|
+
}));
|
|
1350
|
+
}
|
|
1351
|
+
const toml = context.configToml;
|
|
1352
|
+
if (toml === void 0) return [];
|
|
1353
|
+
const migrations = (context.sqlFiles ?? []).filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1354
|
+
if (migrations.length === 0) return [];
|
|
1355
|
+
return expected.filter((trigger) => {
|
|
1356
|
+
const pattern = new RegExp(`create\\s+event\\s+trigger\\s+${ident(trigger.name)}\\s`, "i");
|
|
1357
|
+
return !migrations.some((file) => pattern.test(file.text));
|
|
1358
|
+
}).map((trigger) => ({
|
|
1359
|
+
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}`,
|
|
1360
|
+
target: trigger.name
|
|
1361
|
+
}));
|
|
1362
|
+
}
|
|
1363
|
+
/** Custom-mode modules whose contract functions the database or SQL files don't have. */
|
|
1364
|
+
async function missingContracts(context) {
|
|
1365
|
+
const checks = contractChecks(context);
|
|
1366
|
+
if (checks.length === 0) return [];
|
|
1367
|
+
const db = context.database;
|
|
1368
|
+
const live = db && !("skipped" in db) ? db : void 0;
|
|
1369
|
+
if (!live) return checks.filter((check) => !declared(context, check)).map((check) => ({
|
|
1370
|
+
message: `blocks.${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.`,
|
|
1371
|
+
target: `${check.schema}.${check.name}`,
|
|
1372
|
+
object: {
|
|
1373
|
+
kind: "function",
|
|
1374
|
+
schema: check.schema,
|
|
1375
|
+
name: check.name
|
|
1376
|
+
}
|
|
1377
|
+
}));
|
|
1378
|
+
const names = checks.map((check) => `(${literal$1(check.schema)}, ${literal$1(check.name)})`).join(", ");
|
|
1379
|
+
let rows;
|
|
1380
|
+
try {
|
|
1381
|
+
rows = await live.query(`select n.nspname as schema, p.proname as name,
|
|
1382
|
+
oidvectortypes(p.proargtypes) as args,
|
|
1383
|
+
format_type(p.prorettype, null) as returns
|
|
1384
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1385
|
+
where (n.nspname, p.proname) in (${names})`);
|
|
1386
|
+
} catch (cause) {
|
|
1387
|
+
return [{
|
|
1388
|
+
severity: "info",
|
|
1389
|
+
message: `Reading the custom module contract functions failed: ${errorText(cause)}`,
|
|
1390
|
+
target: "modules"
|
|
1391
|
+
}];
|
|
1392
|
+
}
|
|
1393
|
+
return checks.flatMap((check) => {
|
|
1394
|
+
const found = rows.filter((row) => row.schema === check.schema && row.name === check.name);
|
|
1395
|
+
const target = `${check.schema}.${check.name}`;
|
|
1396
|
+
const object = {
|
|
1397
|
+
kind: "function",
|
|
1398
|
+
schema: check.schema,
|
|
1399
|
+
name: check.name
|
|
1400
|
+
};
|
|
1401
|
+
if (found.length === 0) return [{
|
|
1402
|
+
message: `blocks.${check.module} is in custom mode, but ${target}(${check.args}) doesn't exist. Write it, or switch the module to adopt or managed.`,
|
|
1403
|
+
target,
|
|
1404
|
+
object
|
|
1405
|
+
}];
|
|
1406
|
+
const match = found.find((row) => normalize$1(row.args) === normalize$1(check.args));
|
|
1407
|
+
if (!match) return [{
|
|
1408
|
+
message: `${target} takes (${found.map((row) => row.args).join(" | ")}); the ${check.module} contract calls it with (${check.args}).`,
|
|
1409
|
+
target,
|
|
1410
|
+
object
|
|
1411
|
+
}];
|
|
1412
|
+
if (normalize$1(match.returns) !== normalize$1(check.returns)) return [{
|
|
1413
|
+
message: `${target}(${check.args}) returns ${match.returns}; the ${check.module} contract expects ${check.returns}.`,
|
|
1414
|
+
target,
|
|
1415
|
+
object
|
|
1416
|
+
}];
|
|
1417
|
+
return [];
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
/** With the tenant claim as the active tenant, the hook must put it in the token. */
|
|
1421
|
+
async function missingTenantClaim(context) {
|
|
1422
|
+
if (!context.config.blockNames.includes("tenant")) return [];
|
|
1423
|
+
if ((context.config.blocks.access?.activeTenant ?? DEFAULT_ACTIVE_TENANT) !== "claim") return [];
|
|
1424
|
+
const userId = context.hookUser;
|
|
1425
|
+
const db = context.database;
|
|
1426
|
+
if (!userId || !db || "skipped" in db || !db.session) return [];
|
|
1427
|
+
const claim = context.config.claims.tenant;
|
|
1428
|
+
const findings = [];
|
|
1429
|
+
for (const { config, extras } of configuredHooks(context)) {
|
|
1430
|
+
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
1431
|
+
for (const fn of extras.functions) {
|
|
1432
|
+
const target = `${signatureOf(fn)}:${claim}`;
|
|
1433
|
+
let claims;
|
|
1434
|
+
try {
|
|
1435
|
+
claims = await hookClaims(db, fn, userId);
|
|
1436
|
+
} catch {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
if (claims === void 0) continue;
|
|
1440
|
+
if (tenantClaimPaths(claim).some((path) => claimAt(claims, path) !== void 0)) continue;
|
|
1441
|
+
findings.push({
|
|
1442
|
+
message: `${signatureOf(fn)} returns no \`${claim}\` claim for ${userId} (top level or app_metadata), and blocks.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 blocks.access.activeTenant to 'resolver' (with ServerOptions.tenant) or a profile column.`,
|
|
1443
|
+
target,
|
|
1444
|
+
object: {
|
|
1445
|
+
kind: "function",
|
|
1446
|
+
schema: fn.schema,
|
|
1447
|
+
name: fn.name
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
return findings;
|
|
1453
|
+
}
|
|
1454
|
+
/** Where a deprecated symbol is used: a call, a qualified name or a claim lookup. */
|
|
1455
|
+
function deprecationPattern(entry) {
|
|
1456
|
+
const [first = "", second] = entry.symbol.split(".");
|
|
1457
|
+
const qualified = second === void 0 ? ident(first) : `${ident(first)}\\.${ident(second)}`;
|
|
1458
|
+
switch (entry.kind) {
|
|
1459
|
+
case "function": return new RegExp(`${qualified}\\s*\\(`, "i");
|
|
1460
|
+
case "table":
|
|
1461
|
+
case "column": return new RegExp(`(?<![\\w."])${qualified}(?![\\w"])`, "i");
|
|
1462
|
+
case "claim": {
|
|
1463
|
+
const name = escapeRegExp(entry.symbol);
|
|
1464
|
+
return new RegExp(`->>?\\s*'${name}'|#>>?\\s*'\\{[^}]*\\b${name}\\b[^}]*\\}'`, "i");
|
|
1465
|
+
}
|
|
1466
|
+
default: {
|
|
1467
|
+
const never = entry.kind;
|
|
1468
|
+
throw new TypeError(`Unknown deprecation kind ${String(never)}`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const bareWord = (name) => new RegExp(`(?<![\\w"])${ident(name)}(?![\\w"])`, "i");
|
|
1473
|
+
/**
|
|
1474
|
+
* The line where `text` uses a deprecated symbol. A column also counts
|
|
1475
|
+
* unqualified (`m.org_id`) in a statement that names its table, or in a
|
|
1476
|
+
* policy on that table.
|
|
1477
|
+
*/
|
|
1478
|
+
function lineOfUse(entry, text, policyTable) {
|
|
1479
|
+
const direct = lineOf(text, deprecationPattern(entry));
|
|
1480
|
+
if (direct !== void 0 || entry.kind !== "column") return direct;
|
|
1481
|
+
const [table = "", column = ""] = entry.symbol.split(".");
|
|
1482
|
+
let offset = 0;
|
|
1483
|
+
for (const statement of text.split(";")) {
|
|
1484
|
+
const named = policyTable === table || bareWord(table).test(statement);
|
|
1485
|
+
const at = statement.search(bareWord(column));
|
|
1486
|
+
if (named && at !== -1) return text.slice(0, offset + at).split("\n").length;
|
|
1487
|
+
offset += statement.length + 1;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
/** SQL files and policies that still use a symbol a SQL module deprecated or removed. */
|
|
1491
|
+
function deprecatedSymbols(context) {
|
|
1492
|
+
const configured = new Set(Object.values(context.config.claims));
|
|
1493
|
+
const entries = moduleDeprecations().filter((entry) => context.config.blockNames.includes(entry.module) && !(entry.kind === "claim" && configured.has(entry.symbol)) && !((entry.kind === "table" || entry.kind === "column") && (context.config.blocks[entry.module]?.mode ?? "managed") !== "managed"));
|
|
1494
|
+
if (entries.length === 0) return [];
|
|
1495
|
+
const files = (context.sqlFiles ?? []).filter((file) => !/(^|\/)supabase\/migrations\//.test(file.path) && moduleFileVersion(file.text) === void 0);
|
|
1496
|
+
const findings = [];
|
|
1497
|
+
for (const entry of entries) {
|
|
1498
|
+
const status = entry.removed === void 0 ? `deprecated since ${entry.since}` : `removed in ${entry.removed}`;
|
|
1499
|
+
const advice = `the ${entry.module} module's ${entry.kind} ${entry.symbol}, ${status}. Use ${entry.use}.`;
|
|
1500
|
+
for (const file of files) {
|
|
1501
|
+
const line = lineOfUse(entry, file.text);
|
|
1502
|
+
if (line === void 0) continue;
|
|
1503
|
+
findings.push({
|
|
1504
|
+
message: `${file.path} uses ${advice}`,
|
|
1505
|
+
target: `${file.path}:${entry.symbol}`,
|
|
1506
|
+
location: {
|
|
1507
|
+
file: file.path,
|
|
1508
|
+
line
|
|
1509
|
+
}
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
for (const table of catalogOf(context).tables) for (const policy of table.policies) {
|
|
1513
|
+
if (lineOfUse(entry, `${policy.using ?? ""}\n${policy.check ?? ""}`, table.name) === void 0) continue;
|
|
1514
|
+
findings.push({
|
|
1515
|
+
message: `Policy "${policy.name}" on ${qualified(table)} uses ${advice}`,
|
|
1516
|
+
target: `${qualified(table)}.${policy.name}:${entry.symbol}`,
|
|
1517
|
+
object: policyObject(table, policy)
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return findings;
|
|
1522
|
+
}
|
|
1523
|
+
/** Module triggers and the equivalent triggers they replace. */
|
|
1524
|
+
const EQUIVALENT_TRIGGERS = [{
|
|
1525
|
+
trigger: "bs_updated_at",
|
|
1526
|
+
call: "track_updated_at",
|
|
1527
|
+
pattern: /updated_at|moddatetime|touch/i
|
|
1528
|
+
}, {
|
|
1529
|
+
trigger: "bs_audit",
|
|
1530
|
+
call: "audit",
|
|
1531
|
+
pattern: /audit/i
|
|
1532
|
+
}];
|
|
1533
|
+
/** Tables where a module trigger and an older trigger do the same work. */
|
|
1534
|
+
function duplicateTriggers(context) {
|
|
1535
|
+
return catalogOf(context).tables.flatMap((table) => EQUIVALENT_TRIGGERS.flatMap(({ trigger, call, pattern }) => {
|
|
1536
|
+
if (!table.triggers.some((entry) => entry.name === trigger)) return [];
|
|
1537
|
+
return table.triggers.filter((entry) => entry.name !== trigger && pattern.test(entry.function.split(".").at(-1) ?? "")).map((entry) => ({
|
|
1538
|
+
message: `${qualified(table)} has ${trigger} and ${entry.name} (${entry.function}), so both run on every write. Drop ${entry.name}, or run \`select better_supabase.${call}('${qualified(table)}', replace_trigger => true)\` in a migration.`,
|
|
1539
|
+
target: `${qualified(table)}.${entry.name}`,
|
|
1540
|
+
object: tableObject(table)
|
|
1541
|
+
}));
|
|
1542
|
+
}));
|
|
1543
|
+
}
|
|
1544
|
+
/** `exempt` globs (`public.*_archive`): `*` matches any run of characters. */
|
|
1545
|
+
const globs = (value) => (Array.isArray(value) ? value : []).filter((entry) => typeof entry === "string").map((glob) => new RegExp(`^${glob.split("*").map(escapeRegExp).join(".*")}$`, "i"));
|
|
1546
|
+
/** The module schemas and the app tables SQL modules adopted, which the audit trigger skips. */
|
|
1547
|
+
function moduleOwned(context) {
|
|
1548
|
+
const schemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1549
|
+
const tables = /* @__PURE__ */ new Set();
|
|
1550
|
+
for (const module of Object.values(context.config.blocks)) {
|
|
1551
|
+
if (module?.schema !== void 0) schemas.add(module.schema);
|
|
1552
|
+
for (const table of Object.values(module?.tables ?? {})) {
|
|
1553
|
+
if (table === null) continue;
|
|
1554
|
+
tables.add(table.includes(".") ? table : `${module?.schema ?? "better_supabase"}.${table}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return {
|
|
1558
|
+
schemas,
|
|
1559
|
+
tables
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
/** Tables in `config.schemas` without the `bs_audit` trigger, when `audit` is in `blocks`. */
|
|
1563
|
+
function unauditedTables(context) {
|
|
1564
|
+
if (!context.config.blockNames.includes("audit")) return [];
|
|
1565
|
+
const exempt = globs(context.config.blocks["audit"]?.options?.["exempt"]);
|
|
1566
|
+
const owned = moduleOwned(context);
|
|
1567
|
+
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) => ({
|
|
1568
|
+
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 blocks.audit.options.exempt.`,
|
|
1569
|
+
target: qualified(table),
|
|
1570
|
+
object: tableObject(table)
|
|
1571
|
+
}));
|
|
1572
|
+
}
|
|
1573
|
+
/** RLS tables in `config.schemas` without a restrictive `session_active()` policy, when `sessions` is in `blocks`. */
|
|
1574
|
+
function sessionlessTables(context) {
|
|
1575
|
+
if (!context.config.blockNames.includes("sessions")) return [];
|
|
1576
|
+
const exclude = globs(context.config.blocks["sessions"]?.options?.["exclude"]);
|
|
1577
|
+
const owned = moduleOwned(context);
|
|
1578
|
+
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) => ({
|
|
1579
|
+
message: `${qualified(table)} has no restrictive session_active() policy, so a signed-out or revoked session keeps its access until the token expires. Set blocks.sessions.options.policies to true and run \`better-supabase sql sync\`, add the policy by hand, or list the table in blocks.sessions.options.exclude.`,
|
|
1580
|
+
target: qualified(table),
|
|
1581
|
+
object: tableObject(table)
|
|
1582
|
+
}));
|
|
1583
|
+
}
|
|
1584
|
+
/** `better_supabase` and every `blocks.*.schema` that `[api] schemas` serves through the Data API. */
|
|
1585
|
+
function exposedModuleSchemas(context) {
|
|
1586
|
+
if (context.config.blockNames.length === 0) return [];
|
|
1587
|
+
const moduleSchemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1588
|
+
for (const module of Object.values(context.config.blocks)) if (module?.schema !== void 0) moduleSchemas.add(module.schema);
|
|
1589
|
+
return exposedSchemas(context).filter((schema) => moduleSchemas.has(schema)).map((schema) => ({
|
|
1590
|
+
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 blocks.<module>.api to an exposed schema such as "api" so \`better-supabase add\` writes security invoker entry points there, and call them with rpcTransport(supabase, { schema: "api" }).`,
|
|
1591
|
+
target: schema
|
|
1592
|
+
}));
|
|
1593
|
+
}
|
|
1594
|
+
function migrationOptions(context) {
|
|
1595
|
+
const module = new Set(context.config.blockNames);
|
|
1596
|
+
return migrationOptionUses(context.config.blocks).filter((use) => module.has(use.module)).map((use) => ({
|
|
1597
|
+
message: `${use.message} It exists to adopt an existing schema; remove it once your data matches the managed default.`,
|
|
1598
|
+
target: `blocks.${use.module}.options.${use.option}`
|
|
1599
|
+
}));
|
|
1600
|
+
}
|
|
1601
|
+
/** A roles table shared by tenant and platform roles without `where` on both sides. */
|
|
1602
|
+
function sharedRoleTables(context) {
|
|
1603
|
+
if (context.config.blockNames.length === 0) return [];
|
|
1604
|
+
const access = accessModule(context);
|
|
1605
|
+
if (access.kind !== "provider") return [];
|
|
1606
|
+
let problems;
|
|
1607
|
+
try {
|
|
1608
|
+
problems = sharedRolesProblems(context.config.blockNames, moduleLayout(context.config, context.config.sql.testsDir, [], entitlementsModule(context), access.access, permissionCatalogKeys(context)));
|
|
1609
|
+
} catch {
|
|
1610
|
+
return [];
|
|
1611
|
+
}
|
|
1612
|
+
return problems.map(({ setting, message }) => ({
|
|
1613
|
+
message: `${message} Without it, ${setting.startsWith("blocks.tenant.") ? "an organization invitation, accept or role change can resolve a platform role" : "a platform invitation can resolve a tenant role"}.`,
|
|
1614
|
+
target: setting
|
|
1615
|
+
}));
|
|
1616
|
+
}
|
|
1617
|
+
const MODULE_RULES = [
|
|
1618
|
+
{
|
|
1619
|
+
code: "BS307",
|
|
1620
|
+
severity: "error",
|
|
1621
|
+
title: "Custom SQL module without its contract",
|
|
1622
|
+
description: "A module in `blocks` 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.",
|
|
1623
|
+
check: missingContracts
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
code: "BS308",
|
|
1627
|
+
severity: "warning",
|
|
1628
|
+
title: "Tenant claim the hook does not write",
|
|
1629
|
+
description: "With the `tenant` module and `blocks.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.",
|
|
1630
|
+
check: missingTenantClaim
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
code: "BS309",
|
|
1634
|
+
severity: "warning",
|
|
1635
|
+
title: "Deprecated SQL module symbol",
|
|
1636
|
+
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.",
|
|
1637
|
+
check: deprecatedSymbols
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
code: "BS310",
|
|
1641
|
+
severity: "warning",
|
|
1642
|
+
title: "Duplicate module trigger",
|
|
1643
|
+
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.",
|
|
1644
|
+
check: duplicateTriggers
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
code: "BS313",
|
|
1648
|
+
severity: "warning",
|
|
1649
|
+
title: "Rate limits not wired to PostgREST",
|
|
1650
|
+
description: "The `rate-limit` module is in `blocks`, 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.",
|
|
1651
|
+
check: rateLimitHook
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
code: "BS312",
|
|
1655
|
+
severity: "error",
|
|
1656
|
+
title: "Module schema exposed through the Data API",
|
|
1657
|
+
description: "`[api] schemas` in `config.toml` lists `better_supabase` or a `blocks.*.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.",
|
|
1658
|
+
check: exposedModuleSchemas
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
code: "BS314",
|
|
1662
|
+
severity: "warning",
|
|
1663
|
+
title: "Migration-only module option",
|
|
1664
|
+
description: "A module in adopt mode sets an option that only exists to match an existing schema: plain invitation tokens, webhook secrets or credentials 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.",
|
|
1665
|
+
check: migrationOptions
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
code: "BS315",
|
|
1669
|
+
severity: "warning",
|
|
1670
|
+
title: "Table without an audit trigger",
|
|
1671
|
+
description: "The `audit` module is in `blocks`, 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; `blocks.audit.options.exempt` lists more, as `schema.table` globs such as `public.*_archive`.",
|
|
1672
|
+
check: unauditedTables
|
|
1673
|
+
},
|
|
1674
|
+
{
|
|
1675
|
+
code: "BS319",
|
|
1676
|
+
severity: "error",
|
|
1677
|
+
title: "SQL alters a reserved role",
|
|
1678
|
+
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.",
|
|
1679
|
+
check: reservedRoles
|
|
1680
|
+
},
|
|
1681
|
+
{
|
|
1682
|
+
code: "BS320",
|
|
1683
|
+
severity: "warning",
|
|
1684
|
+
title: "Table without the session policy",
|
|
1685
|
+
description: "The `sessions` module is in `blocks`, 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. `blocks.sessions.options.policies` writes the policy on every table; `options.exclude` lists the tables to skip, as `schema.table` globs.",
|
|
1686
|
+
check: sessionlessTables
|
|
1687
|
+
},
|
|
1688
|
+
{
|
|
1689
|
+
code: "BS322",
|
|
1690
|
+
severity: "warning",
|
|
1691
|
+
title: "Audit registration of a dropped table",
|
|
1692
|
+
description: "The `audit` module is in `blocks`, 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.",
|
|
1693
|
+
check: danglingAuditRegistrations
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
code: "BS323",
|
|
1697
|
+
severity: "warning",
|
|
1698
|
+
title: "Module event trigger missing",
|
|
1699
|
+
description: "A module in `blocks` 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.",
|
|
1700
|
+
check: missingEventTriggers
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
code: "BS324",
|
|
1704
|
+
severity: "error",
|
|
1705
|
+
title: "Shared roles table without a role condition",
|
|
1706
|
+
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'`.",
|
|
1707
|
+
check: sharedRoleTables
|
|
1708
|
+
},
|
|
1709
|
+
{
|
|
1710
|
+
code: "BS325",
|
|
1711
|
+
severity: "warning",
|
|
1712
|
+
title: "Audit log readable only as the service role",
|
|
1713
|
+
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.",
|
|
1714
|
+
check: auditUserSession
|
|
1715
|
+
},
|
|
1716
|
+
{
|
|
1717
|
+
code: "BS326",
|
|
1718
|
+
severity: "warning",
|
|
1719
|
+
title: "Admin Auth call without a secret key in env files",
|
|
1720
|
+
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.",
|
|
1721
|
+
check: missingSecretKey
|
|
1722
|
+
},
|
|
1723
|
+
{
|
|
1724
|
+
code: "BS327",
|
|
1725
|
+
severity: "warning",
|
|
1726
|
+
title: "aal2 required without MFA enabled",
|
|
1727
|
+
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.",
|
|
1728
|
+
check: aal2WithoutMfa
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
code: "BS330",
|
|
1732
|
+
severity: "error",
|
|
1733
|
+
title: "Adopted table without a mapped column",
|
|
1734
|
+
description: "A module in adopt mode maps a table or column that the live database lacks, so its functions fail when they run. The check reads every column the module's SQL names (`blocks.<name>.tables` and `columns`, with the defaults for unmapped ones); columns mapped to `null` are skipped.",
|
|
1735
|
+
check: missingAdoptedColumns
|
|
1736
|
+
},
|
|
1737
|
+
{
|
|
1738
|
+
code: "BS331",
|
|
1739
|
+
severity: "error",
|
|
1740
|
+
title: "Subject registry names a missing table or column",
|
|
1741
|
+
description: "An entry in `config.subjects` names a table, or a column of it (`id`, `tenant`, `title`, `deletedAt`, the `url` placeholders, `rank` and `search`), that the snapshot lacks. `subject_title`, `subject_url`, `can_subject` and every module that falls back to the registry then fail when they run.",
|
|
1742
|
+
check: missingSubjectColumns
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
code: "BS332",
|
|
1746
|
+
severity: "warning",
|
|
1747
|
+
title: "Subject table without RLS",
|
|
1748
|
+
description: "A table in `config.subjects` has row level security disabled. `can_subject` treats a row the caller can read as visible, so without RLS every signed-in user passes the visibility half of the check for every row.",
|
|
1749
|
+
check: subjectsWithoutRls
|
|
1750
|
+
},
|
|
1751
|
+
{
|
|
1752
|
+
code: "BS335",
|
|
1753
|
+
severity: "warning",
|
|
1754
|
+
title: "Partitioned table created unpartitioned",
|
|
1755
|
+
description: "A block's `options.partitioned` is on (`audit`, `outbox`, `usage` with `history`, `webhooks` or `notifications`), but its table was created before it: the primary key is `id` alone, so the table isn't partitioned by month and its purge deletes row by row. The tables are `better_supabase.audit_events`, `outbox_events`, `usage_history`, `webhook_deliveries` and `notification_delivery_events`. Postgres can't convert a table in place. Create a partitioned table, copy the rows over and swap the names in a maintenance window, or turn the option off.",
|
|
1756
|
+
check: unpartitionedTables
|
|
1757
|
+
}
|
|
1758
|
+
];
|
|
1759
|
+
/** The table each block's `options.partitioned` partitions, and the option it also needs. */
|
|
1760
|
+
const PARTITIONED_TABLES = [
|
|
1761
|
+
{
|
|
1762
|
+
block: "audit",
|
|
1763
|
+
table: "audit_events"
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
block: "outbox",
|
|
1767
|
+
table: "outbox_events"
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
block: "usage",
|
|
1771
|
+
table: "usage_history",
|
|
1772
|
+
requires: "history"
|
|
1773
|
+
},
|
|
1774
|
+
{
|
|
1775
|
+
block: "webhooks",
|
|
1776
|
+
table: "webhook_deliveries"
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
block: "notifications",
|
|
1780
|
+
table: "notification_delivery_events"
|
|
1781
|
+
}
|
|
1782
|
+
];
|
|
1783
|
+
function unpartitionedTables(context) {
|
|
1784
|
+
const { tables } = catalogOf(context);
|
|
1785
|
+
return PARTITIONED_TABLES.flatMap(({ block, table, requires }) => {
|
|
1786
|
+
const options = context.config.blocks[block]?.options ?? {};
|
|
1787
|
+
if (!context.config.blockNames.includes(block) || options["partitioned"] !== true || requires !== void 0 && options[requires] !== true) return [];
|
|
1788
|
+
if (tables.find((candidate) => candidate.schema === "better_supabase" && candidate.name === table)?.primaryKey.length !== 1) return [];
|
|
1789
|
+
return [{
|
|
1790
|
+
message: `better_supabase.${table} isn't partitioned, so blocks.${block}.options.partitioned has no effect on it. Postgres can't convert it in place: copy the rows into a partitioned table in a maintenance window, or turn the option off.`,
|
|
1791
|
+
target: `better_supabase.${table}`
|
|
1792
|
+
}];
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1795
|
+
function subjectTables(context) {
|
|
1796
|
+
const catalog = catalogOf(context);
|
|
1797
|
+
return Object.entries(context.config.subjects).flatMap(([type, entry]) => {
|
|
1798
|
+
const [schema, name] = entry.table.includes(".") ? entry.table.split(".", 2) : ["public", entry.table];
|
|
1799
|
+
if (schema === void 0 || name === void 0) return [];
|
|
1800
|
+
if (!context.snapshot.schemas.includes(schema)) return [];
|
|
1801
|
+
return [{
|
|
1802
|
+
type,
|
|
1803
|
+
entry,
|
|
1804
|
+
schema,
|
|
1805
|
+
name,
|
|
1806
|
+
table: catalog.tables.find((candidate) => candidate.schema === schema && candidate.name === name)
|
|
1807
|
+
}];
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
function missingSubjectColumns(context) {
|
|
1811
|
+
return subjectTables(context).flatMap(({ type, entry, schema, name, table }) => {
|
|
1812
|
+
const target = `${schema}.${name}`;
|
|
1813
|
+
if (!table) return [{
|
|
1814
|
+
message: `subjects.${type} names ${target}, which the database does not have. Create the table, or point subjects.${type}.table at the existing one.`,
|
|
1815
|
+
target
|
|
1816
|
+
}];
|
|
1817
|
+
const present = new Set(table.columns.map((column) => column.name));
|
|
1818
|
+
return subjectColumns(entry).filter(([, column]) => !present.has(column)).map(([path, column]) => ({
|
|
1819
|
+
message: `subjects.${type}.${path} names ${target}.${column}, but the table has no such column. Map it to the column that holds the value, or add the column.`,
|
|
1820
|
+
target: `${target}.${column}`,
|
|
1821
|
+
object: tableObject(table)
|
|
1822
|
+
}));
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
function subjectsWithoutRls(context) {
|
|
1826
|
+
return subjectTables(context).flatMap(({ type, table }) => table?.kind === "table" && !table.rls ? [{
|
|
1827
|
+
message: `subjects.${type} is ${table.schema}.${table.name}, which has row level security disabled, so can_subject treats every row as visible. Enable RLS and add the policies that scope it.`,
|
|
1828
|
+
target: `${table.schema}.${table.name}`,
|
|
1829
|
+
object: tableObject(table)
|
|
1830
|
+
}] : []);
|
|
1831
|
+
}
|
|
1832
|
+
function auditUserSession(context) {
|
|
1833
|
+
if (!context.config.blockNames.includes("audit")) return [];
|
|
1834
|
+
const options = context.config.blocks["audit"]?.options ?? {};
|
|
1835
|
+
const roles = Array.isArray(options["eventRoles"]) ? options["eventRoles"].filter((role) => typeof role === "string") : ["service_role"];
|
|
1836
|
+
if (options["readPolicy"] === true && roles.includes("authenticated")) return [];
|
|
1837
|
+
if (!context.sources.some((file) => /\baudit\.(list|listAll)\s*\(/.test(file.text))) return [];
|
|
1838
|
+
return [{
|
|
1839
|
+
message: "blocks.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.",
|
|
1840
|
+
target: "blocks.audit"
|
|
1841
|
+
}];
|
|
1842
|
+
}
|
|
1843
|
+
function missingSecretKey(context) {
|
|
1844
|
+
if (!context.sources.some((file) => /\bdeleteAccount\b|\.admin\s*\(|supabaseAdmin\b/.test(file.text))) return [];
|
|
1845
|
+
if (context.envFiles.some((file) => /^\s*(?:SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY)\s*=/m.test(file.text))) return [];
|
|
1846
|
+
return [{
|
|
1847
|
+
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.",
|
|
1848
|
+
target: "SUPABASE_SECRET_KEY"
|
|
1849
|
+
}];
|
|
1850
|
+
}
|
|
1851
|
+
function aal2WithoutMfa(context) {
|
|
1852
|
+
const toml = context.configToml;
|
|
1853
|
+
if (!toml) return [];
|
|
1854
|
+
if (!context.sources.some((file) => /requireAal\s*\(\s*['"]aal2['"]/.test(file.text))) return [];
|
|
1855
|
+
const enroll = tomlGet(toml.document, [
|
|
1856
|
+
"auth",
|
|
1857
|
+
"mfa",
|
|
1858
|
+
"totp",
|
|
1859
|
+
"enroll_enabled"
|
|
1860
|
+
]);
|
|
1861
|
+
const verify = tomlGet(toml.document, [
|
|
1862
|
+
"auth",
|
|
1863
|
+
"mfa",
|
|
1864
|
+
"totp",
|
|
1865
|
+
"verify_enabled"
|
|
1866
|
+
]);
|
|
1867
|
+
if (enroll === true && verify === true) return [];
|
|
1868
|
+
return [{
|
|
1869
|
+
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.",
|
|
1870
|
+
target: toml.path,
|
|
1871
|
+
location: {
|
|
1872
|
+
file: toml.path,
|
|
1873
|
+
line: lineOf(toml.text, /^\[auth\.mfa\.totp\]/) ?? 1
|
|
1874
|
+
}
|
|
1875
|
+
}];
|
|
1876
|
+
}
|
|
1877
|
+
//#endregion
|
|
1878
|
+
//#region src/cli/doctor/pgdelta.ts
|
|
1879
|
+
/** `config.toml` when the project runs the pg-delta workflow. */
|
|
1880
|
+
function pgdeltaToml(context) {
|
|
1881
|
+
const toml = context.configToml;
|
|
1882
|
+
return toml !== void 0 && workflowOf(context) === "pg-delta" ? toml : void 0;
|
|
1883
|
+
}
|
|
1884
|
+
/** Comments blanked out, so a commented-out statement never matches and line numbers stay. */
|
|
1885
|
+
function withoutComments(text) {
|
|
1886
|
+
return text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, (comment) => " ".repeat(comment.length));
|
|
1887
|
+
}
|
|
1888
|
+
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
1889
|
+
/**
|
|
1890
|
+
* The declarative schema files pg-delta loads, outside the SQL module files
|
|
1891
|
+
* better-supabase writes, when the project diffs with pg-delta.
|
|
1892
|
+
*/
|
|
1893
|
+
function pgdeltaSchemaFiles(context) {
|
|
1894
|
+
const toml = pgdeltaToml(context);
|
|
1895
|
+
if (toml === void 0) return [];
|
|
1896
|
+
const schemas = `${declarativeSchemasDir(toml, "pg-delta")}/`;
|
|
1897
|
+
return (context.sqlFiles ?? []).filter((file) => file.path.startsWith(schemas) && !/^-- @bs-module /m.test(file.text));
|
|
1898
|
+
}
|
|
1899
|
+
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;
|
|
1900
|
+
/** Grantees a bulk grant must not reach: the Data API roles and `public`. */
|
|
1901
|
+
const API_ROLES = /\b(anon|authenticated|public)\b/i;
|
|
1902
|
+
/** `do` blocks; group 2 is the body. */
|
|
1903
|
+
const DO_BLOCK = /\bdo\s+(\$[\w]*\$)([\s\S]*?)\1/gi;
|
|
1904
|
+
const CATALOG = /\b(information_schema\.\w+|(?:pg_catalog\.)?pg_(?:class|tables|views|proc|namespace|attribute|trigger|constraint|index|type|policies|matviews))\b/i;
|
|
1905
|
+
const EXECUTES = /\b(execute|perform)\b/i;
|
|
1906
|
+
function bulkGrants(context) {
|
|
1907
|
+
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1908
|
+
const text = withoutComments(file.text);
|
|
1909
|
+
return [...text.matchAll(BULK_GRANT)].filter((match) => API_ROLES.test(match[3])).map((match) => ({
|
|
1910
|
+
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.`,
|
|
1911
|
+
target: file.path,
|
|
1912
|
+
location: {
|
|
1913
|
+
file: file.path,
|
|
1914
|
+
line: lineAt(text, match.index)
|
|
1915
|
+
}
|
|
1916
|
+
}));
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
function catalogLoops(context) {
|
|
1920
|
+
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1921
|
+
const text = withoutComments(file.text);
|
|
1922
|
+
return [...text.matchAll(DO_BLOCK)].flatMap((match) => {
|
|
1923
|
+
const body = match[2];
|
|
1924
|
+
const catalog = CATALOG.exec(body);
|
|
1925
|
+
if (!catalog || !EXECUTES.test(body)) return [];
|
|
1926
|
+
return [{
|
|
1927
|
+
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.`,
|
|
1928
|
+
target: file.path,
|
|
1929
|
+
location: {
|
|
1930
|
+
file: file.path,
|
|
1931
|
+
line: lineAt(text, match.index)
|
|
1932
|
+
}
|
|
1933
|
+
}];
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([\w-]+)"?/gi;
|
|
1938
|
+
/**
|
|
1939
|
+
* `text` without comments and with string literals blanked, keeping every
|
|
1940
|
+
* offset, so an error message that tells the reader to `create extension`
|
|
1941
|
+
* doesn't count as one.
|
|
1942
|
+
*/
|
|
1943
|
+
const statements = (text) => withoutComments(text).replaceAll(/'(?:[^']|'')*'/g, (literal) => literal.replaceAll(/[^\n]/g, " "));
|
|
1944
|
+
/**
|
|
1945
|
+
* Extensions a pg-delta schema file creates, SQL module files included, that
|
|
1946
|
+
* no migration creates. pg-delta can leave an extension that owns its own
|
|
1947
|
+
* schema (`pgmq`) out of the generated migration.
|
|
1948
|
+
*/
|
|
1949
|
+
function missingExtensions(context) {
|
|
1950
|
+
const toml = pgdeltaToml(context);
|
|
1951
|
+
if (toml === void 0) return [];
|
|
1952
|
+
const schemas = `${declarativeSchemasDir(toml, "pg-delta")}/`;
|
|
1953
|
+
const files = context.sqlFiles ?? [];
|
|
1954
|
+
const migrations = files.filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1955
|
+
if (migrations.length === 0) return [];
|
|
1956
|
+
const created = new Set(migrations.flatMap((file) => [...statements(file.text).matchAll(CREATE_EXTENSION)].map((match) => match[1].toLowerCase())));
|
|
1957
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1958
|
+
return files.filter((file) => file.path.startsWith(schemas)).flatMap((file) => {
|
|
1959
|
+
const text = statements(file.text);
|
|
1960
|
+
return [...text.matchAll(CREATE_EXTENSION)].flatMap((match) => {
|
|
1961
|
+
const name = match[1].toLowerCase();
|
|
1962
|
+
if (created.has(name) || seen.has(name)) return [];
|
|
1963
|
+
seen.add(name);
|
|
1964
|
+
const module = /^-- @bs-module /m.test(file.text);
|
|
1965
|
+
return [{
|
|
1966
|
+
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.`}`,
|
|
1967
|
+
target: file.path,
|
|
1968
|
+
location: {
|
|
1969
|
+
file: file.path,
|
|
1970
|
+
line: lineAt(text, match.index)
|
|
1971
|
+
}
|
|
1972
|
+
}];
|
|
1973
|
+
});
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
/** Statements in declarative schema files that pg-delta can't order. */
|
|
1977
|
+
const PGDELTA_RULES = [
|
|
1978
|
+
{
|
|
1979
|
+
code: "BS317",
|
|
1980
|
+
severity: "warning",
|
|
1981
|
+
title: "Bulk grant in a pg-delta schema file",
|
|
1982
|
+
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`.",
|
|
1983
|
+
check: bulkGrants
|
|
1984
|
+
},
|
|
1985
|
+
{
|
|
1986
|
+
code: "BS318",
|
|
1987
|
+
severity: "warning",
|
|
1988
|
+
title: "Catalog loop in a pg-delta schema file",
|
|
1989
|
+
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.",
|
|
1990
|
+
check: catalogLoops
|
|
1991
|
+
},
|
|
1992
|
+
{
|
|
1993
|
+
code: "BS321",
|
|
1994
|
+
severity: "warning",
|
|
1995
|
+
title: "Extension missing from the migrations under pg-delta",
|
|
1996
|
+
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.",
|
|
1997
|
+
check: missingExtensions
|
|
1998
|
+
}
|
|
1999
|
+
];
|
|
2000
|
+
//#endregion
|
|
2001
|
+
//#region src/cli/doctor/policies.ts
|
|
2002
|
+
const AUTH_ROLE = /\bauth"?\s*\.\s*"?role"?\s*\(\s*\)/i;
|
|
2003
|
+
/** A check of who is calling: the JWT, the user id, or the session role. */
|
|
2004
|
+
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;
|
|
2005
|
+
const USER_METADATA = /\buser_metadata\b/i;
|
|
2006
|
+
/** Privileges an API role never needs on a table. */
|
|
2007
|
+
const NEEDLESS = [
|
|
2008
|
+
"TRUNCATE",
|
|
2009
|
+
"REFERENCES",
|
|
2010
|
+
"TRIGGER"
|
|
2011
|
+
];
|
|
2012
|
+
const policyText = (policy) => withoutStrings(`${policy.using ?? ""} ${policy.check ?? ""}`);
|
|
2013
|
+
/** A permissive policy lets `role` run `command` on the table. */
|
|
2014
|
+
const allows = (table, command, role) => table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
|
|
2015
|
+
/** Policy commands on `storage.objects` the SQL files create, by policy name. */
|
|
2016
|
+
function storagePolicies(context) {
|
|
2017
|
+
const commands = /* @__PURE__ */ new Map();
|
|
2018
|
+
const pattern = /create\s+policy\s+("(?:[^"]|"")+"|\S+)\s+on\s+"?storage"?\s*\.\s*"?objects"?\b([^;]*)/gi;
|
|
2019
|
+
for (const file of context.sqlFiles ?? []) for (const match of withoutStrings(file.text).matchAll(pattern)) {
|
|
2020
|
+
const name = match[1];
|
|
2021
|
+
if (commands.has(name)) continue;
|
|
2022
|
+
const command = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(match[2])?.[1];
|
|
2023
|
+
commands.set(name, command?.toLowerCase() ?? "all");
|
|
2024
|
+
}
|
|
2025
|
+
return commands;
|
|
2026
|
+
}
|
|
2027
|
+
const POLICY_RULES = [
|
|
2028
|
+
{
|
|
2029
|
+
code: "BS109",
|
|
2030
|
+
severity: "warning",
|
|
2031
|
+
title: "auth.role() in a policy or function",
|
|
2032
|
+
description: "Supabase deprecated `auth.role()`. Scope the policy with `to authenticated`, or read the claim with `(select auth.jwt() ->> 'role')`.",
|
|
2033
|
+
check: (context) => {
|
|
2034
|
+
const findings = [];
|
|
2035
|
+
for (const table of catalogOf(context).tables) for (const policy of table.policies) {
|
|
2036
|
+
if (!AUTH_ROLE.test(policyText(policy))) continue;
|
|
2037
|
+
findings.push({
|
|
2038
|
+
message: `Policy "${policy.name}" on ${qualified(table)} calls auth.role(), which Supabase deprecated. Use \`to authenticated\` on the policy, or \`(select auth.jwt() ->> 'role')\`.`,
|
|
2039
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2040
|
+
object: policyObject(table, policy)
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
const bodies = new Map(policyHelpers(context));
|
|
2044
|
+
for (const fn of context.snapshot.generator.functions) {
|
|
2045
|
+
if (!context.config.schemas.includes(fn.schema) || !fn.definition) continue;
|
|
2046
|
+
bodies.set(`${fn.schema}.${fn.name}`, withoutStrings(fn.definition));
|
|
2047
|
+
}
|
|
2048
|
+
for (const [name, body] of bodies) {
|
|
2049
|
+
if (!AUTH_ROLE.test(body)) continue;
|
|
2050
|
+
const dot = name.indexOf(".");
|
|
2051
|
+
findings.push({
|
|
2052
|
+
message: `${name} calls auth.role(), which Supabase deprecated. Read \`auth.jwt() ->> 'role'\` instead.`,
|
|
2053
|
+
target: name,
|
|
2054
|
+
object: {
|
|
2055
|
+
kind: "function",
|
|
2056
|
+
schema: name.slice(0, dot),
|
|
2057
|
+
name: name.slice(dot + 1)
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
return findings;
|
|
2062
|
+
}
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
code: "BS110",
|
|
2066
|
+
severity: "warning",
|
|
2067
|
+
title: "Update policy without a select policy or with check",
|
|
2068
|
+
description: "An update has to find the rows first, so without a select policy for the same role it matches no rows and reports success. Without `with check`, Postgres checks the new row against `using`, which hides whether moving a row to another owner or tenant is allowed.",
|
|
2069
|
+
check: (context) => exposed(context).flatMap((table) => {
|
|
2070
|
+
if (!table.rls) return [];
|
|
2071
|
+
return table.policies.flatMap((policy) => {
|
|
2072
|
+
if (!policy.permissive || policy.command !== "update") return [];
|
|
2073
|
+
const blind = (policy.roles.length === 0 || policy.roles.includes("public") ? ["anon", "authenticated"] : policy.roles).filter((role) => !allows(table, "select", role));
|
|
2074
|
+
const target = `${qualified(table)}.${policy.name}`;
|
|
2075
|
+
if (blind.length > 0) return [{
|
|
2076
|
+
message: `Policy "${policy.name}" on ${qualified(table)} allows update for ${blind.join(", ")}, but no select policy does, so updates match no rows without an error. Add a select policy for ${blind.join(", ")}.`,
|
|
2077
|
+
target,
|
|
2078
|
+
object: policyObject(table, policy)
|
|
2079
|
+
}];
|
|
2080
|
+
if (policy.check !== null) return [];
|
|
2081
|
+
return [{
|
|
2082
|
+
severity: "info",
|
|
2083
|
+
message: `Policy "${policy.name}" on ${qualified(table)} has no \`with check\`, so Postgres checks updated rows against \`using\`. State the rule for the new row with \`with check (...)\`.`,
|
|
2084
|
+
target,
|
|
2085
|
+
object: policyObject(table, policy)
|
|
2086
|
+
}];
|
|
2087
|
+
});
|
|
2088
|
+
})
|
|
2089
|
+
},
|
|
2090
|
+
{
|
|
2091
|
+
code: "BS111",
|
|
2092
|
+
severity: "warning",
|
|
2093
|
+
title: "API roles hold privileges they don't use",
|
|
2094
|
+
description: "`anon` and `authenticated` never need `truncate`, `references` or `trigger`, and `truncate` skips RLS. A grant to `anon` on a table no policy opens to it does nothing except list the table in the Data API schema.",
|
|
2095
|
+
check: (context) => exposed(context).flatMap((table) => {
|
|
2096
|
+
if (table.kind !== "table") return [];
|
|
2097
|
+
const findings = [];
|
|
2098
|
+
for (const role of ["anon", "authenticated"]) {
|
|
2099
|
+
const privileges = new Set(table.grants.filter((grant) => grant.role === role).flatMap((grant) => grant.privileges));
|
|
2100
|
+
const needless = NEEDLESS.filter((privilege) => privileges.has(privilege));
|
|
2101
|
+
if (needless.length > 0) findings.push({
|
|
2102
|
+
message: `${role} has ${needless.join(", ").toLowerCase()} on ${qualified(table)}. \`truncate\` bypasses RLS. Run \`revoke ${needless.join(", ").toLowerCase()} on table ${qualified(table)} from ${role};\`.`,
|
|
2103
|
+
target: `${qualified(table)}:${role}`,
|
|
2104
|
+
object: tableObject(table)
|
|
2105
|
+
});
|
|
2106
|
+
if (role !== "anon" || !table.rls) continue;
|
|
2107
|
+
const unused = COMMANDS$1.filter((command) => privileges.has(command.toUpperCase()) && !allows(table, command, role));
|
|
2108
|
+
if (unused.length > 0) findings.push({
|
|
2109
|
+
severity: "info",
|
|
2110
|
+
message: `anon has ${unused.join(", ")} on ${qualified(table)}, but no policy allows it for anon, so the grant only lists the table in the Data API schema. Run \`revoke ${unused.join(", ")} on table ${qualified(table)} from anon;\`.`,
|
|
2111
|
+
target: `${qualified(table)}:anon:unused`,
|
|
2112
|
+
object: tableObject(table)
|
|
2113
|
+
});
|
|
2114
|
+
}
|
|
2115
|
+
return findings;
|
|
2116
|
+
})
|
|
2117
|
+
},
|
|
2118
|
+
{
|
|
2119
|
+
code: "BS112",
|
|
2120
|
+
severity: "warning",
|
|
2121
|
+
title: "Exposed security definer function that doesn't check the caller",
|
|
2122
|
+
description: "A `security definer` function in an exposed schema runs as its owner, past RLS, and the Data API serves it at `/rpc`. When anon or authenticated may execute it and its body never reads `auth.uid()` or `auth.jwt()`, any caller gets the owner's access. Check the caller, revoke `execute`, or move it to a schema the API doesn't expose.",
|
|
2123
|
+
check: (context) => (context.snapshot.extras.functions ?? []).flatMap((fn) => {
|
|
2124
|
+
if (!fn.securityDefiner || !context.config.schemas.includes(fn.schema)) return [];
|
|
2125
|
+
const callers = fn.execute ?? [];
|
|
2126
|
+
if (callers.length === 0) return [];
|
|
2127
|
+
const body = functionBody(context, fn.schema, fn.name);
|
|
2128
|
+
if (body === void 0 || CALLER_CHECK.test(body)) return [];
|
|
2129
|
+
return [{
|
|
2130
|
+
message: `${fn.schema}.${fn.name}(${fn.signature}) is security definer, ${callers.join(" and ")} may execute it through /rpc, and it never checks auth.uid() or auth.jwt(). Check the caller, run \`revoke execute on function ${fn.schema}.${fn.name}(${fn.signature}) from ${callers.join(", ")};\`, or move it to a private schema.`,
|
|
2131
|
+
target: `${fn.schema}.${fn.name}`,
|
|
2132
|
+
object: functionObject(fn)
|
|
2133
|
+
}];
|
|
2134
|
+
})
|
|
2135
|
+
},
|
|
2136
|
+
{
|
|
2137
|
+
code: "BS113",
|
|
2138
|
+
severity: "info",
|
|
2139
|
+
title: "Storage upload policy without the upsert policies",
|
|
2140
|
+
description: "An upload with `upsert: true` (`x-upsert`) needs `select` and `update` policies on `storage.objects` besides `insert`; without them, replacing an existing file fails with a 403. Doctor reads the policies in `supabase/schemas` and the migrations.",
|
|
2141
|
+
check: (context) => {
|
|
2142
|
+
const commands = new Set(storagePolicies(context).values());
|
|
2143
|
+
if (!commands.has("insert") || commands.has("all")) return [];
|
|
2144
|
+
const missing = ["select", "update"].filter((command) => !commands.has(command));
|
|
2145
|
+
if (missing.length === 0) return [];
|
|
2146
|
+
return [{
|
|
2147
|
+
message: `storage.objects has an insert policy but no ${missing.join(" or ")} policy, so uploads with \`upsert: true\` fail with 403 when the file exists. Add ${missing.join(" and ")} policies with the same bucket and path check.`,
|
|
2148
|
+
target: "storage.objects"
|
|
2149
|
+
}];
|
|
2150
|
+
}
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
code: "BS114",
|
|
2154
|
+
severity: "error",
|
|
2155
|
+
title: "Policy helper reads user_metadata",
|
|
2156
|
+
description: "Users can change their own `raw_user_meta_data` with `auth.updateUser()`, so a helper that reads `user_metadata` to decide access lets them grant themselves rights. Read `app_metadata`, which only the service role can write. Splinter's `rls_references_user_metadata` covers the policy text; this covers the functions policies call.",
|
|
2157
|
+
check: (context) => [...policyHelpers(context).keys()].flatMap((name) => {
|
|
2158
|
+
const dot = name.indexOf(".");
|
|
2159
|
+
const body = functionBody(context, name.slice(0, dot), name.slice(dot + 1));
|
|
2160
|
+
if (!body || !USER_METADATA.test(body)) return [];
|
|
2161
|
+
return [{
|
|
2162
|
+
message: `${name} reads user_metadata, which users can write themselves, and policies call it. Read \`auth.jwt() -> 'app_metadata'\` instead.`,
|
|
2163
|
+
target: name,
|
|
2164
|
+
object: {
|
|
2165
|
+
kind: "function",
|
|
2166
|
+
schema: name.slice(0, dot),
|
|
2167
|
+
name: name.slice(dot + 1)
|
|
2168
|
+
}
|
|
2169
|
+
}];
|
|
2170
|
+
})
|
|
2171
|
+
},
|
|
2172
|
+
{
|
|
2173
|
+
code: "BS115",
|
|
2174
|
+
severity: "warning",
|
|
2175
|
+
title: "Table without a policy still granted to the Data API",
|
|
2176
|
+
description: "A table with RLS on and no permissive policy for `anon` or `authenticated` answers every Data API request with no rows or a denied write, yet a grant lists it in the API schema. Under `blocks.grants.options.fromPolicies`, `better-supabase sql sync` revokes these grants and keeps `service_role`; until the migration is applied, doctor reports the live grants. Tables in `expose` are skipped.",
|
|
2177
|
+
check: (context) => exposed(context).flatMap((table) => {
|
|
2178
|
+
if (table.kind !== "table" || !table.rls) return [];
|
|
2179
|
+
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2180
|
+
if (options?.exclude || options?.serviceRole) return [];
|
|
2181
|
+
if (context.config.expose[qualified(table)] ?? context.config.expose[table.name]) return [];
|
|
2182
|
+
const roles = ["anon", "authenticated"].filter((role) => table.grants.some((grant) => (grant.role === role || grant.role === "PUBLIC") && grant.privileges.length > 0));
|
|
2183
|
+
if (roles.length === 0) return [];
|
|
2184
|
+
if (table.policies.some((policy) => policy.permissive && roles.some((role) => appliesTo(policy, role)))) return [];
|
|
2185
|
+
return [{
|
|
2186
|
+
message: `${qualified(table)} has RLS on and no permissive policy for ${roles.join(" or ")}, but ${roles.join(" and ")} still hold grants on it. Run \`revoke all on table ${qualified(table)} from public, anon, authenticated;\`, or set \`blocks.grants.options.fromPolicies\` and run \`better-supabase sql sync\` so the migration revokes it.`,
|
|
2187
|
+
target: qualified(table),
|
|
2188
|
+
object: tableObject(table)
|
|
2189
|
+
}];
|
|
2190
|
+
})
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
code: "BS116",
|
|
2194
|
+
severity: "warning",
|
|
2195
|
+
title: "Security definer function open to the API roles without being exposed",
|
|
2196
|
+
description: "Postgres grants `execute` on a new function to `public`, so a `security definer` function in an exposed schema is callable at `/rpc` by `anon` and `authenticated` unless it is revoked. When no `expose` entry lists it and no policy calls it, nothing in the project says the API roles should reach it. Doctor never revokes it; list it in `expose` with the roles that call it, or revoke `execute` from `public`, `anon` and `authenticated` and grant it back to those roles.",
|
|
2197
|
+
check: (context) => {
|
|
2198
|
+
const schemas = exposedSchemas(context);
|
|
2199
|
+
const listed = new Set(Object.keys(context.config.exposeFunctions).map((signature) => {
|
|
2200
|
+
const name = signature.slice(0, signature.indexOf("(")).trim();
|
|
2201
|
+
return name.includes(".") ? name : `public.${name}`;
|
|
2202
|
+
}));
|
|
2203
|
+
const inPolicies = new Set(catalogOf(context).tables.flatMap((table) => table.policies.flatMap((policy) => policy.functions ?? [])));
|
|
2204
|
+
return (context.snapshot.extras.functions ?? []).flatMap((fn) => {
|
|
2205
|
+
const name = `${fn.schema}.${fn.name}`;
|
|
2206
|
+
const callers = fn.execute ?? [];
|
|
2207
|
+
if (!fn.securityDefiner || !schemas.includes(fn.schema) || callers.length === 0 || listed.has(name) || inPolicies.has(name)) return [];
|
|
2208
|
+
return [{
|
|
2209
|
+
message: `${name}(${fn.signature}) is security definer, ${callers.join(" and ")} may execute it through /rpc, and no \`expose\` entry or policy uses it. Run \`revoke all on function ${name}(${fn.signature}) from public, anon, authenticated;\`, then \`grant execute on function ${name}(${fn.signature}) to <the roles that call it>;\`, or list it in \`expose\`.`,
|
|
2210
|
+
target: name,
|
|
2211
|
+
object: functionObject(fn)
|
|
2212
|
+
}];
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
code: "BS117",
|
|
2218
|
+
severity: "warning",
|
|
2219
|
+
title: "Policy without a role",
|
|
2220
|
+
description: "A policy without `to`, or with `to public`, applies to `public`, which is every role, `anon` included. Postgres then evaluates it for requests it was never meant for, and a later change to its expression can open the table to signed-out users. Name the roles with `to authenticated` (or `to anon, authenticated`). Restrictive policies only narrow access, so they are skipped.",
|
|
2221
|
+
check: (context) => catalogOf(context).tables.flatMap((table) => table.policies.filter((policy) => policy.permissive && (policy.roles.length === 0 || policy.roles.includes("public"))).map((policy) => ({
|
|
2222
|
+
message: `Policy "${policy.name}" on ${qualified(table)} applies to public (no \`to\` role, or \`to public\`), which is every role, anon included. Recreate it with \`to authenticated\`, or list the roles that need it.`,
|
|
2223
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2224
|
+
object: policyObject(table, policy)
|
|
2225
|
+
})))
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
code: "BS215",
|
|
2229
|
+
severity: "warning",
|
|
2230
|
+
title: "Policy calls a helper per row that could run once",
|
|
2231
|
+
description: "A call such as `current_tenant_id()` takes no column of the row, so its result is the same for every row. Wrapped as `(select current_tenant_id())`, Postgres runs it once per statement as an InitPlan. Splinter's `auth_rls_initplan` covers `auth.*` calls; this covers your own helpers.",
|
|
2232
|
+
check: (context) => {
|
|
2233
|
+
const volatility = new Map((context.snapshot.extras.functions ?? []).map((fn) => [`${fn.schema}.${fn.name}`, fn.volatility]));
|
|
2234
|
+
return exposed(context).flatMap((table) => {
|
|
2235
|
+
if (!table.rls) return [];
|
|
2236
|
+
return table.policies.flatMap((policy) => {
|
|
2237
|
+
const text = policyText(policy);
|
|
2238
|
+
const bare = (policy.functions ?? []).filter((name) => {
|
|
2239
|
+
const dot = name.indexOf(".");
|
|
2240
|
+
const schema = name.slice(0, dot);
|
|
2241
|
+
if (schema === "auth" || volatility.get(name) === "immutable") return false;
|
|
2242
|
+
return new RegExp(`(?<!\\bselect\\s+)(?<![\\w."])(?:"?${escape$2(schema)}"?\\.)?"?${escape$2(name.slice(dot + 1))}"?\\s*\\(\\s*\\)`, "i").test(text);
|
|
2243
|
+
});
|
|
2244
|
+
if (bare.length === 0) return [];
|
|
2245
|
+
return [{
|
|
2246
|
+
message: `Policy "${policy.name}" on ${qualified(table)} calls ${bare.map((name) => `${name}()`).join(", ")} without \`(select ...)\`, so it runs once per row. Write \`(select ${bare[0]}())\`.`,
|
|
2247
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2248
|
+
object: policyObject(table, policy)
|
|
2249
|
+
}];
|
|
2250
|
+
});
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
];
|
|
2255
|
+
//#endregion
|
|
2256
|
+
//#region src/cli/doctor/realtime.ts
|
|
2257
|
+
/** The comment `topic.trigger(..., { payload: "row" })` puts on its function. */
|
|
2258
|
+
const ROW_PAYLOAD_MARKER = "better-supabase: payload row";
|
|
2259
|
+
const BROADCAST_ROW = /realtime\.broadcast_changes\s*\([^;]*\b(?:new|old)\b|'old_record'/i;
|
|
2260
|
+
/** Whether a trigger function body sends `new` or `old` whole. */
|
|
2261
|
+
function sendsWholeRow(source) {
|
|
2262
|
+
return BROADCAST_ROW.test(source.replaceAll(/--[^\n]*/g, ""));
|
|
2263
|
+
}
|
|
2264
|
+
/** Realtime checks that read the trigger functions in the snapshot. */
|
|
2265
|
+
const REALTIME_RULES = [{
|
|
2266
|
+
code: "BS337",
|
|
2267
|
+
severity: "warning",
|
|
2268
|
+
title: "Broadcast trigger sends whole rows",
|
|
2269
|
+
description: "A trigger function sends the whole row to Realtime with `realtime.broadcast_changes(..., new, old)` or `old_record` and `record`, and has no `payload: \"row\"` marker. Subscribers then see every column, including ones their column grants hide.",
|
|
2270
|
+
check: (context) => catalogOf(context).tables.flatMap((table) => table.triggers.filter((trigger) => trigger.source !== void 0 && trigger.comment !== ROW_PAYLOAD_MARKER && sendsWholeRow(trigger.source)).map((trigger) => ({
|
|
2271
|
+
message: `${trigger.name} on ${qualified(table)} calls ${trigger.function}(), which broadcasts whole rows. Regenerate it with \`topic.trigger()\`, whose default payload sends the primary key and the topic values, or pass \`payload: "row"\` to keep whole rows on purpose.`,
|
|
2272
|
+
target: `${qualified(table)}.${trigger.name}`,
|
|
2273
|
+
object: tableObject(table)
|
|
2274
|
+
})))
|
|
2275
|
+
}];
|
|
2276
|
+
//#endregion
|
|
2277
|
+
//#region src/cli/doctor/schema-design.ts
|
|
2278
|
+
/** The `format` the database reports for each app column type. */
|
|
2279
|
+
const APP_COLUMN_FORMAT = {
|
|
2280
|
+
text: "text",
|
|
2281
|
+
uuid: "uuid",
|
|
2282
|
+
integer: "int4",
|
|
2283
|
+
bigint: "int8",
|
|
2284
|
+
numeric: "numeric",
|
|
2285
|
+
boolean: "bool",
|
|
2286
|
+
timestamptz: "timestamptz",
|
|
2287
|
+
date: "date",
|
|
2288
|
+
jsonb: "jsonb",
|
|
2289
|
+
"text[]": "_text"
|
|
2290
|
+
};
|
|
2291
|
+
/**
|
|
2292
|
+
* Whether a partial index's predicate only skips rows where a key column is
|
|
2293
|
+
* null, like `(customer_id IS NOT NULL)`: those rows reference nothing, so the
|
|
2294
|
+
* index still serves the key.
|
|
2295
|
+
*/
|
|
2296
|
+
const skipsNullKeys = (predicate, columns) => {
|
|
2297
|
+
if (!predicate) return false;
|
|
2298
|
+
return predicate.split(/\s+AND\s+/i).every((part) => columns.some((column) => new RegExp(`^\\(*\\s*"?${escape$2(column)}"?\\s+IS\\s+NOT\\s+NULL\\s*\\)*$`, "i").test(part.trim())));
|
|
2299
|
+
};
|
|
2300
|
+
/** Whether the first `columns.length` columns of an index are exactly `columns`, in any order, and it holds every row that references something. */
|
|
2301
|
+
const covers = (index, columns) => (!index.partial || skipsNullKeys(index.predicate, columns)) && index.columns.length >= columns.length && index.columns.slice(0, columns.length).every((column) => columns.includes(column));
|
|
2302
|
+
/** Foreign keys of `table` that no index starts with. */
|
|
2303
|
+
const unindexedForeignKeys = (table) => table.foreignKeys.filter((key) => !table.indexes.some((index) => covers(index, key.columns)));
|
|
2304
|
+
const SOFT_DELETE = /^(?:deleted|archived)_at$/;
|
|
2305
|
+
/** Operators that need a GIN index on jsonb or array columns. */
|
|
2306
|
+
const CONTAINS = String.raw`(?:@>|<@|\?\||\?&|\?|&&)`;
|
|
2307
|
+
/** `.contains("tags"`, `.containedBy('meta'`, `.overlaps("labels"`, supabase-js filters. */
|
|
2308
|
+
const CLIENT_CONTAINS = /\.(?:contains|containedBy|overlaps)\(\s*["'`]([A-Za-z_]\w*)["'`]/g;
|
|
2309
|
+
/** Column types Postgres' wiki and the Supabase skills advise against. */
|
|
2310
|
+
const AVOID = {
|
|
2311
|
+
timestamp: "timestamptz",
|
|
2312
|
+
varchar: "text with a check constraint",
|
|
2313
|
+
bpchar: "text",
|
|
2314
|
+
money: "numeric",
|
|
2315
|
+
json: "jsonb"
|
|
2316
|
+
};
|
|
2317
|
+
/** A Supabase direct (`db.<ref>.supabase.co`) or session-pooler (`:5432`) connection string. */
|
|
2318
|
+
const DIRECT_URL = /postgres(?:ql)?:\/\/[^\s"'`]*@(?:db\.[a-z0-9]+\.supabase\.co(?::5432)?|[\w.-]+\.pooler\.supabase\.com:5432)\b/i;
|
|
2319
|
+
/** Files that show the app runs on serverless or edge functions. */
|
|
2320
|
+
const SERVERLESS = /\bexport\s+(?:const\s+runtime|async\s+function\s+(?:GET|POST|PUT|PATCH|DELETE)|default\s+\{\s*(?:async\s+)?fetch)\b|\bDeno\.serve\(/;
|
|
2321
|
+
/** A connection string through Supavisor's transaction pooler (port 6543). */
|
|
2322
|
+
const TRANSACTION_POOLER = /postgres(?:ql)?:\/\/[^\s"'`]*:6543\b/i;
|
|
2323
|
+
/** `import postgres from "postgres"` or `require("postgres")`: postgres.js. */
|
|
2324
|
+
const POSTGRES_JS = /\bfrom\s+["']postgres["']|\brequire\(\s*["']postgres["']\s*\)/;
|
|
2325
|
+
const PREPARE_OFF = /\bprepare\s*:\s*false\b/;
|
|
2326
|
+
const PRISMA = /["']@prisma\/client["']|\bnew\s+PrismaClient\b/;
|
|
2327
|
+
const OFFSET_PAGINATION = /\bpagination\s*:\s*["'`]offset["'`]/;
|
|
2328
|
+
const EXACT_COUNT = /\bcount\s*:\s*["'`]exact["'`]/;
|
|
2329
|
+
/** `format_type` names of scalar results that a JSON number can't hold exactly. */
|
|
2330
|
+
const WIDE_RESULT = /^(?:bigint|numeric)(?:\[\])?$/;
|
|
2331
|
+
function lines(file) {
|
|
2332
|
+
return file.text.split("\n").map((text, index) => ({
|
|
2333
|
+
line: index + 1,
|
|
2334
|
+
text
|
|
2335
|
+
}));
|
|
2336
|
+
}
|
|
2337
|
+
const SCHEMA_DESIGN_RULES = [
|
|
2338
|
+
{
|
|
2339
|
+
code: "BS216",
|
|
2340
|
+
severity: "info",
|
|
2341
|
+
title: "Foreign key without an index",
|
|
2342
|
+
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; a partial index counts when its `where` only skips rows whose key is null (`where customer_id is not null`). 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.",
|
|
2343
|
+
check: async (context) => {
|
|
2344
|
+
const splinter = await splinterTables(context, "unindexed_foreign_keys");
|
|
2345
|
+
return exposed(context).flatMap((table) => {
|
|
2346
|
+
if (table.kind !== "table" || splinter?.has(qualified(table))) return [];
|
|
2347
|
+
return unindexedForeignKeys(table).map((key) => ({
|
|
2348
|
+
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(", ")});\`.`,
|
|
2349
|
+
target: `${qualified(table)}.${key.name}`,
|
|
2350
|
+
object: tableObject(table)
|
|
2351
|
+
}));
|
|
2352
|
+
});
|
|
2353
|
+
}
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
code: "BS217",
|
|
2357
|
+
severity: "warning",
|
|
2358
|
+
title: "Tenant foreign key that can cross tenants",
|
|
2359
|
+
description: "When a tenant table references another tenant table by `id` alone, a row can point at a parent in another organization; RLS checks each row, not the pair. Reference `(id, organization_id)` with a composite foreign key, backed by `unique (id, organization_id)` on the parent.",
|
|
2360
|
+
check: (context) => {
|
|
2361
|
+
const column = context.config.plugins.tenant?.column;
|
|
2362
|
+
if (!column) return [];
|
|
2363
|
+
const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
|
|
2364
|
+
const hasTenant = (table) => table?.columns.some((entry) => entry.name === column) ?? false;
|
|
2365
|
+
return exposed(context).flatMap((table) => {
|
|
2366
|
+
if (table.kind !== "table" || !hasTenant(table)) return [];
|
|
2367
|
+
return table.foreignKeys.flatMap((key) => {
|
|
2368
|
+
if (key.columns.includes(column)) return [];
|
|
2369
|
+
const parent = tables.get(`${key.refSchema}.${key.refTable}`);
|
|
2370
|
+
if (!hasTenant(parent) || parent === table) return [];
|
|
2371
|
+
return [{
|
|
2372
|
+
message: `${qualified(table)}.${key.name} references ${key.refSchema}.${key.refTable} (${key.refColumns.join(", ")}) without ${column}, so a row can point at another tenant's ${key.refTable}. Add \`unique (${[...key.refColumns, column].join(", ")})\` to ${key.refTable} and reference \`(${[...key.columns, column].join(", ")})\`.`,
|
|
2373
|
+
target: `${qualified(table)}.${key.name}`,
|
|
2374
|
+
object: tableObject(table)
|
|
2375
|
+
}];
|
|
2376
|
+
});
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
code: "BS218",
|
|
2382
|
+
severity: "info",
|
|
2383
|
+
title: "Soft-delete table without a partial index",
|
|
2384
|
+
description: "Queries on a table with `deleted_at` or `archived_at` read the live rows (`where deleted_at is null`). A partial index with that predicate stays small and matches those queries.",
|
|
2385
|
+
check: (context) => exposed(context).flatMap((table) => {
|
|
2386
|
+
if (table.kind !== "table") return [];
|
|
2387
|
+
const column = table.columns.find((entry) => SOFT_DELETE.test(entry.name) && entry.nullable);
|
|
2388
|
+
if (!column) return [];
|
|
2389
|
+
const isNull = new RegExp(`"?${escape$2(column.name)}"?\\s+IS\\s+NULL`, "i");
|
|
2390
|
+
if (table.indexes.some((index) => index.partial && (index.predicate === void 0 || index.predicate !== null && isNull.test(index.predicate)))) return [];
|
|
2391
|
+
return [{
|
|
2392
|
+
message: `${qualified(table)} soft-deletes with ${column.name}, but no index has \`where ${column.name} is null\`. Index the live rows you list, for example \`create index on ${qualified(table)} (created_at desc) where ${column.name} is null;\`.`,
|
|
2393
|
+
target: `${qualified(table)}.${column.name}`,
|
|
2394
|
+
object: tableObject(table)
|
|
2395
|
+
}];
|
|
2396
|
+
})
|
|
2397
|
+
},
|
|
2398
|
+
{
|
|
2399
|
+
code: "BS219",
|
|
2400
|
+
severity: "warning",
|
|
2401
|
+
title: "Containment filter on a column without a GIN index",
|
|
2402
|
+
description: "`@>`, `<@`, `?` and `&&` on jsonb and array columns, and the `.contains()`, `.containedBy()` and `.overlaps()` filters, can only use a GIN index. Without one, every query reads the whole table. Doctor looks in policies, exposed functions and `doctor.sources`.",
|
|
2403
|
+
check: (context) => {
|
|
2404
|
+
const tables = exposed(context).filter((table) => table.kind === "table");
|
|
2405
|
+
const policyText = (table) => withoutStrings(table.policies.map((policy) => `${policy.using ?? ""} ${policy.check ?? ""}`).join(" "));
|
|
2406
|
+
const functions = context.snapshot.generator.functions.filter((fn) => context.config.schemas.includes(fn.schema) && fn.definition).map((fn) => withoutStrings(fn.definition)).join("\n");
|
|
2407
|
+
const filtered = /* @__PURE__ */ new Set();
|
|
2408
|
+
for (const file of context.sources) for (const match of file.text.matchAll(CLIENT_CONTAINS)) filtered.add(match[1]);
|
|
2409
|
+
return tables.flatMap((table) => {
|
|
2410
|
+
const own = policyText(table);
|
|
2411
|
+
return table.columns.filter((column) => {
|
|
2412
|
+
if (!column.isArray && column.udt !== "jsonb") return false;
|
|
2413
|
+
if (table.indexes.some((index) => index.columns[0] === column.name && (index.method === void 0 || index.method === "gin"))) return false;
|
|
2414
|
+
const used = new RegExp(`"?${escape$2(column.name)}"?\\s*${CONTAINS}|${CONTAINS}\\s*[\\w."]*\\b${escape$2(column.name)}\\b`);
|
|
2415
|
+
return filtered.has(column.name) || used.test(own) || functions.includes(table.name) && used.test(functions);
|
|
2416
|
+
}).map((column) => ({
|
|
2417
|
+
message: `${qualified(table)}.${column.name} is filtered by containment but has no GIN index, so each query scans the table. Run \`create index on ${qualified(table)} using gin (${column.name}${column.udt === "jsonb" ? " jsonb_path_ops" : ""});\`${column.udt === "jsonb" ? " (`jsonb_path_ops` supports `@>` only; drop it for `?`)" : ""}.`,
|
|
2418
|
+
target: `${qualified(table)}.${column.name}`,
|
|
2419
|
+
object: tableObject(table)
|
|
2420
|
+
}));
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
code: "BS220",
|
|
2426
|
+
severity: "info",
|
|
2427
|
+
title: "Column type to avoid",
|
|
2428
|
+
description: "`timestamp` drops the time zone, `varchar(n)` and `char(n)` only add a length check `text` does with a constraint, `money` rounds by locale and `json` reparses on every read. Use `timestamptz`, `text`, `numeric` and `jsonb`.",
|
|
2429
|
+
check: (context) => exposed(context).flatMap((table) => {
|
|
2430
|
+
if (table.kind !== "table") return [];
|
|
2431
|
+
const columns = table.columns.filter((column) => column.typeSchema === "pg_catalog" && column.udt in AVOID);
|
|
2432
|
+
if (columns.length === 0) return [];
|
|
2433
|
+
return [{
|
|
2434
|
+
message: `${qualified(table)}: ${columns.map((column) => `${column.name} is ${column.udt}, use ${AVOID[column.udt]}`).join("; ")}.`,
|
|
2435
|
+
target: qualified(table),
|
|
2436
|
+
object: tableObject(table)
|
|
2437
|
+
}];
|
|
2438
|
+
})
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
code: "BS221",
|
|
2442
|
+
severity: "warning",
|
|
2443
|
+
title: "Direct database connection in a serverless app",
|
|
2444
|
+
description: "Each serverless or edge invocation opens its own connection. Through the direct host or the session pooler on port 5432 they exhaust `max_connections`; the transaction pooler on port 6543 shares them. The direct host is also IPv6 only.",
|
|
2445
|
+
check: (context) => {
|
|
2446
|
+
if (!(context.sources.some((file) => SERVERLESS.test(file.text)) || context.envFiles.some((file) => /^\s*VERCEL\w*=/m.test(file.text)))) return [];
|
|
2447
|
+
return context.envFiles.flatMap((file) => lines(file).flatMap(({ line, text }) => {
|
|
2448
|
+
if (/^\s*#/.test(text) || !DIRECT_URL.test(text)) return [];
|
|
2449
|
+
const key = /^\s*(?:export\s+)?(\w+)\s*=/.exec(text)?.[1];
|
|
2450
|
+
return [{
|
|
2451
|
+
message: `${file.path}${key ? `:${key}` : ""} connects through port 5432 or the direct host, but the app runs on serverless functions. Use the transaction pooler: \`postgres://postgres.<ref>:<password>@<region>.pooler.supabase.com:6543/postgres\`.`,
|
|
2452
|
+
target: `${file.path}${key ? `:${key}` : ""}`,
|
|
2453
|
+
location: {
|
|
2454
|
+
file: file.path,
|
|
2455
|
+
line
|
|
2456
|
+
}
|
|
2457
|
+
}];
|
|
2458
|
+
}));
|
|
2459
|
+
}
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
code: "BS222",
|
|
2463
|
+
severity: "warning",
|
|
2464
|
+
title: "Column that can exceed a JavaScript number",
|
|
2465
|
+
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`.",
|
|
2466
|
+
check: (context) => {
|
|
2467
|
+
const { int8, numeric } = context.config.codecs;
|
|
2468
|
+
if (int8 !== "number" && numeric !== "number") return [];
|
|
2469
|
+
return exposed(context).flatMap((table) => {
|
|
2470
|
+
if (configFor(context.config.tables, table.schema, table.name)?.exclude) return [];
|
|
2471
|
+
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");
|
|
2472
|
+
if (columns.length === 0) return [];
|
|
2473
|
+
const codecs = [...columns.some((column) => column.udt === "int8") ? ["`int8: \"bigint\"`"] : [], ...columns.some((column) => column.udt === "numeric") ? ["`numeric: \"string\"`"] : []];
|
|
2474
|
+
return [{
|
|
2475
|
+
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\`.`,
|
|
2476
|
+
target: qualified(table),
|
|
2477
|
+
object: tableObject(table)
|
|
2478
|
+
}];
|
|
2479
|
+
});
|
|
2480
|
+
}
|
|
2481
|
+
},
|
|
2482
|
+
{
|
|
2483
|
+
code: "BS223",
|
|
2484
|
+
severity: "info",
|
|
2485
|
+
title: "Function returns an int8 or numeric scalar",
|
|
2486
|
+
description: "PostgREST sends a scalar `int8` or `numeric` function result as a JSON number, which loses precision past 2^53 (or 15 to 17 significant digits) before any codec sees it. `$rpc` casts codec columns of table and record results to text, but a scalar has no column to cast. Return it as `text` when it can be that large.",
|
|
2487
|
+
check: (context) => {
|
|
2488
|
+
const schemas = exposedSchemas(context);
|
|
2489
|
+
return context.snapshot.generator.functions.filter((fn) => schemas.includes(fn.schema) && WIDE_RESULT.test(fn.return_type)).map((fn) => ({
|
|
2490
|
+
message: `${fn.schema}.${fn.name}(${fn.identity_argument_types}) returns ${fn.is_set_returning_function ? "setof " : ""}${fn.return_type}, which PostgREST sends as a JSON number and \`$rpc\` can't cast. Return it as text (\`returns ${fn.return_type.replace("bigint", "text").replace("numeric", "text")}\`, \`select total::text\`) when it can pass 2^53, or add BS223 to \`doctor.ignore\` when it stays small.`,
|
|
2491
|
+
target: `${fn.schema}.${fn.name}`,
|
|
2492
|
+
object: {
|
|
2493
|
+
kind: "function",
|
|
2494
|
+
schema: fn.schema,
|
|
2495
|
+
name: fn.name
|
|
2496
|
+
}
|
|
2497
|
+
}));
|
|
2498
|
+
}
|
|
2499
|
+
},
|
|
2500
|
+
{
|
|
2501
|
+
code: "BS226",
|
|
2502
|
+
severity: "warning",
|
|
2503
|
+
title: "Prepared statements through the transaction pooler",
|
|
2504
|
+
description: "The transaction pooler on port 6543 hands each transaction to any server connection, so a named prepared statement from one transaction is missing in the next and the query fails with `prepared statement does not exist`. postgres.js prepares statements unless `prepare: false`, and Prisma does unless the URL has `pgbouncer=true`. `pg` (and `better-supabase/postgres`) sends unnamed statements, which are safe. Doctor looks for a `:6543` URL in the env files and `doctor.sources`.",
|
|
2505
|
+
check: (context) => {
|
|
2506
|
+
const urls = [...context.envFiles, ...context.sources].flatMap((file) => lines(file).filter(({ text }) => !/^\s*(?:#|\/\/)/.test(text) && TRANSACTION_POOLER.test(text)).map(({ line, text }) => ({
|
|
2507
|
+
file,
|
|
2508
|
+
line,
|
|
2509
|
+
text
|
|
2510
|
+
})));
|
|
2511
|
+
if (urls.length === 0) return [];
|
|
2512
|
+
const findings = [];
|
|
2513
|
+
for (const file of context.sources) {
|
|
2514
|
+
if (!POSTGRES_JS.test(file.text) || PREPARE_OFF.test(file.text)) continue;
|
|
2515
|
+
findings.push({
|
|
2516
|
+
message: `${file.path} creates a postgres.js client without \`prepare: false\`, and the project connects through the transaction pooler on port 6543, so its prepared statements fail on the next transaction. Pass \`postgres(url, { prepare: false })\`.`,
|
|
2517
|
+
target: `${file.path}:postgres`,
|
|
2518
|
+
location: {
|
|
2519
|
+
file: file.path,
|
|
2520
|
+
line: lineOf(file.text, POSTGRES_JS) ?? 1
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
if (!context.sources.some((file) => PRISMA.test(file.text))) return findings;
|
|
2525
|
+
for (const { file, line, text } of urls) {
|
|
2526
|
+
if (/[?&]pgbouncer=true\b/.test(text)) continue;
|
|
2527
|
+
const key = /^\s*(?:export\s+)?(\w+)\s*=/.exec(text)?.[1];
|
|
2528
|
+
const target = `${file.path}${key ? `:${key}` : `:${line}`}`;
|
|
2529
|
+
findings.push({
|
|
2530
|
+
message: `${target} connects Prisma through the transaction pooler on port 6543 without \`pgbouncer=true\`, so Prisma's prepared statements fail on the next transaction. Add \`?pgbouncer=true\` to the URL, and use the session pooler or direct URL as \`directUrl\` for migrations.`,
|
|
2531
|
+
target,
|
|
2532
|
+
location: {
|
|
2533
|
+
file: file.path,
|
|
2534
|
+
line
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
return findings;
|
|
2539
|
+
}
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
code: "BS227",
|
|
2543
|
+
severity: "info",
|
|
2544
|
+
title: "Offset pagination or exact counts",
|
|
2545
|
+
description: "`pagination: \"offset\"` on a resource or list reads and discards every row before the page, so late pages get slower as the table grows, and rows written between requests shift the pages. `count: \"exact\"` counts every matching row on each request. Use `pagination: \"cursor\"`, and `count: \"planned\"` or `\"estimated\"` on large tables. Doctor looks in `doctor.sources`.",
|
|
2546
|
+
check: (context) => context.sources.flatMap((file) => lines(file).flatMap(({ line, text }) => {
|
|
2547
|
+
if (/^\s*(?:\/\/|\*)/.test(text)) return [];
|
|
2548
|
+
const found = [];
|
|
2549
|
+
if (OFFSET_PAGINATION.test(text)) found.push({
|
|
2550
|
+
message: `${file.path}:${line} uses \`pagination: "offset"\`, so each page reads and skips every earlier row. Use \`pagination: "cursor"\` unless the UI needs page numbers, and keep \`[api] max_rows\` set so no request reads the whole table.`,
|
|
2551
|
+
target: `${file.path}:${line}:pagination`,
|
|
2552
|
+
location: {
|
|
2553
|
+
file: file.path,
|
|
2554
|
+
line
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
if (EXACT_COUNT.test(text)) found.push({
|
|
2558
|
+
message: `${file.path}:${line} uses \`count: "exact"\`, which counts every matching row on each request. Use \`count: "planned"\` or \`"estimated"\` when the table can grow large.`,
|
|
2559
|
+
target: `${file.path}:${line}:count`,
|
|
2560
|
+
location: {
|
|
2561
|
+
file: file.path,
|
|
2562
|
+
line
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
return found;
|
|
2566
|
+
}))
|
|
2567
|
+
},
|
|
2568
|
+
{
|
|
2569
|
+
code: "BS228",
|
|
2570
|
+
severity: "warning",
|
|
2571
|
+
title: "App table column drifted from appTables",
|
|
2572
|
+
description: "A column of an app table has a different type or nullability in the database than `appTables` gives it, usually after an edit by hand or a migration that wasn't regenerated. `gen` types the column from `appTables`, so the app reads values the database doesn't hold. A table or column the database doesn't have yet is left out: that is a migration not applied.",
|
|
2573
|
+
check: (context) => {
|
|
2574
|
+
const columns = new Map(context.snapshot.generator.columns.filter((column) => column.schema === "public").map((column) => [`${column.table}.${column.name}`, column]));
|
|
2575
|
+
return Object.entries(context.config.appTables).flatMap(([table, config]) => Object.entries(config.columns ?? {}).flatMap(([name, column]) => {
|
|
2576
|
+
const found = columns.get(`${table}.${name}`);
|
|
2577
|
+
if (found === void 0) return [];
|
|
2578
|
+
const format = APP_COLUMN_FORMAT[column.type];
|
|
2579
|
+
const nullable = column.nullable !== false;
|
|
2580
|
+
const drift = [...found.format === format ? [] : [`is ${found.format} in the database and ${column.type} in appTables`], ...found.is_nullable === nullable ? [] : [nullable ? "is not null in the database and nullable in appTables" : "is nullable in the database and required in appTables"]];
|
|
2581
|
+
return drift.length === 0 ? [] : [{
|
|
2582
|
+
message: `public.${table}.${name} ${drift.join(", and ")}. Change appTables to match the database, or regenerate the migration with \`better-supabase sql sync\` and apply it. Follow the expand-and-contract steps from \`better-supabase apply\` when rows depend on the old shape.`,
|
|
2583
|
+
target: `public.${table}.${name}`,
|
|
2584
|
+
object: {
|
|
2585
|
+
kind: "table",
|
|
2586
|
+
schema: "public",
|
|
2587
|
+
name: table
|
|
2588
|
+
}
|
|
2589
|
+
}];
|
|
2590
|
+
}));
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
];
|
|
2594
|
+
//#endregion
|
|
2595
|
+
//#region src/cli/doctor/workflow-builder.ts
|
|
2596
|
+
const BUILDER_EXECUTOR = /\b(?:GraphStepCall|executeGraph|nodeRunReporter)\b/;
|
|
2597
|
+
const ADMIN_CALL = /\badmin\s*\(/;
|
|
2598
|
+
function adminInBuilderExecutor(context) {
|
|
2599
|
+
return context.sources.flatMap((file) => {
|
|
2600
|
+
if (!BUILDER_EXECUTOR.test(file.text)) return [];
|
|
2601
|
+
const line = lineOf(file.text, ADMIN_CALL);
|
|
2602
|
+
if (line === void 0) return [];
|
|
2603
|
+
return [{
|
|
2604
|
+
message: `${file.path} runs workflow-builder steps and calls admin(), which bypasses RLS for every step. Resolve the publisher's claims with builder.versions.runAs(call.version) in each step and query as them, so a demoted publisher's workflow stops.`,
|
|
2605
|
+
target: `${file.path}:admin`,
|
|
2606
|
+
location: {
|
|
2607
|
+
file: file.path,
|
|
2608
|
+
line
|
|
2609
|
+
}
|
|
2610
|
+
}];
|
|
2611
|
+
});
|
|
2612
|
+
}
|
|
2613
|
+
const WORKFLOW_BUILDER_RULES = [{
|
|
2614
|
+
code: "BS333",
|
|
2615
|
+
severity: "warning",
|
|
2616
|
+
title: "admin() inside a workflow-builder executor",
|
|
2617
|
+
description: "A file that runs workflow-builder steps calls `admin()`. Steps run with the service role's rights instead of the publisher's, so a published workflow keeps running after its publisher loses a permission.",
|
|
2618
|
+
check: adminInBuilderExecutor
|
|
2619
|
+
}];
|
|
2620
|
+
//#endregion
|
|
2621
|
+
//#region src/cli/doctor/rules.ts
|
|
2622
|
+
const DOCS_URL = "https://bettersupabase.com/docs/cli/doctor";
|
|
2623
|
+
const writes = (policy) => policy.command === "all" || policy.command === "insert" || policy.command === "update" || policy.command === "delete";
|
|
2624
|
+
const publicRoles = (policy) => policy.roles.length === 0 || policy.roles.some((role) => role === "public" || role === "anon");
|
|
2625
|
+
const isTrue = (expression) => expression !== null && /^\(*\s*true\s*\)*$/i.test(expression.trim());
|
|
2626
|
+
async function moduleVersionsBehind(context) {
|
|
2627
|
+
const findings = [];
|
|
2628
|
+
const upgrade = "Run `better-supabase sql upgrade`";
|
|
2629
|
+
for (const { file, path, installed, behind } of await moduleFiles(context)) {
|
|
2630
|
+
if (!behind || installed === void 0) continue;
|
|
2631
|
+
findings.push({
|
|
2632
|
+
message: `${path} has ${file.module} version ${String(installed)}; this release ships version ${String(moduleFileVersion(file.contents)?.version ?? 1)}. ${upgrade}, then \`${migrationCommand(workflowOf(context))}\`.`,
|
|
2633
|
+
target: path,
|
|
2634
|
+
location: {
|
|
2635
|
+
file: path,
|
|
2636
|
+
line: 1
|
|
2637
|
+
}
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
const db = context.database;
|
|
2641
|
+
if (!db || "skipped" in db || context.config.blockNames.length === 0) return findings;
|
|
2642
|
+
let rows;
|
|
2643
|
+
try {
|
|
2644
|
+
rows = await db.query("select name, version from better_supabase.modules");
|
|
2645
|
+
} catch {
|
|
2646
|
+
return findings;
|
|
2647
|
+
}
|
|
2648
|
+
for (const row of rows) {
|
|
2649
|
+
const module = SQL_MODULES[row.name];
|
|
2650
|
+
if (!module || !context.config.blockNames.includes(row.name)) continue;
|
|
2651
|
+
const version = moduleVersion(module);
|
|
2652
|
+
if (row.version >= version) continue;
|
|
2653
|
+
findings.push({
|
|
2654
|
+
message: `The database has ${row.name} version ${String(row.version)}; this release ships version ${String(version)}. ${upgrade} and apply the migrations it prints.`,
|
|
2655
|
+
target: `better_supabase.modules.${row.name}`
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
return findings;
|
|
2659
|
+
}
|
|
2660
|
+
function authSetting(context, key) {
|
|
2661
|
+
return context.configToml ? tomlGet(context.configToml.document, ["auth", key]) : void 0;
|
|
2662
|
+
}
|
|
2663
|
+
function tomlLocation(context, key) {
|
|
2664
|
+
if (!context.configToml) return;
|
|
2665
|
+
const line = lineOf(context.configToml.text, new RegExp(`^\\s*${key}\\s*=`)) ?? lineOf(context.configToml.text, /^\[auth\]/);
|
|
2666
|
+
return line ? {
|
|
2667
|
+
file: context.configToml.path,
|
|
2668
|
+
line
|
|
2669
|
+
} : void 0;
|
|
2670
|
+
}
|
|
2671
|
+
const PUBLIC_PREFIXES = [
|
|
2672
|
+
"NEXT_PUBLIC_",
|
|
2673
|
+
"VITE_",
|
|
2674
|
+
"PUBLIC_",
|
|
2675
|
+
"EXPO_PUBLIC_",
|
|
2676
|
+
"NUXT_PUBLIC_"
|
|
2677
|
+
];
|
|
2678
|
+
function envEntries(file) {
|
|
2679
|
+
return file.text.split("\n").flatMap((raw, index) => {
|
|
2680
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
2681
|
+
if (!match) return [];
|
|
2682
|
+
const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
2683
|
+
return [{
|
|
2684
|
+
key: match[1],
|
|
2685
|
+
value,
|
|
2686
|
+
line: index + 1
|
|
2687
|
+
}];
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* `db.x.aggregate(`, an `_sum:`/`_avg:`/`_min:`/`_max:` include or a list
|
|
2692
|
+
* with `facetCounts: true`.
|
|
2693
|
+
*/
|
|
2694
|
+
const AGGREGATE_USE = /\.aggregate\(|\b_(?:sum|avg|min|max)\s*:\s*\{|\bfacetCounts\s*:\s*true\b/;
|
|
2695
|
+
/**
|
|
2696
|
+
* A policy that reads memberships or the tenant claim: `is_member(...)`,
|
|
2697
|
+
* `current_tenant_id()`, `memberships`. The authorization provider's
|
|
2698
|
+
* functions (`authorization.requires`) count too.
|
|
2699
|
+
*/
|
|
2700
|
+
const TENANT_HELPER = /member|tenant|current_org|org_id/i;
|
|
2701
|
+
/** A foreign key to the tenant table itself, whose own policies are usually read-only. */
|
|
2702
|
+
const TENANT_COLUMN = /^(?:organization|organization|tenant|team|workspace|account)_id$/;
|
|
2703
|
+
const COMMANDS = [
|
|
2704
|
+
"select",
|
|
2705
|
+
"insert",
|
|
2706
|
+
"update",
|
|
2707
|
+
"delete"
|
|
2708
|
+
];
|
|
2709
|
+
const MFA_FACTORS = /\bauth\.\s*"?mfa_factors\b/i;
|
|
2710
|
+
const RULES = [
|
|
2711
|
+
...[
|
|
2712
|
+
advisorRule("BS100", "security", "Supabase security advisor", "Findings from the Security Advisor (splinter): RLS disabled in exposed schemas, RLS without policies, mutable search_path, security definer functions callable by anon, exposed auth.users and more. Hosted projects use the Management API; local stacks and direct connections run the pinned splinter.sql."),
|
|
2713
|
+
{
|
|
2714
|
+
code: "BS103",
|
|
2715
|
+
severity: "error",
|
|
2716
|
+
title: "Policy allows anonymous writes",
|
|
2717
|
+
description: "A permissive policy with `true` for anon or public lets anyone insert, update or delete rows.",
|
|
2718
|
+
check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => policy.permissive && writes(policy) && publicRoles(policy) && (isTrue(policy.using) || isTrue(policy.check))).map((policy) => ({
|
|
2719
|
+
message: `Policy "${policy.name}" on ${qualified(table)} allows ${policy.command} for ${policy.roles.join(", ") || "public"} with \`true\`.`,
|
|
2720
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2721
|
+
object: policyObject(table, policy)
|
|
2722
|
+
})))
|
|
2723
|
+
},
|
|
2724
|
+
{
|
|
2725
|
+
code: "BS106",
|
|
2726
|
+
severity: "error",
|
|
2727
|
+
title: "Table not granted to the Data API",
|
|
2728
|
+
description: "Hosted projects grant new tables to anon and authenticated unless the project turns default privileges off (auto_expose_new_tables = false locally). 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, except tables with RLS on and no permissive policy under `blocks.grants.options.fromPolicies`, which are service-only. Tables marked `serviceRole` in `tables` must have no grants for either role.",
|
|
2729
|
+
check: (context) => {
|
|
2730
|
+
const auto = context.configToml ? tomlGet(context.configToml.document, ["api", "auto_expose_new_tables"]) : void 0;
|
|
2731
|
+
const note = auto === false || auto === "false" ? " config.toml sets [api] auto_expose_new_tables = false, so new tables start without grants." : "";
|
|
2732
|
+
return exposed(context).flatMap((table) => {
|
|
2733
|
+
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2734
|
+
if (options?.exclude) return [];
|
|
2735
|
+
const granted = (role) => new Set(table.grants.filter((grant) => grant.role === role || grant.role === "PUBLIC").flatMap((grant) => grant.privileges.map((privilege) => privilege.toLowerCase())));
|
|
2736
|
+
if (options?.serviceRole) {
|
|
2737
|
+
const fromPublic = table.grants.some((grant) => grant.role === "PUBLIC" && grant.privileges.length > 0);
|
|
2738
|
+
return ["anon", "authenticated"].flatMap((role) => {
|
|
2739
|
+
const have = [...granted(role)];
|
|
2740
|
+
if (have.length === 0) return [];
|
|
2741
|
+
const from = fromPublic ? `${role}, public` : role;
|
|
2742
|
+
return [{
|
|
2743
|
+
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};\`.`,
|
|
2744
|
+
target: `${qualified(table)}:${role}`,
|
|
2745
|
+
object: tableObject(table)
|
|
2746
|
+
}];
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
const serviceOnly = context.config.blockNames.includes("grants") && context.config.blocks["grants"]?.options?.["fromPolicies"] === true && table.rls && !table.policies.some((policy) => policy.permissive && (appliesTo(policy, "anon") || appliesTo(policy, "authenticated")));
|
|
2750
|
+
const wanted = context.config.expose[qualified(table)] ?? context.config.expose[table.name] ?? {
|
|
2751
|
+
anon: [],
|
|
2752
|
+
authenticated: serviceOnly ? [] : ["select"]
|
|
2753
|
+
};
|
|
2754
|
+
return ["anon", "authenticated"].flatMap((role) => {
|
|
2755
|
+
const have = granted(role);
|
|
2756
|
+
const missing = wanted[role].filter((privilege) => !privilege.includes("(") && !have.has(privilege));
|
|
2757
|
+
if (missing.length === 0) return [];
|
|
2758
|
+
return [{
|
|
2759
|
+
message: `${role} has no ${missing.join(", ")} on ${qualified(table)}, so the Data API answers 42501. Add it to \`expose\` and run \`better-supabase add grants\`, or run \`grant ${missing.join(", ")} on table ${qualified(table)} to ${role};\`.${note}`,
|
|
2760
|
+
target: `${qualified(table)}:${role}`,
|
|
2761
|
+
object: tableObject(table)
|
|
2762
|
+
}];
|
|
2763
|
+
});
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
},
|
|
2767
|
+
{
|
|
2768
|
+
code: "BS107",
|
|
2769
|
+
severity: "warning",
|
|
2770
|
+
title: "Tenant table without a policy for every command",
|
|
2771
|
+
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`.",
|
|
2772
|
+
check: (context) => {
|
|
2773
|
+
const column = context.config.plugins.tenant?.column;
|
|
2774
|
+
const providerFunctions = [...new Set((context.config.authorization?.requires ?? []).flatMap((entry) => [entry.function, entry.function.slice(entry.function.indexOf(".") + 1)]))];
|
|
2775
|
+
const tables = exposed(context);
|
|
2776
|
+
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}`)));
|
|
2777
|
+
return tables.flatMap((table) => {
|
|
2778
|
+
if (table.kind !== "table" || !table.rls) return [];
|
|
2779
|
+
if (roots.has(qualified(table))) return [];
|
|
2780
|
+
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2781
|
+
if (options?.exclude || options?.serviceRole) return [];
|
|
2782
|
+
const forAuthenticated = (policy) => policy.roles.some((role) => ["authenticated", "public"].includes(role));
|
|
2783
|
+
const granting = table.policies.filter((policy) => policy.permissive && forAuthenticated(policy));
|
|
2784
|
+
if (granting.length === 0) return [];
|
|
2785
|
+
if (!(column !== void 0 && table.columns.some((entry) => entry.name === column) || granting.some((policy) => {
|
|
2786
|
+
const text = [
|
|
2787
|
+
policy.using ?? "",
|
|
2788
|
+
policy.check ?? "",
|
|
2789
|
+
...policy.functions ?? []
|
|
2790
|
+
].join(" ");
|
|
2791
|
+
return TENANT_HELPER.test(text) || providerFunctions.some((fn) => text.includes(fn));
|
|
2792
|
+
}))) return [];
|
|
2793
|
+
const denying = table.policies.filter((policy) => !policy.permissive && forAuthenticated(policy) && /^\s*false\s*$/i.test(policy.using ?? ""));
|
|
2794
|
+
const covered = new Set([...granting, ...denying].flatMap((policy) => policy.command === "all" ? COMMANDS : [policy.command]));
|
|
2795
|
+
const missing = COMMANDS.filter((command) => !covered.has(command));
|
|
2796
|
+
if (missing.length === 0) return [];
|
|
2797
|
+
return [{
|
|
2798
|
+
message: `${qualified(table)} is tenant-scoped but has no ${missing.join(", ")} policy for authenticated, so ${missing.join(" and ")} silently match no rows. Add the policies, or state the intent with a restrictive \`using (false)\` policy.`,
|
|
2799
|
+
target: qualified(table),
|
|
2800
|
+
object: tableObject(table)
|
|
2801
|
+
}];
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
},
|
|
2805
|
+
{
|
|
2806
|
+
code: "BS108",
|
|
2807
|
+
severity: "error",
|
|
2808
|
+
title: "Policy reads auth.mfa_factors directly",
|
|
2809
|
+
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.",
|
|
2810
|
+
check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => MFA_FACTORS.test(`${policy.using ?? ""} ${policy.check ?? ""}`)).map((policy) => ({
|
|
2811
|
+
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 add mfa\` instead.`,
|
|
2812
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2813
|
+
object: policyObject(table, policy)
|
|
2814
|
+
})))
|
|
2815
|
+
},
|
|
2816
|
+
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."),
|
|
2817
|
+
{
|
|
2818
|
+
code: "BS204",
|
|
2819
|
+
severity: "warning",
|
|
2820
|
+
title: "Tenant column without an index",
|
|
2821
|
+
description: "The tenant plugin and tenant policies filter every query on this column. A view is left out: a query on it uses the indexes of its tables.",
|
|
2822
|
+
check: (context) => {
|
|
2823
|
+
const tenant = context.config.plugins.tenant;
|
|
2824
|
+
if (!tenant) return [];
|
|
2825
|
+
return exposed(context).filter((table) => table.kind === "table" && table.columns.some((column) => column.name === tenant.column) && !table.indexes.some((index) => !index.partial && index.columns[0] === tenant.column)).map((table) => ({
|
|
2826
|
+
message: `${qualified(table)}.${tenant.column} has no index starting with it.`,
|
|
2827
|
+
target: `${qualified(table)}.${tenant.column}`,
|
|
2828
|
+
object: tableObject(table)
|
|
2829
|
+
}));
|
|
2830
|
+
}
|
|
2831
|
+
},
|
|
2832
|
+
{
|
|
2833
|
+
code: "BS210",
|
|
2834
|
+
severity: "warning",
|
|
2835
|
+
title: "Aggregates used while PostgREST disables them",
|
|
2836
|
+
description: "PostgREST rejects `count()`, `sum()` and the other aggregates with PGRST123 unless `pgrst.db_aggregates_enabled` is on for the authenticator role. `aggregate()`, `_sum`/`_avg`/`_min`/`_max` includes and list `facetCounts` need it; `better-supabase/postgres` does not.",
|
|
2837
|
+
check: (context) => {
|
|
2838
|
+
const settings = context.snapshot.extras.roleSettings;
|
|
2839
|
+
if (!settings) return [];
|
|
2840
|
+
const enabled = settings["authenticator"]?.["pgrst.db_aggregates_enabled"];
|
|
2841
|
+
if (enabled === "true" || enabled === "on") return [];
|
|
2842
|
+
for (const file of context.sources) {
|
|
2843
|
+
const line = lineOf(file.text, AGGREGATE_USE);
|
|
2844
|
+
if (line === void 0) continue;
|
|
2845
|
+
return [{
|
|
2846
|
+
message: `${file.path} uses aggregates, but PostgREST has them off, so these requests fail with PGRST123. Run \`alter role authenticator set pgrst.db_aggregates_enabled = 'true'; notify pgrst, 'reload config';\` in a migration.`,
|
|
2847
|
+
target: "authenticator:pgrst.db_aggregates_enabled",
|
|
2848
|
+
location: {
|
|
2849
|
+
file: file.path,
|
|
2850
|
+
line
|
|
2851
|
+
}
|
|
2852
|
+
}];
|
|
2853
|
+
}
|
|
2854
|
+
return [];
|
|
2855
|
+
}
|
|
2856
|
+
},
|
|
2857
|
+
{
|
|
2858
|
+
code: "BS301",
|
|
2859
|
+
severity: "warning",
|
|
2860
|
+
title: "Soft delete hidden by a select policy",
|
|
2861
|
+
description: "PostgREST re-reads updated rows through the select policy. If it hides deleted rows, soft-deleting fails with an RLS error unless the update returns nothing.",
|
|
2862
|
+
check: (context) => {
|
|
2863
|
+
const softDelete = context.config.plugins.softDelete;
|
|
2864
|
+
if (!softDelete) return [];
|
|
2865
|
+
return exposed(context).flatMap((table) => {
|
|
2866
|
+
if (!table.columns.some((column) => column.name === softDelete.column)) return [];
|
|
2867
|
+
return table.policies.filter((policy) => (policy.command === "select" || policy.command === "all") && new RegExp(`\\b${softDelete.column}\\b\\s+IS\\s+NULL`, "i").test(policy.using ?? "")).map((policy) => ({
|
|
2868
|
+
message: `Policy "${policy.name}" on ${qualified(table)} hides rows where ${softDelete.column} is set, so \`softDelete()\` can't read the row back. Filter deleted rows in queries (the plugin does) instead of in the policy.`,
|
|
2869
|
+
target: `${qualified(table)}.${policy.name}`,
|
|
2870
|
+
object: policyObject(table, policy)
|
|
2871
|
+
}));
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
},
|
|
2875
|
+
{
|
|
2876
|
+
code: "BS302",
|
|
2877
|
+
severity: "warning",
|
|
2878
|
+
title: "Bucket differs from the config",
|
|
2879
|
+
description: "A bucket in `buckets` is missing or has different settings in the database.",
|
|
2880
|
+
check: (context) => Object.entries(context.config.buckets).flatMap(([name, bucket]) => {
|
|
2881
|
+
const id = bucket.id ?? name.replaceAll(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
2882
|
+
const defined = defineBucket({
|
|
2883
|
+
id,
|
|
2884
|
+
path: bucket.path,
|
|
2885
|
+
...bucket.public === void 0 ? {} : { public: bucket.public },
|
|
2886
|
+
...bucket.fileSizeLimit === void 0 ? {} : { fileSizeLimit: bucket.fileSizeLimit },
|
|
2887
|
+
...bucket.allowedMimeTypes === void 0 ? {} : { allowedMimeTypes: bucket.allowedMimeTypes },
|
|
2888
|
+
...bucket.versioning === void 0 ? {} : { versioning: bucket.versioning },
|
|
2889
|
+
...bucket.lifecycle === void 0 ? {} : { lifecycle: bucket.lifecycle }
|
|
2890
|
+
});
|
|
2891
|
+
const actual = catalogOf(context).buckets.find((entry) => entry.id === id);
|
|
2892
|
+
const inDatabase = defined.drift(actual).map((drift) => ({
|
|
2893
|
+
message: `Bucket ${id}: ${drift.message}`,
|
|
2894
|
+
target: `storage.buckets.${id}`
|
|
2895
|
+
}));
|
|
2896
|
+
const toml = context.configToml;
|
|
2897
|
+
const declared = toml ? tomlGet(toml.document, [
|
|
2898
|
+
"storage",
|
|
2899
|
+
"buckets",
|
|
2900
|
+
id
|
|
2901
|
+
]) : void 0;
|
|
2902
|
+
if (!toml || !declared || typeof declared !== "object" || Array.isArray(declared)) return inDatabase;
|
|
2903
|
+
const entry = declared;
|
|
2904
|
+
const size = entry["file_size_limit"];
|
|
2905
|
+
const mimes = entry["allowed_mime_types"];
|
|
2906
|
+
const line = lineOf(toml.text, new RegExp(`^\\[storage\\.buckets\\.${id.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]`));
|
|
2907
|
+
const inToml = defined.drift({
|
|
2908
|
+
public: entry["public"] === true,
|
|
2909
|
+
fileSizeLimit: typeof size === "string" || typeof size === "number" ? parseSize(size) : null,
|
|
2910
|
+
allowedMimeTypes: Array.isArray(mimes) ? mimes.map(String) : null
|
|
2911
|
+
}).map((drift) => ({
|
|
2912
|
+
message: `${toml.path} [storage.buckets.${id}]: ${drift.message.replace(`Bucket "${id}" `, "")}`,
|
|
2913
|
+
target: `[storage.buckets.${id}]`,
|
|
2914
|
+
...line ? { location: {
|
|
2915
|
+
file: toml.path,
|
|
2916
|
+
line
|
|
2917
|
+
} } : {}
|
|
2918
|
+
}));
|
|
2919
|
+
return [...inDatabase, ...inToml];
|
|
2920
|
+
})
|
|
2921
|
+
},
|
|
2922
|
+
{
|
|
2923
|
+
code: "BS303",
|
|
2924
|
+
severity: "error",
|
|
2925
|
+
title: "Generated code is out of date",
|
|
2926
|
+
description: "The generated module no longer matches the database, so types and metadata are wrong. Same check as `gen --check`.",
|
|
2927
|
+
check: async (context) => {
|
|
2928
|
+
const stale = [];
|
|
2929
|
+
for (const file of await renderFiles(context.config, context.snapshot)) {
|
|
2930
|
+
const current = await readFile(resolve(context.config.root, file.path), "utf-8").catch(() => void 0);
|
|
2931
|
+
if (!sameText(current, file.contents)) stale.push({
|
|
2932
|
+
message: `${file.path} is ${current === void 0 ? "missing" : "out of date"}. Run \`better-supabase gen\`.`,
|
|
2933
|
+
target: file.path,
|
|
2934
|
+
...current === void 0 ? {} : { location: {
|
|
2935
|
+
file: file.path,
|
|
2936
|
+
line: 1
|
|
2937
|
+
} }
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
return stale;
|
|
2941
|
+
}
|
|
2942
|
+
},
|
|
2943
|
+
{
|
|
2944
|
+
code: "BS304",
|
|
2945
|
+
severity: "warning",
|
|
2946
|
+
title: "SQL module files are out of date",
|
|
2947
|
+
description: "A module listed in `blocks` differs from the version in this release. Same check as `sql sync --check`.",
|
|
2948
|
+
check: async (context) => {
|
|
2949
|
+
const stale = [];
|
|
2950
|
+
const workflow = workflowOf(context);
|
|
2951
|
+
for (const { file, path, current, behind } of await moduleFiles(context)) {
|
|
2952
|
+
if (behind || sameModuleFile(current, file.contents)) continue;
|
|
2953
|
+
const migrated = workflow === "migrations" && file.kind !== "test";
|
|
2954
|
+
const edited = current === void 0 || migrated ? "" : ` If you edited it, move the change to \`blocks.${file.module}\` in better-supabase.config.ts or to the module's SQL hooks: sync overwrites the file.`;
|
|
2955
|
+
const next = file.kind === "data" && !migrated ? "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(workflow)}\`.`;
|
|
2956
|
+
const subject = migrated ? `${file.module} in ${path} is ${current === void 0 ? "missing" : "out of date"}` : `${path} (${file.module}) is ${current === void 0 ? "missing" : "out of date"}`;
|
|
2957
|
+
if (stale.some((finding) => finding.message === `${subject}. ${next}${edited}`)) continue;
|
|
2958
|
+
stale.push({
|
|
2959
|
+
message: `${subject}. ${next}${edited}`,
|
|
2960
|
+
target: path,
|
|
2961
|
+
...current === void 0 ? {} : { location: {
|
|
2962
|
+
file: path,
|
|
2963
|
+
line: 1
|
|
2964
|
+
} }
|
|
2965
|
+
});
|
|
2966
|
+
}
|
|
2967
|
+
return stale;
|
|
2968
|
+
}
|
|
2969
|
+
},
|
|
2970
|
+
{
|
|
2971
|
+
code: "BS316",
|
|
2972
|
+
severity: "warning",
|
|
2973
|
+
title: "Legacy migra diff engine",
|
|
2974
|
+
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. Setting `sql.workflow` to `migra` or `migrations` silences it.",
|
|
2975
|
+
check: (context) => {
|
|
2976
|
+
const toml = context.configToml;
|
|
2977
|
+
if (toml === void 0 || diffEngine(toml) === "pg-delta") return [];
|
|
2978
|
+
if (context.config.sql.workflow !== void 0 && context.config.sql.workflow !== "pg-delta") return [];
|
|
2979
|
+
const schemas = `${toml.dir}/schemas/`;
|
|
2980
|
+
if (!(context.config.blockNames.length > 0 || (context.sqlFiles ?? []).some((file) => file.path.startsWith(schemas)))) return [];
|
|
2981
|
+
const keep = context.config.sql.workflow === void 0 ? " To stay on migra, set `sql.workflow: \"migra\"` in better-supabase.config.ts." : "";
|
|
2982
|
+
return [{
|
|
2983
|
+
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\`.${keep}`,
|
|
2984
|
+
target: toml.path,
|
|
2985
|
+
location: {
|
|
2986
|
+
file: toml.path,
|
|
2987
|
+
line: 1
|
|
2988
|
+
}
|
|
2989
|
+
}];
|
|
2990
|
+
}
|
|
2991
|
+
},
|
|
2992
|
+
{
|
|
2993
|
+
code: "BS305",
|
|
2994
|
+
severity: "warning",
|
|
2995
|
+
title: "Live query table without change broadcasts",
|
|
2996
|
+
description: "A table in `realtime.tables` has no `bs_realtime` trigger, so live queries never hear about its changes.",
|
|
2997
|
+
check: (context) => {
|
|
2998
|
+
const tables = catalogOf(context).tables;
|
|
2999
|
+
return context.config.realtime.tables.flatMap((name) => {
|
|
3000
|
+
const table = tables.find((entry) => entry.name === name || qualified(entry) === name);
|
|
3001
|
+
if (!table) return [{
|
|
3002
|
+
message: `realtime.tables lists "${name}", which doesn't exist.`,
|
|
3003
|
+
target: name
|
|
3004
|
+
}];
|
|
3005
|
+
if (table.triggers.some((trigger) => trigger.name.startsWith("bs_realtime"))) return [];
|
|
3006
|
+
return [{
|
|
3007
|
+
message: `${qualified(table)} is in realtime.tables but has no broadcast trigger. Run \`better-supabase add realtime-tables\`, then \`${migrationCommand(workflowOf(context))}\`.`,
|
|
3008
|
+
target: qualified(table),
|
|
3009
|
+
object: tableObject(table)
|
|
3010
|
+
}];
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
code: "BS306",
|
|
3016
|
+
severity: "warning",
|
|
3017
|
+
title: "Realtime delete events without keys",
|
|
3018
|
+
description: "Tables in the `supabase_realtime` publication send the replica identity of deleted rows. With `nothing`, or `default` without a primary key, delete events carry no keys and clients cannot tell which row went away.",
|
|
3019
|
+
check: (context) => {
|
|
3020
|
+
const published = new Set(catalogOf(context).realtime);
|
|
3021
|
+
return catalogOf(context).tables.filter((table) => published.has(qualified(table)) && (table.replicaIdentity === "NOTHING" || table.replicaIdentity === "DEFAULT" && table.primaryKey.length === 0)).map((table) => ({
|
|
3022
|
+
message: `${qualified(table)} is published to Realtime with replica identity ${table.replicaIdentity?.toLowerCase() ?? "unknown"}${table.primaryKey.length === 0 ? " and no primary key" : ""}. Add a primary key or run \`alter table ${qualified(table)} replica identity full\`.`,
|
|
3023
|
+
target: qualified(table),
|
|
3024
|
+
object: tableObject(table)
|
|
3025
|
+
}));
|
|
3026
|
+
}
|
|
3027
|
+
},
|
|
3028
|
+
{
|
|
3029
|
+
code: "BS311",
|
|
3030
|
+
severity: "warning",
|
|
3031
|
+
title: "SQL module behind its current version",
|
|
3032
|
+
description: "A module file or `better_supabase.modules` records an older module version than this release ships. Same check as `sql upgrade --check`.",
|
|
3033
|
+
check: moduleVersionsBehind
|
|
3034
|
+
},
|
|
3035
|
+
{
|
|
3036
|
+
code: "BS401",
|
|
3037
|
+
severity: "warning",
|
|
3038
|
+
title: "Refresh token reuse interval is 0",
|
|
3039
|
+
description: "Server instances refreshing the same session at once need a reuse interval, or all but one of them sign the user out.",
|
|
3040
|
+
check: (context) => {
|
|
3041
|
+
if (!context.configToml) return [];
|
|
3042
|
+
const rotation = authSetting(context, "enable_refresh_token_rotation");
|
|
3043
|
+
const interval = authSetting(context, "refresh_token_reuse_interval");
|
|
3044
|
+
if (rotation === false || rotation === "false" || interval === void 0 || Number(interval) > 0) return [];
|
|
3045
|
+
const location = tomlLocation(context, "refresh_token_reuse_interval");
|
|
3046
|
+
return [{
|
|
3047
|
+
message: "refresh_token_reuse_interval = 0 with refresh token rotation. Use 10 (the Supabase default) so concurrent refreshes succeed.",
|
|
3048
|
+
target: "[auth] refresh_token_reuse_interval",
|
|
3049
|
+
...location ? { location } : {}
|
|
3050
|
+
}];
|
|
3051
|
+
}
|
|
3052
|
+
},
|
|
3053
|
+
{
|
|
3054
|
+
code: "BS402",
|
|
3055
|
+
severity: "info",
|
|
3056
|
+
title: "Long access token lifetime",
|
|
3057
|
+
description: "Tokens are verified locally without an auth call, so a revoked session stays valid until the token expires.",
|
|
3058
|
+
check: (context) => {
|
|
3059
|
+
if (!context.configToml) return [];
|
|
3060
|
+
const expiry = Number(authSetting(context, "jwt_expiry") ?? 3600);
|
|
3061
|
+
if (expiry <= 3600) return [];
|
|
3062
|
+
const location = tomlLocation(context, "jwt_expiry");
|
|
3063
|
+
return [{
|
|
3064
|
+
message: `jwt_expiry is ${expiry}s. Keep it at 3600 or less; sessions refresh in the proxy.`,
|
|
3065
|
+
target: "[auth] jwt_expiry",
|
|
3066
|
+
...location ? { location } : {}
|
|
3067
|
+
}];
|
|
3068
|
+
}
|
|
3069
|
+
},
|
|
3070
|
+
{
|
|
3071
|
+
code: "BS403",
|
|
3072
|
+
severity: "info",
|
|
3073
|
+
title: "Local stack signs tokens with a shared secret",
|
|
3074
|
+
description: "Hosted projects sign with asymmetric keys. Using the same locally means local tokens verify through JWKS just like production.",
|
|
3075
|
+
check: (context) => {
|
|
3076
|
+
if (!context.configToml || authSetting(context, "signing_keys_path") || tomlGet(context.configToml.document, ["db", "driver"]) !== void 0) return [];
|
|
3077
|
+
const location = tomlLocation(context, "signing_keys_path");
|
|
3078
|
+
return [{
|
|
3079
|
+
message: "No [auth] signing_keys_path. Run `better-supabase keys` to sign local tokens with ES256.",
|
|
3080
|
+
target: "[auth] signing_keys_path",
|
|
3081
|
+
...location ? { location } : {}
|
|
3082
|
+
}];
|
|
3083
|
+
}
|
|
3084
|
+
},
|
|
3085
|
+
{
|
|
3086
|
+
code: "BS406",
|
|
3087
|
+
severity: "warning",
|
|
3088
|
+
title: "Foreign key to auth.users blocks account deletion",
|
|
3089
|
+
description: "A foreign key to `auth.users` with `no action` or `restrict` makes `auth.admin.deleteUser` (and `deleteAccount`) fail with \"Database error deleting user\" while the user has rows. Use `on delete cascade` for data the user owns, or `on delete set null` for records that outlive them.",
|
|
3090
|
+
check: (context) => catalogOf(context).tables.flatMap((table) => table.schema === "auth" ? [] : table.foreignKeys.filter((key) => key.refSchema === "auth" && key.refTable === "users" && (key.onDelete === "no action" || key.onDelete === "restrict")).map((key) => ({
|
|
3091
|
+
message: `${qualified(table)}.${key.name} (${key.columns.join(", ")}) references auth.users with on delete ${key.onDelete}. Deleting the user fails while rows point at it; use on delete cascade or set null.`,
|
|
3092
|
+
target: `${qualified(table)}.${key.name}`,
|
|
3093
|
+
object: tableObject(table)
|
|
3094
|
+
})))
|
|
3095
|
+
},
|
|
3096
|
+
{
|
|
3097
|
+
code: "BS501",
|
|
3098
|
+
severity: "error",
|
|
3099
|
+
title: "Secret in a browser variable",
|
|
3100
|
+
description: "Variables with a public prefix are bundled into client code. A secret key there gives every visitor full access.",
|
|
3101
|
+
check: (context) => context.envFiles.flatMap((file) => envEntries(file).filter((entry) => PUBLIC_PREFIXES.some((prefix) => entry.key.startsWith(prefix)) && (entry.value.startsWith("sb_secret_") || /SECRET|SERVICE_ROLE/.test(entry.key))).map((entry) => ({
|
|
3102
|
+
message: `${entry.key} in ${file.path} is exposed to the browser but holds a secret. Rename it without the public prefix and rotate the key.`,
|
|
3103
|
+
target: `${file.path}:${entry.key}`,
|
|
3104
|
+
location: {
|
|
3105
|
+
file: file.path,
|
|
3106
|
+
line: entry.line
|
|
3107
|
+
}
|
|
3108
|
+
})))
|
|
3109
|
+
},
|
|
3110
|
+
{
|
|
3111
|
+
code: "BS502",
|
|
3112
|
+
severity: "warning",
|
|
3113
|
+
title: "Env file with secrets is not ignored by git",
|
|
3114
|
+
description: "Env files holding a secret key or database password should never be committed.",
|
|
3115
|
+
check: (context) => {
|
|
3116
|
+
const patterns = context.gitignore.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
3117
|
+
const ignored = (path) => patterns.some((pattern) => {
|
|
3118
|
+
const source = pattern.replace(/^\//, "").replaceAll(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
3119
|
+
return new RegExp(`^${source}$`).test(path) || new RegExp(`(^|/)${source}$`).test(path);
|
|
3120
|
+
});
|
|
3121
|
+
return context.envFiles.flatMap((file) => {
|
|
3122
|
+
const secret = envEntries(file).find((entry) => entry.value.startsWith("sb_secret_") || /SECRET_KEY|SERVICE_ROLE|DB_URL|DATABASE_URL/.test(entry.key));
|
|
3123
|
+
if (!secret || ignored(file.path) || file.path.endsWith(".example")) return [];
|
|
3124
|
+
return [{
|
|
3125
|
+
message: `${file.path} contains ${secret.key} but isn't in .gitignore.`,
|
|
3126
|
+
target: file.path,
|
|
3127
|
+
location: {
|
|
3128
|
+
file: file.path,
|
|
3129
|
+
line: secret.line
|
|
3130
|
+
}
|
|
3131
|
+
}];
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
],
|
|
3136
|
+
...RLS_RULES,
|
|
3137
|
+
...POLICY_RULES,
|
|
3138
|
+
...SCHEMA_DESIGN_RULES,
|
|
3139
|
+
...HOOK_RULES,
|
|
3140
|
+
...COOKIE_RULES,
|
|
3141
|
+
...AUTHORIZATION_RULES,
|
|
3142
|
+
...LIVE_RULES,
|
|
3143
|
+
...MODULE_RULES,
|
|
3144
|
+
...PGDELTA_RULES,
|
|
3145
|
+
...LITE_RULES,
|
|
3146
|
+
...DEPENDENCY_RULES,
|
|
3147
|
+
...WORKFLOW_BUILDER_RULES,
|
|
3148
|
+
...FLAG_RULES,
|
|
3149
|
+
...REALTIME_RULES,
|
|
3150
|
+
...ADOPTION_RULES
|
|
3151
|
+
].sort((a, b) => byCodePoint(a.code, b.code));
|
|
3152
|
+
const RULE_CODES = RULES.map((rule) => rule.code);
|
|
3153
|
+
async function runRules(context, rules = RULES) {
|
|
3154
|
+
const findings = [];
|
|
3155
|
+
const scoped = {
|
|
3156
|
+
...context,
|
|
3157
|
+
codes: rules.map((rule) => rule.code)
|
|
3158
|
+
};
|
|
3159
|
+
for (const rule of rules) {
|
|
3160
|
+
const help = `${DOCS_URL}#${rule.code.toLowerCase()}`;
|
|
3161
|
+
const skipped = skippedForMetadata(context.snapshot, rule, help);
|
|
3162
|
+
if (skipped) {
|
|
3163
|
+
findings.push(skipped);
|
|
3164
|
+
continue;
|
|
3165
|
+
}
|
|
3166
|
+
for (const input of await rule.check(scoped)) findings.push({
|
|
3167
|
+
code: rule.code,
|
|
3168
|
+
severity: rule.severity,
|
|
3169
|
+
title: rule.title,
|
|
3170
|
+
help,
|
|
3171
|
+
...input
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
return findings;
|
|
3175
|
+
}
|
|
3176
|
+
//#endregion
|
|
3177
|
+
//#region src/cli/doctor/format.ts
|
|
3178
|
+
const DOCTOR_FORMATS = [
|
|
3179
|
+
"text",
|
|
3180
|
+
"json",
|
|
3181
|
+
"sarif",
|
|
3182
|
+
"github"
|
|
3183
|
+
];
|
|
3184
|
+
const DOCTOR_REPORT_SCHEMA_URL = "https://unpkg.com/better-supabase/schemas/doctor-report-v1.json";
|
|
3185
|
+
const SARIF_SCHEMA_URL = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
|
|
3186
|
+
function summarize(findings) {
|
|
3187
|
+
return {
|
|
3188
|
+
errors: findings.filter((finding) => finding.severity === "error").length,
|
|
3189
|
+
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
|
3190
|
+
infos: findings.filter((finding) => finding.severity === "info").length
|
|
3191
|
+
};
|
|
3192
|
+
}
|
|
3193
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
3194
|
+
const SEVERITY_FORMAT = {
|
|
3195
|
+
error: "red",
|
|
3196
|
+
warning: "yellow",
|
|
3197
|
+
info: "cyan"
|
|
3198
|
+
};
|
|
3199
|
+
function text(findings, paint) {
|
|
3200
|
+
if (findings.length === 0) return "No problems found.";
|
|
3201
|
+
const order = [
|
|
3202
|
+
"error",
|
|
3203
|
+
"warning",
|
|
3204
|
+
"info"
|
|
3205
|
+
];
|
|
3206
|
+
const lines = [...findings].sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity) || byCodePoint(a.code, b.code)).flatMap((finding) => [
|
|
3207
|
+
`${paint(SEVERITY_FORMAT[finding.severity], finding.severity.padEnd(7))} ${paint("bold", finding.code)} ${finding.title}${finding.location ? ` ${finding.location.file}:${finding.location.line}` : ""}`,
|
|
3208
|
+
` ${finding.message}`,
|
|
3209
|
+
` ${finding.help}`
|
|
3210
|
+
]);
|
|
3211
|
+
const summary = summarize(findings);
|
|
3212
|
+
lines.push("", `${plural(summary.errors, "error")}, ${plural(summary.warnings, "warning")}, ${plural(summary.infos, "note")}.`);
|
|
3213
|
+
return lines.join("\n");
|
|
3214
|
+
}
|
|
3215
|
+
function sarifLevel(severity) {
|
|
3216
|
+
switch (severity) {
|
|
3217
|
+
case "error": return "error";
|
|
3218
|
+
case "warning": return "warning";
|
|
3219
|
+
case "info": return "note";
|
|
3220
|
+
default: return severity;
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
function sarif(findings, rules, version, fallback) {
|
|
3224
|
+
return {
|
|
3225
|
+
$schema: SARIF_SCHEMA_URL,
|
|
3226
|
+
version: "2.1.0",
|
|
3227
|
+
runs: [{
|
|
3228
|
+
tool: { driver: {
|
|
3229
|
+
name: "better-supabase doctor",
|
|
3230
|
+
version,
|
|
3231
|
+
informationUri: DOCS_URL,
|
|
3232
|
+
rules: rules.map((rule) => ({
|
|
3233
|
+
id: rule.code,
|
|
3234
|
+
name: rule.title.replaceAll(/[^A-Za-z0-9]+(.)?/g, (_, char) => (char ?? "").toUpperCase()),
|
|
3235
|
+
shortDescription: { text: rule.title },
|
|
3236
|
+
fullDescription: { text: rule.description },
|
|
3237
|
+
helpUri: `${DOCS_URL}#${rule.code.toLowerCase()}`,
|
|
3238
|
+
defaultConfiguration: { level: sarifLevel(rule.severity) }
|
|
3239
|
+
}))
|
|
3240
|
+
} },
|
|
3241
|
+
results: findings.map((finding) => ({
|
|
3242
|
+
ruleId: finding.code,
|
|
3243
|
+
ruleIndex: rules.findIndex((rule) => rule.code === finding.code),
|
|
3244
|
+
level: sarifLevel(finding.severity),
|
|
3245
|
+
message: { text: finding.message },
|
|
3246
|
+
locations: [{ physicalLocation: {
|
|
3247
|
+
artifactLocation: { uri: finding.location?.file ?? fallback },
|
|
3248
|
+
region: { startLine: finding.location?.line ?? 1 }
|
|
3249
|
+
} }],
|
|
3250
|
+
partialFingerprints: { target: `${finding.code}:${finding.target ?? finding.message}` }
|
|
3251
|
+
}))
|
|
3252
|
+
}]
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
const escapeData = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
3256
|
+
const escapeProperty = (value) => escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
3257
|
+
function github(findings) {
|
|
3258
|
+
return findings.map((finding) => {
|
|
3259
|
+
return `::${finding.severity === "info" ? "notice" : finding.severity} ${[...finding.location ? [`file=${escapeProperty(finding.location.file)}`, `line=${finding.location.line}`] : [], `title=${escapeProperty(`${finding.code} ${finding.title}`)}`].join(",")}::${escapeData(`${finding.message} (${finding.help})`)}`;
|
|
3260
|
+
}).join("\n");
|
|
3261
|
+
}
|
|
3262
|
+
function jsonReport(findings, version) {
|
|
3263
|
+
return {
|
|
3264
|
+
$schema: DOCTOR_REPORT_SCHEMA_URL,
|
|
3265
|
+
version: 1,
|
|
3266
|
+
tool: {
|
|
3267
|
+
name: "better-supabase",
|
|
3268
|
+
version
|
|
3269
|
+
},
|
|
3270
|
+
summary: summarize(findings),
|
|
3271
|
+
findings
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
function formatReport(findings, options) {
|
|
3275
|
+
switch (options.format) {
|
|
3276
|
+
case "text": return text(findings, options.paint ?? plain);
|
|
3277
|
+
case "json": return JSON.stringify(jsonReport(findings, options.version), null, 2);
|
|
3278
|
+
case "sarif": return JSON.stringify(sarif(findings, options.rules, options.version, options.fallbackFile), null, 2);
|
|
3279
|
+
case "github": return github(findings);
|
|
3280
|
+
default: return options.format;
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
//#endregion
|
|
3284
|
+
//#region src/cli/doctor/inputs.ts
|
|
3285
|
+
const ENV_FILES = [
|
|
3286
|
+
".env",
|
|
3287
|
+
".env.local",
|
|
3288
|
+
".env.example",
|
|
3289
|
+
".env.development",
|
|
3290
|
+
".env.development.local",
|
|
3291
|
+
".env.production",
|
|
3292
|
+
".env.production.local"
|
|
3293
|
+
];
|
|
3294
|
+
async function readText(root, path) {
|
|
3295
|
+
const absolute = resolve(root, path);
|
|
3296
|
+
return existsSync(absolute) ? {
|
|
3297
|
+
path,
|
|
3298
|
+
text: await readFile(absolute, "utf-8")
|
|
3299
|
+
} : void 0;
|
|
3300
|
+
}
|
|
3301
|
+
/** The declarative schemas in the diff engine's order, then the migrations newest first. */
|
|
3302
|
+
async function sqlFiles(root, toml, workflow) {
|
|
3303
|
+
const read = async (path) => ({
|
|
3304
|
+
path,
|
|
3305
|
+
text: await readFile(resolve(root, path), "utf-8")
|
|
3306
|
+
});
|
|
3307
|
+
const schemas = await Promise.all((await schemaPaths(root, toml, workflow)).files.map(read));
|
|
3308
|
+
const migrationsPath = `${toml?.dir ?? "supabase"}/migrations`;
|
|
3309
|
+
const migrationsDir = resolve(root, migrationsPath);
|
|
3310
|
+
const migrations = existsSync(migrationsDir) ? (await readdir(migrationsDir)).filter((name) => name.endsWith(".sql")).sort().reverse().map((name) => `${migrationsPath}/${name}`) : [];
|
|
3311
|
+
return [...schemas, ...await Promise.all(migrations.map(read))];
|
|
3312
|
+
}
|
|
3313
|
+
/** Files matching `doctor.sources`, skipping dependencies and build output. */
|
|
3314
|
+
async function sourceFiles(config) {
|
|
3315
|
+
const files = [];
|
|
3316
|
+
for await (const path of glob([...config.doctor.sources], {
|
|
3317
|
+
cwd: config.root,
|
|
3318
|
+
exclude: (name) => name === "node_modules" || name === ".next"
|
|
3319
|
+
})) files.push({
|
|
3320
|
+
path,
|
|
3321
|
+
text: await readFile(resolve(config.root, path), "utf-8")
|
|
3322
|
+
});
|
|
3323
|
+
return files.sort((a, b) => byCodePoint(a.path, b.path));
|
|
3324
|
+
}
|
|
3325
|
+
//#endregion
|
|
3326
|
+
//#region src/cli/doctor/advisors.ts
|
|
3327
|
+
/**
|
|
3328
|
+
* splinter (https://github.com/supabase/splinter), the linter behind the
|
|
3329
|
+
* dashboard's Security and Performance Advisors. It has no license, so it is
|
|
3330
|
+
* downloaded at this commit and checked against the hash instead of bundled.
|
|
3331
|
+
*/
|
|
3332
|
+
const SPLINTER_COMMIT = "e74a9e36cb12258cb67d1464bc1cb196e9cd8446";
|
|
3333
|
+
const SPLINTER_SHA256 = "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349";
|
|
3334
|
+
const SPLINTER_URL = `https://raw.githubusercontent.com/supabase/splinter/${SPLINTER_COMMIT}/splinter.sql`;
|
|
3335
|
+
function isLint(value) {
|
|
3336
|
+
const lint = value;
|
|
3337
|
+
return typeof lint === "object" && lint !== null && typeof lint.name === "string" && typeof lint.level === "string" && Array.isArray(lint.categories);
|
|
3338
|
+
}
|
|
3339
|
+
/** The hosted project's advisors (`GET /v1/projects/{ref}/advisors/{category}`). */
|
|
3340
|
+
function managementAdvisors(options) {
|
|
3341
|
+
const base = (options.apiUrl ?? "https://api.supabase.com").replace(/\/$/, "");
|
|
3342
|
+
return {
|
|
3343
|
+
describe: `project ${options.projectRef} advisors (Management API)`,
|
|
3344
|
+
async lints(category) {
|
|
3345
|
+
const url = `${base}/v1/projects/${encodeURIComponent(options.projectRef)}/advisors/${category}`;
|
|
3346
|
+
const text = await request(url, {
|
|
3347
|
+
what: `Management API ${category} advisors`,
|
|
3348
|
+
headers: { authorization: `Bearer ${options.accessToken}` },
|
|
3349
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
3350
|
+
});
|
|
3351
|
+
const body = JSON.parse(text);
|
|
3352
|
+
if (!Array.isArray(body.lints)) throw new CliError("request_failed", `Management API ${category} advisors returned no "lints".`);
|
|
3353
|
+
return body.lints.filter(isLint);
|
|
3354
|
+
}
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
/** The `[api] schemas` list of a `config.toml`, when it names any. */
|
|
3358
|
+
function apiSchemas(document) {
|
|
3359
|
+
const value = document ? tomlGet(document, ["api", "schemas"]) : void 0;
|
|
3360
|
+
if (!Array.isArray(value)) return [];
|
|
3361
|
+
return value.filter((schema) => typeof schema === "string" && schema.trim() !== "");
|
|
3362
|
+
}
|
|
3363
|
+
const literal = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
3364
|
+
/** The read-only transaction splinter runs in, with `pgrst.db_schemas` set first. */
|
|
3365
|
+
function splinterScript(sql, schemas = []) {
|
|
3366
|
+
return `begin read only;\n${schemas.length > 0 ? `set local pgrst.db_schemas = ${literal(schemas.join(","))};\n` : ""}${sql}\n;\nrollback;`;
|
|
3367
|
+
}
|
|
3368
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
3369
|
+
/** Downloads (once) and verifies the pinned `splinter.sql`. */
|
|
3370
|
+
async function splinterSql(options) {
|
|
3371
|
+
const file = resolve(options.cacheDir, `splinter-${SPLINTER_COMMIT}.sql`);
|
|
3372
|
+
if (existsSync(file)) {
|
|
3373
|
+
const cached = await readFile(file, "utf-8");
|
|
3374
|
+
if (sha256(cached) === SPLINTER_SHA256) return cached;
|
|
3375
|
+
}
|
|
3376
|
+
const text = await request(SPLINTER_URL, {
|
|
3377
|
+
what: `Downloading splinter.sql from ${SPLINTER_URL}`,
|
|
3378
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
3379
|
+
});
|
|
3380
|
+
if (sha256(text) !== SPLINTER_SHA256) throw new CliError("integrity_failed", `splinter.sql from ${SPLINTER_URL} does not match the pinned hash; refusing to run it.`);
|
|
3381
|
+
await mkdir(dirname(file), { recursive: true });
|
|
3382
|
+
await writeFile(file, text);
|
|
3383
|
+
return text;
|
|
3384
|
+
}
|
|
3385
|
+
/** `pg` returns one result per statement for a multi-statement query. */
|
|
3386
|
+
function lintRows(result) {
|
|
3387
|
+
const results = Array.isArray(result) ? result : [result];
|
|
3388
|
+
return results.find((entry) => entry.fields?.some((field) => field.name === "cache_key"))?.rows ?? results.at(-1)?.rows ?? [];
|
|
3389
|
+
}
|
|
3390
|
+
/**
|
|
3391
|
+
* Runs splinter against a database (local stack, `$DATABASE_URL` or `--db-url-stdin`) in a
|
|
3392
|
+
* read-only transaction that is rolled back.
|
|
3393
|
+
*/
|
|
3394
|
+
function splinterAdvisors(queryable, describe, options) {
|
|
3395
|
+
let all;
|
|
3396
|
+
const run = async () => {
|
|
3397
|
+
const sql = await splinterSql(options);
|
|
3398
|
+
return lintRows(await queryable.query(splinterScript(sql, options.schemas))).filter(isLint);
|
|
3399
|
+
};
|
|
3400
|
+
return {
|
|
3401
|
+
describe: `${describe} (splinter ${SPLINTER_COMMIT.slice(0, 7)})`,
|
|
3402
|
+
async lints(category) {
|
|
3403
|
+
all ??= run();
|
|
3404
|
+
const wanted = category.toUpperCase();
|
|
3405
|
+
return (await all).filter((lint) => lint.categories.includes(wanted));
|
|
3406
|
+
}
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
//#endregion
|
|
3410
|
+
//#region src/cli/doctor/live-source.ts
|
|
3411
|
+
/** Checks that read the database itself rather than the snapshot. */
|
|
3412
|
+
const LIVE_CODES = /* @__PURE__ */ new Set([
|
|
3413
|
+
"BS100",
|
|
3414
|
+
"BS200",
|
|
3415
|
+
"BS208",
|
|
3416
|
+
"BS209",
|
|
3417
|
+
"BS212",
|
|
3418
|
+
"BS225"
|
|
3419
|
+
]);
|
|
3420
|
+
/**
|
|
3421
|
+
* The database doctor reads, for advisors and live checks: the Management
|
|
3422
|
+
* API for hosted projects, one lazily opened connection otherwise (splinter
|
|
3423
|
+
* runs over it). Saved snapshots skip both.
|
|
3424
|
+
*/
|
|
3425
|
+
function openLive(config, env, source, pg) {
|
|
3426
|
+
const none = { close: () => Promise.resolve() };
|
|
3427
|
+
const file = snapshotFile(config, source);
|
|
3428
|
+
if (file) {
|
|
3429
|
+
const skipped = `reading the saved snapshot ${file}; set $DATABASE_URL or $SUPABASE_DB_URL, or pass --db-url-stdin or --project-ref, to check a database.`;
|
|
3430
|
+
return {
|
|
3431
|
+
...none,
|
|
3432
|
+
advisors: { skipped },
|
|
3433
|
+
database: { skipped }
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
let opened;
|
|
3437
|
+
const open = () => opened ??= openSource(config, env, source, pg);
|
|
3438
|
+
const target = managementTarget(config, env, source);
|
|
3439
|
+
const database = {
|
|
3440
|
+
describe: target ? `project ${target.projectRef} (Management API)` : "database",
|
|
3441
|
+
session: !target,
|
|
3442
|
+
async query(sql) {
|
|
3443
|
+
return (await (await open()).queryable.query(sql)).rows;
|
|
3444
|
+
}
|
|
3445
|
+
};
|
|
3446
|
+
const close = async () => {
|
|
3447
|
+
if (opened) await (await opened).close();
|
|
3448
|
+
};
|
|
3449
|
+
if (target) return {
|
|
3450
|
+
advisors: managementAdvisors(target),
|
|
3451
|
+
database,
|
|
3452
|
+
open,
|
|
3453
|
+
close
|
|
3454
|
+
};
|
|
3455
|
+
let splinter;
|
|
3456
|
+
const cacheDir = resolve(config.root, CACHE_DIR);
|
|
3457
|
+
return {
|
|
3458
|
+
advisors: {
|
|
3459
|
+
describe: "database (splinter)",
|
|
3460
|
+
async lints(category) {
|
|
3461
|
+
splinter ??= Promise.all([open(), readSupabaseToml(config.root).catch(() => void 0)]).then(([db, toml]) => splinterAdvisors(db.queryable, db.describe, {
|
|
3462
|
+
cacheDir,
|
|
3463
|
+
schemas: apiSchemas(toml?.document)
|
|
3464
|
+
}));
|
|
3465
|
+
return (await splinter).lints(category);
|
|
3466
|
+
}
|
|
3467
|
+
},
|
|
3468
|
+
database,
|
|
3469
|
+
open,
|
|
3470
|
+
close
|
|
3471
|
+
};
|
|
3472
|
+
}
|
|
3473
|
+
//#endregion
|
|
3474
|
+
//#region src/cli/doctor/locate.ts
|
|
3475
|
+
const escape = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3476
|
+
/** Where a table, function or policy is declared: declarative schemas first, then the newest migration. */
|
|
3477
|
+
function locate(files, object) {
|
|
3478
|
+
const name = `"?${escape(object.name)}"?`;
|
|
3479
|
+
const qualifiedName = `(?:"?${escape(object.schema)}"?\\.)?${name}`;
|
|
3480
|
+
if (object.kind === "policy" && object.table !== void 0) {
|
|
3481
|
+
const onTable = new RegExp(`create\\s+policy\\s+${name}\\s+on\\s+(?:only\\s+)?(?:"?${escape(object.schema)}"?\\.)?"?${escape(object.table)}"?(?:\\s|;|$)`, "i");
|
|
3482
|
+
for (const file of files) {
|
|
3483
|
+
const match = onTable.exec(file.text);
|
|
3484
|
+
if (match) return {
|
|
3485
|
+
file: file.path,
|
|
3486
|
+
line: file.text.slice(0, match.index).split("\n").length
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
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");
|
|
3492
|
+
for (const file of files) {
|
|
3493
|
+
const line = lineOf(file.text, pattern);
|
|
3494
|
+
if (line) return {
|
|
3495
|
+
file: file.path,
|
|
3496
|
+
line
|
|
3497
|
+
};
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
//#endregion
|
|
3501
|
+
//#region src/cli/doctor/options.ts
|
|
3502
|
+
/** The formats `--format` takes; `--json` picks the JSON one. */
|
|
3503
|
+
const FLAG_FORMATS = DOCTOR_FORMATS.filter((format) => format !== "json");
|
|
3504
|
+
/** `--json` picks the JSON report; `--format`, then `doctor.format`, picks between the others. */
|
|
3505
|
+
function reportFormat(args, configured) {
|
|
3506
|
+
if (args.format === "json") throw new CliError("usage", "--format json is now the global --json flag");
|
|
3507
|
+
if (args.json === true) {
|
|
3508
|
+
if (args.format !== void 0) throw new CliError("usage", "Pass --json or --format, not both");
|
|
3509
|
+
return "json";
|
|
3510
|
+
}
|
|
3511
|
+
if (args.format === void 0) return configured;
|
|
3512
|
+
const format = FLAG_FORMATS.find((candidate) => candidate === args.format);
|
|
3513
|
+
if (!format) throw new CliError("invalid_value", `--format must be one of ${FLAG_FORMATS.join(", ")}`);
|
|
3514
|
+
return format;
|
|
3515
|
+
}
|
|
3516
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3517
|
+
/** `--as <uuid>` or `--claims <json>` as `request.jwt.claims`. */
|
|
3518
|
+
function explainClaims(args) {
|
|
3519
|
+
const as = args.as;
|
|
3520
|
+
const claims = args.claims;
|
|
3521
|
+
if (as && claims) return { error: "Pass --as or --claims, not both" };
|
|
3522
|
+
if (as) {
|
|
3523
|
+
if (!UUID.test(as)) return { error: `--as takes a user id (uuid), got "${as}"` };
|
|
3524
|
+
return {
|
|
3525
|
+
sub: as,
|
|
3526
|
+
role: "authenticated"
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
if (!claims) return { role: "anon" };
|
|
3530
|
+
try {
|
|
3531
|
+
const parsed = JSON.parse(claims);
|
|
3532
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { error: "--claims takes a JSON object" };
|
|
3533
|
+
return {
|
|
3534
|
+
role: "anon",
|
|
3535
|
+
...parsed
|
|
3536
|
+
};
|
|
3537
|
+
} catch (cause) {
|
|
3538
|
+
return { error: `--claims is not valid JSON: ${errorText(cause)}` };
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
//#endregion
|
|
3542
|
+
//#region src/cli/commands/doctor.ts
|
|
3543
|
+
const ARGS = {
|
|
3544
|
+
format: {
|
|
3545
|
+
type: "string",
|
|
3546
|
+
description: "text, sarif (code scanning) or github (workflow annotations); also doctor.format, default text. --json for the JSON report",
|
|
3547
|
+
valueHint: "format"
|
|
3548
|
+
},
|
|
3549
|
+
out: {
|
|
3550
|
+
type: "string",
|
|
3551
|
+
description: "Write the report to a file; also doctor.output",
|
|
3552
|
+
valueHint: "file"
|
|
3553
|
+
},
|
|
3554
|
+
only: {
|
|
3555
|
+
type: "string",
|
|
3556
|
+
description: "Run only these checks, e.g. --only BS100,BS304; also doctor.only",
|
|
3557
|
+
valueHint: "codes"
|
|
3558
|
+
},
|
|
3559
|
+
ignore: {
|
|
3560
|
+
type: "string",
|
|
3561
|
+
description: "Skip checks; also doctor.ignore in the config",
|
|
3562
|
+
valueHint: "codes"
|
|
3563
|
+
},
|
|
3564
|
+
"fail-on": {
|
|
3565
|
+
type: "string",
|
|
3566
|
+
description: "Exit 1 on findings at this level: error (the default), warning (the default with doctor.strict) or never",
|
|
3567
|
+
valueHint: "level"
|
|
3568
|
+
},
|
|
3569
|
+
metadata: {
|
|
3570
|
+
type: "string",
|
|
3571
|
+
description: "Check a GeneratorMetadata document (- for stdin); checks it lacks data for are skipped",
|
|
3572
|
+
valueHint: "path|-"
|
|
3573
|
+
},
|
|
3574
|
+
stats: {
|
|
3575
|
+
type: "boolean",
|
|
3576
|
+
description: "Report slow frequent statements from pg_stat_statements (BS209)"
|
|
3577
|
+
},
|
|
3578
|
+
explain: {
|
|
3579
|
+
type: "string",
|
|
3580
|
+
description: "EXPLAIN ANALYZE these tables under RLS, rolled back (BS212)",
|
|
3581
|
+
valueHint: "tables"
|
|
3582
|
+
},
|
|
3583
|
+
as: {
|
|
3584
|
+
type: "string",
|
|
3585
|
+
description: "Plan as this authenticated user, and measure the claims the custom access token hook returns for them (BS405)",
|
|
3586
|
+
valueHint: "uuid"
|
|
3587
|
+
},
|
|
3588
|
+
claims: {
|
|
3589
|
+
type: "string",
|
|
3590
|
+
description: "Plan with these JWT claims ({\"role\":\"authenticated\",...})",
|
|
3591
|
+
valueHint: "json"
|
|
3592
|
+
},
|
|
3593
|
+
"fix-grants": {
|
|
3594
|
+
type: "boolean",
|
|
3595
|
+
description: "Print the grant and revoke SQL BS404 asks for, to add to your schema or migration"
|
|
3596
|
+
}
|
|
3597
|
+
};
|
|
3598
|
+
async function runDoctor(config, args, env, options = {}) {
|
|
3599
|
+
const format = reportFormat(args, config.doctor.format);
|
|
3600
|
+
const only = args.only === void 0 ? [...config.doctor.only] : list(args.only);
|
|
3601
|
+
const ignore = /* @__PURE__ */ new Set([...config.doctor.ignore, ...list(args.ignore)]);
|
|
3602
|
+
const unknown = [...only, ...ignore].filter((code) => !RULE_CODES.includes(code));
|
|
3603
|
+
if (unknown.length > 0) throw new CliError("usage", `Unknown check ${unknown.join(", ")}. Checks: ${RULE_CODES.join(", ")}`);
|
|
3604
|
+
const explainTables = list(args.explain);
|
|
3605
|
+
const stats = args.stats === true;
|
|
3606
|
+
const claims = explainClaims(args);
|
|
3607
|
+
if ("error" in claims) throw new CliError("usage", String(claims.error));
|
|
3608
|
+
if (args.claims !== void 0 && explainTables.length === 0) throw new CliError("usage", "--claims plans a query, so it needs --explain <table>.");
|
|
3609
|
+
const hookUser = args.as;
|
|
3610
|
+
const asked = /* @__PURE__ */ new Set([
|
|
3611
|
+
...stats ? ["BS209"] : [],
|
|
3612
|
+
...explainTables.length > 0 ? ["BS212"] : [],
|
|
3613
|
+
...hookUser ? ["BS405"] : []
|
|
3614
|
+
]);
|
|
3615
|
+
const fixGrants = args["fix-grants"] === true;
|
|
3616
|
+
const rules = fixGrants ? RULES.filter((rule) => rule.code === "BS404") : RULES.filter((rule) => (only.length === 0 || only.includes(rule.code) || asked.has(rule.code)) && !ignore.has(rule.code));
|
|
3617
|
+
const snapshotPath = args.snapshot;
|
|
3618
|
+
const dbUrl = options.dbUrl;
|
|
3619
|
+
const projectRef = args["project-ref"];
|
|
3620
|
+
const source = {
|
|
3621
|
+
...stats || explainTables.length > 0 || hookUser ? { live: true } : {},
|
|
3622
|
+
...snapshotPath ? { snapshotPath } : {},
|
|
3623
|
+
...dbUrl ? { dbUrl } : {},
|
|
3624
|
+
...projectRef ? { projectRef } : {},
|
|
3625
|
+
...options.signal ? { signal: options.signal } : {}
|
|
3626
|
+
};
|
|
3627
|
+
const pg = options.connect ?? connect;
|
|
3628
|
+
const wantsLive = hookUser !== void 0 || rules.some((rule) => LIVE_CODES.has(rule.code));
|
|
3629
|
+
const opened = options.snapshot || !wantsLive ? {
|
|
3630
|
+
advisors: options.advisors,
|
|
3631
|
+
database: options.database,
|
|
3632
|
+
close: () => Promise.resolve()
|
|
3633
|
+
} : {
|
|
3634
|
+
...openLive(config, env, source, pg),
|
|
3635
|
+
...options.advisors === void 0 ? {} : { advisors: options.advisors },
|
|
3636
|
+
...options.database === void 0 ? {} : { database: options.database }
|
|
3637
|
+
};
|
|
3638
|
+
const [snapshot, envFiles, [configToml, sql], gitignore, sources, readSets, peers] = await Promise.all([
|
|
3639
|
+
options.snapshot ?? loadSnapshot(config, env, source, pg, opened.open),
|
|
3640
|
+
Promise.all(ENV_FILES.map((path) => readText(config.root, path))).then((files) => files.filter((file) => file !== void 0)),
|
|
3641
|
+
readSupabaseToml(config.root).then(async (toml) => [toml, await sqlFiles(config.root, toml, sqlWorkflow(config, toml))]),
|
|
3642
|
+
readText(config.root, ".gitignore"),
|
|
3643
|
+
sourceFiles(config),
|
|
3644
|
+
compiledReadSets(config).catch((cause) => ({ skipped: errorText(cause) })),
|
|
3645
|
+
options.peers ?? peerVersions(config.root).catch(() => [])
|
|
3646
|
+
]).catch(async (cause) => {
|
|
3647
|
+
await opened.close();
|
|
3648
|
+
throw cause;
|
|
3649
|
+
});
|
|
3650
|
+
const replay = opened.open ? (await opened.open()).replay : void 0;
|
|
3651
|
+
const live = replay ? {
|
|
3652
|
+
advisors: options.advisors ?? { skipped: replay },
|
|
3653
|
+
database: options.database ?? { skipped: replay }
|
|
3654
|
+
} : opened;
|
|
3655
|
+
const context = {
|
|
3656
|
+
config,
|
|
3657
|
+
snapshot,
|
|
3658
|
+
sqlFiles: sql,
|
|
3659
|
+
configToml,
|
|
3660
|
+
envFiles,
|
|
3661
|
+
gitignore: gitignore?.text ?? "",
|
|
3662
|
+
sources,
|
|
3663
|
+
readSets,
|
|
3664
|
+
peers,
|
|
3665
|
+
...live.advisors ? { advisors: live.advisors } : {},
|
|
3666
|
+
...live.database ? { database: live.database } : {},
|
|
3667
|
+
...stats ? { stats } : {},
|
|
3668
|
+
...hookUser ? { hookUser } : {},
|
|
3669
|
+
...explainTables.length > 0 ? { explain: {
|
|
3670
|
+
tables: explainTables,
|
|
3671
|
+
claims
|
|
3672
|
+
} } : {}
|
|
3673
|
+
};
|
|
3674
|
+
if (fixGrants) {
|
|
3675
|
+
await opened.close();
|
|
3676
|
+
return {
|
|
3677
|
+
code: 0,
|
|
3678
|
+
output: hookGrantBlock(hookGrantProblems(context), workflowOf(context))
|
|
3679
|
+
};
|
|
3680
|
+
}
|
|
3681
|
+
let ran;
|
|
3682
|
+
try {
|
|
3683
|
+
ran = await runRules(context, rules);
|
|
3684
|
+
} finally {
|
|
3685
|
+
await opened.close();
|
|
3686
|
+
}
|
|
3687
|
+
const findings = ran.map((finding) => {
|
|
3688
|
+
if (finding.location || !finding.object) return finding;
|
|
3689
|
+
const location = locate(sql, finding.object);
|
|
3690
|
+
return location ? {
|
|
3691
|
+
...finding,
|
|
3692
|
+
location
|
|
3693
|
+
} : finding;
|
|
3694
|
+
});
|
|
3695
|
+
const out = args.out ?? (format === config.doctor.format ? config.doctor.output : void 0);
|
|
3696
|
+
const fromRepo = format === "sarif" || format === "github";
|
|
3697
|
+
const repoRoot = findRepoRoot(config.root);
|
|
3698
|
+
const repoPath = (file) => display(repoRoot, resolve(config.root, file));
|
|
3699
|
+
const report = formatReport(fromRepo ? findings.map((finding) => finding.location ? {
|
|
3700
|
+
...finding,
|
|
3701
|
+
location: {
|
|
3702
|
+
...finding.location,
|
|
3703
|
+
file: repoPath(finding.location.file)
|
|
3704
|
+
}
|
|
3705
|
+
} : finding) : findings, {
|
|
3706
|
+
format,
|
|
3707
|
+
rules,
|
|
3708
|
+
version: VERSION,
|
|
3709
|
+
fallbackFile: repoPath(context.configToml?.path ?? "package.json"),
|
|
3710
|
+
...options.paint && !out ? { paint: options.paint } : {}
|
|
3711
|
+
});
|
|
3712
|
+
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
3713
|
+
const warnings = findings.filter((finding) => finding.severity === "warning").length;
|
|
3714
|
+
const failOn = args["fail-on"] ?? (config.doctor.strict ? "warning" : "error");
|
|
3715
|
+
if (failOn !== "error" && failOn !== "warning" && failOn !== "never") throw new CliError("usage", `--fail-on takes error, warning or never, got "${failOn}".`);
|
|
3716
|
+
const code = failOn !== "never" && (errors > 0 || failOn === "warning" && warnings > 0) ? 1 : 0;
|
|
3717
|
+
const failure = code === 0 ? {} : {
|
|
3718
|
+
errorCode: "findings",
|
|
3719
|
+
error: `Doctor found ${errors} errors and ${warnings} warnings${failOn === "warning" ? " (--fail-on warning)" : ""}.`
|
|
3720
|
+
};
|
|
3721
|
+
if (out) {
|
|
3722
|
+
const applied = await applyPlan({
|
|
3723
|
+
root: config.root,
|
|
3724
|
+
regenerate: "better-supabase doctor",
|
|
3725
|
+
files: [{
|
|
3726
|
+
path: insideRoot(config.root, out, "--out"),
|
|
3727
|
+
contents: `${report}\n`,
|
|
3728
|
+
note: `${errors} errors, ${warnings} warnings`
|
|
3729
|
+
}]
|
|
3730
|
+
}, {});
|
|
3731
|
+
return {
|
|
3732
|
+
code,
|
|
3733
|
+
output: changeLines(applied).join("\n"),
|
|
3734
|
+
data: {
|
|
3735
|
+
out,
|
|
3736
|
+
errors,
|
|
3737
|
+
warnings
|
|
3738
|
+
},
|
|
3739
|
+
...failure
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
return format === "json" ? {
|
|
3743
|
+
code,
|
|
3744
|
+
data: jsonReport(findings, VERSION),
|
|
3745
|
+
...failure
|
|
3746
|
+
} : {
|
|
3747
|
+
code,
|
|
3748
|
+
output: report,
|
|
3749
|
+
...failure
|
|
3750
|
+
};
|
|
3751
|
+
}
|
|
3752
|
+
const ruleCodes = () => [.../* @__PURE__ */ new Set([...RULE_CODES, ...LIVE_CODES])];
|
|
3753
|
+
const doctorCommand = defineCliCommand({
|
|
3754
|
+
meta: commandMeta("doctor"),
|
|
3755
|
+
args: {
|
|
3756
|
+
...SOURCE_ARGS,
|
|
3757
|
+
...ARGS
|
|
3758
|
+
},
|
|
3759
|
+
lists: [
|
|
3760
|
+
"only",
|
|
3761
|
+
"ignore",
|
|
3762
|
+
"explain"
|
|
3763
|
+
],
|
|
3764
|
+
completions: {
|
|
3765
|
+
format: () => FLAG_FORMATS,
|
|
3766
|
+
only: ruleCodes,
|
|
3767
|
+
ignore: ruleCodes
|
|
3768
|
+
},
|
|
3769
|
+
run: async (args, { config, cwd, env, io, log, paint, signal }) => {
|
|
3770
|
+
if (args.metadata !== void 0) {
|
|
3771
|
+
refuseWithMetadata([
|
|
3772
|
+
...sourceFlags(args),
|
|
3773
|
+
...args.stats === true ? ["--stats"] : [],
|
|
3774
|
+
...args.explain === void 0 ? [] : ["--explain"],
|
|
3775
|
+
...args.as === void 0 ? [] : ["--as"]
|
|
3776
|
+
]);
|
|
3777
|
+
let snapshot;
|
|
3778
|
+
try {
|
|
3779
|
+
snapshot = await readMetadataSnapshot(args.metadata, cwd, io);
|
|
3780
|
+
} catch (cause) {
|
|
3781
|
+
if (cause instanceof MetadataRejectedError) return {
|
|
3782
|
+
code: 65,
|
|
3783
|
+
errorCode: "invalid_input",
|
|
3784
|
+
error: cause.message
|
|
3785
|
+
};
|
|
3786
|
+
throw cause;
|
|
3787
|
+
}
|
|
3788
|
+
const skipped = `reading a GeneratorMetadata document; set $DATABASE_URL or $SUPABASE_DB_URL, or pass --db-url-stdin or --project-ref, to check a database.`;
|
|
3789
|
+
return runDoctor(config, args, env, {
|
|
3790
|
+
snapshot,
|
|
3791
|
+
advisors: { skipped },
|
|
3792
|
+
database: { skipped },
|
|
3793
|
+
paint
|
|
3794
|
+
});
|
|
3795
|
+
}
|
|
3796
|
+
const dbUrl = await stdinDatabaseUrl(args["db-url-stdin"], io);
|
|
3797
|
+
const check = () => runDoctor(config, args, env, {
|
|
3798
|
+
paint,
|
|
3799
|
+
...dbUrl ? { dbUrl } : {},
|
|
3800
|
+
...signal ? { signal } : {}
|
|
3801
|
+
});
|
|
3802
|
+
return args.snapshot === void 0 ? log.progress("Checking the database", check) : check();
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
//#endregion
|
|
3806
|
+
export { doctorCommand };
|