@zuzjs/flare-admin 0.1.5 → 0.1.6

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()`
153
+
154
+ Close the WebSocket connection on a specific app instance. Safe to call multiple times.
138
155
 
139
- ### `auth(name?)`
156
+ ```typescript
157
+ admin.disconnect();
158
+ ```
159
+
160
+ ---
140
161
 
141
- Shorthand for `getApp(name).auth()`.
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,39 +199,157 @@ const ticket = await admin.auth().getTicket(uid, {
178
199
  // }
179
200
  ```
180
201
 
181
- Short REST example for `/admin/ticket`:
202
+ ---
182
203
 
183
- ```http
184
- POST /admin/ticket
185
- Authorization: Bearer FA_ADMIN_xxxxxxxxxxxx
186
- Content-Type: application/json
204
+ ## Database
187
205
 
188
- {
189
- "appId": "my-app",
190
- "uid": "user_123",
191
- "role": "user",
192
- "ttlSeconds": 300
193
- }
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();
194
221
  ```
195
222
 
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
- }
223
+ ### Query Builder
224
+
225
+ `admin.db().collection(...)` supports the full structured query API:
226
+
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`
228
+
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`
230
+
231
+ **Joins:** `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
232
+
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();
245
+ ```
246
+
247
+ ### `allowSensitiveAuthUserFields(false)`
248
+
249
+ Auth-user joins default to full fields. Pass `false` to restrict to public-profile-only output:
250
+
251
+ ```typescript
252
+ const safeBoards = await admin.db()
253
+ .collection("boards")
254
+ .join("users", { source: "team.uid", target: "id", as: "team" })
255
+ .allowSensitiveAuthUserFields(false)
256
+ .get();
257
+ ```
258
+
259
+ ### `getRawQuery()`
260
+
261
+ Inspect the structured query that will be sent:
262
+
263
+ ```typescript
264
+ const raw = admin.db()
265
+ .collection("boards")
266
+ .where({ uid: userId })
267
+ .getRawQuery();
268
+
269
+ console.log(raw.collection, raw.query);
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Realtime
275
+
276
+ ### `admin.live().collection(...).onSnapshot(cb)`
277
+
278
+ Single-snapshot subscription — fires once on the initial data load, then auto-unsubscribes:
279
+
280
+ ```typescript
281
+ const unsub = admin.live()
282
+ .collection("orders")
283
+ .where({ status: "pending" })
284
+ .orderBy("createdAt", "desc")
285
+ .onSnapshot((snap) => {
286
+ console.log(snap.type, snap.data);
287
+ });
288
+ ```
289
+
290
+ ### `.stream(options?)` — live batched stream
291
+
292
+ Returns a long-lived `AdminCollectionStream<T>` that maintains a local snapshot and fans out batched change events to listeners.
293
+
294
+ ```typescript
295
+ const stream = admin.live()
296
+ .collection("orders")
297
+ .where({ status: "pending" })
298
+ .stream({
299
+ flushMs?: number, // batch flush delay in ms (default: 24)
300
+ maxBatchSize?: number, // max changes per flush (default: 200)
301
+ insertAt?: "start" | "end", // where new docs land (default: "end")
302
+ maxDocs?: number, // cap the local snapshot size
303
+ sort?: (a: T, b: T) => number, // comparator applied after each flush
304
+ idField?: string, // identity field name (default: "id")
305
+ getId?: (doc: T) => string, // custom id extractor
306
+ });
307
+
308
+ // Subscribe to changes
309
+ const off = stream.listen((docs, meta) => {
310
+ console.log(meta.reason, meta.version, docs.length);
311
+ // meta.reason: "snapshot" | "change-batch"
312
+ // meta.ready: true after the first snapshot arrives
313
+ });
314
+
315
+ // Read the current snapshot without subscribing
316
+ const current = stream.getSnapshot();
317
+
318
+ // Remove a specific listener (does not close the stream)
319
+ off();
320
+
321
+ // Close the stream and release the WebSocket subscription
322
+ stream.close();
323
+ ```
324
+
325
+ ### `.asStore(options?)` — framework-agnostic external store
326
+
327
+ Returns an `AdminCollectionExternalStore<T>` compatible with React `useSyncExternalStore` or any subscribe/getSnapshot pattern:
328
+
329
+ ```typescript
330
+ const store = admin.live()
331
+ .collection("orders")
332
+ .asStore({ flushMs: 50 });
333
+
334
+ // React
335
+ import { useSyncExternalStore } from "react";
336
+ const orders = useSyncExternalStore(store.subscribe, store.getSnapshot);
337
+
338
+ // Or directly
339
+ const unsub = store.subscribe(() => {
340
+ console.log(store.getSnapshot());
341
+ });
342
+
343
+ store.close(); // release when done
209
344
  ```
210
345
 
346
+ ### Realtime query builder parity
347
+
348
+ The same query builder surface (`where`, `orderBy`, `join`, `limit`, etc.) is available on `admin.live().collection(...)` just like on `admin.db().collection(...)`.
349
+
211
350
  ---
212
351
 
213
- ## Security rules on the server
352
+ ## Security rules
214
353
 
215
354
  Once a client calls `flare.auth(token)`, the FlareServer socket has:
216
355
 
@@ -219,8 +358,6 @@ auth.uid = the uid you passed to createCustomToken()
219
358
  auth.role = "user" | "admin" | "anon"
220
359
  ```
