@zuzjs/flare-admin 0.1.4 → 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.
138
134
 
139
- ### `auth(name?)`
135
+ ### `disconnectApp(name?)`
140
136
 
141
- Shorthand for `getApp(name).auth()`.
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.
155
+
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,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);
209
270
  ```
210
271
 
211
272
  ---
212
273
 
213
- ## Security rules on the server
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
344
+ ```
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
+
350
+ ---
351
+
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,129 +377,166 @@ 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)
414
+ ## Storage API (S3-like)
311
415
 
312
- `admin.db().collection(...)` and `admin.connection().collection(...)` are aligned with the client-style query builder API.
313
-
314
- ### Supported filter helpers
416
+ ```typescript
417
+ import { connectApp, AdminStorageSignedAction } from "@zuzjs/flare-admin";
315
418
 
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`
419
+ const storage = admin.storage();
317
420
 
318
- ### Supported sort / cursor / aggregate helpers
421
+ await storage.createBucket("reports");
319
422
 
320
- `latest`, `newest`, `oldest`, `orderBy`, `limit`, `offset`, `startAt`, `startAfter`, `endAt`, `endBefore`, `count`, `sum`, `avg`, `min`, `max`, `distinct`, `groupBy`, `having`, `select`, `distinctField`, `vectorSearch`
423
+ await storage.putObject({
424
+ bucket: "reports",
425
+ key: "weekly/summary.json",
426
+ body: Buffer.from(JSON.stringify({ ok: true })),
427
+ contentType: "application/json",
428
+ encrypt: true,
429
+ });
321
430
 
322
- ### Supported join helpers
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 });
323
433
 
324
- `join`, `Join`, `joinNested`, `JoinNested`, `withRelation`
434
+ await storage.deleteObjects({ bucket: "reports", keys: ["weekly/summary.json"] });
435
+ ```
325
436
 
326
- Example:
437
+ ### Signed URLs
327
438
 
328
439
  ```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();
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,
447
+ });
448
+
449
+ await fetch(signedUpload.url, {
450
+ method: signedUpload.method,
451
+ headers: { "Content-Type": "video/mp4" },
452
+ body: videoBuffer,
453
+ });
454
+
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
+ });
340
463
  ```
341
464
 
342
- ### Realtime parity
465
+ Notes:
466
+ - `forceDownload` and `embedOnly` are mutually exclusive.
467
+ - `allowedOrigins` defaults to `['*']` if omitted.
343
468
 
344
- The same query builder surface is available on socket subscriptions:
469
+ ### Direct download helpers
345
470
 
346
471
  ```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
- });
472
+ const directUrl = await admin.getObjectUrl({
473
+ bucket: "reports",
474
+ key: "weekly/summary.json",
475
+ expiresInSeconds: 120,
476
+ allowedOrigins: ["*"],
477
+ });
478
+
479
+ const triggered = await admin.downloadObject({
480
+ bucket: "reports",
481
+ key: "weekly/summary.json",
482
+ filename: "summary.json",
483
+ forceDownload: true,
484
+ allowedOrigins: ["https://app.example.com"],
485
+ });
354
486
  ```
355
487
 
356
- ### Debugging structured query output
488
+ ### Storage rules
489
+
490
+ ```txt
491
+ service zuz.storage {
492
+ match /storage/{bucket}/objects {
493
+ match /{document=**} {
494
+ allow read: if auth != null;
495
+
496
+ allow write: if auth != null
497
+ && requestData.storage.usage.storageBytes + requestData.size
498
+ <= requestData.storage.plan.storageBytes;
499
+
500
+ allow delete: if auth != null;
501
+ }
502
+ }
503
+ }
504
+ ```
357
505
 
358
- Both DB and realtime collection references provide `getRawQuery()`:
506
+ ### External AWS SDK from Flare `/aws` config
359
507
 
360
508
  ```typescript
361
- const raw = admin.db()
362
- .collection("boards")
363
- .where({ uid: userId })
364
- .withRelation("team.uid->users.id as collaborators")
365
- .getRawQuery();
509
+ import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
366
510
 
367
- console.log(raw.collection, raw.query);
511
+ const aws = await admin.storage().awsConfig("storage-server-id");
512
+
513
+ const s3 = new S3Client({
514
+ endpoint: aws.endpoint,
515
+ region: aws.region,
516
+ forcePathStyle: Boolean(aws.forcePathStyle),
517
+ credentials: {
518
+ accessKeyId: aws.accessKeyId,
519
+ secretAccessKey: aws.secretAccessKey,
520
+ },
521
+ });
522
+
523
+ await s3.send(new PutObjectCommand({
524
+ Bucket: aws.bucket,
525
+ Key: `${aws.prefix ?? ""}manual/test.txt`,
526
+ Body: "hello",
527
+ ContentType: "text/plain",
528
+ }));
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
+ });
368
542
  ```