@rebasepro/types 0.19.1 → 0.19.2-canary.g09316f6

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.
@@ -62,6 +62,12 @@ export interface SchemaCommitPaths {
62
62
  searchFile: string;
63
63
  /** Vector columns and ANN indexes — like search, applied by Rebase not Atlas. */
64
64
  vectorFile: string;
65
+ /**
66
+ * `autoValue: "on_update"` triggers and the function they share. Atlas's
67
+ * free tier will not parse a desired state containing a function, so this
68
+ * is applied by Rebase like search and vector.
69
+ */
70
+ triggersFile: string;
65
71
  }
66
72
  export declare const DEFAULT_COMMIT_PATHS: SchemaCommitPaths;
67
73
  /** One file the commit writes, as content rather than as a path on a disk. */
@@ -0,0 +1,147 @@
1
+ /**
2
+ * First-class multi-tenancy: one declaration, every layer.
3
+ *
4
+ * A tenant-scoped collection was expert work. It took four separate,
5
+ * hand-written pieces that nothing checked against each other — a column, an
6
+ * `existsIn` or raw RLS rule, a value stamped on every insert by a callback,
7
+ * and an index somebody had to remember. Miss the index and the table scans;
8
+ * miss the stamp and the row is invisible the moment it is written; miss the
9
+ * rule and every tenant reads every other tenant's rows, which is the failure
10
+ * nothing surfaces until it is a disclosure.
11
+ *
12
+ * {@link CollectionTenantConfig} is the one place that says "this collection
13
+ * belongs to a tenant", and the four pieces are derived from it:
14
+ *
15
+ * - the column is `NOT NULL` and gets a btree index (`planSchema`);
16
+ * - a **restrictive** RLS policy is injected for every operation, so it
17
+ * composes with (rather than replaces) whatever `securityRules` the
18
+ * collection declares — tenancy narrows, it never grants;
19
+ * - the write path stamps the caller's tenant on create, refuses a write that
20
+ * names another tenant, and refuses an update that moves a row between
21
+ * tenants;
22
+ * - the OpenAPI document marks the field so a generated client can see it.
23
+ *
24
+ * @see CollectionTenantConfig
25
+ * @group Models
26
+ */
27
+ /**
28
+ * The caller's tenant comes from a claim on their session token.
29
+ *
30
+ * The single-tenant-per-user shape: an identity provider (or Rebase's own
31
+ * custom-claims hook) puts the organization on the token, and every request
32
+ * carries it. Compiles to a comparison against `rebase.jwt() ->> '<claim>'`,
33
+ * which is the same value a hand-written rule would read — so the generated
34
+ * policy and anything an author writes beside it agree by construction.
35
+ *
36
+ * @group Models
37
+ */
38
+ export interface TenantClaimSource {
39
+ /**
40
+ * The claim's name on the access token, e.g. `"org_id"`.
41
+ *
42
+ * Custom claims survive verification and reach RLS as `rebase.jwt()`; the
43
+ * identity claims (`uid`, `roles`, `aal`, `isAnonymous`) are written after
44
+ * them when a token is minted and cannot be shadowed, so naming one of
45
+ * those here is refused rather than quietly reading the identity.
46
+ */
47
+ claim: string;
48
+ }
49
+ /**
50
+ * The caller's tenants come from rows of a membership collection.
51
+ *
52
+ * The many-tenants-per-user shape — a `memberships` table with a user column
53
+ * and a tenant column, which is how a person belongs to three organizations at
54
+ * once. Compiles to a correlated `EXISTS` over that table (`policy.existsIn`),
55
+ * so the database answers "is the caller a member of this row's tenant?" in the
56
+ * same query rather than in an N+1 of lookups.
57
+ *
58
+ * Nothing is put on the token, so nothing has to be re-minted when somebody
59
+ * joins or leaves a tenant — the next statement already sees the new row.
60
+ *
61
+ * @group Models
62
+ */
63
+ export interface TenantMembershipSource {
64
+ membership: {
65
+ /** Slug of the collection holding the memberships. */
66
+ collection: string;
67
+ /** The property on it that holds the user id (compared to `rebase.uid()`). */
68
+ userField: string;
69
+ /** The property on it that holds the tenant id. */
70
+ tenantField: string;
71
+ };
72
+ }
73
+ /** Where the caller's tenant comes from. @group Models */
74
+ export type TenantSource = TenantClaimSource | TenantMembershipSource;
75
+ /** Narrow a {@link TenantSource} to its claim form. @group Models */
76
+ export declare function isTenantClaimSource(source: TenantSource): source is TenantClaimSource;
77
+ /** Narrow a {@link TenantSource} to its membership form. @group Models */
78
+ export declare function isTenantMembershipSource(source: TenantSource): source is TenantMembershipSource;
79
+ /**
80
+ * The roles tenancy does not apply to, when the collection names none.
81
+ *
82
+ * `admin`, mirroring the security baseline every collection already carries
83
+ * (`<table>_default_admin_read` / `_write`): the Studio, `dataAsAdmin` and a
84
+ * support operator all run with it, and a tenancy rule that locked them out
85
+ * would make the admin panel show an empty table on a collection full of rows.
86
+ *
87
+ * @group Models
88
+ */
89
+ export declare const DEFAULT_TENANT_BYPASS_ROLES: readonly string[];
90
+ /**
91
+ * Declare a collection tenant-scoped.
92
+ *
93
+ * ```ts
94
+ * export const posts = buildCollection({
95
+ * slug: "posts",
96
+ * properties: {
97
+ * orgId: { type: "string", validation: { required: true } },
98
+ * title: { type: "string" }
99
+ * },
100
+ * tenant: { field: "orgId", from: { claim: "org_id" } }
101
+ * });
102
+ * ```
103
+ *
104
+ * The property has to exist — this says what a column *means*, it does not
105
+ * conjure one into existence, exactly like `softDelete`. A config naming a
106
+ * property the collection does not declare is refused at boot rather than at
107
+ * the first read.
108
+ *
109
+ * ## What it composes with
110
+ *
111
+ * The injected policy is **restrictive**, so it is ANDed with every permissive
112
+ * policy on the table: `securityRules`, `ownerField`, the injected admin
113
+ * baseline. That is the only composition that is safe by construction — a
114
+ * permissive tenancy policy would OR with the author's rules and a single
115
+ * `access: "public"` rule would take the whole tenancy boundary off.
116
+ *
117
+ * Postgres-only. RLS is what enforces it, and an engine without row-level
118
+ * security cannot be given this guarantee by an application-layer filter that
119
+ * a raw query goes around.
120
+ *
121
+ * @group Models
122
+ */
123
+ export interface CollectionTenantConfig<M extends Record<string, unknown> = Record<string, unknown>> {
124
+ /**
125
+ * The property holding the tenant id.
126
+ *
127
+ * A `string` or `number` property, or a `reference` / `belongsTo` relation
128
+ * to the tenants collection — in which case the foreign key the relation
129
+ * already declares is the column, and no second one is created.
130
+ *
131
+ * The column is made `NOT NULL` and indexed: a nullable tenant column is a
132
+ * row that belongs to nobody and is therefore invisible to everybody, and
133
+ * an unindexed one turns every RLS-filtered read into a sequential scan.
134
+ */
135
+ field: Extract<keyof M, string> | string;
136
+ /** Where the caller's tenant comes from. */
137
+ from: TenantSource;
138
+ /**
139
+ * Roles that see and write across every tenant.
140
+ *
141
+ * Defaults to {@link DEFAULT_TENANT_BYPASS_ROLES}. An empty array means
142
+ * "nobody bypasses" — the trusted server context still does, because it is
143
+ * what runs migrations and the auth flows, and a policy that excluded it
144
+ * would break the boot rather than protect a tenant.
145
+ */
146
+ bypassRoles?: readonly string[];
147
+ }
@@ -39,6 +39,41 @@ export type WirePrimaryKeys = {
39
39
  type: "string" | "number";
40
40
  isUUID?: boolean;
41
41
  }[];
