@vettly/supabase 0.1.1 → 0.1.2
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 +364 -48
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,37 +2,60 @@
|
|
|
2
2
|
|
|
3
3
|
Vettly decision infrastructure for Supabase Edge Functions. Deno-compatible client with fetch-based transport for serverless environments.
|
|
4
4
|
|
|
5
|
+
## Why Edge-Native?
|
|
6
|
+
|
|
7
|
+
Supabase Edge Functions run on Deno at the edge. This package provides:
|
|
8
|
+
|
|
9
|
+
- **Deno-compatible** - Pure fetch-based transport, no Node.js dependencies
|
|
10
|
+
- **Edge-optimized** - Minimal cold start, works in Supabase's 2ms startup
|
|
11
|
+
- **Handler utilities** - One-liner Edge Function moderation
|
|
12
|
+
- **Full audit trail** - Every decision recorded with unique ID
|
|
13
|
+
|
|
5
14
|
## Installation
|
|
6
15
|
|
|
16
|
+
### npm (for bundling)
|
|
17
|
+
|
|
7
18
|
```bash
|
|
8
19
|
npm install @vettly/supabase
|
|
9
20
|
```
|
|
10
21
|
|
|
11
|
-
|
|
22
|
+
### Deno (direct import)
|
|
12
23
|
|
|
13
24
|
```typescript
|
|
14
25
|
import { moderate } from 'npm:@vettly/supabase'
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Quick Start - One-Liner
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
The fastest way to add moderation to an Edge Function:
|
|
20
33
|
|
|
21
34
|
```typescript
|
|
35
|
+
// supabase/functions/comments/index.ts
|
|
22
36
|
import { createModerationHandler } from '@vettly/supabase'
|
|
23
37
|
|
|
24
38
|
Deno.serve(createModerationHandler({
|
|
25
|
-
policyId: '
|
|
26
|
-
onBlock: (result) => new Response(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
39
|
+
policyId: 'community-safe',
|
|
40
|
+
onBlock: (result) => new Response(
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
error: 'Content blocked',
|
|
43
|
+
decisionId: result.decisionId,
|
|
44
|
+
categories: result.categories.filter(c => c.triggered)
|
|
45
|
+
}),
|
|
46
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
47
|
+
)
|
|
30
48
|
}))
|
|
31
49
|
```
|
|
32
50
|
|
|
33
|
-
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick Start - Manual Integration
|
|
54
|
+
|
|
55
|
+
For more control:
|
|
34
56
|
|
|
35
57
|
```typescript
|
|
58
|
+
// supabase/functions/posts/index.ts
|
|
36
59
|
import { createClient } from '@vettly/supabase'
|
|
37
60
|
|
|
38
61
|
const vettly = createClient({
|
|
@@ -40,109 +63,402 @@ const vettly = createClient({
|
|
|
40
63
|
})
|
|
41
64
|
|
|
42
65
|
Deno.serve(async (req) => {
|
|
43
|
-
const { content } = await req.json()
|
|
66
|
+
const { content, userId } = await req.json()
|
|
44
67
|
|
|
68
|
+
// Check content
|
|
45
69
|
const result = await vettly.check(content, {
|
|
46
|
-
policyId: 'community-safe'
|
|
70
|
+
policyId: 'community-safe',
|
|
71
|
+
metadata: { userId }
|
|
47
72
|
})
|
|
48
73
|
|
|
49
74
|
if (result.action === 'block') {
|
|
50
|
-
return new Response(
|
|
75
|
+
return new Response(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
error: 'Content blocked',
|
|
78
|
+
decisionId: result.decisionId
|
|
79
|
+
}),
|
|
80
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
81
|
+
)
|
|
51
82
|
}
|
|
52
83
|
|
|
53
|
-
|
|
84
|
+
// Content allowed - proceed with your logic
|
|
85
|
+
// Store result.decisionId for audit trail
|
|
86
|
+
|
|
87
|
+
return new Response(
|
|
88
|
+
JSON.stringify({ success: true, decisionId: result.decisionId }),
|
|
89
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
90
|
+
)
|
|
54
91
|
})
|
|
55
92
|
```
|
|
56
93
|
|
|
57
|
-
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## API Reference
|
|
58
97
|
|
|
59
98
|
### `createClient(config)`
|
|
60
99
|
|
|
61
100
|
Create a configured Vettly client.
|
|
62
101
|
|
|
63
102
|
```typescript
|
|
103
|
+
import { createClient } from '@vettly/supabase'
|
|
104
|
+
|
|
64
105
|
const client = createClient({
|
|
65
|
-
apiKey: '
|
|
66
|
-
apiUrl: 'https://api.vettly.dev'
|
|
106
|
+
apiKey: Deno.env.get('VETTLY_API_KEY')!,
|
|
107
|
+
apiUrl: 'https://api.vettly.dev' // optional
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### `client.check(content, options)`
|
|
112
|
+
|
|
113
|
+
Check text content against a policy.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
const result = await client.check('User-generated text', {
|
|
117
|
+
policyId: 'community-safe',
|
|
118
|
+
metadata: { userId: 'user_123' }
|
|
67
119
|
})
|
|
68
120
|
|
|
69
|
-
//
|
|
70
|
-
|
|
121
|
+
console.log(result.action) // 'allow' | 'warn' | 'flag' | 'block'
|
|
122
|
+
console.log(result.decisionId) // UUID for audit trail
|
|
123
|
+
console.log(result.categories) // Array of { category, score, triggered }
|
|
124
|
+
```
|
|
71
125
|
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
#### `client.checkImage(imageUrl, options)`
|
|
127
|
+
|
|
128
|
+
Check an image against a policy.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// From URL
|
|
132
|
+
const result = await client.checkImage(
|
|
133
|
+
'https://cdn.example.com/image.jpg',
|
|
134
|
+
{ policyId: 'strict' }
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// From base64
|
|
138
|
+
const result = await client.checkImage(
|
|
139
|
+
'data:image/jpeg;base64,/9j/4AAQ...',
|
|
140
|
+
{ policyId: 'strict' }
|
|
141
|
+
)
|
|
74
142
|
```
|
|
75
143
|
|
|
144
|
+
---
|
|
145
|
+
|
|
76
146
|
### `moderate(content, options)`
|
|
77
147
|
|
|
78
|
-
Quick moderation without creating a client
|
|
148
|
+
Quick moderation without creating a client. Uses `VETTLY_API_KEY` environment variable.
|
|
79
149
|
|
|
80
150
|
```typescript
|
|
81
151
|
import { moderate } from '@vettly/supabase'
|
|
82
152
|
|
|
83
|
-
const result = await moderate('
|
|
153
|
+
const result = await moderate('User content', { policyId: 'default' })
|
|
154
|
+
|
|
155
|
+
if (result.action === 'block') {
|
|
156
|
+
// Handle blocked content
|
|
157
|
+
}
|
|
84
158
|
```
|
|
85
159
|
|
|
160
|
+
---
|
|
161
|
+
|
|
86
162
|
### `createModerationHandler(config)`
|
|
87
163
|
|
|
88
|
-
Create an Edge Function handler
|
|
164
|
+
Create an Edge Function handler with built-in moderation.
|
|
89
165
|
|
|
90
166
|
```typescript
|
|
91
|
-
import { createModerationHandler } from '@vettly/supabase
|
|
167
|
+
import { createModerationHandler } from '@vettly/supabase'
|
|
92
168
|
|
|
93
|
-
|
|
169
|
+
Deno.serve(createModerationHandler({
|
|
170
|
+
// Required
|
|
94
171
|
policyId: 'community-safe',
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
172
|
+
|
|
173
|
+
// Optional: field path in JSON body (default: 'content')
|
|
174
|
+
field: 'content',
|
|
175
|
+
|
|
176
|
+
// Optional: custom block response
|
|
177
|
+
onBlock: (result) => new Response(
|
|
178
|
+
JSON.stringify({ error: 'Blocked', decisionId: result.decisionId }),
|
|
179
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
180
|
+
),
|
|
181
|
+
|
|
182
|
+
// Optional: handler for allowed content
|
|
183
|
+
onAllow: async (req, result) => {
|
|
184
|
+
const body = await req.json()
|
|
185
|
+
|
|
186
|
+
// Your business logic here
|
|
187
|
+
// result.decisionId available for audit trail
|
|
188
|
+
|
|
189
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
190
|
+
headers: { 'Content-Type': 'application/json' }
|
|
191
|
+
})
|
|
99
192
|
}
|
|
100
|
-
})
|
|
193
|
+
}))
|
|
101
194
|
```
|
|
102
195
|
|
|
196
|
+
---
|
|
197
|
+
|
|
103
198
|
### `withModeration(handler, config)`
|
|
104
199
|
|
|
105
200
|
Wrap an existing Edge Function with moderation.
|
|
106
201
|
|
|
107
202
|
```typescript
|
|
108
|
-
import { withModeration } from '@vettly/supabase
|
|
203
|
+
import { withModeration } from '@vettly/supabase'
|
|
109
204
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
205
|
+
async function myHandler(req: Request): Promise<Response> {
|
|
206
|
+
const body = await req.json()
|
|
207
|
+
|
|
208
|
+
// Your existing logic
|
|
209
|
+
await db.posts.create({ content: body.content })
|
|
210
|
+
|
|
211
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
212
|
+
headers: { 'Content-Type': 'application/json' }
|
|
213
|
+
})
|
|
113
214
|
}
|
|
114
215
|
|
|
115
|
-
|
|
116
|
-
policyId: '
|
|
117
|
-
field: '
|
|
118
|
-
})
|
|
216
|
+
Deno.serve(withModeration(myHandler, {
|
|
217
|
+
policyId: 'community-safe',
|
|
218
|
+
field: 'content'
|
|
219
|
+
}))
|
|
119
220
|
```
|
|
120
221
|
|
|
222
|
+
---
|
|
223
|
+
|
|
121
224
|
## Response Format
|
|
122
225
|
|
|
226
|
+
All moderation methods return:
|
|
227
|
+
|
|
123
228
|
```typescript
|
|
124
229
|
interface ModerationResult {
|
|
125
|
-
decisionId: string
|
|
126
|
-
safe: boolean
|
|
127
|
-
flagged: boolean
|
|
230
|
+
decisionId: string // UUID for audit trail
|
|
231
|
+
safe: boolean // True if content passes
|
|
232
|
+
flagged: boolean // True if flagged for review
|
|
128
233
|
action: 'allow' | 'warn' | 'flag' | 'block'
|
|
129
234
|
categories: Array<{
|
|
130
|
-
category: string
|
|
131
|
-
score: number
|
|
132
|
-
triggered: boolean
|
|
235
|
+
category: string // e.g., 'hate_speech', 'harassment'
|
|
236
|
+
score: number // 0.0 to 1.0
|
|
237
|
+
triggered: boolean // True if threshold exceeded
|
|
133
238
|
}>
|
|
134
|
-
latency: number
|
|
239
|
+
latency: number // Response time in ms
|
|
135
240
|
}
|
|
136
241
|
```
|
|
137
242
|
|
|
138
|
-
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## Environment Setup
|
|
246
|
+
|
|
247
|
+
### Supabase Dashboard
|
|
248
|
+
|
|
249
|
+
1. Go to your project settings
|
|
250
|
+
2. Navigate to Edge Functions > Secrets
|
|
251
|
+
3. Add `VETTLY_API_KEY` with your API key
|
|
252
|
+
|
|
253
|
+
### Local Development
|
|
254
|
+
|
|
255
|
+
Create `.env.local` in your Supabase project:
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
VETTLY_API_KEY=sk_live_...
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Or set in `supabase/functions/.env`:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
VETTLY_API_KEY=sk_live_...
|
|
265
|
+
```
|
|
139
266
|
|
|
140
|
-
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
## Examples
|
|
270
|
+
|
|
271
|
+
### Comments API
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
// supabase/functions/comments/index.ts
|
|
275
|
+
import { createClient } from '@vettly/supabase'
|
|
276
|
+
import { createClient as createSupabase } from '@supabase/supabase-js'
|
|
277
|
+
|
|
278
|
+
const vettly = createClient({ apiKey: Deno.env.get('VETTLY_API_KEY')! })
|
|
279
|
+
const supabase = createSupabase(
|
|
280
|
+
Deno.env.get('SUPABASE_URL')!,
|
|
281
|
+
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
Deno.serve(async (req) => {
|
|
285
|
+
if (req.method !== 'POST') {
|
|
286
|
+
return new Response('Method not allowed', { status: 405 })
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const { content, postId, userId } = await req.json()
|
|
290
|
+
|
|
291
|
+
// Moderate content
|
|
292
|
+
const result = await vettly.check(content, {
|
|
293
|
+
policyId: 'comments',
|
|
294
|
+
metadata: { postId, userId }
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (result.action === 'block') {
|
|
298
|
+
return new Response(
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
error: 'Comment blocked',
|
|
301
|
+
decisionId: result.decisionId
|
|
302
|
+
}),
|
|
303
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Save comment with audit trail
|
|
308
|
+
const { data, error } = await supabase
|
|
309
|
+
.from('comments')
|
|
310
|
+
.insert({
|
|
311
|
+
content,
|
|
312
|
+
post_id: postId,
|
|
313
|
+
user_id: userId,
|
|
314
|
+
moderation_decision_id: result.decisionId,
|
|
315
|
+
moderation_action: result.action
|
|
316
|
+
})
|
|
317
|
+
.select()
|
|
318
|
+
.single()
|
|
319
|
+
|
|
320
|
+
if (error) {
|
|
321
|
+
return new Response(
|
|
322
|
+
JSON.stringify({ error: error.message }),
|
|
323
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return new Response(
|
|
328
|
+
JSON.stringify(data),
|
|
329
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Image Upload Moderation
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
// supabase/functions/upload-image/index.ts
|
|
338
|
+
import { createClient } from '@vettly/supabase'
|
|
339
|
+
|
|
340
|
+
const vettly = createClient({ apiKey: Deno.env.get('VETTLY_API_KEY')! })
|
|
341
|
+
|
|
342
|
+
Deno.serve(async (req) => {
|
|
343
|
+
const formData = await req.formData()
|
|
344
|
+
const file = formData.get('file') as File
|
|
345
|
+
|
|
346
|
+
// Convert to base64 for moderation
|
|
347
|
+
const buffer = await file.arrayBuffer()
|
|
348
|
+
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
|
349
|
+
const dataUri = `data:${file.type};base64,${base64}`
|
|
350
|
+
|
|
351
|
+
// Check image
|
|
352
|
+
const result = await vettly.checkImage(dataUri, { policyId: 'images' })
|
|
353
|
+
|
|
354
|
+
if (result.action === 'block') {
|
|
355
|
+
return new Response(
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
error: 'Image rejected',
|
|
358
|
+
decisionId: result.decisionId,
|
|
359
|
+
categories: result.categories.filter(c => c.triggered)
|
|
360
|
+
}),
|
|
361
|
+
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Proceed with upload...
|
|
366
|
+
// Store result.decisionId with the image record
|
|
367
|
+
|
|
368
|
+
return new Response(
|
|
369
|
+
JSON.stringify({ success: true, decisionId: result.decisionId }),
|
|
370
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
371
|
+
)
|
|
372
|
+
})
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Webhook Handler
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
// supabase/functions/moderation-webhook/index.ts
|
|
379
|
+
Deno.serve(async (req) => {
|
|
380
|
+
const signature = req.headers.get('x-vettly-signature')
|
|
381
|
+
const payload = await req.text()
|
|
382
|
+
|
|
383
|
+
// Verify webhook signature
|
|
384
|
+
// (implement verification as shown in main SDK docs)
|
|
385
|
+
|
|
386
|
+
const event = JSON.parse(payload)
|
|
387
|
+
|
|
388
|
+
switch (event.type) {
|
|
389
|
+
case 'decision.blocked':
|
|
390
|
+
// Notify moderators
|
|
391
|
+
await notifySlack(`Content blocked: ${event.data.decisionId}`)
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
case 'decision.flagged':
|
|
395
|
+
// Add to review queue
|
|
396
|
+
await addToReviewQueue(event.data)
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return new Response('OK')
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Error Handling
|
|
407
|
+
|
|
408
|
+
```typescript
|
|
409
|
+
import { createClient } from '@vettly/supabase'
|
|
410
|
+
|
|
411
|
+
const vettly = createClient({ apiKey: Deno.env.get('VETTLY_API_KEY')! })
|
|
412
|
+
|
|
413
|
+
Deno.serve(async (req) => {
|
|
414
|
+
try {
|
|
415
|
+
const { content } = await req.json()
|
|
416
|
+
const result = await vettly.check(content, { policyId: 'default' })
|
|
417
|
+
|
|
418
|
+
return new Response(JSON.stringify(result), {
|
|
419
|
+
headers: { 'Content-Type': 'application/json' }
|
|
420
|
+
})
|
|
421
|
+
} catch (error) {
|
|
422
|
+
// Log error but fail open (allow content through)
|
|
423
|
+
console.error('Moderation error:', error)
|
|
424
|
+
|
|
425
|
+
return new Response(
|
|
426
|
+
JSON.stringify({ warning: 'Moderation unavailable', allowed: true }),
|
|
427
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## TypeScript Support
|
|
436
|
+
|
|
437
|
+
Full TypeScript support with Deno:
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
import { createClient, type ModerationResult } from '@vettly/supabase'
|
|
441
|
+
|
|
442
|
+
const vettly = createClient({ apiKey: Deno.env.get('VETTLY_API_KEY')! })
|
|
443
|
+
|
|
444
|
+
Deno.serve(async (req: Request): Promise<Response> => {
|
|
445
|
+
const { content }: { content: string } = await req.json()
|
|
446
|
+
|
|
447
|
+
const result: ModerationResult = await vettly.check(content, {
|
|
448
|
+
policyId: 'community-safe'
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
return new Response(JSON.stringify(result), {
|
|
452
|
+
headers: { 'Content-Type': 'application/json' }
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
```
|
|
141
456
|
|
|
142
|
-
|
|
457
|
+
---
|
|
143
458
|
|
|
144
459
|
## Links
|
|
145
460
|
|
|
146
461
|
- [vettly.dev](https://vettly.dev) - Sign up
|
|
147
462
|
- [docs.vettly.dev](https://docs.vettly.dev) - Documentation
|
|
148
463
|
- [Supabase Edge Functions](https://supabase.com/docs/guides/functions) - Supabase docs
|
|
464
|
+
- [@vettly/sdk](https://www.npmjs.com/package/@vettly/sdk) - Core SDK
|
package/package.json
CHANGED