@rebasepro/cli 0.25.1 → 0.26.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/README.md +2 -1
- package/bin/rebase.js +143 -4
- package/dist/{agent-setup-BYb2Tbdz.js → agent-setup-D9mHRZ5R.js} +125 -3
- package/dist/agent-setup-D9mHRZ5R.js.map +1 -0
- package/dist/{api-keys-CSMbsmAn.js → api-keys-CyJ26KEJ.js} +41 -9
- package/dist/api-keys-CyJ26KEJ.js.map +1 -0
- package/dist/{apps-iBXNZhVN.js → apps-CnLDV5b6.js} +6 -6
- package/dist/{apps-iBXNZhVN.js.map → apps-CnLDV5b6.js.map} +1 -1
- package/dist/{atlas-dev-database-BusbHP6n.js → atlas-dev-database-BODqdCWt.js} +2 -2
- package/dist/{atlas-dev-database-BusbHP6n.js.map → atlas-dev-database-BODqdCWt.js.map} +1 -1
- package/dist/{auth-DRsTWPky.js → auth-dII2jCO1.js} +10 -7
- package/dist/auth-dII2jCO1.js.map +1 -0
- package/dist/{branch-pointer-Bn_FjNld.js → branch-pointer-BQUF6lYa.js} +1 -1
- package/dist/{build-et7Rccxi.js → build-Cza7ZQrp.js} +6 -6
- package/dist/{build-et7Rccxi.js.map → build-Cza7ZQrp.js.map} +1 -1
- package/dist/{bundle-DlqkDYSe.js → bundle-C6yEtMJ-.js} +53 -5
- package/dist/bundle-C6yEtMJ-.js.map +1 -0
- package/dist/bundle.d.ts +14 -0
- package/dist/{cloud-B-ZIjHrA.js → cloud-BdnmqQaZ.js} +8527 -4340
- package/dist/cloud-BdnmqQaZ.js.map +1 -0
- package/dist/collections-dir-DlvDhime.js +69 -0
- package/dist/collections-dir-DlvDhime.js.map +1 -0
- package/dist/commands/agent-setup.d.ts +58 -0
- package/dist/commands/api-keys.d.ts +13 -0
- package/dist/commands/cloud/activity.d.ts +25 -0
- package/dist/commands/cloud/app-key.d.ts +18 -0
- package/dist/commands/cloud/auth.d.ts +1 -0
- package/dist/commands/cloud/billing-invoices.d.ts +39 -0
- package/dist/commands/cloud/claim.d.ts +7 -0
- package/dist/commands/cloud/context.d.ts +115 -6
- package/dist/commands/cloud/deploy-summary.d.ts +162 -0
- package/dist/commands/cloud/deploy.d.ts +64 -0
- package/dist/commands/cloud/env.d.ts +24 -0
- package/dist/commands/cloud/index.d.ts +13 -1
- package/dist/commands/cloud/init.d.ts +143 -0
- package/dist/commands/cloud/login-flow.d.ts +51 -0
- package/dist/commands/cloud/notifications.d.ts +28 -0
- package/dist/commands/cloud/orgs.d.ts +13 -0
- package/dist/commands/cloud/previews.d.ts +123 -0
- package/dist/commands/cloud/resources.d.ts +68 -14
- package/dist/commands/cloud/runtime-logs.d.ts +176 -0
- package/dist/commands/cloud/service-key.d.ts +15 -0
- package/dist/commands/cloud/token-capabilities.d.ts +61 -8
- package/dist/commands/cloud/tokens.d.ts +18 -3
- package/dist/commands/cloud/usage.d.ts +63 -0
- package/dist/commands/dev.d.ts +10 -0
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/generate-view.d.ts +94 -0
- package/dist/commands/generate_sdk.d.ts +15 -0
- package/dist/commands/skills.d.ts +4 -3
- package/dist/commands/storage.d.ts +4 -0
- package/dist/commands/verify.d.ts +96 -0
- package/dist/{consent-BaB-eQKG.js → consent-BD7F29T3.js} +2 -2
- package/dist/{consent-BaB-eQKG.js.map → consent-BD7F29T3.js.map} +1 -1
- package/dist/{console-BlD9kjFW.js → console-BXB6-8GT.js} +12 -6
- package/dist/console-BXB6-8GT.js.map +1 -0
- package/dist/{context-DHiiiJ5C.js → context-GUxkZm-S.js} +481 -24
- package/dist/context-GUxkZm-S.js.map +1 -0
- package/dist/{daemon-DDMRbz4T.js → daemon-Cp7oNXth.js} +2 -2
- package/dist/{daemon-DDMRbz4T.js.map → daemon-Cp7oNXth.js.map} +1 -1
- package/dist/{db-9P6MnmdC.js → db-KzlqddGD.js} +37 -30
- package/dist/db-KzlqddGD.js.map +1 -0
- package/dist/{derive-MTdClnkc.js → derive-ae9UOoOB.js} +2 -2
- package/dist/{derive-MTdClnkc.js.map → derive-ae9UOoOB.js.map} +1 -1
- package/dist/{dev-BDN_OKXA.js → dev-DTqVMgB3.js} +59 -32
- package/dist/dev-DTqVMgB3.js.map +1 -0
- package/dist/{dev-preflight-FT9GbJVj.js → dev-preflight-Cxv3TiD1.js} +2 -2
- package/dist/{dev-preflight-FT9GbJVj.js.map → dev-preflight-Cxv3TiD1.js.map} +1 -1
- package/dist/{dev-server-CIZwAfZa.js → dev-server-OJsUGZEB.js} +2 -2
- package/dist/{dev-server-CIZwAfZa.js.map → dev-server-OJsUGZEB.js.map} +1 -1
- package/dist/{doctor-D8YXz7df.js → doctor-BRWOlqeG.js} +13 -11
- package/dist/doctor-BRWOlqeG.js.map +1 -0
- package/dist/{eject-izG2KjMo.js → eject-DABcDnPN.js} +3 -3
- package/dist/{eject-izG2KjMo.js.map → eject-DABcDnPN.js.map} +1 -1
- package/dist/{fold-static-CybSay_W.js → fold-static-BnAK-anQ.js} +3 -3
- package/dist/{fold-static-CybSay_W.js.map → fold-static-BnAK-anQ.js.map} +1 -1
- package/dist/generate-view-IFwCADvb.js +662 -0
- package/dist/generate-view-IFwCADvb.js.map +1 -0
- package/dist/{generate_sdk-B4R65oUV.js → generate_sdk-BvcWvoUh.js} +33 -7
- package/dist/generate_sdk-BvcWvoUh.js.map +1 -0
- package/dist/index.es.js +57 -38
- package/dist/index.es.js.map +1 -1
- package/dist/{init-DYaeppEN.js → init-CaU6GhwI.js} +8 -8
- package/dist/{init-DYaeppEN.js.map → init-CaU6GhwI.js.map} +1 -1
- package/dist/last-dev-database-DpW5dfoX.js +2 -0
- package/dist/{manifest-nvPdLlJY.js → manifest-BtzDy7KQ.js} +36 -3
- package/dist/{manifest-nvPdLlJY.js.map → manifest-BtzDy7KQ.js.map} +1 -1
- package/dist/{normalize-imports-BYw8ad87.js → normalize-imports-D0zXlM6g.js} +3 -3
- package/dist/{normalize-imports-BYw8ad87.js.map → normalize-imports-D0zXlM6g.js.map} +1 -1
- package/dist/{path-flags-BFgG9V6y.js → path-flags-DNvtEi2-.js} +5 -4
- package/dist/path-flags-DNvtEi2-.js.map +1 -0
- package/dist/{prepare-1rVNd2PF.js → prepare-CTw9NEkh.js} +4 -4
- package/dist/{prepare-1rVNd2PF.js.map → prepare-CTw9NEkh.js.map} +1 -1
- package/dist/{project-g4K6kV1t.js → project-CasK56vN.js} +2 -2
- package/dist/{project-g4K6kV1t.js.map → project-CasK56vN.js.map} +1 -1
- package/dist/resolve-D647F1LA.js +2 -0
- package/dist/{resources-CovP8AZ-.js → resources-CPSC5Kcu.js} +4 -4
- package/dist/{resources-CovP8AZ-.js.map → resources-CPSC5Kcu.js.map} +1 -1
- package/dist/{schema-nuxvvc50.js → schema-BSXwZSiI.js} +9 -7
- package/dist/schema-BSXwZSiI.js.map +1 -0
- package/dist/{skills-DeUIsZcT.js → skills-BU2flMOy.js} +52 -7
- package/dist/skills-BU2flMOy.js.map +1 -0
- package/dist/{start-RzZqDroW.js → start-mVb6upwY.js} +5 -5
- package/dist/{start-RzZqDroW.js.map → start-mVb6upwY.js.map} +1 -1
- package/dist/static-syntax-check.d.ts +64 -0
- package/dist/{status-D9Zm88NZ.js → status-BREvtzJm.js} +7 -7
- package/dist/{status-D9Zm88NZ.js.map → status-BREvtzJm.js.map} +1 -1
- package/dist/{storage-BB3umRfw.js → storage-DtOXGflp.js} +159 -15
- package/dist/storage-DtOXGflp.js.map +1 -0
- package/dist/{telemetry-HfU5uAh1.js → telemetry-CgwJPVMO.js} +2 -2
- package/dist/{telemetry-HfU5uAh1.js.map → telemetry-CgwJPVMO.js.map} +1 -1
- package/dist/{telemetry-TseNLk6X.js → telemetry-DwK5RQJl.js} +4 -4
- package/dist/{telemetry-TseNLk6X.js.map → telemetry-DwK5RQJl.js.map} +1 -1
- package/dist/{upgrade-apiPKKE_.js → upgrade-B7sVwL8S.js} +4 -4
- package/dist/{upgrade-apiPKKE_.js.map → upgrade-B7sVwL8S.js.map} +1 -1
- package/dist/utils/collections-dir.d.ts +14 -0
- package/dist/utils/path-flags.d.ts +2 -1
- package/dist/utils/tool-stdio.d.ts +1 -1
- package/dist/verify-D7UsV_qe.js +730 -0
- package/dist/verify-D7UsV_qe.js.map +1 -0
- package/package.json +9 -7
- package/templates/eject/backend/src/index.ts +9 -1
- package/templates/generated-schemas/ecommerce.ts +3 -0
- package/templates/overlays/baas/ai-instructions.md +13 -0
- package/templates/template/.claude/hooks/post-edit-collection.sh +99 -0
- package/templates/template/.claude/settings.json +17 -0
- package/templates/template/.env.example +10 -1
- package/templates/template/ai-instructions.md +21 -1
- package/templates/template/config/collections/presets/ecommerce/index.ts +23 -0
- package/templates/template/docker-compose.yml +4 -1
- package/templates/template/frontend/package.json +1 -0
- package/templates/views/AppView.tsx.tmpl +138 -0
- package/templates/views/EntityView.tsx.tmpl +64 -0
- package/templates/views/index.ts.tmpl +8 -0
- package/dist/agent-setup-BYb2Tbdz.js.map +0 -1
- package/dist/api-keys-CSMbsmAn.js.map +0 -1
- package/dist/auth-DRsTWPky.js.map +0 -1
- package/dist/bundle-DlqkDYSe.js.map +0 -1
- package/dist/cloud-B-ZIjHrA.js.map +0 -1
- package/dist/console-BlD9kjFW.js.map +0 -1
- package/dist/context-DHiiiJ5C.js.map +0 -1
- package/dist/db-9P6MnmdC.js.map +0 -1
- package/dist/dev-BDN_OKXA.js.map +0 -1
- package/dist/doctor-D8YXz7df.js.map +0 -1
- package/dist/generate_sdk-B4R65oUV.js.map +0 -1
- package/dist/last-dev-database-B_75fxZC.js +0 -2
- package/dist/path-flags-BFgG9V6y.js.map +0 -1
- package/dist/resolve-BQUAC3Wi.js +0 -2
- package/dist/schema-nuxvvc50.js.map +0 -1
- package/dist/skills-DeUIsZcT.js.map +0 -1
- package/dist/storage-BB3umRfw.js.map +0 -1
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { a as findBackendDir, d as readEnvFile, i as failAsJson, l as getActiveBackendPlugin, p as requireProjectRoot, w as wantsHelp, x as parseCommandArgs } from "./project-CasK56vN.js";
|
|
2
|
+
import { r as missingCollectionsPath, t as COLLECTIONS_FLAG_HELP } from "./path-flags-DNvtEi2-.js";
|
|
3
|
+
import { resolveCollectionsDir } from "./collections-dir-DlvDhime.js";
|
|
4
|
+
import { n as isLoadedCollectionFile } from "./collection-drift-BKj_BoMq.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import { isFirebaseCollectionConfig, isRelationalCollectionConfig, parseEnvBoolean } from "@rebasepro/types";
|
|
9
|
+
//#region src/commands/verify.ts
|
|
10
|
+
/**
|
|
11
|
+
* CLI command: rebase verify
|
|
12
|
+
*
|
|
13
|
+
* One command that says whether the collections are safe to ship, fast enough
|
|
14
|
+
* to run after every edit. An agent writing `config/collections/*.ts` gets three
|
|
15
|
+
* things wrong more than anything else — an option that does not exist, a UI
|
|
16
|
+
* widget outside `admin: { … }`, and no `securityRules` — and every one of them
|
|
17
|
+
* used to surface late: at boot, in review, or never. The project's Claude Code
|
|
18
|
+
* hook (`.claude/hooks/post-edit-collection.sh`, written by `rebase init` and
|
|
19
|
+
* `rebase skills --hooks`) runs this after each edit to a collection file, and
|
|
20
|
+
* CI runs it on every push.
|
|
21
|
+
*
|
|
22
|
+
* Four steps, each reported on its own:
|
|
23
|
+
*
|
|
24
|
+
* 1. **Collection config** — every file under `config/collections/` loads and
|
|
25
|
+
* passes `findCollectionConfigProblems` with unknown keys as errors: the same
|
|
26
|
+
* validator the backend runs at boot, at its strictest.
|
|
27
|
+
* 2. **RLS coverage** — a collection that declares no `securityRules` and
|
|
28
|
+
* inherits none, or one with a `user_id` / `owner_id` / `organization_id` /
|
|
29
|
+
* `tenant_id` column that leans on directory defaults instead of its own
|
|
30
|
+
* rules; and a rule that lets a role read every row of such a table.
|
|
31
|
+
* 3. **Migration freshness** — whether `drizzle/migrations` still builds the
|
|
32
|
+
* tables and columns the collections declare (offline), and with `--live`
|
|
33
|
+
* whether the database has applied every migration.
|
|
34
|
+
* 4. **Live RLS audit** — with `--live`, the `@rebasepro/rls-check` catalogue
|
|
35
|
+
* against the project's database, resolved as `db *` resolves it (the
|
|
36
|
+
* managed one unless something names another).
|
|
37
|
+
*
|
|
38
|
+
* `--json` prints one object an agent can act on: every finding carries the
|
|
39
|
+
* file, the dotted path into the config and the fix.
|
|
40
|
+
*/
|
|
41
|
+
/** The flags `rebase verify` takes. */
|
|
42
|
+
var VERIFY_FLAGS = {
|
|
43
|
+
"--collections": String,
|
|
44
|
+
"-c": "--collections",
|
|
45
|
+
"--json": Boolean,
|
|
46
|
+
"--quiet": Boolean,
|
|
47
|
+
"-q": "--quiet",
|
|
48
|
+
"--live": Boolean,
|
|
49
|
+
"--database-url": String,
|
|
50
|
+
"--docker": Boolean
|
|
51
|
+
};
|
|
52
|
+
var VERIFY_FLAG_HELP = {
|
|
53
|
+
"--collections, -c": COLLECTIONS_FLAG_HELP,
|
|
54
|
+
"--json": "Print one JSON object: every step, and every finding with its file, path and fix",
|
|
55
|
+
"--quiet, -q": "Print nothing when everything passes; only the problems when something fails",
|
|
56
|
+
"--live": "Also connect to the project's database: check migrations are applied and run the rls-check audit",
|
|
57
|
+
"--database-url": "With --live: audit this database instead of the project's",
|
|
58
|
+
"--docker": "With --live: use the database from docker compose, starting it if needed"
|
|
59
|
+
};
|
|
60
|
+
var STEP_TITLES = {
|
|
61
|
+
config: "Collection config",
|
|
62
|
+
rls: "RLS coverage",
|
|
63
|
+
migrations: "Migration freshness",
|
|
64
|
+
live: "Live RLS audit"
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Columns that say "this row belongs to someone". A table carrying one and
|
|
68
|
+
* served without a row filter is the classic multi-tenant leak.
|
|
69
|
+
*/
|
|
70
|
+
var OWNER_COLUMNS = [
|
|
71
|
+
"user_id",
|
|
72
|
+
"owner_id",
|
|
73
|
+
"organization_id",
|
|
74
|
+
"tenant_id"
|
|
75
|
+
];
|
|
76
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
+
/** What a collection file must default-export to be loaded as one. */
|
|
78
|
+
function isCollection(value) {
|
|
79
|
+
return isRecord(value) && typeof value.slug === "string" && isRecord(value.properties);
|
|
80
|
+
}
|
|
81
|
+
/** `organizationId` → `organization_id`, as the generator derives a column from a key. */
|
|
82
|
+
function snake(key) {
|
|
83
|
+
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[\s-]+/g, "_").toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Load the collections the way the backend finds them: every top-level file
|
|
87
|
+
* the loader reads (`isLoadedCollectionFile`), each default-exporting one
|
|
88
|
+
* collection, plus `defaultSecurityRules` from the directory's `index.ts`.
|
|
89
|
+
*
|
|
90
|
+
* Through jiti, with its caches off: the CLI is plain Node and the files are
|
|
91
|
+
* TypeScript, and a verifier that answered from the previous edit's module
|
|
92
|
+
* would be worse than none. A file that does not load is a finding, not a
|
|
93
|
+
* crash — after an agent's edit it is the most likely problem of all.
|
|
94
|
+
*/
|
|
95
|
+
async function loadCollectionFiles(projectRoot, collectionsDir) {
|
|
96
|
+
const { createJiti } = await import("jiti");
|
|
97
|
+
const jiti = createJiti(path.join(collectionsDir, "index.ts"), {
|
|
98
|
+
interopDefault: true,
|
|
99
|
+
moduleCache: false,
|
|
100
|
+
fsCache: false
|
|
101
|
+
});
|
|
102
|
+
const relative = (file) => path.relative(projectRoot, file);
|
|
103
|
+
const loaded = {
|
|
104
|
+
collections: [],
|
|
105
|
+
sources: [],
|
|
106
|
+
ownRules: [],
|
|
107
|
+
defaults: void 0,
|
|
108
|
+
failures: []
|
|
109
|
+
};
|
|
110
|
+
for (const name of fs.readdirSync(collectionsDir).filter(isLoadedCollectionFile).sort()) {
|
|
111
|
+
const file = path.join(collectionsDir, name);
|
|
112
|
+
try {
|
|
113
|
+
const exported = await jiti.import(file, { default: true });
|
|
114
|
+
if (!isCollection(exported)) {
|
|
115
|
+
loaded.failures.push({
|
|
116
|
+
step: "config",
|
|
117
|
+
check: "config-no-collection",
|
|
118
|
+
severity: "error",
|
|
119
|
+
file: relative(file),
|
|
120
|
+
message: `${name} does not default-export a collection (an object with a \`slug\` and \`properties\`).`,
|
|
121
|
+
fix: "End the file with `export default defineCollection({ slug, name, properties, … })` — the backend refuses to boot on a collection file that exports anything else."
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
loaded.collections.push(exported);
|
|
126
|
+
loaded.sources.push(relative(file));
|
|
127
|
+
loaded.ownRules.push(exported.securityRules);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
loaded.failures.push({
|
|
130
|
+
step: "config",
|
|
131
|
+
check: "config-load",
|
|
132
|
+
severity: "error",
|
|
133
|
+
file: relative(file),
|
|
134
|
+
message: `${name} does not load: ${error instanceof Error ? error.message : String(error)}`,
|
|
135
|
+
fix: "Fix the syntax or import error. The backend refuses to boot on the same file."
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const index of ["index.ts", "index.js"]) {
|
|
140
|
+
const file = path.join(collectionsDir, index);
|
|
141
|
+
if (!fs.existsSync(file)) continue;
|
|
142
|
+
try {
|
|
143
|
+
const mod = await jiti.import(file);
|
|
144
|
+
const rules = isRecord(mod) ? mod.defaultSecurityRules : void 0;
|
|
145
|
+
if (Array.isArray(rules)) {
|
|
146
|
+
loaded.defaults = rules;
|
|
147
|
+
loaded.defaultsFile = relative(file);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
loaded.failures.push({
|
|
151
|
+
step: "config",
|
|
152
|
+
check: "config-load",
|
|
153
|
+
severity: "error",
|
|
154
|
+
file: relative(file),
|
|
155
|
+
message: `${index} does not load: ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
fix: "Fix the file, or delete it if the project declares no `defaultSecurityRules`. The backend refuses to boot without reading it."
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
return loaded;
|
|
162
|
+
}
|
|
163
|
+
async function checkConfig(loaded) {
|
|
164
|
+
const { findCollectionConfigProblems } = await import("@rebasepro/server");
|
|
165
|
+
const fileBySlug = new Map(loaded.collections.map((c, i) => [c.slug, loaded.sources[i]]));
|
|
166
|
+
return findCollectionConfigProblems(loaded.collections, {
|
|
167
|
+
unknownKeys: "error",
|
|
168
|
+
sources: loaded.sources
|
|
169
|
+
}).map((problem) => {
|
|
170
|
+
const slug = problem.path.split(".")[0];
|
|
171
|
+
const file = fileBySlug.get(slug);
|
|
172
|
+
return {
|
|
173
|
+
step: "config",
|
|
174
|
+
check: problem.kind === "key" ? "config-key" : "config-incoherent",
|
|
175
|
+
severity: problem.severity,
|
|
176
|
+
message: problem.message,
|
|
177
|
+
path: problem.path,
|
|
178
|
+
...fileBySlug.has(slug) && { collection: slug },
|
|
179
|
+
...file && { file }
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** The owner-ish columns a collection's table carries: plain columns and `belongsTo` keys. */
|
|
184
|
+
function ownerColumnsOf(collection) {
|
|
185
|
+
const found = [];
|
|
186
|
+
const consider = (column, key) => {
|
|
187
|
+
if (OWNER_COLUMNS.includes(column)) found.push({
|
|
188
|
+
column,
|
|
189
|
+
key
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
for (const [key, property] of Object.entries(collection.properties ?? {})) {
|
|
193
|
+
if (!isRecord(property)) continue;
|
|
194
|
+
if (property.type === "relation") {
|
|
195
|
+
const relation = property.relation;
|
|
196
|
+
if (isRecord(relation) && relation.kind === "belongsTo") {
|
|
197
|
+
const name = typeof relation.relationName === "string" && relation.relationName ? relation.relationName : key;
|
|
198
|
+
const singular = /[^s]s$/.test(name) ? name.slice(0, -1) : name;
|
|
199
|
+
consider(typeof relation.localKey === "string" ? relation.localKey : `${snake(singular)}_id`, key);
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
consider(typeof property.columnName === "string" ? property.columnName : snake(key), key);
|
|
204
|
+
}
|
|
205
|
+
return found;
|
|
206
|
+
}
|
|
207
|
+
/** Whether a rule applies to reads. */
|
|
208
|
+
function coversSelect(rule) {
|
|
209
|
+
const operations = rule.operations ?? [rule.operation ?? "all"];
|
|
210
|
+
return operations.includes("select") || operations.includes("all");
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* A permissive read rule with no row filter: every row, to everyone the rule
|
|
214
|
+
* names. `access: "public"` is the same thing said on purpose, so it is not
|
|
215
|
+
* this; `roles: ["admin"]` restates the default admin read and adds nothing.
|
|
216
|
+
*/
|
|
217
|
+
function isUnfilteredRead(rule) {
|
|
218
|
+
if (!coversSelect(rule) || rule.mode === "restrictive") return false;
|
|
219
|
+
if (rule.access === "public" || rule.ownerField || rule.condition) return false;
|
|
220
|
+
if (rule.using !== void 0 && !/^\s*\(?\s*true\s*\)?\s*$/i.test(rule.using)) return false;
|
|
221
|
+
if (rule.using === void 0 && rule.withCheck !== void 0) return false;
|
|
222
|
+
const roles = rule.roles ?? [];
|
|
223
|
+
return !(roles.length > 0 && roles.every((role) => role === "admin"));
|
|
224
|
+
}
|
|
225
|
+
/** A read rule that returns every row to someone: an unfiltered one, or `access: "public"`. */
|
|
226
|
+
function opensEveryRow(rule) {
|
|
227
|
+
return isUnfilteredRead(rule) || rule.access === "public" && coversSelect(rule) && rule.mode !== "restrictive";
|
|
228
|
+
}
|
|
229
|
+
function describeRuleTarget(rule) {
|
|
230
|
+
return rule.roles?.length ? `role${rule.roles.length > 1 ? "s" : ""} ${rule.roles.map((r) => `"${r}"`).join(", ")}` : "every signed-in user";
|
|
231
|
+
}
|
|
232
|
+
function checkRls(loaded) {
|
|
233
|
+
const findings = [];
|
|
234
|
+
loaded.collections.forEach((collection, i) => {
|
|
235
|
+
if (isFirebaseCollectionConfig(collection)) return;
|
|
236
|
+
const slug = collection.slug;
|
|
237
|
+
const file = loaded.sources[i];
|
|
238
|
+
const own = loaded.ownRules[i] ?? [];
|
|
239
|
+
const owners = ownerColumnsOf(collection);
|
|
240
|
+
const tenantScoped = isRelationalCollectionConfig(collection) && isRecord(collection.tenant);
|
|
241
|
+
const ownerField = owners[0]?.column ?? "user_id";
|
|
242
|
+
if (own.length === 0) {
|
|
243
|
+
if (collection.auth) return;
|
|
244
|
+
if (owners.length > 0 && !tenantScoped) {
|
|
245
|
+
const perUser = ownerField === "user_id" || ownerField === "owner_id";
|
|
246
|
+
const fix = perUser ? `Add \`securityRules: [{ operation: "all", ownerField: "${ownerField}" }]\` so each user reaches only their own rows. A table meant to be readable by everyone says so: \`{ operation: "select", access: "public" }\`.` : `Scope it with \`tenant: { field: "${owners[0].key}", from: { claim: "${ownerField}" } }\` (the claim that carries the caller's tenant), plus the \`securityRules\` that grant access inside a tenant — e.g. \`[{ operation: "select", access: "public" }]\`.`;
|
|
247
|
+
const inherited = loaded.defaults ?? [];
|
|
248
|
+
if (inherited.length === 0) findings.push({
|
|
249
|
+
step: "rls",
|
|
250
|
+
check: "rls-missing",
|
|
251
|
+
severity: "error",
|
|
252
|
+
collection: slug,
|
|
253
|
+
file,
|
|
254
|
+
path: `${slug}.securityRules`,
|
|
255
|
+
message: `"${slug}" has a \`${ownerField}\` column but declares no \`securityRules\`, and inherits none: nothing says which rows belong to whom (by default only admins can read or write it).`,
|
|
256
|
+
fix
|
|
257
|
+
});
|
|
258
|
+
else if (inherited.some(opensEveryRow)) findings.push({
|
|
259
|
+
step: "rls",
|
|
260
|
+
check: perUser ? "rls-inherited-read" : "rls-missing",
|
|
261
|
+
severity: perUser ? "warning" : "error",
|
|
262
|
+
collection: slug,
|
|
263
|
+
file,
|
|
264
|
+
path: `${slug}.securityRules`,
|
|
265
|
+
message: `"${slug}" has a \`${ownerField}\` column but declares no \`securityRules\` of its own, so it inherits \`defaultSecurityRules\` from ${loaded.defaultsFile}, which let ${describeRuleTarget(inherited.find(opensEveryRow) ?? {})} read every row regardless of \`${ownerField}\`.`,
|
|
266
|
+
fix
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!loaded.defaults?.length) findings.push({
|
|
271
|
+
step: "rls",
|
|
272
|
+
check: "rls-missing",
|
|
273
|
+
severity: "error",
|
|
274
|
+
collection: slug,
|
|
275
|
+
file,
|
|
276
|
+
path: `${slug}.securityRules`,
|
|
277
|
+
message: `"${slug}" declares no \`securityRules\` and inherits none, so nothing says who may read or write it (by default only admins can).`,
|
|
278
|
+
fix: "Declare its access: `securityRules: [{ operation: \"select\", access: \"public\" }, { operations: [\"insert\", \"update\", \"delete\"], roles: [\"admin\"] }]` for a public table, `{ operation: \"all\", ownerField: \"user_id\" }` for per-user rows, or `{ operation: \"all\", roles: [\"admin\"] }` to keep it admin-only on purpose."
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (owners.length === 0 || tenantScoped) return;
|
|
283
|
+
own.forEach((rule, index) => {
|
|
284
|
+
if (!isUnfilteredRead(rule)) return;
|
|
285
|
+
findings.push({
|
|
286
|
+
step: "rls",
|
|
287
|
+
check: "rls-unfiltered-read",
|
|
288
|
+
severity: "warning",
|
|
289
|
+
collection: slug,
|
|
290
|
+
file,
|
|
291
|
+
path: `${slug}.securityRules[${index}]`,
|
|
292
|
+
message: `"${slug}" has a \`${ownerField}\` column, and securityRules[${index}] lets ${describeRuleTarget(rule)} read every row of it with no row filter.`,
|
|
293
|
+
fix: `Filter the rule — \`ownerField: "${ownerField}"\` or a \`condition\` — or, if every row really is meant for ${describeRuleTarget(rule)}, keep it and ignore this warning.`
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
return findings;
|
|
298
|
+
}
|
|
299
|
+
var POSTGRES_PLUGIN = "@rebasepro/server-postgres";
|
|
300
|
+
/** Atlas's baseline bit in `atlas_schema_revisions.type`. */
|
|
301
|
+
var REVISION_TYPE_BASELINE = 1;
|
|
302
|
+
/**
|
|
303
|
+
* The migration files the database has not run, as Atlas's own `migrate status`
|
|
304
|
+
* counts them.
|
|
305
|
+
*
|
|
306
|
+
* A file is applied when its version has a row, or when it is at or behind the
|
|
307
|
+
* newest *baseline* row: `migrate apply --baseline V` records V alone, and
|
|
308
|
+
* means "everything up to V is already here". `rebase db migrate --baseline` is
|
|
309
|
+
* what the CLI prescribes for a booted database, so set membership alone
|
|
310
|
+
* reported the earlier migrations of every baselined database as not applied —
|
|
311
|
+
* and prescribed `rebase db migrate`, which answers "No migration files to
|
|
312
|
+
* execute". That run failed forever.
|
|
313
|
+
*/
|
|
314
|
+
function pendingMigrations(files, revisions, compare, versionOf) {
|
|
315
|
+
const recorded = new Set(revisions.map((row) => row.version));
|
|
316
|
+
const baseline = revisions.filter((row) => (Number(row.type) & REVISION_TYPE_BASELINE) !== 0).map((row) => row.version).reduce((newest, version) => newest === null || compare(version, newest) > 0 ? version : newest, null);
|
|
317
|
+
return files.filter((file) => {
|
|
318
|
+
const version = versionOf(file);
|
|
319
|
+
if (recorded.has(version)) return false;
|
|
320
|
+
return baseline === null || compare(version, baseline) > 0;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
async function checkMigrations(options, loaded, step) {
|
|
324
|
+
const skip = (detail) => {
|
|
325
|
+
step.status = "skipped";
|
|
326
|
+
step.detail = detail;
|
|
327
|
+
return [];
|
|
328
|
+
};
|
|
329
|
+
if (!options.backendDir) return skip("no backend/ directory");
|
|
330
|
+
const plugin = getActiveBackendPlugin(options.backendDir);
|
|
331
|
+
if (plugin !== POSTGRES_PLUGIN) return skip(`migrations are checked for ${POSTGRES_PLUGIN} projects${plugin ? `; this one uses ${plugin}` : ""}`);
|
|
332
|
+
const migrationsDir = path.join(options.backendDir, "drizzle", "migrations");
|
|
333
|
+
const files = fs.existsSync(migrationsDir) ? fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort() : [];
|
|
334
|
+
if (files.length === 0) return skip("no migrations yet — the schema is applied with `rebase db push`; `rebase db generate` starts versioned migrations");
|
|
335
|
+
const relativeDir = path.relative(options.projectRoot, migrationsDir);
|
|
336
|
+
const { findUnmigratedSchema } = await import("@rebasepro/server-postgres");
|
|
337
|
+
const findings = [];
|
|
338
|
+
const fix = "Run `rebase db generate` to write the migration, then `rebase db migrate` to apply it.";
|
|
339
|
+
const fileBySlug = new Map(loaded.collections.map((c, i) => [c.slug, loaded.sources[i]]));
|
|
340
|
+
let changes;
|
|
341
|
+
try {
|
|
342
|
+
changes = findUnmigratedSchema(loaded.collections, files.map((f) => fs.readFileSync(path.join(migrationsDir, f), "utf-8")));
|
|
343
|
+
} catch (error) {
|
|
344
|
+
return [{
|
|
345
|
+
step: "migrations",
|
|
346
|
+
check: "migration-plan",
|
|
347
|
+
severity: "error",
|
|
348
|
+
message: `Could not derive the schema from the collections: ${error instanceof Error ? error.message : String(error)}`,
|
|
349
|
+
fix: "`rebase schema generate` reports the same problem with more context."
|
|
350
|
+
}];
|
|
351
|
+
}
|
|
352
|
+
for (const change of changes) {
|
|
353
|
+
const where = change.column ? `${change.table}.${change.column}` : change.table;
|
|
354
|
+
const message = change.kind === "missing-table" ? `Table ${where} is declared by the collections but no migration in ${relativeDir} creates it.` : change.kind === "missing-column" ? `Column ${where} is declared by the collections but no migration in ${relativeDir} adds it.` : `Column ${where} is no longer declared by the collections, but the migrations in ${relativeDir} still create it.`;
|
|
355
|
+
const file = change.slug ? fileBySlug.get(change.slug) : void 0;
|
|
356
|
+
findings.push({
|
|
357
|
+
step: "migrations",
|
|
358
|
+
check: change.kind === "removed-column" ? "migration-stale-column" : "migration-missing",
|
|
359
|
+
severity: "error",
|
|
360
|
+
message,
|
|
361
|
+
fix,
|
|
362
|
+
...change.slug && { collection: change.slug },
|
|
363
|
+
...file && { file }
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
step.detail = `${files.length} migration${files.length === 1 ? "" : "s"} in ${relativeDir}`;
|
|
367
|
+
if (options.live && options.databaseUrl) findings.push(...await checkAppliedMigrations(options.databaseUrl, files));
|
|
368
|
+
return findings;
|
|
369
|
+
}
|
|
370
|
+
/** Which migration files the database has not run, from Atlas's revision table. */
|
|
371
|
+
async function checkAppliedMigrations(databaseUrl, files) {
|
|
372
|
+
const client = new (await (import("pg"))).default.Client({
|
|
373
|
+
connectionString: databaseUrl,
|
|
374
|
+
connectionTimeoutMillis: 5e3
|
|
375
|
+
});
|
|
376
|
+
try {
|
|
377
|
+
await client.connect();
|
|
378
|
+
if (!(await client.query("SELECT to_regclass('rebase.atlas_schema_revisions') IS NOT NULL AS present")).rows[0]?.present) return [{
|
|
379
|
+
step: "migrations",
|
|
380
|
+
check: "migration-unapplied",
|
|
381
|
+
severity: "warning",
|
|
382
|
+
message: "The database has no migration history (rebase.atlas_schema_revisions) — it was provisioned with `rebase db push`, or never migrated.",
|
|
383
|
+
fix: "`rebase db migrate --baseline <version>` adopts an existing database; `rebase db migrate` applies the migrations to a new one."
|
|
384
|
+
}];
|
|
385
|
+
const applied = await client.query("SELECT version, type FROM rebase.atlas_schema_revisions WHERE error IS NULL OR error = ''");
|
|
386
|
+
const { compareMigrationVersions, migrationVersionOf } = await import("@rebasepro/server-postgres");
|
|
387
|
+
const pending = pendingMigrations(files, applied.rows.map((row) => ({
|
|
388
|
+
version: row.version,
|
|
389
|
+
type: Number(row.type)
|
|
390
|
+
})), compareMigrationVersions, migrationVersionOf);
|
|
391
|
+
if (pending.length === 0) return [];
|
|
392
|
+
return [{
|
|
393
|
+
step: "migrations",
|
|
394
|
+
check: "migration-unapplied",
|
|
395
|
+
severity: "error",
|
|
396
|
+
message: `${pending.length} migration${pending.length === 1 ? " has" : "s have"} not been applied to the database: ${pending.join(", ")}.`,
|
|
397
|
+
fix: "Run `rebase db migrate`."
|
|
398
|
+
}];
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return [{
|
|
401
|
+
step: "migrations",
|
|
402
|
+
check: "migration-unapplied",
|
|
403
|
+
severity: "warning",
|
|
404
|
+
message: `Could not read the database's migration history: ${error instanceof Error ? error.message : String(error)}`
|
|
405
|
+
}];
|
|
406
|
+
} finally {
|
|
407
|
+
await client.end().catch(() => void 0);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** rls-check's severities, folded onto this command's two. `info` is not reported. */
|
|
411
|
+
function liveSeverity(severity) {
|
|
412
|
+
if (severity === "critical" || severity === "high") return "error";
|
|
413
|
+
if (severity === "medium" || severity === "low") return "warning";
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Why the database `--live` was pointed at cannot be opened, or `null` when it
|
|
418
|
+
* can. Asked once, before the live steps, so an unreachable database is one
|
|
419
|
+
* error rather than a skipped audit and a migration warning: under `--live`
|
|
420
|
+
* the audit was asked for, and a CI run in which it never ran is not green.
|
|
421
|
+
*/
|
|
422
|
+
async function databaseUnreachable(databaseUrl) {
|
|
423
|
+
const client = new (await (import("pg"))).default.Client({
|
|
424
|
+
connectionString: databaseUrl,
|
|
425
|
+
connectionTimeoutMillis: 5e3
|
|
426
|
+
});
|
|
427
|
+
try {
|
|
428
|
+
await client.connect();
|
|
429
|
+
return null;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return error instanceof Error ? error.message : String(error);
|
|
432
|
+
} finally {
|
|
433
|
+
await client.end().catch(() => void 0);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* A `USING (true)` read policy Rebase compiled from an `access: "public"`
|
|
438
|
+
* select rule, on a backend that refuses a request with no token.
|
|
439
|
+
*
|
|
440
|
+
* rls-check reads the policy alone, and for the role every signed-in request
|
|
441
|
+
* runs as it is "every row to everyone": critical. But the API answers 401
|
|
442
|
+
* before RLS is consulted unless `AUTH_REQUIRE=false`, so what it grants is
|
|
443
|
+
* every row to every *signed-in* user — exactly what step 2 accepts as a rule
|
|
444
|
+
* said on purpose, and what boot reports as an info line. A fresh blog
|
|
445
|
+
* scaffold failed its own `verify --live` with three such errors. It is a
|
|
446
|
+
* warning here, as an unfiltered read is in step 2; with `AUTH_REQUIRE=false`
|
|
447
|
+
* the rows really are public and it stays an error.
|
|
448
|
+
*/
|
|
449
|
+
function isTokenGatedPublicRead(finding, authRequired, isGeneratedPolicyName) {
|
|
450
|
+
if (!authRequired || finding.id !== "policy-always-true") return false;
|
|
451
|
+
const { table, policy } = finding.target;
|
|
452
|
+
if (!table || !policy) return false;
|
|
453
|
+
return isGeneratedPolicyName(policy, table) && policy.startsWith(`${table}_select_`);
|
|
454
|
+
}
|
|
455
|
+
async function checkLive(databaseUrl, step, authRequired) {
|
|
456
|
+
const { scan, CHECKS } = await import("@rebasepro/rls-check");
|
|
457
|
+
const { isGeneratedPolicyName } = await import("@rebasepro/server-postgres");
|
|
458
|
+
let result;
|
|
459
|
+
try {
|
|
460
|
+
result = await scan({
|
|
461
|
+
connectionString: databaseUrl,
|
|
462
|
+
statementTimeoutMs: 1e4
|
|
463
|
+
});
|
|
464
|
+
} catch (error) {
|
|
465
|
+
step.detail = "the audit did not run";
|
|
466
|
+
return [{
|
|
467
|
+
step: "live",
|
|
468
|
+
check: "live-scan-failed",
|
|
469
|
+
severity: "error",
|
|
470
|
+
message: `The live RLS audit failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
471
|
+
fix: "`npx @rebasepro/rls-check <database-url>` runs the same audit on its own and reports the failure in full."
|
|
472
|
+
}];
|
|
473
|
+
}
|
|
474
|
+
step.detail = `${CHECKS.length} rls-check checks against ${result.database.host}/${result.database.name}`;
|
|
475
|
+
const findings = [];
|
|
476
|
+
for (const finding of result.findings) {
|
|
477
|
+
const severity = liveSeverity(finding.severity);
|
|
478
|
+
if (!severity) continue;
|
|
479
|
+
const tokenGated = severity === "error" && isTokenGatedPublicRead(finding, authRequired, isGeneratedPolicyName);
|
|
480
|
+
findings.push({
|
|
481
|
+
step: "live",
|
|
482
|
+
check: finding.id,
|
|
483
|
+
severity: tokenGated ? "warning" : severity,
|
|
484
|
+
message: tokenGated ? `${finding.title} — compiled from an \`access: "public"\` read rule: every signed-in user reads every row. A request with no token is refused by the API (AUTH_REQUIRE is on), so it is not an anonymous read.` : finding.title,
|
|
485
|
+
...finding.fix && { fix: finding.fix },
|
|
486
|
+
path: finding.target.table ? `${finding.target.schema}.${finding.target.table}` : finding.target.schema
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
for (const degraded of result.diagnostics.degraded) findings.push({
|
|
490
|
+
step: "live",
|
|
491
|
+
check: "rls-check-degraded",
|
|
492
|
+
severity: "warning",
|
|
493
|
+
message: `The audit could not read ${degraded.what}: ${degraded.error}. A clean result does not cover it.`
|
|
494
|
+
});
|
|
495
|
+
return findings;
|
|
496
|
+
}
|
|
497
|
+
function settle(step, findings) {
|
|
498
|
+
if (step.status === "skipped") return step;
|
|
499
|
+
const mine = findings.filter((f) => f.step === step.id);
|
|
500
|
+
step.status = mine.some((f) => f.severity === "error") ? "failed" : mine.length > 0 ? "warnings" : "passed";
|
|
501
|
+
return step;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Run every step and return the report. Prints nothing and never exits, so a
|
|
505
|
+
* caller — the command, a test — decides what to do with it.
|
|
506
|
+
*/
|
|
507
|
+
async function runVerify(options) {
|
|
508
|
+
const findings = [];
|
|
509
|
+
const steps = [
|
|
510
|
+
"config",
|
|
511
|
+
"rls",
|
|
512
|
+
"migrations",
|
|
513
|
+
"live"
|
|
514
|
+
].map((id) => ({
|
|
515
|
+
id,
|
|
516
|
+
title: STEP_TITLES[id],
|
|
517
|
+
status: "passed"
|
|
518
|
+
}));
|
|
519
|
+
const [configStep, rlsStep, migrationsStep, liveStep] = steps;
|
|
520
|
+
const collectionsDir = path.relative(options.projectRoot, options.collectionsDir) || ".";
|
|
521
|
+
if (!fs.existsSync(options.collectionsDir) || !fs.statSync(options.collectionsDir).isDirectory()) {
|
|
522
|
+
for (const step of steps) {
|
|
523
|
+
step.status = "skipped";
|
|
524
|
+
step.detail = `no collections directory at ${collectionsDir}`;
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
collectionsDir,
|
|
529
|
+
steps,
|
|
530
|
+
findings,
|
|
531
|
+
summary: {
|
|
532
|
+
errors: 0,
|
|
533
|
+
warnings: 0
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const loaded = await loadCollectionFiles(options.projectRoot, options.collectionsDir);
|
|
538
|
+
findings.push(...loaded.failures);
|
|
539
|
+
if (loaded.collections.length > 0) findings.push(...await checkConfig(loaded));
|
|
540
|
+
const counted = `${loaded.collections.length} collection${loaded.collections.length === 1 ? "" : "s"}`;
|
|
541
|
+
configStep.detail = `${counted}, unknown keys are errors`;
|
|
542
|
+
rlsStep.detail = counted;
|
|
543
|
+
if (loaded.defaults?.length) {
|
|
544
|
+
const { applyCollectionDefaults } = await import("@rebasepro/server");
|
|
545
|
+
applyCollectionDefaults(loaded.collections, { defaultSecurityRules: [...loaded.defaults] });
|
|
546
|
+
}
|
|
547
|
+
findings.push(...checkRls(loaded));
|
|
548
|
+
let liveUrl;
|
|
549
|
+
if (options.live) if (!options.databaseUrl) {
|
|
550
|
+
liveStep.detail = "no database to audit";
|
|
551
|
+
findings.push({
|
|
552
|
+
step: "live",
|
|
553
|
+
check: "live-no-database",
|
|
554
|
+
severity: "error",
|
|
555
|
+
message: options.databaseUnavailable ? `--live was asked for, and the database could not be prepared: ${options.databaseUnavailable}` : "--live was asked for, and no database is configured to audit.",
|
|
556
|
+
fix: "Pass --database-url <url> (or set DATABASE_URL), or run without --live for the offline checks."
|
|
557
|
+
});
|
|
558
|
+
} else {
|
|
559
|
+
const unreachable = await databaseUnreachable(options.databaseUrl);
|
|
560
|
+
if (unreachable) {
|
|
561
|
+
liveStep.detail = "the database did not answer";
|
|
562
|
+
findings.push({
|
|
563
|
+
step: "live",
|
|
564
|
+
check: "live-unreachable",
|
|
565
|
+
severity: "error",
|
|
566
|
+
message: `--live was asked for, and the database could not be reached: ${unreachable}. Neither the migration check nor the RLS audit ran.`,
|
|
567
|
+
fix: "Check the database is running and the URL is right (`rebase db url` prints the one this project resolves), or run without --live for the offline checks."
|
|
568
|
+
});
|
|
569
|
+
} else liveUrl = options.databaseUrl;
|
|
570
|
+
}
|
|
571
|
+
if (findings.some((f) => f.step === "config" && f.severity === "error")) {
|
|
572
|
+
migrationsStep.status = "skipped";
|
|
573
|
+
migrationsStep.detail = "fix the collection config errors first";
|
|
574
|
+
} else findings.push(...await checkMigrations({
|
|
575
|
+
...options,
|
|
576
|
+
databaseUrl: liveUrl
|
|
577
|
+
}, loaded, migrationsStep));
|
|
578
|
+
if (!options.live) {
|
|
579
|
+
liveStep.status = "skipped";
|
|
580
|
+
liveStep.detail = "pass --live to audit the database";
|
|
581
|
+
} else if (liveUrl) findings.push(...await checkLive(liveUrl, liveStep, options.authRequired ?? true));
|
|
582
|
+
steps.forEach((step) => settle(step, findings));
|
|
583
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
584
|
+
return {
|
|
585
|
+
ok: errors === 0,
|
|
586
|
+
collectionsDir,
|
|
587
|
+
steps,
|
|
588
|
+
findings,
|
|
589
|
+
summary: {
|
|
590
|
+
errors,
|
|
591
|
+
warnings: findings.length - errors
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function printFinding(finding) {
|
|
596
|
+
const mark = finding.severity === "error" ? chalk.red("✗") : chalk.yellow("!");
|
|
597
|
+
const where = [finding.file, finding.path].filter(Boolean).join(" → ");
|
|
598
|
+
console.log(` ${mark} ${where ? `${chalk.bold(where)} ` : ""}${finding.message}`);
|
|
599
|
+
if (finding.fix) console.log(chalk.gray(` fix: ${finding.fix}`));
|
|
600
|
+
}
|
|
601
|
+
function summaryLine(report) {
|
|
602
|
+
const { errors, warnings } = report.summary;
|
|
603
|
+
return `${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`;
|
|
604
|
+
}
|
|
605
|
+
function printReport(report, quiet) {
|
|
606
|
+
if (quiet) {
|
|
607
|
+
if (report.findings.length === 0) return;
|
|
608
|
+
if (!report.ok) console.log(chalk.red(`rebase verify: ${summaryLine(report)}`));
|
|
609
|
+
for (const finding of report.findings) printFinding(finding);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
console.log("");
|
|
613
|
+
console.log(` ${chalk.bold("rebase verify")} ${chalk.gray(report.collectionsDir)}`);
|
|
614
|
+
console.log("");
|
|
615
|
+
for (const step of report.steps) {
|
|
616
|
+
const mark = step.status === "passed" ? chalk.green("✓") : step.status === "failed" ? chalk.red("✗") : step.status === "warnings" ? chalk.yellow("!") : chalk.gray("–");
|
|
617
|
+
const detail = step.detail ? chalk.gray(step.status === "skipped" ? `skipped: ${step.detail}` : step.detail) : "";
|
|
618
|
+
console.log(` ${mark} ${step.title.padEnd(22)} ${detail}`);
|
|
619
|
+
}
|
|
620
|
+
if (report.findings.length > 0) {
|
|
621
|
+
console.log("");
|
|
622
|
+
for (const finding of report.findings) printFinding(finding);
|
|
623
|
+
}
|
|
624
|
+
console.log("");
|
|
625
|
+
console.log(report.ok ? chalk.green(` ✓ ${summaryLine(report)}`) : chalk.red(` ✗ ${summaryLine(report)}`));
|
|
626
|
+
console.log("");
|
|
627
|
+
}
|
|
628
|
+
function printVerifyHelp() {
|
|
629
|
+
console.log(`
|
|
630
|
+
${chalk.bold("rebase verify")} — Check the collections are safe to ship
|
|
631
|
+
|
|
632
|
+
${chalk.green.bold("Usage")}
|
|
633
|
+
rebase verify [options]
|
|
634
|
+
|
|
635
|
+
${chalk.green.bold("Options")}
|
|
636
|
+
${Object.entries(VERIFY_FLAG_HELP).map(([flag, help]) => ` ${chalk.blue(flag.padEnd(20))} ${help}`).join("\n")}
|
|
637
|
+
|
|
638
|
+
${chalk.green.bold("Steps")}
|
|
639
|
+
1. Collection config every file loads and passes the boot validator, unknown keys as errors
|
|
640
|
+
2. RLS coverage securityRules are declared, and owner/tenant columns are filtered on
|
|
641
|
+
3. Migration freshness drizzle/migrations builds every declared table and column
|
|
642
|
+
4. Live RLS audit with --live: the rls-check catalogue against the project's database
|
|
643
|
+
|
|
644
|
+
Exits 1 when any step reports an error; warnings do not fail. Under --live, a
|
|
645
|
+
database that is missing or does not answer is an error. Fast and offline
|
|
646
|
+
without --live: for CI, and for the Claude Code hook that ${chalk.cyan("rebase skills --hooks")} installs.
|
|
647
|
+
`);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Whether the backend refuses a request with no token: `AUTH_REQUIRE` from the
|
|
651
|
+
* environment, else `.env`, read as every boolean env var is read
|
|
652
|
+
* (`parseEnvBoolean`: `false`/`0`/`no`/`off` turn it off). On unless one of
|
|
653
|
+
* those says otherwise — the runtime's default. Compared raw, `AUTH_REQUIRE=0`
|
|
654
|
+
* read as "on", and a compiled public read was reported as token-gated on a
|
|
655
|
+
* backend that serves it to anyone.
|
|
656
|
+
*/
|
|
657
|
+
function authRequiredFrom(environment, envFile) {
|
|
658
|
+
return parseEnvBoolean(environment.AUTH_REQUIRE ?? envFile.AUTH_REQUIRE) !== false;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The database `--live` audits, resolved as every other database door resolves
|
|
662
|
+
* it (`db *`, `console`, `doctor`): `--database-url`, `DATABASE_URL`, `.env`,
|
|
663
|
+
* `--docker` or the last `rebase dev --docker`, and otherwise the managed
|
|
664
|
+
* development database — started if it is not running. Reading only
|
|
665
|
+
* `DATABASE_URL` meant the audit never ran on a fresh scaffold, whose database
|
|
666
|
+
* is the managed one.
|
|
667
|
+
*
|
|
668
|
+
* Progress goes to stderr, so `--json` keeps stdout to the report.
|
|
669
|
+
*/
|
|
670
|
+
async function resolveLiveDatabase(projectRoot, rawArgs, options) {
|
|
671
|
+
try {
|
|
672
|
+
const { ensureDockerDatabase, databaseUrlOf } = await import("./db-KzlqddGD.js");
|
|
673
|
+
await ensureDockerDatabase(projectRoot, rawArgs);
|
|
674
|
+
const { prepareDatabaseEnv } = await import("./prepare-CTw9NEkh.js");
|
|
675
|
+
return { databaseUrl: databaseUrlOf(await prepareDatabaseEnv(projectRoot, {
|
|
676
|
+
flagUrl: options.flagUrl,
|
|
677
|
+
flagDocker: options.flagDocker,
|
|
678
|
+
quiet: options.quiet,
|
|
679
|
+
onProgress: options.quiet ? void 0 : (line) => console.error(chalk.gray(` ${line}`))
|
|
680
|
+
}), readEnvFile(projectRoot)) };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
return { databaseUnavailable: error instanceof Error ? error.message : String(error) };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
async function verifyCommand(rawArgs) {
|
|
686
|
+
if (wantsHelp(rawArgs)) {
|
|
687
|
+
printVerifyHelp();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const { flags } = parseCommandArgs({
|
|
691
|
+
spec: VERIFY_FLAGS,
|
|
692
|
+
rawArgs,
|
|
693
|
+
commandWords: 1,
|
|
694
|
+
command: "verify",
|
|
695
|
+
maxPositionals: 0
|
|
696
|
+
});
|
|
697
|
+
const projectRoot = requireProjectRoot();
|
|
698
|
+
const backendDir = findBackendDir(projectRoot);
|
|
699
|
+
if (flags["--collections"]) {
|
|
700
|
+
const refusal = missingCollectionsPath(rawArgs, process.cwd(), backendDir ?? projectRoot, "verify");
|
|
701
|
+
if (refusal) {
|
|
702
|
+
if (flags["--json"]) failAsJson(`Collections path not found: "${flags["--collections"]}" (resolved to ${path.resolve(process.cwd(), flags["--collections"])}).`, "collections_not_found", "Leave --collections out (config/collections is the default), or pass a directory that exists; relative paths are resolved from where you run the command.");
|
|
703
|
+
console.error("");
|
|
704
|
+
for (const line of refusal) console.error(line);
|
|
705
|
+
console.error("");
|
|
706
|
+
process.exit(1);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const collectionsDir = flags["--collections"] ? path.resolve(process.cwd(), flags["--collections"]) : resolveCollectionsDir(projectRoot);
|
|
710
|
+
const live = flags["--live"] === true;
|
|
711
|
+
const report = await runVerify({
|
|
712
|
+
projectRoot,
|
|
713
|
+
collectionsDir,
|
|
714
|
+
backendDir,
|
|
715
|
+
live,
|
|
716
|
+
...live ? await resolveLiveDatabase(projectRoot, rawArgs, {
|
|
717
|
+
flagUrl: flags["--database-url"] ?? null,
|
|
718
|
+
flagDocker: flags["--docker"] === true,
|
|
719
|
+
quiet: flags["--quiet"] === true
|
|
720
|
+
}) : {},
|
|
721
|
+
authRequired: authRequiredFrom(process.env, readEnvFile(projectRoot))
|
|
722
|
+
});
|
|
723
|
+
if (flags["--json"]) console.log(JSON.stringify(report, null, 2));
|
|
724
|
+
else printReport(report, flags["--quiet"] === true);
|
|
725
|
+
if (!report.ok) process.exitCode = 1;
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
728
|
+
export { verifyCommand };
|
|
729
|
+
|
|
730
|
+
//# sourceMappingURL=verify-D7UsV_qe.js.map
|