@rebasepro/agent-skills 0.7.0 → 0.9.0

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.
@@ -17,7 +17,7 @@ Rebase implements a **multi-layered, defense-in-depth** security architecture. S
17
17
  - [Layer 2: API Key Permission Guard](#layer-2-api-key-permission-guard)
18
18
  - [Layer 3: DataHooks (API Boundary)](#layer-3-datahooks-api-boundary)
19
19
  - [Layer 4: Scoped DataDriver](#layer-4-scoped-datadriver)
20
- - [Layer 5: Entity Callbacks](#layer-5-entity-callbacks)
20
+ - [Layer 5: Collection Callbacks](#layer-5-collection-callbacks)
21
21
  - [Fail-Closed Design](#fail-closed-design)
22
22
  - [Securing Without Database RLS](#securing-without-database-rls)
23
23
  - [Common Security Patterns](#common-security-patterns)
@@ -61,7 +61,7 @@ Every request — REST, GraphQL, and WebSocket — passes through **5 security l
61
61
  └──────────────┬───────────────────────────────────────┘
62
62
 
63
63
  ┌──────────────▼───────────────────────────────────────┐
64
- │ Layer 5: Entity Callbacks (Per-Collection)
64
+ │ Layer 5: Collection Callbacks (Per-Collection)
65
65
  │ beforeSave / afterRead / beforeDelete │
66
66
  │ Per-collection hooks inside the DataDriver │
67
67
  └──────────────────────────────────────────────────────┘
@@ -77,12 +77,12 @@ All three protocols share the same security middleware stack:
77
77
 
78
78
  ### REST
79
79
  ```
80
- HTTP Request → Auth Middleware → API Key Guard → DataHooks → Scoped Driver → Entity Callbacks → Response
80
+ HTTP Request → Auth Middleware → API Key Guard → DataHooks → Scoped Driver → Collection Callbacks → Response
81
81
  ```
82
82
 
83
83
  ### GraphQL
84
84
  ```
85
- GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Entity Callbacks → Response
85
+ GraphQL Request → Auth Middleware → API Key Guard → Scoped Driver → Collection Callbacks → Response
86
86
  ```
87
87
  GraphQL shares the same Hono middleware chain as REST. Resolvers extract the scoped driver from context and throw if unavailable.
88
88
 
@@ -136,6 +136,14 @@ export async function scopeDataDriver(
136
136
  | Anonymous (`requireAuth: false`) | `"anon"` | `["anon"]` | RLS with anonymous identity |
137
137
  | No token + `requireAuth: true` | — | — | **Rejected (401)** |
138
138
 
139
+ > **IMPORTANT FOR AGENTS:** These are **reserved system identity values** that the middleware injects automatically. When writing DataHooks or Collection Callbacks, developers should use these identities to gate behavior:
140
+ > - `uid: "service"` + `roles: ["admin"]` — Server-side `rebase.data` calls (cron jobs, custom functions using `rebase.data`, webhooks). These go through the full middleware pipeline authenticated with the service key.
141
+ > - `uid: "anon"` + `roles: ["anon"]` — Unauthenticated requests when `requireAuth: false`. **Note:** for anonymous REST requests, `context.user` in Collection Callbacks may be `undefined`; only the DataDriver is scoped with the anon identity. For WebSocket connections, a full `User` object with `uid: "anon"` is provided.
142
+ > - `uid: "api-key:{id}"` + `roles: ["service"]` (or `["admin", "service"]`) — API key requests.
143
+ > - Real user IDs and roles for JWT-authenticated requests.
144
+ >
145
+ > **Key insight:** `rebase.data` (the server-side singleton) is NOT a raw admin driver — it round-trips through the REST API using the service key, so all DataHooks and Collection Callbacks fire with `uid: "service"`, `roles: ["admin"]`. This means callbacks can distinguish server-internal reads from end-user reads by checking `context.user?.roles?.includes("admin")`.
146
+
139
147
  ---
140
148
 
141
149
  ## Layer 2: API Key Permission Guard
@@ -239,9 +247,9 @@ interface BackendHookContext {
239
247
 
240
248
  ### Execution Order
241
249
 
242
- DataHooks run **after** per-collection `EntityCallbacks` (which execute inside the DataDriver, closer to the database) and **before** the API response is sent to the client. This gives you two opportunities to enforce security:
250
+ DataHooks run **after** per-collection `CollectionCallbacks` (which execute inside the DataDriver, closer to the database) and **before** the API response is sent to the client. This gives you two opportunities to enforce security:
243
251
 
244
- 1. **EntityCallbacks** — per-collection, inside the driver
252
+ 1. **CollectionCallbacks** — per-collection, inside the driver
245
253
  2. **DataHooks** — cross-cutting, at the API boundary
246
254
 
247
255
  ---
@@ -263,12 +271,12 @@ PostgreSQL RLS policies use `auth.uid()`, `auth.roles()`, and `auth.jwt()` to re
263
271
 
264
272
  ---
265
273
 
266
- ## Layer 5: Entity Callbacks
274
+ ## Layer 5: Collection Callbacks
267
275
 
268
- Entity callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
276
+ Collection callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
269
277
 
270
278
  ```typescript
271
- const ordersCollection: PostgresCollection = {
279
+ const ordersCollection: PostgresCollectionConfig = {
272
280
  name: "Orders",
273
281
  slug: "orders",
274
282
  table: "orders",
@@ -281,9 +289,9 @@ const ordersCollection: PostgresCollection = {
281
289
  }
282
290
  return values;
283
291
  },
284
- beforeDelete: async ({ entity, context }) => {
292
+ beforeDelete: async ({ row, context }) => {
285
293
  // Prevent deletion of fulfilled orders
286
- if (entity.values.status === "fulfilled") {
294
+ if (row.status === "fulfilled") {
287
295
  throw new Error("Cannot delete fulfilled orders");
288
296
  }
289
297
  },
@@ -292,7 +300,7 @@ const ordersCollection: PostgresCollection = {
292
300
  };
293
301
  ```
294
302
 
295
- For full documentation on entity callbacks, see the `rebase-collections` skill.
303
+ For full documentation on collection callbacks, see the `rebase-collections` skill.
296
304
 
297
305
  ---
298
306
 
@@ -319,7 +327,7 @@ Rebase follows a **fail-closed** security model throughout the stack:
319
327
 
320
328
  ## Securing Without Database RLS
321
329
 
322
- If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **Entity Callbacks** to enforce security entirely at the application level.
330
+ If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **Collection Callbacks** to enforce security entirely at the application level.
323
331
 
324
332
  ### Strategy: DataHooks as Your Security Layer
325
333
 
@@ -376,7 +384,7 @@ const hooks: BackendHooks = {
376
384
 
377
385
  if (!user.roles.includes("admin")) {
378
386
  // For non-admins, you may need to fetch the entity first
379
- // to verify ownership. Use Entity Callbacks for this pattern
387
+ // to verify ownership. Use Collection Callbacks for this pattern
380
388
  // since they receive the full entity.
381
389
  throw ApiError.forbidden("Only admins can delete records");
382
390
  }
@@ -385,40 +393,40 @@ const hooks: BackendHooks = {
385
393
  };
386
394
  ```
387
395
 
388
- ### Strategy: Entity Callbacks for Ownership Checks
396
+ ### Strategy: Collection Callbacks for Ownership Checks
389
397
 
390
- Entity Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
398
+ Collection Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
391
399
 
392
400
  ```typescript
393
- const ordersCollection: PostgresCollection = {
401
+ const ordersCollection: PostgresCollectionConfig = {
394
402
  name: "Orders",
395
403
  slug: "orders",
396
404
  table: "orders",
397
405
  callbacks: {
398
- beforeSave: async ({ values, entityId, context, previousValues }) => {
406
+ beforeSave: async ({ values, id, context, previousValues }) => {
399
407
  const user = context.user;
400
408
  if (!user) throw new Error("Unauthorized");
401
409
 
402
410
  // On update, verify the current user owns this order
403
- if (entityId && previousValues) {
411
+ if (id && previousValues) {
404
412
  if (previousValues.user_id !== user.uid && !user.roles?.includes("admin")) {
405
413
  throw new Error("You can only edit your own orders");
406
414
  }
407
415
  }
408
416
 
409
417
  // On create, stamp ownership
410
- if (!entityId) {
418
+ if (!id) {
411
419
  values.user_id = user.uid;
412
420
  }
413
421
 
414
422
  return values;
415
423
  },
416
424
 
417
- beforeDelete: async ({ entity, context }) => {
425
+ beforeDelete: async ({ row, context }) => {
418
426
  const user = context.user;
419
427
  if (!user) throw new Error("Unauthorized");
420
428
 
421
- if (entity.values.user_id !== user.uid && !user.roles?.includes("admin")) {
429
+ if (row.user_id !== user.uid && !user.roles?.includes("admin")) {
422
430
  throw new Error("You can only delete your own orders");
423
431
  }
424
432
  },
@@ -429,15 +437,15 @@ const ordersCollection: PostgresCollection = {
429
437
 
430
438
  ### Combining Both Layers
431
439
 
432
- For maximum security, use both DataHooks and Entity Callbacks together:
440
+ For maximum security, use both DataHooks and Collection Callbacks together:
433
441
 
434
442
  | Concern | Best Layer | Why |
435
443
  |---|---|---|
436
444
  | Cross-cutting read filtering | DataHooks `afterRead` | Applies to ALL collections in one place |
437
445
  | Cross-cutting write validation | DataHooks `beforeSave` | Single enforcement point for all writes |
438
446
  | PII masking / field redaction | DataHooks `afterRead` | Cross-cutting, role-based |
439
- | Ownership checks on writes/deletes | Entity Callbacks | Has access to the full entity for comparison |
440
- | Business rule validation | Entity Callbacks | Collection-specific, typed values |
447
+ | Ownership checks on writes/deletes | Collection Callbacks | Has access to the full entity for comparison |
448
+ | Business rule validation | Collection Callbacks | Collection-specific, typed values |
441
449
  | Audit logging | DataHooks `afterSave` / `afterDelete` | Cross-cutting, fire-and-forget |
442
450
 
443
451
  ---
@@ -495,13 +503,44 @@ const hooks: BackendHooks = {
495
503
  },
496
504
 
497
505
  beforeDelete(slug, entityId, ctx) {
498
- // Tenant isolation on deletes is best handled in Entity Callbacks
506
+ // Tenant isolation on deletes is best handled in Collection Callbacks
499
507
  // where you have access to the entity's tenant_id
500
508
  },
501
509
  },
502
510
  };
503
511
  ```
504
512
 
513
+ ### Membership-Scoped Access (RLS, no N+1)
514
+
515
+ When membership lives in a **join collection** (e.g. `team_members`), prefer the
516
+ first-class `policy.existsIn` predicate over a per-row `afterRead` lookup. It is
517
+ enforced by Postgres RLS in a single correlated `EXISTS` subquery — no N+1, and
518
+ it cannot be bypassed by a client that skips the SDK.
519
+
520
+ ```typescript
521
+ import { policy } from "@rebasepro/types";
522
+
523
+ // config/collections/documents.ts — only members of the doc's team can read it:
524
+ securityRules: [
525
+ {
526
+ operation: "select",
527
+ condition: policy.existsIn({
528
+ collection: "team_members",
529
+ where: policy.and(
530
+ policy.compare(policy.field("team_id"), "eq", policy.outerField("team_id")),
531
+ policy.compare(policy.field("user_id"), "eq", policy.authUid()),
532
+ ),
533
+ }),
534
+ },
535
+ ]
536
+ ```
537
+
538
+ Inside `where`: `policy.field(...)` = a column of the joined collection
539
+ (`team_members`); `policy.outerField(...)` = a column of the row being checked
540
+ (`documents`); `policy.authUid()` = the caller. Reach for the `afterRead`
541
+ approach above only when access depends on data RLS can't see (e.g. an external
542
+ service). Run `rebase db push` after editing the collection to apply the policy.
543
+
505
544
  ### Role-Based Collection Access
506
545
 
507
546
  ```typescript
@@ -585,7 +624,7 @@ Use this checklist when setting up security for a Rebase project:
585
624
  - [ ] **Default role is NOT admin** — `auth.defaultRole` must never be `"admin"` (startup error)
586
625
  - [ ] **DataHooks enforce access control** — If not using database RLS, `hooks.data` enforces read/write/delete permissions
587
626
  - [ ] **Sensitive fields are masked** — `afterRead` masks PII for non-admin users
588
- - [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Entity Callbacks verify ownership on update/delete
627
+ - [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Collection Callbacks verify ownership on update/delete
589
628
  - [ ] **API keys are scoped** — API keys have minimal permissions (specific collections + operations)
590
629
  - [ ] **API keys are never client-side** — API keys bypass RLS; only use server-side
591
630
  - [ ] **CORS is configured** — Restrict origins in production
@@ -602,5 +641,6 @@ Use this checklist when setting up security for a Rebase project:
602
641
  - **REST API Generator**: `packages/server-core/src/api/rest/api-generator.ts` — DataHooks integration
603
642
  - **Backend Hooks Types**: `packages/types/src/types/backend_hooks.ts` — `DataHooks`, `BackendHooks` interfaces
604
643
  - **Backend Init**: `packages/server-core/src/init.ts` — `hooks` config property
605
- - **Entity Callbacks**: See `rebase-collections` skill Entity Callbacks section
644
+ - **Reserved Identity Values**: See Identity Types table above — `"service"`, `"anon"`, `"api-key:{id}"` are system-assigned identities in `context.user` / `ctx.requestUser`
645
+ - **Collection Callbacks**: See `rebase-collections` skill → Collection Callbacks section
606
646
  - **Auth Configuration**: See `rebase-auth` skill → Server-Side Configuration section
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: rebase-storage
3
- description: Guide for setting up and using file storage in Rebase. Use this skill when the user needs to configure S3 or local file storage, handle file uploads, TUS resumable uploads, image transformations, or integrate the media manager.
3
+ description: Guide for setting up and using file storage in Rebase. Use this skill when the user needs to configure S3, Google Cloud Storage (GCS), or local file storage, handle file uploads, TUS resumable uploads, image transformations, multi-backend frontend storage sources, or integrate the media manager.
4
4
  ---
5
5
 
6
6
  # Rebase Storage
7
7
 
8
- Rebase provides built-in file storage with support for local filesystem and S3-compatible services, TUS v1.0.0 resumable uploads, on-the-fly image transformation, and a multi-backend registry.
8
+ Rebase provides built-in file storage with support for local filesystem, S3-compatible services, and Google Cloud Storage (GCS), TUS v1.0.0 resumable uploads, on-the-fly image transformation, a multi-backend registry, and frontend storage sources.
9
9
 
10
10
  > **IMPORTANT FOR AGENTS:** Always read the `rebase-basics` skill first before using this skill. Storage requires a running Rebase backend with `initializeRebaseBackend()`.
11
11
 
@@ -15,7 +15,7 @@ The `storage` option in `initializeRebaseBackend()` accepts three forms:
15
15
 
16
16
  | Form | Type | Description |
17
17
  |------|------|-------------|
18
- | Single config | `BackendStorageConfig` | `{ type: 'local' | 's3', ... }` — creates a single `(default)` backend |
18
+ | Single config | `BackendStorageConfig` | `{ type: 'local' | 's3' | 'gcs', ... }` — creates a single `(default)` backend |
19
19
  | Single controller | `StorageController` | A custom controller instance — registered as `(default)` |
20
20
  | Multi-backend map | `Record<string, BackendStorageConfig \| StorageController>` | Named backends, first becomes `(default)` if no `(default)` key |
21
21
 
@@ -44,6 +44,19 @@ The `storage` option in `initializeRebaseBackend()` accepts three forms:
44
44
  | `allowedMimeTypes` | `string[]` | `undefined` (all allowed) | Restrict uploads to these MIME types |
45
45
  | `signedUrlExpiration` | `number` | `3600` | Signed URL expiry in seconds |
46
46
 
47
+ ### GCSStorageConfig
48
+
49
+ | Property | Type | Default | Description |
50
+ |----------|------|---------|-------------|
51
+ | `type` | `"gcs"` | — | **Required.** Must be `"gcs"` |
52
+ | `bucket` | `string` | — | **Required.** GCS bucket name |
53
+ | `projectId` | `string` | `undefined` | Google Cloud project ID (auto-detected from credentials if omitted) |
54
+ | `maxFileSize` | `number` | `52428800` (50 MB) | Maximum file size in bytes |
55
+ | `allowedMimeTypes` | `string[]` | `undefined` (all allowed) | Restrict uploads to these MIME types |
56
+ | `signedUrlExpiration` | `number` | `3600` | Signed URL expiry in seconds |
57
+
58
+ > **IMPORTANT FOR AGENTS:** GCS requires `@google-cloud/storage` as a peer dependency. Install it: `pnpm add @google-cloud/storage`. Authentication uses Application Default Credentials — set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to point to a service account key file, or rely on the default credentials when running on GCP.
59
+
47
60
  ### StorageRoutesConfig
48
61
 
49
62
  These options are set internally when mounting routes but are derived from the backend config:
@@ -93,7 +106,7 @@ Local storage includes **path traversal protection** — any resolved path that
93
106
 
94
107
  ### S3-Compatible Storage
95
108
 
96
- Works with AWS S3, MinIO, DigitalOcean Spaces, Cloudflare R2, Hetzner Object Storage, Backblaze B2, and any S3-compatible service. GCS also works via its S3-compatible interoperability API.
109
+ Works with AWS S3, MinIO, DigitalOcean Spaces, Cloudflare R2, Hetzner Object Storage, Backblaze B2, and any S3-compatible service.
97
110
 
98
111
  ```typescript
99
112
  const backend = await initializeRebaseBackend({
@@ -118,6 +131,28 @@ The S3 controller:
118
131
  - Supports `s3://` and `gs://` URL schemes in key parameters for backward compatibility
119
132
  - Flattens nested metadata to string values (S3 requirement)
120
133
 
134
+ ### Google Cloud Storage (GCS)
135
+ Native Google Cloud Storage support via the `@google-cloud/storage` SDK. This is the preferred approach for GCS — no S3 interop layer needed. (Also works with Firebase Storage buckets, which are GCS buckets under the hood).
136
+
137
+ ```typescript
138
+ const backend = await initializeRebaseBackend({
139
+ server, app,
140
+ bootstrappers: [/* ... */],
141
+ storage: {
142
+ type: "gcs",
143
+ bucket: process.env.GCS_BUCKET!,
144
+ projectId: process.env.GCS_PROJECT_ID, // optional, auto-detected on GCP
145
+ },
146
+ });
147
+ ```
148
+
149
+ The GCS controller:
150
+ - Uses `@google-cloud/storage` natively (no S3 interop overhead)
151
+ - Authenticates via Application Default Credentials (`GOOGLE_APPLICATION_CREDENTIALS`)
152
+ - Maps the logical bucket name `"default"` to the configured GCS bucket
153
+ - Supports `gs://` URL scheme in key parameters
154
+ - Generates signed URLs using GCS's native signed URL mechanism
155
+
121
156
  ### Multiple Storage Backends
122
157
 
123
158
  Rebase supports multiple storage backends simultaneously via the `StorageRegistry`:
@@ -138,7 +173,7 @@ const backend = await initializeRebaseBackend({
138
173
  });
139
174
  ```
140
175
 
141
- > **IMPORTANT FOR AGENTS:** If no `"(default)"` key is provided, the first entry is automatically registered as the default (with a console warning). The REST API routes always use the default controller. Use `storageRegistry.get("media")` or `storageRegistry.getOrDefault("media")` to access named backends programmatically.
176
+ > **IMPORTANT FOR AGENTS:** If no `"(default)"` key is provided, the first entry is automatically registered as the default (with a console warning). The REST API routes use the default controller unless a `?storageId=<key>` query parameter is provided (see [REST API Endpoints](#rest-api-endpoints)). Use `storageRegistry.get("media")` or `storageRegistry.getOrDefault("media")` to access named backends programmatically.
142
177
 
143
178
  ### StorageRegistry API
144
179
 
@@ -156,7 +191,7 @@ The `StorageRegistry` interface:
156
191
 
157
192
  ### Custom Storage Providers
158
193
 
159
- Implement the `StorageController` interface for unsupported providers (Azure Blob, native GCS SDK, etc.):
194
+ GCS is now built-in (see [Google Cloud Storage (GCS)](#google-cloud-storage-gcs)). Implement the `StorageController` interface for other unsupported providers (Azure Blob, etc.):
160
195
 
161
196
  ```typescript
162
197
  interface StorageController {
@@ -185,7 +220,7 @@ The backend validates storage-related environment variables via a Zod schema:
185
220
 
186
221
  | Variable | Type | Default | Description |
187
222
  |----------|------|---------|-------------|
188
- | `STORAGE_TYPE` | `"local" \| "s3"` | `"local"` | Storage provider type |
223
+ | `STORAGE_TYPE` | `"local" \| "s3" \| "gcs"` | `"local"` | Storage provider type |
189
224
  | `STORAGE_PATH` | `string` | `"./uploads"` | Base path for local storage / TUS temp directory |
190
225
  | `FORCE_LOCAL_STORAGE` | `"true" \| "false"` | `false` | Suppress the production warning for local storage |
191
226
  | `S3_BUCKET` | `string` | — | S3 bucket name |
@@ -194,8 +229,12 @@ The backend validates storage-related environment variables via a Zod schema:
194
229
  | `S3_SECRET_ACCESS_KEY` | `string` | — | S3 secret access key |
195
230
  | `S3_ENDPOINT` | `string` (URL) | — | Custom S3-compatible endpoint |
196
231
  | `S3_FORCE_PATH_STYLE` | `"true" \| "false"` | — | Enable path-style URLs |
232
+ | `GCS_BUCKET` | `string` | — | GCS bucket name |
233
+ | `GCS_PROJECT_ID` | `string` | — | Google Cloud project ID (auto-detected on GCP) |
234
+ | `GOOGLE_APPLICATION_CREDENTIALS` | `string` (path) | — | Path to GCP service account key JSON file |
197
235
 
198
236
  ```env
237
+ # S3 example
199
238
  STORAGE_TYPE=s3
200
239
  S3_BUCKET=my-bucket
201
240
  S3_REGION=us-east-1
@@ -203,12 +242,35 @@ S3_ACCESS_KEY_ID=AKIA...
203
242
  S3_SECRET_ACCESS_KEY=...
204
243
  S3_ENDPOINT=https://minio.example.com # Optional: for MinIO, R2, etc.
205
244
  S3_FORCE_PATH_STYLE=true # Optional: for MinIO
245
+
246
+ # GCS example
247
+ STORAGE_TYPE=gcs
248
+ GCS_BUCKET=my-gcs-bucket
249
+ GCS_PROJECT_ID=my-gcp-project # Optional: auto-detected on GCP
250
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
206
251
  ```
207
252
 
208
253
  ## REST API Endpoints
209
254
 
210
255
  All storage routes are mounted at `/api/storage` by default. Write operations require authentication when `requireAuth` is `true` (the default). Read operations also require authentication unless `publicRead` is `true`.
211
256
 
257
+ ### Multi-Backend Routing (`storageId`)
258
+
259
+ All storage endpoints accept a `?storageId=<key>` query parameter that routes the request to a specific named backend in the `StorageRegistry`. For multipart uploads (`POST /api/storage/upload`), the `storageId` can also be sent as a form field.
260
+
261
+ If `storageId` is omitted, the request is routed to the `(default)` backend.
262
+
263
+ ```
264
+ # Upload to the "media" backend
265
+ POST /api/storage/upload?storageId=media
266
+
267
+ # Download from the "media" backend
268
+ GET /api/storage/file/images/photo.jpg?storageId=media
269
+
270
+ # List files in the "media" backend
271
+ GET /api/storage/list?prefix=images/&storageId=media
272
+ ```
273
+
212
274
  ### Standard Endpoints
213
275
 
214
276
  | Method | Endpoint | Auth | Description |
@@ -241,6 +303,7 @@ Upload a file via `multipart/form-data`.
241
303
  | `file` | `File` | Yes | The file to upload |
242
304
  | `key` | `string` | No | Storage key/path. Falls back to original filename, then `"unnamed"` |
243
305
  | `bucket` | `string` | No | Target bucket name |
306
+ | `storageId` | `string` | No | Route to a named backend (alternative to `?storageId` query param) |
244
307
  | `metadata_*` | `string` | No | Custom metadata keys prefixed with `metadata_` |
245
308
 
246
309
  **Response** (201):
@@ -562,6 +625,24 @@ Transformed responses include `Cache-Control: public, max-age=31536000, immutabl
562
625
 
563
626
  The `@rebasepro/client` package provides a `StorageSource` interface via `createStorage(transport)`. These methods are available on `client.storage` (or through the React/Studio hooks).
564
627
 
628
+ All SDK methods accept an optional `storageId` parameter (passed as a query parameter) to route the request to a specific named backend:
629
+
630
+ ```typescript
631
+ // Upload to a specific backend
632
+ const result = await client.storage.putObject({
633
+ file: myFile,
634
+ key: "products/images/photo.jpg",
635
+ storageId: "media", // routes to the "media" backend
636
+ });
637
+
638
+ // Get signed URL from a specific backend
639
+ const config = await client.storage.getSignedUrl(
640
+ "products/images/photo.jpg",
641
+ "default", // bucket
642
+ { storageId: "media" }
643
+ );
644
+ ```
645
+
565
646
  ### putObject
566
647
 
567
648
  Upload a file.
@@ -591,12 +672,15 @@ const config = await client.storage.getSignedUrl(
591
672
  if (config.fileNotFound) {
592
673
  console.log("File does not exist");
593
674
  } else {
594
- console.log(config.url); // Full URL with auth token appended
675
+ console.log(config.url); // Private: short-lived scoped token appended. Public: clean permanent URL.
595
676
  console.log(config.metadata); // { bucket, fullPath, name, size, contentType, customMetadata }
596
677
  }
597
678
  ```
598
679
 
599
- The URL is constructed as `{baseUrl}/api/storage/file/{path}?token={accessToken}`.
680
+ **URL model never contains the access token:**
681
+
682
+ - **Private files** → `{baseUrl}/api/storage/file/{path}?token={scopedDownloadToken}`. The token is a **short-lived, path-scoped** download token (default 5 min), signed by the server and valid only for that object — never the caller's full access JWT. Because it expires, **do not persist a private URL**: store the file **path** and call `getSignedUrl()` again at render time.
683
+ - **Public files** → `{baseUrl}/api/storage/file/{path}` with **no token**. A file is public when it lives under the `public/` prefix — set `storage: { public: true }` on the property, or pass `public: true` to `putObject`. Public URLs are **stable, permanent, and CDN-cacheable**, so they are safe to store in a database and hotlink. For public paths, `getSignedUrl()` returns the URL immediately with no server round-trip.
600
684
 
601
685
  Protocol prefixes (`local://`, `s3://`) are automatically stripped from the key.
602
686
 
@@ -652,9 +736,9 @@ console.log(result.nextPageToken); // for next page, or undefined
652
736
  Define file upload fields in your collections using the `storage` option on string properties:
653
737
 
654
738
  ```typescript
655
- import { PostgresCollection } from "@rebasepro/types";
739
+ import { PostgresCollectionConfig } from "@rebasepro/types";
656
740
 
657
- const productsCollection: PostgresCollection = {
741
+ const productsCollection: PostgresCollectionConfig = {
658
742
  name: "Products",
659
743
  table: "products",
660
744
  properties: {
@@ -662,7 +746,8 @@ const productsCollection: PostgresCollection = {
662
746
  name: "Product Image",
663
747
  type: "string",
664
748
  storage: {
665
- storagePath: "products/images",
749
+ storageSource: "external-source",
750
+ storagePath: "products/{entityId}",
666
751
  acceptedFiles: ["image/*"],
667
752
  maxSize: 5 * 1024 * 1024, // 5MB
668
753
  }
@@ -673,6 +758,7 @@ const productsCollection: PostgresCollection = {
673
758
  of: {
674
759
  type: "string",
675
760
  storage: {
761
+ storageSource: "media", // routes to the "media" backend
676
762
  storagePath: "products/documents",
677
763
  acceptedFiles: ["application/pdf", "application/msword"],
678
764
  }
@@ -682,6 +768,88 @@ const productsCollection: PostgresCollection = {
682
768
  };
683
769
  ```
684
770
 
771
+ The `storageSource` field maps to a named backend registered in the `StorageRegistry` (server-side) or in the `storageSources` prop (client-side). When omitted, the `(default)` backend is used.
772
+
773
+ ### StorageConfig Properties
774
+
775
+ | Property | Type | Description |
776
+ |----------|------|-------------|
777
+ | `storageSource` | `string` | Named backend to route uploads to (optional, defaults to `(default)`) |
778
+ | `storagePath` | `string` | Path template for uploaded files. Supports `{entityId}` placeholder |
779
+ | `acceptedFiles` | `string[]` | Accepted MIME types or globs (e.g., `["image/*"]`) |
780
+ | `maxSize` | `number` | Maximum file size in bytes |
781
+ | `public` | `boolean` | Store files under the `public/` prefix and serve them via stable, token-less, permanent, CDN-cacheable URLs (safe to persist/hotlink). Defaults to `false` (private, short-lived signed URLs). |
782
+
783
+ ## Frontend Storage Sources
784
+
785
+ The `<Rebase>` component accepts a `storageSources` prop to register client-side storage sources, following the same pattern as `dataSources` for databases. This enables **direct** (client-to-provider) file operations, bypassing the Rebase backend.
786
+
787
+ ### Registering Storage Sources
788
+
789
+ ```tsx
790
+ import type { RebaseStorageSource } from "@rebasepro/core";
791
+ import { myExternalStorageSource } from "./my-external-storage";
792
+
793
+ <Rebase
794
+ client={rebaseClient}
795
+ storageSources={[
796
+ {
797
+ key: "external",
798
+ engine: "external",
799
+ transport: "direct",
800
+ source: myExternalStorageSource,
801
+ },
802
+ {
803
+ key: "media",
804
+ engine: "s3",
805
+ transport: "server", // proxied through the Rebase backend
806
+ label: "Media Storage",
807
+ },
808
+ ]}
809
+ >
810
+ {children}
811
+ </Rebase>
812
+ ```
813
+
814
+ ### StorageSourceDefinition
815
+
816
+ | Property | Type | Description |
817
+ |----------|------|-------------|
818
+ | `key` | `string` | **Required.** Unique identifier for this storage source |
819
+ | `engine` | `string` | **Required.** Storage engine type (e.g., `"firebase"`, `"s3"`, `"gcs"`, `"local"`) |
820
+ | `transport` | `"server" \| "direct"` | **Required.** `"server"` routes through the backend; `"direct"` talks to the provider from the client |
821
+ | `source` | `StorageSource` | The client-side `StorageSource` implementation (required when `transport` is `"direct"`) |
822
+ | `label` | `string` | Optional human-readable label for the UI |
823
+
824
+ ### StorageSourcesContext
825
+
826
+ Registered storage sources are exposed to the component tree via `StorageSourcesContext`, mirroring the `DataSourcesContext` pattern:
827
+
828
+ ```typescript
829
+ import { useStorageSources } from "@rebasepro/core";
830
+
831
+ function MyUploadComponent() {
832
+ const storageSources = useStorageSources();
833
+
834
+ // Get a specific source
835
+ const externalSource = storageSources.find(s => s.key === "external");
836
+
837
+ // Use it for direct uploads
838
+ if (externalSource?.source) {
839
+ await externalSource.source.putObject({ file, key: "photos/image.jpg" });
840
+ }
841
+ }
842
+ ```
843
+
844
+ ### Direct vs Server Transport
845
+
846
+ | Transport | Description | Use Case |
847
+ |-----------|-------------|----------|
848
+ | `"server"` | File operations are proxied through the Rebase backend REST API | S3, GCS, or local backends managed by the server |
849
+ | `"direct"` | File operations go directly from the client to the storage provider | External cloud storage, or any provider with client-side SDKs |
850
+
851
+ > **IMPORTANT FOR AGENTS:** Direct transport sources must provide a `source` property implementing the `StorageSource` interface. Server transport sources use the built-in SDK transport and only need `key` and `engine`.
852
+
685
853
  ## Storage Browser (Studio)
686
854
 
687
855
  The `@rebasepro/studio` package includes a built-in `StorageView` component:
@@ -256,8 +256,8 @@ These components can be overridden globally on `<Rebase>` (which acts as a fallb
256
256
  `<RebaseCMS>` accepts the full `RebaseCMSConfig`:
257
257
 
258
258
  ```typescript
259
- interface RebaseCMSConfig<EC extends EntityCollection = EntityCollection> {
260
- collections?: EC[] | EntityCollectionsBuilder<EC>;
259
+ interface RebaseCMSConfig<EC extends CollectionConfig = CollectionConfig> {
260
+ collections?: EC[] | CollectionConfigsBuilder<EC>;
261
261
  views?: AppView[] | AppViewsBuilder;
262
262
  homePage?: ReactNode;
263
263
  entityViews?: EntityCustomView[];
@@ -464,7 +464,7 @@ type CollectionPanelProps = {
464
464
  updateUrl?: boolean; // Sync filter/sort with URL (default: false)
465
465
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
466
466
  className?: string; // Container CSS class
467
- collectionOverrides?: Partial<EntityCollection>; // Additional overrides
467
+ collectionOverrides?: Partial<CollectionConfig>; // Additional overrides
468
468
  };
469
469
  ```
470
470
 
@@ -512,7 +512,7 @@ The Studio Bridge provides CMS capabilities to Studio components. When CMS is pr
512
512
  ```typescript
513
513
  interface StudioBridge {
514
514
  collectionRegistry: CollectionRegistryController;
515
- sideEntityController: SideEntityController;
515
+ sideEntityController: SidePanelController;
516
516
  urlController: UrlController;
517
517
  navigationState: NavigationStateController;
518
518
  breadcrumbs: BreadcrumbsController;
@@ -524,7 +524,7 @@ interface StudioBridge {
524
524
  | Hook | Return Type | Description |
525
525
  |------|-------------|-------------|
526
526
  | `useStudioCollectionRegistry()` | `CollectionRegistryController` | Access registered collections from Studio |
527
- | `useStudioSideEntityController()` | `SideEntityController` | Open/close entity side panels from Studio |
527
+ | `useStudioSidePanelController()` | `SidePanelController` | Open/close entity side panels from Studio |
528
528
  | `useStudioUrlController()` | `UrlController` | Build URLs and navigate from Studio |
529
529
  | `useStudioNavigationState()` | `NavigationStateController` | Access navigation state from Studio |
530
530
  | `useStudioBreadcrumbs()` | `BreadcrumbsController` | Set breadcrumbs from Studio tools |
@@ -534,7 +534,7 @@ All bridge hooks are exported from `@rebasepro/studio` (re-exported from `@rebas
534
534
  ```typescript
535
535
  import {
536
536
  useStudioCollectionRegistry,
537
- useStudioSideEntityController,
537
+ useStudioSidePanelController,
538
538
  useStudioUrlController,
539
539
  useStudioNavigationState,
540
540
  useStudioBreadcrumbs
@@ -567,7 +567,7 @@ import { StudioBridgeProvider } from "@rebasepro/studio";
567
567
 
568
568
  <StudioBridgeProvider value={{
569
569
  collectionRegistry: useCollectionRegistryController(),
570
- sideEntityController: useSideEntityController(),
570
+ sideEntityController: useSidePanel(),
571
571
  urlController: useUrlController(),
572
572
  navigationState: useNavigationStateController(),
573
573
  breadcrumbs: useBreadcrumbsController(),
@@ -610,7 +610,7 @@ These hooks are exported from `@rebasepro/core` and available inside any `<Rebas
610
610
 
611
611
  | Hook | Package | Description |
612
612
  |------|---------|-------------|
613
- | `useSideEntityController()` | `@rebasepro/admin` | Open/close entity side panels |
613
+ | `useSidePanel()` | `@rebasepro/admin` | Open/close entity side panels |
614
614
  | `useNavigationStateController()` | `@rebasepro/admin` | Navigate between views |
615
615
  | `useUrlController()` | `@rebasepro/admin` | Build URLs and navigate |
616
616
  | `useBreadcrumbsController()` | `@rebasepro/admin` | Set breadcrumbs |