@luanpdd/kit-mcp 1.7.0 → 1.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/CHANGELOG.md +101 -0
- package/README.md +39 -1
- package/gates/agent-no-recursive-dispatch.md +48 -0
- package/gates/budget-description.md +68 -0
- package/gates/no-personal-uuid.md +72 -0
- package/gates/obs-agents-mcp-supabase.md +86 -0
- package/gates/obs-skills-frontmatter.md +76 -0
- package/gates/omm-no-regression.md +83 -0
- package/gates/skill-must-include.md +71 -0
- package/gates/sync-idempotent.md +62 -0
- package/kit/agents/burn-rate-forecaster.md +160 -0
- package/kit/agents/codebase-mapper.md +1 -1
- package/kit/agents/executor.md +17 -0
- package/kit/agents/incident-investigator.md +245 -0
- package/kit/agents/observability-instrumenter.md +200 -0
- package/kit/agents/omm-auditor.md +199 -0
- package/kit/agents/planner.md +35 -0
- package/kit/agents/project-researcher.md +1 -1
- package/kit/agents/schema-checker.md +4 -4
- package/kit/agents/slo-engineer.md +224 -0
- package/kit/agents/supabase-architect.md +166 -0
- package/kit/agents/supabase-auth-bootstrapper.md +315 -0
- package/kit/agents/supabase-edge-fn-writer.md +207 -0
- package/kit/agents/supabase-migration-writer.md +174 -0
- package/kit/agents/supabase-realtime-implementer.md +275 -0
- package/kit/agents/supabase-rls-writer.md +235 -0
- package/kit/agents/supabase-storage-implementer.md +258 -0
- package/kit/agents/user-profiler.md +1 -1
- package/kit/agents/verifier.md +1 -1
- package/kit/commands/auditar-marco.md +22 -1
- package/kit/commands/auditar-observabilidade.md +103 -0
- package/kit/commands/burn-rate-status.md +140 -0
- package/kit/commands/concluir-marco.md +19 -1
- package/kit/commands/definir-slo.md +108 -0
- package/kit/commands/depurar.md +17 -0
- package/kit/commands/discutir-fase.md +26 -0
- package/kit/commands/fazer.md +15 -0
- package/kit/commands/forense.md +20 -1
- package/kit/commands/instrumentar-fase.md +200 -0
- package/kit/commands/investigar-producao.md +162 -0
- package/kit/commands/observabilidade.md +116 -0
- package/kit/commands/planejar-fase.md +20 -0
- package/kit/commands/supabase.md +148 -0
- package/kit/commands/verificar-trabalho.md +26 -0
- package/kit/framework/workflows/discuss-phase.md +19 -0
- package/kit/framework/workflows/plan-phase.md +25 -0
- package/kit/skills/_shared-observability/glossary.md +396 -0
- package/kit/skills/_shared-supabase/glossary.md +180 -0
- package/kit/skills/burn-rate-alerting/SKILL.md +258 -0
- package/kit/skills/core-analysis-loop/SKILL.md +352 -0
- package/kit/skills/distributed-tracing/SKILL.md +362 -0
- package/kit/skills/event-based-slos/SKILL.md +274 -0
- package/kit/skills/observability-driven-development/SKILL.md +315 -0
- package/kit/skills/observability-maturity-model/SKILL.md +222 -0
- package/kit/skills/opentelemetry-standard/SKILL.md +351 -0
- package/kit/skills/structured-events/SKILL.md +265 -0
- package/kit/skills/supabase-auth-ssr/SKILL.md +260 -0
- package/kit/skills/supabase-cron-queues/SKILL.md +266 -0
- package/kit/skills/supabase-database-functions/SKILL.md +247 -0
- package/kit/skills/supabase-declarative-schema/SKILL.md +183 -0
- package/kit/skills/supabase-edge-functions/SKILL.md +242 -0
- package/kit/skills/supabase-migrations/SKILL.md +175 -0
- package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -0
- package/kit/skills/supabase-postgres-style/SKILL.md +138 -0
- package/kit/skills/supabase-realtime/SKILL.md +236 -0
- package/kit/skills/supabase-rls-policies/SKILL.md +185 -0
- package/kit/skills/supabase-storage/SKILL.md +234 -0
- package/kit/skills/telemetry-pipelines/SKILL.md +259 -0
- package/kit/skills/telemetry-sampling/SKILL.md +256 -0
- package/package.json +1 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-realtime
|
|
3
|
+
description: Use ao implementar Realtime — broadcast com private:true, naming scope:entity:id, RLS sobre realtime.messages, removeChannel cleanup, migrar de postgres_changes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Supabase — Realtime
|
|
7
|
+
|
|
8
|
+
## Quando usar
|
|
9
|
+
|
|
10
|
+
LLM carrega esta skill quando implementar features Realtime em Supabase (chat, presence, notifications, live dashboards). Trigger phrases:
|
|
11
|
+
|
|
12
|
+
- "Supabase Realtime", "broadcast", "presence"
|
|
13
|
+
- "subscrever a mudanças no banco em tempo real"
|
|
14
|
+
- "WebSocket Supabase"
|
|
15
|
+
- "migrar postgres_changes para broadcast"
|
|
16
|
+
- "RLS realtime.messages"
|
|
17
|
+
- "channel state", "removeChannel"
|
|
18
|
+
|
|
19
|
+
## Regras absolutas
|
|
20
|
+
|
|
21
|
+
- **Use `broadcast` por default** — `postgres_changes` é pattern legado (single-threaded, não escala). **Migrar para broadcast** em features novas.
|
|
22
|
+
- **`private: true`** em todos os canais novos — exige autenticação + RLS sobre `realtime.messages`. Default em produção 2026.
|
|
23
|
+
- **Naming canônico `scope:entity:id`** — ex: `room:messages:abc123`, `user:notifications:xyz789`, `org:announcements:org_42`.
|
|
24
|
+
- **Eventos em `entity_action`** — ex: `message_inserted`, `task_updated`, `presence_joined`.
|
|
25
|
+
- **`removeChannel` no cleanup obrigatório** — chamar `supabase.removeChannel(channel)` em `useEffect return` ou equivalente. Sem cleanup, memory leak + stale state (anti-pitfall B1).
|
|
26
|
+
- **State checking antes de subscribe** — `if (channel.state === 'joined') return;` evita double-subscribe.
|
|
27
|
+
- **RLS sobre `realtime.messages`** — SELECT (read) e INSERT (write) policies separadas, com index nas colunas usadas.
|
|
28
|
+
- **Use Presence com moderação** — apenas para online status / cursors colaborativos, não para listas de objects (use queries normais).
|
|
29
|
+
- Realtime tem **retry built-in** — log `status` no callback do `subscribe` mas não implementar retry manual.
|
|
30
|
+
|
|
31
|
+
## Patterns canônicos
|
|
32
|
+
|
|
33
|
+
### Subscribe via broadcast — client com cleanup
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// PT-BR: subscrição típica em Client Component
|
|
37
|
+
'use client'
|
|
38
|
+
import { useEffect, useState } from 'react'
|
|
39
|
+
import { createClient } from '@/utils/supabase/client'
|
|
40
|
+
|
|
41
|
+
export function ChatRoom({ roomId }: { roomId: string }) {
|
|
42
|
+
const supabase = createClient()
|
|
43
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const channel = supabase
|
|
47
|
+
.channel(`room:messages:${roomId}`, { config: { private: true } })
|
|
48
|
+
.on('broadcast', { event: 'message_inserted' }, ({ payload }) => {
|
|
49
|
+
setMessages((prev) => [...prev, payload as Message])
|
|
50
|
+
})
|
|
51
|
+
.subscribe((status) => {
|
|
52
|
+
if (status === 'SUBSCRIBED') console.log('joined channel')
|
|
53
|
+
if (status === 'CHANNEL_ERROR') console.error('channel error')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// PT-BR: cleanup obrigatório — sem isso, memory leak
|
|
57
|
+
return () => {
|
|
58
|
+
supabase.removeChannel(channel)
|
|
59
|
+
}
|
|
60
|
+
}, [roomId, supabase])
|
|
61
|
+
|
|
62
|
+
return <ul>{messages.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### RLS sobre `realtime.messages`
|
|
67
|
+
|
|
68
|
+
```sql
|
|
69
|
+
-- PT-BR: SELECT policy permite ouvir broadcast em canal autenticado
|
|
70
|
+
-- Granular: SELECT = read, INSERT = write — duas policies separadas
|
|
71
|
+
create policy "auth_select_realtime_messages"
|
|
72
|
+
on realtime.messages
|
|
73
|
+
for select
|
|
74
|
+
to authenticated
|
|
75
|
+
using ((select auth.uid()) is not null);
|
|
76
|
+
|
|
77
|
+
-- PT-BR: INSERT policy permite enviar broadcast
|
|
78
|
+
create policy "auth_insert_realtime_messages"
|
|
79
|
+
on realtime.messages
|
|
80
|
+
for insert
|
|
81
|
+
to authenticated
|
|
82
|
+
with check ((select auth.uid()) is not null);
|
|
83
|
+
|
|
84
|
+
-- PT-BR: index obrigatório (extension é a coluna usada por broadcast)
|
|
85
|
+
create index if not exists realtime_messages_extension_idx
|
|
86
|
+
on realtime.messages (extension);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### DB trigger via `realtime.broadcast_changes`
|
|
90
|
+
|
|
91
|
+
Para emitir broadcast quando linha de tabela muda (substitui `postgres_changes`):
|
|
92
|
+
|
|
93
|
+
```sql
|
|
94
|
+
-- PT-BR: trigger function emite broadcast no canal scope:entity:id
|
|
95
|
+
create or replace function public.notify_message_insert()
|
|
96
|
+
returns trigger
|
|
97
|
+
language plpgsql
|
|
98
|
+
security invoker
|
|
99
|
+
set search_path = ''
|
|
100
|
+
as $$
|
|
101
|
+
begin
|
|
102
|
+
perform realtime.broadcast_changes(
|
|
103
|
+
'room:messages:' || new.room_id::text, -- canal
|
|
104
|
+
'message_inserted', -- event name
|
|
105
|
+
'INSERT', -- operation
|
|
106
|
+
'messages', -- table
|
|
107
|
+
'public', -- schema
|
|
108
|
+
new, -- new row
|
|
109
|
+
null -- old row
|
|
110
|
+
);
|
|
111
|
+
return new;
|
|
112
|
+
end;
|
|
113
|
+
$$;
|
|
114
|
+
|
|
115
|
+
create trigger messages_broadcast_on_insert
|
|
116
|
+
after insert on public.messages
|
|
117
|
+
for each row
|
|
118
|
+
execute function public.notify_message_insert();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Presence — apenas para online status
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// PT-BR: presence é sparingly — só para "quem está online"
|
|
125
|
+
const channel = supabase
|
|
126
|
+
.channel(`room:${roomId}`, { config: { private: true } })
|
|
127
|
+
.on('presence', { event: 'sync' }, () => {
|
|
128
|
+
const state = channel.presenceState()
|
|
129
|
+
setOnlineUsers(Object.keys(state))
|
|
130
|
+
})
|
|
131
|
+
.subscribe(async (status) => {
|
|
132
|
+
if (status !== 'SUBSCRIBED') return
|
|
133
|
+
await channel.track({ user_id: userId, online_at: new Date().toISOString() })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
supabase.removeChannel(channel)
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Migrar de `postgres_changes` para `broadcast`
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
// ❌ PADRÃO LEGADO — postgres_changes
|
|
145
|
+
const channel = supabase
|
|
146
|
+
.channel('messages_changes')
|
|
147
|
+
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, callback)
|
|
148
|
+
.subscribe()
|
|
149
|
+
|
|
150
|
+
// ✅ PADRÃO ATUAL — broadcast com trigger DB
|
|
151
|
+
// 1. Criar trigger SQL `realtime.broadcast_changes` (ver pattern acima)
|
|
152
|
+
// 2. Subscribe via broadcast no client:
|
|
153
|
+
const channel = supabase
|
|
154
|
+
.channel(`room:messages:${roomId}`, { config: { private: true } })
|
|
155
|
+
.on('broadcast', { event: 'message_inserted' }, callback)
|
|
156
|
+
.subscribe()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Anti-patterns
|
|
160
|
+
|
|
161
|
+
### Anti-pattern 1: Canal sem `private: true`
|
|
162
|
+
|
|
163
|
+
**Errado:**
|
|
164
|
+
```ts
|
|
165
|
+
const channel = supabase.channel('messages') // canal público
|
|
166
|
+
.on('broadcast', { event: 'msg' }, callback)
|
|
167
|
+
.subscribe()
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Por quê:** canal público — qualquer cliente recebe payload sem RLS. Em produção isso vaza dados (broadcast pode incluir info sensível).
|
|
171
|
+
|
|
172
|
+
**Certo:**
|
|
173
|
+
```ts
|
|
174
|
+
const channel = supabase
|
|
175
|
+
.channel(`room:messages:${roomId}`, { config: { private: true } })
|
|
176
|
+
.on('broadcast', { event: 'message_inserted' }, callback)
|
|
177
|
+
.subscribe()
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Anti-pattern 2: Subscribe sem `removeChannel` no cleanup
|
|
181
|
+
|
|
182
|
+
**Errado:**
|
|
183
|
+
```tsx
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
const channel = supabase.channel('...').subscribe()
|
|
186
|
+
// ⚠ sem return — canal nunca limpo
|
|
187
|
+
}, [])
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Por quê:** memory leak. Em SPA com navegação, canais antigos continuam recebendo eventos — UI fica em estado inconsistente. WebSocket connections crescem indefinidamente.
|
|
191
|
+
|
|
192
|
+
**Certo:**
|
|
193
|
+
```tsx
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const channel = supabase.channel('...').subscribe()
|
|
196
|
+
return () => {
|
|
197
|
+
supabase.removeChannel(channel)
|
|
198
|
+
}
|
|
199
|
+
}, [])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Anti-pattern 3: `postgres_changes` em features novas
|
|
203
|
+
|
|
204
|
+
**Errado:**
|
|
205
|
+
```ts
|
|
206
|
+
supabase.channel('changes')
|
|
207
|
+
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, callback)
|
|
208
|
+
.subscribe()
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Por quê:** `postgres_changes` é single-threaded em Realtime backend. Em escala (>100 connections, >1k events/sec), throughput cai drasticamente. Documentado em [Realtime Limits](https://supabase.com/docs/guides/realtime/limits).
|
|
212
|
+
|
|
213
|
+
**Certo:** trigger DB com `realtime.broadcast_changes` + subscribe via `broadcast` (ver pattern "Migrar" acima).
|
|
214
|
+
|
|
215
|
+
### Anti-pattern 4: Presence para listar objetos
|
|
216
|
+
|
|
217
|
+
**Errado:**
|
|
218
|
+
```ts
|
|
219
|
+
// ⚠ usar presence para listar tasks ativas
|
|
220
|
+
channel.on('presence', { event: 'sync' }, () => {
|
|
221
|
+
const tasks = Object.values(channel.presenceState())
|
|
222
|
+
setTasks(tasks)
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Por quê:** Presence é projetado para "quem está online" — state efêmero ligado a connection. Para listas de objetos, use query normal + broadcast quando muda. Presence inflado degrada toda a infraestrutura Realtime do projeto.
|
|
227
|
+
|
|
228
|
+
**Certo:** query SQL para `tasks` + broadcast em mudanças via trigger DB.
|
|
229
|
+
|
|
230
|
+
## Ver também
|
|
231
|
+
|
|
232
|
+
- [supabase-rls-policies](../supabase-rls-policies/SKILL.md) — RLS sobre `realtime.messages` (SELECT + INSERT separados)
|
|
233
|
+
- [supabase-database-functions](../supabase-database-functions/SKILL.md) — trigger functions com `set search_path = ''`
|
|
234
|
+
- [supabase-auth-ssr](../supabase-auth-ssr/SKILL.md) — autenticação que habilita canais `private: true`
|
|
235
|
+
- [supabase-edge-functions](../supabase-edge-functions/SKILL.md) — Edge Functions disparando broadcast via `realtime.send`
|
|
236
|
+
- [glossário](../_shared-supabase/glossary.md) — termos PT-BR↔EN
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-rls-policies
|
|
3
|
+
description: Use ao criar/auditar RLS — sempre (select auth.uid()), policies separadas por operação, índices nas colunas, NUNCA user_metadata em autorização.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Supabase — RLS Policies
|
|
7
|
+
|
|
8
|
+
## Quando usar
|
|
9
|
+
|
|
10
|
+
LLM carrega esta skill quando criar, auditar ou debugar Row Level Security em Supabase. Trigger phrases:
|
|
11
|
+
|
|
12
|
+
- "criar policy RLS", "RLS policy", "row level security"
|
|
13
|
+
- "policies separadas por operação"
|
|
14
|
+
- "auth.uid()", "auth.jwt()"
|
|
15
|
+
- "MFA enforcement", "AAL2"
|
|
16
|
+
- "auditar segurança de tabela Supabase"
|
|
17
|
+
|
|
18
|
+
## Regras absolutas
|
|
19
|
+
|
|
20
|
+
**WARNING — REGRA #1 (segurança crítica):** **NUNCA** referencie `user_metadata` em policy de autorização. `user_metadata` é editável pelo cliente via `auth.updateUser({data: {...}})` — usuário pode auto-elevar `role: 'admin'` ou `plan: 'premium'`. Use **`app_metadata`** (set apenas via service_role) para roles/permissions.
|
|
21
|
+
|
|
22
|
+
**REGRA #2 (performance crítica):** **SEMPRE** envolva `auth.uid()` em `(select auth.uid())`. Sem o wrapper, Postgres reavalia a função **uma vez por linha** — degrada queries com filtro RLS em **até 1000×**.
|
|
23
|
+
|
|
24
|
+
**Outras regras:**
|
|
25
|
+
|
|
26
|
+
- **`policies separadas por operação`** — uma `for select`, uma `for insert`, uma `for update`, uma `for delete`. **Nunca** `for all` cobrindo CRUD inteiro.
|
|
27
|
+
- **`TO authenticated`** ou **`to anon`** sempre explícito — nunca deixar implícito (default `to public` é insecure).
|
|
28
|
+
- `for select` e `for delete` usam **apenas `using`** (sem `with check`).
|
|
29
|
+
- `for insert` usa **apenas `with check`** (sem `using`).
|
|
30
|
+
- `for update` usa **`using` + `with check`** (using para qual linha pode ser atualizada, with check para qual estado a linha pode assumir).
|
|
31
|
+
- Índice obrigatório nas colunas referenciadas pela policy: `create index on public.tasks (user_id);`. Sem index, scan full em cada query.
|
|
32
|
+
- `permissive` é default e preferido. `restrictive` é raro e exige justificativa explícita.
|
|
33
|
+
- Para MFA enforcement: `(auth.jwt()->>'aal')::text = 'aal2'` em policies que exigem 2FA ativo.
|
|
34
|
+
|
|
35
|
+
## Patterns canônicos
|
|
36
|
+
|
|
37
|
+
### SELECT — usuário lê apenas suas próprias linhas
|
|
38
|
+
|
|
39
|
+
```sql
|
|
40
|
+
-- política de SELECT com wrapper (select auth.uid()) obrigatório
|
|
41
|
+
create policy "users_select_own_tasks"
|
|
42
|
+
on public.tasks
|
|
43
|
+
for select
|
|
44
|
+
to authenticated
|
|
45
|
+
using ((select auth.uid()) = user_id);
|
|
46
|
+
|
|
47
|
+
-- index obrigatório (sem isso, scan full)
|
|
48
|
+
create index tasks_user_id_idx on public.tasks (user_id);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### INSERT, UPDATE, DELETE separados
|
|
52
|
+
|
|
53
|
+
```sql
|
|
54
|
+
-- INSERT — usuário só pode criar linhas com user_id = ele mesmo
|
|
55
|
+
create policy "users_insert_own_tasks"
|
|
56
|
+
on public.tasks
|
|
57
|
+
for insert
|
|
58
|
+
to authenticated
|
|
59
|
+
with check ((select auth.uid()) = user_id);
|
|
60
|
+
|
|
61
|
+
-- UPDATE — restringe quais linhas (using) E qual estado novo (with check)
|
|
62
|
+
create policy "users_update_own_tasks"
|
|
63
|
+
on public.tasks
|
|
64
|
+
for update
|
|
65
|
+
to authenticated
|
|
66
|
+
using ((select auth.uid()) = user_id)
|
|
67
|
+
with check ((select auth.uid()) = user_id);
|
|
68
|
+
|
|
69
|
+
-- DELETE — apenas a coluna using (sem with check)
|
|
70
|
+
create policy "users_delete_own_tasks"
|
|
71
|
+
on public.tasks
|
|
72
|
+
for delete
|
|
73
|
+
to authenticated
|
|
74
|
+
using ((select auth.uid()) = user_id);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Role admin via `app_metadata`
|
|
78
|
+
|
|
79
|
+
```sql
|
|
80
|
+
-- segurança: app_metadata é set apenas via service_role (admin API)
|
|
81
|
+
-- cliente NÃO pode mutá-lo
|
|
82
|
+
create policy "admins_manage_all_tasks"
|
|
83
|
+
on public.tasks
|
|
84
|
+
for update
|
|
85
|
+
to authenticated
|
|
86
|
+
using (
|
|
87
|
+
(select auth.jwt()->'app_metadata'->>'role') = 'admin'
|
|
88
|
+
)
|
|
89
|
+
with check (
|
|
90
|
+
(select auth.jwt()->'app_metadata'->>'role') = 'admin'
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### MFA enforcement (AAL2)
|
|
95
|
+
|
|
96
|
+
```sql
|
|
97
|
+
-- exigir 2FA ativo para acessar dados sensíveis
|
|
98
|
+
create policy "mfa_required_for_billing"
|
|
99
|
+
on public.billing_records
|
|
100
|
+
for select
|
|
101
|
+
to authenticated
|
|
102
|
+
using (
|
|
103
|
+
(select (auth.jwt()->>'aal')::text) = 'aal2'
|
|
104
|
+
and (select auth.uid()) = user_id
|
|
105
|
+
);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Anti-patterns
|
|
109
|
+
|
|
110
|
+
### Anti-pattern 1: `auth.uid()` sem `(select)` wrapper
|
|
111
|
+
|
|
112
|
+
**Errado:**
|
|
113
|
+
```sql
|
|
114
|
+
create policy "users_select_own_tasks"
|
|
115
|
+
on public.tasks
|
|
116
|
+
for select
|
|
117
|
+
to authenticated
|
|
118
|
+
using (auth.uid() = user_id); -- sem (select) — re-executa por linha
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Por quê:** Postgres reavalia `auth.uid()` para cada linha sendo testada. Em tabela com 100k linhas, isso é 100k chamadas. O `(select)` permite Postgres executar **uma vez** e reusar — degradação de até **1000×** sem o wrapper. Documentado em [RLS Performance](https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv).
|
|
122
|
+
|
|
123
|
+
**Certo:**
|
|
124
|
+
```sql
|
|
125
|
+
using ((select auth.uid()) = user_id)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Anti-pattern 2: `WARNING user_metadata` em autorização — privilege escalation
|
|
129
|
+
|
|
130
|
+
**Errado:**
|
|
131
|
+
```sql
|
|
132
|
+
create policy "admins_manage_all"
|
|
133
|
+
on public.tasks
|
|
134
|
+
for update
|
|
135
|
+
to authenticated
|
|
136
|
+
using (
|
|
137
|
+
(auth.jwt()->'user_metadata'->>'role') = 'admin' -- editável pelo cliente!
|
|
138
|
+
);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Por quê:** o cliente pode chamar `supabase.auth.updateUser({ data: { role: 'admin' } })` e instantaneamente ganhar privilégios de admin. `user_metadata` é projetado para preferences do usuário (tema, idioma), não para autorização. Documentado em [Splinter linter 0015](https://supabase.github.io/splinter/0015_rls_references_user_metadata/).
|
|
142
|
+
|
|
143
|
+
**Certo:** ver "Role admin via `app_metadata`" acima — `app_metadata` requer service_role para mutar.
|
|
144
|
+
|
|
145
|
+
### Anti-pattern 3: `for all` em vez de policies granulares
|
|
146
|
+
|
|
147
|
+
**Errado:**
|
|
148
|
+
```sql
|
|
149
|
+
create policy "users_manage_own_tasks"
|
|
150
|
+
on public.tasks
|
|
151
|
+
for all -- cobre CRUD inteiro com mesma regra
|
|
152
|
+
to authenticated
|
|
153
|
+
using ((select auth.uid()) = user_id);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Por quê:** semântica de `for all` mistura `using` (que controla SELECT/UPDATE/DELETE) com `with check` (que controla INSERT/UPDATE), levando a confusão. Em UPDATE você pode querer regras diferentes para "qual linha tocar" vs "qual estado novo". Granularidade explícita previne erros sutis.
|
|
157
|
+
|
|
158
|
+
**Certo:** ver pattern com 4 policies separadas acima (SELECT, INSERT, UPDATE, DELETE).
|
|
159
|
+
|
|
160
|
+
### Anti-pattern 4: Sem índice nas colunas da policy
|
|
161
|
+
|
|
162
|
+
**Errado:**
|
|
163
|
+
```sql
|
|
164
|
+
-- policy referencia user_id mas não há index
|
|
165
|
+
create policy "users_select_own_tasks" on public.tasks
|
|
166
|
+
for select to authenticated
|
|
167
|
+
using ((select auth.uid()) = user_id);
|
|
168
|
+
|
|
169
|
+
-- (esqueceu) create index on public.tasks (user_id);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Por quê:** cada query com filtro RLS força sequential scan. Em produção com 100k+ linhas, isso é lentidão crônica.
|
|
173
|
+
|
|
174
|
+
**Certo:**
|
|
175
|
+
```sql
|
|
176
|
+
create index tasks_user_id_idx on public.tasks (user_id);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Ver também
|
|
180
|
+
|
|
181
|
+
- [supabase-database-functions](../supabase-database-functions/SKILL.md) — funções com `set search_path = ''` que respeitam RLS
|
|
182
|
+
- [supabase-storage](../supabase-storage/SKILL.md) — RLS sobre `storage.objects` (multi-tenant path isolation)
|
|
183
|
+
- [supabase-auth-ssr](../supabase-auth-ssr/SKILL.md) — autenticação que popula `auth.uid()`
|
|
184
|
+
- [supabase-migrations](../supabase-migrations/SKILL.md) — migrations sempre com RLS habilitado em novas tabelas
|
|
185
|
+
- [glossário](../_shared-supabase/glossary.md) — termos PT-BR↔EN + roles + comandos CLI
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: supabase-storage
|
|
3
|
+
description: Use ao integrar Storage — buckets públicos vs privados, signed URLs com expiration, RLS sobre storage.objects com multi-tenant path, image transforms, TUS uploads.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Supabase — Storage
|
|
7
|
+
|
|
8
|
+
## Quando usar
|
|
9
|
+
|
|
10
|
+
LLM carrega esta skill quando trabalhar com upload, download, ou serve de arquivos via Supabase Storage. Trigger phrases:
|
|
11
|
+
|
|
12
|
+
- "Supabase Storage", "upload de arquivo"
|
|
13
|
+
- "signed URL", "createSignedUrl"
|
|
14
|
+
- "bucket público vs privado"
|
|
15
|
+
- "RLS storage.objects"
|
|
16
|
+
- "multi-tenant arquivos"
|
|
17
|
+
- "image transforms Supabase"
|
|
18
|
+
- "TUS resumable upload"
|
|
19
|
+
|
|
20
|
+
## Regras absolutas
|
|
21
|
+
|
|
22
|
+
- **Bucket privado é default em produção** — apenas dados públicos (avatares públicos, marketing) vão em buckets públicos.
|
|
23
|
+
- **Bucket público:** URL direta `getPublicUrl()` + servida via CDN (cache).
|
|
24
|
+
- **Bucket privado:** apenas `signed URL` (`createSignedUrl()`) com `expiresIn` curto (60s downloads, 3600s imagens).
|
|
25
|
+
- **`storage.objects`** — RLS sempre habilitada. Sem RLS, qualquer authenticated lê qualquer bucket privado.
|
|
26
|
+
- **`multi-tenant path`** isolation — usar `auth.uid()` (ou `org_id`) como path prefix: `<user_id>/<filename>`. Validar em RLS via `(storage.foldername(name))[1] = (select auth.uid())::text`.
|
|
27
|
+
- **Image transformations** apenas em buckets com transformation enabled (Pro plan+). Query params `?width=800&height=600&resize=contain`.
|
|
28
|
+
- **Uploads grandes (> 6 MB):** use TUS resumable protocol (`uploadToSignedUrl` + chunked upload).
|
|
29
|
+
- **Awareness de egress billing** — bucket público sem cache headers customizados pode disparar custo significativo. Use Smart CDN + TTL adequado.
|
|
30
|
+
- **Não overwrite arquivos públicos** com mesmo nome — CDN cache fica stale. Use versionamento (`avatar-v2.jpg`) ou random suffix.
|
|
31
|
+
|
|
32
|
+
## Patterns canônicos
|
|
33
|
+
|
|
34
|
+
### RLS multi-tenant em `storage.objects`
|
|
35
|
+
|
|
36
|
+
```sql
|
|
37
|
+
-- PT-BR: usuário só vê arquivos sob seu próprio prefix de path
|
|
38
|
+
-- path canônico: <user_id>/<filename> (ex: 550e8400-e29b-41d4-a716-446655440000/avatar.jpg)
|
|
39
|
+
|
|
40
|
+
create policy "users_read_own_files"
|
|
41
|
+
on storage.objects for select to authenticated
|
|
42
|
+
using (
|
|
43
|
+
bucket_id = 'private-uploads'
|
|
44
|
+
and (storage.foldername(name))[1] = (select auth.uid())::text
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
create policy "users_insert_own_files"
|
|
48
|
+
on storage.objects for insert to authenticated
|
|
49
|
+
with check (
|
|
50
|
+
bucket_id = 'private-uploads'
|
|
51
|
+
and (storage.foldername(name))[1] = (select auth.uid())::text
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
create policy "users_update_own_files"
|
|
55
|
+
on storage.objects for update to authenticated
|
|
56
|
+
using (
|
|
57
|
+
bucket_id = 'private-uploads'
|
|
58
|
+
and (storage.foldername(name))[1] = (select auth.uid())::text
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
create policy "users_delete_own_files"
|
|
62
|
+
on storage.objects for delete to authenticated
|
|
63
|
+
using (
|
|
64
|
+
bucket_id = 'private-uploads'
|
|
65
|
+
and (storage.foldername(name))[1] = (select auth.uid())::text
|
|
66
|
+
);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Upload com path multi-tenant
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// PT-BR: cliente — path sempre prefixado com user.id
|
|
73
|
+
import { createClient } from '@/utils/supabase/client'
|
|
74
|
+
|
|
75
|
+
async function uploadAvatar(file: File) {
|
|
76
|
+
const supabase = createClient()
|
|
77
|
+
const { data: { user } } = await supabase.auth.getUser()
|
|
78
|
+
if (!user) throw new Error('not authenticated')
|
|
79
|
+
|
|
80
|
+
// PT-BR: path = <user_id>/<filename>
|
|
81
|
+
const path = `${user.id}/avatar.jpg`
|
|
82
|
+
|
|
83
|
+
const { data, error } = await supabase.storage
|
|
84
|
+
.from('private-uploads')
|
|
85
|
+
.upload(path, file, {
|
|
86
|
+
cacheControl: '3600',
|
|
87
|
+
upsert: true, // PT-BR: sobrescreve se mesmo path
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (error) throw error
|
|
91
|
+
return data
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Signed URL — download privado
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// PT-BR: signed URL com expiração 1h
|
|
99
|
+
const { data, error } = await supabase.storage
|
|
100
|
+
.from('private-uploads')
|
|
101
|
+
.createSignedUrl(`${userId}/avatar.jpg`, 3600)
|
|
102
|
+
|
|
103
|
+
// data.signedUrl pode ser usado em <img src={data.signedUrl}> por 1h
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Image transformations (em bucket com transform habilitado)
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// PT-BR: signed URL com transformação inline
|
|
110
|
+
const { data } = await supabase.storage
|
|
111
|
+
.from('private-uploads')
|
|
112
|
+
.createSignedUrl(`${userId}/avatar.jpg`, 3600, {
|
|
113
|
+
transform: { width: 200, height: 200, resize: 'cover' },
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Public bucket — getPublicUrl + cache headers
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// PT-BR: para bucket PÚBLICO apenas (não funciona em privado)
|
|
121
|
+
const { data } = supabase.storage
|
|
122
|
+
.from('public-avatars')
|
|
123
|
+
.getPublicUrl('hero.jpg')
|
|
124
|
+
|
|
125
|
+
// PT-BR: ao upload, set cacheControl alto para reduzir egress
|
|
126
|
+
await supabase.storage
|
|
127
|
+
.from('public-avatars')
|
|
128
|
+
.upload('hero.jpg', file, {
|
|
129
|
+
cacheControl: '31536000', // 1 ano — assets imutáveis
|
|
130
|
+
upsert: false, // não sobrescrever (versionar via path)
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### TUS resumable upload (arquivos grandes)
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// PT-BR: signed upload URL + TUS chunked upload (>6MB)
|
|
138
|
+
import * as tus from 'npm:tus-js-client'
|
|
139
|
+
|
|
140
|
+
async function uploadLarge(file: File, path: string) {
|
|
141
|
+
const supabase = createClient()
|
|
142
|
+
const { data, error } = await supabase.storage
|
|
143
|
+
.from('private-uploads')
|
|
144
|
+
.createSignedUploadUrl(path)
|
|
145
|
+
if (error) throw error
|
|
146
|
+
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const upload = new tus.Upload(file, {
|
|
149
|
+
endpoint: data.signedUrl,
|
|
150
|
+
headers: { authorization: `Bearer ${data.token}` },
|
|
151
|
+
chunkSize: 6 * 1024 * 1024, // 6 MB chunks
|
|
152
|
+
onError: reject,
|
|
153
|
+
onSuccess: () => resolve(upload.url),
|
|
154
|
+
})
|
|
155
|
+
upload.start()
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Notas de futuro (alpha — não detalhar em produção)
|
|
161
|
+
|
|
162
|
+
- **Vector Buckets** e **Analytics Buckets** existem em alpha (2026). Mencione apenas como existência se relevante; pattern canônico ainda mudando — não detalhar.
|
|
163
|
+
|
|
164
|
+
## Anti-patterns
|
|
165
|
+
|
|
166
|
+
### Anti-pattern 1: Path sem prefix de tenant
|
|
167
|
+
|
|
168
|
+
**Errado:**
|
|
169
|
+
```ts
|
|
170
|
+
await supabase.storage.from('private-uploads').upload('avatar.jpg', file)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Por quê:** path global — qualquer user sobrescreve `avatar.jpg`. RLS multi-tenant não consegue isolar.
|
|
174
|
+
|
|
175
|
+
**Certo:**
|
|
176
|
+
```ts
|
|
177
|
+
const path = `${user.id}/avatar.jpg`
|
|
178
|
+
await supabase.storage.from('private-uploads').upload(path, file)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Anti-pattern 2: Bucket privado sem RLS em `storage.objects`
|
|
182
|
+
|
|
183
|
+
**Errado:**
|
|
184
|
+
```sql
|
|
185
|
+
create bucket 'private-uploads';
|
|
186
|
+
-- (esqueceu policies em storage.objects)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Por quê:** sem RLS em `storage.objects`, qualquer `authenticated` lê arquivos do bucket — multi-tenancy quebrado.
|
|
190
|
+
|
|
191
|
+
**Certo:** ver pattern "RLS multi-tenant" acima — 4 policies separadas (SELECT/INSERT/UPDATE/DELETE).
|
|
192
|
+
|
|
193
|
+
### Anti-pattern 3: `getPublicUrl` em bucket privado
|
|
194
|
+
|
|
195
|
+
**Errado:**
|
|
196
|
+
```ts
|
|
197
|
+
// PT-BR: bucket-id é privado
|
|
198
|
+
const { data } = supabase.storage.from('private-uploads').getPublicUrl('x.jpg')
|
|
199
|
+
// data.publicUrl retorna mas o URL não funciona (403)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Por quê:** `getPublicUrl` só funciona em buckets marcados public. Em privado, retorna URL que sempre dá 403.
|
|
203
|
+
|
|
204
|
+
**Certo:** use `createSignedUrl` com expiration:
|
|
205
|
+
```ts
|
|
206
|
+
const { data } = await supabase.storage
|
|
207
|
+
.from('private-uploads')
|
|
208
|
+
.createSignedUrl('x.jpg', 3600)
|
|
209
|
+
// data.signedUrl funciona por 1h
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Anti-pattern 4: Overwrite de arquivo público com mesmo path
|
|
213
|
+
|
|
214
|
+
**Errado:**
|
|
215
|
+
```ts
|
|
216
|
+
// PT-BR: hero.jpg público
|
|
217
|
+
await supabase.storage.from('public').upload('hero.jpg', newFile, { upsert: true })
|
|
218
|
+
// CDN cache antigo continua servindo old hero.jpg por horas/dias
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Por quê:** CDN cache pelo path. Overwrite não invalida cache; usuários veem versão antiga.
|
|
222
|
+
|
|
223
|
+
**Certo:** versionar ou random suffix:
|
|
224
|
+
```ts
|
|
225
|
+
await supabase.storage.from('public').upload(`hero-${version}.jpg`, newFile)
|
|
226
|
+
// ou: hero-<sha>.jpg, hero-<timestamp>.jpg
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Ver também
|
|
230
|
+
|
|
231
|
+
- [supabase-rls-policies](../supabase-rls-policies/SKILL.md) — RLS sobre `storage.objects` + multi-tenant pattern
|
|
232
|
+
- [supabase-auth-ssr](../supabase-auth-ssr/SKILL.md) — usuário autenticado obtém `auth.uid()` para path prefix
|
|
233
|
+
- [supabase-edge-functions](../supabase-edge-functions/SKILL.md) — Edge Functions podem mediar uploads complexos
|
|
234
|
+
- [glossário](../_shared-supabase/glossary.md) — termos PT-BR↔EN
|