@rebasepro/common 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.
@@ -1,5 +1,22 @@
1
1
  import { DataType, Entity, EntityReference, EntityRelation, EntityStatus, EntityValues, Properties, Property } from "@rebasepro/types";
2
2
  export declare function isPropertyBuilder(property?: Property): boolean;
3
+ /**
4
+ * What a form opens with: a value for every property it can write.
5
+ *
6
+ * `excludeFromApi` columns are left out, and that is the whole of the rule —
7
+ * they are not part of the API surface in either direction, so there is nothing
8
+ * for a form to open showing and nothing it may send back. Including them was
9
+ * not cosmetic: the baseline is what gets submitted, so a new record carried
10
+ * `passwordHash: null` and `emailVerificationToken: null` into the create, and
11
+ * the server refused the whole write with "these columns are the server's to
12
+ * set" — the users collection could not be added to from the panel at all. The
13
+ * fields were invisible on screen (`admin.disabled.hidden`), which is what made
14
+ * the error read as being about the roles the operator *had* just edited.
15
+ *
16
+ * Server-side defaulting does not come through here: `applyDefaultValuesOnCreate`
17
+ * asks each property for its own default, so an excluded column with a declared
18
+ * `defaultValue` is still filled in on an in-process write.
19
+ */
3
20
  export declare function getDefaultValuesFor<M extends Record<string, unknown>>(properties: Properties): Partial<EntityValues<M>>;
4
21
  export declare function getDefaultValueFor(property?: Property): unknown;
5
22
  export declare function getDefaultValueFortype(type: DataType): unknown;
@@ -13,6 +30,68 @@ export declare function updateDateAutoValues<M extends Record<string, unknown>>(
13
30
  status: EntityStatus;
14
31
  timestampNowValue: unknown;
15
32
  }): EntityValues<M>;
33
+ /**
34
+ * Stamp the acting user's uid into the `user_on_create` / `user_on_update`
35
+ * columns a collection declares.
36
+ *
37
+ * A deliberate sibling of {@link updateDateAutoValues} rather than another
38
+ * branch inside it. The two share a shape and nothing else: one takes an
39
+ * instant the server generates and the other takes an identity the request
40
+ * carries, so overloading the timestamp function would have meant threading a
41
+ * second, unrelated argument through every one of its callers and letting a
42
+ * `date` property and a `string` property compete for the same `autoValue`
43
+ * union. Called side by side in the driver.
44
+ *
45
+ * The stamped value overwrites whatever arrived in the body. A caller who can
46
+ * set `createdBy` is a caller who can attribute their write to somebody else,
47
+ * which is the one thing an audit column must not allow.
48
+ *
49
+ * `uid` is `undefined` for an anonymous request, a service token or an
50
+ * in-process write; the column is set to an explicit `null` there. Explicit
51
+ * matters on an update: leaving the key absent would keep whatever uid the
52
+ * column already held, so an anonymous edit would be recorded as the previous
53
+ * editor's. Refusing that write outright is `required`'s job, not this
54
+ * function's — see `assertWriteValuesValid`.
55
+ *
56
+ * Top-level properties only, deliberately, unlike {@link updateDateAutoValues}.
57
+ * `traverseValuesProperties` cannot express "set this key to null" — a `null`
58
+ * from its operation means "leave the key out" — and an audit column nested
59
+ * inside a `map` is not a column at all, so there is nothing down there to
60
+ * stamp.
61
+ *
62
+ * @group Driver
63
+ */
64
+ export declare function updateUserAutoValues<M extends Record<string, unknown>>({ inputValues, properties, status, uid }: {
65
+ inputValues: Partial<EntityValues<M>>;
66
+ properties: Properties;
67
+ status: EntityStatus;
68
+ uid: string | undefined;
69
+ }): EntityValues<M>;
70
+ /**
71
+ * Fill in the `defaultValue`s a create left unset.
72
+ *
73
+ * `defaultValue` was read by exactly one thing: the Studio's form, which uses it
74
+ * to prefill inputs. Every other way into the same collection — the REST create,
75
+ * the SDK, the socket, an import — stored whatever arrived and nothing where the
76
+ * key was absent. So `active: { type: "boolean", defaultValue: true }` produced
77
+ * rows with `active` unset through the API and `true` through the panel, from
78
+ * one declaration that reads like a promise about the data.
79
+ *
80
+ * Only genuinely absent keys are filled. An explicit `null` is a caller saying
81
+ * "no value", which is a different statement from not mentioning the field, and
82
+ * overwriting it would make the default impossible to opt out of.
83
+ *
84
+ * `getDefaultValuesFor` also invents a per-type default for properties with no
85
+ * `defaultValue` at all (`false` for a boolean, `[]` for an array, `null` for
86
+ * the rest) — right for a form, which must render *something* in every input,
87
+ * and wrong here, where an absent key must stay absent so the column's own
88
+ * DEFAULT applies. Only declared defaults are taken.
89
+ *
90
+ * @param values the caller's payload
91
+ * @param properties the collection's declared properties
92
+ * @group Driver
93
+ */
94
+ export declare function applyDefaultValuesOnCreate<M extends Record<string, unknown>>(values: Partial<EntityValues<M>> | undefined, properties: Properties): Partial<EntityValues<M>>;
16
95
  /**
17
96
  * Add missing required fields, expected in the collection, to the values of a entity
18
97
  * @param values
@@ -16,6 +16,7 @@ export * from "./relations.js";
16
16
  export * from "./resolve-relation.js";
17
17
  export * from "./auth-default-policies.js";
18
18
  export * from "./junction-policies.js";
19
+ export * from "./tenant.js";
19
20
  export * from "./conditions.js";
20
21
  export * from "./pg-column-to-property.js";
21
22
  export * from "./string-column-length.js";
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, PolicyExpression, Relation, SecurityRule } from "@rebasepro/types";
1
+ import { CollectionConfig, PolicyExpression, Properties, Relation, SecurityRule } from "@rebasepro/types";
2
2
  /**
3
3
  * RLS derivation for many-to-many junction tables.
4
4
  *
@@ -72,6 +72,15 @@ export interface JunctionSpec {
72
72
  endpoints: [JunctionEndpoint, JunctionEndpoint];
73
73
  /** Every collection that declares a relation through this table. */
