@rebasepro/common 0.16.0 → 0.16.1-canary.g0d7af95

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,21 +1,21 @@
1
- export * from "./collections";
2
- export * from "./common";
3
- export * from "./entities";
4
- export * from "./identity";
5
- export * from "./email";
6
- export * from "./enums";
7
- export * from "./paths";
8
- export * from "./resolutions";
9
- export * from "./policy";
10
- export * from "./permissions";
11
- export * from "./builders";
12
- export * from "./storage";
13
- export * from "./callbacks";
14
- export * from "./relations";
15
- export * from "./resolve-relation";
16
- export * from "./auth-default-policies";
17
- export * from "./junction-policies";
18
- export * from "./conditions";
19
- export * from "./pg-column-to-property";
20
- export * from "./string-column-length";
21
- export * from "./internal-tables";
1
+ export * from "./collections.js";
2
+ export * from "./common.js";
3
+ export * from "./entities.js";
4
+ export * from "./identity.js";
5
+ export * from "./email.js";
6
+ export * from "./enums.js";
7
+ export * from "./paths.js";
8
+ export * from "./resolutions.js";
9
+ export * from "./policy/index.js";
10
+ export * from "./permissions.js";
11
+ export * from "./builders.js";
12
+ export * from "./storage.js";
13
+ export * from "./callbacks.js";
14
+ export * from "./relations.js";
15
+ export * from "./resolve-relation.js";
16
+ export * from "./auth-default-policies.js";
17
+ export * from "./junction-policies.js";
18
+ export * from "./conditions.js";
19
+ export * from "./pg-column-to-property.js";
20
+ export * from "./string-column-length.js";
21
+ export * from "./internal-tables.js";
@@ -62,6 +62,15 @@ export declare const REBASE_USER_ROLE = "rebase_user";
62
62
  *
63
63
  * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
64
64
  * because `db migrate apply` passes `--revisions-schema rebase`.
65
+ *
66
+ * Every entry here must also be revoked by whatever creates it, and vice versa:
67
+ * the creation-time revoke fires once, on the boot that first makes the table,
68
+ * so it cannot help a database provisioned before that revoke existed. This
69
+ * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep
70
+ * is the only thing that can repair an already-granted table. A table revoked
71
+ * at creation but missing here is therefore permanently stranded on any
72
+ * database that predates its revoke — which is exactly what happened to
73
+ * `jobs`, added here after a scan of a pre-0.14 database found it.
65
74
  */
66
75
  export declare const REBASE_INTERNAL_TABLES: readonly string[];
67
76
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,4 @@
1
- export * from "./securityRuleToConditions";
2
- export * from "./sqlToPolicy";
3
- export * from "./policyToPostgres";
4
- export * from "./evaluatePolicy";
1
+ export * from "./securityRuleToConditions.js";
2
+ export * from "./sqlToPolicy.js";
3
+ export * from "./policyToPostgres.js";
4
+ export * from "./evaluatePolicy.js";
@@ -45,6 +45,31 @@ export declare function resolveCollectionRelations(collection: CollectionConfig)
45
45
  */
46
46
  export declare function getRelationTargetPath(property: RelationProperty): string | undefined;
47
47
  export declare function getTableName(collection: CollectionConfig): string;
48
+ /**
49
+ * The variable name a generated table is bound to.
50
+ *
51
+ * Camel-cases underscores, and then guarantees the result is a legal
52
+ * identifier. It did only the first, so a table name that is legal in Postgres
53
+ * and not in JavaScript produced a `schema.generated.ts` that does not parse:
54
+ *
55
+ * `2024_archive` → `export const 2024Archive = pgTable(…)`
56
+ * "An identifier or keyword cannot immediately follow
57
+ * a numeric literal"
58
+ * `reporting.events` → `export const reporting.events = pgTable(…)`
59
+ * "',' expected"
60
+ *
61
+ * That file is imported by the server, so the failure is not one broken
62
+ * collection — `rebase build` and `db push` fail at tsc for the whole
63
+ * directory. And it is reachable from a documented flow: `rebase init` against
64
+ * a database holding a table called `2024_archive` writes a collection file
65
+ * that parses and a schema file that does not.
66
+ *
67
+ * **A no-op for every name that already worked**, which is what makes changing
68
+ * a derived name safe here: the only inputs whose output changes are the ones
69
+ * that produced a syntax error, and nothing can be running against those.
70
+ * Separators become camel case rather than disappearing, so `reporting.events`
71
+ * and `reporting_events` do not collide into one variable.
72
+ */
48
73
  export declare function getTableVarName(tableName: string): string;
