@zuzjs/flare-admin 0.1.5 → 0.1.7
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 +316 -301
- package/dist/db/Collection.d.ts +27 -1
- package/dist/index.cjs +4 -4
- package/dist/index.d.cts +163 -51
- package/dist/index.d.ts +38 -10
- package/dist/index.js +3 -3
- package/dist/lib/grpc.d.ts +1 -1
- package/dist/lib/http.d.ts +3 -0
- package/dist/lib/notifications.d.ts +1 -1
- package/dist/realtime/Connection.d.ts +14 -0
- package/dist/realtime/LiveCollection.d.ts +3 -1
- package/dist/realtime/WsConnection.d.ts +11 -1
- package/dist/types/index.d.ts +75 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ admin.auth()
|
|
|
22
22
|
socket elevated ✓
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
**No MongoDB URI is ever shared.** The SDK talks to FlareServer's `/admin/token` REST endpoint using only the `adminKey`.
|
|
25
|
+
**No MongoDB URI is ever shared.** The SDK talks to FlareServer's `/admin/token` REST endpoint using only the `adminKey`.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -44,8 +44,6 @@ pnpm add @zuzjs/flare-admin
|
|
|
44
44
|
flare app create my-app
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
This prints two configs:
|
|
48
|
-
|
|
49
47
|
| Config | Safe for... |
|
|
50
48
|
|---|---|
|
|
51
49
|
| `apiKey` | Browser / client-side |
|
|
@@ -55,7 +53,7 @@ This prints two configs:
|
|
|
55
53
|
|
|
56
54
|
```bash
|
|
57
55
|
# .env (server-side only)
|
|
58
|
-
FLARE_URL=
|
|
56
|
+
FLARE_URL=https://flare.zuzcdn.net
|
|
59
57
|
FLARE_APP_ID=my-app
|
|
60
58
|
FLARE_ADMIN_KEY=FA_ADMIN_xxxxxxxxxxxx
|
|
61
59
|
```
|
|
@@ -78,14 +76,9 @@ const admin = connectApp({
|
|
|
78
76
|
import { getApp } from "@zuzjs/flare-admin";
|
|
79
77
|
|
|
80
78
|
app.post("/login", async (req, res) => {
|
|
81
|
-
// --- your existing auth logic ---
|
|
82
79
|
const user = await myAuthLogic(req.body);
|
|
83
80
|
if (!user) return res.status(401).json({ error: "invalid credentials" });
|
|
84
81
|
|
|
85
|
-
// Set your own session / cookie as normal
|
|
86
|
-
req.session.userId = user.id;
|
|
87
|
-
|
|
88
|
-
// --- the bridge ---
|
|
89
82
|
const flareToken = await getApp().auth().createCustomToken(user.id, {
|
|
90
83
|
role: user.isAdmin ? "admin" : "user",
|
|
91
84
|
claims: { email: user.email, plan: user.plan },
|
|
@@ -104,12 +97,11 @@ import FlareClient from "@zuzjs/flare";
|
|
|
104
97
|
const flare = new FlareClient({
|
|
105
98
|
endpoint: "https://flare.zuzcdn.net",
|
|
106
99
|
appId: "my-app",
|
|
107
|
-
apiKey: "FA_xxxxxxxx",
|
|
100
|
+
apiKey: "FA_xxxxxxxx",
|
|
108
101
|
});
|
|
109
102
|
|
|
110
103
|
flare.connect();
|
|
111
104
|
|
|
112
|
-
// After login:
|
|
113
105
|
const { flareToken } = await fetch("/login", { method: "POST", body: ... }).then(r => r.json());
|
|
114
106
|
await flare.auth(flareToken);
|
|
115
107
|
// ✅ Socket is now elevated — auth.uid and auth.role are set
|
|
@@ -125,51 +117,80 @@ Initialize a FlareAdmin app. Idempotent — safe to call at module scope.
|
|
|
125
117
|
|
|
126
118
|
```typescript
|
|
127
119
|
const admin = connectApp({
|
|
128
|
-
serverUrl:
|
|
129
|
-
appId:
|
|
130
|
-
adminKey:
|
|
131
|
-
|
|
120
|
+
serverUrl: string; // FlareServer base URL
|
|
121
|
+
appId: string; // App ID from `flare app create`
|
|
122
|
+
adminKey: string; // Admin key — server-side only
|
|
123
|
+
httpBase?: string; // Override base URL for all admin HTTP APIs (e.g. proxy)
|
|
124
|
+
defaultTtl?: string; // Default token TTL, e.g. "24h" (default)
|
|
125
|
+
dataMapper?: Record<string, (row: any) => any>; // Per-collection response mappers
|
|
132
126
|
});
|
|
133
127
|
```
|
|
134
128
|
|
|
129
|
+
`httpBase` routes all admin HTTP traffic through a different base URL (useful for proxies or gateways). When set, it replaces `serverUrl` as the base for every `/admin/*` HTTP request. Trailing slashes are normalized automatically.
|
|
130
|
+
|
|
135
131
|
### `getApp(name?)`
|
|
136
132
|
|
|
137
|
-
Retrieve an already-initialized app instance.
|
|
133
|
+
Retrieve an already-initialized app instance. Throws if not initialized.
|
|
134
|
+
|
|
135
|
+
### `disconnectApp(name?)`
|
|
136
|
+
|
|
137
|
+
Disconnect and remove an app from the registry. Returns `true` if the app existed.
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
disconnectApp(); // default app
|
|
141
|
+
disconnectApp("app-a"); // named app
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### `disconnectAllApps()`
|
|
145
|
+
|
|
146
|
+
Disconnect and clear every initialized app.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
disconnectAllApps();
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `app.disconnect()`
|
|
138
153
|
|
|
139
|
-
|
|
154
|
+
Close the WebSocket connection on a specific app instance. Safe to call multiple times.
|
|
140
155
|
|
|
141
|
-
|
|
156
|
+
```typescript
|
|
157
|
+
admin.disconnect();
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Auth
|
|
142
163
|
|
|
143
164
|
### `admin.auth().createCustomToken(uid, opts?)`
|
|
144
165
|
|
|
145
|
-
Mint a custom auth token.
|
|
166
|
+
Mint a custom auth token for use by the browser client.
|
|
146
167
|
|
|
147
168
|
```typescript
|
|
148
169
|
const token = await admin.auth().createCustomToken(uid, {
|
|
149
170
|
role?: "user" | "admin" | "anon", // default: "user"
|
|
150
|
-
claims?: Record<string, unknown>,
|
|
171
|
+
claims?: Record<string, unknown>,
|
|
151
172
|
ttl?: string, // e.g. "1h", "7d"
|
|
152
173
|
});
|
|
153
174
|
```
|
|
154
175
|
|
|
155
176
|
### `admin.auth().getTicket(uid, opts?)`
|
|
156
177
|
|
|
157
|
-
Mint a one-time ticket
|
|
178
|
+
Mint a one-time ticket for WebSocket auth flows.
|
|
158
179
|
|
|
159
180
|
```typescript
|
|
160
181
|
const ticket = await admin.auth().getTicket(uid, {
|
|
161
|
-
role?:
|
|
162
|
-
email?:
|
|
163
|
-
sid?:
|
|
164
|
-
ttlSeconds?: number,
|
|
165
|
-
ip?:
|
|
182
|
+
role?: "user" | "admin" | "anon",
|
|
183
|
+
email?: string,
|
|
184
|
+
sid?: string,
|
|
185
|
+
ttlSeconds?: number,
|
|
186
|
+
ip?: string,
|
|
166
187
|
});
|
|
167
188
|
|
|
168
|
-
// ticket shape
|
|
189
|
+
// ticket shape:
|
|
169
190
|
// {
|
|
170
|
-
// ticket: "websocket:550e8400
|
|
191
|
+
// ticket: "websocket:550e8400-...",
|
|
171
192
|
// tag: "websocket",
|
|
172
|
-
// uuid: "550e8400
|
|
193
|
+
// uuid: "550e8400-...",
|
|
173
194
|
// expires_at: "2026-04-15T12:34:56Z",
|
|
174
195
|
// one_time: true,
|
|
175
196
|
// uid: "user_123",
|
|
@@ -178,75 +199,105 @@ const ticket = await admin.auth().getTicket(uid, {
|
|
|
178
199
|
// }
|
|
179
200
|
```
|
|
180
201
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
```http
|
|
184
|
-
POST /admin/ticket
|
|
185
|
-
Authorization: Bearer FA_ADMIN_xxxxxxxxxxxx
|
|
186
|
-
Content-Type: application/json
|
|
202
|
+
---
|
|
187
203
|
|
|
188
|
-
|
|
189
|
-
"appId": "my-app",
|
|
190
|
-
"uid": "user_123",
|
|
191
|
-
"role": "user",
|
|
192
|
-
"ttlSeconds": 300
|
|
193
|
-
}
|
|
194
|
-
```
|
|
204
|
+
## Database
|
|
195
205
|
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
206
|
+
```typescript
|
|
207
|
+
// One-shot queries (bypasses security rules)
|
|
208
|
+
const users = await admin.db().collection("users").get();
|
|
209
|
+
|
|
210
|
+
await admin.db().collection("users").doc("alice").set({ name: "Alice" });
|
|
211
|
+
await admin.db().collection("users").doc("alice").update({ plan: "pro" });
|
|
212
|
+
await admin.db().collection("users").doc("alice").delete();
|
|
213
|
+
|
|
214
|
+
// Rich queries
|
|
215
|
+
const seniors = await admin.db()
|
|
216
|
+
.collection("users")
|
|
217
|
+
.where({ age: ">= 60" })
|
|
218
|
+
.orderBy("name")
|
|
219
|
+
.limit(10)
|
|
220
|
+
.get();
|
|
209
221
|
```
|
|
210
222
|
|
|
211
|
-
|
|
223
|
+
### Query Builder
|
|
212
224
|
|
|
213
|
-
|
|
225
|
+
`admin.db().collection(...)` supports the full structured query API:
|
|
214
226
|
|
|
215
|
-
|
|
227
|
+
**Filters:** `where`, `and`, `or`, `in`, `andIn`, `orIn`, `notIn`, `andNotIn`, `orNotIn`, `arrayContains`, `andArrayContains`, `orArrayContains`, `arrayContainsAny`, `andArrayContainsAny`, `orArrayContainsAny`, `some`, `andSome`, `orSome`, `like`, `andLike`, `orLike`, `notLike`, `andNotLike`, `orNotLike`, `exists`, `andExists`, `orExists`, `notExists`, `andNotExists`, `orNotExists`
|
|
216
228
|
|
|
217
|
-
|
|
218
|
-
auth.uid = the uid you passed to createCustomToken()
|
|
219
|
-
auth.role = "user" | "admin" | "anon"
|
|
220
|
-
```
|
|
229
|
+
**Sort / cursor / aggregate:** `latest`, `newest`, `oldest`, `orderBy`, `limit`, `offset`, `startAt`, `startAfter`, `endAt`, `endBefore`, `count`, `sum`, `avg`, `min`, `max`, `distinct`, `groupBy`, `having`, `select`, `distinctField`, `vectorSearch`
|
|
221
230
|
|
|
222
|
-
|
|
231
|
+
**Joins:** `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
|
|
223
232
|
|
|
224
|
-
```
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
}
|
|
233
|
+
```typescript
|
|
234
|
+
const rows = await admin.db()
|
|
235
|
+
.collection("boards")
|
|
236
|
+
.where({ uid: userId })
|
|
237
|
+
.orSome("team", { uid: userId })
|
|
238
|
+
.join("lists", { source: "id", target: "boardId", as: "lists" })
|
|
239
|
+
.join("users", { source: "team.uid", target: "id", as: "teamMembers" })
|
|
240
|
+
.joinNested("lists", "cards", { source: "id", target: "listId", as: "cards" })
|
|
241
|
+
.withRelation("team.uid->users.id as collaborators")
|
|
242
|
+
.orderBy("updatedAt", "desc")
|
|
243
|
+
.limit(20)
|
|
244
|
+
.get();
|
|
231
245
|
```
|
|
232
246
|
|
|
233
|
-
|
|
247
|
+
### Bulk Writes (Memory Efficient)
|
|
234
248
|
|
|
235
|
-
|
|
249
|
+
`addMany`, `updateMany`, and id-based `deleteMany` run in bounded chunks so large datasets can be processed with controlled memory usage.
|
|
236
250
|
|
|
237
251
|
```typescript
|
|
238
|
-
const
|
|
239
|
-
|
|
252
|
+
const users = admin.db().collection<{ name: string; plan?: string }>("users");
|
|
253
|
+
|
|
254
|
+
const addResult = await users.addMany(
|
|
255
|
+
[{ name: "Alice" }, { name: "Bob" }],
|
|
256
|
+
{
|
|
257
|
+
batchSize: 500,
|
|
258
|
+
concurrency: 8,
|
|
259
|
+
onProgress: (p) => {
|
|
260
|
+
console.log("addMany", p.processed, p.total, p.percent);
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const updateResult = await users.updateMany(
|
|
266
|
+
[
|
|
267
|
+
{ id: "user_1", data: { plan: "pro" } },
|
|
268
|
+
{ id: "user_2", data: { plan: "team" } },
|
|
269
|
+
],
|
|
270
|
+
{ continueOnError: true },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Delete specific ids with progress
|
|
274
|
+
const deleteByIdsResult = await users.deleteMany(["user_3", "user_4"], {
|
|
275
|
+
onProgress: (p) => console.log("deleteMany(ids)", p.processed, p.total),
|
|
276
|
+
});
|
|
240
277
|
|
|
241
|
-
|
|
242
|
-
const
|
|
278
|
+
// Existing query-based deleteMany remains available
|
|
279
|
+
const deletedByQueryCount = await admin.db()
|
|
280
|
+
.collection("users")
|
|
281
|
+
.where({ plan: "free" })
|
|
282
|
+
.deleteMany();
|
|
283
|
+
|
|
284
|
+
console.log({ addResult, updateResult, deleteByIdsResult, deletedByQueryCount });
|
|
243
285
|
```
|
|
244
286
|
|
|
245
|
-
|
|
287
|
+
```typescript
|
|
288
|
+
// Stream input from an async source (best for huge datasets)
|
|
289
|
+
async function* rows() {
|
|
290
|
+
for (let i = 0; i < 1_000_000; i += 1) {
|
|
291
|
+
yield { name: `user-${i}` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await admin.db().collection("users").addMany(rows(), { batchSize: 1000, concurrency: 4 });
|
|
296
|
+
```
|
|
246
297
|
|
|
247
|
-
|
|
298
|
+
### `allowSensitiveAuthUserFields(false)`
|
|
248
299
|
|
|
249
|
-
|
|
300
|
+
Auth-user joins default to full fields. Pass `false` to restrict to public-profile-only output:
|
|
250
301
|
|
|
251
302
|
```typescript
|
|
252
303
|
const safeBoards = await admin.db()
|
|
@@ -256,195 +307,236 @@ const safeBoards = await admin.db()
|
|
|
256
307
|
.get();
|
|
257
308
|
```
|
|
258
309
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
310
|
+
### `getRawQuery()`
|
|
311
|
+
|
|
312
|
+
Inspect the structured query that will be sent:
|
|
313
|
+
|
|
314
|
+
```typescript
|
|
315
|
+
const raw = admin.db()
|
|
316
|
+
.collection("boards")
|
|
317
|
+
.where({ uid: userId })
|
|
318
|
+
.getRawQuery();
|
|
319
|
+
|
|
320
|
+
console.log(raw.collection, raw.query);
|
|
321
|
+
```
|
|
262
322
|
|
|
263
|
-
|
|
323
|
+
---
|
|
264
324
|
|
|
265
|
-
|
|
325
|
+
## Realtime
|
|
266
326
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
-
|
|
327
|
+
### `admin.live().collection(...).onSnapshot(cb)`
|
|
328
|
+
|
|
329
|
+
Single-snapshot subscription — fires once on the initial data load, then auto-unsubscribes:
|
|
270
330
|
|
|
271
331
|
```typescript
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
332
|
+
const unsub = admin.live()
|
|
333
|
+
.collection("orders")
|
|
334
|
+
.where({ status: "pending" })
|
|
335
|
+
.orderBy("createdAt", "desc")
|
|
336
|
+
.onSnapshot((snap) => {
|
|
337
|
+
console.log(snap.type, snap.data);
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### `.stream(options?)` — live batched stream
|
|
342
|
+
|
|
343
|
+
Returns a long-lived `AdminCollectionStream<T>` that maintains a local snapshot and fans out batched change events to listeners.
|
|
344
|
+
|
|
345
|
+
```typescript
|
|
346
|
+
const stream = admin.live()
|
|
347
|
+
.collection("orders")
|
|
348
|
+
.where({ status: "pending" })
|
|
349
|
+
.stream({
|
|
350
|
+
flushMs?: number, // batch flush delay in ms (default: 24)
|
|
351
|
+
maxBatchSize?: number, // max changes per flush (default: 200)
|
|
352
|
+
insertAt?: "start" | "end", // where new docs land (default: "end")
|
|
353
|
+
maxDocs?: number, // cap the local snapshot size
|
|
354
|
+
sort?: (a: T, b: T) => number, // comparator applied after each flush
|
|
355
|
+
idField?: string, // identity field name (default: "id")
|
|
356
|
+
getId?: (doc: T) => string, // custom id extractor
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Subscribe to changes
|
|
360
|
+
const off = stream.listen((docs, meta) => {
|
|
361
|
+
console.log(meta.reason, meta.version, docs.length);
|
|
362
|
+
// meta.reason: "snapshot" | "change-batch"
|
|
363
|
+
// meta.ready: true after the first snapshot arrives
|
|
290
364
|
});
|
|
291
365
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
.where({ boardId: "123" })
|
|
295
|
-
.join("users", {
|
|
296
|
-
source: "team.uid",
|
|
297
|
-
target: "uid",
|
|
298
|
-
as: "team",
|
|
299
|
-
})
|
|
300
|
-
.get();
|
|
366
|
+
// Read the current snapshot without subscribing
|
|
367
|
+
const current = stream.getSnapshot();
|
|
301
368
|
|
|
302
|
-
//
|
|
303
|
-
|
|
369
|
+
// Remove a specific listener (does not close the stream)
|
|
370
|
+
off();
|
|
371
|
+
|
|
372
|
+
// Close the stream and release the WebSocket subscription
|
|
373
|
+
stream.close();
|
|
304
374
|
```
|
|
305
375
|
|
|
306
|
-
|
|
376
|
+
### `.asStore(options?)` — framework-agnostic external store
|
|
307
377
|
|
|
308
|
-
|
|
378
|
+
Returns an `AdminCollectionExternalStore<T>` compatible with React `useSyncExternalStore` or any subscribe/getSnapshot pattern:
|
|
309
379
|
|
|
310
|
-
|
|
380
|
+
```typescript
|
|
381
|
+
const store = admin.live()
|
|
382
|
+
.collection("orders")
|
|
383
|
+
.asStore({ flushMs: 50 });
|
|
311
384
|
|
|
312
|
-
|
|
385
|
+
// React
|
|
386
|
+
import { useSyncExternalStore } from "react";
|
|
387
|
+
const orders = useSyncExternalStore(store.subscribe, store.getSnapshot);
|
|
313
388
|
|
|
314
|
-
|
|
389
|
+
// Or directly
|
|
390
|
+
const unsub = store.subscribe(() => {
|
|
391
|
+
console.log(store.getSnapshot());
|
|
392
|
+
});
|
|
315
393
|
|
|
316
|
-
|
|
394
|
+
store.close(); // release when done
|
|
395
|
+
```
|
|
317
396
|
|
|
318
|
-
###
|
|
397
|
+
### Realtime query builder parity
|
|
319
398
|
|
|
320
|
-
|
|
399
|
+
The same query builder surface (`where`, `orderBy`, `join`, `limit`, etc.) is available on `admin.live().collection(...)` just like on `admin.db().collection(...)`.
|
|
321
400
|
|
|
322
|
-
|
|
401
|
+
---
|
|
323
402
|
|
|
324
|
-
|
|
403
|
+
## Security rules
|
|
325
404
|
|
|
326
|
-
|
|
405
|
+
Once a client calls `flare.auth(token)`, the FlareServer socket has:
|
|
327
406
|
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.
|
|
337
|
-
.
|
|
338
|
-
.
|
|
339
|
-
|
|
407
|
+
```
|
|
408
|
+
auth.uid = the uid you passed to createCustomToken()
|
|
409
|
+
auth.role = "user" | "admin" | "anon"
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
```json
|
|
413
|
+
{
|
|
414
|
+
"users": { ".read": "auth != null", ".write": "auth.uid == $docId" },
|
|
415
|
+
"posts": { ".read": "true", ".write": "auth != null" },
|
|
416
|
+
"settings": { ".read": "auth != null", ".write": "auth.role == 'admin'" },
|
|
417
|
+
"*": { ".read": "false", ".write": "false" }
|
|
418
|
+
}
|
|
340
419
|
```
|
|
341
420
|
|
|
342
|
-
|
|
421
|
+
---
|
|
343
422
|
|
|
344
|
-
|
|
423
|
+
## Multi-tenant / multiple apps
|
|
345
424
|
|
|
346
425
|
```typescript
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
426
|
+
const adminA = connectApp({ serverUrl, appId: "app-a", adminKey: "..." }, "a");
|
|
427
|
+
const adminB = connectApp({ serverUrl, appId: "app-b", adminKey: "..." }, "b");
|
|
428
|
+
|
|
429
|
+
const tokenA = await getApp("a").auth().createCustomToken(userId);
|
|
430
|
+
const tokenB = await getApp("b").auth().createCustomToken(userId);
|
|
431
|
+
|
|
432
|
+
disconnectAllApps();
|
|
354
433
|
```
|
|
355
434
|
|
|
356
|
-
|
|
435
|
+
---
|
|
357
436
|
|
|
358
|
-
|
|
437
|
+
## Data Mapper
|
|
359
438
|
|
|
360
|
-
|
|
361
|
-
const raw = admin.db()
|
|
362
|
-
.collection("boards")
|
|
363
|
-
.where({ uid: userId })
|
|
364
|
-
.withRelation("team.uid->users.id as collaborators")
|
|
365
|
-
.getRawQuery();
|
|
439
|
+
Pass `dataMapper` in `connectApp(...)` to shape inbound data. Keys match collection names or join aliases (`as`).
|
|
366
440
|
|
|
367
|
-
|
|
441
|
+
```typescript
|
|
442
|
+
const admin = connectApp({
|
|
443
|
+
serverUrl: process.env.FLARE_URL!,
|
|
444
|
+
appId: process.env.FLARE_APP_ID!,
|
|
445
|
+
adminKey: process.env.FLARE_ADMIN_KEY!,
|
|
446
|
+
dataMapper: {
|
|
447
|
+
boards: (row) => ({
|
|
448
|
+
id: row.id,
|
|
449
|
+
name: row.name,
|
|
450
|
+
createdAt: new Date(row.createdAt ?? row.created_at),
|
|
451
|
+
}),
|
|
452
|
+
team: (row) => ({
|
|
453
|
+
id: row.id,
|
|
454
|
+
name: row.authMeta?.additionalParams?.name || "Unknown",
|
|
455
|
+
email: row.email,
|
|
456
|
+
}),
|
|
457
|
+
},
|
|
458
|
+
});
|
|
368
459
|
```
|
|
369
460
|
|
|
461
|
+
For `join(..., { as: "team" })`, define `dataMapper.team`.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
370
465
|
## Storage API (S3-like)
|
|
371
466
|
|
|
372
467
|
```typescript
|
|
373
468
|
import { connectApp, AdminStorageSignedAction } from "@zuzjs/flare-admin";
|
|
374
469
|
|
|
375
|
-
const admin = connectApp({
|
|
376
|
-
serverUrl: process.env.FLARE_URL!,
|
|
377
|
-
appId: process.env.FLARE_APP_ID!,
|
|
378
|
-
adminKey: process.env.FLARE_ADMIN_KEY!,
|
|
379
|
-
});
|
|
380
|
-
|
|
381
470
|
const storage = admin.storage();
|
|
382
471
|
|
|
383
472
|
await storage.createBucket("reports");
|
|
384
473
|
|
|
385
474
|
await storage.putObject({
|
|
386
|
-
bucket:
|
|
387
|
-
key:
|
|
388
|
-
body:
|
|
475
|
+
bucket: "reports",
|
|
476
|
+
key: "weekly/summary.json",
|
|
477
|
+
body: Buffer.from(JSON.stringify({ ok: true })),
|
|
389
478
|
contentType: "application/json",
|
|
390
|
-
encrypt:
|
|
479
|
+
encrypt: true,
|
|
391
480
|
});
|
|
392
481
|
|
|
393
|
-
const meta
|
|
394
|
-
|
|
395
|
-
key: "weekly/summary.json",
|
|
396
|
-
});
|
|
482
|
+
const meta = await storage.headObject({ bucket: "reports", key: "weekly/summary.json" });
|
|
483
|
+
const downloaded = await storage.getObject({ bucket: "reports", key: "weekly/summary.json", decrypt: true });
|
|
397
484
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
key: "weekly/summary.json",
|
|
401
|
-
decrypt: true,
|
|
402
|
-
});
|
|
485
|
+
await storage.deleteObjects({ bucket: "reports", keys: ["weekly/summary.json"] });
|
|
486
|
+
```
|
|
403
487
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
488
|
+
### Signed URLs
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
const signedUpload = await admin.createSignedUrl({
|
|
492
|
+
bucket: "reports",
|
|
493
|
+
key: "uploads/big-video.mp4",
|
|
494
|
+
action: AdminStorageSignedAction.Upload,
|
|
495
|
+
expiresInSeconds: 300,
|
|
496
|
+
contentType: "video/mp4",
|
|
497
|
+
encrypt: true,
|
|
412
498
|
});
|
|
413
499
|
|
|
414
|
-
await
|
|
415
|
-
|
|
416
|
-
|
|
500
|
+
await fetch(signedUpload.url, {
|
|
501
|
+
method: signedUpload.method,
|
|
502
|
+
headers: { "Content-Type": "video/mp4" },
|
|
503
|
+
body: videoBuffer,
|
|
417
504
|
});
|
|
418
505
|
|
|
419
|
-
|
|
506
|
+
const signedDownload = await admin.createSignedUrl({
|
|
507
|
+
bucket: "reports",
|
|
508
|
+
key: "uploads/big-video.mp4",
|
|
509
|
+
action: AdminStorageSignedAction.Download,
|
|
510
|
+
expiresInSeconds: 300,
|
|
511
|
+
decrypt: true,
|
|
512
|
+
allowedOrigins: ["https://app.example.com"],
|
|
513
|
+
});
|
|
420
514
|
```
|
|
421
515
|
|
|
422
|
-
|
|
516
|
+
Notes:
|
|
517
|
+
- `forceDownload` and `embedOnly` are mutually exclusive.
|
|
518
|
+
- `allowedOrigins` defaults to `['*']` if omitted.
|
|
519
|
+
|
|
520
|
+
### Direct download helpers
|
|
423
521
|
|
|
424
522
|
```typescript
|
|
425
523
|
const directUrl = await admin.getObjectUrl({
|
|
426
|
-
bucket:
|
|
427
|
-
key:
|
|
524
|
+
bucket: "reports",
|
|
525
|
+
key: "weekly/summary.json",
|
|
428
526
|
expiresInSeconds: 120,
|
|
429
|
-
allowedOrigins:
|
|
527
|
+
allowedOrigins: ["*"],
|
|
430
528
|
});
|
|
431
529
|
|
|
432
530
|
const triggered = await admin.downloadObject({
|
|
433
|
-
bucket:
|
|
434
|
-
key:
|
|
435
|
-
filename:
|
|
436
|
-
forceDownload:
|
|
531
|
+
bucket: "reports",
|
|
532
|
+
key: "weekly/summary.json",
|
|
533
|
+
filename: "summary.json",
|
|
534
|
+
forceDownload: true,
|
|
437
535
|
allowedOrigins: ["https://app.example.com"],
|
|
438
536
|
});
|
|
439
|
-
|
|
440
|
-
console.log(directUrl, triggered.triggered);
|
|
441
537
|
```
|
|
442
538
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
Use `service zuz.storage` rules to enforce per-app policy on top of platform limits.
|
|
446
|
-
|
|
447
|
-
Example 1: Authenticated read, quota-aware write
|
|
539
|
+
### Storage rules
|
|
448
540
|
|
|
449
541
|
```txt
|
|
450
542
|
service zuz.storage {
|
|
@@ -454,9 +546,7 @@ service zuz.storage {
|
|
|
454
546
|
|
|
455
547
|
allow write: if auth != null
|
|
456
548
|
&& requestData.storage.usage.storageBytes + requestData.size
|
|
457
|
-
<= requestData.storage.plan.storageBytes
|
|
458
|
-
&& requestData.storage.usage.bandwidthBytes + requestData.size
|
|
459
|
-
<= requestData.storage.plan.bandwidthBytes;
|
|
549
|
+
<= requestData.storage.plan.storageBytes;
|
|
460
550
|
|
|
461
551
|
allow delete: if auth != null;
|
|
462
552
|
}
|
|
@@ -464,115 +554,40 @@ service zuz.storage {
|
|
|
464
554
|
}
|
|
465
555
|
```
|
|
466
556
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
```txt
|
|
470
|
-
service zuz.storage {
|
|
471
|
-
match /storage/{bucket}/objects {
|
|
472
|
-
match /{document=**} {
|
|
473
|
-
allow read: if auth != null;
|
|
474
|
-
|
|
475
|
-
allow write: if auth != null
|
|
476
|
-
&& (
|
|
477
|
-
auth.role == 'admin'
|
|
478
|
-
|| auth.claims.plan == 'pro'
|
|
479
|
-
)
|
|
480
|
-
&& requestData.storage.usage.storageBytes + requestData.size
|
|
481
|
-
<= requestData.storage.plan.storageBytes
|
|
482
|
-
&& requestData.storage.usage.bandwidthBytes + requestData.size
|
|
483
|
-
<= requestData.storage.plan.bandwidthBytes;
|
|
484
|
-
|
|
485
|
-
allow delete: if auth != null
|
|
486
|
-
&& (auth.role == 'admin' || auth.claims.plan == 'pro');
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
Apply rules with admin API:
|
|
493
|
-
|
|
494
|
-
```http
|
|
495
|
-
POST /v1/apps/{appId}/admin/storage/rules
|
|
496
|
-
Authorization: Bearer FA_ADMIN_xxxxxxxxxxxx
|
|
497
|
-
Content-Type: application/json
|
|
498
|
-
|
|
499
|
-
{
|
|
500
|
-
"rulesDsl": "service zuz.storage { match /storage/{bucket}/objects { match /{document=**} { allow read: if auth != null; } } }"
|
|
501
|
-
}
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
Tip: map users to app-level plans through app settings (`storagePlans`, `storageUserPlans`, `storageDefaultPlanId`) so `requestData.storage.plan.*` resolves correctly at runtime.
|
|
505
|
-
|
|
506
|
-
## Storage Signed URLs
|
|
507
|
-
|
|
508
|
-
Create signed URLs for first-party upload/download/delete/edit without exposing admin key to file-transfer clients.
|
|
509
|
-
|
|
510
|
-
```typescript
|
|
511
|
-
const signedUpload = await admin.createSignedUrl({
|
|
512
|
-
bucket: "reports",
|
|
513
|
-
key: "uploads/big-video.mp4",
|
|
514
|
-
action: AdminStorageSignedAction.Upload,
|
|
515
|
-
expiresInSeconds: 300,
|
|
516
|
-
contentType: "video/mp4",
|
|
517
|
-
encrypt: true,
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
await fetch(signedUpload.url, {
|
|
521
|
-
method: signedUpload.method,
|
|
522
|
-
headers: { "Content-Type": "video/mp4" },
|
|
523
|
-
body: videoBuffer,
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
const signedDownload = await admin.createSignedUrl({
|
|
527
|
-
bucket: "reports",
|
|
528
|
-
key: "uploads/big-video.mp4",
|
|
529
|
-
action: AdminStorageSignedAction.Download,
|
|
530
|
-
expiresInSeconds: 300,
|
|
531
|
-
decrypt: true,
|
|
532
|
-
forceDownload: false,
|
|
533
|
-
allowedOrigins: ["https://app.example.com"],
|
|
534
|
-
embedOnly: true,
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
const bytes = await fetch(signedDownload.url, { method: signedDownload.method }).then((r) => r.arrayBuffer());
|
|
538
|
-
|
|
539
|
-
console.log("expiresAt", signedDownload.expiresAt, "ttl", signedDownload.expiresInSeconds);
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
Notes:
|
|
543
|
-
- `token` (for example `st_...`) is a signed ticket identifier; expiry is returned as `expiresAt`/`expiresInSeconds`.
|
|
544
|
-
- `forceDownload` and `embedOnly` are mutually exclusive.
|
|
545
|
-
- `allowedOrigins` defaults to `['*']` if omitted.
|
|
546
|
-
- `downloadObject(...)` triggers a browser click only when `document` is available. In server runtimes, it returns `{ ok, url, filename, triggered: false }` so you can redirect, proxy, or return the signed URL.
|
|
547
|
-
|
|
548
|
-
## External AWS SDK Init From Flare `/aws` Config
|
|
557
|
+
### External AWS SDK from Flare `/aws` config
|
|
549
558
|
|
|
550
559
|
```typescript
|
|
551
560
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
552
|
-
import { connectApp } from "@zuzjs/flare-admin";
|
|
553
|
-
|
|
554
|
-
const admin = connectApp({
|
|
555
|
-
serverUrl: process.env.FLARE_URL!,
|
|
556
|
-
appId: process.env.FLARE_APP_ID!,
|
|
557
|
-
adminKey: process.env.FLARE_ADMIN_KEY!,
|
|
558
|
-
});
|
|
559
561
|
|
|
560
562
|
const aws = await admin.storage().awsConfig("storage-server-id");
|
|
561
563
|
|
|
562
564
|
const s3 = new S3Client({
|
|
563
|
-
endpoint:
|
|
564
|
-
region:
|
|
565
|
+
endpoint: aws.endpoint,
|
|
566
|
+
region: aws.region,
|
|
565
567
|
forcePathStyle: Boolean(aws.forcePathStyle),
|
|
566
568
|
credentials: {
|
|
567
|
-
accessKeyId:
|
|
569
|
+
accessKeyId: aws.accessKeyId,
|
|
568
570
|
secretAccessKey: aws.secretAccessKey,
|
|
569
571
|
},
|
|
570
572
|
});
|
|
571
573
|
|
|
572
574
|
await s3.send(new PutObjectCommand({
|
|
573
|
-
Bucket:
|
|
574
|
-
Key:
|
|
575
|
-
Body:
|
|
575
|
+
Bucket: aws.bucket,
|
|
576
|
+
Key: `${aws.prefix ?? ""}manual/test.txt`,
|
|
577
|
+
Body: "hello",
|
|
576
578
|
ContentType: "text/plain",
|
|
577
579
|
}));
|
|
578
580
|
```
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Push Notifications
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
await admin.notifications().send({
|
|
588
|
+
uid: "user_123",
|
|
589
|
+
title: "Hello",
|
|
590
|
+
body: "Your order shipped!",
|
|
591
|
+
data: { orderId: "abc" },
|
|
592
|
+
});
|
|
593
|
+
```
|