74
74
  declaringSides: JunctionDeclaringSide[];
75
+ /**
76
+ * The junction's own columns beyond the two keys — `through.properties`,
77
+ * merged across every declaring side. `{}` when there are none.
78
+ *
79
+ * See {@link ManyToManyRelation.through} for what they are; every side that
80
+ * names a key has to describe the same column, which is checked when the
81
+ * specs are resolved rather than left for `CREATE TABLE` to discover.
82
+ */
83
+ properties: Properties;
75
84
  }
76
85
  /**
77
86
  * Walk every collection's resolved relations and aggregate the junction tables
@@ -85,6 +94,13 @@ export declare function resolveJunctionSpecs(collections: CollectionConfig[]): M
85
94
  * compilation and naming. Its two FK columns carry explicit `columnName`s so
86
95
  * `outerField` operands resolve to the exact columns the CREATE TABLE emitted,
87
96
  * whatever their casing.
97
+ *
98
+ * The payload columns are here too, exactly as authored. That is what lets one
99
+ * reading of a `Property` serve the junction as well as a collection: the
100
+ * schema planner plans these columns with the same function it plans a
101
+ * collection's with, and the write path validates a `_pivot` against them with
102
+ * the same validator a row's values go through. A second description of a
103
+ * payload column anywhere is a second description that can disagree.
88
104
  */
89
105
  export declare function getJunctionCollectionConfig(spec: JunctionSpec): CollectionConfig;