221
360
 
222
- Use these in your security rules:
223
-
224
361
  ```json
225
362
  {
226
363
  "users": { ".read": "auth != null", ".write": "auth.uid == $docId" },
@@ -240,211 +377,115 @@ const adminB = connectApp({ serverUrl, appId: "app-b", adminKey: "..." }, "b");
240
377
 
241
378
  const tokenA = await getApp("a").auth().createCustomToken(userId);
242
379
  const tokenB = await getApp("b").auth().createCustomToken(userId);
243
- ```
244
-
245
- ## Admin-Only Auth Join Field Control
246
380
 
247
- Auth-user joins in admin SDK default to full fields.
248
-
249
- Use `allowSensitiveAuthUserFields(false)` per query when you want public-profile-only output:
250
-
251
- ```typescript
252
- const safeBoards = await admin.db()
253
- .collection("boards")
254
- .join("users", { source: "team.uid", target: "id", as: "team" })
255
- .allowSensitiveAuthUserFields(false)
256
- .get();
381
+ disconnectAllApps();
257
382
  ```
258
383
 
259
- Important:
260
- - This option is admin-only.
261
- - Normal client/system query paths cannot enable sensitive auth-user fields.
262
-
263
- ## Data Mapper (Admin)
384
+ ---
264
385
 
265
- You can pass `dataMapper` in `connectApp(...)` to shape inbound data in admin SDK reads.
386
+ ## Data Mapper
266
387
 
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`.
388
+ Pass `dataMapper` in `connectApp(...)` to shape inbound data. Keys match collection names or join aliases (`as`).
270
389
 
271
390
  ```typescript
272
391
  const admin = connectApp({
273
392
  serverUrl: process.env.FLARE_URL!,
274
- appId: process.env.FLARE_APP_ID!,
275
- adminKey: process.env.FLARE_ADMIN_KEY!,
393
+ appId: process.env.FLARE_APP_ID!,
394
+ adminKey: process.env.FLARE_ADMIN_KEY!,
276
395
  dataMapper: {
277
396
  boards: (row) => ({
278
- id: row.id,
279
- name: row.name,
280
- description: row.description,
397
+ id: row.id,
398
+ name: row.name,
281
399
  createdAt: new Date(row.createdAt ?? row.created_at),
282
400
  }),
283
401
  team: (row) => ({
284
- id: row.id,
285
- name: row.authMeta?.additionalParams?.name || "Unknown",
402
+ id: row.id,
403
+ name: row.authMeta?.additionalParams?.name || "Unknown",
286
404
  email: row.email,
287
- createdAt: new Date(row.createdAt ?? row.created_at),
288
405
  }),
289
406
  },
290
407
  });
