@mccaskie-labs/workbench-supabase 0.0.0-stage → 0.1.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/CHANGELOG.md +5 -0
- package/README.md +53 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +136 -0
- package/dist/clients.d.ts +39 -0
- package/dist/clients.js +85 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +48 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/migrations.d.ts +11 -0
- package/dist/migrations.js +42 -0
- package/dist/prod-schema.d.ts +58 -0
- package/dist/prod-schema.js +168 -0
- package/dist/strings.d.ts +7 -0
- package/dist/strings.js +135 -0
- package/package.json +51 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @mccaskie-labs/workbench-supabase
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- First version (step 4.11): `supabaseConfig`, `createBrowserClient`, `createServerClient`, `createUserClient`, `createServiceClient` (logs its reason once per isolate), `WorkbenchSupabaseError`, and the CLI's `check:migrations`, `check:prod-schema` and `prod-schema-sql` (`docs/spec.md` §3).
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mccaskie-labs/workbench-supabase
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Supabase for apps built on Workbench, a private platform. Each app has its own Supabase projects (`<id>-staging`, `<id>-production`), and Workbench Control writes their URL and keys onto the app's Workers, so the app holds no other Supabase credential. Published publicly so Workbench projects can install it with no token; it is not supported for other uses.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @mccaskie-labs/workbench-supabase @mccaskie-labs/workbench-config zod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createUserClient, createServiceClient, supabaseConfig } from '@mccaskie-labs/workbench-supabase'
|
|
11
|
+
import { workerSecret } from '@mccaskie-labs/workbench-config'
|
|
12
|
+
|
|
13
|
+
const cfg = supabaseConfig(env) // SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY; throws naming any that are missing
|
|
14
|
+
const db = createUserClient({ ...cfg, accessToken }) // RLS applies as the caller
|
|
15
|
+
const admin = createServiceClient({ url: cfg.url, secretKey: workerSecret(env, 'SUPABASE_SECRET_KEY'), reason: 'nightly clean-up' })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
| Export | What it does |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| `supabaseConfig(env)` | `{ url, publishableKey }` from `SUPABASE_URL` and `SUPABASE_PUBLISHABLE_KEY`. One `WorkbenchConfigError` names every bad variable; no value is ever printed. Only `sb_publishable_` keys: legacy JWT keys are refused |
|
|
21
|
+
| `createBrowserClient(cfg)` | In the browser: the signed-in user's session, kept in cookies (`@supabase/ssr`) |
|
|
22
|
+
| `createServerClient({ ...cfg, cookies })` | On the server, as the signed-in user from the request's cookies. `cookies` is `{ getAll, setAll }` |
|
|
23
|
+
| `createUserClient({ ...cfg, accessToken })` | As the caller, with the token they sent (audit F7). No session is stored or refreshed |
|
|
24
|
+
| `createServiceClient({ url, secretKey, reason })` | The `sb_secret_` key: **bypasses RLS**. `reason` (one line) is logged once per isolate: `[workbench-supabase] service client (bypasses RLS): <reason>`. Refused in a browser and for a publishable key |
|
|
25
|
+
| `WorkbenchSupabaseError` | A client built wrongly: `code` is `invalid_config` or `forbidden`. Never holds a key, URL or token |
|
|
26
|
+
|
|
27
|
+
Every client takes an optional `fetch`, for tests against a fake Supabase.
|
|
28
|
+
|
|
29
|
+
## Worker secrets
|
|
30
|
+
|
|
31
|
+
Control writes them with `cf_worker_secret_put`, from the Worker's own database (`<id>-staging` Worker: `<id>-staging`; `<id>` Worker: `<id>-production`). Nobody types a value.
|
|
32
|
+
|
|
33
|
+
| Secret | Source | Gate |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `SUPABASE_URL` | `supabase_url` | Free |
|
|
36
|
+
| `SUPABASE_PUBLISHABLE_KEY` | `supabase_publishable` | Free |
|
|
37
|
+
| `SUPABASE_SECRET_KEY` | `supabase_secret` | Two-step (Robert approves) |
|
|
38
|
+
|
|
39
|
+
## Checks
|
|
40
|
+
|
|
41
|
+
Add both to the project's `Checks`:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx workbench-supabase check:migrations # every supabase/migrations/<14 digits>_<name>.sql has supabase/rollback/<same>_down.sql
|
|
45
|
+
npx workbench-supabase check:prod-schema # code in src/ names nothing production's database lacks
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`check:prod-schema` compares what the migrations create (public tables, views, functions and added columns) with `supabase/production-schema.json`, production's schema snapshot, and fails when a string literal in code bound for production (`src/` by default, or each `--code <path>`; tests are skipped) names something missing there. Refresh the snapshot after each production apply: run the SQL from `npx workbench-supabase prod-schema-sql` with `sb_query env=production` and save its `snapshot` value. Before production has a database, the snapshot is `{ "format": 1, "tables": {}, "functions": [] }`.
|
|
49
|
+
|
|
50
|
+
A use that is kept off production by a switch is allowed by an entry in `supabase/prod-schema-allow.json`: `[{ "file": "src/x.ts", "names": ["notes"], "why": "only when WORKBENCH_ENV is staging" }]`. An entry that matches nothing fails too, so the list stays true.
|
|
51
|
+
|
|
52
|
+
It is a tripwire, not a proof: it sees names in string literals only (`.from('x')`, `.rpc('x')`, `.select('a, x')`), not names built at run time.
|
|
53
|
+
|
|
54
|
+
Runs on Node 24 and the Cloudflare Workers runtime (the CLI on Node only). `zod` 4 is a peer dependency, through `workbench-config`.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// workbench-supabase: the database checks a project runs in Checks.
|
|
3
|
+
//
|
|
4
|
+
// workbench-supabase check:migrations [dir]
|
|
5
|
+
// workbench-supabase check:prod-schema [dir] [--code <path>]...
|
|
6
|
+
// workbench-supabase prod-schema-sql
|
|
7
|
+
//
|
|
8
|
+
// `dir` is the project root (default: the current folder). Offline; reads files only.
|
|
9
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join, relative, sep } from "node:path";
|
|
11
|
+
import { migrationProblems } from "./migrations.js";
|
|
12
|
+
import { findUses, missingOnProduction, parseAllow, parseProductionSchema, PRODUCTION_SCHEMA_SQL, unusedAllows } from "./prod-schema.js";
|
|
13
|
+
const MIGRATIONS = "supabase/migrations";
|
|
14
|
+
const ROLLBACK = "supabase/rollback";
|
|
15
|
+
const SNAPSHOT = "supabase/production-schema.json";
|
|
16
|
+
const ALLOW = "supabase/prod-schema-allow.json";
|
|
17
|
+
const CODE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
18
|
+
const SKIP_FILE = /(\.(test|spec)\.[cm]?[jt]sx?|\.d\.[cm]?ts)$/;
|
|
19
|
+
const SKIP_DIR = new Set(["node_modules", "dist", ".wrangler", ".git", "test", "tests", "__tests__"]);
|
|
20
|
+
const list = (dir) => (existsSync(dir) ? readdirSync(dir) : []);
|
|
21
|
+
const posix = (p) => p.split(sep).join("/");
|
|
22
|
+
function walk(root, path, out) {
|
|
23
|
+
if (!existsSync(path))
|
|
24
|
+
return;
|
|
25
|
+
if (statSync(path).isFile()) {
|
|
26
|
+
if (CODE_EXT.test(path) && !SKIP_FILE.test(path))
|
|
27
|
+
out.push(posix(relative(root, path)));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
31
|
+
if (entry.isDirectory() && (SKIP_DIR.has(entry.name) || entry.name.startsWith(".")))
|
|
32
|
+
continue;
|
|
33
|
+
walk(root, join(path, entry.name), out);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function checkMigrations(dir) {
|
|
37
|
+
const ups = list(join(dir, MIGRATIONS));
|
|
38
|
+
const problems = migrationProblems(ups, list(join(dir, ROLLBACK)));
|
|
39
|
+
if (problems.length) {
|
|
40
|
+
for (const p of problems)
|
|
41
|
+
console.error(`${p.file}: ${p.problem}`);
|
|
42
|
+
console.error(`check:migrations failed: ${problems.length} problem(s).`);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
const count = ups.filter((f) => f.endsWith(".sql")).length;
|
|
46
|
+
console.log(`check:migrations passed: ${count} migration(s), each with a down file.`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
function readJson(path, what) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new Error(`${what} isn't valid JSON: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function checkProdSchema(dir, code) {
|
|
58
|
+
const snapshotPath = join(dir, SNAPSHOT);
|
|
59
|
+
if (!existsSync(snapshotPath)) {
|
|
60
|
+
console.error(`check:prod-schema: no ${SNAPSHOT}. Save production's schema there: run \`workbench-supabase prod-schema-sql\` on production ` +
|
|
61
|
+
"(sb_query env=production) and save its snapshot value. Before production has a database, use " +
|
|
62
|
+
'{ "format": 1, "tables": {}, "functions": [] }.');
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
let prod;
|
|
66
|
+
let allow;
|
|
67
|
+
try {
|
|
68
|
+
prod = parseProductionSchema(readJson(snapshotPath, SNAPSHOT));
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
console.error(`check:prod-schema: ${SNAPSHOT}: ${e.message}`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
allow = existsSync(join(dir, ALLOW)) ? parseAllow(readJson(join(dir, ALLOW), ALLOW)) : [];
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
console.error(`check:prod-schema: ${ALLOW}: ${e.message}`);
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
const migrations = list(join(dir, MIGRATIONS))
|
|
82
|
+
.filter((f) => f.endsWith(".sql"))
|
|
83
|
+
.sort()
|
|
84
|
+
.map((f) => ({ stem: f.replace(/\.sql$/, ""), sql: readFileSync(join(dir, MIGRATIONS, f), "utf8") }));
|
|
85
|
+
const missing = missingOnProduction(migrations, prod);
|
|
86
|
+
const files = [];
|
|
87
|
+
for (const c of code.length ? code : ["src"])
|
|
88
|
+
walk(dir, join(dir, c), files);
|
|
89
|
+
const sources = [...new Set(files)].sort().map((path) => ({ path, text: readFileSync(join(dir, path), "utf8") }));
|
|
90
|
+
const uses = findUses(sources, missing, allow);
|
|
91
|
+
const bad = uses.filter((u) => !u.allowed);
|
|
92
|
+
const unused = unusedAllows(allow, uses);
|
|
93
|
+
console.log(`check:prod-schema: ${missing.size} name(s) in migrations that production lacks; scanned ${sources.length} file(s): ` +
|
|
94
|
+
`${uses.length} use(s), ${uses.length - bad.length} allowed.`);
|
|
95
|
+
if (bad.length) {
|
|
96
|
+
console.error("\nCode bound for production names something production's database doesn't have:");
|
|
97
|
+
for (const u of bad) {
|
|
98
|
+
const m = missing.get(u.name);
|
|
99
|
+
console.error(` ${u.file}:${u.line} ${m.kind} ${m.label} (${m.migration})`);
|
|
100
|
+
}
|
|
101
|
+
console.error(`\nApply the migration on production first (then refresh ${SNAPSHOT}), or keep the use behind a switch that is off on ` +
|
|
102
|
+
`production and add an entry to ${ALLOW}: { "file", "names", "why" }.`);
|
|
103
|
+
}
|
|
104
|
+
if (unused.length) {
|
|
105
|
+
console.error(`\nEntries in ${ALLOW} that match nothing (remove them):`);
|
|
106
|
+
for (const u of unused)
|
|
107
|
+
console.error(` ${u.file}: ${u.name}`);
|
|
108
|
+
}
|
|
109
|
+
if (bad.length || unused.length)
|
|
110
|
+
return 1;
|
|
111
|
+
console.log("OK. Limits: direct uses in string literals only; names built at run time aren't seen.");
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
function main(argv) {
|
|
115
|
+
const [command, ...rest] = argv;
|
|
116
|
+
const code = [];
|
|
117
|
+
const positional = [];
|
|
118
|
+
for (let i = 0; i < rest.length; i++) {
|
|
119
|
+
if (rest[i] === "--code" && rest[i + 1])
|
|
120
|
+
code.push(rest[++i]);
|
|
121
|
+
else
|
|
122
|
+
positional.push(rest[i]);
|
|
123
|
+
}
|
|
124
|
+
const dir = positional[0] ?? ".";
|
|
125
|
+
if (command === "check:migrations")
|
|
126
|
+
return checkMigrations(dir);
|
|
127
|
+
if (command === "check:prod-schema")
|
|
128
|
+
return checkProdSchema(dir, code);
|
|
129
|
+
if (command === "prod-schema-sql") {
|
|
130
|
+
console.log(PRODUCTION_SCHEMA_SQL);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
console.error("Usage: workbench-supabase check:migrations [dir] | check:prod-schema [dir] [--code <path>]... | prod-schema-sql");
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type CookieMethodsServer } from "@supabase/ssr";
|
|
2
|
+
import { type SupabaseClient } from "@supabase/supabase-js";
|
|
3
|
+
/** Reads and writes the request's cookies: `getAll` from the request, `setAll` onto the response. */
|
|
4
|
+
export type CookieAdapter = CookieMethodsServer;
|
|
5
|
+
type Fetch = typeof fetch;
|
|
6
|
+
interface PublicConfig {
|
|
7
|
+
url: string;
|
|
8
|
+
publishableKey: string;
|
|
9
|
+
/** For tests: a fake Supabase. Defaults to the global fetch. */
|
|
10
|
+
fetch?: Fetch;
|
|
11
|
+
}
|
|
12
|
+
/** In the browser: the signed-in user's session, kept in cookies so the server sees it too. */
|
|
13
|
+
export declare function createBrowserClient<DB = any>(cfg: PublicConfig): SupabaseClient<DB>;
|
|
14
|
+
/** On the server, acting as the signed-in user whose session is in the request's cookies. */
|
|
15
|
+
export declare function createServerClient<DB = any>(cfg: PublicConfig & {
|
|
16
|
+
cookies: CookieAdapter;
|
|
17
|
+
}): SupabaseClient<DB>;
|
|
18
|
+
/**
|
|
19
|
+
* Acts as the caller, with the access token they sent (audit F7): RLS applies
|
|
20
|
+
* as that user. No session is stored or refreshed, and `auth` methods are
|
|
21
|
+
* unavailable on this client.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createUserClient<DB = any>(cfg: PublicConfig & {
|
|
24
|
+
accessToken: string;
|
|
25
|
+
}): SupabaseClient<DB>;
|
|
26
|
+
/** Forgets which reasons were logged, so a test can see the line again. */
|
|
27
|
+
export declare function resetServiceLog(): void;
|
|
28
|
+
/**
|
|
29
|
+
* The secret key: bypasses RLS. `reason` says why this code needs that, and is
|
|
30
|
+
* logged once per isolate (per reason), so every use is visible in the
|
|
31
|
+
* Worker's logs. Refused in a browser, and refused for a publishable key.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createServiceClient<DB = any>(cfg: {
|
|
34
|
+
url: string;
|
|
35
|
+
secretKey: string;
|
|
36
|
+
reason: string;
|
|
37
|
+
fetch?: Fetch;
|
|
38
|
+
}): SupabaseClient<DB>;
|
|
39
|
+
export {};
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The four Supabase clients (docs/spec.md §3). Lifted from Imprint's
|
|
2
|
+
// apps/admin/lib/supabase-server.ts, supabase-browser.ts and
|
|
3
|
+
// packages/site-kit/src/lib/supabase.ts.
|
|
4
|
+
//
|
|
5
|
+
// createBrowserClient in the browser, the signed-in user's session in cookies
|
|
6
|
+
// createServerClient on the server, acting as the signed-in user (@supabase/ssr)
|
|
7
|
+
// createUserClient the caller's own access token, no session handling (audit F7)
|
|
8
|
+
// createServiceClient the secret key: bypasses RLS, so it needs a reason, which
|
|
9
|
+
// is logged once per isolate
|
|
10
|
+
import { createBrowserClient as ssrBrowser, createServerClient as ssrServer } from "@supabase/ssr";
|
|
11
|
+
import { createClient } from "@supabase/supabase-js";
|
|
12
|
+
import { PUBLISHABLE_PREFIX, SECRET_PREFIX, urlProblem } from "./config.js";
|
|
13
|
+
import { WorkbenchSupabaseError } from "./errors.js";
|
|
14
|
+
function checkUrl(url) {
|
|
15
|
+
if (typeof url !== "string" || urlProblem(url)) {
|
|
16
|
+
throw new WorkbenchSupabaseError("invalid_config", "The Supabase URL is missing or invalid: expected https://<project ref>.supabase.co, or the local stack.");
|
|
17
|
+
}
|
|
18
|
+
return url.replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
function checkPublishable(key) {
|
|
21
|
+
if (typeof key !== "string" || !key.startsWith(PUBLISHABLE_PREFIX)) {
|
|
22
|
+
throw new WorkbenchSupabaseError("invalid_config", key?.startsWith?.(SECRET_PREFIX)
|
|
23
|
+
? "A secret key was passed where the publishable key belongs. Use createServiceClient for the secret key."
|
|
24
|
+
: `Expected a ${PUBLISHABLE_PREFIX} key.`);
|
|
25
|
+
}
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
const global = (f) => (f ? { global: { fetch: f } } : {});
|
|
29
|
+
/** In the browser: the signed-in user's session, kept in cookies so the server sees it too. */
|
|
30
|
+
export function createBrowserClient(cfg) {
|
|
31
|
+
return ssrBrowser(checkUrl(cfg.url), checkPublishable(cfg.publishableKey), { ...global(cfg.fetch) });
|
|
32
|
+
}
|
|
33
|
+
/** On the server, acting as the signed-in user whose session is in the request's cookies. */
|
|
34
|
+
export function createServerClient(cfg) {
|
|
35
|
+
if (!cfg.cookies || typeof cfg.cookies.getAll !== "function") {
|
|
36
|
+
throw new WorkbenchSupabaseError("invalid_config", "createServerClient needs a cookie adapter with getAll (and setAll to write refreshed sessions).");
|
|
37
|
+
}
|
|
38
|
+
return ssrServer(checkUrl(cfg.url), checkPublishable(cfg.publishableKey), { cookies: cfg.cookies, ...global(cfg.fetch) });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Acts as the caller, with the access token they sent (audit F7): RLS applies
|
|
42
|
+
* as that user. No session is stored or refreshed, and `auth` methods are
|
|
43
|
+
* unavailable on this client.
|
|
44
|
+
*/
|
|
45
|
+
export function createUserClient(cfg) {
|
|
46
|
+
if (typeof cfg.accessToken !== "string" || cfg.accessToken === "") {
|
|
47
|
+
throw new WorkbenchSupabaseError("invalid_config", "createUserClient needs the caller's access token.");
|
|
48
|
+
}
|
|
49
|
+
const token = cfg.accessToken;
|
|
50
|
+
return createClient(checkUrl(cfg.url), checkPublishable(cfg.publishableKey), {
|
|
51
|
+
accessToken: async () => token,
|
|
52
|
+
...global(cfg.fetch),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const logged = new Set();
|
|
56
|
+
/** Forgets which reasons were logged, so a test can see the line again. */
|
|
57
|
+
export function resetServiceLog() {
|
|
58
|
+
logged.clear();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The secret key: bypasses RLS. `reason` says why this code needs that, and is
|
|
62
|
+
* logged once per isolate (per reason), so every use is visible in the
|
|
63
|
+
* Worker's logs. Refused in a browser, and refused for a publishable key.
|
|
64
|
+
*/
|
|
65
|
+
export function createServiceClient(cfg) {
|
|
66
|
+
if ("document" in globalThis) {
|
|
67
|
+
throw new WorkbenchSupabaseError("forbidden", "createServiceClient runs on the server only: the secret key must never reach a browser.");
|
|
68
|
+
}
|
|
69
|
+
if (typeof cfg.secretKey !== "string" || !cfg.secretKey.startsWith(SECRET_PREFIX)) {
|
|
70
|
+
throw new WorkbenchSupabaseError("invalid_config", `createServiceClient needs a ${SECRET_PREFIX} key (SUPABASE_SECRET_KEY).`);
|
|
71
|
+
}
|
|
72
|
+
const reason = typeof cfg.reason === "string" ? cfg.reason.trim() : "";
|
|
73
|
+
if (reason.length < 3 || reason.length > 200 || /[\r\n]/.test(reason)) {
|
|
74
|
+
throw new WorkbenchSupabaseError("invalid_config", "createServiceClient needs a reason: one line, 3 to 200 characters, saying why RLS is bypassed.");
|
|
75
|
+
}
|
|
76
|
+
const url = checkUrl(cfg.url);
|
|
77
|
+
if (!logged.has(reason)) {
|
|
78
|
+
logged.add(reason);
|
|
79
|
+
console.info(`[workbench-supabase] service client (bypasses RLS): ${reason}`);
|
|
80
|
+
}
|
|
81
|
+
return createClient(url, cfg.secretKey, {
|
|
82
|
+
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
83
|
+
...global(cfg.fetch),
|
|
84
|
+
});
|
|
85
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Where an app finds its own Supabase project (docs/spec.md §3). Both values are public. */
|
|
2
|
+
export interface SupabaseConfig {
|
|
3
|
+
url: string;
|
|
4
|
+
publishableKey: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const URL_VARIABLE = "SUPABASE_URL";
|
|
7
|
+
export declare const PUBLISHABLE_VARIABLE = "SUPABASE_PUBLISHABLE_KEY";
|
|
8
|
+
/** The secret key's Worker secret, written by Control (cf_worker_secret_put source=supabase_secret). Never in this config. */
|
|
9
|
+
export declare const SECRET_VARIABLE = "SUPABASE_SECRET_KEY";
|
|
10
|
+
export declare const PUBLISHABLE_PREFIX = "sb_publishable_";
|
|
11
|
+
export declare const SECRET_PREFIX = "sb_secret_";
|
|
12
|
+
/** A problem with the URL, in words that never include it. Null when it's fine. */
|
|
13
|
+
export declare function urlProblem(url: string): string | null;
|
|
14
|
+
/**
|
|
15
|
+
* Reads SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY. Throws one
|
|
16
|
+
* WorkbenchConfigError naming every missing or invalid variable; never prints
|
|
17
|
+
* a value and never falls back to a default (audit F18). Legacy JWT keys
|
|
18
|
+
* (anon, service_role) are refused: Control only ever writes sb_publishable_
|
|
19
|
+
* and sb_secret_ keys.
|
|
20
|
+
*/
|
|
21
|
+
export declare function supabaseConfig(env: Record<string, unknown>): SupabaseConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { WorkbenchConfigError } from "@mccaskie-labs/workbench-config";
|
|
2
|
+
export const URL_VARIABLE = "SUPABASE_URL";
|
|
3
|
+
export const PUBLISHABLE_VARIABLE = "SUPABASE_PUBLISHABLE_KEY";
|
|
4
|
+
/** The secret key's Worker secret, written by Control (cf_worker_secret_put source=supabase_secret). Never in this config. */
|
|
5
|
+
export const SECRET_VARIABLE = "SUPABASE_SECRET_KEY";
|
|
6
|
+
export const PUBLISHABLE_PREFIX = "sb_publishable_";
|
|
7
|
+
export const SECRET_PREFIX = "sb_secret_";
|
|
8
|
+
const HOSTED = /^https:\/\/[a-z0-9]{20}\.supabase\.co$/;
|
|
9
|
+
const LOCAL = /^http:\/\/(127\.0\.0\.1|localhost):\d{2,5}$/;
|
|
10
|
+
/** A problem with the URL, in words that never include it. Null when it's fine. */
|
|
11
|
+
export function urlProblem(url) {
|
|
12
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
13
|
+
if (HOSTED.test(trimmed) || LOCAL.test(trimmed))
|
|
14
|
+
return null;
|
|
15
|
+
return "expected https://<project ref>.supabase.co, or http://127.0.0.1:<port> for the local stack";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Reads SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY. Throws one
|
|
19
|
+
* WorkbenchConfigError naming every missing or invalid variable; never prints
|
|
20
|
+
* a value and never falls back to a default (audit F18). Legacy JWT keys
|
|
21
|
+
* (anon, service_role) are refused: Control only ever writes sb_publishable_
|
|
22
|
+
* and sb_secret_ keys.
|
|
23
|
+
*/
|
|
24
|
+
export function supabaseConfig(env) {
|
|
25
|
+
const problems = [];
|
|
26
|
+
const url = env[URL_VARIABLE];
|
|
27
|
+
const key = env[PUBLISHABLE_VARIABLE];
|
|
28
|
+
if (typeof url !== "string" || url === "")
|
|
29
|
+
problems.push({ variable: URL_VARIABLE, problem: "missing" });
|
|
30
|
+
else {
|
|
31
|
+
const why = urlProblem(url);
|
|
32
|
+
if (why)
|
|
33
|
+
problems.push({ variable: URL_VARIABLE, problem: "invalid", detail: why });
|
|
34
|
+
}
|
|
35
|
+
if (typeof key !== "string" || key === "")
|
|
36
|
+
problems.push({ variable: PUBLISHABLE_VARIABLE, problem: "missing" });
|
|
37
|
+
else if (key.startsWith(SECRET_PREFIX)) {
|
|
38
|
+
problems.push({ variable: PUBLISHABLE_VARIABLE, problem: "invalid", detail: `holds a secret key; it must be a ${PUBLISHABLE_PREFIX} key` });
|
|
39
|
+
}
|
|
40
|
+
else if (!key.startsWith(PUBLISHABLE_PREFIX)) {
|
|
41
|
+
problems.push({ variable: PUBLISHABLE_VARIABLE, problem: "invalid", detail: `expected a ${PUBLISHABLE_PREFIX} key (legacy anon keys aren't used)` });
|
|
42
|
+
}
|
|
43
|
+
if (problems.length) {
|
|
44
|
+
const code = problems.every((p) => p.problem === "missing") ? "missing_variable" : "invalid_variable";
|
|
45
|
+
throw new WorkbenchConfigError(code, problems);
|
|
46
|
+
}
|
|
47
|
+
return { url: url.replace(/\/+$/, ""), publishableKey: key };
|
|
48
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type SupabaseErrorCode = "invalid_config" | "forbidden";
|
|
2
|
+
/** A client built wrongly. The message never holds a key, URL or token. */
|
|
3
|
+
export declare class WorkbenchSupabaseError extends Error {
|
|
4
|
+
readonly code: SupabaseErrorCode;
|
|
5
|
+
constructor(code: SupabaseErrorCode, message: string);
|
|
6
|
+
}
|
package/dist/errors.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { supabaseConfig, URL_VARIABLE, PUBLISHABLE_VARIABLE, SECRET_VARIABLE, PUBLISHABLE_PREFIX, SECRET_PREFIX, type SupabaseConfig, } from "./config.ts";
|
|
2
|
+
export { createBrowserClient, createServerClient, createUserClient, createServiceClient, resetServiceLog, type CookieAdapter } from "./clients.ts";
|
|
3
|
+
export { WorkbenchSupabaseError, type SupabaseErrorCode } from "./errors.ts";
|
|
4
|
+
export { migrationProblems, type MigrationProblem } from "./migrations.ts";
|
|
5
|
+
export { PRODUCTION_SCHEMA_SQL, createdBy, missingOnProduction, findUses, unusedAllows, parseProductionSchema, parseAllow, type ProductionSchema, type AllowEntry, type MissingName, type Use, } from "./prod-schema.ts";
|
|
6
|
+
export { stringPieces, type StringPiece } from "./strings.ts";
|
|
7
|
+
export type { SupabaseClient } from "@supabase/supabase-js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { supabaseConfig, URL_VARIABLE, PUBLISHABLE_VARIABLE, SECRET_VARIABLE, PUBLISHABLE_PREFIX, SECRET_PREFIX, } from "./config.js";
|
|
2
|
+
export { createBrowserClient, createServerClient, createUserClient, createServiceClient, resetServiceLog } from "./clients.js";
|
|
3
|
+
export { WorkbenchSupabaseError } from "./errors.js";
|
|
4
|
+
export { migrationProblems } from "./migrations.js";
|
|
5
|
+
export { PRODUCTION_SCHEMA_SQL, createdBy, missingOnProduction, findUses, unusedAllows, parseProductionSchema, parseAllow, } from "./prod-schema.js";
|
|
6
|
+
export { stringPieces } from "./strings.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const MIGRATION_NAME: RegExp;
|
|
2
|
+
export declare const DOWN_NAME: RegExp;
|
|
3
|
+
export interface MigrationProblem {
|
|
4
|
+
file: string;
|
|
5
|
+
problem: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `migrations` and `rollback` are the file names in supabase/migrations and
|
|
9
|
+
* supabase/rollback. Dotfiles (.gitkeep) are ignored.
|
|
10
|
+
*/
|
|
11
|
+
export declare function migrationProblems(migrations: string[], rollback: string[]): MigrationProblem[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// check:migrations (docs/spec.md §3): every supabase/migrations/<14 digits>_<name>.sql
|
|
2
|
+
// has supabase/rollback/<same>_down.sql, and names are valid. Control's
|
|
3
|
+
// sb_migration_apply refuses a file with no down file, so this catches it in CI.
|
|
4
|
+
export const MIGRATION_NAME = /^(\d{14})_([a-z0-9][a-z0-9_]*)\.sql$/;
|
|
5
|
+
export const DOWN_NAME = /^(\d{14})_([a-z0-9][a-z0-9_]*)_down\.sql$/;
|
|
6
|
+
/**
|
|
7
|
+
* `migrations` and `rollback` are the file names in supabase/migrations and
|
|
8
|
+
* supabase/rollback. Dotfiles (.gitkeep) are ignored.
|
|
9
|
+
*/
|
|
10
|
+
export function migrationProblems(migrations, rollback) {
|
|
11
|
+
const problems = [];
|
|
12
|
+
const ups = new Map();
|
|
13
|
+
const versions = new Map();
|
|
14
|
+
for (const f of migrations.filter((x) => !x.startsWith(".")).sort()) {
|
|
15
|
+
const m = MIGRATION_NAME.exec(f);
|
|
16
|
+
if (!m) {
|
|
17
|
+
problems.push({ file: `supabase/migrations/${f}`, problem: "the name isn't <14 digits>_<lower case name>.sql" });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const seen = versions.get(m[1]);
|
|
21
|
+
if (seen)
|
|
22
|
+
problems.push({ file: `supabase/migrations/${f}`, problem: `version ${m[1]} is also used by ${seen}` });
|
|
23
|
+
versions.set(m[1], f);
|
|
24
|
+
ups.set(f.replace(/\.sql$/, ""), f);
|
|
25
|
+
}
|
|
26
|
+
const downs = new Set();
|
|
27
|
+
for (const f of rollback.filter((x) => !x.startsWith(".")).sort()) {
|
|
28
|
+
if (!DOWN_NAME.test(f)) {
|
|
29
|
+
problems.push({ file: `supabase/rollback/${f}`, problem: "the name isn't <14 digits>_<name>_down.sql" });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const stem = f.replace(/_down\.sql$/, "");
|
|
33
|
+
downs.add(stem);
|
|
34
|
+
if (!ups.has(stem))
|
|
35
|
+
problems.push({ file: `supabase/rollback/${f}`, problem: `no migration ${stem}.sql for this down file` });
|
|
36
|
+
}
|
|
37
|
+
for (const [stem, f] of ups) {
|
|
38
|
+
if (!downs.has(stem))
|
|
39
|
+
problems.push({ file: `supabase/migrations/${f}`, problem: `no down file: add supabase/rollback/${stem}_down.sql` });
|
|
40
|
+
}
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export interface ProductionSchema {
|
|
2
|
+
format: 1;
|
|
3
|
+
/** Tables and views in `public`, each with its columns. */
|
|
4
|
+
tables: Record<string, string[]>;
|
|
5
|
+
/** Functions in `public`. */
|
|
6
|
+
functions: string[];
|
|
7
|
+
}
|
|
8
|
+
/** One allowed use: `file` may name `names` because `why` (the switch that keeps it off production). */
|
|
9
|
+
export interface AllowEntry {
|
|
10
|
+
file: string;
|
|
11
|
+
names: string[];
|
|
12
|
+
why: string;
|
|
13
|
+
}
|
|
14
|
+
export interface CreatedNames {
|
|
15
|
+
tables: string[];
|
|
16
|
+
functions: string[];
|
|
17
|
+
columns: Array<[table: string, column: string]>;
|
|
18
|
+
}
|
|
19
|
+
export interface MissingName {
|
|
20
|
+
kind: "table" | "function" | "column";
|
|
21
|
+
/** The name as code would write it. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** `table.column` for a column. */
|
|
24
|
+
label: string;
|
|
25
|
+
migration: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Use {
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
name: string;
|
|
31
|
+
allowed: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The query whose one row is the snapshot, read through pg_catalog so it works
|
|
35
|
+
* as a restricted read-only role. Run it on production (sb_query env=production)
|
|
36
|
+
* and save the `snapshot` value as supabase/production-schema.json.
|
|
37
|
+
*/
|
|
38
|
+
export declare const PRODUCTION_SCHEMA_SQL = "select json_build_object(\n 'format', 1,\n 'tables', coalesce((\n select json_object_agg(relname, cols order by relname) from (\n select c.relname, json_agg(a.attname::text order by a.attnum) as cols\n from pg_catalog.pg_class c\n join pg_catalog.pg_namespace n on n.oid = c.relnamespace\n join pg_catalog.pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped\n where n.nspname = 'public' and c.relkind in ('r', 'p', 'v', 'm', 'f')\n group by c.relname\n ) t\n ), '{}'::json),\n 'functions', coalesce((\n select json_agg(distinct p.proname::text order by p.proname::text)\n from pg_catalog.pg_proc p\n join pg_catalog.pg_namespace n on n.oid = p.pronamespace\n where n.nspname = 'public'\n ), '[]'::json)\n) as snapshot";
|
|
39
|
+
/** Checks the snapshot's shape. Throws with a plain reason. */
|
|
40
|
+
export declare function parseProductionSchema(json: unknown): ProductionSchema;
|
|
41
|
+
export declare function parseAllow(json: unknown): AllowEntry[];
|
|
42
|
+
/** What a migration creates in `public`: tables and views, functions, and columns added to existing tables. */
|
|
43
|
+
export declare function createdBy(sql: string): CreatedNames;
|
|
44
|
+
/** Every name the repo's migrations create that production's snapshot lacks. */
|
|
45
|
+
export declare function missingOnProduction(migrations: Array<{
|
|
46
|
+
stem: string;
|
|
47
|
+
sql: string;
|
|
48
|
+
}>, prod: ProductionSchema): Map<string, MissingName>;
|
|
49
|
+
/** Every use of a missing name inside a string literal, with whether the allow list covers it. */
|
|
50
|
+
export declare function findUses(files: Array<{
|
|
51
|
+
path: string;
|
|
52
|
+
text: string;
|
|
53
|
+
}>, missing: Map<string, MissingName>, allow: AllowEntry[]): Use[];
|
|
54
|
+
/** Allow entries that match no use: stale, so they fail too and the list stays true. */
|
|
55
|
+
export declare function unusedAllows(allow: AllowEntry[], uses: Use[]): Array<{
|
|
56
|
+
file: string;
|
|
57
|
+
name: string;
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// check:prod-schema (docs/spec.md §3): fails when code bound for production
|
|
2
|
+
// names a table, view, column or function that production's database doesn't
|
|
3
|
+
// have. Imprint's scripts/check-prod-schema.mjs, made generic: instead of a
|
|
4
|
+
// list of migrations applied on production, production's schema is a snapshot,
|
|
5
|
+
// supabase/production-schema.json, refreshed after each production apply
|
|
6
|
+
// (from sb_schema_fingerprint once it is built; until then from
|
|
7
|
+
// PRODUCTION_SCHEMA_SQL through sb_query, read-only).
|
|
8
|
+
//
|
|
9
|
+
// It is a tripwire, not a proof. It sees direct uses only: names inside string
|
|
10
|
+
// literals, as whole words. Names built at run time aren't seen, and a column
|
|
11
|
+
// with a common word for a name matches any string holding that word (allow it,
|
|
12
|
+
// or rename it).
|
|
13
|
+
import { stringPieces } from "./strings.js";
|
|
14
|
+
/**
|
|
15
|
+
* The query whose one row is the snapshot, read through pg_catalog so it works
|
|
16
|
+
* as a restricted read-only role. Run it on production (sb_query env=production)
|
|
17
|
+
* and save the `snapshot` value as supabase/production-schema.json.
|
|
18
|
+
*/
|
|
19
|
+
export const PRODUCTION_SCHEMA_SQL = `select json_build_object(
|
|
20
|
+
'format', 1,
|
|
21
|
+
'tables', coalesce((
|
|
22
|
+
select json_object_agg(relname, cols order by relname) from (
|
|
23
|
+
select c.relname, json_agg(a.attname::text order by a.attnum) as cols
|
|
24
|
+
from pg_catalog.pg_class c
|
|
25
|
+
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
|
|
26
|
+
join pg_catalog.pg_attribute a on a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped
|
|
27
|
+
where n.nspname = 'public' and c.relkind in ('r', 'p', 'v', 'm', 'f')
|
|
28
|
+
group by c.relname
|
|
29
|
+
) t
|
|
30
|
+
), '{}'::json),
|
|
31
|
+
'functions', coalesce((
|
|
32
|
+
select json_agg(distinct p.proname::text order by p.proname::text)
|
|
33
|
+
from pg_catalog.pg_proc p
|
|
34
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
35
|
+
where n.nspname = 'public'
|
|
36
|
+
), '[]'::json)
|
|
37
|
+
) as snapshot`;
|
|
38
|
+
/** Checks the snapshot's shape. Throws with a plain reason. */
|
|
39
|
+
export function parseProductionSchema(json) {
|
|
40
|
+
const s = json;
|
|
41
|
+
if (!s || typeof s !== "object" || s.format !== 1)
|
|
42
|
+
throw new Error("format must be 1");
|
|
43
|
+
if (!s.tables || typeof s.tables !== "object" || Array.isArray(s.tables))
|
|
44
|
+
throw new Error("tables must be an object of table name to column names");
|
|
45
|
+
for (const [t, cols] of Object.entries(s.tables)) {
|
|
46
|
+
if (!Array.isArray(cols) || !cols.every((c) => typeof c === "string"))
|
|
47
|
+
throw new Error(`tables.${t} must be a list of column names`);
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(s.functions) || !s.functions.every((f) => typeof f === "string"))
|
|
50
|
+
throw new Error("functions must be a list of names");
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
export function parseAllow(json) {
|
|
54
|
+
if (!Array.isArray(json))
|
|
55
|
+
throw new Error("the allow list must be an array of { file, names, why }");
|
|
56
|
+
return json.map((e, i) => {
|
|
57
|
+
const x = e;
|
|
58
|
+
if (typeof x?.file !== "string" || !Array.isArray(x.names) || !x.names.every((n) => typeof n === "string") || typeof x.why !== "string" || x.why.trim() === "") {
|
|
59
|
+
throw new Error(`entry ${i} needs file, names and why (the switch that keeps the use off production)`);
|
|
60
|
+
}
|
|
61
|
+
return { file: x.file, names: x.names, why: x.why };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const stripSql = (sql) => sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
|
|
65
|
+
const NAME = String.raw `(?:"?(\w+)"?\s*\.\s*)?"?(\w+)"?`;
|
|
66
|
+
const isPublic = (schema) => !schema || schema.toLowerCase() === "public";
|
|
67
|
+
/** What a migration creates in `public`: tables and views, functions, and columns added to existing tables. */
|
|
68
|
+
export function createdBy(sql) {
|
|
69
|
+
const s = stripSql(sql);
|
|
70
|
+
const out = { tables: [], functions: [], columns: [] };
|
|
71
|
+
for (const m of s.matchAll(new RegExp(String.raw `\bcreate\s+(?:(?:unlogged|temp|temporary)\s+)?table\s+(?:if\s+not\s+exists\s+)?${NAME}`, "gi"))) {
|
|
72
|
+
if (isPublic(m[1]))
|
|
73
|
+
out.tables.push(m[2].toLowerCase());
|
|
74
|
+
}
|
|
75
|
+
for (const m of s.matchAll(new RegExp(String.raw `\bcreate\s+(?:or\s+replace\s+)?(?:materialized\s+)?view\s+(?:if\s+not\s+exists\s+)?${NAME}`, "gi"))) {
|
|
76
|
+
if (isPublic(m[1]))
|
|
77
|
+
out.tables.push(m[2].toLowerCase());
|
|
78
|
+
}
|
|
79
|
+
for (const m of s.matchAll(new RegExp(String.raw `\bcreate\s+(?:or\s+replace\s+)?function\s+${NAME}`, "gi"))) {
|
|
80
|
+
if (isPublic(m[1]))
|
|
81
|
+
out.functions.push(m[2].toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
for (const m of s.matchAll(new RegExp(String.raw `\balter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?${NAME}([^;]*);`, "gi"))) {
|
|
84
|
+
if (!isPublic(m[1]))
|
|
85
|
+
continue;
|
|
86
|
+
for (const c of m[3].matchAll(/\badd\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?"?(\w+)"?/gi)) {
|
|
87
|
+
const col = c[1].toLowerCase();
|
|
88
|
+
if (["constraint", "primary", "unique", "foreign", "check", "exclude"].includes(col))
|
|
89
|
+
continue;
|
|
90
|
+
out.columns.push([m[2].toLowerCase(), col]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** Every name the repo's migrations create that production's snapshot lacks. */
|
|
96
|
+
export function missingOnProduction(migrations, prod) {
|
|
97
|
+
const tables = new Map(Object.entries(prod.tables).map(([t, cols]) => [t.toLowerCase(), new Set(cols.map((c) => c.toLowerCase()))]));
|
|
98
|
+
const functions = new Set(prod.functions.map((f) => f.toLowerCase()));
|
|
99
|
+
const out = new Map();
|
|
100
|
+
const add = (n) => {
|
|
101
|
+
if (!out.has(n.name))
|
|
102
|
+
out.set(n.name, n);
|
|
103
|
+
};
|
|
104
|
+
for (const { stem, sql } of migrations) {
|
|
105
|
+
const c = createdBy(sql);
|
|
106
|
+
for (const t of c.tables)
|
|
107
|
+
if (!tables.has(t))
|
|
108
|
+
add({ kind: "table", name: t, label: t, migration: stem });
|
|
109
|
+
for (const f of c.functions)
|
|
110
|
+
if (!functions.has(f))
|
|
111
|
+
add({ kind: "function", name: f, label: f, migration: stem });
|
|
112
|
+
for (const [t, col] of c.columns) {
|
|
113
|
+
// A column of a table production lacks is covered by the table itself.
|
|
114
|
+
const cols = tables.get(t);
|
|
115
|
+
if (cols && !cols.has(col))
|
|
116
|
+
add({ kind: "column", name: col, label: `${t}.${col}`, migration: stem });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
122
|
+
/** Every use of a missing name inside a string literal, with whether the allow list covers it. */
|
|
123
|
+
export function findUses(files, missing, allow) {
|
|
124
|
+
if (missing.size === 0)
|
|
125
|
+
return [];
|
|
126
|
+
const re = new RegExp(String.raw `(?<![A-Za-z0-9_])(${[...missing.keys()].sort((a, b) => b.length - a.length).map(escape).join("|")})(?![A-Za-z0-9_])`, "g");
|
|
127
|
+
const uses = [];
|
|
128
|
+
for (const { path, text } of files) {
|
|
129
|
+
re.lastIndex = 0;
|
|
130
|
+
const found = re.test(text);
|
|
131
|
+
// matchAll copies lastIndex, so reset it before reading the pieces.
|
|
132
|
+
re.lastIndex = 0;
|
|
133
|
+
if (!found)
|
|
134
|
+
continue;
|
|
135
|
+
const lineStarts = [0];
|
|
136
|
+
for (let i = 0; i < text.length; i++)
|
|
137
|
+
if (text[i] === "\n")
|
|
138
|
+
lineStarts.push(i + 1);
|
|
139
|
+
const lineOf = (pos) => {
|
|
140
|
+
let lo = 0;
|
|
141
|
+
let hi = lineStarts.length - 1;
|
|
142
|
+
while (lo < hi) {
|
|
143
|
+
const mid = (lo + hi + 1) >> 1;
|
|
144
|
+
if (lineStarts[mid] <= pos)
|
|
145
|
+
lo = mid;
|
|
146
|
+
else
|
|
147
|
+
hi = mid - 1;
|
|
148
|
+
}
|
|
149
|
+
return lo + 1;
|
|
150
|
+
};
|
|
151
|
+
for (const piece of stringPieces(text)) {
|
|
152
|
+
for (const m of piece.text.matchAll(re)) {
|
|
153
|
+
const name = m[1];
|
|
154
|
+
uses.push({ file: path, line: lineOf(piece.start + m.index), name, allowed: allow.some((a) => a.file === path && a.names.includes(name)) });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return uses;
|
|
159
|
+
}
|
|
160
|
+
/** Allow entries that match no use: stale, so they fail too and the list stays true. */
|
|
161
|
+
export function unusedAllows(allow, uses) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const a of allow)
|
|
164
|
+
for (const n of a.names)
|
|
165
|
+
if (!uses.some((u) => u.allowed && u.file === a.file && u.name === n))
|
|
166
|
+
out.push({ file: a.file, name: n });
|
|
167
|
+
return out;
|
|
168
|
+
}
|
package/dist/strings.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// A small JavaScript and TypeScript lexer that finds string literals and
|
|
2
|
+
// template text, and skips comments and regular expressions. supabase-js names
|
|
3
|
+
// tables, columns and functions in strings (.from('x'), .rpc('x'),
|
|
4
|
+
// .select('a, x')), so these are where check:prod-schema looks. Imprint used
|
|
5
|
+
// the TypeScript compiler API; TypeScript 7 has none, so this is hand-written.
|
|
6
|
+
// It is a tripwire, not a parser: a regular expression after a keyword other
|
|
7
|
+
// than those below (or in JSX text) may be read as division.
|
|
8
|
+
const REGEX_AFTER_WORD = new Set([
|
|
9
|
+
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "throw", "case", "do", "else", "yield", "await",
|
|
10
|
+
]);
|
|
11
|
+
/** Every string literal and template text in `src`, in order. */
|
|
12
|
+
export function stringPieces(src) {
|
|
13
|
+
const out = [];
|
|
14
|
+
// For each open template, the brace depth inside its current ${ }.
|
|
15
|
+
const templates = [];
|
|
16
|
+
let i = 0;
|
|
17
|
+
// Whether a `/` here starts a regular expression (true) or is division.
|
|
18
|
+
let regexOk = true;
|
|
19
|
+
const readTemplate = (from) => {
|
|
20
|
+
// From just after ` or }, up to and including the closing ` or ${.
|
|
21
|
+
let j = from;
|
|
22
|
+
while (j < src.length) {
|
|
23
|
+
const c = src[j];
|
|
24
|
+
if (c === "\\") {
|
|
25
|
+
j += 2;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (c === "`") {
|
|
29
|
+
out.push({ start: from, text: src.slice(from, j) });
|
|
30
|
+
return j + 1;
|
|
31
|
+
}
|
|
32
|
+
if (c === "$" && src[j + 1] === "{") {
|
|
33
|
+
out.push({ start: from, text: src.slice(from, j) });
|
|
34
|
+
templates.push(0);
|
|
35
|
+
return j + 2;
|
|
36
|
+
}
|
|
37
|
+
j++;
|
|
38
|
+
}
|
|
39
|
+
out.push({ start: from, text: src.slice(from) });
|
|
40
|
+
return src.length;
|
|
41
|
+
};
|
|
42
|
+
while (i < src.length) {
|
|
43
|
+
const c = src[i];
|
|
44
|
+
const next = src[i + 1];
|
|
45
|
+
if (c === "/" && next === "/") {
|
|
46
|
+
const end = src.indexOf("\n", i);
|
|
47
|
+
i = end < 0 ? src.length : end;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (c === "/" && next === "*") {
|
|
51
|
+
const end = src.indexOf("*/", i + 2);
|
|
52
|
+
i = end < 0 ? src.length : end + 2;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (c === "'" || c === '"') {
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
while (j < src.length && src[j] !== c && src[j] !== "\n")
|
|
58
|
+
j += src[j] === "\\" ? 2 : 1;
|
|
59
|
+
out.push({ start: i + 1, text: src.slice(i + 1, j) });
|
|
60
|
+
i = j + 1;
|
|
61
|
+
regexOk = false;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === "`") {
|
|
65
|
+
i = readTemplate(i + 1);
|
|
66
|
+
regexOk = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (c === "{" && templates.length) {
|
|
70
|
+
templates[templates.length - 1]++;
|
|
71
|
+
i++;
|
|
72
|
+
regexOk = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (c === "}" && templates.length) {
|
|
76
|
+
if (templates[templates.length - 1] === 0) {
|
|
77
|
+
templates.pop();
|
|
78
|
+
i = readTemplate(i + 1);
|
|
79
|
+
regexOk = false;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
templates[templates.length - 1]--;
|
|
83
|
+
i++;
|
|
84
|
+
regexOk = false;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (c === "/" && regexOk) {
|
|
88
|
+
let j = i + 1;
|
|
89
|
+
let inClass = false;
|
|
90
|
+
while (j < src.length && src[j] !== "\n") {
|
|
91
|
+
const d = src[j];
|
|
92
|
+
if (d === "\\") {
|
|
93
|
+
j += 2;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (d === "[")
|
|
97
|
+
inClass = true;
|
|
98
|
+
else if (d === "]")
|
|
99
|
+
inClass = false;
|
|
100
|
+
else if (d === "/" && !inClass)
|
|
101
|
+
break;
|
|
102
|
+
j++;
|
|
103
|
+
}
|
|
104
|
+
i = j + 1;
|
|
105
|
+
while (i < src.length && /[a-z]/i.test(src[i]))
|
|
106
|
+
i++;
|
|
107
|
+
regexOk = false;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
111
|
+
let j = i + 1;
|
|
112
|
+
while (j < src.length && /[\w$]/.test(src[j]))
|
|
113
|
+
j++;
|
|
114
|
+
regexOk = REGEX_AFTER_WORD.has(src.slice(i, j));
|
|
115
|
+
i = j;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (/[0-9]/.test(c)) {
|
|
119
|
+
let j = i + 1;
|
|
120
|
+
while (j < src.length && /[\w.]/.test(src[j]))
|
|
121
|
+
j++;
|
|
122
|
+
i = j;
|
|
123
|
+
regexOk = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (/\s/.test(c)) {
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// Punctuation: a regex may follow anything but a closing bracket.
|
|
131
|
+
regexOk = !(c === ")" || c === "]" || c === "}");
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-supabase",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Supabase clients for Workbench apps (browser, server, the caller's own token, and a service client that logs its reason), plus check:migrations and check:prod-schema.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mccaskie-labs/workbench.git",
|
|
10
|
+
"directory": "packages/supabase"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"workbench-supabase": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"CHANGELOG.md"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"typecheck": "tsc -p tsconfig.check.json && tsc -p test/tsconfig.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"build": "node scripts/build.mjs",
|
|
38
|
+
"prepack": "node scripts/build.mjs"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@mccaskie-labs/workbench-config": "^0.1.3",
|
|
42
|
+
"@supabase/ssr": "^0.12.7",
|
|
43
|
+
"@supabase/supabase-js": "^2.119.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "24.19.2",
|
|
47
|
+
"vitest": "^5.1.0",
|
|
48
|
+
"zod": "4.6.7"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"zod": "^4.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|