90
106
  /**
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The one reading of `collection.tenant`.
3
+ *
4
+ * Four things have to agree for a tenant-scoped collection to work — the
5
+ * column, the RLS policy, the value stamped on insert and the index — and
6
+ * before this they were four hand-written declarations that nothing compared.
7
+ * The three that are schema become a {@link SecurityRule} and a column effect
8
+ * derived here and in `planSchema`; the fourth, the write path, is
9
+ * {@link resolveTenantWrite}.
10
+ *
11
+ * Everything in this module is pure. The policy it builds is a `SecurityRule`
12
+ * like any other, which is what makes `db push`, the doctor, boot-ensure, the
13
+ * drift detector and the Studio treat the tenancy policy as what it is —
14
+ * generated, named, and recognisable — rather than as somebody's hand-written
15
+ * SQL that a push should offer to drop.
16
+ */
17
+ import { type CollectionConfig, type CollectionTenantConfig, type EntityStatus, type PolicyExpression, type SecurityRule } from "@rebasepro/types";
18
+ /**
19
+ * The collection's tenancy declaration, or nothing.
20
+ *
21
+ * Read through this rather than off the object, so the one shape check —
22
+ * `tenant` is an object carrying a `field` and a `from` — is in one place. A
23
+ * config that is *wrong* is refused by `validateCollectionConfig` with a
24
+ * message; this is only asking whether there is one.
25
+ */
26
+ export declare function getTenantConfig(collection: CollectionConfig | undefined): CollectionTenantConfig | undefined;
27
+ /** The roles tenancy does not apply to, defaulted. */
28
+ export declare function tenantBypassRoles(tenant: CollectionTenantConfig): readonly string[];
29
+ /**
30
+ * The name of the policy a tenant declaration compiles to.
31
+ *
32
+ * Explicit — not a `getPolicyNameHash` of the rule — precisely because the
33
+ * rule's *body* is compiled with more information in some callers than in
34
+ * others (`planSchema` can resolve a relation's target collection and so knows
35
+ * the column's type; the Studio, asking only for names, cannot). A hashed name
36
+ * would then differ between the two, and the same policy would read as drift.
37
+ * A frozen identifier: see `contracts/derived-names.txt`.
38
+ */
39
+ export declare function tenantPolicyName(tableName: string): string;
40
+ /** The `reason` on the index a tenant column gets. Rendered into `schema.sql`. */
41
+ export declare const TENANT_INDEX_REASON = "tenant scope";
42
+ /**
43
+ * The condition a tenant declaration means, as a policy expression.
44
+ *
45
+ * `serverContext()` first, for the same reason every injected baseline rule
46
+ * carries it: the trusted plane runs migrations, the auth flows and the boot,
47
+ * and a restrictive policy that excluded it would not protect a tenant, it
48
+ * would stop the server from starting.
49
+ *
50
+ * Then the bypass roles, then the tenancy test itself — a claim comparison or a
51
+ * correlated `EXISTS` over the membership table, which are the two ways a
52
+ * deployment answers "which tenant is this caller in".
53
+ */
54
+ export declare function tenantScopeExpression(tenant: CollectionTenantConfig): PolicyExpression;
55
+ /**
56
+ * The rule a tenant declaration compiles to, or nothing when there is none.
57
+ *
58
+ * **Restrictive**, and that is the whole design. A restrictive policy is ANDed
59
+ * with every other policy on the table, so tenancy narrows what the
60
+ * collection's own `securityRules` allow and can never widen it. A permissive
61
+ * one would OR with them, and a single `access: "public"` rule elsewhere in the
62
+ * file would take the entire tenancy boundary off without contradicting
63
+ * anything a reader could see.
64
+ *
65
+ * One rule with `operation: "all"` rather than four with `operations: [...]`:
66
+ * `FOR ALL` gives Postgres the USING clause for SELECT/UPDATE/DELETE and the
67
+ * WITH CHECK clause for INSERT/UPDATE, which is exactly the coverage wanted,
68
+ * as one policy with one name instead of four.
69
+ */
70
+ export declare function buildTenantSecurityRule(collection: CollectionConfig): SecurityRule | undefined;
71
+ /** Why a write was refused by tenancy. */
72
+ export interface TenantWriteRefusal {
73
+ code: "TENANT_REQUIRED" | "TENANT_MISMATCH" | "TENANT_IMMUTABLE";
74
+ /** The property, for a `violations` entry and for the message. */
75
+ field: string;
76
+ message: string;
77
+ }
78
+ /** What {@link resolveTenantWrite} decided. */
79
+ export type TenantWriteDecision =
80
+ /** The values to write, with the tenant stamped if it was missing. */
81
+ {
82
+ values: Record<string, unknown>;
83
+ refusal?: undefined;
84
+ } | {
85
+ refusal: TenantWriteRefusal;
86
+ values?: undefined;
87
+ };
88
+ export interface TenantWriteInput {
89
+ tenant: CollectionTenantConfig;
90
+ /** The write's values, after defaults and hooks. */
91
+ values: Record<string, unknown>;
92
+ status: EntityStatus;
93
+ /**
94
+ * Every tenant the caller may write into.
95
+ *
96
+ * One entry for a claim, however many memberships they hold for the
97
+ * membership form, and none for a caller carrying neither.
98
+ */
99
+ callerTenants: readonly unknown[];
100
+ /**
101
+ * Whether `callerTenants` is the whole list.
102
+ *
103
+ * A membership lookup is capped — a caller with more memberships than the
104
+ * cap would otherwise make every write of theirs a large read. When the cap
105
+ * is hit this is `false`, and a value that is not in the list is **let
106
+ * through** rather than refused: the list is no longer evidence of absence,
107
+ * and the policy's `WITH CHECK` is what actually decides. The API check is
108
+ * an earlier, clearer refusal of the same writes, never a second authority.
109
+ */
110
+ callerTenantsComplete?: boolean;
111
+ /**
112
+ * True when tenancy does not apply to this caller — a bypass role, or the
113
+ * trusted server context. The same set the policy lets through, so the API
114
+ * and the database refuse the same writes.
115
+ */
116
+ bypass: boolean;
117
+ /** The row's current values, on an update. */
118
+ previousValues?: Record<string, unknown>;
119
+ /** The collection slug, for the message. */
120
+ slug: string;
121
+ }
122
+ /**
123
+ * Stamp, or refuse, the tenant on a write.
124
+ *
125
+ * Three refusals, and each exists because the alternative lands somewhere
126
+ * worse:
127
+ *
128
+ * - **`TENANT_REQUIRED`** — the caller has no tenant, or belongs to several and
129
+ * named none. Stamping a guess would put the row in the wrong tenant; letting
130
+ * it through would write a NULL into a `NOT NULL` column and surface as a
131
+ * 23502 naming a column the caller never wrote.
132
+ * - **`TENANT_MISMATCH`** — the caller named a tenant that is not theirs. The
133
+ * database refuses this too, through the policy's `WITH CHECK`, but as a
134
+ * 42501 "new row violates row-level security policy" with no mention of which
135
+ * field or why. Refused here so the answer names the field.
136
+ * - **`TENANT_IMMUTABLE`** — an update that moves a row to another tenant. RLS
137
+ * would allow it whenever the caller belongs to both, and it is almost never
138
+ * what anybody meant: it takes the row out of one tenant's history and drops
139
+ * it into another's, with no trace on either side. A deliberate move is a
140
+ * `bypassRoles` operation.
141
+ *
142
+ * A bypass caller is exempt from all three: they are trusted across tenants by
143
+ * declaration, and stamping their write would silently confine a support
144
+ * operator's row to whichever tenant they happen to carry.
145
+ */
146
+ export declare function resolveTenantWrite(input: TenantWriteInput): TenantWriteDecision;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
- "version": "0.19.1",
3
+ "version": "0.19.2-canary.g09316f6",
4
4
  "description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
5
5
  "keywords": [
6
6
  "rebase",
@@ -44,8 +44,8 @@
44
44
  "dependencies": {
45
45
  "fast-equals": "6.0.2",
46
46
  "json-logic-js": "^2.0.5",
47
- "@rebasepro/types": "0.19.1",
48
- "@rebasepro/utils": "0.19.1"
47
+ "@rebasepro/types": "0.19.2-canary.g09316f6",
48
+ "@rebasepro/utils": "0.19.2-canary.g09316f6"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@jest/globals": "^30.4.1",