291
-
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();
301
-
302
- // rows[*] is mapped by dataMapper.boards
303
- // rows[*].team[*] is mapped by dataMapper.team (join alias)
304
408
  ```
305
409
 
306
- Tip: for `as: "team"`, define `dataMapper.team`.
410
+ For `join(..., { as: "team" })`, define `dataMapper.team`.
307
411
 
308
412
  ---
309
413
 
310
- ## Query Builder Parity (Admin)
311
-
312
- `admin.db().collection(...)` and `admin.connection().collection(...)` are aligned with the client-style query builder API.
313
-
314
- ### Supported filter helpers
315
-
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`
317
-
318
- ### Supported sort / cursor / aggregate helpers
319
-
320
- `latest`, `newest`, `oldest`, `orderBy`, `limit`, `offset`, `startAt`, `startAfter`, `endAt`, `endBefore`, `count`, `sum`, `avg`, `min`, `max`, `distinct`, `groupBy`, `having`, `select`, `distinctField`, `vectorSearch`
321
-
322
- ### Supported join helpers
323
-
324
- `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
325
-
326
- Example:
327
-
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();
340
- ```
341
-
342
- ### Realtime parity
343
-
344
- The same query builder surface is available on socket subscriptions:
345
-
346
- ```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
- });
354
- ```
355
-
356
- ### Debugging structured query output
357
-
358
- Both DB and realtime collection references provide `getRawQuery()`:
359
-
360
- ```typescript
361
- const raw = admin.db()
362
- .collection("boards")
363
- .where({ uid: userId })
364
- .withRelation("team.uid->users.id as collaborators")
365
- .getRawQuery();
366
-
367
- console.log(raw.collection, raw.query);
368
- ```
369
-
370
414
  ## Storage API (S3-like)
371
415
 
372
416
  ```typescript
373
417
  import { connectApp, AdminStorageSignedAction } from "@zuzjs/flare-admin";
374
418
 
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
419
  const storage = admin.storage();
382
420
 
383
421
  await storage.createBucket("reports");
384
422
 
385
423
  await storage.putObject({
386
- bucket: "reports",
387
- key: "weekly/summary.json",
388
- body: Buffer.from(JSON.stringify({ ok: true })),
424
+ bucket: "reports",
425
+ key: "weekly/summary.json",
426
+ body: Buffer.from(JSON.stringify({ ok: true })),
389
427
  contentType: "application/json",
390
- encrypt: true,
428
+ encrypt: true,
391
429
  });
392
430
 
393
- const meta = await storage.headObject({
394
- bucket: "reports",
395
- key: "weekly/summary.json",
396
- });
431
+ const meta = await storage.headObject({ bucket: "reports", key: "weekly/summary.json" });
432
+ const downloaded = await storage.getObject({ bucket: "reports", key: "weekly/summary.json", decrypt: true });
397
433
 
398
- const downloaded = await storage.getObject({
399
- bucket: "reports",
400
- key: "weekly/summary.json",
401
- decrypt: true,
402
- });
434
+ await storage.deleteObjects({ bucket: "reports", keys: ["weekly/summary.json"] });
435
+ ```
403
436
 
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,
437
+ ### Signed URLs
438
+
439
+ ```typescript
440
+ const signedUpload = await admin.createSignedUrl({
441
+ bucket: "reports",
442
+ key: "uploads/big-video.mp4",
443
+ action: AdminStorageSignedAction.Upload,
444
+ expiresInSeconds: 300,
445
+ contentType: "video/mp4",
446
+ encrypt: true,
412
447
  });
413
448
 
414
- await storage.deleteObjects({
415
- bucket: "reports",
416
- keys: ["weekly/summary.json"],
449
+ await fetch(signedUpload.url, {
450
+ method: signedUpload.method,
451
+ headers: { "Content-Type": "video/mp4" },
452
+ body: videoBuffer,
417
453
  });
418
454
 
419
- console.log(meta.size, downloaded.contentBase64, signedDownload.url);
455
+ const signedDownload = await admin.createSignedUrl({
456
+ bucket: "reports",
457
+ key: "uploads/big-video.mp4",
458
+ action: AdminStorageSignedAction.Download,
459
+ expiresInSeconds: 300,
460
+ decrypt: true,
461
+ allowedOrigins: ["https://app.example.com"],
462
+ });
420
463
  ```
