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
|
@@ -1,3686 +0,0 @@
|
|
|
1
|
-
import { a as list, r as defineCliCommand } from "./command-DV-StG1x.js";
|
|
2
|
-
import { n as CliError, r as errorText } from "./errors-CqTLXe2M.js";
|
|
3
|
-
import { a as writeIfChanged, i as sameText, t as display } from "./io-CFzJVJ68.js";
|
|
4
|
-
import { i as plain, r as painter } from "./style-CB3vxgh0.js";
|
|
5
|
-
import { t as VERSION } from "./version-B4k0aUxS.js";
|
|
6
|
-
import { t as CACHE_DIR } from "./cache-CIfA-jBV.js";
|
|
7
|
-
import { t as byCodePoint } from "./compare-BmIWVEui.js";
|
|
8
|
-
import { h as satisfies, m as peerVersions, t as MetadataRejectedError } from "./typegen-Demi5-VQ.js";
|
|
9
|
-
import { s as configFor } from "./emit-BHHfwUoT.js";
|
|
10
|
-
import { _ as tableObject, a as METADATA_GAPS_TEXT, c as accessModule, d as exposed, f as exposedSchemas, g as qualified, h as policyObject, l as entitlementsModule, m as permissionCatalogKeys, p as lineOf, s as AUTHORIZATION_RULES, u as catalogOf, y as renderFiles } from "./run-DuN1MX-7.js";
|
|
11
|
-
import { s as providerLabel, t as compiledReadSets } from "./read-sets-DjJrm8Wj.js";
|
|
12
|
-
import { c as stdinDatabaseUrl } from "./config-M-WzL9O2.js";
|
|
13
|
-
import { c as migrationCommand, d as schemaPaths, i as findRepoRoot, l as pgFunctionHooks, p as tomlGet, r as diffEngine, s as httpHooks, u as readSupabaseToml } from "./supabase-toml-B-bIukQF.js";
|
|
14
|
-
import { t as connect } from "./db-BkcfOlyB.js";
|
|
15
|
-
import { c as isSqliteLiteDriver, n as managementTarget, o as snapshotFile, r as openSource, s as liteDriverOf, t as loadSnapshot } from "./snapshot-Bstk6ulj.js";
|
|
16
|
-
import { n as moduleLayoutFor } from "./sql-layout-D6cphHT3.js";
|
|
17
|
-
import { n as readMetadataSnapshot } from "./metadata-DSRKCaf-.js";
|
|
18
|
-
import { n as withSpinner } from "./prompts-DZJB1nTw.js";
|
|
19
|
-
import { dirname, resolve } from "node:path";
|
|
20
|
-
import { existsSync } from "node:fs";
|
|
21
|
-
import { glob, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
22
|
-
import { createHash } from "node:crypto";
|
|
23
|
-
import { DEFAULT_ACTIVE_TENANT, tenantClaimPaths } from "better-supabase/config";
|
|
24
|
-
import { SQL_MODULES, contractSignature, customContracts, migrationOptionUses, moduleDeprecations, moduleEventTriggers, moduleFileVersion, moduleLayout, moduleVersion, renderModules, sameModuleFile, sharedRolesProblems } from "better-supabase/sql";
|
|
25
|
-
import { defineBucket, parseSize } from "better-supabase/storage";
|
|
26
|
-
//#region src/cli/doctor/advisor-rules.ts
|
|
27
|
-
/** One request per advisor category, shared by the advisor rules and the rules that defer to them. */
|
|
28
|
-
const fetched = /* @__PURE__ */ new WeakMap();
|
|
29
|
-
function lintsOf(advisors, category) {
|
|
30
|
-
let byCategory = fetched.get(advisors);
|
|
31
|
-
if (!byCategory) {
|
|
32
|
-
byCategory = /* @__PURE__ */ new Map();
|
|
33
|
-
fetched.set(advisors, byCategory);
|
|
34
|
-
}
|
|
35
|
-
let lints = byCategory.get(category);
|
|
36
|
-
if (!lints) {
|
|
37
|
-
lints = advisors.lints(category);
|
|
38
|
-
byCategory.set(category, lints);
|
|
39
|
-
}
|
|
40
|
-
return lints;
|
|
41
|
-
}
|
|
42
|
-
const ADVISOR_SEVERITY = {
|
|
43
|
-
ERROR: "error",
|
|
44
|
-
WARN: "warning",
|
|
45
|
-
INFO: "info"
|
|
46
|
-
};
|
|
47
|
-
function lintObject(lint) {
|
|
48
|
-
const { schema, name, type } = lint.metadata ?? {};
|
|
49
|
-
if (typeof schema !== "string" || typeof name !== "string") return void 0;
|
|
50
|
-
if (type === "table" || type === "view") return {
|
|
51
|
-
kind: "table",
|
|
52
|
-
schema,
|
|
53
|
-
name
|
|
54
|
-
};
|
|
55
|
-
if (type === "function") return {
|
|
56
|
-
kind: "function",
|
|
57
|
-
schema,
|
|
58
|
-
name
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function lintFinding(lint) {
|
|
62
|
-
const object = lintObject(lint);
|
|
63
|
-
return {
|
|
64
|
-
severity: ADVISOR_SEVERITY[lint.level] ?? "warning",
|
|
65
|
-
title: lint.title,
|
|
66
|
-
message: `${lint.detail.replaceAll("\\`", "`")} [${lint.name}]`,
|
|
67
|
-
target: object ? `${object.schema}.${object.name}` : lint.cache_key,
|
|
68
|
-
help: lint.remediation,
|
|
69
|
-
...object ? { object } : {}
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/** A rule that reports one category of Supabase's advisors (splinter) as findings. */
|
|
73
|
-
function advisorRule(code, category, title, description) {
|
|
74
|
-
return {
|
|
75
|
-
code,
|
|
76
|
-
severity: "warning",
|
|
77
|
-
title,
|
|
78
|
-
description,
|
|
79
|
-
async check(context) {
|
|
80
|
-
const { advisors } = context;
|
|
81
|
-
if (!advisors) return [];
|
|
82
|
-
if ("skipped" in advisors) return [{
|
|
83
|
-
severity: "info",
|
|
84
|
-
message: `Skipped the ${category} advisor: ${advisors.skipped}`
|
|
85
|
-
}];
|
|
86
|
-
try {
|
|
87
|
-
return (await lintsOf(advisors, category)).map(lintFinding);
|
|
88
|
-
} catch (cause) {
|
|
89
|
-
return [{ message: `The ${category} advisor could not run (${advisors.describe}): ${errorText(cause)}` }];
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
/** The performance advisor rule the duplicate rules defer to. */
|
|
95
|
-
const PERFORMANCE_ADVISOR = "BS200";
|
|
96
|
-
/**
|
|
97
|
-
* The tables splinter reports `lint` for (`schema.table`), when the
|
|
98
|
-
* performance advisor runs in this run and its lints loaded. A rule that
|
|
99
|
-
* repeats the lint (BS207, BS216) drops its findings for these tables; with
|
|
100
|
-
* `undefined` (no advisors, a saved snapshot, an advisor error, or BS200 not
|
|
101
|
-
* in the run) it reports them itself.
|
|
102
|
-
*/
|
|
103
|
-
async function splinterTables(context, lint) {
|
|
104
|
-
const { advisors } = context;
|
|
105
|
-
if (!advisors || "skipped" in advisors) return void 0;
|
|
106
|
-
if (!context.codes?.includes(PERFORMANCE_ADVISOR)) return void 0;
|
|
107
|
-
let lints;
|
|
108
|
-
try {
|
|
109
|
-
lints = await lintsOf(advisors, "performance");
|
|
110
|
-
} catch {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
return new Set(lints.flatMap((entry) => {
|
|
114
|
-
if (entry.name !== lint) return [];
|
|
115
|
-
const object = lintObject(entry);
|
|
116
|
-
return object ? [`${object.schema}.${object.name}`] : [];
|
|
117
|
-
}));
|
|
118
|
-
}
|
|
119
|
-
//#endregion
|
|
120
|
-
//#region src/cli/doctor/cookies.ts
|
|
121
|
-
const TOKENS_ONLY = /\bencode\s*:\s*["'`]tokens-only["'`]/;
|
|
122
|
-
/** Imports that create the browser client, which writes the session cookie in the browser. */
|
|
123
|
-
const BROWSER_SIDE = /from\s+["']better-supabase\/client["']|\bcreateBrowserClient\s*\(/;
|
|
124
|
-
/** Imports that read or refresh the session cookie on the server. */
|
|
125
|
-
const SERVER_SIDE = /from\s+["']better-supabase\/(?:server|next|ssr|hono|orpc|edge|expo)["']|\bcreateServerClient\s*\(/;
|
|
126
|
-
function cookieSide(file) {
|
|
127
|
-
const browser = BROWSER_SIDE.test(file.text);
|
|
128
|
-
if (browser === SERVER_SIDE.test(file.text)) return void 0;
|
|
129
|
-
return browser ? "browser" : "server";
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Files on one side of the session cookie that set `encode: 'tokens-only'`
|
|
133
|
-
* while files on the other side don't (BS412).
|
|
134
|
-
*/
|
|
135
|
-
function encodingDrift(context) {
|
|
136
|
-
const sides = context.sources.flatMap((file) => {
|
|
137
|
-
const side = cookieSide(file);
|
|
138
|
-
return side ? [{
|
|
139
|
-
file,
|
|
140
|
-
side,
|
|
141
|
-
tokensOnly: TOKENS_ONLY.test(file.text)
|
|
142
|
-
}] : [];
|
|
143
|
-
});
|
|
144
|
-
const tokensOnly = sides.filter((entry) => entry.tokensOnly);
|
|
145
|
-
if (tokensOnly.length === 0) return [];
|
|
146
|
-
const findings = [];
|
|
147
|
-
for (const side of ["browser", "server"]) {
|
|
148
|
-
const set = tokensOnly.find((entry) => entry.side === side);
|
|
149
|
-
if (!set) continue;
|
|
150
|
-
const other = side === "browser" ? "server" : "browser";
|
|
151
|
-
const unset = sides.filter((entry) => entry.side === other && !entry.tokensOnly);
|
|
152
|
-
if (unset.length === 0) continue;
|
|
153
|
-
const line = lineOf(set.file.text, TOKENS_ONLY) ?? 1;
|
|
154
|
-
findings.push({
|
|
155
|
-
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.`,
|
|
156
|
-
target: `${set.file.path}:encode`,
|
|
157
|
-
location: {
|
|
158
|
-
file: set.file.path,
|
|
159
|
-
line
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
return findings;
|
|
164
|
-
}
|
|
165
|
-
const COOKIE_RULES = [{
|
|
166
|
-
code: "BS412",
|
|
167
|
-
severity: "info",
|
|
168
|
-
title: "Session cookie encoding differs between server and browser",
|
|
169
|
-
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.",
|
|
170
|
-
check: encodingDrift
|
|
171
|
-
}];
|
|
172
|
-
//#endregion
|
|
173
|
-
//#region src/cli/doctor/dependencies.ts
|
|
174
|
-
const DEPENDENCY_RULES = [{
|
|
175
|
-
code: "BS601",
|
|
176
|
-
severity: "warning",
|
|
177
|
-
title: "Peer outside its range",
|
|
178
|
-
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.",
|
|
179
|
-
check: (context) => (context.peers ?? []).flatMap((peer) => peer.installed !== void 0 && satisfies(peer.installed, peer.range) === false ? [{
|
|
180
|
-
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.`,
|
|
181
|
-
target: peer.name
|
|
182
|
-
}] : [])
|
|
183
|
-
}];
|
|
184
|
-
//#endregion
|
|
185
|
-
//#region src/cli/doctor/live.ts
|
|
186
|
-
/** Statements slower than this (ms, mean) with more calls than `SLOW_CALLS` are BS209. */
|
|
187
|
-
const SLOW_MEAN_MS = 50;
|
|
188
|
-
const SLOW_CALLS = 1e3;
|
|
189
|
-
/** Rows `--explain` reads, like a `findMany()` under Supabase's default `max_rows`. */
|
|
190
|
-
const EXPLAIN_LIMIT = 1e3;
|
|
191
|
-
const escape$2 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
192
|
-
const ident$1 = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
|
|
193
|
-
const literal$1 = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
194
|
-
const oneLine = (query, max = 160) => {
|
|
195
|
-
const flat = query.replaceAll(/\s+/g, " ").trim();
|
|
196
|
-
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
197
|
-
};
|
|
198
|
-
const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
199
|
-
const megabytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
200
|
-
/** The schema `pg_stat_statements` lives in, when the extension is installed. */
|
|
201
|
-
async function statementsView(db) {
|
|
202
|
-
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'`);
|
|
203
|
-
return row ? `${ident$1(row.schema)}.pg_stat_statements` : void 0;
|
|
204
|
-
}
|
|
205
|
-
/** The first exposed table a statement names, to locate it in the SQL files. */
|
|
206
|
-
function tableIn(query, tables) {
|
|
207
|
-
return tables.find((table) => {
|
|
208
|
-
const schema = escape$2(table.schema);
|
|
209
|
-
const name = escape$2(table.name);
|
|
210
|
-
return new RegExp(`(?:"${schema}"|\\b${schema})\\.(?:"${name}"|${name}\\b)`).test(query);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
const live = (context) => context.database;
|
|
214
|
-
/**
|
|
215
|
-
* Function calls and self time so far in this transaction. The view can
|
|
216
|
-
* still hold unflushed counts from earlier transactions, so callers diff two
|
|
217
|
-
* readings.
|
|
218
|
-
*/
|
|
219
|
-
async function functionTimes(db) {
|
|
220
|
-
const rows = await db.query(`select schemaname || '.' || funcname as name, calls, self_time from pg_stat_xact_user_functions`);
|
|
221
|
-
return new Map(rows.map((row) => [row.name, {
|
|
222
|
-
calls: Number(row.calls),
|
|
223
|
-
selfTime: Number(row.self_time)
|
|
224
|
-
}]));
|
|
225
|
-
}
|
|
226
|
-
/** Node types, timings and loops of an `EXPLAIN (ANALYZE, FORMAT JSON)` plan; never rows. */
|
|
227
|
-
function summarizePlan(output) {
|
|
228
|
-
const nodes = [];
|
|
229
|
-
const perRowSubPlans = [];
|
|
230
|
-
let initPlans = 0;
|
|
231
|
-
const walk = (node) => {
|
|
232
|
-
const loops = node["Actual Loops"] ?? 1;
|
|
233
|
-
const ms = (node["Actual Total Time"] ?? 0).toFixed(2);
|
|
234
|
-
const on = node["Relation Name"] ? ` on ${node["Relation Name"]}` : "";
|
|
235
|
-
const sub = node["Subplan Name"] ? `${node["Subplan Name"]}: ` : "";
|
|
236
|
-
nodes.push(`${sub}${node["Node Type"]}${on} ${ms} ms ×${loops}`);
|
|
237
|
-
if (node["Parent Relationship"] === "InitPlan") initPlans += 1;
|
|
238
|
-
if (node["Parent Relationship"] === "SubPlan" && loops > 1) perRowSubPlans.push(`${sub}${node["Node Type"]}${on} ×${loops}`);
|
|
239
|
-
for (const child of node.Plans ?? []) walk(child);
|
|
240
|
-
};
|
|
241
|
-
walk(output.Plan);
|
|
242
|
-
return {
|
|
243
|
-
nodes,
|
|
244
|
-
initPlans,
|
|
245
|
-
perRowSubPlans,
|
|
246
|
-
executionMs: output["Execution Time"] ?? 0
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
const ROLE = /^[a-z_][a-z0-9_]*$/;
|
|
250
|
-
async function explainTable(db, table, claims) {
|
|
251
|
-
const role = typeof claims["role"] === "string" ? claims["role"] : "anon";
|
|
252
|
-
if (!ROLE.test(role)) throw new Error(`Invalid role "${role}" in the claims`);
|
|
253
|
-
await db.query("begin");
|
|
254
|
-
try {
|
|
255
|
-
await db.query(`set local statement_timeout = '30s'`);
|
|
256
|
-
let tracked = true;
|
|
257
|
-
await db.query("savepoint bs_track");
|
|
258
|
-
try {
|
|
259
|
-
await db.query(`set local track_functions = 'all'`);
|
|
260
|
-
await db.query("release savepoint bs_track");
|
|
261
|
-
} catch {
|
|
262
|
-
tracked = false;
|
|
263
|
-
await db.query("rollback to savepoint bs_track");
|
|
264
|
-
}
|
|
265
|
-
await db.query(`select set_config('request.jwt.claims', ${literal$1(JSON.stringify(claims))}, true)`);
|
|
266
|
-
const before = tracked ? await functionTimes(db) : /* @__PURE__ */ new Map();
|
|
267
|
-
await db.query(`set local role ${ident$1(role)}`);
|
|
268
|
-
const [row] = await db.query(`explain (analyze, buffers, format json) select * from ${ident$1(table.schema)}.${ident$1(table.name)} limit ${EXPLAIN_LIMIT}`);
|
|
269
|
-
const raw = row?.["QUERY PLAN"];
|
|
270
|
-
const plan = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
271
|
-
if (!plan?.[0]) throw new Error("EXPLAIN returned no plan");
|
|
272
|
-
const functions = tracked ? [...await functionTimes(db)].map(([name, after]) => {
|
|
273
|
-
const start = before.get(name);
|
|
274
|
-
return {
|
|
275
|
-
name,
|
|
276
|
-
calls: after.calls - (start?.calls ?? 0),
|
|
277
|
-
selfTime: after.selfTime - (start?.selfTime ?? 0)
|
|
278
|
-
};
|
|
279
|
-
}).filter((fn) => fn.calls > 0).sort((a, b) => b.selfTime - a.selfTime) : void 0;
|
|
280
|
-
return {
|
|
281
|
-
summary: summarizePlan(plan[0]),
|
|
282
|
-
functions
|
|
283
|
-
};
|
|
284
|
-
} finally {
|
|
285
|
-
await db.query("rollback");
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
function resolveTable(context, name) {
|
|
289
|
-
return name.includes(".") ? catalogOf(context).tables.find((table) => qualified(table) === name) : exposed(context).find((table) => table.name === name);
|
|
290
|
-
}
|
|
291
|
-
const LIVE_RULES = [
|
|
292
|
-
{
|
|
293
|
-
code: "BS208",
|
|
294
|
-
severity: "warning",
|
|
295
|
-
title: "Queries spill to temporary files",
|
|
296
|
-
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`.",
|
|
297
|
-
async check(context) {
|
|
298
|
-
const db = live(context);
|
|
299
|
-
if (!db || "skipped" in db) return [];
|
|
300
|
-
try {
|
|
301
|
-
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()`);
|
|
302
|
-
const files = Number(stats?.temp_files ?? 0);
|
|
303
|
-
if (files === 0) return [];
|
|
304
|
-
let top = "";
|
|
305
|
-
const view = await statementsView(db);
|
|
306
|
-
if (view) {
|
|
307
|
-
const statements = await db.query(`select query, temp_blks_written from ${view} where temp_blks_written > 0 order by temp_blks_written desc limit 5`).catch(() => []);
|
|
308
|
-
if (statements.length > 0) top = ` Top statements by temporary blocks written: ${statements.map((statement) => `${oneLine(statement.query, 100)} (${statement.temp_blks_written} blocks)`).join("; ")}.`;
|
|
309
|
-
}
|
|
310
|
-
return [{
|
|
311
|
-
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}`,
|
|
312
|
-
target: "pg_stat_database:temp_files"
|
|
313
|
-
}];
|
|
314
|
-
} catch (cause) {
|
|
315
|
-
return [{
|
|
316
|
-
severity: "info",
|
|
317
|
-
message: `Could not read pg_stat_database (${db.describe}): ${errorText(cause)}`
|
|
318
|
-
}];
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
code: "BS209",
|
|
324
|
-
severity: "warning",
|
|
325
|
-
title: "Slow frequent statements",
|
|
326
|
-
description: `Statements from \`pg_stat_statements\` with a mean time above ${SLOW_MEAN_MS} ms and more than ${SLOW_CALLS} calls. Runs with \`--stats\`.`,
|
|
327
|
-
async check(context) {
|
|
328
|
-
if (!context.stats) return [];
|
|
329
|
-
const db = live(context);
|
|
330
|
-
if (!db) return [];
|
|
331
|
-
if ("skipped" in db) return [{
|
|
332
|
-
severity: "info",
|
|
333
|
-
message: `Skipped --stats: ${db.skipped}`
|
|
334
|
-
}];
|
|
335
|
-
try {
|
|
336
|
-
const view = await statementsView(db);
|
|
337
|
-
if (!view) return [{
|
|
338
|
-
severity: "info",
|
|
339
|
-
message: "pg_stat_statements is not installed, so --stats has nothing to read. Run `create extension pg_stat_statements with schema extensions;`."
|
|
340
|
-
}];
|
|
341
|
-
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`);
|
|
342
|
-
const tables = exposed(context);
|
|
343
|
-
return statements.map((statement) => {
|
|
344
|
-
const table = tableIn(statement.query, tables);
|
|
345
|
-
return {
|
|
346
|
-
message: `${Number(statement.mean_exec_time).toFixed(1)} ms mean over ${statement.calls} calls: ${oneLine(statement.query)}`,
|
|
347
|
-
target: `pg_stat_statements:${statement.queryid}`,
|
|
348
|
-
...table ? { object: tableObject(table) } : {}
|
|
349
|
-
};
|
|
350
|
-
});
|
|
351
|
-
} catch (cause) {
|
|
352
|
-
return [{
|
|
353
|
-
severity: "info",
|
|
354
|
-
message: `Could not read pg_stat_statements (${db.describe}): ${errorText(cause)}`
|
|
355
|
-
}];
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
},
|
|
359
|
-
{
|
|
360
|
-
code: "BS212",
|
|
361
|
-
severity: "info",
|
|
362
|
-
title: "RLS plan for a table",
|
|
363
|
-
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.`,
|
|
364
|
-
async check(context) {
|
|
365
|
-
const request = context.explain;
|
|
366
|
-
if (!request) return [];
|
|
367
|
-
const db = live(context);
|
|
368
|
-
if (!db || "skipped" in db || !db.session) return [{
|
|
369
|
-
severity: "warning",
|
|
370
|
-
message: `--explain needs a direct database connection (local stack, $DATABASE_URL, $SUPABASE_DB_URL or --db-url-stdin)${db && "skipped" in db ? `: ${db.skipped}` : "."}`
|
|
371
|
-
}];
|
|
372
|
-
const who = typeof request.claims["sub"] === "string" ? `${String(request.claims["role"] ?? "anon")} ${request.claims["sub"]}` : String(request.claims["role"] ?? "anon");
|
|
373
|
-
const findings = [];
|
|
374
|
-
for (const name of request.tables) {
|
|
375
|
-
const table = resolveTable(context, name);
|
|
376
|
-
if (!table) {
|
|
377
|
-
findings.push({
|
|
378
|
-
severity: "warning",
|
|
379
|
-
message: `--explain: no table "${name}" in the exposed schemas; qualify tables in other schemas, like better_supabase.memberships.`
|
|
380
|
-
});
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
try {
|
|
384
|
-
const { summary, functions } = await explainTable(db, table, request.claims);
|
|
385
|
-
const helpers = functions ? functions.length > 0 ? ` Function time: ${functions.map((fn) => {
|
|
386
|
-
const self = fn.selfTime;
|
|
387
|
-
const share = summary.executionMs > 0 ? Math.round(self / summary.executionMs * 100) : 0;
|
|
388
|
-
return `${fn.name} ${self.toFixed(2)} ms over ${plural$1(fn.calls, "call")} (${share}%)`;
|
|
389
|
-
}).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.";
|
|
390
|
-
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.` : "";
|
|
391
|
-
findings.push({
|
|
392
|
-
...summary.perRowSubPlans.length > 0 ? { severity: "warning" } : {},
|
|
393
|
-
message: `${qualified(table)} as ${who}: ${summary.executionMs.toFixed(2)} ms, ${plural$1(summary.initPlans, "InitPlan")}. Plan: ${summary.nodes.join(" > ")}.${perRow}${helpers}`,
|
|
394
|
-
target: `${qualified(table)}:explain`,
|
|
395
|
-
object: tableObject(table)
|
|
396
|
-
});
|
|
397
|
-
} catch (cause) {
|
|
398
|
-
findings.push({
|
|
399
|
-
severity: "warning",
|
|
400
|
-
message: `--explain ${qualified(table)} failed: ${errorText(cause)}`,
|
|
401
|
-
target: `${qualified(table)}:explain`
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
return findings;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
];
|
|
409
|
-
//#endregion
|
|
410
|
-
//#region src/cli/doctor/hooks.ts
|
|
411
|
-
/** Claims past this size make every request carry a large cookie and header. */
|
|
412
|
-
const HOOK_CLAIMS_LIMIT = 2048;
|
|
413
|
-
/**
|
|
414
|
-
* The BS405 limits. With a provider hook budget, `doctor.claimsLimit` (else
|
|
415
|
-
* the budget's bytes) limits its claims and the whole token keeps 2048;
|
|
416
|
-
* without one, `doctor.claimsLimit` limits the whole token.
|
|
417
|
-
*/
|
|
418
|
-
function claimsLimits(context) {
|
|
419
|
-
const configured = context.config.doctor.claimsLimit;
|
|
420
|
-
const budget = providerHook(context)?.budget;
|
|
421
|
-
return budget ? {
|
|
422
|
-
token: HOOK_CLAIMS_LIMIT,
|
|
423
|
-
budget: {
|
|
424
|
-
bytes: configured ?? budget.bytes,
|
|
425
|
-
claims: budget.claims,
|
|
426
|
-
...budget.truncatedClaim ? { truncatedClaim: budget.truncatedClaim } : {}
|
|
427
|
-
}
|
|
428
|
-
} : { token: configured ?? HOOK_CLAIMS_LIMIT };
|
|
429
|
-
}
|
|
430
|
-
/** The module function that fills the `memberships` claim. */
|
|
431
|
-
const MODULE_MEMBERSHIPS = /better_supabase\s*\.\s*membership_claims\b/i;
|
|
432
|
-
const providerHook = (context) => context.config.authorization?.tokenHook;
|
|
433
|
-
const splitName = (qualified) => {
|
|
434
|
-
const dot = qualified.indexOf(".");
|
|
435
|
-
return {
|
|
436
|
-
schema: qualified.slice(0, dot),
|
|
437
|
-
name: qualified.slice(dot + 1)
|
|
438
|
-
};
|
|
439
|
-
};
|
|
440
|
-
const escapeRegExp$1 = (text) => text.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
441
|
-
/**
|
|
442
|
-
* The claims the provider's hook writes that a second writer would
|
|
443
|
-
* contradict, with the tenant claim it writes to.
|
|
444
|
-
*/
|
|
445
|
-
function ownedClaims(hook) {
|
|
446
|
-
return [.../* @__PURE__ */ new Set([...hook.ownedClaims, ...hook.tenantClaim ? [hook.tenantClaim] : []])];
|
|
447
|
-
}
|
|
448
|
-
const quotedName = (schema, name) => `(?:"?${escapeRegExp$1(schema)}"?\\s*\\.\\s*)?"?${escapeRegExp$1(name)}"?`;
|
|
449
|
-
const createsFunction = (fn) => new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${quotedName(fn.schema, fn.name)}\\s*\\(`, "i");
|
|
450
|
-
/** The SQL file that has the provider's hook marker and creates `fn`. */
|
|
451
|
-
function markerFileFor(context, fn) {
|
|
452
|
-
const marker = providerHook(context)?.markers?.hook;
|
|
453
|
-
if (marker === void 0) return void 0;
|
|
454
|
-
const creates = createsFunction(fn);
|
|
455
|
-
return (context.sqlFiles ?? []).find((file) => file.text.includes(marker) && creates.test(file.text));
|
|
456
|
-
}
|
|
457
|
-
/** Whether `fn` is the provider's hook: by its name or its marker file. */
|
|
458
|
-
function isProviderHook(context, fn) {
|
|
459
|
-
const hook = providerHook(context);
|
|
460
|
-
if (!hook) return false;
|
|
461
|
-
if (hook.function === `${fn.schema}.${fn.name}`) return true;
|
|
462
|
-
return markerFileFor(context, fn) !== void 0;
|
|
463
|
-
}
|
|
464
|
-
/** Whether the hook body calls the provider's hook (a wrapper around it). */
|
|
465
|
-
function wrapsProviderHook(hook, source) {
|
|
466
|
-
const { schema, name } = splitName(hook.function);
|
|
467
|
-
return new RegExp(`${quotedName(schema, name)}\\s*\\(`, "i").test(source);
|
|
468
|
-
}
|
|
469
|
-
/** Which of `claims` the hook body writes through `jsonb_set` or `jsonb_build_object`. */
|
|
470
|
-
function writtenClaims(source, claims) {
|
|
471
|
-
return claims.filter((claim) => {
|
|
472
|
-
const name = escapeRegExp$1(claim);
|
|
473
|
-
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);
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
const API_ROLES$2 = ["authenticated", "anon"];
|
|
477
|
-
/** Hooks enabled in `config.toml`, matched with their introspected functions. */
|
|
478
|
-
function configuredHooks(context) {
|
|
479
|
-
if (!context.configToml) return [];
|
|
480
|
-
const introspected = context.snapshot.extras.hooks ?? [];
|
|
481
|
-
return pgFunctionHooks(context.configToml.document).map((config) => ({
|
|
482
|
-
config,
|
|
483
|
-
extras: introspected.find((hook) => hook.hook === config.hook && hook.schema === config.schema && hook.name === config.name)
|
|
484
|
-
}));
|
|
485
|
-
}
|
|
486
|
-
function hookLocation(context, hook) {
|
|
487
|
-
if (!context.configToml) return void 0;
|
|
488
|
-
const line = lineOf(context.configToml.text, new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`));
|
|
489
|
-
return line ? {
|
|
490
|
-
file: context.configToml.path,
|
|
491
|
-
line
|
|
492
|
-
} : void 0;
|
|
493
|
-
}
|
|
494
|
-
const signatureOf = (fn) => `${fn.schema}.${fn.name}(${fn.signature})`;
|
|
495
|
-
/** What is wrong with who may call a hook function, and the SQL that fixes it. */
|
|
496
|
-
function grantProblems(fn) {
|
|
497
|
-
const problems = [];
|
|
498
|
-
const fix = [];
|
|
499
|
-
if (!fn.schemaUsage.includes("supabase_auth_admin")) {
|
|
500
|
-
problems.push(`supabase_auth_admin has no usage on schema ${fn.schema}`);
|
|
501
|
-
fix.push(`grant usage on schema ${fn.schema} to supabase_auth_admin;`);
|
|
502
|
-
}
|
|
503
|
-
if (!fn.execute.includes("supabase_auth_admin")) {
|
|
504
|
-
problems.push("supabase_auth_admin may not execute it");
|
|
505
|
-
fix.push(`grant execute on function ${signatureOf(fn)} to supabase_auth_admin;`);
|
|
506
|
-
}
|
|
507
|
-
const exposedTo = API_ROLES$2.filter((role) => fn.execute.includes(role));
|
|
508
|
-
if (exposedTo.length > 0 || fn.publicExecute) {
|
|
509
|
-
const roles = [...exposedTo, ...fn.publicExecute ? ["public"] : []];
|
|
510
|
-
problems.push(`${roles.join(", ")} may execute it, so any client can call it through the API`);
|
|
511
|
-
fix.push(`revoke execute on function ${signatureOf(fn)} from authenticated, anon, public;`);
|
|
512
|
-
}
|
|
513
|
-
return {
|
|
514
|
-
problems,
|
|
515
|
-
fix
|
|
516
|
-
};
|
|
517
|
-
}
|
|
518
|
-
const MIGRATIONS = /(?:^|\/)supabase\/migrations\//;
|
|
519
|
-
/**
|
|
520
|
-
* The file that grants `fn` to `supabase_auth_admin`: a migration with the
|
|
521
|
-
* provider's grants marker, or under pg-delta, which carries grants, the declarative
|
|
522
|
-
* schema file that defines `fn`. `supabase db diff` drops grants, so the
|
|
523
|
-
* legacy engine only counts the migration.
|
|
524
|
-
*/
|
|
525
|
-
function grantsSourceFor(context, fn, engine) {
|
|
526
|
-
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");
|
|
527
|
-
const creates = createsFunction(fn);
|
|
528
|
-
const marker = providerHook(context)?.markers?.grants;
|
|
529
|
-
return (context.sqlFiles ?? []).find((file) => grants.test(file.text) && (marker !== void 0 && file.text.includes(marker) || engine === "pg-delta" && !MIGRATIONS.test(file.path) && creates.test(file.text)))?.path;
|
|
530
|
-
}
|
|
531
|
-
/** Configured hook functions whose grants are wrong (BS404, `doctor --fix-grants`). */
|
|
532
|
-
function hookGrantProblems(context) {
|
|
533
|
-
const engine = diffEngine(context.configToml);
|
|
534
|
-
return configuredHooks(context).flatMap(({ config, extras }) => (extras?.functions ?? []).flatMap((fn) => {
|
|
535
|
-
const { problems, fix } = grantProblems(fn);
|
|
536
|
-
if (problems.length === 0) return [];
|
|
537
|
-
const file = grantsSourceFor(context, fn, engine);
|
|
538
|
-
const provider = context.config.authorization;
|
|
539
|
-
const command = provider?.tokenHook?.grantsCommand;
|
|
540
|
-
return [{
|
|
541
|
-
hook: config.hook,
|
|
542
|
-
fn,
|
|
543
|
-
problems,
|
|
544
|
-
fix: file ? {
|
|
545
|
-
kind: "migration",
|
|
546
|
-
file
|
|
547
|
-
} : provider && isProviderHook(context, fn) ? {
|
|
548
|
-
kind: "provider",
|
|
549
|
-
name: providerLabel(provider),
|
|
550
|
-
...command ? { command } : {}
|
|
551
|
-
} : {
|
|
552
|
-
kind: "sql",
|
|
553
|
-
sql: fix
|
|
554
|
-
}
|
|
555
|
-
}];
|
|
556
|
-
}));
|
|
557
|
-
}
|
|
558
|
-
/** The sentence that tells the reader how to apply `fix`. */
|
|
559
|
-
function grantFixText(fix, engine) {
|
|
560
|
-
switch (fix.kind) {
|
|
561
|
-
case "sql": return engine === "pg-delta" ? `Add to the schema file that defines the function, then run \`supabase db schema declarative sync\` (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}` : `Append to the migration \`supabase db diff\` wrote (\`doctor --fix-grants\` prints every block):\n${fix.sql.join("\n")}`;
|
|
562
|
-
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.`;
|
|
563
|
-
case "migration": return engine === "pg-delta" ? `${fix.file} grants it, so the database is behind: run \`supabase db schema declarative sync\`, then apply the migrations (\`supabase migration up\`).` : `${fix.file} grants it, so the database is behind the migrations: apply them (\`supabase migration up\`).`;
|
|
564
|
-
default: return fix;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
/** `doctor --fix-grants`: the SQL block for every BS404 problem, as one migration snippet. */
|
|
568
|
-
function hookGrantBlock(problems, engine = "pg-delta") {
|
|
569
|
-
if (problems.length === 0) return "-- Every configured Auth hook function has its grants.";
|
|
570
|
-
const lines = ["-- Auth hook grants (better-supabase doctor --fix-grants).", engine === "pg-delta" ? "-- Add this to the schema file that defines the function, then run `supabase db schema declarative sync`." : "-- `supabase db diff` does not carry function grants; append this to its migration."];
|
|
571
|
-
for (const { fn, hook, fix } of problems) {
|
|
572
|
-
const name = `${signatureOf(fn)} ([auth.hook.${hook}])`;
|
|
573
|
-
switch (fix.kind) {
|
|
574
|
-
case "sql":
|
|
575
|
-
lines.push("", `-- ${name}`, ...fix.sql);
|
|
576
|
-
break;
|
|
577
|
-
case "provider":
|
|
578
|
-
lines.push("", `-- ${name} is the hook of ${fix.name}${fix.command ? `: ${fix.command}` : ""}`);
|
|
579
|
-
break;
|
|
580
|
-
case "migration":
|
|
581
|
-
lines.push("", engine === "pg-delta" ? `-- ${name}: ${fix.file} grants it; run \`supabase db schema declarative sync\`, then \`supabase migration up\`.` : `-- ${name}: ${fix.file} grants it; run \`supabase migration up\`.`);
|
|
582
|
-
break;
|
|
583
|
-
default: return fix;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
return lines.join("\n");
|
|
587
|
-
}
|
|
588
|
-
const emptySearchPath = (fn) => {
|
|
589
|
-
const value = fn.settings["search_path"];
|
|
590
|
-
return value === "" || value === "\"\"" || value === "''";
|
|
591
|
-
};
|
|
592
|
-
/** Auth's issuer shape (`{url}/auth/v1`); doctor has no project URL to put in it. */
|
|
593
|
-
const DOCTOR_ISSUER = "https://doctor.invalid/auth/v1";
|
|
594
|
-
/**
|
|
595
|
-
* Runs the hook as Auth does for `userId` (as `supabase_auth_admin` when the
|
|
596
|
-
* connecting role may switch to it), in a transaction that is rolled back.
|
|
597
|
-
* Undefined when `auth.users` has no such user.
|
|
598
|
-
*/
|
|
599
|
-
async function withHookEvent(db, userId, run) {
|
|
600
|
-
await db.query("begin");
|
|
601
|
-
try {
|
|
602
|
-
await db.query(`set local statement_timeout = '10s'`);
|
|
603
|
-
const [user] = await db.query(`select set_config('better_supabase.hook_event', jsonb_build_object(
|
|
604
|
-
'user_id', u.id,
|
|
605
|
-
'authentication_method', 'password',
|
|
606
|
-
'claims', jsonb_build_object(
|
|
607
|
-
'aud', 'authenticated', 'role', 'authenticated', 'sub', u.id,
|
|
608
|
-
'email', coalesce(u.email, ''), 'phone', coalesce(u.phone, ''),
|
|
609
|
-
'app_metadata', coalesce(u.raw_app_meta_data, '{}'::jsonb),
|
|
610
|
-
'user_metadata', coalesce(u.raw_user_meta_data, '{}'::jsonb),
|
|
611
|
-
'aal', 'aal1', 'session_id', gen_random_uuid(),
|
|
612
|
-
'is_anonymous', coalesce(u.is_anonymous, false),
|
|
613
|
-
'amr', jsonb_build_array(jsonb_build_object(
|
|
614
|
-
'method', 'password', 'timestamp', extract(epoch from now())::int8
|
|
615
|
-
)),
|
|
616
|
-
'iss', ${literal$1(DOCTOR_ISSUER)},
|
|
617
|
-
'iat', extract(epoch from now())::int8,
|
|
618
|
-
'exp', extract(epoch from now())::int8 + 3600
|
|
619
|
-
)
|
|
620
|
-
)::text, true) as event
|
|
621
|
-
from auth.users u where u.id = ${literal$1(userId)}::uuid`);
|
|
622
|
-
if (!user?.event) return void 0;
|
|
623
|
-
const [role] = await db.query(`select case when exists(select 1 from pg_roles where rolname = 'supabase_auth_admin')
|
|
624
|
-
then pg_has_role('supabase_auth_admin', 'member') else false end as member`);
|
|
625
|
-
if (role?.member) await db.query("set local role supabase_auth_admin");
|
|
626
|
-
return await run();
|
|
627
|
-
} finally {
|
|
628
|
-
await db.query("rollback");
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
632
|
-
const hookCall = (fn) => `${ident$1(fn.schema)}.${ident$1(fn.name)}(current_setting('better_supabase.hook_event')::jsonb) -> 'claims'`;
|
|
633
|
-
/** The claims the hook returns for `userId`, or undefined without that user. */
|
|
634
|
-
async function hookClaims(db, fn, userId) {
|
|
635
|
-
return withHookEvent(db, userId, async () => {
|
|
636
|
-
const [row] = await db.query(`select ${hookCall(fn)} as claims`);
|
|
637
|
-
const claims = typeof row?.claims === "string" ? JSON.parse(row.claims) : row?.claims;
|
|
638
|
-
return isRecord(claims) ? claims : {};
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
async function hookClaimsSize(db, fn, userId, budget) {
|
|
642
|
-
const budgetSql = budget && budget.claims.length > 0 ? budget.claims.map((claim) => `coalesce(octet_length((c -> ${literal$1(claim)})::text), 0)`).join(" + ") : "0";
|
|
643
|
-
const truncatedSql = budget?.truncatedClaim ? `c ->> ${literal$1(budget.truncatedClaim)} = 'true'` : "false";
|
|
644
|
-
return withHookEvent(db, userId, async () => {
|
|
645
|
-
const [row] = await db.query(`select octet_length(c::text) as bytes,
|
|
646
|
-
octet_length((c -> 'memberships')::text) as memberships,
|
|
647
|
-
${budgetSql} as budget,
|
|
648
|
-
${truncatedSql} as truncated
|
|
649
|
-
from (select ${hookCall(fn)} as c) h`);
|
|
650
|
-
return {
|
|
651
|
-
bytes: Number(row?.bytes ?? 0),
|
|
652
|
-
memberships: Number(row?.memberships ?? 0),
|
|
653
|
-
budget: Number(row?.budget ?? 0),
|
|
654
|
-
truncated: row?.truncated === true
|
|
655
|
-
};
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
const LOCAL_HOSTS = /* @__PURE__ */ new Set([
|
|
659
|
-
"localhost",
|
|
660
|
-
"127.0.0.1",
|
|
661
|
-
"[::1]",
|
|
662
|
-
"host.docker.internal"
|
|
663
|
-
]);
|
|
664
|
-
/** The `secrets = ...` line of `[auth.hook.<hook>]` reads an `env()` value. */
|
|
665
|
-
function secretsFromEnv(text, hook) {
|
|
666
|
-
const start = text.search(new RegExp(`^\\s*\\[auth\\.hook\\.${hook}\\]`, "m"));
|
|
667
|
-
if (start < 0) return false;
|
|
668
|
-
const rest = text.slice(start).split("\n").slice(1);
|
|
669
|
-
const end = rest.findIndex((line) => /^\s*\[/.test(line));
|
|
670
|
-
const section = (end < 0 ? rest : rest.slice(0, end)).join("\n");
|
|
671
|
-
return /^\s*secrets\s*=\s*["']env\(/m.test(section);
|
|
672
|
-
}
|
|
673
|
-
/**
|
|
674
|
-
* What is wrong with one Standard Webhooks secret (`v1,whsec_<base64>`), the
|
|
675
|
-
* format Auth signs HTTP hook requests with. Never quotes the secret.
|
|
676
|
-
*/
|
|
677
|
-
function secretProblem(secret) {
|
|
678
|
-
const match = /^v1,whsec_([A-Za-z0-9+/]+={0,2})$/.exec(secret.trim());
|
|
679
|
-
if (!match) return "is not in the `v1,whsec_<base64>` format";
|
|
680
|
-
const bytes = atob(match[1]).length;
|
|
681
|
-
return bytes < 24 || bytes > 64 ? `decodes to ${bytes} bytes; Standard Webhooks secrets are 24 to 64` : void 0;
|
|
682
|
-
}
|
|
683
|
-
const HOOK_RULES = [
|
|
684
|
-
{
|
|
685
|
-
code: "BS404",
|
|
686
|
-
severity: "error",
|
|
687
|
-
title: "Auth hook function grants",
|
|
688
|
-
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.",
|
|
689
|
-
check: (context) => [...configuredHooks(context).flatMap(({ config, extras }) => {
|
|
690
|
-
if (!extras || extras.functions.length > 0) return [];
|
|
691
|
-
const location = hookLocation(context, config.hook);
|
|
692
|
-
return [{
|
|
693
|
-
message: `[auth.hook.${config.hook}] calls ${config.schema}.${config.name}, which does not exist. Sign-ins fail until it does.`,
|
|
694
|
-
target: `[auth.hook.${config.hook}]`,
|
|
695
|
-
...location ? { location } : {}
|
|
696
|
-
}];
|
|
697
|
-
}), ...hookGrantProblems(context).map(({ hook, fn, problems, fix }) => ({
|
|
698
|
-
message: `${signatureOf(fn)} ([auth.hook.${hook}]): ${problems.join("; ")}. ${grantFixText(fix, diffEngine(context.configToml))}`,
|
|
699
|
-
target: signatureOf(fn),
|
|
700
|
-
object: {
|
|
701
|
-
kind: "function",
|
|
702
|
-
schema: fn.schema,
|
|
703
|
-
name: fn.name
|
|
704
|
-
}
|
|
705
|
-
}))]
|
|
706
|
-
},
|
|
707
|
-
{
|
|
708
|
-
code: "BS405",
|
|
709
|
-
severity: "warning",
|
|
710
|
-
title: "Custom access token hook shape",
|
|
711
|
-
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.`,
|
|
712
|
-
async check(context) {
|
|
713
|
-
const findings = [];
|
|
714
|
-
const limits = claimsLimits(context);
|
|
715
|
-
for (const { config, extras } of configuredHooks(context)) {
|
|
716
|
-
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
717
|
-
for (const fn of extras.functions) {
|
|
718
|
-
const object = {
|
|
719
|
-
kind: "function",
|
|
720
|
-
schema: fn.schema,
|
|
721
|
-
name: fn.name
|
|
722
|
-
};
|
|
723
|
-
const problems = [];
|
|
724
|
-
if (fn.volatility !== "stable") problems.push(`it is ${fn.volatility}; declare it \`stable\``);
|
|
725
|
-
if (!emptySearchPath(fn)) problems.push("it has no `set search_path = ''`; add it and qualify every table");
|
|
726
|
-
if (problems.length > 0) findings.push({
|
|
727
|
-
message: `${signatureOf(fn)}: ${problems.join("; ")}.`,
|
|
728
|
-
target: signatureOf(fn),
|
|
729
|
-
object
|
|
730
|
-
});
|
|
731
|
-
if (!context.hookUser) continue;
|
|
732
|
-
const db = context.database;
|
|
733
|
-
if (!db || "skipped" in db || !db.session) {
|
|
734
|
-
findings.push({
|
|
735
|
-
severity: "info",
|
|
736
|
-
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}` : "."}`,
|
|
737
|
-
target: `${signatureOf(fn)}:claims`
|
|
738
|
-
});
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
try {
|
|
742
|
-
const size = await hookClaimsSize(db, fn, context.hookUser, limits.budget);
|
|
743
|
-
if (size === void 0) {
|
|
744
|
-
findings.push({
|
|
745
|
-
severity: "info",
|
|
746
|
-
message: `--as ${context.hookUser}: no such user in auth.users, so the hook was not called.`,
|
|
747
|
-
target: `${signatureOf(fn)}:claims`
|
|
748
|
-
});
|
|
749
|
-
continue;
|
|
750
|
-
}
|
|
751
|
-
if (limits.budget !== void 0 && size.budget > limits.budget.bytes) findings.push({
|
|
752
|
-
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.`,
|
|
753
|
-
target: `${signatureOf(fn)}:budget`,
|
|
754
|
-
object
|
|
755
|
-
});
|
|
756
|
-
if (size.bytes > limits.token) findings.push({
|
|
757
|
-
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.`,
|
|
758
|
-
target: `${signatureOf(fn)}:claims`,
|
|
759
|
-
object
|
|
760
|
-
});
|
|
761
|
-
if (size.truncated) findings.push({
|
|
762
|
-
severity: "info",
|
|
763
|
-
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.`,
|
|
764
|
-
target: `${signatureOf(fn)}:truncated`,
|
|
765
|
-
object
|
|
766
|
-
});
|
|
767
|
-
} catch (cause) {
|
|
768
|
-
findings.push({
|
|
769
|
-
message: `Calling ${signatureOf(fn)} for ${context.hookUser} failed: ${errorText(cause)}`,
|
|
770
|
-
target: `${signatureOf(fn)}:claims`,
|
|
771
|
-
object
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
return findings;
|
|
777
|
-
}
|
|
778
|
-
},
|
|
779
|
-
{
|
|
780
|
-
code: "BS407",
|
|
781
|
-
severity: "error",
|
|
782
|
-
title: "Two authorization hooks",
|
|
783
|
-
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.",
|
|
784
|
-
check: (context) => {
|
|
785
|
-
const provider = context.config.authorization;
|
|
786
|
-
const hook = provider?.tokenHook;
|
|
787
|
-
if (!provider || !hook) return [];
|
|
788
|
-
const label = providerLabel(provider);
|
|
789
|
-
const registered = new Map((hook.registeredClaims ?? []).map((claim) => [claim.name, claim.function]));
|
|
790
|
-
const owned = ownedClaims(hook);
|
|
791
|
-
const findings = [];
|
|
792
|
-
for (const { config, extras } of configuredHooks(context)) {
|
|
793
|
-
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
794
|
-
for (const fn of extras.functions) {
|
|
795
|
-
if (fn.source === void 0 || isProviderHook(context, fn)) continue;
|
|
796
|
-
const module = owned.includes("memberships") && MODULE_MEMBERSHIPS.test(fn.source);
|
|
797
|
-
const wrapper = wrapsProviderHook(hook, fn.source);
|
|
798
|
-
const written = writtenClaims(fn.source, [...owned, ...wrapper ? registered.keys() : []]);
|
|
799
|
-
if (!module && written.length === 0) continue;
|
|
800
|
-
const location = hookLocation(context, config.hook);
|
|
801
|
-
const what = [...module ? ["calls better_supabase.membership_claims"] : [], ...written.length > 0 ? [`writes ${written.join(", ")}`] : []].join(" and ");
|
|
802
|
-
const extra = written.filter((claim) => registered.has(claim));
|
|
803
|
-
const sources = extra.map((claim) => `${claim} from ${registered.get(claim)}`).join(", ");
|
|
804
|
-
findings.push({
|
|
805
|
-
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.`,
|
|
806
|
-
target: signatureOf(fn),
|
|
807
|
-
object: {
|
|
808
|
-
kind: "function",
|
|
809
|
-
schema: fn.schema,
|
|
810
|
-
name: fn.name
|
|
811
|
-
},
|
|
812
|
-
...location ? { location } : {}
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
return findings;
|
|
817
|
-
}
|
|
818
|
-
},
|
|
819
|
-
{
|
|
820
|
-
code: "BS410",
|
|
821
|
-
severity: "warning",
|
|
822
|
-
title: "HTTP auth hooks",
|
|
823
|
-
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`.",
|
|
824
|
-
check: (context) => {
|
|
825
|
-
const toml = context.configToml;
|
|
826
|
-
if (!toml) return [];
|
|
827
|
-
return httpHooks(toml.document).flatMap(({ hook, uri, secrets }) => {
|
|
828
|
-
const location = hookLocation(context, hook);
|
|
829
|
-
const at = location ? { location } : {};
|
|
830
|
-
const target = `[auth.hook.${hook}]`;
|
|
831
|
-
const findings = [{
|
|
832
|
-
severity: "info",
|
|
833
|
-
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.`,
|
|
834
|
-
target,
|
|
835
|
-
...at
|
|
836
|
-
}];
|
|
837
|
-
if (uri.protocol === "http:" && !LOCAL_HOSTS.has(uri.hostname)) findings.push({
|
|
838
|
-
message: `${target} sends claims and its signature to ${uri.host} over plain http. Use https.`,
|
|
839
|
-
target,
|
|
840
|
-
...at
|
|
841
|
-
});
|
|
842
|
-
const fromEnv = secretsFromEnv(toml.text, hook);
|
|
843
|
-
if (secrets === void 0 || secrets.trim() === "") {
|
|
844
|
-
findings.push({
|
|
845
|
-
severity: "error",
|
|
846
|
-
message: `${target} has no \`secrets\`, so Auth can't sign its requests. Set \`secrets = "env(AUTH_HOOK_SECRET)"\` with a \`v1,whsec_<base64>\` value.`,
|
|
847
|
-
target,
|
|
848
|
-
...at
|
|
849
|
-
});
|
|
850
|
-
return findings;
|
|
851
|
-
}
|
|
852
|
-
if (!fromEnv) findings.push({
|
|
853
|
-
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)"\`.`,
|
|
854
|
-
target,
|
|
855
|
-
...at
|
|
856
|
-
});
|
|
857
|
-
if (fromEnv && secrets.trim().startsWith("env(")) return findings;
|
|
858
|
-
const problems = secrets.split("|").flatMap((secret, index) => {
|
|
859
|
-
const problem = secretProblem(secret);
|
|
860
|
-
return problem ? [`secret ${index + 1} ${problem}`] : [];
|
|
861
|
-
});
|
|
862
|
-
if (problems.length > 0) findings.push({
|
|
863
|
-
severity: "error",
|
|
864
|
-
message: `${target}: ${problems.join("; ")}. Auth rejects the config; generate one with \`openssl rand -base64 32\` and prefix it with \`v1,whsec_\`.`,
|
|
865
|
-
target,
|
|
866
|
-
...at
|
|
867
|
-
});
|
|
868
|
-
return findings;
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
];
|
|
873
|
-
//#endregion
|
|
874
|
-
//#region src/cli/doctor/lite.ts
|
|
875
|
-
const RPC_CALL = /(?:\.|\$)rpc\s*\(|\brpcTransport\s*\(/;
|
|
876
|
-
const MISSING_FEATURES = [{
|
|
877
|
-
feature: "Realtime",
|
|
878
|
-
pattern: /from\s+["']better-supabase\/realtime["']|\.channel\s*\(|\.removeChannel\s*\(/
|
|
879
|
-
}, {
|
|
880
|
-
feature: "Edge Functions",
|
|
881
|
-
pattern: /\.functions\.invoke\s*\(|\bfunctions\(\)\.invoke\s*\(/
|
|
882
|
-
}];
|
|
883
|
-
/** The `[db] driver` of a Supabase Lite project, or `undefined` for any other project. */
|
|
884
|
-
function liteDriver(context) {
|
|
885
|
-
try {
|
|
886
|
-
return liteDriverOf(context.configToml);
|
|
887
|
-
} catch {
|
|
888
|
-
return;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
function rpcOnSqlite(context) {
|
|
892
|
-
const driver = liteDriver(context);
|
|
893
|
-
if (driver === void 0 || !isSqliteLiteDriver(driver)) return [];
|
|
894
|
-
return context.sources.flatMap((file) => {
|
|
895
|
-
const line = lineOf(file.text, RPC_CALL);
|
|
896
|
-
if (line === void 0) return [];
|
|
897
|
-
return [{
|
|
898
|
-
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.`,
|
|
899
|
-
target: `${file.path}:rpc`,
|
|
900
|
-
location: {
|
|
901
|
-
file: file.path,
|
|
902
|
-
line
|
|
903
|
-
}
|
|
904
|
-
}];
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
function missingLiteFeature(context) {
|
|
908
|
-
if (liteDriver(context) === void 0) return [];
|
|
909
|
-
return context.sources.flatMap((file) => MISSING_FEATURES.flatMap(({ feature, pattern }) => {
|
|
910
|
-
const line = lineOf(file.text, pattern);
|
|
911
|
-
if (line === void 0) return [];
|
|
912
|
-
return [{
|
|
913
|
-
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\`.`,
|
|
914
|
-
target: `${file.path}:${feature}`,
|
|
915
|
-
location: {
|
|
916
|
-
file: file.path,
|
|
917
|
-
line
|
|
918
|
-
}
|
|
919
|
-
}];
|
|
920
|
-
}));
|
|
921
|
-
}
|
|
922
|
-
const LITE_RULES = [{
|
|
923
|
-
code: "BS328",
|
|
924
|
-
severity: "warning",
|
|
925
|
-
title: "rpc() on a Supabase Lite SQLite driver",
|
|
926
|
-
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.",
|
|
927
|
-
check: rpcOnSqlite
|
|
928
|
-
}, {
|
|
929
|
-
code: "BS329",
|
|
930
|
-
severity: "warning",
|
|
931
|
-
title: "Feature Supabase Lite does not implement",
|
|
932
|
-
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.",
|
|
933
|
-
check: missingLiteFeature
|
|
934
|
-
}];
|
|
935
|
-
//#endregion
|
|
936
|
-
//#region src/cli/doctor/metadata.ts
|
|
937
|
-
/**
|
|
938
|
-
* Rules that read what a `GeneratorMetadata` document leaves out (policies,
|
|
939
|
-
* grants, indexes, triggers, buckets, the realtime publication, foreign key
|
|
940
|
-
* actions, multi-column constraints, role settings, hook functions). On a
|
|
941
|
-
* snapshot built from such a document they would report what is missing
|
|
942
|
-
* from the document, not from the database, so they skip with an info finding.
|
|
943
|
-
*/
|
|
944
|
-
const EXTRAS_RULES = /* @__PURE__ */ new Set([
|
|
945
|
-
"BS103",
|
|
946
|
-
"BS106",
|
|
947
|
-
"BS107",
|
|
948
|
-
"BS108",
|
|
949
|
-
"BS109",
|
|
950
|
-
"BS110",
|
|
951
|
-
"BS111",
|
|
952
|
-
"BS112",
|
|
953
|
-
"BS113",
|
|
954
|
-
"BS114",
|
|
955
|
-
"BS115",
|
|
956
|
-
"BS116",
|
|
957
|
-
"BS204",
|
|
958
|
-
"BS205",
|
|
959
|
-
"BS206",
|
|
960
|
-
"BS207",
|
|
961
|
-
"BS210",
|
|
962
|
-
"BS211",
|
|
963
|
-
"BS213",
|
|
964
|
-
"BS215",
|
|
965
|
-
"BS216",
|
|
966
|
-
"BS217",
|
|
967
|
-
"BS218",
|
|
968
|
-
"BS219",
|
|
969
|
-
"BS301",
|
|
970
|
-
"BS302",
|
|
971
|
-
"BS303",
|
|
972
|
-
"BS305",
|
|
973
|
-
"BS306",
|
|
974
|
-
"BS310",
|
|
975
|
-
"BS315",
|
|
976
|
-
"BS404",
|
|
977
|
-
"BS405",
|
|
978
|
-
"BS406",
|
|
979
|
-
"BS407"
|
|
980
|
-
]);
|
|
981
|
-
/** The info finding for a rule in `EXTRAS_RULES` that did not run, or `undefined` when it can. */
|
|
982
|
-
function skippedForMetadata(snapshot, rule, help) {
|
|
983
|
-
if (!snapshot.extras.fromMetadata || !EXTRAS_RULES.has(rule.code)) return void 0;
|
|
984
|
-
return {
|
|
985
|
-
code: rule.code,
|
|
986
|
-
severity: "info",
|
|
987
|
-
title: rule.title,
|
|
988
|
-
help,
|
|
989
|
-
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.`
|
|
990
|
-
};
|
|
991
|
-
}
|
|
992
|
-
//#endregion
|
|
993
|
-
//#region src/cli/doctor/modules.ts
|
|
994
|
-
/** The non-empty string at a dotted path, as the tenant() plugin reads it. */
|
|
995
|
-
function claimAt(claims, path) {
|
|
996
|
-
let value = claims;
|
|
997
|
-
for (const segment of path.split(".")) value = isRecord(value) ? value[segment] : void 0;
|
|
998
|
-
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
999
|
-
}
|
|
1000
|
-
function contractChecks(context) {
|
|
1001
|
-
return customContracts(context.config.sql.moduleNames, moduleLayout(context.config, context.config.sql.testsDir, [], void 0, void 0, permissionCatalogKeys(context))).flatMap((contract) => contract.functions.map((fn) => ({
|
|
1002
|
-
module: contract.module,
|
|
1003
|
-
schema: contract.schema,
|
|
1004
|
-
name: fn.name,
|
|
1005
|
-
args: contractSignature(fn, contract.idType),
|
|
1006
|
-
returns: fn.returns.replaceAll("{id}", contract.idType)
|
|
1007
|
-
})));
|
|
1008
|
-
}
|
|
1009
|
-
const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1010
|
-
const ident = (name) => `"?${escapeRegExp(name)}"?`;
|
|
1011
|
-
/** Whether a `create function schema.name(` appears in the SQL files. */
|
|
1012
|
-
function declared(context, check) {
|
|
1013
|
-
const pattern = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+${ident(check.schema)}\\.${ident(check.name)}\\s*\\(`, "i");
|
|
1014
|
-
return (context.sqlFiles ?? []).some((file) => pattern.test(file.text));
|
|
1015
|
-
}
|
|
1016
|
-
const normalize$1 = (types) => types.replaceAll(/\s+/g, " ").replaceAll(/\s*,\s*/g, ", ").trim();
|
|
1017
|
-
const PRE_REQUEST_HOOK = `select h.hook,
|
|
1018
|
-
coalesce(pg_catalog.strpos(pg_catalog.pg_get_functiondef(pg_catalog.to_regproc(h.hook)), 'better_supabase.check_request') > 0, false) as calls
|
|
1019
|
-
from (
|
|
1020
|
-
select (
|
|
1021
|
-
select pg_catalog.split_part(setting, '=', 2)
|
|
1022
|
-
from pg_catalog.pg_db_role_setting s
|
|
1023
|
-
join pg_catalog.pg_roles r on r.oid = s.setrole
|
|
1024
|
-
cross join lateral pg_catalog.unnest(s.setconfig) setting
|
|
1025
|
-
where r.rolname = 'authenticator' and s.setdatabase = 0
|
|
1026
|
-
and setting like 'pgrst.db_pre_request=%'
|
|
1027
|
-
limit 1
|
|
1028
|
-
) as hook
|
|
1029
|
-
) h`;
|
|
1030
|
-
/** `rate-limit` in `sql.modules` without PostgREST's pre-request hook reaching `check_request()`. */
|
|
1031
|
-
async function rateLimitHook(context) {
|
|
1032
|
-
const db = context.database;
|
|
1033
|
-
if (!context.config.sql.moduleNames.includes("rate-limit") || context.config.sql.modules["rate-limit"]?.options?.["preRequest"] === false || !db || "skipped" in db) return [];
|
|
1034
|
-
let rows;
|
|
1035
|
-
try {
|
|
1036
|
-
rows = await db.query(PRE_REQUEST_HOOK);
|
|
1037
|
-
} catch {
|
|
1038
|
-
return [];
|
|
1039
|
-
}
|
|
1040
|
-
const [row] = rows;
|
|
1041
|
-
if (!row || row.hook === "better_supabase.check_request" || row.calls) return [];
|
|
1042
|
-
return [{
|
|
1043
|
-
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}.`,
|
|
1044
|
-
target: "authenticator pgrst.db_pre_request"
|
|
1045
|
-
}];
|
|
1046
|
-
}
|
|
1047
|
-
const DANGLING_AUDIT = `select a.target::oid::text as oid
|
|
1048
|
-
from better_supabase.audited_tables a
|
|
1049
|
-
where pg_catalog.to_regclass('better_supabase.audited_tables') is not null
|
|
1050
|
-
and not exists (select 1 from pg_catalog.pg_class c where c.oid = a.target::oid)
|
|
1051
|
-
order by 1`;
|
|
1052
|
-
/** `better_supabase.audited_tables` rows whose table no longer exists. */
|
|
1053
|
-
async function danglingAuditRegistrations(context) {
|
|
1054
|
-
const db = context.database;
|
|
1055
|
-
if (!context.config.sql.moduleNames.includes("audit") || !db || "skipped" in db) return [];
|
|
1056
|
-
let rows;
|
|
1057
|
-
try {
|
|
1058
|
-
rows = await db.query(DANGLING_AUDIT);
|
|
1059
|
-
} catch {
|
|
1060
|
-
return [];
|
|
1061
|
-
}
|
|
1062
|
-
return rows.map((row) => ({
|
|
1063
|
-
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)\`.`,
|
|
1064
|
-
target: `better_supabase.audited_tables ${row.oid}`
|
|
1065
|
-
}));
|
|
1066
|
-
}
|
|
1067
|
-
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.";
|
|
1068
|
-
/** Event triggers the `sql.modules` create that the database, or every migration, lacks. */
|
|
1069
|
-
async function missingEventTriggers(context) {
|
|
1070
|
-
const expected = moduleEventTriggers(context.config.sql.moduleNames);
|
|
1071
|
-
if (expected.length === 0) return [];
|
|
1072
|
-
const db = context.database;
|
|
1073
|
-
if (db && !("skipped" in db)) {
|
|
1074
|
-
let rows;
|
|
1075
|
-
try {
|
|
1076
|
-
rows = await db.query("select evtname as name from pg_catalog.pg_event_trigger");
|
|
1077
|
-
} catch {
|
|
1078
|
-
return [];
|
|
1079
|
-
}
|
|
1080
|
-
const present = new Set(rows.map((row) => row.name));
|
|
1081
|
-
return expected.filter((trigger) => !present.has(trigger.name)).map((trigger) => ({
|
|
1082
|
-
message: `The database has no event trigger ${trigger.name}, which the ${trigger.module} module creates. ${EVENT_TRIGGER_FIX}`,
|
|
1083
|
-
target: trigger.name
|
|
1084
|
-
}));
|
|
1085
|
-
}
|
|
1086
|
-
const toml = context.configToml;
|
|
1087
|
-
if (toml === void 0) return [];
|
|
1088
|
-
const migrations = (context.sqlFiles ?? []).filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1089
|
-
if (migrations.length === 0) return [];
|
|
1090
|
-
return expected.filter((trigger) => {
|
|
1091
|
-
const pattern = new RegExp(`create\\s+event\\s+trigger\\s+${ident(trigger.name)}\\s`, "i");
|
|
1092
|
-
return !migrations.some((file) => pattern.test(file.text));
|
|
1093
|
-
}).map((trigger) => ({
|
|
1094
|
-
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}`,
|
|
1095
|
-
target: trigger.name
|
|
1096
|
-
}));
|
|
1097
|
-
}
|
|
1098
|
-
/** Custom-mode modules whose contract functions the database or SQL files don't have. */
|
|
1099
|
-
async function missingContracts(context) {
|
|
1100
|
-
const checks = contractChecks(context);
|
|
1101
|
-
if (checks.length === 0) return [];
|
|
1102
|
-
const db = context.database;
|
|
1103
|
-
const live = db && !("skipped" in db) ? db : void 0;
|
|
1104
|
-
if (!live) return checks.filter((check) => !declared(context, check)).map((check) => ({
|
|
1105
|
-
message: `sql.modules.${check.module} is in custom mode, but no SQL file declares ${check.schema}.${check.name}(${check.args}). Write it, or switch the module to adopt or managed.`,
|
|
1106
|
-
target: `${check.schema}.${check.name}`,
|
|
1107
|
-
object: {
|
|
1108
|
-
kind: "function",
|
|
1109
|
-
schema: check.schema,
|
|
1110
|
-
name: check.name
|
|
1111
|
-
}
|
|
1112
|
-
}));
|
|
1113
|
-
const names = checks.map((check) => `(${literal$1(check.schema)}, ${literal$1(check.name)})`).join(", ");
|
|
1114
|
-
let rows;
|
|
1115
|
-
try {
|
|
1116
|
-
rows = await live.query(`select n.nspname as schema, p.proname as name,
|
|
1117
|
-
oidvectortypes(p.proargtypes) as args,
|
|
1118
|
-
format_type(p.prorettype, null) as returns
|
|
1119
|
-
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
1120
|
-
where (n.nspname, p.proname) in (${names})`);
|
|
1121
|
-
} catch (cause) {
|
|
1122
|
-
return [{
|
|
1123
|
-
severity: "info",
|
|
1124
|
-
message: `Reading the custom module contract functions failed: ${errorText(cause)}`,
|
|
1125
|
-
target: "modules"
|
|
1126
|
-
}];
|
|
1127
|
-
}
|
|
1128
|
-
return checks.flatMap((check) => {
|
|
1129
|
-
const found = rows.filter((row) => row.schema === check.schema && row.name === check.name);
|
|
1130
|
-
const target = `${check.schema}.${check.name}`;
|
|
1131
|
-
const object = {
|
|
1132
|
-
kind: "function",
|
|
1133
|
-
schema: check.schema,
|
|
1134
|
-
name: check.name
|
|
1135
|
-
};
|
|
1136
|
-
if (found.length === 0) return [{
|
|
1137
|
-
message: `sql.modules.${check.module} is in custom mode, but ${target}(${check.args}) doesn't exist. Write it, or switch the module to adopt or managed.`,
|
|
1138
|
-
target,
|
|
1139
|
-
object
|
|
1140
|
-
}];
|
|
1141
|
-
const match = found.find((row) => normalize$1(row.args) === normalize$1(check.args));
|
|
1142
|
-
if (!match) return [{
|
|
1143
|
-
message: `${target} takes (${found.map((row) => row.args).join(" | ")}); the ${check.module} contract calls it with (${check.args}).`,
|
|
1144
|
-
target,
|
|
1145
|
-
object
|
|
1146
|
-
}];
|
|
1147
|
-
if (normalize$1(match.returns) !== normalize$1(check.returns)) return [{
|
|
1148
|
-
message: `${target}(${check.args}) returns ${match.returns}; the ${check.module} contract expects ${check.returns}.`,
|
|
1149
|
-
target,
|
|
1150
|
-
object
|
|
1151
|
-
}];
|
|
1152
|
-
return [];
|
|
1153
|
-
});
|
|
1154
|
-
}
|
|
1155
|
-
/** With the tenant claim as the active tenant, the hook must put it in the token. */
|
|
1156
|
-
async function missingTenantClaim(context) {
|
|
1157
|
-
if (!context.config.sql.moduleNames.includes("tenant")) return [];
|
|
1158
|
-
if ((context.config.sql.modules.access?.activeTenant ?? DEFAULT_ACTIVE_TENANT) !== "claim") return [];
|
|
1159
|
-
const userId = context.hookUser;
|
|
1160
|
-
const db = context.database;
|
|
1161
|
-
if (!userId || !db || "skipped" in db || !db.session) return [];
|
|
1162
|
-
const claim = context.config.claims.tenant;
|
|
1163
|
-
const findings = [];
|
|
1164
|
-
for (const { config, extras } of configuredHooks(context)) {
|
|
1165
|
-
if (config.hook !== "custom_access_token" || !extras) continue;
|
|
1166
|
-
for (const fn of extras.functions) {
|
|
1167
|
-
const target = `${signatureOf(fn)}:${claim}`;
|
|
1168
|
-
let claims;
|
|
1169
|
-
try {
|
|
1170
|
-
claims = await hookClaims(db, fn, userId);
|
|
1171
|
-
} catch {
|
|
1172
|
-
continue;
|
|
1173
|
-
}
|
|
1174
|
-
if (claims === void 0) continue;
|
|
1175
|
-
if (tenantClaimPaths(claim).some((path) => claimAt(claims, path) !== void 0)) continue;
|
|
1176
|
-
findings.push({
|
|
1177
|
-
message: `${signatureOf(fn)} returns no \`${claim}\` claim for ${userId} (top level or app_metadata), and sql.modules.access.activeTenant is 'claim', so current_tenant_id() is null and the tenant() plugin rejects their requests. Write the claim in the hook, or set sql.modules.access.activeTenant to 'resolver' (with ServerOptions.tenant) or a profile column.`,
|
|
1178
|
-
target,
|
|
1179
|
-
object: {
|
|
1180
|
-
kind: "function",
|
|
1181
|
-
schema: fn.schema,
|
|
1182
|
-
name: fn.name
|
|
1183
|
-
}
|
|
1184
|
-
});
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
return findings;
|
|
1188
|
-
}
|
|
1189
|
-
/** Where a deprecated symbol is used: a call, a qualified name or a claim lookup. */
|
|
1190
|
-
function deprecationPattern(entry) {
|
|
1191
|
-
const [first = "", second] = entry.symbol.split(".");
|
|
1192
|
-
const qualified = second === void 0 ? ident(first) : `${ident(first)}\\.${ident(second)}`;
|
|
1193
|
-
switch (entry.kind) {
|
|
1194
|
-
case "function": return new RegExp(`${qualified}\\s*\\(`, "i");
|
|
1195
|
-
case "table":
|
|
1196
|
-
case "column": return new RegExp(`(?<![\\w."])${qualified}(?![\\w"])`, "i");
|
|
1197
|
-
case "claim": {
|
|
1198
|
-
const name = escapeRegExp(entry.symbol);
|
|
1199
|
-
return new RegExp(`->>?\\s*'${name}'|#>>?\\s*'\\{[^}]*\\b${name}\\b[^}]*\\}'`, "i");
|
|
1200
|
-
}
|
|
1201
|
-
default: {
|
|
1202
|
-
const never = entry.kind;
|
|
1203
|
-
throw new TypeError(`Unknown deprecation kind ${String(never)}`);
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
const bareWord = (name) => new RegExp(`(?<![\\w"])${ident(name)}(?![\\w"])`, "i");
|
|
1208
|
-
/**
|
|
1209
|
-
* The line where `text` uses a deprecated symbol. A column also counts
|
|
1210
|
-
* unqualified (`m.org_id`) in a statement that names its table, or in a
|
|
1211
|
-
* policy on that table.
|
|
1212
|
-
*/
|
|
1213
|
-
function lineOfUse(entry, text, policyTable) {
|
|
1214
|
-
const direct = lineOf(text, deprecationPattern(entry));
|
|
1215
|
-
if (direct !== void 0 || entry.kind !== "column") return direct;
|
|
1216
|
-
const [table = "", column = ""] = entry.symbol.split(".");
|
|
1217
|
-
let offset = 0;
|
|
1218
|
-
for (const statement of text.split(";")) {
|
|
1219
|
-
const named = policyTable === table || bareWord(table).test(statement);
|
|
1220
|
-
const at = statement.search(bareWord(column));
|
|
1221
|
-
if (named && at !== -1) return text.slice(0, offset + at).split("\n").length;
|
|
1222
|
-
offset += statement.length + 1;
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
/** SQL files and policies that still use a symbol a SQL module deprecated or removed. */
|
|
1226
|
-
function deprecatedSymbols(context) {
|
|
1227
|
-
const configured = new Set(Object.values(context.config.claims));
|
|
1228
|
-
const entries = moduleDeprecations().filter((entry) => context.config.sql.moduleNames.includes(entry.module) && !(entry.kind === "claim" && configured.has(entry.symbol)) && !((entry.kind === "table" || entry.kind === "column") && (context.config.sql.modules[entry.module]?.mode ?? "managed") !== "managed"));
|
|
1229
|
-
if (entries.length === 0) return [];
|
|
1230
|
-
const files = (context.sqlFiles ?? []).filter((file) => !/(^|\/)supabase\/migrations\//.test(file.path) && moduleFileVersion(file.text) === void 0);
|
|
1231
|
-
const findings = [];
|
|
1232
|
-
for (const entry of entries) {
|
|
1233
|
-
const status = entry.removed === void 0 ? `deprecated since ${entry.since}` : `removed in ${entry.removed}`;
|
|
1234
|
-
const advice = `the ${entry.module} module's ${entry.kind} ${entry.symbol}, ${status}. Use ${entry.use}.`;
|
|
1235
|
-
for (const file of files) {
|
|
1236
|
-
const line = lineOfUse(entry, file.text);
|
|
1237
|
-
if (line === void 0) continue;
|
|
1238
|
-
findings.push({
|
|
1239
|
-
message: `${file.path} uses ${advice}`,
|
|
1240
|
-
target: `${file.path}:${entry.symbol}`,
|
|
1241
|
-
location: {
|
|
1242
|
-
file: file.path,
|
|
1243
|
-
line
|
|
1244
|
-
}
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
for (const table of catalogOf(context).tables) for (const policy of table.policies) {
|
|
1248
|
-
if (lineOfUse(entry, `${policy.using ?? ""}\n${policy.check ?? ""}`, table.name) === void 0) continue;
|
|
1249
|
-
findings.push({
|
|
1250
|
-
message: `Policy "${policy.name}" on ${qualified(table)} uses ${advice}`,
|
|
1251
|
-
target: `${qualified(table)}.${policy.name}:${entry.symbol}`,
|
|
1252
|
-
object: policyObject(table, policy)
|
|
1253
|
-
});
|
|
1254
|
-
}
|
|
1255
|
-
}
|
|
1256
|
-
return findings;
|
|
1257
|
-
}
|
|
1258
|
-
/** Module triggers and the equivalent triggers they replace. */
|
|
1259
|
-
const EQUIVALENT_TRIGGERS = [{
|
|
1260
|
-
trigger: "bs_updated_at",
|
|
1261
|
-
call: "track_updated_at",
|
|
1262
|
-
pattern: /updated_at|moddatetime|touch/i
|
|
1263
|
-
}, {
|
|
1264
|
-
trigger: "bs_audit",
|
|
1265
|
-
call: "audit",
|
|
1266
|
-
pattern: /audit/i
|
|
1267
|
-
}];
|
|
1268
|
-
/** Tables where a module trigger and an older trigger do the same work. */
|
|
1269
|
-
function duplicateTriggers(context) {
|
|
1270
|
-
return catalogOf(context).tables.flatMap((table) => EQUIVALENT_TRIGGERS.flatMap(({ trigger, call, pattern }) => {
|
|
1271
|
-
if (!table.triggers.some((entry) => entry.name === trigger)) return [];
|
|
1272
|
-
return table.triggers.filter((entry) => entry.name !== trigger && pattern.test(entry.function.split(".").at(-1) ?? "")).map((entry) => ({
|
|
1273
|
-
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.`,
|
|
1274
|
-
target: `${qualified(table)}.${entry.name}`,
|
|
1275
|
-
object: tableObject(table)
|
|
1276
|
-
}));
|
|
1277
|
-
}));
|
|
1278
|
-
}
|
|
1279
|
-
/** `exempt` globs (`public.*_archive`): `*` matches any run of characters. */
|
|
1280
|
-
const globs = (value) => (Array.isArray(value) ? value : []).filter((entry) => typeof entry === "string").map((glob) => new RegExp(`^${glob.split("*").map(escapeRegExp).join(".*")}$`, "i"));
|
|
1281
|
-
/** The module schemas and the app tables SQL modules adopted, which the audit trigger skips. */
|
|
1282
|
-
function moduleOwned(context) {
|
|
1283
|
-
const schemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1284
|
-
const tables = /* @__PURE__ */ new Set();
|
|
1285
|
-
for (const module of Object.values(context.config.sql.modules)) {
|
|
1286
|
-
if (module?.schema !== void 0) schemas.add(module.schema);
|
|
1287
|
-
for (const table of Object.values(module?.tables ?? {})) {
|
|
1288
|
-
if (table === null) continue;
|
|
1289
|
-
tables.add(table.includes(".") ? table : `${module?.schema ?? "better_supabase"}.${table}`);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
return {
|
|
1293
|
-
schemas,
|
|
1294
|
-
tables
|
|
1295
|
-
};
|
|
1296
|
-
}
|
|
1297
|
-
/** Tables in `config.schemas` without the `bs_audit` trigger, when `audit` is in `sql.modules`. */
|
|
1298
|
-
function unauditedTables(context) {
|
|
1299
|
-
if (!context.config.sql.moduleNames.includes("audit")) return [];
|
|
1300
|
-
const exempt = globs(context.config.sql.modules["audit"]?.options?.["exempt"]);
|
|
1301
|
-
const owned = moduleOwned(context);
|
|
1302
|
-
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) => ({
|
|
1303
|
-
message: `${qualified(table)} has no audit trigger, so its inserts, updates and deletes are not in the audit log. Run \`select better_supabase.audit('${qualified(table)}')\` in a schema file, or list it in sql.modules.audit.options.exempt.`,
|
|
1304
|
-
target: qualified(table),
|
|
1305
|
-
object: tableObject(table)
|
|
1306
|
-
}));
|
|
1307
|
-
}
|
|
1308
|
-
/** RLS tables in `config.schemas` without a restrictive `session_active()` policy, when `sessions` is in `sql.modules`. */
|
|
1309
|
-
function sessionlessTables(context) {
|
|
1310
|
-
if (!context.config.sql.moduleNames.includes("sessions")) return [];
|
|
1311
|
-
const exclude = globs(context.config.sql.modules["sessions"]?.options?.["exclude"]);
|
|
1312
|
-
const owned = moduleOwned(context);
|
|
1313
|
-
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) => ({
|
|
1314
|
-
message: `${qualified(table)} has no restrictive session_active() policy, so a signed-out or revoked session keeps its access until the token expires. Set sql.modules.sessions.options.policies to true and run \`better-supabase sql sync\`, add the policy by hand, or list the table in sql.modules.sessions.options.exclude.`,
|
|
1315
|
-
target: qualified(table),
|
|
1316
|
-
object: tableObject(table)
|
|
1317
|
-
}));
|
|
1318
|
-
}
|
|
1319
|
-
/** `better_supabase` and every `modules.*.schema` that `[api] schemas` serves through the Data API. */
|
|
1320
|
-
function exposedModuleSchemas(context) {
|
|
1321
|
-
if (context.config.sql.moduleNames.length === 0) return [];
|
|
1322
|
-
const moduleSchemas = /* @__PURE__ */ new Set(["better_supabase"]);
|
|
1323
|
-
for (const module of Object.values(context.config.sql.modules)) if (module?.schema !== void 0) moduleSchemas.add(module.schema);
|
|
1324
|
-
return exposedSchemas(context).filter((schema) => moduleSchemas.has(schema)).map((schema) => ({
|
|
1325
|
-
message: `The Data API serves the module schema ${schema}, so its tables and internal helpers are reachable over REST and RPC. Remove it from [api] schemas in supabase/config.toml (and the dashboard's exposed schemas), set sql.modules.<module>.api to an exposed schema such as "api" so \`sql add\` writes security invoker entry points there, and call them with rpcTransport(supabase, { schema: "api" }).`,
|
|
1326
|
-
target: schema
|
|
1327
|
-
}));
|
|
1328
|
-
}
|
|
1329
|
-
/**
|
|
1330
|
-
* supautils' `reserved_roles` on Supabase. Nobody but a superuser may alter,
|
|
1331
|
-
* drop or change the memberships of these.
|
|
1332
|
-
*/
|
|
1333
|
-
const RESERVED_ROLES = /* @__PURE__ */ new Set([
|
|
1334
|
-
"supabase_admin",
|
|
1335
|
-
"supabase_auth_admin",
|
|
1336
|
-
"supabase_storage_admin",
|
|
1337
|
-
"supabase_functions_admin",
|
|
1338
|
-
"supabase_read_only_user",
|
|
1339
|
-
"supabase_realtime_admin",
|
|
1340
|
-
"supabase_replication_admin",
|
|
1341
|
-
"supabase_etl_admin",
|
|
1342
|
-
"dashboard_user",
|
|
1343
|
-
"pgbouncer"
|
|
1344
|
-
]);
|
|
1345
|
-
/** Reserved roles whose settings (`alter role ... set`) supautils allows changing. */
|
|
1346
|
-
const SETTINGS_ONLY_ROLES = /* @__PURE__ */ new Set([
|
|
1347
|
-
"authenticator",
|
|
1348
|
-
"authenticated",
|
|
1349
|
-
"anon",
|
|
1350
|
-
"service_role"
|
|
1351
|
-
]);
|
|
1352
|
-
/** Comments and string literals blanked, newlines kept so offsets map to lines. */
|
|
1353
|
-
const sqlCode = (text) => text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, "").replaceAll(/'(?:[^']|'')*'/g, "''");
|
|
1354
|
-
const roleNames = (list) => list.split(",").map((name) => name.trim().replaceAll("\"", "").toLowerCase()).filter((name) => name !== "");
|
|
1355
|
-
/** Why a statement changes a reserved role, or undefined when it doesn't. */
|
|
1356
|
-
function reservedRoleChange(statement) {
|
|
1357
|
-
const alter = /^\s*(alter|drop)\s+(?:role|user)\s+(?:if\s+exists\s+)?("[^"]+"|\w+)\s*(\w*)/i.exec(statement);
|
|
1358
|
-
if (alter) {
|
|
1359
|
-
const role = roleNames(alter[2])[0];
|
|
1360
|
-
const verb = alter[1].toLowerCase();
|
|
1361
|
-
const next = alter[3].toLowerCase();
|
|
1362
|
-
if (RESERVED_ROLES.has(role)) return `${verb}s the reserved role ${role}`;
|
|
1363
|
-
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`;
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
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);
|
|
1367
|
-
if (!membership) return void 0;
|
|
1368
|
-
const role = [...roleNames(membership[2]), ...roleNames(membership[3])].find((name) => RESERVED_ROLES.has(name));
|
|
1369
|
-
return role === void 0 ? void 0 : `${membership[1].toLowerCase()}s a membership of the reserved role ${role}`;
|
|
1370
|
-
}
|
|
1371
|
-
/** Where a statement ends, including statements inside `do` blocks and function bodies. */
|
|
1372
|
-
const STATEMENT_BOUNDARY = /;|\$\w*\$|\b(?:begin|then|else|loop)\b/gi;
|
|
1373
|
-
/** Statements in schema files and migrations that supautils rejects on Supabase. */
|
|
1374
|
-
function reservedRoles(context) {
|
|
1375
|
-
return (context.sqlFiles ?? []).flatMap((file) => {
|
|
1376
|
-
const code = sqlCode(file.text);
|
|
1377
|
-
const findings = [];
|
|
1378
|
-
let start = 0;
|
|
1379
|
-
for (const end of [...code.matchAll(STATEMENT_BOUNDARY), void 0]) {
|
|
1380
|
-
const until = end?.index ?? code.length;
|
|
1381
|
-
const statement = code.slice(start, until);
|
|
1382
|
-
const reason = reservedRoleChange(statement);
|
|
1383
|
-
if (reason !== void 0) {
|
|
1384
|
-
const at = start + statement.search(/\S/);
|
|
1385
|
-
const line = code.slice(0, at).split("\n").length;
|
|
1386
|
-
findings.push({
|
|
1387
|
-
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.`,
|
|
1388
|
-
target: `${file.path}:${String(line)}`,
|
|
1389
|
-
location: {
|
|
1390
|
-
file: file.path,
|
|
1391
|
-
line
|
|
1392
|
-
}
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
start = until + (end?.[0].length ?? 0);
|
|
1396
|
-
}
|
|
1397
|
-
return findings;
|
|
1398
|
-
});
|
|
1399
|
-
}
|
|
1400
|
-
function migrationOptions(context) {
|
|
1401
|
-
const module = new Set(context.config.sql.moduleNames);
|
|
1402
|
-
return migrationOptionUses(context.config.sql.modules).filter((use) => module.has(use.module)).map((use) => ({
|
|
1403
|
-
message: `${use.message} It exists to adopt an existing schema; remove it once your data matches the managed default.`,
|
|
1404
|
-
target: `sql.modules.${use.module}.options.${use.option}`
|
|
1405
|
-
}));
|
|
1406
|
-
}
|
|
1407
|
-
/** A roles table shared by tenant and platform roles without `where` on both sides. */
|
|
1408
|
-
function sharedRoleTables(context) {
|
|
1409
|
-
if (context.config.sql.moduleNames.length === 0) return [];
|
|
1410
|
-
const access = accessModule(context);
|
|
1411
|
-
if (access.kind !== "provider") return [];
|
|
1412
|
-
let problems;
|
|
1413
|
-
try {
|
|
1414
|
-
problems = sharedRolesProblems(context.config.sql.moduleNames, moduleLayout(context.config, context.config.sql.testsDir, [], entitlementsModule(context), access.access, permissionCatalogKeys(context)));
|
|
1415
|
-
} catch {
|
|
1416
|
-
return [];
|
|
1417
|
-
}
|
|
1418
|
-
return problems.map(({ setting, message }) => ({
|
|
1419
|
-
message: `${message} Without it, ${setting.startsWith("sql.modules.tenant.") ? "an organization invitation, accept or role change can resolve a platform role" : "a platform invitation can resolve a tenant role"}.`,
|
|
1420
|
-
target: setting
|
|
1421
|
-
}));
|
|
1422
|
-
}
|
|
1423
|
-
const MODULE_RULES = [
|
|
1424
|
-
{
|
|
1425
|
-
code: "BS307",
|
|
1426
|
-
severity: "error",
|
|
1427
|
-
title: "Custom SQL module without its contract",
|
|
1428
|
-
description: "A module in `sql.modules` uses `mode: 'custom'`, so the app writes its contract functions. One is missing or has another signature, and the modules and TypeScript APIs that call it fail at run time.",
|
|
1429
|
-
check: missingContracts
|
|
1430
|
-
},
|
|
1431
|
-
{
|
|
1432
|
-
code: "BS308",
|
|
1433
|
-
severity: "warning",
|
|
1434
|
-
title: "Tenant claim the hook does not write",
|
|
1435
|
-
description: "With the `tenant` module and `sql.modules.access.activeTenant: 'claim'`, `current_tenant_id()` and the `tenant()` plugin read the tenant from the `claims.tenant` claim, at the top level or in `app_metadata`. With `--as <user id>` doctor calls the custom access token hook for that user and warns when the claims it returns have neither. The default source, `'resolver'`, takes the tenant from the request instead.",
|
|
1436
|
-
check: missingTenantClaim
|
|
1437
|
-
},
|
|
1438
|
-
{
|
|
1439
|
-
code: "BS309",
|
|
1440
|
-
severity: "warning",
|
|
1441
|
-
title: "Deprecated SQL module symbol",
|
|
1442
|
-
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.",
|
|
1443
|
-
check: deprecatedSymbols
|
|
1444
|
-
},
|
|
1445
|
-
{
|
|
1446
|
-
code: "BS310",
|
|
1447
|
-
severity: "warning",
|
|
1448
|
-
title: "Duplicate module trigger",
|
|
1449
|
-
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.",
|
|
1450
|
-
check: duplicateTriggers
|
|
1451
|
-
},
|
|
1452
|
-
{
|
|
1453
|
-
code: "BS313",
|
|
1454
|
-
severity: "warning",
|
|
1455
|
-
title: "Rate limits not wired to PostgREST",
|
|
1456
|
-
description: "The `rate-limit` module is in `sql.modules`, but on the live database `pgrst.db_pre_request` for the `authenticator` role is unset or names a function that doesn't call `better_supabase.check_request()`, so Data API requests are never counted.",
|
|
1457
|
-
check: rateLimitHook
|
|
1458
|
-
},
|
|
1459
|
-
{
|
|
1460
|
-
code: "BS312",
|
|
1461
|
-
severity: "error",
|
|
1462
|
-
title: "Module schema exposed through the Data API",
|
|
1463
|
-
description: "`[api] schemas` in `config.toml` lists `better_supabase` or a `modules.*.schema`. The module schemas hold internal tables and helpers that are granted to `authenticated` for policies, so exposing them makes those callable over REST and RPC.",
|
|
1464
|
-
check: exposedModuleSchemas
|
|
1465
|
-
},
|
|
1466
|
-
{
|
|
1467
|
-
code: "BS314",
|
|
1468
|
-
severity: "warning",
|
|
1469
|
-
title: "Migration-only module option",
|
|
1470
|
-
description: "A module in adopt mode sets an option that only exists to match an existing schema: plain invitation tokens, webhook secrets in a column, non-text webhook ids, a custom outbox source, or audit values mapped to an adopted log's. Remove it once the data matches the managed default.",
|
|
1471
|
-
check: migrationOptions
|
|
1472
|
-
},
|
|
1473
|
-
{
|
|
1474
|
-
code: "BS315",
|
|
1475
|
-
severity: "warning",
|
|
1476
|
-
title: "Table without an audit trigger",
|
|
1477
|
-
description: "The `audit` module is in `sql.modules`, and a table in `schemas` has no `bs_audit` trigger (or another trigger that calls `audit_row_change()`), so its changes are not in the audit log. The module's own schemas and the tables SQL modules adopt are skipped; `sql.modules.audit.options.exempt` lists more, as `schema.table` globs such as `public.*_archive`.",
|
|
1478
|
-
check: unauditedTables
|
|
1479
|
-
},
|
|
1480
|
-
{
|
|
1481
|
-
code: "BS319",
|
|
1482
|
-
severity: "error",
|
|
1483
|
-
title: "SQL alters a reserved role",
|
|
1484
|
-
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.",
|
|
1485
|
-
check: reservedRoles
|
|
1486
|
-
},
|
|
1487
|
-
{
|
|
1488
|
-
code: "BS320",
|
|
1489
|
-
severity: "warning",
|
|
1490
|
-
title: "Table without the session policy",
|
|
1491
|
-
description: "The `sessions` module is in `sql.modules`, and a table with RLS in `schemas` has no restrictive policy that calls `better_supabase.session_active()`, so a token whose session was signed out, or whose user was banned or deleted, keeps reaching it until it expires. `sql.modules.sessions.options.policies` writes the policy on every table; `options.exclude` lists the tables to skip, as `schema.table` globs.",
|
|
1492
|
-
check: sessionlessTables
|
|
1493
|
-
},
|
|
1494
|
-
{
|
|
1495
|
-
code: "BS322",
|
|
1496
|
-
severity: "warning",
|
|
1497
|
-
title: "Audit registration of a dropped table",
|
|
1498
|
-
description: "The `audit` module is in `sql.modules`, and `better_supabase.audited_tables` on the live database has a row whose table no longer exists. A table dropped before the module's `sql_drop` event trigger was installed keeps its row; the module's data migration deletes such rows.",
|
|
1499
|
-
check: danglingAuditRegistrations
|
|
1500
|
-
},
|
|
1501
|
-
{
|
|
1502
|
-
code: "BS323",
|
|
1503
|
-
severity: "warning",
|
|
1504
|
-
title: "Module event trigger missing",
|
|
1505
|
-
description: "A module in `sql.modules` creates an event trigger (`bs_audit_forget_dropped` for `audit`, `bs_ensure_rls` for `ensure-rls`) that the live database lacks, or, without a database, that no migration creates. Event triggers belong to no schema, so a schema diff limited to some schemas leaves them out; the module's data migration creates them.",
|
|
1506
|
-
check: missingEventTriggers
|
|
1507
|
-
},
|
|
1508
|
-
{
|
|
1509
|
-
code: "BS324",
|
|
1510
|
-
severity: "error",
|
|
1511
|
-
title: "Shared roles table without a role condition",
|
|
1512
|
-
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'`.",
|
|
1513
|
-
check: sharedRoleTables
|
|
1514
|
-
},
|
|
1515
|
-
{
|
|
1516
|
-
code: "BS325",
|
|
1517
|
-
severity: "warning",
|
|
1518
|
-
title: "Audit log readable only as the service role",
|
|
1519
|
-
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.",
|
|
1520
|
-
check: auditUserSession
|
|
1521
|
-
},
|
|
1522
|
-
{
|
|
1523
|
-
code: "BS326",
|
|
1524
|
-
severity: "warning",
|
|
1525
|
-
title: "Admin Auth call without a secret key in env files",
|
|
1526
|
-
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.",
|
|
1527
|
-
check: missingSecretKey
|
|
1528
|
-
},
|
|
1529
|
-
{
|
|
1530
|
-
code: "BS327",
|
|
1531
|
-
severity: "warning",
|
|
1532
|
-
title: "aal2 required without MFA enabled",
|
|
1533
|
-
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.",
|
|
1534
|
-
check: aal2WithoutMfa
|
|
1535
|
-
}
|
|
1536
|
-
];
|
|
1537
|
-
function auditUserSession(context) {
|
|
1538
|
-
if (!context.config.sql.moduleNames.includes("audit")) return [];
|
|
1539
|
-
const options = context.config.sql.modules["audit"]?.options ?? {};
|
|
1540
|
-
const roles = Array.isArray(options["eventRoles"]) ? options["eventRoles"].filter((role) => typeof role === "string") : ["service_role"];
|
|
1541
|
-
if (options["readPolicy"] === true && roles.includes("authenticated")) return [];
|
|
1542
|
-
if (!context.sources.some((file) => /\baudit\.(list|listAll)\s*\(/.test(file.text))) return [];
|
|
1543
|
-
return [{
|
|
1544
|
-
message: "sql.modules.audit leaves the log readable only as the service role (`eventRoles` defaults to service_role, `readPolicy` defaults to false), but the app calls audit.list() or audit.listAll() as a user. Set options.readPolicy to true and include authenticated in options.eventRoles.",
|
|
1545
|
-
target: "sql.modules.audit"
|
|
1546
|
-
}];
|
|
1547
|
-
}
|
|
1548
|
-
function missingSecretKey(context) {
|
|
1549
|
-
if (!context.sources.some((file) => /\bdeleteAccount\b|\.admin\s*\(|supabaseAdmin\b/.test(file.text))) return [];
|
|
1550
|
-
if (context.envFiles.some((file) => /^\s*(?:SUPABASE_SECRET_KEY|SUPABASE_SERVICE_ROLE_KEY)\s*=/m.test(file.text))) return [];
|
|
1551
|
-
return [{
|
|
1552
|
-
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.",
|
|
1553
|
-
target: "SUPABASE_SECRET_KEY"
|
|
1554
|
-
}];
|
|
1555
|
-
}
|
|
1556
|
-
function aal2WithoutMfa(context) {
|
|
1557
|
-
const toml = context.configToml;
|
|
1558
|
-
if (!toml) return [];
|
|
1559
|
-
if (!context.sources.some((file) => /requireAal\s*\(\s*['"]aal2['"]/.test(file.text))) return [];
|
|
1560
|
-
const enroll = tomlGet(toml.document, [
|
|
1561
|
-
"auth",
|
|
1562
|
-
"mfa",
|
|
1563
|
-
"totp",
|
|
1564
|
-
"enroll_enabled"
|
|
1565
|
-
]);
|
|
1566
|
-
const verify = tomlGet(toml.document, [
|
|
1567
|
-
"auth",
|
|
1568
|
-
"mfa",
|
|
1569
|
-
"totp",
|
|
1570
|
-
"verify_enabled"
|
|
1571
|
-
]);
|
|
1572
|
-
if (enroll === true && verify === true) return [];
|
|
1573
|
-
return [{
|
|
1574
|
-
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.",
|
|
1575
|
-
target: toml.path,
|
|
1576
|
-
location: {
|
|
1577
|
-
file: toml.path,
|
|
1578
|
-
line: lineOf(toml.text, /^\[auth\.mfa\.totp\]/) ?? 1
|
|
1579
|
-
}
|
|
1580
|
-
}];
|
|
1581
|
-
}
|
|
1582
|
-
//#endregion
|
|
1583
|
-
//#region src/cli/doctor/pgdelta.ts
|
|
1584
|
-
/** Comments blanked out, so a commented-out statement never matches and line numbers stay. */
|
|
1585
|
-
function withoutComments(text) {
|
|
1586
|
-
return text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, " ")).replaceAll(/--[^\n]*/g, (comment) => " ".repeat(comment.length));
|
|
1587
|
-
}
|
|
1588
|
-
const lineAt = (text, index) => text.slice(0, index).split("\n").length;
|
|
1589
|
-
/**
|
|
1590
|
-
* The declarative schema files pg-delta loads, outside the SQL module files
|
|
1591
|
-
* better-supabase writes, when the project diffs with pg-delta.
|
|
1592
|
-
*/
|
|
1593
|
-
function pgdeltaSchemaFiles(context) {
|
|
1594
|
-
const toml = context.configToml;
|
|
1595
|
-
if (toml === void 0 || diffEngine(toml) !== "pg-delta") return [];
|
|
1596
|
-
const schemas = `${toml.dir}/schemas/`;
|
|
1597
|
-
return (context.sqlFiles ?? []).filter((file) => file.path.startsWith(schemas) && !/^-- @bs-module /m.test(file.text));
|
|
1598
|
-
}
|
|
1599
|
-
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;
|
|
1600
|
-
/** Grantees a bulk grant must not reach: the Data API roles and `public`. */
|
|
1601
|
-
const API_ROLES$1 = /\b(anon|authenticated|public)\b/i;
|
|
1602
|
-
/** `do` blocks; group 2 is the body. */
|
|
1603
|
-
const DO_BLOCK = /\bdo\s+(\$[\w]*\$)([\s\S]*?)\1/gi;
|
|
1604
|
-
const CATALOG = /\b(information_schema\.\w+|(?:pg_catalog\.)?pg_(?:class|tables|views|proc|namespace|attribute|trigger|constraint|index|type|policies|matviews))\b/i;
|
|
1605
|
-
const EXECUTES = /\b(execute|perform)\b/i;
|
|
1606
|
-
function bulkGrants(context) {
|
|
1607
|
-
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1608
|
-
const text = withoutComments(file.text);
|
|
1609
|
-
return [...text.matchAll(BULK_GRANT)].filter((match) => API_ROLES$1.test(match[3])).map((match) => ({
|
|
1610
|
-
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.`,
|
|
1611
|
-
target: file.path,
|
|
1612
|
-
location: {
|
|
1613
|
-
file: file.path,
|
|
1614
|
-
line: lineAt(text, match.index)
|
|
1615
|
-
}
|
|
1616
|
-
}));
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1619
|
-
function catalogLoops(context) {
|
|
1620
|
-
return pgdeltaSchemaFiles(context).flatMap((file) => {
|
|
1621
|
-
const text = withoutComments(file.text);
|
|
1622
|
-
return [...text.matchAll(DO_BLOCK)].flatMap((match) => {
|
|
1623
|
-
const body = match[2];
|
|
1624
|
-
const catalog = CATALOG.exec(body);
|
|
1625
|
-
if (!catalog || !EXECUTES.test(body)) return [];
|
|
1626
|
-
return [{
|
|
1627
|
-
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.`,
|
|
1628
|
-
target: file.path,
|
|
1629
|
-
location: {
|
|
1630
|
-
file: file.path,
|
|
1631
|
-
line: lineAt(text, match.index)
|
|
1632
|
-
}
|
|
1633
|
-
}];
|
|
1634
|
-
});
|
|
1635
|
-
});
|
|
1636
|
-
}
|
|
1637
|
-
const CREATE_EXTENSION = /\bcreate\s+extension\s+(?:if\s+not\s+exists\s+)?"?([\w-]+)"?/gi;
|
|
1638
|
-
/**
|
|
1639
|
-
* Extensions a pg-delta schema file creates, SQL module files included, that
|
|
1640
|
-
* no migration creates. pg-delta can leave an extension that owns its own
|
|
1641
|
-
* schema (`pgmq`) out of the generated migration.
|
|
1642
|
-
*/
|
|
1643
|
-
function missingExtensions(context) {
|
|
1644
|
-
const toml = context.configToml;
|
|
1645
|
-
if (toml === void 0 || diffEngine(toml) !== "pg-delta") return [];
|
|
1646
|
-
const files = context.sqlFiles ?? [];
|
|
1647
|
-
const migrations = files.filter((file) => file.path.startsWith(`${toml.dir}/migrations/`));
|
|
1648
|
-
if (migrations.length === 0) return [];
|
|
1649
|
-
const created = new Set(migrations.flatMap((file) => [...withoutComments(file.text).matchAll(CREATE_EXTENSION)].map((match) => match[1].toLowerCase())));
|
|
1650
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1651
|
-
return files.filter((file) => file.path.startsWith(`${toml.dir}/schemas/`)).flatMap((file) => {
|
|
1652
|
-
const text = withoutComments(file.text);
|
|
1653
|
-
return [...text.matchAll(CREATE_EXTENSION)].flatMap((match) => {
|
|
1654
|
-
const name = match[1].toLowerCase();
|
|
1655
|
-
if (created.has(name) || seen.has(name)) return [];
|
|
1656
|
-
seen.add(name);
|
|
1657
|
-
const module = /^-- @bs-module /m.test(file.text);
|
|
1658
|
-
return [{
|
|
1659
|
-
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.`}`,
|
|
1660
|
-
target: file.path,
|
|
1661
|
-
location: {
|
|
1662
|
-
file: file.path,
|
|
1663
|
-
line: lineAt(text, match.index)
|
|
1664
|
-
}
|
|
1665
|
-
}];
|
|
1666
|
-
});
|
|
1667
|
-
});
|
|
1668
|
-
}
|
|
1669
|
-
/** Statements in declarative schema files that pg-delta can't order. */
|
|
1670
|
-
const PGDELTA_RULES = [
|
|
1671
|
-
{
|
|
1672
|
-
code: "BS317",
|
|
1673
|
-
severity: "warning",
|
|
1674
|
-
title: "Bulk grant in a pg-delta schema file",
|
|
1675
|
-
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`.",
|
|
1676
|
-
check: bulkGrants
|
|
1677
|
-
},
|
|
1678
|
-
{
|
|
1679
|
-
code: "BS318",
|
|
1680
|
-
severity: "warning",
|
|
1681
|
-
title: "Catalog loop in a pg-delta schema file",
|
|
1682
|
-
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.",
|
|
1683
|
-
check: catalogLoops
|
|
1684
|
-
},
|
|
1685
|
-
{
|
|
1686
|
-
code: "BS321",
|
|
1687
|
-
severity: "warning",
|
|
1688
|
-
title: "Extension missing from the migrations under pg-delta",
|
|
1689
|
-
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.",
|
|
1690
|
-
check: missingExtensions
|
|
1691
|
-
}
|
|
1692
|
-
];
|
|
1693
|
-
//#endregion
|
|
1694
|
-
//#region src/cli/doctor/rls.ts
|
|
1695
|
-
const escape$1 = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1696
|
-
/** `'it''s'` and other string literals, blanked so their text can't match. */
|
|
1697
|
-
const withoutStrings = (text) => text.replaceAll(/'(?:[^']|'')*'/g, "''");
|
|
1698
|
-
const functionName = (fn) => `${fn.schema}.${fn.name}`;
|
|
1699
|
-
const functionObject = (fn) => ({
|
|
1700
|
-
kind: "function",
|
|
1701
|
-
schema: fn.schema,
|
|
1702
|
-
name: fn.name
|
|
1703
|
-
});
|
|
1704
|
-
/** The functions policies call, by `schema.name`; overloads share a key. */
|
|
1705
|
-
function functionsByName(context) {
|
|
1706
|
-
const map = /* @__PURE__ */ new Map();
|
|
1707
|
-
for (const fn of context.snapshot.extras.functions ?? []) {
|
|
1708
|
-
const key = functionName(fn);
|
|
1709
|
-
const list = map.get(key);
|
|
1710
|
-
if (list) list.push(fn);
|
|
1711
|
-
else map.set(key, [fn]);
|
|
1712
|
-
}
|
|
1713
|
-
return map;
|
|
1714
|
-
}
|
|
1715
|
-
/** Argument lists of every call to `schema.name(...)` in an expression. */
|
|
1716
|
-
function callArguments(expression, fn) {
|
|
1717
|
-
if (!expression) return [];
|
|
1718
|
-
const text = withoutStrings(expression);
|
|
1719
|
-
const pattern = new RegExp(`(?<![\\w."])(?:"?${escape$1(fn.schema)}"?\\.)?"?${escape$1(fn.name)}"?\\s*\\(`, "g");
|
|
1720
|
-
const calls = [];
|
|
1721
|
-
for (const match of text.matchAll(pattern)) {
|
|
1722
|
-
const start = match.index + match[0].length;
|
|
1723
|
-
let depth = 1;
|
|
1724
|
-
let end = start;
|
|
1725
|
-
while (end < text.length && depth > 0) {
|
|
1726
|
-
if (text[end] === "(") depth += 1;
|
|
1727
|
-
else if (text[end] === ")") depth -= 1;
|
|
1728
|
-
end += 1;
|
|
1729
|
-
}
|
|
1730
|
-
calls.push(text.slice(start, end - 1));
|
|
1731
|
-
}
|
|
1732
|
-
return calls;
|
|
1733
|
-
}
|
|
1734
|
-
/** The first column of `table` an argument list refers to, bare or as `table.column`. */
|
|
1735
|
-
function columnArgument(args, table) {
|
|
1736
|
-
return table.columns.find((column) => new RegExp(`(?<![\\w."$:])(?:"?${escape$1(table.name)}"?\\.)?"?${escape$1(column.name)}"?(?![\\w"(.$])`).test(args))?.name;
|
|
1737
|
-
}
|
|
1738
|
-
const expressions = (policy) => [policy.using, policy.check];
|
|
1739
|
-
/**
|
|
1740
|
-
* Why Postgres can't inline `fn` into the query that calls it, or
|
|
1741
|
-
* `undefined` when it can: only `language sql` functions that are not
|
|
1742
|
-
* volatile, not security definer and have no `set` options are inlined.
|
|
1743
|
-
*/
|
|
1744
|
-
function notInlinable(fn) {
|
|
1745
|
-
if (fn.language !== "sql") return `a ${fn.language} function`;
|
|
1746
|
-
if (fn.volatility === "volatile") return "a volatile function";
|
|
1747
|
-
if (fn.securityDefiner) return "a security definer function";
|
|
1748
|
-
const settings = Object.keys(fn.settings);
|
|
1749
|
-
if (settings.length > 0) return `a function with \`set ${settings.join(", ")}\``;
|
|
1750
|
-
}
|
|
1751
|
-
const rlsTables$1 = (context) => exposed(context).filter((table) => table.rls);
|
|
1752
|
-
const COMMANDS$1 = [
|
|
1753
|
-
"select",
|
|
1754
|
-
"insert",
|
|
1755
|
-
"update",
|
|
1756
|
-
"delete"
|
|
1757
|
-
];
|
|
1758
|
-
/** Policies apply to every role when they list `public` or none. */
|
|
1759
|
-
const appliesTo = (policy, role) => policy.roles.length === 0 || policy.roles.includes("public") || policy.roles.includes(role);
|
|
1760
|
-
/** Commands and roles that more than one permissive policy of the table covers. */
|
|
1761
|
-
function permissiveOverlaps(table) {
|
|
1762
|
-
const permissive = table.policies.filter((policy) => policy.permissive);
|
|
1763
|
-
const roles = /* @__PURE__ */ new Set();
|
|
1764
|
-
for (const policy of permissive) {
|
|
1765
|
-
const named = policy.roles.filter((role) => role !== "public");
|
|
1766
|
-
if (named.length < policy.roles.length || policy.roles.length === 0) {
|
|
1767
|
-
roles.add("anon");
|
|
1768
|
-
roles.add("authenticated");
|
|
1769
|
-
}
|
|
1770
|
-
for (const role of named) roles.add(role);
|
|
1771
|
-
}
|
|
1772
|
-
const overlaps = [];
|
|
1773
|
-
for (const command of COMMANDS$1) for (const role of [...roles].sort()) {
|
|
1774
|
-
const policies = permissive.filter((policy) => (policy.command === "all" || policy.command === command) && appliesTo(policy, role)).map((policy) => policy.name);
|
|
1775
|
-
if (policies.length > 1) overlaps.push({
|
|
1776
|
-
command,
|
|
1777
|
-
role,
|
|
1778
|
-
policies
|
|
1779
|
-
});
|
|
1780
|
-
}
|
|
1781
|
-
return overlaps;
|
|
1782
|
-
}
|
|
1783
|
-
const WRITES = ["insert", "update"];
|
|
1784
|
-
const API_ROLES = ["anon", "authenticated"];
|
|
1785
|
-
const NOT_ALIAS = /* @__PURE__ */ new Set([
|
|
1786
|
-
"where",
|
|
1787
|
-
"join",
|
|
1788
|
-
"inner",
|
|
1789
|
-
"left",
|
|
1790
|
-
"right",
|
|
1791
|
-
"full",
|
|
1792
|
-
"cross",
|
|
1793
|
-
"on",
|
|
1794
|
-
"using",
|
|
1795
|
-
"group",
|
|
1796
|
-
"order",
|
|
1797
|
-
"limit",
|
|
1798
|
-
"union",
|
|
1799
|
-
"natural",
|
|
1800
|
-
"lateral",
|
|
1801
|
-
"as"
|
|
1802
|
-
]);
|
|
1803
|
-
/** The body of `schema.name` from the snapshot, else from the first SQL file that creates it. */
|
|
1804
|
-
function functionBody(context, schema, name) {
|
|
1805
|
-
const introspected = context.snapshot.generator.functions.find((fn) => fn.schema === schema && fn.name === name && fn.definition)?.definition;
|
|
1806
|
-
if (introspected) return introspected;
|
|
1807
|
-
const creates = new RegExp(`create\\s+(?:or\\s+replace\\s+)?function\\s+(?:"?${escape$1(schema)}"?\\.)?"?${escape$1(name)}"?\\s*\\([\\s\\S]*?\\bas\\s+(\\$\\w*\\$)([\\s\\S]*?)\\1`, "i");
|
|
1808
|
-
for (const file of context.sqlFiles ?? []) {
|
|
1809
|
-
const match = creates.exec(file.text);
|
|
1810
|
-
if (match) return match[2];
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
const helpersByContext = /* @__PURE__ */ new WeakMap();
|
|
1814
|
-
/** The functions policies call, and the functions those call, with their bodies. */
|
|
1815
|
-
function policyHelpers(context) {
|
|
1816
|
-
let helpers = helpersByContext.get(context);
|
|
1817
|
-
if (!helpers) {
|
|
1818
|
-
helpers = findPolicyHelpers(context);
|
|
1819
|
-
helpersByContext.set(context, helpers);
|
|
1820
|
-
}
|
|
1821
|
-
return helpers;
|
|
1822
|
-
}
|
|
1823
|
-
function findPolicyHelpers(context) {
|
|
1824
|
-
const known = context.snapshot.generator.functions;
|
|
1825
|
-
const queue = catalogOf(context).tables.flatMap((table) => table.policies.flatMap((policy) => policy.functions ?? []));
|
|
1826
|
-
const bodies = /* @__PURE__ */ new Map();
|
|
1827
|
-
while (queue.length > 0) {
|
|
1828
|
-
const key = queue.pop();
|
|
1829
|
-
if (bodies.has(key)) continue;
|
|
1830
|
-
const dot = key.indexOf(".");
|
|
1831
|
-
const body = functionBody(context, key.slice(0, dot), key.slice(dot + 1));
|
|
1832
|
-
if (!body) continue;
|
|
1833
|
-
const text = withoutStrings(body);
|
|
1834
|
-
bodies.set(key, text);
|
|
1835
|
-
for (const fn of known) {
|
|
1836
|
-
const called = `${fn.schema}.${fn.name}`;
|
|
1837
|
-
if (!bodies.has(called) && callArguments(text, fn).length > 0) queue.push(called);
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
return bodies;
|
|
1841
|
-
}
|
|
1842
|
-
/** Tables a function body reads (`from` and `join`), with the names it refers to them by. */
|
|
1843
|
-
function readTables(body, tables) {
|
|
1844
|
-
const found = /* @__PURE__ */ new Map();
|
|
1845
|
-
for (const match of body.matchAll(/\b(?:from|join)\s+(?:"?(\w+)"?\s*\.\s*)?"?(\w+)"?(?:\s+(?:as\s+)?"?(\w+)"?)?/gi)) {
|
|
1846
|
-
const [, schema, name, alias] = match;
|
|
1847
|
-
const table = tables.find((t) => t.name === name && t.schema === schema) ?? (schema ? void 0 : tables.find((t) => t.name === name && t.schema === "public") ?? tables.find((t) => t.name === name));
|
|
1848
|
-
if (!table) continue;
|
|
1849
|
-
const names = found.get(table) ?? /* @__PURE__ */ new Set([table.name]);
|
|
1850
|
-
if (alias && !NOT_ALIAS.has(alias.toLowerCase())) names.add(alias);
|
|
1851
|
-
found.set(table, names);
|
|
1852
|
-
}
|
|
1853
|
-
return [...found].map(([table, names]) => ({
|
|
1854
|
-
table,
|
|
1855
|
-
names: [...names]
|
|
1856
|
-
}));
|
|
1857
|
-
}
|
|
1858
|
-
/**
|
|
1859
|
-
* Columns that decide who may read other rows, by `schema.table`: the
|
|
1860
|
-
* columns of the tables policy helpers read that their bodies mention
|
|
1861
|
-
* (qualified, or bare when the body reads one table), and the manifest's
|
|
1862
|
-
* `decidingColumns`.
|
|
1863
|
-
*/
|
|
1864
|
-
function grantColumns(context) {
|
|
1865
|
-
const tables = catalogOf(context).tables;
|
|
1866
|
-
const columns = /* @__PURE__ */ new Map();
|
|
1867
|
-
const entry = (table, column) => {
|
|
1868
|
-
const byColumn = columns.get(table) ?? /* @__PURE__ */ new Map();
|
|
1869
|
-
columns.set(table, byColumn);
|
|
1870
|
-
const existing = byColumn.get(column);
|
|
1871
|
-
if (existing) return existing;
|
|
1872
|
-
const created = {
|
|
1873
|
-
helpers: /* @__PURE__ */ new Set(),
|
|
1874
|
-
provider: false
|
|
1875
|
-
};
|
|
1876
|
-
byColumn.set(column, created);
|
|
1877
|
-
return created;
|
|
1878
|
-
};
|
|
1879
|
-
for (const [helper, body] of policyHelpers(context)) {
|
|
1880
|
-
const read = readTables(body, tables);
|
|
1881
|
-
for (const { table, names } of read) for (const column of table.columns) {
|
|
1882
|
-
const col = `"?${escape$1(column.name)}"?(?![\\w"(])`;
|
|
1883
|
-
const qualifiedColumn = new RegExp(`(?<![\\w"])(?:${names.map((name) => `"?${escape$1(name)}"?`).join("|")})\\s*\\.\\s*${col}`, "i");
|
|
1884
|
-
const bare = new RegExp(`(?<![\\w."])${col}`, "i");
|
|
1885
|
-
if (qualifiedColumn.test(body) || read.length === 1 && bare.test(body)) entry(qualified(table), column.name).helpers.add(helper);
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
for (const deciding of context.config.authorization?.decidingColumns ?? []) {
|
|
1889
|
-
const dot = deciding.lastIndexOf(".");
|
|
1890
|
-
if (dot > 0) entry(deciding.slice(0, dot), deciding.slice(dot + 1)).provider = true;
|
|
1891
|
-
}
|
|
1892
|
-
return columns;
|
|
1893
|
-
}
|
|
1894
|
-
/** Whether `role` may run `command` on rows of `table` at all: no RLS, or a permissive policy for it. */
|
|
1895
|
-
const policyAllows = (table, command, role) => !table.rls || table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
|
|
1896
|
-
/** Whether `grant` reaches `role`: granted to it, or to PUBLIC, which every role inherits. */
|
|
1897
|
-
const grantsTo = (grant, role) => grant.role === role || grant.role === "PUBLIC";
|
|
1898
|
-
/** The grant columns `role` may write with `command`, through a table-level or a column-level grant. */
|
|
1899
|
-
function writableColumns(table, columns, command, role) {
|
|
1900
|
-
if (!policyAllows(table, command, role)) return [];
|
|
1901
|
-
const privilege = command.toUpperCase();
|
|
1902
|
-
if (table.grants.some((grant) => grantsTo(grant, role) && grant.privileges.includes(privilege))) return [...columns];
|
|
1903
|
-
return columns.filter((column) => (table.columnGrants ?? []).some((grant) => grantsTo(grant, role) && grant.column === column && grant.privileges.includes(privilege)));
|
|
1904
|
-
}
|
|
1905
|
-
/** PostgREST's default for `db-hoisted-tx-settings`. */
|
|
1906
|
-
const DEFAULT_HOISTED = [
|
|
1907
|
-
"statement_timeout",
|
|
1908
|
-
"plan_filter.statement_cost_limit",
|
|
1909
|
-
"default_transaction_isolation"
|
|
1910
|
-
];
|
|
1911
|
-
const TIMEOUT_ROLES = [
|
|
1912
|
-
"anon",
|
|
1913
|
-
"authenticated",
|
|
1914
|
-
"authenticator"
|
|
1915
|
-
];
|
|
1916
|
-
const RLS_RULES = [
|
|
1917
|
-
{
|
|
1918
|
-
code: "BS205",
|
|
1919
|
-
severity: "warning",
|
|
1920
|
-
title: "Policy calls a slow function once per row",
|
|
1921
|
-
description: "A policy passes a column of the row to a function Postgres cannot inline: plpgsql, volatile, security definer, or with a `set` option such as `search_path`. It runs once for every row the query reads.",
|
|
1922
|
-
check: (context) => {
|
|
1923
|
-
const functions = functionsByName(context);
|
|
1924
|
-
return rlsTables$1(context).flatMap((table) => table.policies.flatMap((policy) => {
|
|
1925
|
-
for (const name of policy.functions ?? []) {
|
|
1926
|
-
const slow = functions.get(name)?.find((fn) => notInlinable(fn) !== void 0);
|
|
1927
|
-
if (!slow) continue;
|
|
1928
|
-
const column = expressions(policy).flatMap((expression) => callArguments(expression, slow)).map((args) => columnArgument(args, table)).find((found) => found !== void 0);
|
|
1929
|
-
if (!column) continue;
|
|
1930
|
-
const fix = slow.securityDefiner || Object.keys(slow.settings).length > 0 ? "" : `, or make it \`language sql stable\` so Postgres inlines it`;
|
|
1931
|
-
return [{
|
|
1932
|
-
message: `Policy "${policy.name}" on ${qualified(table)} calls ${name}(${column}), ${notInlinable(slow)}, with a column of the row, so it runs once per row. Have a helper return the allowed values once and compare: \`${column} in (select <helper>())\`${fix}.`,
|
|
1933
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
1934
|
-
object: policyObject(table, policy)
|
|
1935
|
-
}];
|
|
1936
|
-
}
|
|
1937
|
-
return [];
|
|
1938
|
-
}));
|
|
1939
|
-
}
|
|
1940
|
-
},
|
|
1941
|
-
{
|
|
1942
|
-
code: "BS206",
|
|
1943
|
-
severity: "warning",
|
|
1944
|
-
title: "Security definer policy helper that cannot be inlined",
|
|
1945
|
-
description: "A security definer function is used in many policies. Postgres never inlines a security definer function, so a call that takes a column of the row runs once per row. Wrapped in `(select ...)`, or returning a set the policy compares with `in (select ...)`, it runs once per statement.",
|
|
1946
|
-
check: (context) => {
|
|
1947
|
-
const limit = context.config.doctor.policyHelperLimit;
|
|
1948
|
-
const functions = functionsByName(context);
|
|
1949
|
-
const uses = /* @__PURE__ */ new Map();
|
|
1950
|
-
for (const table of rlsTables$1(context)) for (const policy of table.policies) for (const name of policy.functions ?? []) {
|
|
1951
|
-
const list = uses.get(name) ?? [];
|
|
1952
|
-
list.push(`${qualified(table)}.${policy.name}`);
|
|
1953
|
-
uses.set(name, list);
|
|
1954
|
-
}
|
|
1955
|
-
return [...uses].flatMap(([name, policies]) => {
|
|
1956
|
-
if (policies.length <= limit) return [];
|
|
1957
|
-
const fn = functions.get(name)?.find((candidate) => candidate.securityDefiner);
|
|
1958
|
-
if (!fn) return [];
|
|
1959
|
-
const shown = policies.slice(0, 5).join(", ");
|
|
1960
|
-
const more = policies.length > 5 ? ` and ${policies.length - 5} more` : "";
|
|
1961
|
-
return [{
|
|
1962
|
-
message: `${name} is a security definer ${fn.language} function used in ${policies.length} policies (${shown}${more}; the limit is ${limit}). Postgres can't inline it, so call it as \`(select ${name}(...))\` when its arguments don't come from the row, or return the allowed ids as a set and compare with \`column in (select ${name}())\`, so it runs once per statement.`,
|
|
1963
|
-
target: name,
|
|
1964
|
-
object: functionObject(fn)
|
|
1965
|
-
}];
|
|
1966
|
-
});
|
|
1967
|
-
}
|
|
1968
|
-
},
|
|
1969
|
-
{
|
|
1970
|
-
code: "BS207",
|
|
1971
|
-
severity: "warning",
|
|
1972
|
-
title: "Several permissive policies for one command and role",
|
|
1973
|
-
description: "Postgres evaluates every permissive policy that applies and ORs them, so each extra policy adds its cost to every row. Merge them into one policy per command and role. When the performance advisor (BS200) runs, splinter's `multiple_permissive_policies` reports a table it covers and this rule skips it; on a saved snapshot this rule reports every table.",
|
|
1974
|
-
check: async (context) => {
|
|
1975
|
-
const splinter = await splinterTables(context, "multiple_permissive_policies");
|
|
1976
|
-
return rlsTables$1(context).flatMap((table) => {
|
|
1977
|
-
if (splinter?.has(qualified(table))) return [];
|
|
1978
|
-
const overlaps = permissiveOverlaps(table);
|
|
1979
|
-
if (overlaps.length === 0) return [];
|
|
1980
|
-
const first = table.policies.find((policy) => policy.name === overlaps[0].policies[0]);
|
|
1981
|
-
const list = overlaps.map((overlap) => `${overlap.command} for ${overlap.role}: ${overlap.policies.join(", ")}`).join("; ");
|
|
1982
|
-
return [{
|
|
1983
|
-
message: `${qualified(table)} has several permissive policies per command and role (${list}). Postgres runs all of them for every row; merge each group into one policy.`,
|
|
1984
|
-
target: qualified(table),
|
|
1985
|
-
object: policyObject(table, first)
|
|
1986
|
-
}];
|
|
1987
|
-
});
|
|
1988
|
-
}
|
|
1989
|
-
},
|
|
1990
|
-
{
|
|
1991
|
-
code: "BS213",
|
|
1992
|
-
severity: "warning",
|
|
1993
|
-
title: "API roles can write the columns that grant access",
|
|
1994
|
-
description: "RLS helpers decide access from columns such as `memberships.user_id`, `memberships.role` or `contacts.customer_id`, and an authorization provider lists them as `decidingColumns`. When `anon` or `authenticated` may insert or update one of them, and a policy lets them write the row, a user can grant themselves access. A table-level grant counts even after a column-level revoke.",
|
|
1995
|
-
check: (context) => {
|
|
1996
|
-
const tables = new Map(catalogOf(context).tables.map((table) => [qualified(table), table]));
|
|
1997
|
-
return [...grantColumns(context)].flatMap(([name, byColumn]) => {
|
|
1998
|
-
const table = tables.get(name);
|
|
1999
|
-
if (!table) return [];
|
|
2000
|
-
const columns = table.columns.map((column) => column.name).filter((column) => byColumn.has(column)).sort();
|
|
2001
|
-
const writes = [];
|
|
2002
|
-
const fix = [];
|
|
2003
|
-
for (const role of API_ROLES) {
|
|
2004
|
-
const tableLevel = [];
|
|
2005
|
-
for (const command of WRITES) {
|
|
2006
|
-
const writable = writableColumns(table, columns, command, role);
|
|
2007
|
-
if (writable.length === 0) continue;
|
|
2008
|
-
writes.push(`${role} may ${command} ${writable.join(", ")}`);
|
|
2009
|
-
const privilege = command.toUpperCase();
|
|
2010
|
-
if (table.grants.some((grant) => grantsTo(grant, role) && grant.privileges.includes(privilege))) {
|
|
2011
|
-
tableLevel.push(command);
|
|
2012
|
-
continue;
|
|
2013
|
-
}
|
|
2014
|
-
const fromPublic = (table.columnGrants ?? []).some((grant) => grant.role === "PUBLIC" && writable.includes(grant.column) && grant.privileges.includes(privilege));
|
|
2015
|
-
fix.push(`revoke ${command} (${writable.join(", ")}) on ${name} from ${fromPublic ? `${role}, public` : role};`);
|
|
2016
|
-
}
|
|
2017
|
-
if (tableLevel.length === 0) continue;
|
|
2018
|
-
const fromPublic = table.grants.some((grant) => grant.role === "PUBLIC" && tableLevel.some((command) => grant.privileges.includes(command.toUpperCase())));
|
|
2019
|
-
fix.push(`revoke ${tableLevel.join(", ")} on ${name} from ${fromPublic ? `${role}, public` : role};`);
|
|
2020
|
-
const rest = table.columns.map((column) => column.name).filter((column) => !byColumn.has(column));
|
|
2021
|
-
if (rest.length > 0) fix.push(`grant ${tableLevel.map((command) => `${command} (${rest.join(", ")})`).join(", ")} on ${name} to ${role};`);
|
|
2022
|
-
}
|
|
2023
|
-
if (writes.length === 0) return [];
|
|
2024
|
-
const helpers = [...new Set(columns.flatMap((column) => [...byColumn.get(column).helpers]))].sort();
|
|
2025
|
-
const deciding = columns.filter((column) => byColumn.get(column).provider);
|
|
2026
|
-
const readers = [...helpers.length > 0 ? [`${helpers.join(", ")} read${helpers.length === 1 ? "s" : ""} them to decide access`] : [], ...deciding.length > 0 ? [`the authorization provider lists ${deciding.join(", ")} as deciding columns`] : []].join(", and ");
|
|
2027
|
-
return [{
|
|
2028
|
-
message: `${name}: ${writes.join("; ")}. ${readers}, so a user who writes them can grant themselves access. Revoke the columns and grant the rest:\n${fix.join("\n")}`,
|
|
2029
|
-
target: name,
|
|
2030
|
-
object: tableObject(table)
|
|
2031
|
-
}];
|
|
2032
|
-
});
|
|
2033
|
-
}
|
|
2034
|
-
},
|
|
2035
|
-
{
|
|
2036
|
-
code: "BS211",
|
|
2037
|
-
severity: "info",
|
|
2038
|
-
title: "Statement timeouts for the Data API roles",
|
|
2039
|
-
description: "PostgREST switches to `anon` or `authenticated` for each request, so their `statement_timeout` limits Data API queries. A function's own `set statement_timeout` only applies when PostgREST hoists it (`pgrst.db_hoisted_tx_settings`). `idle_in_transaction_session_timeout` ends sessions that leave a transaction open; it applies to the role a connection logs in as.",
|
|
2040
|
-
check: (context) => {
|
|
2041
|
-
const settings = context.snapshot.extras.roleSettings;
|
|
2042
|
-
if (!settings) return [];
|
|
2043
|
-
const values = (setting) => TIMEOUT_ROLES.map((role) => `${role} ${settings[role]?.[setting] ?? "not set"}`).join(", ");
|
|
2044
|
-
const findings = [{
|
|
2045
|
-
message: `statement_timeout: ${values("statement_timeout")}. idle_in_transaction_session_timeout: ${values("idle_in_transaction_session_timeout")}. Unset roles use the database default.`,
|
|
2046
|
-
target: "roles:statement_timeout"
|
|
2047
|
-
}];
|
|
2048
|
-
const configured = settings["authenticator"]?.["pgrst.db_hoisted_tx_settings"];
|
|
2049
|
-
if ((configured ? configured.split(",").map((setting) => setting.trim()) : DEFAULT_HOISTED).includes("statement_timeout")) return findings;
|
|
2050
|
-
for (const fn of context.snapshot.extras.functions ?? []) {
|
|
2051
|
-
const timeout = fn.settings["statement_timeout"];
|
|
2052
|
-
if (!timeout || !context.config.schemas.includes(fn.schema)) continue;
|
|
2053
|
-
findings.push({
|
|
2054
|
-
severity: "warning",
|
|
2055
|
-
message: `${functionName(fn)}(${fn.signature}) sets statement_timeout = ${timeout}, but pgrst.db_hoisted_tx_settings (${configured}) leaves it out, so the setting doesn't limit the RPC. Add statement_timeout to it, or set the timeout on the role.`,
|
|
2056
|
-
target: `${functionName(fn)}:statement_timeout`,
|
|
2057
|
-
object: functionObject(fn)
|
|
2058
|
-
});
|
|
2059
|
-
}
|
|
2060
|
-
return findings;
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
];
|
|
2064
|
-
//#endregion
|
|
2065
|
-
//#region src/cli/doctor/policies.ts
|
|
2066
|
-
const AUTH_ROLE = /\bauth"?\s*\.\s*"?role"?\s*\(\s*\)/i;
|
|
2067
|
-
/** A check of who is calling: the JWT, the user id, or the session role. */
|
|
2068
|
-
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;
|
|
2069
|
-
const USER_METADATA = /\buser_metadata\b/i;
|
|
2070
|
-
/** Privileges an API role never needs on a table. */
|
|
2071
|
-
const NEEDLESS = [
|
|
2072
|
-
"TRUNCATE",
|
|
2073
|
-
"REFERENCES",
|
|
2074
|
-
"TRIGGER"
|
|
2075
|
-
];
|
|
2076
|
-
const policyText = (policy) => withoutStrings(`${policy.using ?? ""} ${policy.check ?? ""}`);
|
|
2077
|
-
/** A permissive policy lets `role` run `command` on the table. */
|
|
2078
|
-
const allows = (table, command, role) => table.policies.some((policy) => policy.permissive && (policy.command === "all" || policy.command === command) && appliesTo(policy, role));
|
|
2079
|
-
/** Policy commands on `storage.objects` the SQL files create, by policy name. */
|
|
2080
|
-
function storagePolicies(context) {
|
|
2081
|
-
const commands = /* @__PURE__ */ new Map();
|
|
2082
|
-
const pattern = /create\s+policy\s+("(?:[^"]|"")+"|\S+)\s+on\s+"?storage"?\s*\.\s*"?objects"?\b([^;]*)/gi;
|
|
2083
|
-
for (const file of context.sqlFiles ?? []) for (const match of withoutStrings(file.text).matchAll(pattern)) {
|
|
2084
|
-
const name = match[1];
|
|
2085
|
-
if (commands.has(name)) continue;
|
|
2086
|
-
const command = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(match[2])?.[1];
|
|
2087
|
-
commands.set(name, command?.toLowerCase() ?? "all");
|
|
2088
|
-
}
|
|
2089
|
-
return commands;
|
|
2090
|
-
}
|
|
2091
|
-
const POLICY_RULES = [
|
|
2092
|
-
{
|
|
2093
|
-
code: "BS109",
|
|
2094
|
-
severity: "warning",
|
|
2095
|
-
title: "auth.role() in a policy or function",
|
|
2096
|
-
description: "Supabase deprecated `auth.role()`. Scope the policy with `to authenticated`, or read the claim with `(select auth.jwt() ->> 'role')`.",
|
|
2097
|
-
check: (context) => {
|
|
2098
|
-
const findings = [];
|
|
2099
|
-
for (const table of catalogOf(context).tables) for (const policy of table.policies) {
|
|
2100
|
-
if (!AUTH_ROLE.test(policyText(policy))) continue;
|
|
2101
|
-
findings.push({
|
|
2102
|
-
message: `Policy "${policy.name}" on ${qualified(table)} calls auth.role(), which Supabase deprecated. Use \`to authenticated\` on the policy, or \`(select auth.jwt() ->> 'role')\`.`,
|
|
2103
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
2104
|
-
object: policyObject(table, policy)
|
|
2105
|
-
});
|
|
2106
|
-
}
|
|
2107
|
-
const bodies = new Map(policyHelpers(context));
|
|
2108
|
-
for (const fn of context.snapshot.generator.functions) {
|
|
2109
|
-
if (!context.config.schemas.includes(fn.schema) || !fn.definition) continue;
|
|
2110
|
-
bodies.set(`${fn.schema}.${fn.name}`, withoutStrings(fn.definition));
|
|
2111
|
-
}
|
|
2112
|
-
for (const [name, body] of bodies) {
|
|
2113
|
-
if (!AUTH_ROLE.test(body)) continue;
|
|
2114
|
-
const dot = name.indexOf(".");
|
|
2115
|
-
findings.push({
|
|
2116
|
-
message: `${name} calls auth.role(), which Supabase deprecated. Read \`auth.jwt() ->> 'role'\` instead.`,
|
|
2117
|
-
target: name,
|
|
2118
|
-
object: {
|
|
2119
|
-
kind: "function",
|
|
2120
|
-
schema: name.slice(0, dot),
|
|
2121
|
-
name: name.slice(dot + 1)
|
|
2122
|
-
}
|
|
2123
|
-
});
|
|
2124
|
-
}
|
|
2125
|
-
return findings;
|
|
2126
|
-
}
|
|
2127
|
-
},
|
|
2128
|
-
{
|
|
2129
|
-
code: "BS110",
|
|
2130
|
-
severity: "warning",
|
|
2131
|
-
title: "Update policy without a select policy or with check",
|
|
2132
|
-
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.",
|
|
2133
|
-
check: (context) => exposed(context).flatMap((table) => {
|
|
2134
|
-
if (!table.rls) return [];
|
|
2135
|
-
return table.policies.flatMap((policy) => {
|
|
2136
|
-
if (!policy.permissive || policy.command !== "update") return [];
|
|
2137
|
-
const blind = (policy.roles.length === 0 || policy.roles.includes("public") ? ["anon", "authenticated"] : policy.roles).filter((role) => !allows(table, "select", role));
|
|
2138
|
-
const target = `${qualified(table)}.${policy.name}`;
|
|
2139
|
-
if (blind.length > 0) return [{
|
|
2140
|
-
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(", ")}.`,
|
|
2141
|
-
target,
|
|
2142
|
-
object: policyObject(table, policy)
|
|
2143
|
-
}];
|
|
2144
|
-
if (policy.check !== null) return [];
|
|
2145
|
-
return [{
|
|
2146
|
-
severity: "info",
|
|
2147
|
-
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 (...)\`.`,
|
|
2148
|
-
target,
|
|
2149
|
-
object: policyObject(table, policy)
|
|
2150
|
-
}];
|
|
2151
|
-
});
|
|
2152
|
-
})
|
|
2153
|
-
},
|
|
2154
|
-
{
|
|
2155
|
-
code: "BS111",
|
|
2156
|
-
severity: "warning",
|
|
2157
|
-
title: "API roles hold privileges they don't use",
|
|
2158
|
-
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.",
|
|
2159
|
-
check: (context) => exposed(context).flatMap((table) => {
|
|
2160
|
-
if (table.kind !== "table") return [];
|
|
2161
|
-
const findings = [];
|
|
2162
|
-
for (const role of ["anon", "authenticated"]) {
|
|
2163
|
-
const privileges = new Set(table.grants.filter((grant) => grant.role === role).flatMap((grant) => grant.privileges));
|
|
2164
|
-
const needless = NEEDLESS.filter((privilege) => privileges.has(privilege));
|
|
2165
|
-
if (needless.length > 0) findings.push({
|
|
2166
|
-
message: `${role} has ${needless.join(", ").toLowerCase()} on ${qualified(table)}. \`truncate\` bypasses RLS. Run \`revoke ${needless.join(", ").toLowerCase()} on table ${qualified(table)} from ${role};\`.`,
|
|
2167
|
-
target: `${qualified(table)}:${role}`,
|
|
2168
|
-
object: tableObject(table)
|
|
2169
|
-
});
|
|
2170
|
-
if (role !== "anon" || !table.rls) continue;
|
|
2171
|
-
const unused = COMMANDS$1.filter((command) => privileges.has(command.toUpperCase()) && !allows(table, command, role));
|
|
2172
|
-
if (unused.length > 0) findings.push({
|
|
2173
|
-
severity: "info",
|
|
2174
|
-
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;\`.`,
|
|
2175
|
-
target: `${qualified(table)}:anon:unused`,
|
|
2176
|
-
object: tableObject(table)
|
|
2177
|
-
});
|
|
2178
|
-
}
|
|
2179
|
-
return findings;
|
|
2180
|
-
})
|
|
2181
|
-
},
|
|
2182
|
-
{
|
|
2183
|
-
code: "BS112",
|
|
2184
|
-
severity: "warning",
|
|
2185
|
-
title: "Exposed security definer function that doesn't check the caller",
|
|
2186
|
-
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.",
|
|
2187
|
-
check: (context) => (context.snapshot.extras.functions ?? []).flatMap((fn) => {
|
|
2188
|
-
if (!fn.securityDefiner || !context.config.schemas.includes(fn.schema)) return [];
|
|
2189
|
-
const callers = fn.execute ?? [];
|
|
2190
|
-
if (callers.length === 0) return [];
|
|
2191
|
-
const body = functionBody(context, fn.schema, fn.name);
|
|
2192
|
-
if (body === void 0 || CALLER_CHECK.test(body)) return [];
|
|
2193
|
-
return [{
|
|
2194
|
-
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.`,
|
|
2195
|
-
target: `${fn.schema}.${fn.name}`,
|
|
2196
|
-
object: functionObject(fn)
|
|
2197
|
-
}];
|
|
2198
|
-
})
|
|
2199
|
-
},
|
|
2200
|
-
{
|
|
2201
|
-
code: "BS113",
|
|
2202
|
-
severity: "info",
|
|
2203
|
-
title: "Storage upload policy without the upsert policies",
|
|
2204
|
-
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.",
|
|
2205
|
-
check: (context) => {
|
|
2206
|
-
const commands = new Set(storagePolicies(context).values());
|
|
2207
|
-
if (!commands.has("insert") || commands.has("all")) return [];
|
|
2208
|
-
const missing = ["select", "update"].filter((command) => !commands.has(command));
|
|
2209
|
-
if (missing.length === 0) return [];
|
|
2210
|
-
return [{
|
|
2211
|
-
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.`,
|
|
2212
|
-
target: "storage.objects"
|
|
2213
|
-
}];
|
|
2214
|
-
}
|
|
2215
|
-
},
|
|
2216
|
-
{
|
|
2217
|
-
code: "BS114",
|
|
2218
|
-
severity: "error",
|
|
2219
|
-
title: "Policy helper reads user_metadata",
|
|
2220
|
-
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.",
|
|
2221
|
-
check: (context) => [...policyHelpers(context).keys()].flatMap((name) => {
|
|
2222
|
-
const dot = name.indexOf(".");
|
|
2223
|
-
const body = functionBody(context, name.slice(0, dot), name.slice(dot + 1));
|
|
2224
|
-
if (!body || !USER_METADATA.test(body)) return [];
|
|
2225
|
-
return [{
|
|
2226
|
-
message: `${name} reads user_metadata, which users can write themselves, and policies call it. Read \`auth.jwt() -> 'app_metadata'\` instead.`,
|
|
2227
|
-
target: name,
|
|
2228
|
-
object: {
|
|
2229
|
-
kind: "function",
|
|
2230
|
-
schema: name.slice(0, dot),
|
|
2231
|
-
name: name.slice(dot + 1)
|
|
2232
|
-
}
|
|
2233
|
-
}];
|
|
2234
|
-
})
|
|
2235
|
-
},
|
|
2236
|
-
{
|
|
2237
|
-
code: "BS115",
|
|
2238
|
-
severity: "warning",
|
|
2239
|
-
title: "Table without a policy still granted to the Data API",
|
|
2240
|
-
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 `sql.modules.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.",
|
|
2241
|
-
check: (context) => exposed(context).flatMap((table) => {
|
|
2242
|
-
if (table.kind !== "table" || !table.rls) return [];
|
|
2243
|
-
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2244
|
-
if (options?.exclude || options?.serviceRole) return [];
|
|
2245
|
-
if (context.config.expose[qualified(table)] ?? context.config.expose[table.name]) return [];
|
|
2246
|
-
const roles = ["anon", "authenticated"].filter((role) => table.grants.some((grant) => (grant.role === role || grant.role === "PUBLIC") && grant.privileges.length > 0));
|
|
2247
|
-
if (roles.length === 0) return [];
|
|
2248
|
-
if (table.policies.some((policy) => policy.permissive && roles.some((role) => appliesTo(policy, role)))) return [];
|
|
2249
|
-
return [{
|
|
2250
|
-
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 \`sql.modules.grants.options.fromPolicies\` and run \`better-supabase sql sync\` so the migration revokes it.`,
|
|
2251
|
-
target: qualified(table),
|
|
2252
|
-
object: tableObject(table)
|
|
2253
|
-
}];
|
|
2254
|
-
})
|
|
2255
|
-
},
|
|
2256
|
-
{
|
|
2257
|
-
code: "BS116",
|
|
2258
|
-
severity: "warning",
|
|
2259
|
-
title: "Security definer function open to the API roles without being exposed",
|
|
2260
|
-
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.",
|
|
2261
|
-
check: (context) => {
|
|
2262
|
-
const schemas = exposedSchemas(context);
|
|
2263
|
-
const listed = new Set(Object.keys(context.config.exposeFunctions).map((signature) => {
|
|
2264
|
-
const name = signature.slice(0, signature.indexOf("(")).trim();
|
|
2265
|
-
return name.includes(".") ? name : `public.${name}`;
|
|
2266
|
-
}));
|
|
2267
|
-
const inPolicies = new Set(catalogOf(context).tables.flatMap((table) => table.policies.flatMap((policy) => policy.functions ?? [])));
|
|
2268
|
-
return (context.snapshot.extras.functions ?? []).flatMap((fn) => {
|
|
2269
|
-
const name = `${fn.schema}.${fn.name}`;
|
|
2270
|
-
const callers = fn.execute ?? [];
|
|
2271
|
-
if (!fn.securityDefiner || !schemas.includes(fn.schema) || callers.length === 0 || listed.has(name) || inPolicies.has(name)) return [];
|
|
2272
|
-
return [{
|
|
2273
|
-
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\`.`,
|
|
2274
|
-
target: name,
|
|
2275
|
-
object: functionObject(fn)
|
|
2276
|
-
}];
|
|
2277
|
-
});
|
|
2278
|
-
}
|
|
2279
|
-
},
|
|
2280
|
-
{
|
|
2281
|
-
code: "BS215",
|
|
2282
|
-
severity: "warning",
|
|
2283
|
-
title: "Policy calls a helper per row that could run once",
|
|
2284
|
-
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.",
|
|
2285
|
-
check: (context) => {
|
|
2286
|
-
const volatility = new Map((context.snapshot.extras.functions ?? []).map((fn) => [`${fn.schema}.${fn.name}`, fn.volatility]));
|
|
2287
|
-
return exposed(context).flatMap((table) => {
|
|
2288
|
-
if (!table.rls) return [];
|
|
2289
|
-
return table.policies.flatMap((policy) => {
|
|
2290
|
-
const text = policyText(policy);
|
|
2291
|
-
const bare = (policy.functions ?? []).filter((name) => {
|
|
2292
|
-
const dot = name.indexOf(".");
|
|
2293
|
-
const schema = name.slice(0, dot);
|
|
2294
|
-
if (schema === "auth" || volatility.get(name) === "immutable") return false;
|
|
2295
|
-
return new RegExp(`(?<!\\bselect\\s+)(?<![\\w."])(?:"?${escape$1(schema)}"?\\.)?"?${escape$1(name.slice(dot + 1))}"?\\s*\\(\\s*\\)`, "i").test(text);
|
|
2296
|
-
});
|
|
2297
|
-
if (bare.length === 0) return [];
|
|
2298
|
-
return [{
|
|
2299
|
-
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]}())\`.`,
|
|
2300
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
2301
|
-
object: policyObject(table, policy)
|
|
2302
|
-
}];
|
|
2303
|
-
});
|
|
2304
|
-
});
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
];
|
|
2308
|
-
//#endregion
|
|
2309
|
-
//#region src/cli/doctor/schema-design.ts
|
|
2310
|
-
/** Whether the first `columns.length` columns of a non-partial index are exactly `columns`, in any order. */
|
|
2311
|
-
const covers = (index, columns) => !index.partial && index.columns.length >= columns.length && index.columns.slice(0, columns.length).every((column) => columns.includes(column));
|
|
2312
|
-
/** Foreign keys of `table` that no index starts with. */
|
|
2313
|
-
const unindexedForeignKeys = (table) => table.foreignKeys.filter((key) => !table.indexes.some((index) => covers(index, key.columns)));
|
|
2314
|
-
const SOFT_DELETE = /^(?:deleted|archived)_at$/;
|
|
2315
|
-
/** Operators that need a GIN index on jsonb or array columns. */
|
|
2316
|
-
const CONTAINS = String.raw`(?:@>|<@|\?\||\?&|\?|&&)`;
|
|
2317
|
-
/** `.contains("tags"`, `.containedBy('meta'`, `.overlaps("labels"`, supabase-js filters. */
|
|
2318
|
-
const CLIENT_CONTAINS = /\.(?:contains|containedBy|overlaps)\(\s*["'`]([A-Za-z_]\w*)["'`]/g;
|
|
2319
|
-
/** Column types Postgres' wiki and the Supabase skills advise against. */
|
|
2320
|
-
const AVOID = {
|
|
2321
|
-
timestamp: "timestamptz",
|
|
2322
|
-
varchar: "text with a check constraint",
|
|
2323
|
-
bpchar: "text",
|
|
2324
|
-
money: "numeric",
|
|
2325
|
-
json: "jsonb"
|
|
2326
|
-
};
|
|
2327
|
-
/** A Supabase direct (`db.<ref>.supabase.co`) or session-pooler (`:5432`) connection string. */
|
|
2328
|
-
const DIRECT_URL = /postgres(?:ql)?:\/\/[^\s"'`]*@(?:db\.[a-z0-9]+\.supabase\.co(?::5432)?|[\w.-]+\.pooler\.supabase\.com:5432)\b/i;
|
|
2329
|
-
/** Files that show the app runs on serverless or edge functions. */
|
|
2330
|
-
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\(/;
|
|
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. 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$1(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$1(column.name)}"?\\s*${CONTAINS}|${CONTAINS}\\s*[\\w."]*\\b${escape$1(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
|
-
//#endregion
|
|
2484
|
-
//#region src/cli/doctor/rules.ts
|
|
2485
|
-
const DOCS_URL = "https://bettersupabase.com/docs/cli/doctor";
|
|
2486
|
-
const writes = (policy) => policy.command === "all" || policy.command === "insert" || policy.command === "update" || policy.command === "delete";
|
|
2487
|
-
const publicRoles = (policy) => policy.roles.length === 0 || policy.roles.some((role) => role === "public" || role === "anon");
|
|
2488
|
-
const isTrue = (expression) => expression !== null && /^\(*\s*true\s*\)*$/i.test(expression.trim());
|
|
2489
|
-
/** The `sql.modules` files as this release renders them, next to what is on disk. */
|
|
2490
|
-
async function moduleFiles(context) {
|
|
2491
|
-
if (context.config.sql.moduleNames.length === 0) return [];
|
|
2492
|
-
const access = accessModule(context);
|
|
2493
|
-
if (access.kind === "invalid") return [];
|
|
2494
|
-
const readSets = context.readSets;
|
|
2495
|
-
const skipped = readSets !== void 0 && "skipped" in readSets;
|
|
2496
|
-
let rendered;
|
|
2497
|
-
try {
|
|
2498
|
-
rendered = renderModules(context.config.sql.moduleNames, await moduleLayoutFor(context.config, {
|
|
2499
|
-
names: context.config.sql.moduleNames,
|
|
2500
|
-
testsDir: context.config.sql.testsDir,
|
|
2501
|
-
readSets: skipped ? [] : readSets,
|
|
2502
|
-
entitlementsProvider: entitlementsModule(context),
|
|
2503
|
-
accessProvider: access.kind === "provider" ? access.access : void 0,
|
|
2504
|
-
permissionCatalog: permissionCatalogKeys(context)
|
|
2505
|
-
}));
|
|
2506
|
-
} catch {
|
|
2507
|
-
return [];
|
|
2508
|
-
}
|
|
2509
|
-
const files = rendered.filter((file) => !(skipped && file.module === "read-sets"));
|
|
2510
|
-
return Promise.all(files.map(async (file) => {
|
|
2511
|
-
const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
|
|
2512
|
-
const version = moduleFileVersion(file.contents)?.version ?? 1;
|
|
2513
|
-
return {
|
|
2514
|
-
file,
|
|
2515
|
-
current,
|
|
2516
|
-
behind: current !== void 0 && (moduleFileVersion(current)?.version ?? 1) < version
|
|
2517
|
-
};
|
|
2518
|
-
}));
|
|
2519
|
-
}
|
|
2520
|
-
async function moduleVersionsBehind(context) {
|
|
2521
|
-
const findings = [];
|
|
2522
|
-
const upgrade = "Run `better-supabase sql upgrade`";
|
|
2523
|
-
for (const { file, current, behind } of await moduleFiles(context)) {
|
|
2524
|
-
if (!behind || current === void 0) continue;
|
|
2525
|
-
findings.push({
|
|
2526
|
-
message: `${file.path} has ${file.module} version ${String(moduleFileVersion(current)?.version ?? 1)}; this release ships version ${String(moduleFileVersion(file.contents)?.version ?? 1)}. ${upgrade}, then \`${migrationCommand(context.configToml)}\`.`,
|
|
2527
|
-
target: file.path,
|
|
2528
|
-
location: {
|
|
2529
|
-
file: file.path,
|
|
2530
|
-
line: 1
|
|
2531
|
-
}
|
|
2532
|
-
});
|
|
2533
|
-
}
|
|
2534
|
-
const db = context.database;
|
|
2535
|
-
if (!db || "skipped" in db || context.config.sql.moduleNames.length === 0) return findings;
|
|
2536
|
-
let rows;
|
|
2537
|
-
try {
|
|
2538
|
-
rows = await db.query("select name, version from better_supabase.modules");
|
|
2539
|
-
} catch {
|
|
2540
|
-
return findings;
|
|
2541
|
-
}
|
|
2542
|
-
for (const row of rows) {
|
|
2543
|
-
const module = SQL_MODULES[row.name];
|
|
2544
|
-
if (!module || !context.config.sql.moduleNames.includes(row.name)) continue;
|
|
2545
|
-
const version = moduleVersion(module);
|
|
2546
|
-
if (row.version >= version) continue;
|
|
2547
|
-
findings.push({
|
|
2548
|
-
message: `The database has ${row.name} version ${String(row.version)}; this release ships version ${String(version)}. ${upgrade} and apply the migrations it prints.`,
|
|
2549
|
-
target: `better_supabase.modules.${row.name}`
|
|
2550
|
-
});
|
|
2551
|
-
}
|
|
2552
|
-
return findings;
|
|
2553
|
-
}
|
|
2554
|
-
function authSetting(context, key) {
|
|
2555
|
-
return context.configToml ? tomlGet(context.configToml.document, ["auth", key]) : void 0;
|
|
2556
|
-
}
|
|
2557
|
-
function tomlLocation(context, key) {
|
|
2558
|
-
if (!context.configToml) return void 0;
|
|
2559
|
-
const line = lineOf(context.configToml.text, new RegExp(`^\\s*${key}\\s*=`)) ?? lineOf(context.configToml.text, /^\[auth\]/);
|
|
2560
|
-
return line ? {
|
|
2561
|
-
file: context.configToml.path,
|
|
2562
|
-
line
|
|
2563
|
-
} : void 0;
|
|
2564
|
-
}
|
|
2565
|
-
const PUBLIC_PREFIXES = [
|
|
2566
|
-
"NEXT_PUBLIC_",
|
|
2567
|
-
"VITE_",
|
|
2568
|
-
"PUBLIC_",
|
|
2569
|
-
"EXPO_PUBLIC_",
|
|
2570
|
-
"NUXT_PUBLIC_"
|
|
2571
|
-
];
|
|
2572
|
-
function envEntries(file) {
|
|
2573
|
-
return file.text.split("\n").flatMap((raw, index) => {
|
|
2574
|
-
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
2575
|
-
if (!match) return [];
|
|
2576
|
-
const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
2577
|
-
return [{
|
|
2578
|
-
key: match[1],
|
|
2579
|
-
value,
|
|
2580
|
-
line: index + 1
|
|
2581
|
-
}];
|
|
2582
|
-
});
|
|
2583
|
-
}
|
|
2584
|
-
/**
|
|
2585
|
-
* `db.x.aggregate(`, an `_sum:`/`_avg:`/`_min:`/`_max:` include or a list
|
|
2586
|
-
* with `facetCounts: true`.
|
|
2587
|
-
*/
|
|
2588
|
-
const AGGREGATE_USE = /\.aggregate\(|\b_(?:sum|avg|min|max)\s*:\s*\{|\bfacetCounts\s*:\s*true\b/;
|
|
2589
|
-
/**
|
|
2590
|
-
* A policy that reads memberships or the tenant claim: `is_member(...)`,
|
|
2591
|
-
* `current_tenant_id()`, `memberships`. The authorization provider's
|
|
2592
|
-
* functions (`authorization.requires`) count too.
|
|
2593
|
-
*/
|
|
2594
|
-
const TENANT_HELPER = /member|tenant|current_org|org_id/i;
|
|
2595
|
-
/** A foreign key to the tenant table itself, whose own policies are usually read-only. */
|
|
2596
|
-
const TENANT_COLUMN = /^(?:organization|organization|tenant|team|workspace|account)_id$/;
|
|
2597
|
-
const COMMANDS = [
|
|
2598
|
-
"select",
|
|
2599
|
-
"insert",
|
|
2600
|
-
"update",
|
|
2601
|
-
"delete"
|
|
2602
|
-
];
|
|
2603
|
-
const MFA_FACTORS = /\bauth\.\s*"?mfa_factors\b/i;
|
|
2604
|
-
const RULES = [
|
|
2605
|
-
...[
|
|
2606
|
-
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."),
|
|
2607
|
-
{
|
|
2608
|
-
code: "BS103",
|
|
2609
|
-
severity: "error",
|
|
2610
|
-
title: "Policy allows anonymous writes",
|
|
2611
|
-
description: "A permissive policy with `true` for anon or public lets anyone insert, update or delete rows.",
|
|
2612
|
-
check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => policy.permissive && writes(policy) && publicRoles(policy) && (isTrue(policy.using) || isTrue(policy.check))).map((policy) => ({
|
|
2613
|
-
message: `Policy "${policy.name}" on ${qualified(table)} allows ${policy.command} for ${policy.roles.join(", ") || "public"} with \`true\`.`,
|
|
2614
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
2615
|
-
object: policyObject(table, policy)
|
|
2616
|
-
})))
|
|
2617
|
-
},
|
|
2618
|
-
{
|
|
2619
|
-
code: "BS106",
|
|
2620
|
-
severity: "error",
|
|
2621
|
-
title: "Table not granted to the Data API",
|
|
2622
|
-
description: "Supabase no longer grants new tables to anon and authenticated. Without a grant, every Data API request fails with 42501 before RLS runs. Tables in `expose` need the privileges listed there; other tables need `select` for authenticated, except tables with RLS on and no permissive policy under `sql.modules.grants.options.fromPolicies`, which are service-only. Tables marked `serviceRole` in `tables` must have no grants for either role.",
|
|
2623
|
-
check: (context) => {
|
|
2624
|
-
const auto = context.configToml ? tomlGet(context.configToml.document, ["api", "auto_expose_new_tables"]) : void 0;
|
|
2625
|
-
const note = auto === false || auto === "false" ? " config.toml sets [api] auto_expose_new_tables = false, so new tables start without grants." : "";
|
|
2626
|
-
return exposed(context).flatMap((table) => {
|
|
2627
|
-
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2628
|
-
if (options?.exclude) return [];
|
|
2629
|
-
const granted = (role) => new Set(table.grants.filter((grant) => grant.role === role || grant.role === "PUBLIC").flatMap((grant) => grant.privileges.map((privilege) => privilege.toLowerCase())));
|
|
2630
|
-
if (options?.serviceRole) {
|
|
2631
|
-
const fromPublic = table.grants.some((grant) => grant.role === "PUBLIC" && grant.privileges.length > 0);
|
|
2632
|
-
return ["anon", "authenticated"].flatMap((role) => {
|
|
2633
|
-
const have = [...granted(role)];
|
|
2634
|
-
if (have.length === 0) return [];
|
|
2635
|
-
const from = fromPublic ? `${role}, public` : role;
|
|
2636
|
-
return [{
|
|
2637
|
-
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};\`.`,
|
|
2638
|
-
target: `${qualified(table)}:${role}`,
|
|
2639
|
-
object: tableObject(table)
|
|
2640
|
-
}];
|
|
2641
|
-
});
|
|
2642
|
-
}
|
|
2643
|
-
const serviceOnly = context.config.sql.moduleNames.includes("grants") && context.config.sql.modules["grants"]?.options?.["fromPolicies"] === true && table.rls && !table.policies.some((policy) => policy.permissive && (appliesTo(policy, "anon") || appliesTo(policy, "authenticated")));
|
|
2644
|
-
const wanted = context.config.expose[qualified(table)] ?? context.config.expose[table.name] ?? {
|
|
2645
|
-
anon: [],
|
|
2646
|
-
authenticated: serviceOnly ? [] : ["select"]
|
|
2647
|
-
};
|
|
2648
|
-
return ["anon", "authenticated"].flatMap((role) => {
|
|
2649
|
-
const have = granted(role);
|
|
2650
|
-
const missing = wanted[role].filter((privilege) => !privilege.includes("(") && !have.has(privilege));
|
|
2651
|
-
if (missing.length === 0) return [];
|
|
2652
|
-
return [{
|
|
2653
|
-
message: `${role} has no ${missing.join(", ")} on ${qualified(table)}, so the Data API answers 42501. Add it to \`expose\` and run \`better-supabase sql add grants\`, or run \`grant ${missing.join(", ")} on table ${qualified(table)} to ${role};\`.${note}`,
|
|
2654
|
-
target: `${qualified(table)}:${role}`,
|
|
2655
|
-
object: tableObject(table)
|
|
2656
|
-
}];
|
|
2657
|
-
});
|
|
2658
|
-
});
|
|
2659
|
-
}
|
|
2660
|
-
},
|
|
2661
|
-
{
|
|
2662
|
-
code: "BS107",
|
|
2663
|
-
severity: "warning",
|
|
2664
|
-
title: "Tenant table without a policy for every command",
|
|
2665
|
-
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`.",
|
|
2666
|
-
check: (context) => {
|
|
2667
|
-
const column = context.config.plugins.tenant?.column;
|
|
2668
|
-
const providerFunctions = [...new Set((context.config.authorization?.requires ?? []).flatMap((entry) => [entry.function, entry.function.slice(entry.function.indexOf(".") + 1)]))];
|
|
2669
|
-
const tables = exposed(context);
|
|
2670
|
-
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}`)));
|
|
2671
|
-
return tables.flatMap((table) => {
|
|
2672
|
-
if (table.kind !== "table" || !table.rls) return [];
|
|
2673
|
-
if (roots.has(qualified(table))) return [];
|
|
2674
|
-
const options = configFor(context.config.tables, table.schema, table.name);
|
|
2675
|
-
if (options?.exclude || options?.serviceRole) return [];
|
|
2676
|
-
const forAuthenticated = (policy) => policy.roles.some((role) => ["authenticated", "public"].includes(role));
|
|
2677
|
-
const granting = table.policies.filter((policy) => policy.permissive && forAuthenticated(policy));
|
|
2678
|
-
if (granting.length === 0) return [];
|
|
2679
|
-
if (!(column !== void 0 && table.columns.some((entry) => entry.name === column) || granting.some((policy) => {
|
|
2680
|
-
const text = [
|
|
2681
|
-
policy.using ?? "",
|
|
2682
|
-
policy.check ?? "",
|
|
2683
|
-
...policy.functions ?? []
|
|
2684
|
-
].join(" ");
|
|
2685
|
-
return TENANT_HELPER.test(text) || providerFunctions.some((fn) => text.includes(fn));
|
|
2686
|
-
}))) return [];
|
|
2687
|
-
const denying = table.policies.filter((policy) => !policy.permissive && forAuthenticated(policy) && /^\s*false\s*$/i.test(policy.using ?? ""));
|
|
2688
|
-
const covered = new Set([...granting, ...denying].flatMap((policy) => policy.command === "all" ? COMMANDS : [policy.command]));
|
|
2689
|
-
const missing = COMMANDS.filter((command) => !covered.has(command));
|
|
2690
|
-
if (missing.length === 0) return [];
|
|
2691
|
-
return [{
|
|
2692
|
-
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.`,
|
|
2693
|
-
target: qualified(table),
|
|
2694
|
-
object: tableObject(table)
|
|
2695
|
-
}];
|
|
2696
|
-
});
|
|
2697
|
-
}
|
|
2698
|
-
},
|
|
2699
|
-
{
|
|
2700
|
-
code: "BS108",
|
|
2701
|
-
severity: "error",
|
|
2702
|
-
title: "Policy reads auth.mfa_factors directly",
|
|
2703
|
-
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.",
|
|
2704
|
-
check: (context) => exposed(context).flatMap((table) => table.policies.filter((policy) => MFA_FACTORS.test(`${policy.using ?? ""} ${policy.check ?? ""}`)).map((policy) => ({
|
|
2705
|
-
message: `Policy "${policy.name}" on ${qualified(table)} reads auth.mfa_factors, which authenticated can't select, so every request fails with 42501. Use \`(select better_supabase.mfa_satisfied())\` from \`better-supabase sql add mfa\` instead.`,
|
|
2706
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
2707
|
-
object: policyObject(table, policy)
|
|
2708
|
-
})))
|
|
2709
|
-
},
|
|
2710
|
-
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."),
|
|
2711
|
-
{
|
|
2712
|
-
code: "BS204",
|
|
2713
|
-
severity: "warning",
|
|
2714
|
-
title: "Tenant column without an index",
|
|
2715
|
-
description: "The tenant plugin and tenant policies filter every query on this column.",
|
|
2716
|
-
check: (context) => {
|
|
2717
|
-
const tenant = context.config.plugins.tenant;
|
|
2718
|
-
if (!tenant) return [];
|
|
2719
|
-
return exposed(context).filter((table) => table.columns.some((column) => column.name === tenant.column) && !table.indexes.some((index) => !index.partial && index.columns[0] === tenant.column)).map((table) => ({
|
|
2720
|
-
message: `${qualified(table)}.${tenant.column} has no index starting with it.`,
|
|
2721
|
-
target: `${qualified(table)}.${tenant.column}`,
|
|
2722
|
-
object: tableObject(table)
|
|
2723
|
-
}));
|
|
2724
|
-
}
|
|
2725
|
-
},
|
|
2726
|
-
{
|
|
2727
|
-
code: "BS210",
|
|
2728
|
-
severity: "warning",
|
|
2729
|
-
title: "Aggregates used while PostgREST disables them",
|
|
2730
|
-
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.",
|
|
2731
|
-
check: (context) => {
|
|
2732
|
-
const settings = context.snapshot.extras.roleSettings;
|
|
2733
|
-
if (!settings) return [];
|
|
2734
|
-
const enabled = settings["authenticator"]?.["pgrst.db_aggregates_enabled"];
|
|
2735
|
-
if (enabled === "true" || enabled === "on") return [];
|
|
2736
|
-
for (const file of context.sources) {
|
|
2737
|
-
const line = lineOf(file.text, AGGREGATE_USE);
|
|
2738
|
-
if (line === void 0) continue;
|
|
2739
|
-
return [{
|
|
2740
|
-
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.`,
|
|
2741
|
-
target: "authenticator:pgrst.db_aggregates_enabled",
|
|
2742
|
-
location: {
|
|
2743
|
-
file: file.path,
|
|
2744
|
-
line
|
|
2745
|
-
}
|
|
2746
|
-
}];
|
|
2747
|
-
}
|
|
2748
|
-
return [];
|
|
2749
|
-
}
|
|
2750
|
-
},
|
|
2751
|
-
{
|
|
2752
|
-
code: "BS301",
|
|
2753
|
-
severity: "warning",
|
|
2754
|
-
title: "Soft delete hidden by a select policy",
|
|
2755
|
-
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.",
|
|
2756
|
-
check: (context) => {
|
|
2757
|
-
const softDelete = context.config.plugins.softDelete;
|
|
2758
|
-
if (!softDelete) return [];
|
|
2759
|
-
return exposed(context).flatMap((table) => {
|
|
2760
|
-
if (!table.columns.some((column) => column.name === softDelete.column)) return [];
|
|
2761
|
-
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) => ({
|
|
2762
|
-
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.`,
|
|
2763
|
-
target: `${qualified(table)}.${policy.name}`,
|
|
2764
|
-
object: policyObject(table, policy)
|
|
2765
|
-
}));
|
|
2766
|
-
});
|
|
2767
|
-
}
|
|
2768
|
-
},
|
|
2769
|
-
{
|
|
2770
|
-
code: "BS302",
|
|
2771
|
-
severity: "warning",
|
|
2772
|
-
title: "Bucket differs from the config",
|
|
2773
|
-
description: "A bucket in `buckets` is missing or has different settings in the database.",
|
|
2774
|
-
check: (context) => Object.entries(context.config.buckets).flatMap(([name, bucket]) => {
|
|
2775
|
-
const id = bucket.id ?? name.replaceAll(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
|
|
2776
|
-
const defined = defineBucket({
|
|
2777
|
-
id,
|
|
2778
|
-
path: bucket.path,
|
|
2779
|
-
...bucket.public === void 0 ? {} : { public: bucket.public },
|
|
2780
|
-
...bucket.fileSizeLimit === void 0 ? {} : { fileSizeLimit: bucket.fileSizeLimit },
|
|
2781
|
-
...bucket.allowedMimeTypes === void 0 ? {} : { allowedMimeTypes: bucket.allowedMimeTypes },
|
|
2782
|
-
...bucket.versioning === void 0 ? {} : { versioning: bucket.versioning },
|
|
2783
|
-
...bucket.lifecycle === void 0 ? {} : { lifecycle: bucket.lifecycle }
|
|
2784
|
-
});
|
|
2785
|
-
const actual = catalogOf(context).buckets.find((entry) => entry.id === id);
|
|
2786
|
-
const inDatabase = defined.drift(actual).map((drift) => ({
|
|
2787
|
-
message: `Bucket ${id}: ${drift.message}`,
|
|
2788
|
-
target: `storage.buckets.${id}`
|
|
2789
|
-
}));
|
|
2790
|
-
const toml = context.configToml;
|
|
2791
|
-
const declared = toml ? tomlGet(toml.document, [
|
|
2792
|
-
"storage",
|
|
2793
|
-
"buckets",
|
|
2794
|
-
id
|
|
2795
|
-
]) : void 0;
|
|
2796
|
-
if (!toml || !declared || typeof declared !== "object" || Array.isArray(declared)) return inDatabase;
|
|
2797
|
-
const entry = declared;
|
|
2798
|
-
const size = entry["file_size_limit"];
|
|
2799
|
-
const mimes = entry["allowed_mime_types"];
|
|
2800
|
-
const line = lineOf(toml.text, new RegExp(`^\\[storage\\.buckets\\.${id.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\]`));
|
|
2801
|
-
const inToml = defined.drift({
|
|
2802
|
-
public: entry["public"] === true,
|
|
2803
|
-
fileSizeLimit: typeof size === "string" || typeof size === "number" ? parseSize(size) : null,
|
|
2804
|
-
allowedMimeTypes: Array.isArray(mimes) ? mimes.map(String) : null
|
|
2805
|
-
}).map((drift) => ({
|
|
2806
|
-
message: `${toml.path} [storage.buckets.${id}]: ${drift.message.replace(`Bucket "${id}" `, "")}`,
|
|
2807
|
-
target: `[storage.buckets.${id}]`,
|
|
2808
|
-
...line ? { location: {
|
|
2809
|
-
file: toml.path,
|
|
2810
|
-
line
|
|
2811
|
-
} } : {}
|
|
2812
|
-
}));
|
|
2813
|
-
return [...inDatabase, ...inToml];
|
|
2814
|
-
})
|
|
2815
|
-
},
|
|
2816
|
-
{
|
|
2817
|
-
code: "BS303",
|
|
2818
|
-
severity: "error",
|
|
2819
|
-
title: "Generated code is out of date",
|
|
2820
|
-
description: "The generated module no longer matches the database, so types and metadata are wrong. Same check as `gen --check`.",
|
|
2821
|
-
check: async (context) => {
|
|
2822
|
-
const stale = [];
|
|
2823
|
-
for (const file of await renderFiles(context.config, context.snapshot)) {
|
|
2824
|
-
const current = await readFile(resolve(context.config.root, file.path), "utf8").catch(() => void 0);
|
|
2825
|
-
if (!sameText(current, file.contents)) stale.push({
|
|
2826
|
-
message: `${file.path} is ${current === void 0 ? "missing" : "out of date"}. Run \`better-supabase gen\`.`,
|
|
2827
|
-
target: file.path,
|
|
2828
|
-
...current === void 0 ? {} : { location: {
|
|
2829
|
-
file: file.path,
|
|
2830
|
-
line: 1
|
|
2831
|
-
} }
|
|
2832
|
-
});
|
|
2833
|
-
}
|
|
2834
|
-
return stale;
|
|
2835
|
-
}
|
|
2836
|
-
},
|
|
2837
|
-
{
|
|
2838
|
-
code: "BS304",
|
|
2839
|
-
severity: "warning",
|
|
2840
|
-
title: "SQL module files are out of date",
|
|
2841
|
-
description: "A module listed in `sql.modules` differs from the version in this release. Same check as `sql sync --check`.",
|
|
2842
|
-
check: async (context) => {
|
|
2843
|
-
const stale = [];
|
|
2844
|
-
for (const { file, current, behind } of await moduleFiles(context)) {
|
|
2845
|
-
if (behind || sameModuleFile(current, file.contents)) continue;
|
|
2846
|
-
const edited = current === void 0 ? "" : ` If you edited it, move the change to \`sql.modules.${file.module}\` in better-supabase.config.ts or to the module's SQL hooks: sync overwrites the file.`;
|
|
2847
|
-
const next = file.kind === "data" ? "Run `better-supabase sql sync`, which also writes the rows into a migration, since a schema diff skips them." : `Run \`better-supabase sql sync\`, then \`${migrationCommand(context.configToml)}\`.`;
|
|
2848
|
-
stale.push({
|
|
2849
|
-
message: `${file.path} (${file.module}) is ${current === void 0 ? "missing" : "out of date"}. ${next}${edited}`,
|
|
2850
|
-
target: file.path,
|
|
2851
|
-
...current === void 0 ? {} : { location: {
|
|
2852
|
-
file: file.path,
|
|
2853
|
-
line: 1
|
|
2854
|
-
} }
|
|
2855
|
-
});
|
|
2856
|
-
}
|
|
2857
|
-
return stale;
|
|
2858
|
-
}
|
|
2859
|
-
},
|
|
2860
|
-
{
|
|
2861
|
-
code: "BS316",
|
|
2862
|
-
severity: "warning",
|
|
2863
|
-
title: "Legacy migra diff engine",
|
|
2864
|
-
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.",
|
|
2865
|
-
check: (context) => {
|
|
2866
|
-
const toml = context.configToml;
|
|
2867
|
-
if (toml === void 0 || diffEngine(toml) === "pg-delta") return [];
|
|
2868
|
-
const schemas = `${toml.dir}/schemas/`;
|
|
2869
|
-
if (!(context.config.sql.moduleNames.length > 0 || (context.sqlFiles ?? []).some((file) => file.path.startsWith(schemas)))) return [];
|
|
2870
|
-
return [{
|
|
2871
|
-
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\`.`,
|
|
2872
|
-
target: toml.path,
|
|
2873
|
-
location: {
|
|
2874
|
-
file: toml.path,
|
|
2875
|
-
line: 1
|
|
2876
|
-
}
|
|
2877
|
-
}];
|
|
2878
|
-
}
|
|
2879
|
-
},
|
|
2880
|
-
{
|
|
2881
|
-
code: "BS305",
|
|
2882
|
-
severity: "warning",
|
|
2883
|
-
title: "Live query table without change broadcasts",
|
|
2884
|
-
description: "A table in `realtime.tables` has no `bs_realtime` trigger, so live queries never hear about its changes.",
|
|
2885
|
-
check: (context) => {
|
|
2886
|
-
const tables = catalogOf(context).tables;
|
|
2887
|
-
return context.config.realtime.tables.flatMap((name) => {
|
|
2888
|
-
const table = tables.find((entry) => entry.name === name || qualified(entry) === name);
|
|
2889
|
-
if (!table) return [{
|
|
2890
|
-
message: `realtime.tables lists "${name}", which doesn't exist.`,
|
|
2891
|
-
target: name
|
|
2892
|
-
}];
|
|
2893
|
-
if (table.triggers.some((trigger) => trigger.name.startsWith("bs_realtime"))) return [];
|
|
2894
|
-
return [{
|
|
2895
|
-
message: `${qualified(table)} is in realtime.tables but has no broadcast trigger. Run \`better-supabase sql add realtime-tables\`, then \`${migrationCommand(context.configToml)}\`.`,
|
|
2896
|
-
target: qualified(table),
|
|
2897
|
-
object: tableObject(table)
|
|
2898
|
-
}];
|
|
2899
|
-
});
|
|
2900
|
-
}
|
|
2901
|
-
},
|
|
2902
|
-
{
|
|
2903
|
-
code: "BS306",
|
|
2904
|
-
severity: "warning",
|
|
2905
|
-
title: "Realtime delete events without keys",
|
|
2906
|
-
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.",
|
|
2907
|
-
check: (context) => {
|
|
2908
|
-
const published = new Set(catalogOf(context).realtime);
|
|
2909
|
-
return catalogOf(context).tables.filter((table) => published.has(qualified(table)) && (table.replicaIdentity === "NOTHING" || table.replicaIdentity === "DEFAULT" && table.primaryKey.length === 0)).map((table) => ({
|
|
2910
|
-
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\`.`,
|
|
2911
|
-
target: qualified(table),
|
|
2912
|
-
object: tableObject(table)
|
|
2913
|
-
}));
|
|
2914
|
-
}
|
|
2915
|
-
},
|
|
2916
|
-
{
|
|
2917
|
-
code: "BS311",
|
|
2918
|
-
severity: "warning",
|
|
2919
|
-
title: "SQL module behind its current version",
|
|
2920
|
-
description: "A module file or `better_supabase.modules` records an older module version than this release ships. Same check as `sql upgrade --check`.",
|
|
2921
|
-
check: moduleVersionsBehind
|
|
2922
|
-
},
|
|
2923
|
-
{
|
|
2924
|
-
code: "BS401",
|
|
2925
|
-
severity: "warning",
|
|
2926
|
-
title: "Refresh token reuse interval is 0",
|
|
2927
|
-
description: "Server instances refreshing the same session at once need a reuse interval, or all but one of them sign the user out.",
|
|
2928
|
-
check: (context) => {
|
|
2929
|
-
if (!context.configToml) return [];
|
|
2930
|
-
const rotation = authSetting(context, "enable_refresh_token_rotation");
|
|
2931
|
-
const interval = authSetting(context, "refresh_token_reuse_interval");
|
|
2932
|
-
if (rotation === false || rotation === "false" || interval === void 0 || Number(interval) > 0) return [];
|
|
2933
|
-
const location = tomlLocation(context, "refresh_token_reuse_interval");
|
|
2934
|
-
return [{
|
|
2935
|
-
message: "refresh_token_reuse_interval = 0 with refresh token rotation. Use 10 (the Supabase default) so concurrent refreshes succeed.",
|
|
2936
|
-
target: "[auth] refresh_token_reuse_interval",
|
|
2937
|
-
...location ? { location } : {}
|
|
2938
|
-
}];
|
|
2939
|
-
}
|
|
2940
|
-
},
|
|
2941
|
-
{
|
|
2942
|
-
code: "BS402",
|
|
2943
|
-
severity: "info",
|
|
2944
|
-
title: "Long access token lifetime",
|
|
2945
|
-
description: "Tokens are verified locally without an auth call, so a revoked session stays valid until the token expires.",
|
|
2946
|
-
check: (context) => {
|
|
2947
|
-
if (!context.configToml) return [];
|
|
2948
|
-
const expiry = Number(authSetting(context, "jwt_expiry") ?? 3600);
|
|
2949
|
-
if (expiry <= 3600) return [];
|
|
2950
|
-
const location = tomlLocation(context, "jwt_expiry");
|
|
2951
|
-
return [{
|
|
2952
|
-
message: `jwt_expiry is ${expiry}s. Keep it at 3600 or less; sessions refresh in the proxy.`,
|
|
2953
|
-
target: "[auth] jwt_expiry",
|
|
2954
|
-
...location ? { location } : {}
|
|
2955
|
-
}];
|
|
2956
|
-
}
|
|
2957
|
-
},
|
|
2958
|
-
{
|
|
2959
|
-
code: "BS403",
|
|
2960
|
-
severity: "info",
|
|
2961
|
-
title: "Local stack signs tokens with a shared secret",
|
|
2962
|
-
description: "Hosted projects sign with asymmetric keys. Using the same locally means local tokens verify through JWKS just like production.",
|
|
2963
|
-
check: (context) => {
|
|
2964
|
-
if (!context.configToml || authSetting(context, "signing_keys_path") || tomlGet(context.configToml.document, ["db", "driver"]) !== void 0) return [];
|
|
2965
|
-
const location = tomlLocation(context, "signing_keys_path");
|
|
2966
|
-
return [{
|
|
2967
|
-
message: "No [auth] signing_keys_path. Run `better-supabase keys` to sign local tokens with ES256.",
|
|
2968
|
-
target: "[auth] signing_keys_path",
|
|
2969
|
-
...location ? { location } : {}
|
|
2970
|
-
}];
|
|
2971
|
-
}
|
|
2972
|
-
},
|
|
2973
|
-
{
|
|
2974
|
-
code: "BS406",
|
|
2975
|
-
severity: "warning",
|
|
2976
|
-
title: "Foreign key to auth.users blocks account deletion",
|
|
2977
|
-
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.",
|
|
2978
|
-
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) => ({
|
|
2979
|
-
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.`,
|
|
2980
|
-
target: `${qualified(table)}.${key.name}`,
|
|
2981
|
-
object: tableObject(table)
|
|
2982
|
-
})))
|
|
2983
|
-
},
|
|
2984
|
-
{
|
|
2985
|
-
code: "BS501",
|
|
2986
|
-
severity: "error",
|
|
2987
|
-
title: "Secret in a browser variable",
|
|
2988
|
-
description: "Variables with a public prefix are bundled into client code. A secret key there gives every visitor full access.",
|
|
2989
|
-
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) => ({
|
|
2990
|
-
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.`,
|
|
2991
|
-
target: `${file.path}:${entry.key}`,
|
|
2992
|
-
location: {
|
|
2993
|
-
file: file.path,
|
|
2994
|
-
line: entry.line
|
|
2995
|
-
}
|
|
2996
|
-
})))
|
|
2997
|
-
},
|
|
2998
|
-
{
|
|
2999
|
-
code: "BS502",
|
|
3000
|
-
severity: "warning",
|
|
3001
|
-
title: "Env file with secrets is not ignored by git",
|
|
3002
|
-
description: "Env files holding a secret key or database password should never be committed.",
|
|
3003
|
-
check: (context) => {
|
|
3004
|
-
const patterns = context.gitignore.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
3005
|
-
const ignored = (path) => patterns.some((pattern) => {
|
|
3006
|
-
const source = pattern.replace(/^\//, "").replaceAll(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("*", ".*");
|
|
3007
|
-
return new RegExp(`^${source}$`).test(path) || new RegExp(`(^|/)${source}$`).test(path);
|
|
3008
|
-
});
|
|
3009
|
-
return context.envFiles.flatMap((file) => {
|
|
3010
|
-
const secret = envEntries(file).find((entry) => entry.value.startsWith("sb_secret_") || /SECRET_KEY|SERVICE_ROLE|DB_URL|DATABASE_URL/.test(entry.key));
|
|
3011
|
-
if (!secret || ignored(file.path) || file.path.endsWith(".example")) return [];
|
|
3012
|
-
return [{
|
|
3013
|
-
message: `${file.path} contains ${secret.key} but isn't in .gitignore.`,
|
|
3014
|
-
target: file.path,
|
|
3015
|
-
location: {
|
|
3016
|
-
file: file.path,
|
|
3017
|
-
line: secret.line
|
|
3018
|
-
}
|
|
3019
|
-
}];
|
|
3020
|
-
});
|
|
3021
|
-
}
|
|
3022
|
-
}
|
|
3023
|
-
],
|
|
3024
|
-
...RLS_RULES,
|
|
3025
|
-
...POLICY_RULES,
|
|
3026
|
-
...SCHEMA_DESIGN_RULES,
|
|
3027
|
-
...HOOK_RULES,
|
|
3028
|
-
...COOKIE_RULES,
|
|
3029
|
-
...AUTHORIZATION_RULES,
|
|
3030
|
-
...LIVE_RULES,
|
|
3031
|
-
...MODULE_RULES,
|
|
3032
|
-
...PGDELTA_RULES,
|
|
3033
|
-
...LITE_RULES,
|
|
3034
|
-
...DEPENDENCY_RULES
|
|
3035
|
-
].sort((a, b) => byCodePoint(a.code, b.code));
|
|
3036
|
-
const RULE_CODES = RULES.map((rule) => rule.code);
|
|
3037
|
-
async function runRules(context, rules = RULES) {
|
|
3038
|
-
const findings = [];
|
|
3039
|
-
const scoped = {
|
|
3040
|
-
...context,
|
|
3041
|
-
codes: rules.map((rule) => rule.code)
|
|
3042
|
-
};
|
|
3043
|
-
for (const rule of rules) {
|
|
3044
|
-
const help = `${DOCS_URL}#${rule.code.toLowerCase()}`;
|
|
3045
|
-
const skipped = skippedForMetadata(context.snapshot, rule, help);
|
|
3046
|
-
if (skipped) {
|
|
3047
|
-
findings.push(skipped);
|
|
3048
|
-
continue;
|
|
3049
|
-
}
|
|
3050
|
-
for (const input of await rule.check(scoped)) findings.push({
|
|
3051
|
-
code: rule.code,
|
|
3052
|
-
severity: rule.severity,
|
|
3053
|
-
title: rule.title,
|
|
3054
|
-
help,
|
|
3055
|
-
...input
|
|
3056
|
-
});
|
|
3057
|
-
}
|
|
3058
|
-
return findings;
|
|
3059
|
-
}
|
|
3060
|
-
//#endregion
|
|
3061
|
-
//#region src/cli/doctor/format.ts
|
|
3062
|
-
const DOCTOR_FORMATS = [
|
|
3063
|
-
"text",
|
|
3064
|
-
"json",
|
|
3065
|
-
"sarif",
|
|
3066
|
-
"github"
|
|
3067
|
-
];
|
|
3068
|
-
const DOCTOR_REPORT_SCHEMA_URL = "https://unpkg.com/better-supabase/schemas/doctor-report-v1.json";
|
|
3069
|
-
const SARIF_SCHEMA_URL = "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
|
|
3070
|
-
function summarize(findings) {
|
|
3071
|
-
return {
|
|
3072
|
-
errors: findings.filter((finding) => finding.severity === "error").length,
|
|
3073
|
-
warnings: findings.filter((finding) => finding.severity === "warning").length,
|
|
3074
|
-
infos: findings.filter((finding) => finding.severity === "info").length
|
|
3075
|
-
};
|
|
3076
|
-
}
|
|
3077
|
-
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
3078
|
-
const SEVERITY_FORMAT = {
|
|
3079
|
-
error: "red",
|
|
3080
|
-
warning: "yellow",
|
|
3081
|
-
info: "cyan"
|
|
3082
|
-
};
|
|
3083
|
-
function text(findings, paint) {
|
|
3084
|
-
if (findings.length === 0) return "No problems found.";
|
|
3085
|
-
const order = [
|
|
3086
|
-
"error",
|
|
3087
|
-
"warning",
|
|
3088
|
-
"info"
|
|
3089
|
-
];
|
|
3090
|
-
const lines = [...findings].sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity) || byCodePoint(a.code, b.code)).flatMap((finding) => [
|
|
3091
|
-
`${paint(SEVERITY_FORMAT[finding.severity], finding.severity.padEnd(7))} ${paint("bold", finding.code)} ${finding.title}${finding.location ? ` ${finding.location.file}:${finding.location.line}` : ""}`,
|
|
3092
|
-
` ${finding.message}`,
|
|
3093
|
-
` ${finding.help}`
|
|
3094
|
-
]);
|
|
3095
|
-
const summary = summarize(findings);
|
|
3096
|
-
lines.push("", `${plural(summary.errors, "error")}, ${plural(summary.warnings, "warning")}, ${plural(summary.infos, "note")}.`);
|
|
3097
|
-
return lines.join("\n");
|
|
3098
|
-
}
|
|
3099
|
-
function sarifLevel(severity) {
|
|
3100
|
-
switch (severity) {
|
|
3101
|
-
case "error": return "error";
|
|
3102
|
-
case "warning": return "warning";
|
|
3103
|
-
case "info": return "note";
|
|
3104
|
-
default: return severity;
|
|
3105
|
-
}
|
|
3106
|
-
}
|
|
3107
|
-
function sarif(findings, rules, version, fallback) {
|
|
3108
|
-
return {
|
|
3109
|
-
$schema: SARIF_SCHEMA_URL,
|
|
3110
|
-
version: "2.1.0",
|
|
3111
|
-
runs: [{
|
|
3112
|
-
tool: { driver: {
|
|
3113
|
-
name: "better-supabase doctor",
|
|
3114
|
-
version,
|
|
3115
|
-
informationUri: DOCS_URL,
|
|
3116
|
-
rules: rules.map((rule) => ({
|
|
3117
|
-
id: rule.code,
|
|
3118
|
-
name: rule.title.replaceAll(/[^A-Za-z0-9]+(.)?/g, (_, char) => (char ?? "").toUpperCase()),
|
|
3119
|
-
shortDescription: { text: rule.title },
|
|
3120
|
-
fullDescription: { text: rule.description },
|
|
3121
|
-
helpUri: `${DOCS_URL}#${rule.code.toLowerCase()}`,
|
|
3122
|
-
defaultConfiguration: { level: sarifLevel(rule.severity) }
|
|
3123
|
-
}))
|
|
3124
|
-
} },
|
|
3125
|
-
results: findings.map((finding) => ({
|
|
3126
|
-
ruleId: finding.code,
|
|
3127
|
-
ruleIndex: rules.findIndex((rule) => rule.code === finding.code),
|
|
3128
|
-
level: sarifLevel(finding.severity),
|
|
3129
|
-
message: { text: finding.message },
|
|
3130
|
-
locations: [{ physicalLocation: {
|
|
3131
|
-
artifactLocation: { uri: finding.location?.file ?? fallback },
|
|
3132
|
-
region: { startLine: finding.location?.line ?? 1 }
|
|
3133
|
-
} }],
|
|
3134
|
-
partialFingerprints: { target: `${finding.code}:${finding.target ?? finding.message}` }
|
|
3135
|
-
}))
|
|
3136
|
-
}]
|
|
3137
|
-
};
|
|
3138
|
-
}
|
|
3139
|
-
const escapeData = (value) => value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
3140
|
-
const escapeProperty = (value) => escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
3141
|
-
function github(findings) {
|
|
3142
|
-
return findings.map((finding) => {
|
|
3143
|
-
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})`)}`;
|
|
3144
|
-
}).join("\n");
|
|
3145
|
-
}
|
|
3146
|
-
function jsonReport(findings, version) {
|
|
3147
|
-
return {
|
|
3148
|
-
$schema: DOCTOR_REPORT_SCHEMA_URL,
|
|
3149
|
-
version: 1,
|
|
3150
|
-
tool: {
|
|
3151
|
-
name: "better-supabase",
|
|
3152
|
-
version
|
|
3153
|
-
},
|
|
3154
|
-
summary: summarize(findings),
|
|
3155
|
-
findings
|
|
3156
|
-
};
|
|
3157
|
-
}
|
|
3158
|
-
function formatReport(findings, options) {
|
|
3159
|
-
switch (options.format) {
|
|
3160
|
-
case "text": return text(findings, options.paint ?? plain);
|
|
3161
|
-
case "json": return JSON.stringify(jsonReport(findings, options.version), null, 2);
|
|
3162
|
-
case "sarif": return JSON.stringify(sarif(findings, options.rules, options.version, options.fallbackFile), null, 2);
|
|
3163
|
-
case "github": return github(findings);
|
|
3164
|
-
default: return options.format;
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
//#endregion
|
|
3168
|
-
//#region src/cli/doctor/inputs.ts
|
|
3169
|
-
const ENV_FILES = [
|
|
3170
|
-
".env",
|
|
3171
|
-
".env.local",
|
|
3172
|
-
".env.example",
|
|
3173
|
-
".env.development",
|
|
3174
|
-
".env.development.local",
|
|
3175
|
-
".env.production",
|
|
3176
|
-
".env.production.local"
|
|
3177
|
-
];
|
|
3178
|
-
async function readText(root, path) {
|
|
3179
|
-
const absolute = resolve(root, path);
|
|
3180
|
-
return existsSync(absolute) ? {
|
|
3181
|
-
path,
|
|
3182
|
-
text: await readFile(absolute, "utf8")
|
|
3183
|
-
} : void 0;
|
|
3184
|
-
}
|
|
3185
|
-
/** The declarative schemas in the diff engine's order, then the migrations newest first. */
|
|
3186
|
-
async function sqlFiles(root, toml) {
|
|
3187
|
-
const read = async (path) => ({
|
|
3188
|
-
path,
|
|
3189
|
-
text: await readFile(resolve(root, path), "utf8")
|
|
3190
|
-
});
|
|
3191
|
-
const schemas = await Promise.all((await schemaPaths(root, toml)).files.map(read));
|
|
3192
|
-
const migrationsPath = `${toml?.dir ?? "supabase"}/migrations`;
|
|
3193
|
-
const migrationsDir = resolve(root, migrationsPath);
|
|
3194
|
-
const migrations = existsSync(migrationsDir) ? (await readdir(migrationsDir)).filter((name) => name.endsWith(".sql")).sort().reverse().map((name) => `${migrationsPath}/${name}`) : [];
|
|
3195
|
-
return [...schemas, ...await Promise.all(migrations.map(read))];
|
|
3196
|
-
}
|
|
3197
|
-
/** Files matching `doctor.sources`, skipping dependencies and build output. */
|
|
3198
|
-
async function sourceFiles(config) {
|
|
3199
|
-
const files = [];
|
|
3200
|
-
for await (const path of glob([...config.doctor.sources], {
|
|
3201
|
-
cwd: config.root,
|
|
3202
|
-
exclude: (name) => name === "node_modules" || name === ".next"
|
|
3203
|
-
})) files.push({
|
|
3204
|
-
path,
|
|
3205
|
-
text: await readFile(resolve(config.root, path), "utf8")
|
|
3206
|
-
});
|
|
3207
|
-
return files.sort((a, b) => byCodePoint(a.path, b.path));
|
|
3208
|
-
}
|
|
3209
|
-
//#endregion
|
|
3210
|
-
//#region src/cli/doctor/advisors.ts
|
|
3211
|
-
/**
|
|
3212
|
-
* splinter (https://github.com/supabase/splinter), the linter behind the
|
|
3213
|
-
* dashboard's Security and Performance Advisors. It has no license, so it is
|
|
3214
|
-
* downloaded at this commit and checked against the hash instead of bundled.
|
|
3215
|
-
*/
|
|
3216
|
-
const SPLINTER_COMMIT = "e74a9e36cb12258cb67d1464bc1cb196e9cd8446";
|
|
3217
|
-
const SPLINTER_SHA256 = "d8d558baad3e03832e521c527907fa50a9a172fabd899dd0f5c2504a5a0e9349";
|
|
3218
|
-
const SPLINTER_URL = `https://raw.githubusercontent.com/supabase/splinter/${SPLINTER_COMMIT}/splinter.sql`;
|
|
3219
|
-
function isLint(value) {
|
|
3220
|
-
const lint = value;
|
|
3221
|
-
return typeof lint === "object" && lint !== null && typeof lint.name === "string" && typeof lint.level === "string" && Array.isArray(lint.categories);
|
|
3222
|
-
}
|
|
3223
|
-
/** The hosted project's advisors (`GET /v1/projects/{ref}/advisors/{category}`). */
|
|
3224
|
-
function managementAdvisors(options) {
|
|
3225
|
-
const base = (options.apiUrl ?? "https://api.supabase.com").replace(/\/$/, "");
|
|
3226
|
-
const doFetch = options.fetch ?? fetch;
|
|
3227
|
-
return {
|
|
3228
|
-
describe: `project ${options.projectRef} advisors (Management API)`,
|
|
3229
|
-
async lints(category) {
|
|
3230
|
-
const url = `${base}/v1/projects/${encodeURIComponent(options.projectRef)}/advisors/${category}`;
|
|
3231
|
-
const response = await doFetch(url, { headers: { authorization: `Bearer ${options.accessToken}` } });
|
|
3232
|
-
const text = await response.text();
|
|
3233
|
-
if (!response.ok) throw new Error(`Management API ${category} advisors failed (${response.status}): ${text.slice(0, 300)}`);
|
|
3234
|
-
const body = JSON.parse(text);
|
|
3235
|
-
if (!Array.isArray(body.lints)) throw new TypeError(`Management API ${category} advisors returned no "lints".`);
|
|
3236
|
-
return body.lints.filter(isLint);
|
|
3237
|
-
}
|
|
3238
|
-
};
|
|
3239
|
-
}
|
|
3240
|
-
/** The `[api] schemas` list of a `config.toml`, when it names any. */
|
|
3241
|
-
function apiSchemas(document) {
|
|
3242
|
-
const value = document ? tomlGet(document, ["api", "schemas"]) : void 0;
|
|
3243
|
-
if (!Array.isArray(value)) return [];
|
|
3244
|
-
return value.filter((schema) => typeof schema === "string" && schema.trim() !== "");
|
|
3245
|
-
}
|
|
3246
|
-
const literal = (value) => `'${value.replaceAll("'", "''")}'`;
|
|
3247
|
-
/** The read-only transaction splinter runs in, with `pgrst.db_schemas` set first. */
|
|
3248
|
-
function splinterScript(sql, schemas = []) {
|
|
3249
|
-
return `begin read only;\n${schemas.length > 0 ? `set local pgrst.db_schemas = ${literal(schemas.join(","))};\n` : ""}${sql}\n;\nrollback;`;
|
|
3250
|
-
}
|
|
3251
|
-
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
3252
|
-
/** Downloads (once) and verifies the pinned `splinter.sql`. */
|
|
3253
|
-
async function splinterSql(options) {
|
|
3254
|
-
const file = resolve(options.cacheDir, `splinter-${SPLINTER_COMMIT}.sql`);
|
|
3255
|
-
if (existsSync(file)) {
|
|
3256
|
-
const cached = await readFile(file, "utf8");
|
|
3257
|
-
if (sha256(cached) === SPLINTER_SHA256) return cached;
|
|
3258
|
-
}
|
|
3259
|
-
const response = await (options.fetch ?? fetch)(SPLINTER_URL);
|
|
3260
|
-
if (!response.ok) throw new Error(`Downloading splinter.sql failed (${response.status}) from ${SPLINTER_URL}`);
|
|
3261
|
-
const text = await response.text();
|
|
3262
|
-
if (sha256(text) !== SPLINTER_SHA256) throw new Error(`splinter.sql from ${SPLINTER_URL} does not match the pinned hash; refusing to run it.`);
|
|
3263
|
-
await mkdir(dirname(file), { recursive: true });
|
|
3264
|
-
await writeFile(file, text);
|
|
3265
|
-
return text;
|
|
3266
|
-
}
|
|
3267
|
-
/** `pg` returns one result per statement for a multi-statement query. */
|
|
3268
|
-
function lintRows(result) {
|
|
3269
|
-
const results = Array.isArray(result) ? result : [result];
|
|
3270
|
-
return results.find((entry) => entry.fields?.some((field) => field.name === "cache_key"))?.rows ?? results.at(-1)?.rows ?? [];
|
|
3271
|
-
}
|
|
3272
|
-
/**
|
|
3273
|
-
* Runs splinter against a database (local stack, `$DATABASE_URL` or `--db-url-stdin`) in a
|
|
3274
|
-
* read-only transaction that is rolled back.
|
|
3275
|
-
*/
|
|
3276
|
-
function splinterAdvisors(queryable, describe, options) {
|
|
3277
|
-
let all;
|
|
3278
|
-
const run = async () => {
|
|
3279
|
-
const sql = await splinterSql(options);
|
|
3280
|
-
return lintRows(await queryable.query(splinterScript(sql, options.schemas))).filter(isLint);
|
|
3281
|
-
};
|
|
3282
|
-
return {
|
|
3283
|
-
describe: `${describe} (splinter ${SPLINTER_COMMIT.slice(0, 7)})`,
|
|
3284
|
-
async lints(category) {
|
|
3285
|
-
all ??= run();
|
|
3286
|
-
const wanted = category.toUpperCase();
|
|
3287
|
-
return (await all).filter((lint) => lint.categories.includes(wanted));
|
|
3288
|
-
}
|
|
3289
|
-
};
|
|
3290
|
-
}
|
|
3291
|
-
//#endregion
|
|
3292
|
-
//#region src/cli/doctor/live-source.ts
|
|
3293
|
-
/** Checks that read the database itself rather than the snapshot. */
|
|
3294
|
-
const LIVE_CODES = /* @__PURE__ */ new Set([
|
|
3295
|
-
"BS100",
|
|
3296
|
-
"BS200",
|
|
3297
|
-
"BS208",
|
|
3298
|
-
"BS209",
|
|
3299
|
-
"BS212"
|
|
3300
|
-
]);
|
|
3301
|
-
/**
|
|
3302
|
-
* The database doctor reads, for advisors and live checks: the Management
|
|
3303
|
-
* API for hosted projects, one lazily opened connection otherwise (splinter
|
|
3304
|
-
* runs over it). Saved snapshots skip both.
|
|
3305
|
-
*/
|
|
3306
|
-
function openLive(config, env, source, pg) {
|
|
3307
|
-
const none = { close: () => Promise.resolve() };
|
|
3308
|
-
const file = snapshotFile(config, source);
|
|
3309
|
-
if (file) {
|
|
3310
|
-
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.`;
|
|
3311
|
-
return {
|
|
3312
|
-
...none,
|
|
3313
|
-
advisors: { skipped },
|
|
3314
|
-
database: { skipped }
|
|
3315
|
-
};
|
|
3316
|
-
}
|
|
3317
|
-
let opened;
|
|
3318
|
-
const open = () => opened ??= openSource(config, env, source, pg);
|
|
3319
|
-
const target = managementTarget(config, env, source);
|
|
3320
|
-
const database = {
|
|
3321
|
-
describe: target ? `project ${target.projectRef} (Management API)` : "database",
|
|
3322
|
-
session: !target,
|
|
3323
|
-
async query(sql) {
|
|
3324
|
-
return (await (await open()).queryable.query(sql)).rows;
|
|
3325
|
-
}
|
|
3326
|
-
};
|
|
3327
|
-
const close = async () => {
|
|
3328
|
-
if (opened) await (await opened).close();
|
|
3329
|
-
};
|
|
3330
|
-
if (target) return {
|
|
3331
|
-
advisors: managementAdvisors(target),
|
|
3332
|
-
database,
|
|
3333
|
-
open,
|
|
3334
|
-
close
|
|
3335
|
-
};
|
|
3336
|
-
let splinter;
|
|
3337
|
-
const cacheDir = resolve(config.root, CACHE_DIR);
|
|
3338
|
-
return {
|
|
3339
|
-
advisors: {
|
|
3340
|
-
describe: "database (splinter)",
|
|
3341
|
-
async lints(category) {
|
|
3342
|
-
splinter ??= Promise.all([open(), readSupabaseToml(config.root).catch(() => void 0)]).then(([db, toml]) => splinterAdvisors(db.queryable, db.describe, {
|
|
3343
|
-
cacheDir,
|
|
3344
|
-
schemas: apiSchemas(toml?.document)
|
|
3345
|
-
}));
|
|
3346
|
-
return (await splinter).lints(category);
|
|
3347
|
-
}
|
|
3348
|
-
},
|
|
3349
|
-
database,
|
|
3350
|
-
open,
|
|
3351
|
-
close
|
|
3352
|
-
};
|
|
3353
|
-
}
|
|
3354
|
-
//#endregion
|
|
3355
|
-
//#region src/cli/doctor/locate.ts
|
|
3356
|
-
const escape = (name) => name.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3357
|
-
/** Where a table, function or policy is declared: declarative schemas first, then the newest migration. */
|
|
3358
|
-
function locate(files, object) {
|
|
3359
|
-
const name = `"?${escape(object.name)}"?`;
|
|
3360
|
-
const qualifiedName = `(?:"?${escape(object.schema)}"?\\.)?${name}`;
|
|
3361
|
-
if (object.kind === "policy" && object.table !== void 0) {
|
|
3362
|
-
const onTable = new RegExp(`create\\s+policy\\s+${name}\\s+on\\s+(?:only\\s+)?(?:"?${escape(object.schema)}"?\\.)?"?${escape(object.table)}"?(?:\\s|;|$)`, "i");
|
|
3363
|
-
for (const file of files) {
|
|
3364
|
-
const match = onTable.exec(file.text);
|
|
3365
|
-
if (match) return {
|
|
3366
|
-
file: file.path,
|
|
3367
|
-
line: file.text.slice(0, match.index).split("\n").length
|
|
3368
|
-
};
|
|
3369
|
-
}
|
|
3370
|
-
return;
|
|
3371
|
-
}
|
|
3372
|
-
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");
|
|
3373
|
-
for (const file of files) {
|
|
3374
|
-
const line = lineOf(file.text, pattern);
|
|
3375
|
-
if (line) return {
|
|
3376
|
-
file: file.path,
|
|
3377
|
-
line
|
|
3378
|
-
};
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
//#endregion
|
|
3382
|
-
//#region src/cli/doctor/options.ts
|
|
3383
|
-
const FLAG_FORMATS = DOCTOR_FORMATS.filter((format) => format !== "json");
|
|
3384
|
-
/** `--json` picks the JSON report; `--format`, then `doctor.format`, picks between the others. */
|
|
3385
|
-
function reportFormat(args, configured) {
|
|
3386
|
-
if (args.format === "json") throw new CliError("usage", "--format json is now the global --json flag");
|
|
3387
|
-
if (args.json === true) {
|
|
3388
|
-
if (args.format !== void 0) throw new CliError("usage", "Pass --json or --format, not both");
|
|
3389
|
-
return "json";
|
|
3390
|
-
}
|
|
3391
|
-
if (args.format === void 0) return configured;
|
|
3392
|
-
const format = FLAG_FORMATS.find((candidate) => candidate === args.format);
|
|
3393
|
-
if (!format) throw new CliError("usage", `--format must be one of ${FLAG_FORMATS.join(", ")}`);
|
|
3394
|
-
return format;
|
|
3395
|
-
}
|
|
3396
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3397
|
-
/** `--as <uuid>` or `--claims <json>` as `request.jwt.claims`. */
|
|
3398
|
-
function explainClaims(args) {
|
|
3399
|
-
const as = args.as;
|
|
3400
|
-
const claims = args.claims;
|
|
3401
|
-
if (as && claims) return { error: "Pass --as or --claims, not both" };
|
|
3402
|
-
if (as) {
|
|
3403
|
-
if (!UUID.test(as)) return { error: `--as takes a user id (uuid), got "${as}"` };
|
|
3404
|
-
return {
|
|
3405
|
-
sub: as,
|
|
3406
|
-
role: "authenticated"
|
|
3407
|
-
};
|
|
3408
|
-
}
|
|
3409
|
-
if (!claims) return { role: "anon" };
|
|
3410
|
-
try {
|
|
3411
|
-
const parsed = JSON.parse(claims);
|
|
3412
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return { error: "--claims takes a JSON object" };
|
|
3413
|
-
return {
|
|
3414
|
-
role: "anon",
|
|
3415
|
-
...parsed
|
|
3416
|
-
};
|
|
3417
|
-
} catch (cause) {
|
|
3418
|
-
return { error: `--claims is not valid JSON: ${errorText(cause)}` };
|
|
3419
|
-
}
|
|
3420
|
-
}
|
|
3421
|
-
//#endregion
|
|
3422
|
-
//#region src/cli/commands/doctor.ts
|
|
3423
|
-
const ARGS = {
|
|
3424
|
-
format: {
|
|
3425
|
-
type: "string",
|
|
3426
|
-
description: "text, sarif (code scanning) or github (workflow annotations); also doctor.format, default text. --json for the JSON report",
|
|
3427
|
-
valueHint: "format"
|
|
3428
|
-
},
|
|
3429
|
-
out: {
|
|
3430
|
-
type: "string",
|
|
3431
|
-
description: "Write the report to a file; also doctor.output",
|
|
3432
|
-
valueHint: "file"
|
|
3433
|
-
},
|
|
3434
|
-
only: {
|
|
3435
|
-
type: "string",
|
|
3436
|
-
description: "Run only these checks, e.g. --only BS100,BS304; also doctor.only",
|
|
3437
|
-
valueHint: "codes"
|
|
3438
|
-
},
|
|
3439
|
-
ignore: {
|
|
3440
|
-
type: "string",
|
|
3441
|
-
description: "Skip checks; also doctor.ignore in the config",
|
|
3442
|
-
valueHint: "codes"
|
|
3443
|
-
},
|
|
3444
|
-
strict: {
|
|
3445
|
-
type: "boolean",
|
|
3446
|
-
description: "Fail on warnings; also doctor.strict"
|
|
3447
|
-
},
|
|
3448
|
-
snapshot: {
|
|
3449
|
-
type: "string",
|
|
3450
|
-
description: "Check a saved snapshot instead of the database",
|
|
3451
|
-
valueHint: "file"
|
|
3452
|
-
},
|
|
3453
|
-
metadata: {
|
|
3454
|
-
type: "string",
|
|
3455
|
-
description: "Check a GeneratorMetadata document (- for stdin); checks it lacks data for are skipped",
|
|
3456
|
-
valueHint: "path|-"
|
|
3457
|
-
},
|
|
3458
|
-
"db-url-stdin": {
|
|
3459
|
-
type: "boolean",
|
|
3460
|
-
description: "Read the connection string from stdin instead of the config, $DATABASE_URL or $SUPABASE_DB_URL"
|
|
3461
|
-
},
|
|
3462
|
-
"project-ref": {
|
|
3463
|
-
type: "string",
|
|
3464
|
-
description: "Read a hosted project through the Management API",
|
|
3465
|
-
valueHint: "ref"
|
|
3466
|
-
},
|
|
3467
|
-
stats: {
|
|
3468
|
-
type: "boolean",
|
|
3469
|
-
description: "Report slow frequent statements from pg_stat_statements (BS209)"
|
|
3470
|
-
},
|
|
3471
|
-
explain: {
|
|
3472
|
-
type: "string",
|
|
3473
|
-
description: "EXPLAIN ANALYZE these tables under RLS, rolled back (BS212)",
|
|
3474
|
-
valueHint: "tables"
|
|
3475
|
-
},
|
|
3476
|
-
as: {
|
|
3477
|
-
type: "string",
|
|
3478
|
-
description: "Plan as this authenticated user, and measure the claims the custom access token hook returns for them (BS405)",
|
|
3479
|
-
valueHint: "uuid"
|
|
3480
|
-
},
|
|
3481
|
-
claims: {
|
|
3482
|
-
type: "string",
|
|
3483
|
-
description: "Plan with these JWT claims ({\"role\":\"authenticated\",...})",
|
|
3484
|
-
valueHint: "json"
|
|
3485
|
-
},
|
|
3486
|
-
"fix-grants": {
|
|
3487
|
-
type: "boolean",
|
|
3488
|
-
description: "Print the grant and revoke SQL BS404 asks for, to add to your schema or migration"
|
|
3489
|
-
}
|
|
3490
|
-
};
|
|
3491
|
-
async function runDoctor(config, args, env, options = {}) {
|
|
3492
|
-
const format = reportFormat(args, config.doctor.format);
|
|
3493
|
-
const only = args.only === void 0 ? [...config.doctor.only] : list(args.only);
|
|
3494
|
-
const ignore = /* @__PURE__ */ new Set([...config.doctor.ignore, ...list(args.ignore)]);
|
|
3495
|
-
const unknown = [...only, ...ignore].filter((code) => !RULE_CODES.includes(code));
|
|
3496
|
-
if (unknown.length > 0) return {
|
|
3497
|
-
code: 2,
|
|
3498
|
-
error: `Unknown check ${unknown.join(", ")}. Checks: ${RULE_CODES.join(", ")}`
|
|
3499
|
-
};
|
|
3500
|
-
const explainTables = list(args.explain);
|
|
3501
|
-
const stats = args.stats === true;
|
|
3502
|
-
const claims = explainClaims(args);
|
|
3503
|
-
if ("error" in claims) return {
|
|
3504
|
-
code: 2,
|
|
3505
|
-
error: String(claims.error)
|
|
3506
|
-
};
|
|
3507
|
-
const hookUser = args.as;
|
|
3508
|
-
const asked = /* @__PURE__ */ new Set([
|
|
3509
|
-
...stats ? ["BS209"] : [],
|
|
3510
|
-
...explainTables.length > 0 ? ["BS212"] : [],
|
|
3511
|
-
...hookUser ? ["BS405"] : []
|
|
3512
|
-
]);
|
|
3513
|
-
const fixGrants = args["fix-grants"] === true;
|
|
3514
|
-
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));
|
|
3515
|
-
const snapshotPath = args.snapshot;
|
|
3516
|
-
const dbUrl = options.dbUrl;
|
|
3517
|
-
const projectRef = args["project-ref"];
|
|
3518
|
-
const source = {
|
|
3519
|
-
...stats || explainTables.length > 0 || hookUser ? { live: true } : {},
|
|
3520
|
-
...snapshotPath ? { snapshotPath } : {},
|
|
3521
|
-
...dbUrl ? { dbUrl } : {},
|
|
3522
|
-
...projectRef ? { projectRef } : {},
|
|
3523
|
-
...options.signal ? { signal: options.signal } : {}
|
|
3524
|
-
};
|
|
3525
|
-
const pg = options.connect ?? connect;
|
|
3526
|
-
const wantsLive = hookUser !== void 0 || rules.some((rule) => LIVE_CODES.has(rule.code));
|
|
3527
|
-
const opened = options.snapshot || !wantsLive ? {
|
|
3528
|
-
advisors: options.advisors,
|
|
3529
|
-
database: options.database,
|
|
3530
|
-
close: () => Promise.resolve()
|
|
3531
|
-
} : {
|
|
3532
|
-
...openLive(config, env, source, pg),
|
|
3533
|
-
...options.advisors === void 0 ? {} : { advisors: options.advisors },
|
|
3534
|
-
...options.database === void 0 ? {} : { database: options.database }
|
|
3535
|
-
};
|
|
3536
|
-
const [snapshot, envFiles, [configToml, sql], gitignore, sources, readSets, peers] = await Promise.all([
|
|
3537
|
-
options.snapshot ?? loadSnapshot(config, env, source, pg, opened.open),
|
|
3538
|
-
Promise.all(ENV_FILES.map((path) => readText(config.root, path))).then((files) => files.filter((file) => file !== void 0)),
|
|
3539
|
-
readSupabaseToml(config.root).then(async (toml) => [toml, await sqlFiles(config.root, toml)]),
|
|
3540
|
-
readText(config.root, ".gitignore"),
|
|
3541
|
-
sourceFiles(config),
|
|
3542
|
-
compiledReadSets(config).catch((cause) => ({ skipped: errorText(cause) })),
|
|
3543
|
-
options.peers ?? peerVersions(config.root).catch(() => [])
|
|
3544
|
-
]).catch(async (cause) => {
|
|
3545
|
-
await opened.close();
|
|
3546
|
-
throw cause;
|
|
3547
|
-
});
|
|
3548
|
-
const replay = opened.open ? (await opened.open()).replay : void 0;
|
|
3549
|
-
const live = replay ? {
|
|
3550
|
-
advisors: options.advisors ?? { skipped: replay },
|
|
3551
|
-
database: options.database ?? { skipped: replay }
|
|
3552
|
-
} : opened;
|
|
3553
|
-
const context = {
|
|
3554
|
-
config,
|
|
3555
|
-
snapshot,
|
|
3556
|
-
sqlFiles: sql,
|
|
3557
|
-
configToml,
|
|
3558
|
-
envFiles,
|
|
3559
|
-
gitignore: gitignore?.text ?? "",
|
|
3560
|
-
sources,
|
|
3561
|
-
readSets,
|
|
3562
|
-
peers,
|
|
3563
|
-
...live.advisors ? { advisors: live.advisors } : {},
|
|
3564
|
-
...live.database ? { database: live.database } : {},
|
|
3565
|
-
...stats ? { stats } : {},
|
|
3566
|
-
...hookUser ? { hookUser } : {},
|
|
3567
|
-
...explainTables.length > 0 ? { explain: {
|
|
3568
|
-
tables: explainTables,
|
|
3569
|
-
claims
|
|
3570
|
-
} } : {}
|
|
3571
|
-
};
|
|
3572
|
-
if (fixGrants) {
|
|
3573
|
-
await opened.close();
|
|
3574
|
-
return {
|
|
3575
|
-
code: 0,
|
|
3576
|
-
output: hookGrantBlock(hookGrantProblems(context), diffEngine(context.configToml))
|
|
3577
|
-
};
|
|
3578
|
-
}
|
|
3579
|
-
let ran;
|
|
3580
|
-
try {
|
|
3581
|
-
ran = await runRules(context, rules);
|
|
3582
|
-
} finally {
|
|
3583
|
-
await opened.close();
|
|
3584
|
-
}
|
|
3585
|
-
const findings = ran.map((finding) => {
|
|
3586
|
-
if (finding.location || !finding.object) return finding;
|
|
3587
|
-
const location = locate(sql, finding.object);
|
|
3588
|
-
return location ? {
|
|
3589
|
-
...finding,
|
|
3590
|
-
location
|
|
3591
|
-
} : finding;
|
|
3592
|
-
});
|
|
3593
|
-
const out = args.out ?? (format === config.doctor.format ? config.doctor.output : void 0);
|
|
3594
|
-
const fromRepo = format === "sarif" || format === "github";
|
|
3595
|
-
const repoRoot = findRepoRoot(config.root);
|
|
3596
|
-
const repoPath = (file) => display(repoRoot, resolve(config.root, file));
|
|
3597
|
-
const report = formatReport(fromRepo ? findings.map((finding) => finding.location ? {
|
|
3598
|
-
...finding,
|
|
3599
|
-
location: {
|
|
3600
|
-
...finding.location,
|
|
3601
|
-
file: repoPath(finding.location.file)
|
|
3602
|
-
}
|
|
3603
|
-
} : finding) : findings, {
|
|
3604
|
-
format,
|
|
3605
|
-
rules,
|
|
3606
|
-
version: VERSION,
|
|
3607
|
-
fallbackFile: repoPath(context.configToml?.path ?? "package.json"),
|
|
3608
|
-
...options.paint && !out ? { paint: options.paint } : {}
|
|
3609
|
-
});
|
|
3610
|
-
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
3611
|
-
const warnings = findings.filter((finding) => finding.severity === "warning").length;
|
|
3612
|
-
const strict = args.strict === true || config.doctor.strict;
|
|
3613
|
-
const code = errors > 0 || strict && warnings > 0 ? 1 : 0;
|
|
3614
|
-
if (out) {
|
|
3615
|
-
await writeIfChanged(resolve(config.root, out), `${report}\n`);
|
|
3616
|
-
return {
|
|
3617
|
-
code,
|
|
3618
|
-
output: `Wrote ${out}: ${errors} errors, ${warnings} warnings.`,
|
|
3619
|
-
data: {
|
|
3620
|
-
out,
|
|
3621
|
-
errors,
|
|
3622
|
-
warnings
|
|
3623
|
-
}
|
|
3624
|
-
};
|
|
3625
|
-
}
|
|
3626
|
-
return format === "json" ? {
|
|
3627
|
-
code,
|
|
3628
|
-
data: jsonReport(findings, VERSION)
|
|
3629
|
-
} : {
|
|
3630
|
-
code,
|
|
3631
|
-
output: report
|
|
3632
|
-
};
|
|
3633
|
-
}
|
|
3634
|
-
const doctorCommand = defineCliCommand({
|
|
3635
|
-
meta: {
|
|
3636
|
-
name: "doctor",
|
|
3637
|
-
description: "Checks RLS, indexes, drift, auth config and env files; exits 1 on errors, or on warnings with --strict"
|
|
3638
|
-
},
|
|
3639
|
-
args: ARGS,
|
|
3640
|
-
lists: [
|
|
3641
|
-
"only",
|
|
3642
|
-
"ignore",
|
|
3643
|
-
"explain"
|
|
3644
|
-
],
|
|
3645
|
-
run: async (args, { config, cwd, env, io, signal }) => {
|
|
3646
|
-
if (args.metadata !== void 0) {
|
|
3647
|
-
const conflicting = [
|
|
3648
|
-
...args.snapshot === void 0 ? [] : ["--snapshot"],
|
|
3649
|
-
...args["db-url-stdin"] === true ? ["--db-url-stdin"] : [],
|
|
3650
|
-
...args["project-ref"] === void 0 ? [] : ["--project-ref"],
|
|
3651
|
-
...args.stats === true ? ["--stats"] : [],
|
|
3652
|
-
...args.explain === void 0 ? [] : ["--explain"],
|
|
3653
|
-
...args.as === void 0 ? [] : ["--as"]
|
|
3654
|
-
];
|
|
3655
|
-
if (conflicting.length > 0) return {
|
|
3656
|
-
code: 2,
|
|
3657
|
-
error: `--metadata checks the document, not a database, so it can't be combined with ${conflicting.join(", ")}`
|
|
3658
|
-
};
|
|
3659
|
-
let snapshot;
|
|
3660
|
-
try {
|
|
3661
|
-
snapshot = await readMetadataSnapshot(args.metadata, cwd, io);
|
|
3662
|
-
} catch (cause) {
|
|
3663
|
-
if (cause instanceof MetadataRejectedError) return {
|
|
3664
|
-
code: 65,
|
|
3665
|
-
error: cause.message
|
|
3666
|
-
};
|
|
3667
|
-
throw cause;
|
|
3668
|
-
}
|
|
3669
|
-
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.`;
|
|
3670
|
-
return runDoctor(config, args, env, {
|
|
3671
|
-
snapshot,
|
|
3672
|
-
advisors: { skipped },
|
|
3673
|
-
database: { skipped },
|
|
3674
|
-
paint: painter(io.color)
|
|
3675
|
-
});
|
|
3676
|
-
}
|
|
3677
|
-
const dbUrl = await stdinDatabaseUrl(args["db-url-stdin"], io);
|
|
3678
|
-
return withSpinner(args.snapshot === void 0 ? io.prompts : void 0, "Checking the database", () => runDoctor(config, args, env, {
|
|
3679
|
-
paint: painter(io.color),
|
|
3680
|
-
...dbUrl ? { dbUrl } : {},
|
|
3681
|
-
...signal ? { signal } : {}
|
|
3682
|
-
}));
|
|
3683
|
-
}
|
|
3684
|
-
});
|
|
3685
|
-
//#endregion
|
|
3686
|
-
export { doctorCommand };
|