@rebasepro/agent-skills 0.8.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.
- package/package.json +1 -1
- package/skills/rebase-admin/SKILL.md +32 -32
- package/skills/rebase-api/SKILL.md +6 -6
- package/skills/rebase-auth/SKILL.md +29 -7
- package/skills/rebase-backend-postgres/SKILL.md +3 -3
- package/skills/rebase-basics/SKILL.md +4 -4
- package/skills/rebase-collections/SKILL.md +46 -49
- package/skills/rebase-cron-jobs/SKILL.md +2 -2
- package/skills/rebase-deployment/SKILL.md +1 -1
- package/skills/rebase-email/SKILL.md +1 -1
- package/skills/rebase-entity-history/SKILL.md +9 -9
- package/skills/rebase-realtime/SKILL.md +5 -5
- package/skills/rebase-security/SKILL.md +62 -31
- package/skills/rebase-storage/SKILL.md +8 -4
- package/skills/rebase-studio/SKILL.md +8 -8
- package/skills/rebase-webhooks/SKILL.md +25 -30
|
@@ -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:
|
|
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:
|
|
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 →
|
|
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 →
|
|
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,13 +136,13 @@ 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
|
|
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
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
|
|
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
142
|
> - `uid: "api-key:{id}"` + `roles: ["service"]` (or `["admin", "service"]`) — API key requests.
|
|
143
143
|
> - Real user IDs and roles for JWT-authenticated requests.
|
|
144
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
|
|
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
146
|
|
|
147
147
|
---
|
|
148
148
|
|
|
@@ -247,9 +247,9 @@ interface BackendHookContext {
|
|
|
247
247
|
|
|
248
248
|
### Execution Order
|
|
249
249
|
|
|
250
|
-
DataHooks run **after** per-collection `
|
|
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:
|
|
251
251
|
|
|
252
|
-
1. **
|
|
252
|
+
1. **CollectionCallbacks** — per-collection, inside the driver
|
|
253
253
|
2. **DataHooks** — cross-cutting, at the API boundary
|
|
254
254
|
|
|
255
255
|
---
|
|
@@ -271,12 +271,12 @@ PostgreSQL RLS policies use `auth.uid()`, `auth.roles()`, and `auth.jwt()` to re
|
|
|
271
271
|
|
|
272
272
|
---
|
|
273
273
|
|
|
274
|
-
## Layer 5:
|
|
274
|
+
## Layer 5: Collection Callbacks
|
|
275
275
|
|
|
276
|
-
|
|
276
|
+
Collection callbacks are per-collection lifecycle hooks that run **inside** the DataDriver, close to the database. They provide collection-specific security enforcement:
|
|
277
277
|
|
|
278
278
|
```typescript
|
|
279
|
-
const ordersCollection:
|
|
279
|
+
const ordersCollection: PostgresCollectionConfig = {
|
|
280
280
|
name: "Orders",
|
|
281
281
|
slug: "orders",
|
|
282
282
|
table: "orders",
|
|
@@ -289,9 +289,9 @@ const ordersCollection: PostgresCollection = {
|
|
|
289
289
|
}
|
|
290
290
|
return values;
|
|
291
291
|
},
|
|
292
|
-
beforeDelete: async ({
|
|
292
|
+
beforeDelete: async ({ row, context }) => {
|
|
293
293
|
// Prevent deletion of fulfilled orders
|
|
294
|
-
if (
|
|
294
|
+
if (row.status === "fulfilled") {
|
|
295
295
|
throw new Error("Cannot delete fulfilled orders");
|
|
296
296
|
}
|
|
297
297
|
},
|
|
@@ -300,7 +300,7 @@ const ordersCollection: PostgresCollection = {
|
|
|
300
300
|
};
|
|
301
301
|
```
|
|
302
302
|
|
|
303
|
-
For full documentation on
|
|
303
|
+
For full documentation on collection callbacks, see the `rebase-collections` skill.
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|
|
@@ -327,7 +327,7 @@ Rebase follows a **fail-closed** security model throughout the stack:
|
|
|
327
327
|
|
|
328
328
|
## Securing Without Database RLS
|
|
329
329
|
|
|
330
|
-
If you cannot modify database-level RLS policies — or your database doesn't support them — use **DataHooks** and **
|
|
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.
|
|
331
331
|
|
|
332
332
|
### Strategy: DataHooks as Your Security Layer
|
|
333
333
|
|
|
@@ -384,7 +384,7 @@ const hooks: BackendHooks = {
|
|
|
384
384
|
|
|
385
385
|
if (!user.roles.includes("admin")) {
|
|
386
386
|
// For non-admins, you may need to fetch the entity first
|
|
387
|
-
// to verify ownership. Use
|
|
387
|
+
// to verify ownership. Use Collection Callbacks for this pattern
|
|
388
388
|
// since they receive the full entity.
|
|
389
389
|
throw ApiError.forbidden("Only admins can delete records");
|
|
390
390
|
}
|
|
@@ -393,40 +393,40 @@ const hooks: BackendHooks = {
|
|
|
393
393
|
};
|
|
394
394
|
```
|
|
395
395
|
|
|
396
|
-
### Strategy:
|
|
396
|
+
### Strategy: Collection Callbacks for Ownership Checks
|
|
397
397
|
|
|
398
|
-
|
|
398
|
+
Collection Callbacks receive the full entity data, making them ideal for ownership verification on deletes and updates:
|
|
399
399
|
|
|
400
400
|
```typescript
|
|
401
|
-
const ordersCollection:
|
|
401
|
+
const ordersCollection: PostgresCollectionConfig = {
|
|
402
402
|
name: "Orders",
|
|
403
403
|
slug: "orders",
|
|
404
404
|
table: "orders",
|
|
405
405
|
callbacks: {
|
|
406
|
-
beforeSave: async ({ values,
|
|
406
|
+
beforeSave: async ({ values, id, context, previousValues }) => {
|
|
407
407
|
const user = context.user;
|
|
408
408
|
if (!user) throw new Error("Unauthorized");
|
|
409
409
|
|
|
410
410
|
// On update, verify the current user owns this order
|
|
411
|
-
if (
|
|
411
|
+
if (id && previousValues) {
|
|
412
412
|
if (previousValues.user_id !== user.uid && !user.roles?.includes("admin")) {
|
|
413
413
|
throw new Error("You can only edit your own orders");
|
|
414
414
|
}
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
// On create, stamp ownership
|
|
418
|
-
if (!
|
|
418
|
+
if (!id) {
|
|
419
419
|
values.user_id = user.uid;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
return values;
|
|
423
423
|
},
|
|
424
424
|
|
|
425
|
-
beforeDelete: async ({
|
|
425
|
+
beforeDelete: async ({ row, context }) => {
|
|
426
426
|
const user = context.user;
|
|
427
427
|
if (!user) throw new Error("Unauthorized");
|
|
428
428
|
|
|
429
|
-
if (
|
|
429
|
+
if (row.user_id !== user.uid && !user.roles?.includes("admin")) {
|
|
430
430
|
throw new Error("You can only delete your own orders");
|
|
431
431
|
}
|
|
432
432
|
},
|
|
@@ -437,15 +437,15 @@ const ordersCollection: PostgresCollection = {
|
|
|
437
437
|
|
|
438
438
|
### Combining Both Layers
|
|
439
439
|
|
|
440
|
-
For maximum security, use both DataHooks and
|
|
440
|
+
For maximum security, use both DataHooks and Collection Callbacks together:
|
|
441
441
|
|
|
442
442
|
| Concern | Best Layer | Why |
|
|
443
443
|
|---|---|---|
|
|
444
444
|
| Cross-cutting read filtering | DataHooks `afterRead` | Applies to ALL collections in one place |
|
|
445
445
|
| Cross-cutting write validation | DataHooks `beforeSave` | Single enforcement point for all writes |
|
|
446
446
|
| PII masking / field redaction | DataHooks `afterRead` | Cross-cutting, role-based |
|
|
447
|
-
| Ownership checks on writes/deletes |
|
|
448
|
-
| Business rule validation |
|
|
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 |
|
|
449
449
|
| Audit logging | DataHooks `afterSave` / `afterDelete` | Cross-cutting, fire-and-forget |
|
|
450
450
|
|
|
451
451
|
---
|
|
@@ -503,13 +503,44 @@ const hooks: BackendHooks = {
|
|
|
503
503
|
},
|
|
504
504
|
|
|
505
505
|
beforeDelete(slug, entityId, ctx) {
|
|
506
|
-
// Tenant isolation on deletes is best handled in
|
|
506
|
+
// Tenant isolation on deletes is best handled in Collection Callbacks
|
|
507
507
|
// where you have access to the entity's tenant_id
|
|
508
508
|
},
|
|
509
509
|
},
|
|
510
510
|
};
|
|
511
511
|
```
|
|
512
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
|
+
|
|
513
544
|
### Role-Based Collection Access
|
|
514
545
|
|
|
515
546
|
```typescript
|
|
@@ -593,7 +624,7 @@ Use this checklist when setting up security for a Rebase project:
|
|
|
593
624
|
- [ ] **Default role is NOT admin** — `auth.defaultRole` must never be `"admin"` (startup error)
|
|
594
625
|
- [ ] **DataHooks enforce access control** — If not using database RLS, `hooks.data` enforces read/write/delete permissions
|
|
595
626
|
- [ ] **Sensitive fields are masked** — `afterRead` masks PII for non-admin users
|
|
596
|
-
- [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation;
|
|
627
|
+
- [ ] **Ownership is enforced** — `beforeSave` stamps `user_id` on creation; Collection Callbacks verify ownership on update/delete
|
|
597
628
|
- [ ] **API keys are scoped** — API keys have minimal permissions (specific collections + operations)
|
|
598
629
|
- [ ] **API keys are never client-side** — API keys bypass RLS; only use server-side
|
|
599
630
|
- [ ] **CORS is configured** — Restrict origins in production
|
|
@@ -611,5 +642,5 @@ Use this checklist when setting up security for a Rebase project:
|
|
|
611
642
|
- **Backend Hooks Types**: `packages/types/src/types/backend_hooks.ts` — `DataHooks`, `BackendHooks` interfaces
|
|
612
643
|
- **Backend Init**: `packages/server-core/src/init.ts` — `hooks` config property
|
|
613
644
|
- **Reserved Identity Values**: See Identity Types table above — `"service"`, `"anon"`, `"api-key:{id}"` are system-assigned identities in `context.user` / `ctx.requestUser`
|
|
614
|
-
- **
|
|
645
|
+
- **Collection Callbacks**: See `rebase-collections` skill → Collection Callbacks section
|
|
615
646
|
- **Auth Configuration**: See `rebase-auth` skill → Server-Side Configuration section
|
|
@@ -672,12 +672,15 @@ const config = await client.storage.getSignedUrl(
|
|
|
672
672
|
if (config.fileNotFound) {
|
|
673
673
|
console.log("File does not exist");
|
|
674
674
|
} else {
|
|
675
|
-
console.log(config.url); //
|
|
675
|
+
console.log(config.url); // Private: short-lived scoped token appended. Public: clean permanent URL.
|
|
676
676
|
console.log(config.metadata); // { bucket, fullPath, name, size, contentType, customMetadata }
|
|
677
677
|
}
|
|
678
678
|
```
|
|
679
679
|
|
|
680
|
-
|
|
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.
|
|
681
684
|
|
|
682
685
|
Protocol prefixes (`local://`, `s3://`) are automatically stripped from the key.
|
|
683
686
|
|
|
@@ -733,9 +736,9 @@ console.log(result.nextPageToken); // for next page, or undefined
|
|
|
733
736
|
Define file upload fields in your collections using the `storage` option on string properties:
|
|
734
737
|
|
|
735
738
|
```typescript
|
|
736
|
-
import {
|
|
739
|
+
import { PostgresCollectionConfig } from "@rebasepro/types";
|
|
737
740
|
|
|
738
|
-
const productsCollection:
|
|
741
|
+
const productsCollection: PostgresCollectionConfig = {
|
|
739
742
|
name: "Products",
|
|
740
743
|
table: "products",
|
|
741
744
|
properties: {
|
|
@@ -775,6 +778,7 @@ The `storageSource` field maps to a named backend registered in the `StorageRegi
|
|
|
775
778
|
| `storagePath` | `string` | Path template for uploaded files. Supports `{entityId}` placeholder |
|
|
776
779
|
| `acceptedFiles` | `string[]` | Accepted MIME types or globs (e.g., `["image/*"]`) |
|
|
777
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). |
|
|
778
782
|
|
|
779
783
|
## Frontend Storage Sources
|
|
780
784
|
|
|
@@ -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
|
|
260
|
-
collections?: 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<
|
|
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:
|
|
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
|
-
| `
|
|
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
|
-
|
|
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:
|
|
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
|
-
| `
|
|
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 |
|
|
@@ -5,7 +5,7 @@ description: Guide for sending outbound HTTP webhooks on entity changes in a Reb
|
|
|
5
5
|
|
|
6
6
|
# Rebase Webhooks
|
|
7
7
|
|
|
8
|
-
> **IMPORTANT FOR AGENTS**: The `WebhookDispatcher` is a **standalone service class** exported from `@rebasepro/server-core`. It is **not** auto-wired into the backend init pipeline — you must instantiate it yourself and call `onEntityChange()` from your application code (e.g.
|
|
8
|
+
> **IMPORTANT FOR AGENTS**: The `WebhookDispatcher` is a **standalone service class** exported from `@rebasepro/server-core`. It is **not** auto-wired into the backend init pipeline — you must instantiate it yourself and call `onEntityChange()` from your application code (e.g. collection callbacks, custom functions, or cron jobs). Do NOT look for a `webhooks` key in `RebaseBackendConfig`.
|
|
9
9
|
|
|
10
10
|
> **IMPORTANT FOR AGENTS**: Webhooks are **outbound** HTTP POST requests sent by your Rebase backend to external URLs. They are NOT inbound endpoints. To receive webhooks FROM external services, use Rebase custom functions instead (see `rebase-custom-functions` skill).
|
|
11
11
|
|
|
@@ -71,7 +71,7 @@ dispatcher.setWebhooks([
|
|
|
71
71
|
|
|
72
72
|
### Dispatch on Entity Changes
|
|
73
73
|
|
|
74
|
-
Call `onEntityChange()` whenever
|
|
74
|
+
Call `onEntityChange()` whenever a entity is created, updated, or deleted. The dispatcher checks all registered webhooks and fires matching ones.
|
|
75
75
|
|
|
76
76
|
```typescript
|
|
77
77
|
const results = await dispatcher.onEntityChange(
|
|
@@ -355,13 +355,13 @@ If the receiver does not respond within 10 seconds, the request is aborted and t
|
|
|
355
355
|
|
|
356
356
|
## Integration Patterns
|
|
357
357
|
|
|
358
|
-
### With
|
|
358
|
+
### With Collection Callbacks
|
|
359
359
|
|
|
360
|
-
The most common pattern is to wire the dispatcher into Rebase
|
|
360
|
+
The most common pattern is to wire the dispatcher into Rebase collection callbacks so webhooks fire automatically on CRUD operations:
|
|
361
361
|
|
|
362
362
|
```typescript
|
|
363
363
|
// backend/collections/orders.ts
|
|
364
|
-
import type {
|
|
364
|
+
import type { CollectionConfig, CollectionCallbacks } from "@rebasepro/types";
|
|
365
365
|
import { WebhookDispatcher } from "@rebasepro/server-core/services/webhook-service";
|
|
366
366
|
|
|
367
367
|
const dispatcher = new WebhookDispatcher();
|
|
@@ -376,35 +376,28 @@ dispatcher.setWebhooks([
|
|
|
376
376
|
},
|
|
377
377
|
]);
|
|
378
378
|
|
|
379
|
-
const callbacks:
|
|
380
|
-
|
|
379
|
+
const callbacks: CollectionCallbacks = {
|
|
380
|
+
afterSave: async ({ id, values, previousValues, status, collection }) => {
|
|
381
|
+
const event = status === "new" ? "INSERT" : "UPDATE";
|
|
381
382
|
await dispatcher.onEntityChange(
|
|
382
383
|
collection.path,
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
},
|
|
388
|
-
afterUpdate: async ({ entity, previousEntity, collection }) => {
|
|
389
|
-
await dispatcher.onEntityChange(
|
|
390
|
-
collection.path,
|
|
391
|
-
"UPDATE",
|
|
392
|
-
entity.id,
|
|
393
|
-
entity,
|
|
394
|
-
previousEntity
|
|
384
|
+
event,
|
|
385
|
+
String(id),
|
|
386
|
+
values,
|
|
387
|
+
status === "new" ? undefined : previousValues
|
|
395
388
|
);
|
|
396
389
|
},
|
|
397
|
-
afterDelete: async ({
|
|
390
|
+
afterDelete: async ({ id, collection }) => {
|
|
398
391
|
await dispatcher.onEntityChange(
|
|
399
392
|
collection.path,
|
|
400
393
|
"DELETE",
|
|
401
|
-
|
|
394
|
+
String(id),
|
|
402
395
|
null
|
|
403
396
|
);
|
|
404
397
|
},
|
|
405
398
|
};
|
|
406
399
|
|
|
407
|
-
const ordersCollection:
|
|
400
|
+
const ordersCollection: CollectionConfig = {
|
|
408
401
|
name: "Orders",
|
|
409
402
|
path: "orders",
|
|
410
403
|
callbacks,
|
|
@@ -519,9 +512,11 @@ Then import it from any callback or function:
|
|
|
519
512
|
```typescript
|
|
520
513
|
import { dispatcher } from "../lib/webhooks";
|
|
521
514
|
|
|
522
|
-
// In
|
|
523
|
-
|
|
524
|
-
|
|
515
|
+
// In a collection callback:
|
|
516
|
+
afterSave: async ({ id, values, status, collection }) => {
|
|
517
|
+
if (status === "new") {
|
|
518
|
+
await dispatcher.onEntityChange(collection.path, "INSERT", String(id), values);
|
|
519
|
+
}
|
|
525
520
|
},
|
|
526
521
|
```
|
|
527
522
|
|
|
@@ -535,7 +530,7 @@ Registers the list of webhooks to watch. Filters out any with `enabled: false`.
|
|
|
535
530
|
|-----------|------|-------------|
|
|
536
531
|
| `webhooks` | `WebhookConfig[]` | Array of webhook configurations. |
|
|
537
532
|
|
|
538
|
-
### `onEntityChange(table, event,
|
|
533
|
+
### `onEntityChange(table, event, id, entity, previousEntity?): Promise<WebhookDeliveryResult[]>`
|
|
539
534
|
|
|
540
535
|
Checks all registered webhooks for matching `table` + `event`, and dispatches to each match.
|
|
541
536
|
|
|
@@ -543,7 +538,7 @@ Checks all registered webhooks for matching `table` + `event`, and dispatches to
|
|
|
543
538
|
|-----------|------|-------------|
|
|
544
539
|
| `table` | `string` | The database table name (e.g. `"orders"`). |
|
|
545
540
|
| `event` | `"INSERT" \| "UPDATE" \| "DELETE"` | The type of entity change. |
|
|
546
|
-
| `
|
|
541
|
+
| `id` | `string` | The unique ID of the changed entity. |
|
|
547
542
|
| `entity` | `Record<string, unknown> \| null` | The current entity data. May be `null` for deletes. |
|
|
548
543
|
| `previousEntity` | `Record<string, unknown> \| null` | *(Optional)* The previous entity state. Only relevant for `UPDATE` events — included as `old_record` in the payload. |
|
|
549
544
|
|
|
@@ -557,7 +552,7 @@ The dispatcher supports three event types, passed as the `event` parameter to `o
|
|
|
557
552
|
|-------|-------------|--------------------|-----------------------|
|
|
558
553
|
| `INSERT` | A new entity was created | The new entity | `undefined` |
|
|
559
554
|
| `UPDATE` | An existing entity was modified | The updated entity | The entity before update |
|
|
560
|
-
| `DELETE` |
|
|
555
|
+
| `DELETE` | a entity was removed | The deleted entity (or `null`) | `undefined` |
|
|
561
556
|
|
|
562
557
|
## Error Handling
|
|
563
558
|
|
|
@@ -592,9 +587,9 @@ for (const result of results) {
|
|
|
592
587
|
If you don't want webhook delivery to block your API response, use fire-and-forget:
|
|
593
588
|
|
|
594
589
|
```typescript
|
|
595
|
-
|
|
590
|
+
afterSave: async ({ id, values, collection }) => {
|
|
596
591
|
// Fire-and-forget — don't await
|
|
597
|
-
dispatcher.onEntityChange(collection.path, "INSERT",
|
|
592
|
+
dispatcher.onEntityChange(collection.path, "INSERT", String(id), values)
|
|
598
593
|
.catch(err => console.error("Webhook dispatch error:", err));
|
|
599
594
|
},
|
|
600
595
|
```
|