49
74
  export declare function getEnumVarName(tableName: string, propName: string): string;
50
75
  export declare function getColumnName(fullColumn: string): string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.16.0",
4
+ "version": "0.16.1-canary.g0d7af95",
5
5
  "description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "fast-equals": "6.0.2",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/utils": "0.16.0",
44
- "@rebasepro/types": "0.16.0"
43
+ "@rebasepro/types": "0.16.1-canary.g0d7af95",
44
+ "@rebasepro/utils": "0.16.1-canary.g0d7af95"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
@@ -98,7 +98,7 @@
98
98
  },
99
99
  "scripts": {
100
100
  "watch": "vite build --watch",
101
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
101
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../tooling/scripts/add-dts-extensions.mjs dist && node ../../tooling/scripts/assert-build-output.mjs",
102
102
  "test:lint": "eslint \"src/**\" --quiet",
103
103
  "test": "jest --passWithNoTests",
104
104
  "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
@@ -333,6 +333,15 @@ ids });
333
333
  orderBy: normalizeOrderBy(params?.orderBy),
334
334
  searchString: params?.searchString,
335
335
  searchExplain: params?.searchExplain,
336
+ // Forwarded so the SERVER can refuse it. `realtimeService`
337
+ // rejects a subscription carrying `vectorSearch` — a
338
+ // subscription is re-run on every matching write and
339
+ // nothing there computes distances — and the docs promise
340
+ // that refusal. Both producers hand-list their fields and
341
+ // both omitted this one, so the guard could not fire and
342
+ // `.vectorSearch(…).listen()` returned an ordinary
343
+ // `id DESC` listing with no `_distance` and no error.
344
+ vectorSearch: params?.vectorSearch,
336
345
  onUpdate: (entities) => {
337
346
  onUpdate({
338
347
  data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
@@ -0,0 +1,176 @@
1
+ import {
2
+ REBASE_INTERNAL_TABLES,
3
+ REBASE_USER_ROLE,
4
+ revokeInternalTableAccess,
5
+ revokeInternalTableSql
6
+ } from "./internal-tables";
7
+
8
+ /**
9
+ * The privilege boundary around Rebase's own tables.
10
+ *
11
+ * This module is the only thing keeping `rebase_user` away from refresh-token
12
+ * hashes, TOTP secrets and API keys, and until now nothing tested it. Its
13
+ * failure mode is the quiet kind: `revokeInternalTableAccess` swallows every
14
+ * error into an `onError` callback, so a revoke that fails on all twenty tables
15
+ * produces a boot that looks completely normal.
16
+ *
17
+ * The list membership assertions matter as much as the SQL ones. A table
18
+ * revoked at creation but missing from `REBASE_INTERNAL_TABLES` is stranded on
19
+ * every database provisioned before that revoke shipped — the boot-time sweep
20
+ * in `ensureAppRole` iterates this list and nothing else can repair an
21
+ * already-granted table.
22
+ */
23
+ describe("internal table access", () => {
24
+
25
+ // ─── The list itself ─────────────────────────────────────────────────────
26
+
27
+ describe("REBASE_INTERNAL_TABLES", () => {
28
+
29
+ it("covers every table a creator revokes at creation time", () => {
30
+ // Kept in sync by hand with the `revokeInternalTableSql` call sites.
31
+ // A creation-time revoke fires once, on the boot that first makes
32
+ // the table; only the sweep can fix a database that predates it.
33
+ const revokedAtCreation = [
34
+ "api_keys",
35
+ "branches",
36
+ "channel_cursors",
37
+ "channel_messages",
38
+ "channel_presence",
39
+ "cron_claims",
40
+ "cron_logs",
41
+ "entity_history",
42
+ "idempotency_keys",
43
+ "jobs",
44
+ "rate_limit_hits"
45
+ ];
46
+
47
+ const missing = revokedAtCreation.filter((table) => !REBASE_INTERNAL_TABLES.includes(table));
48
+ expect(missing).toEqual([]);
49
+ });
50
+
51
+ it("holds the auth tables whose rows no end user may ever address", () => {
52
+ for (const table of [
53
+ "refresh_tokens",
54
+ "password_reset_tokens",
55
+ "magic_link_tokens",
56
+ "mfa_factors",
57
+ "mfa_challenges",
58
+ "recovery_codes",
59
+ "user_identities"
60
+ ]) {
61
+ expect(REBASE_INTERNAL_TABLES).toContain(table);
62
+ }
63
+ });
64
+
65
+ it("leaves `users` out, because revoking there breaks sign-in", () => {
66
+ // The auth user table is also a collection: RLS enabled, policies
67
+ // applied, and users read their own row through it.
68
+ expect(REBASE_INTERNAL_TABLES).not.toContain("users");
69
+ });
70
+
71
+ it("has no duplicates", () => {
72
+ expect(new Set(REBASE_INTERNAL_TABLES).size).toBe(REBASE_INTERNAL_TABLES.length);
73
+ });
74
+ });
75
+
76
+ // ─── The statement ───────────────────────────────────────────────────────
77
+
78
+ describe("revokeInternalTableSql", () => {
79
+
80
+ it("revokes every privilege from the end-user role", () => {
81
+ const sql = revokeInternalTableSql("rebase", "refresh_tokens");
82
+
83
+ expect(sql).toContain(`REVOKE ALL ON "rebase"."refresh_tokens" FROM ${REBASE_USER_ROLE}`);
84
+ });
85
+
86
+ it("guards on the role existing, so an unprivileged install is a no-op", () => {
87
+ // A bare REVOKE naming a missing role is an error, not a no-op, and
88
+ // it would abort the boot it runs in.
89
+ const sql = revokeInternalTableSql("rebase", "api_keys");
90
+
91
+ expect(sql).toContain(`SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}'`);
92
+ });
93
+
94
+ it("tolerates a table that does not exist yet", () => {
95
+ // `cron_logs` never appears in a project with no cron jobs.
96
+ expect(revokeInternalTableSql("rebase", "cron_logs")).toContain("to_regclass");
97
+ });
98
+
99
+ it("is a single statement, for handles that reject multi-statement strings", () => {
100
+ // The semicolons inside the block are PL/pgSQL's, not the protocol's:
101
+ // what matters is that the whole string is one dollar-quoted DO,
102
+ // opened once and closed at the very end.
103
+ const sql = revokeInternalTableSql("rebase", "jobs");
104
+
105
+ expect(sql.startsWith("DO $rebase_revoke$")).toBe(true);
106
+ expect(sql.endsWith("$rebase_revoke$;")).toBe(true);
107
+ expect(sql.split("$rebase_revoke$")).toHaveLength(3);
108
+ });
109
+
110
+ it("refuses to interpolate an unsafe identifier", () => {
111
+ expect(() => revokeInternalTableSql("rebase", 'x"; DROP TABLE users; --')).toThrow(/unsafe table name/);
112
+ expect(() => revokeInternalTableSql('pub"lic', "api_keys")).toThrow(/unsafe schema name/);
113
+ });
114
+ });
115
+
116
+ // ─── The sweep ───────────────────────────────────────────────────────────
117
+
118
+ describe("revokeInternalTableAccess", () => {
119
+
120
+ it("issues one statement per internal table", async () => {
121
+ const executed: string[] = [];
122
+
123
+ await revokeInternalTableAccess(async (sql) => { executed.push(sql); }, "rebase");
124
+
125
+ expect(executed).toHaveLength(REBASE_INTERNAL_TABLES.length);
126
+ for (const table of REBASE_INTERNAL_TABLES) {
127
+ expect(executed.some((sql) => sql.includes(`"rebase"."${table}"`))).toBe(true);
128
+ }
129
+ });
130
+
131
+ it("reports a failure rather than swallowing it", async () => {
132
+ // The whole risk: an onError that no caller wires up turns twenty
133
+ // failed revokes into a silent, apparently-clean boot.
134
+ const seen: string[] = [];
135
+
136
+ await revokeInternalTableAccess(
137
+ async (sql) => {
138
+ if (sql.includes("refresh_tokens")) throw new Error("permission denied");
139
+ },
140
+ "rebase",
141
+ { onError: (table) => seen.push(table) }
142
+ );
143
+
144
+ expect(seen).toEqual(["refresh_tokens"]);
145
+ });
146
+
147
+ it("keeps going after one table fails", async () => {
148
+ const executed: string[] = [];
149
+
150
+ await revokeInternalTableAccess(
151
+ async (sql) => {
152
+ executed.push(sql);
153
+ if (sql.includes("user_identities")) throw new Error("not the owner");
154
+ },
155
+ "rebase",
156
+ { onError: () => undefined }
157
+ );
158
+
159
+ // A connection that does not own one table must not strand the rest.
160
+ expect(executed).toHaveLength(REBASE_INTERNAL_TABLES.length);
161
+ });
162
+
163
+ it("honours an explicit table list", async () => {
164
+ const executed: string[] = [];
165
+
166
+ await revokeInternalTableAccess(
167
+ async (sql) => { executed.push(sql); },
168
+ "rebase",
169
+ { tables: ["api_keys"] }
170
+ );
171
+
172
+ expect(executed).toHaveLength(1);
173
+ expect(executed[0]).toContain('"rebase"."api_keys"');
174
+ });
175
+ });
176
+ });
@@ -64,6 +64,15 @@ export const REBASE_USER_ROLE = "rebase_user";
64
64
  *
65
65
  * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
66
66
  * because `db migrate apply` passes `--revisions-schema rebase`.
67
+ *
68
+ * Every entry here must also be revoked by whatever creates it, and vice versa:
69
+ * the creation-time revoke fires once, on the boot that first makes the table,
70
+ * so it cannot help a database provisioned before that revoke existed. This
71
+ * list is what the boot-time sweep in `ensureAppRole` iterates, and the sweep
72
+ * is the only thing that can repair an already-granted table. A table revoked
73
+ * at creation but missing here is therefore permanently stranded on any
74
+ * database that predates its revoke — which is exactly what happened to
75
+ * `jobs`, added here after a scan of a pre-0.14 database found it.
67
76
  */
68
77
  export const REBASE_INTERNAL_TABLES: readonly string[] = [
69
78
  // auth
@@ -80,10 +89,12 @@ export const REBASE_INTERNAL_TABLES: readonly string[] = [
80
89
  "api_keys",
81
90
  "cron_logs",
82
91
  "cron_claims",
92
+ "jobs",
83
93
  "rate_limit_hits",
84
94
  "idempotency_keys",
85
95
  "entity_history",
86
96
  "branches",
97
+ "metric_samples",
87
98
  // realtime channels — authorization for these lives in the channel rules the
88
99
  // server evaluates before it reads or writes, never in a row policy
89
100
  "channel_messages",
@@ -104,8 +104,55 @@ export function getTableName(collection: CollectionConfig): string {
104
104
  return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
105
105
  }
106
106
 
107
+ /**
108
+ * A JavaScript identifier: what a generated `export const <name> =` needs.
109
+ *
110
+ * Deliberately the same shape the two schema generators already define
111
+ * privately — this is the third place that needed it, and the first two guard
112
+ * property keys and member accesses while nothing guarded the variable name
113
+ * itself.
114
+ */
115
+ const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
116
+
117
+ /**
118
+ * The variable name a generated table is bound to.
119
+ *
120
+ * Camel-cases underscores, and then guarantees the result is a legal
121
+ * identifier. It did only the first, so a table name that is legal in Postgres
122
+ * and not in JavaScript produced a `schema.generated.ts` that does not parse:
123
+ *
124
+ * `2024_archive` → `export const 2024Archive = pgTable(…)`
125
+ * "An identifier or keyword cannot immediately follow
126
+ * a numeric literal"
127
+ * `reporting.events` → `export const reporting.events = pgTable(…)`
128
+ * "',' expected"
129
+ *
130
+ * That file is imported by the server, so the failure is not one broken
131
+ * collection — `rebase build` and `db push` fail at tsc for the whole
132
+ * directory. And it is reachable from a documented flow: `rebase init` against
133
+ * a database holding a table called `2024_archive` writes a collection file
134
+ * that parses and a schema file that does not.
135
+ *
136
+ * **A no-op for every name that already worked**, which is what makes changing
137
+ * a derived name safe here: the only inputs whose output changes are the ones
138
+ * that produced a syntax error, and nothing can be running against those.
139
+ * Separators become camel case rather than disappearing, so `reporting.events`
140
+ * and `reporting_events` do not collide into one variable.
141
+ */
107
142
  export function getTableVarName(tableName: string): string {
108
- return tableName.replace(/_([a-z])/g, (_, char) => char.toUpperCase());
143
+ const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
144
+ if (JS_IDENTIFIER.test(camel)) return camel;
145
+
146
+ const sanitised = camel
147
+ // Any other separator gets the same treatment `_` did, so two tables
148
+ // differing only by separator keep differing.
149
+ .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>
150
+ (char ? char.toUpperCase() : ""))
151
+ // A leading digit is legal in Postgres and not in JavaScript. Prefixed
152
+ // rather than stripped, so `2024_archive` and `archive` stay distinct.
153
+ .replace(/^([0-9])/, "t$1");
154
+
155
+ return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;
109
156
  }
110
157
 
111
158
  export function getEnumVarName(tableName: string, propName: string): string {