@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 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`. This works identically whether you self-host Flare or use it as SaaS at `https://flare.zuz.com.pk`.
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=hhttps://flare.zuzcdn.net # your FlareServer 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", // browser-safe key
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: string; // FlareServer base URL
129
- appId: string; // App ID from `flare app create`
130
- adminKey: string; // Admin key — server-side only
131
- defaultTtl?: string; // Default token TTL, e.g. "24h" (default)
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
- ### `auth(name?)`
154
+ Close the WebSocket connection on a specific app instance. Safe to call multiple times.
140
155
 
141
- Shorthand for `getApp(name).auth()`.
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>, // extra JWT payload fields
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 from `/admin/ticket` for WebSocket auth flows.
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?: "user" | "admin" | "anon", // default: "user"
162
- email?: string,
163
- sid?: string,
164
- ttlSeconds?: number, // server clamps to safe range
165
- ip?: string, // optional override
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-e29b-41d4-a716-446655440000",
191
+ // ticket: "websocket:550e8400-...",
171
192
  // tag: "websocket",
172
- // uuid: "550e8400-e29b-41d4-a716-446655440000",
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
- Short REST example for `/admin/ticket`:
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
- ```json
197
- {
198
- "ticket": {
199
- "ticket": "websocket:550e8400-e29b-41d4-a716-446655440000",
200
- "tag": "websocket",
201
- "uuid": "550e8400-e29b-41d4-a716-446655440000",
202
- "expires_at": "2026-04-15T12:34:56Z",
203
- "one_time": true,
204
- "uid": "user_123",
205
- "role": "user",
206
- "ip": "203.0.113.20"
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
- ## Security rules on the server
225
+ `admin.db().collection(...)` supports the full structured query API:
214
226
 
215
- Once a client calls `flare.auth(token)`, the FlareServer socket has:
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
- Use these in your security rules:
231
+ **Joins:** `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
223
232
 
224
- ```json
225
- {
226
- "users": { ".read": "auth != null", ".write": "auth.uid == $docId" },
227
- "posts": { ".read": "true", ".write": "auth != null" },
228
- "settings": { ".read": "auth != null", ".write": "auth.role == 'admin'" },
229
- "*": { ".read": "false", ".write": "false" }
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
- ## Multi-tenant / multiple apps
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 adminA = connectApp({ serverUrl, appId: "app-a", adminKey: "..." }, "a");
239
- const adminB = connectApp({ serverUrl, appId: "app-b", adminKey: "..." }, "b");
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
- const tokenA = await getApp("a").auth().createCustomToken(userId);
242
- const tokenB = await getApp("b").auth().createCustomToken(userId);
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
- ## Admin-Only Auth Join Field Control
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
- Auth-user joins in admin SDK default to full fields.
298
+ ### `allowSensitiveAuthUserFields(false)`
248
299
 
249
- Use `allowSensitiveAuthUserFields(false)` per query when you want public-profile-only output:
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
- Important:
260
- - This option is admin-only.
261
- - Normal client/system query paths cannot enable sensitive auth-user fields.
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
- ## Data Mapper (Admin)
323
+ ---
264
324
 
265
- You can pass `dataMapper` in `connectApp(...)` to shape inbound data in admin SDK reads.
325
+ ## Realtime
266
326
 
267
- Mapping rules:
268
- - Base collection rows use the mapper key matching the collection name.
269
- - Join payload rows use the mapper key matching join `as`.
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 admin = connectApp({
273
- serverUrl: process.env.FLARE_URL!,
274
- appId: process.env.FLARE_APP_ID!,
275
- adminKey: process.env.FLARE_ADMIN_KEY!,
276
- dataMapper: {
277
- boards: (row) => ({
278
- id: row.id,
279
- name: row.name,
280
- description: row.description,
281
- createdAt: new Date(row.createdAt ?? row.created_at),
282
- }),
283
- team: (row) => ({
284
- id: row.id,
285
- name: row.authMeta?.additionalParams?.name || "Unknown",
286
- email: row.email,
287
- createdAt: new Date(row.createdAt ?? row.created_at),
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
- const rows = await admin.db()
293
- .collection("boards")
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
- // rows[*] is mapped by dataMapper.boards
303
- // rows[*].team[*] is mapped by dataMapper.team (join alias)
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
- Tip: for `as: "team"`, define `dataMapper.team`.
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
- ## Query Builder Parity (Admin)
380
+ ```typescript
381
+ const store = admin.live()
382
+ .collection("orders")
383
+ .asStore({ flushMs: 50 });
311
384
 