42
+ /**
43
+ * What a `collection_update` frame says about the page it carries.
44
+ *
45
+ * The same shape a REST list's `meta` has, and for the same reason: a
46
+ * subscriber renders the list it was handed, and rendering it needs to know how
47
+ * many rows there are in total and whether there is another page.
48
+ *
49
+ * It used not to be sent. The frame carried rows and primary keys and nothing
50
+ * else, so the SDK issued a `GET /<collection>/count` **on every push** to
51
+ * recover it — one extra round trip per write, per subscriber, and a window in
52
+ * which the count and the rows described different states of the collection.
53
+ * The refetch already knows the query, so it counts once, beside the rows,
54
+ * inside the same RLS-bound transaction that read them.
55
+ */
56
+ export interface CollectionUpdateMeta {
57
+ /** Rows matching the subscription's query. Absent when the count failed. */
58
+ total?: number;
59
+ /** Page size the rows were read with. */
60
+ limit: number;
61
+ /** Rows skipped to reach them. */
62
+ offset: number;
63
+ /** Whether rows exist beyond this page. */
64
+ hasMore: boolean;
65
+ /** The opaque cursor continuing this listing — see `PaginationMeta.nextCursor`. */
66
+ nextCursor?: string;
67
+ /**
68
+ * The count could not be taken, so `total` is missing and `hasMore` is a
69
+ * floor rather than an answer.
70
+ *
71
+ * Flagged rather than guessed: a subscriber that knows the total is unknown
72
+ * can keep the last one it had, which is what the SDK does. Substituting
73
+ * `rows.length` would claim a page read at offset 10 held two rows.
74
+ */
75
+ partial?: boolean;
76
+ }
42
77
  export interface CollectionUpdateMessage extends WebSocketMessage {
43
78
  type: "collection_update";
44
79
  subscriptionId: string;
@@ -50,6 +85,8 @@ export interface CollectionUpdateMessage extends WebSocketMessage {
50
85
  * unchanged rows' references needs an address to match them by.
51
86
  */
52
87
  pks?: WirePrimaryKeys;
88
+ /** See {@link CollectionUpdateMeta}. */
89
+ meta?: CollectionUpdateMeta;
53
90
  }
54
91
  export interface SingleUpdateMessage extends WebSocketMessage {
55
92
  type: "single_update";
@@ -58,5 +58,26 @@ export type User = {
58
58
  * Accessible by the frontend, but only writable by the backend.
59
59
  */
60
60
  readonly metadata?: Record<string, any>;
61
+ /**
62
+ * The **custom claims on this session's token**, as the server verified
63
+ * them — not the user row's {@link metadata}, which is a different fact
64
+ * with a different lifetime.
65
+ *
66
+ * The distinction matters for authorization. `metadata` is whatever the
67
+ * users table holds right now; `claims` is what the caller's token asserts,
68
+ * which is what the database sees: they reach RLS as `rebase.jwt()`, and
69
+ * `policy.authClaim("org_id")` reads one. Multi-tenancy's `claim` form is
70
+ * built on them.
71
+ *
72
+ * Identity claims are not here. `uid`, `roles`, `aal` and `isAnonymous` are
73
+ * written *after* the custom claims when a token is minted, precisely so a
74
+ * claims hook cannot assert them, and they have their own fields on this
75
+ * type. Keeping them out means a rule reading `claims` can never be reading
76
+ * something the session asserted about its own identity.
77
+ *
78
+ * Absent for a caller with no token — an API key, a service identity, an
79
+ * anonymous request.
80
+ */
81
+ readonly claims?: Record<string, unknown>;
61
82
  getIdToken?: (forceRefresh?: boolean) => Promise<string>;
62
83
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebasepro/types",
3
- "version": "0.19.1",
3
+ "version": "0.19.2-canary.g09316f6",
4
4
  "description": "Rebase type definitions — shared interfaces and controller types",
5
5
  "keywords": [
6
6
  "rebase",