421
464
 
422
- Direct signed-download helpers:
465
+ Notes:
466
+ - `forceDownload` and `embedOnly` are mutually exclusive.
467
+ - `allowedOrigins` defaults to `['*']` if omitted.
468
+
469
+ ### Direct download helpers
423
470
 
424
471
  ```typescript
425
472
  const directUrl = await admin.getObjectUrl({
426
- bucket: "reports",
427
- key: "weekly/summary.json",
473
+ bucket: "reports",
474
+ key: "weekly/summary.json",
428
475
  expiresInSeconds: 120,
429
- allowedOrigins: ["*"],
476
+ allowedOrigins: ["*"],
430
477
  });
431
478
 
432
479
  const triggered = await admin.downloadObject({
433
- bucket: "reports",
434
- key: "weekly/summary.json",
435
- filename: "summary.json",
436
- forceDownload: true,
480
+ bucket: "reports",
481
+ key: "weekly/summary.json",
482
+ filename: "summary.json",
483
+ forceDownload: true,
437
484
  allowedOrigins: ["https://app.example.com"],
438
485
  });
439
-
440
- console.log(directUrl, triggered.triggered);
441
486
  ```
442
487
 
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
488
+ ### Storage rules
448
489
 
449
490
  ```txt
450
491
  service zuz.storage {
@@ -454,9 +495,7 @@ service zuz.storage {
454
495
 
455
496
  allow write: if auth != null
456
497
  && requestData.storage.usage.storageBytes + requestData.size
457
- <= requestData.storage.plan.storageBytes
458
- && requestData.storage.usage.bandwidthBytes + requestData.size
459
- <= requestData.storage.plan.bandwidthBytes;
498
+ <= requestData.storage.plan.storageBytes;
460
499
 
461
500
  allow delete: if auth != null;
462
501
  }
@@ -464,115 +503,40 @@ service zuz.storage {
464
503
  }
465
504
  ```
466
505
 
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
506
+ ### External AWS SDK from Flare `/aws` config
549
507
 
550
508
  ```typescript
551
509
  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
510
 
560
511
  const aws = await admin.storage().awsConfig("storage-server-id");
561
512
 
562
513
  const s3 = new S3Client({
563
- endpoint: aws.endpoint,
564
- region: aws.region,
514
+ endpoint: aws.endpoint,
515
+ region: aws.region,
565
516
  forcePathStyle: Boolean(aws.forcePathStyle),
566
517
  credentials: {
567
- accessKeyId: aws.accessKeyId,
518
+ accessKeyId: aws.accessKeyId,
568
519
  secretAccessKey: aws.secretAccessKey,
569
520
  },
570
521
  });
571
522
 
572
523
  await s3.send(new PutObjectCommand({
573
- Bucket: aws.bucket,
574
- Key: `${aws.prefix ?? ""}manual/admin-test.txt`,
575
- Body: "hello from admin external sdk",
524
+ Bucket: aws.bucket,
525
+ Key: `${aws.prefix ?? ""}manual/test.txt`,
526
+ Body: "hello",
576
527
  ContentType: "text/plain",
577
528
  }));
578
529
  ```
530
+
531
+ ---
532
+
533
+ ## Push Notifications
534
+
535
+ ```typescript
536
+ await admin.notifications().send({
537
+ uid: "user_123",
538
+ title: "Hello",
539
+ body: "Your order shipped!",
540
+ data: { orderId: "abc" },
541
+ });
542
+ ```