312
- `admin.db().collection(...)` and `admin.connection().collection(...)` are aligned with the client-style query builder API.
385
+ // React
386
+ import { useSyncExternalStore } from "react";
387
+ const orders = useSyncExternalStore(store.subscribe, store.getSnapshot);
313
388
 
314
- ### Supported filter helpers
389
+ // Or directly
390
+ const unsub = store.subscribe(() => {
391
+ console.log(store.getSnapshot());
392
+ });
315
393
 
316
- `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`
394
+ store.close(); // release when done
395
+ ```
317
396
 
318
- ### Supported sort / cursor / aggregate helpers
397
+ ### Realtime query builder parity
319
398
 
320
- `latest`, `newest`, `oldest`, `orderBy`, `limit`, `offset`, `startAt`, `startAfter`, `endAt`, `endBefore`, `count`, `sum`, `avg`, `min`, `max`, `distinct`, `groupBy`, `having`, `select`, `distinctField`, `vectorSearch`
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
- ### Supported join helpers
401
+ ---
323
402
 
324
- `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
403
+ ## Security rules
325
404
 
326
- Example:
405
+ Once a client calls `flare.auth(token)`, the FlareServer socket has:
327
406
 
328
- ```typescript
329
- const rows = await admin.db()
330
- .collection("boards")
331
- .where({ uid: userId })
332
- .orSome("team", { uid: userId })
333
- .join("lists", { source: "id", target: "boardId", as: "lists" })
334
- .join("users", { source: "team.uid", target: "id", as: "teamMembers" })
335
- .joinNested("lists", "cards", { source: "id", target: "listId", as: "cards" })
336
- .withRelation("team.uid->users.id as collaborators")
337
- .orderBy("updatedAt", "desc")
338
- .limit(20)
339
- .get();
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
- ### Realtime parity
421
+ ---
343
422
 
344
- The same query builder surface is available on socket subscriptions:
423
+ ## Multi-tenant / multiple apps
345
424
 
346
425
  ```typescript
347
- const stop = admin.connection()
348
- .collection("boards")
349
- .where({ uid: userId })
350
- .join("lists", { source: "id", target: "boardId", as: "lists" })
351
- .onSnapshot((event) => {
352
- console.log(event.type, event.data);
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
- ### Debugging structured query output
435
+ ---
357
436
 
358
- Both DB and realtime collection references provide `getRawQuery()`:
437
+ ## Data Mapper
359
438
 
360
- ```typescript
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
- console.log(raw.collection, raw.query);
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: "reports",
387
- key: "weekly/summary.json",
388
- body: Buffer.from(JSON.stringify({ ok: true })),
475
+ bucket: "reports",
476
+ key: "weekly/summary.json",
477
+ body: Buffer.from(JSON.stringify({ ok: true })),
389
478
  contentType: "application/json",
390
- encrypt: true,
479
+ encrypt: true,
391
480
  });
392
481
 
393
- const meta = await storage.headObject({
394
- bucket: "reports",
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
- const downloaded = await storage.getObject({
399
- bucket: "reports",
400
- key: "weekly/summary.json",
401
- decrypt: true,
402
- });
485
+ await storage.deleteObjects({ bucket: "reports", keys: ["weekly/summary.json"] });
486
+ ```
403
487
 
404
- const signedDownload = await admin.createSignedUrl({
405
- bucket: "reports",
406
- key: "weekly/summary.json",
407
- action: AdminStorageSignedAction.Download,
408
- expiresInSeconds: 180,
409
- forceDownload: true,
410
- allowedOrigins: ["https://app.example.com"],
411
- embedOnly: false,
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 storage.deleteObjects({
415
- bucket: "reports",
416
- keys: ["weekly/summary.json"],
500
+ await fetch(signedUpload.url, {
501
+ method: signedUpload.method,
502
+ headers: { "Content-Type": "video/mp4" },
503
+ body: videoBuffer,
417
504
  });
418
505
 
419
- console.log(meta.size, downloaded.contentBase64, signedDownload.url);
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
- Direct signed-download helpers:
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: "reports",
427
- key: "weekly/summary.json",
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: "reports",
434
- key: "weekly/summary.json",
435
- filename: "summary.json",
436
- forceDownload: true,
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
- ## App-Level Storage Rules (Samples)
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
- Example 2: Pro-plan write + admin override
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: aws.endpoint,
564
- region: aws.region,
565
+ endpoint: aws.endpoint,
566
+ region: aws.region,
565
567
  forcePathStyle: Boolean(aws.forcePathStyle),
566
568
  credentials: {
567
- accessKeyId: aws.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: aws.bucket,
574
- Key: `${aws.prefix ?? ""}manual/admin-test.txt`,
575
- Body: "hello from admin external sdk",
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
+ ```