@specific.dev/spectest 0.38.0 → 0.41.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/dist/components/k3s.js +1 -24
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +405 -946
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +3 -3
- package/dist/ingress.d.ts +1 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2040 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5489 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2725 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +133 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +846 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/src/vendor/rrweb-record.min.js +5061 -0
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
import { createHmac } from "node:crypto";
|
|
52
52
|
import { existsSync } from "node:fs";
|
|
53
53
|
import { readdir, stat } from "node:fs/promises";
|
|
54
|
-
import { join } from "node:path";
|
|
54
|
+
import { dirname, join } from "node:path";
|
|
55
55
|
import { serviceGroup, } from "../index.js";
|
|
56
56
|
import { SQL } from "../sql.js";
|
|
57
57
|
import { email, emailHelpers } from "./email.js";
|
|
@@ -70,7 +70,17 @@ const IMAGES = {
|
|
|
70
70
|
imgproxy: "darthsim/imgproxy:v3.30.1",
|
|
71
71
|
meta: "supabase/postgres-meta:v0.96.6",
|
|
72
72
|
studio: "supabase/studio:2026.07.07-sha-a6a04f2",
|
|
73
|
+
functions: "supabase/edge-runtime:v1.74.0",
|
|
73
74
|
};
|
|
75
|
+
/**
|
|
76
|
+
* The project root inside the VM. A service's `volumes[].source` is a static
|
|
77
|
+
* string in the wire config, so the edge runtime's bind mount cannot go
|
|
78
|
+
* through `ctx.projectRoot` (which only exists once a hook runs) — this is
|
|
79
|
+
* the same value, read the same way the harness reads it.
|
|
80
|
+
*/
|
|
81
|
+
function projectRootPath() {
|
|
82
|
+
return process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
83
|
+
}
|
|
74
84
|
// Supabase's well-known demo secret (≥32 chars). Local-only; override via opts.
|
|
75
85
|
const DEFAULT_JWT_SECRET = "your-super-secret-jwt-token-with-at-least-32-characters-long";
|
|
76
86
|
// Fixed issued-at / expiry for the derived API keys. Constant (not `Date.now`)
|
|
@@ -147,6 +157,32 @@ function buildBootstrapSql(dbPassword, jwtSecret, withRealtime) {
|
|
|
147
157
|
// as `apikey` header, `Authorization: Bearer <key>`, or the websocket
|
|
148
158
|
// `?apikey=` query param) is swapped for the corresponding *derived JWT*
|
|
149
159
|
// before proxying, so the upstream services still see a validly-signed token.
|
|
160
|
+
//
|
|
161
|
+
// SPECTEST DEVIATION — the `apikey` **query parameter** is a first-class
|
|
162
|
+
// credential here, not just a websocket one. Kong's key-auth already accepts
|
|
163
|
+
// it (`key_in_query` defaults on), but upstream saw only the header, so a
|
|
164
|
+
// query-param request reached PostgREST as the anon role. A browser is the
|
|
165
|
+
// reason this matters: `browser.goto(url)` sends no headers, so a top-level
|
|
166
|
+
// navigation to a gated URL can carry a key ONLY in the query string (and a
|
|
167
|
+
// 401 there surfaces as Chromium's opaque `ERR_INVALID_AUTH_CREDENTIALS`,
|
|
168
|
+
// not a status code).
|
|
169
|
+
//
|
|
170
|
+
// The rest/graphql routes additionally `remove.querystring: [apikey]` in
|
|
171
|
+
// their request-transformer, because PostgREST reads every unknown query
|
|
172
|
+
// parameter as a column filter and answers `400 PGRST100` on the leftover
|
|
173
|
+
// key. `hide_credentials: true` on key-auth would strip it (and the header)
|
|
174
|
+
// far earlier — before this request-transformer runs — which would break
|
|
175
|
+
// both the header swap above and the realtime websocket route, whose own
|
|
176
|
+
// expression reads `query_params.apikey`. So the strip belongs here, on the
|
|
177
|
+
// one upstream that cannot tolerate the parameter.
|
|
178
|
+
//
|
|
179
|
+
// MEASURED CONSEQUENCE — request-transformer removes the query parameter
|
|
180
|
+
// BEFORE it renders the Authorization template, so on `/rest/v1` the JWT
|
|
181
|
+
// never reaches PostgREST and the request runs as **anon**, whichever key
|
|
182
|
+
// was sent. Verified against an RLS-protected table: the service-role key
|
|
183
|
+
// as a header returned the row, the same key in the query returned `[]`.
|
|
184
|
+
// Anon is the right privilege for the browser navigation this exists for;
|
|
185
|
+
// anything needing service-role must send the header.
|
|
150
186
|
function buildLuaExprs(extra) {
|
|
151
187
|
// Keep a client's `Authorization: Bearer <extraKey>` from winning the
|
|
152
188
|
// header-passthrough clause with the raw (non-JWT) key.
|
|
@@ -154,9 +190,9 @@ function buildLuaExprs(extra) {
|
|
|
154
190
|
.map((m) => ` and headers.authorization ~= 'Bearer ${m.key}'`)
|
|
155
191
|
.join("");
|
|
156
192
|
const mappings = extra
|
|
157
|
-
.map((m) => ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}') and 'Bearer ${m.jwt}')`)
|
|
193
|
+
.map((m) => ` or ((headers.apikey == '${m.key}' or headers.authorization == 'Bearer ${m.key}' or query_params.apikey == '${m.key}') and 'Bearer ${m.jwt}')`)
|
|
158
194
|
.join("");
|
|
159
|
-
const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey)`;
|
|
195
|
+
const auth = `$((headers.authorization ~= nil and headers.authorization:sub(1, 10) ~= 'Bearer sb_'${guards} and headers.authorization)${mappings} or headers.apikey or query_params.apikey)`;
|
|
160
196
|
const wsMappings = extra
|
|
161
197
|
.map((m) => `(query_params.apikey == '${m.key}' and '${m.jwt}') or `)
|
|
162
198
|
.join("");
|
|
@@ -287,6 +323,9 @@ services:
|
|
|
287
323
|
replace:
|
|
288
324
|
headers:
|
|
289
325
|
- "Authorization: $LUA_AUTH_EXPR"
|
|
326
|
+
remove:
|
|
327
|
+
querystring:
|
|
328
|
+
- apikey
|
|
290
329
|
- name: acl
|
|
291
330
|
config:
|
|
292
331
|
hide_groups_header: true
|
|
@@ -312,6 +351,9 @@ services:
|
|
|
312
351
|
replace:
|
|
313
352
|
headers:
|
|
314
353
|
- "Authorization: $LUA_AUTH_EXPR"
|
|
354
|
+
remove:
|
|
355
|
+
querystring:
|
|
356
|
+
- apikey
|
|
315
357
|
- name: acl
|
|
316
358
|
config:
|
|
317
359
|
hide_groups_header: true
|
|
@@ -339,6 +381,9 @@ services:
|
|
|
339
381
|
replace:
|
|
340
382
|
headers:
|
|
341
383
|
- "Authorization: $LUA_AUTH_EXPR"
|
|
384
|
+
remove:
|
|
385
|
+
querystring:
|
|
386
|
+
- apikey
|
|
342
387
|
- name: acl
|
|
343
388
|
config:
|
|
344
389
|
hide_groups_header: true
|
|
@@ -450,6 +495,7 @@ services:
|
|
|
450
495
|
if auth == nil or auth == "" or auth:find("^%s*$") then
|
|
451
496
|
kong.service.request.clear_header("authorization")
|
|
452
497
|
end
|
|
498
|
+
# >>> functions (dropped when the edge runtime is off)
|
|
453
499
|
- name: functions-v1
|
|
454
500
|
url: http://functions:9000/
|
|
455
501
|
read_timeout: 150000
|
|
@@ -460,6 +506,7 @@ services:
|
|
|
460
506
|
- /functions/v1/
|
|
461
507
|
plugins:
|
|
462
508
|
- name: cors
|
|
509
|
+
# <<< functions
|
|
463
510
|
- name: well-known-oauth
|
|
464
511
|
url: http://auth:9999/.well-known/oauth-authorization-server
|
|
465
512
|
routes:
|
|
@@ -498,11 +545,125 @@ services:
|
|
|
498
545
|
config:
|
|
499
546
|
hide_credentials: true
|
|
500
547
|
`;
|
|
548
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
549
|
+
// Edge functions — the runtime's "main service": the request router that
|
|
550
|
+
// spawns one user worker per function directory.
|
|
551
|
+
//
|
|
552
|
+
// Upstream ships an equivalent file in the repo the user is expected to copy
|
|
553
|
+
// (`docker/volumes/functions/main/index.ts`). We inject our own instead, for
|
|
554
|
+
// two reasons: the project's `supabase/functions/` holds the user's
|
|
555
|
+
// functions and should not need a `main/` of ours in it; and upstream's
|
|
556
|
+
// router imports `jsr:@panva/jose` at boot, which is a network fetch inside
|
|
557
|
+
// an otherwise hermetic VM. HS256 is the only algorithm this stack signs
|
|
558
|
+
// with, and Web Crypto verifies it in a few lines with no dependency.
|
|
559
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
560
|
+
const FUNCTIONS_MAIN_TS = String.raw `// spectest-generated edge-functions router. Do not edit.
|
|
561
|
+
const FUNCTIONS_DIR = "/home/deno/functions";
|
|
562
|
+
const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
|
|
563
|
+
const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
|
|
564
|
+
|
|
565
|
+
function json(body: unknown, status: number): Response {
|
|
566
|
+
return new Response(JSON.stringify(body), {
|
|
567
|
+
status,
|
|
568
|
+
headers: { "content-type": "application/json" },
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function base64UrlToBytes(s: string): Uint8Array {
|
|
573
|
+
const padded = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
574
|
+
const bin = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
|
575
|
+
const out = new Uint8Array(bin.length);
|
|
576
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// HS256 only — every token this stack mints (the anon/service-role API keys
|
|
581
|
+
// and every GoTrue access token) is signed with the shared JWT secret.
|
|
582
|
+
async function validJwt(token: string): Promise<boolean> {
|
|
583
|
+
const parts = token.split(".");
|
|
584
|
+
if (parts.length !== 3 || !JWT_SECRET) return false;
|
|
585
|
+
try {
|
|
586
|
+
const key = await crypto.subtle.importKey(
|
|
587
|
+
"raw",
|
|
588
|
+
new TextEncoder().encode(JWT_SECRET),
|
|
589
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
590
|
+
false,
|
|
591
|
+
["verify"],
|
|
592
|
+
);
|
|
593
|
+
const signed = new TextEncoder().encode(parts[0] + "." + parts[1]);
|
|
594
|
+
if (!(await crypto.subtle.verify("HMAC", key, base64UrlToBytes(parts[2]), signed))) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
const claims = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[1])));
|
|
598
|
+
if (typeof claims.exp === "number" && claims.exp * 1000 < Date.now()) return false;
|
|
599
|
+
return true;
|
|
600
|
+
} catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
Deno.serve(async (req: Request) => {
|
|
606
|
+
const { pathname } = new URL(req.url);
|
|
607
|
+
|
|
608
|
+
// Ready probe — answered before any auth check, so bring-up never needs a
|
|
609
|
+
// token, and never treated as a function name.
|
|
610
|
+
if (pathname === "/_spectest/health") return new Response("ok");
|
|
611
|
+
|
|
612
|
+
const name = pathname.split("/")[1] ?? "";
|
|
613
|
+
if (name === "") return json({ msg: "missing function name in request" }, 400);
|
|
614
|
+
|
|
615
|
+
if (VERIFY_JWT && req.method !== "OPTIONS") {
|
|
616
|
+
const header = req.headers.get("authorization") ?? "";
|
|
617
|
+
const [scheme, token] = header.split(" ");
|
|
618
|
+
if (scheme !== "Bearer" || !token || !(await validJwt(token))) {
|
|
619
|
+
return json({ msg: "Invalid JWT" }, 401);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const servicePath = FUNCTIONS_DIR + "/" + name;
|
|
624
|
+
try {
|
|
625
|
+
const worker = await EdgeRuntime.userWorkers.create({
|
|
626
|
+
servicePath,
|
|
627
|
+
memoryLimitMb: 256,
|
|
628
|
+
workerTimeoutMs: 5 * 60 * 1000,
|
|
629
|
+
noModuleCache: false,
|
|
630
|
+
importMapPath: null,
|
|
631
|
+
envVars: Object.entries(Deno.env.toObject()),
|
|
632
|
+
});
|
|
633
|
+
return await worker.fetch(req);
|
|
634
|
+
} catch (e) {
|
|
635
|
+
// A missing directory lands here too, so say which function was asked
|
|
636
|
+
// for — the runtime's own message names only the path.
|
|
637
|
+
return json({ msg: String(e), function: name, servicePath }, 500);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
`;
|
|
641
|
+
/** Keep or drop a `# >>> <tag>` … `# <<< <tag>` block, and always remove the
|
|
642
|
+
* marker lines themselves. */
|
|
643
|
+
function stripMarkedBlock(yaml, tag, keep) {
|
|
644
|
+
const out = [];
|
|
645
|
+
let inside = false;
|
|
646
|
+
for (const line of yaml.split("\n")) {
|
|
647
|
+
if (line.trim().startsWith(`# >>> ${tag}`)) {
|
|
648
|
+
inside = true;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (line.trim().startsWith(`# <<< ${tag}`)) {
|
|
652
|
+
inside = false;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (inside && !keep)
|
|
656
|
+
continue;
|
|
657
|
+
out.push(line);
|
|
658
|
+
}
|
|
659
|
+
return out.join("\n");
|
|
660
|
+
}
|
|
501
661
|
/** Produce the final Kong declarative config: rewrite upstream bare hostnames
|
|
502
662
|
* to the group's final service DNS names, substitute the API keys / dashboard
|
|
503
663
|
* credentials / Lua expressions, and drop the empty opaque-key lines. */
|
|
504
664
|
function buildKongYaml(key, vals) {
|
|
505
665
|
let y = KONG_YML_RAW;
|
|
666
|
+
y = stripMarkedBlock(y, "functions", vals.functions);
|
|
506
667
|
// Extra API keys land where kong-entrypoint.sh's opaque keys would: as
|
|
507
668
|
// additional keyauth credentials on the matching consumer. The template's
|
|
508
669
|
// placeholder lines are either expanded here or dropped by the filter below.
|
|
@@ -542,73 +703,126 @@ function buildKongYaml(key, vals) {
|
|
|
542
703
|
.join("\n");
|
|
543
704
|
return y;
|
|
544
705
|
}
|
|
545
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
546
|
-
// Migration application (runs in the db service's `setup`, after the DB is
|
|
547
|
-
// ready and before any dependent service starts). Uses the setup context's
|
|
548
|
-
// `exec` (docker exec with piped stdin) and `projectRoot` — no hand-rolled
|
|
549
|
-
// child_process, no hard-coded workspace path.
|
|
550
|
-
// ──────────────────────────────────────────────────────────────────────────
|
|
551
706
|
/** Pipe one SQL file's contents into `psql` inside the db container. Uses
|
|
552
707
|
* `psql -f -` (stdin) so multi-statement files and non-transactional DDL
|
|
553
708
|
* (e.g. `CREATE INDEX CONCURRENTLY`) behave exactly as `supabase db reset`
|
|
554
709
|
* applies them — not wrapped in a single transaction. `ON_ERROR_STOP=1` fails
|
|
555
710
|
* fast on the first bad statement. */
|
|
556
|
-
async function applySqlFile(ctx, sql, label, user
|
|
557
|
-
|
|
711
|
+
async function applySqlFile(ctx, sql, label, user,
|
|
712
|
+
/** The db service to run `psql` in. Defaults to the hook's own service,
|
|
713
|
+
* which is the db only for the bootstrap; the project's migrations run
|
|
714
|
+
* from the group hook, where `ctx.name` is the gateway. */
|
|
715
|
+
service = ctx.name) {
|
|
716
|
+
const res = await ctx.exec(service, ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", "postgres", "-f", "-"], { stdin: sql, timeoutMs: 300_000 });
|
|
558
717
|
if (res.exitCode !== 0) {
|
|
559
718
|
throw new Error(`supabase: applying ${label} failed (psql rc=${res.exitCode}):\n${res.stderr.trim() || res.stdout.trim()}`);
|
|
560
719
|
}
|
|
561
720
|
}
|
|
562
|
-
/** Resolve, sort, and apply the project's Supabase migrations
|
|
563
|
-
* the
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
721
|
+
/** Resolve, sort, and apply the project's Supabase migrations — and the
|
|
722
|
+
* `seed.sql` the standard layout puts beside them — against the running db
|
|
723
|
+
* container as the `postgres` role (matching `supabase db reset`: tables end
|
|
724
|
+
* up owned by postgres).
|
|
725
|
+
*
|
|
726
|
+
* Only ever called with a path the user gave: there is no assumed location,
|
|
727
|
+
* so this never applies SQL nobody asked for. */
|
|
728
|
+
async function applyMigrations(ctx, migrations,
|
|
729
|
+
/** Where `psql` runs — the db part, named from the group hook. */
|
|
730
|
+
db) {
|
|
731
|
+
const dir = migrations.startsWith("/")
|
|
732
|
+
? migrations
|
|
733
|
+
: join(ctx.projectRoot, migrations);
|
|
734
|
+
// A path that doesn't resolve is a hard error: a silent no-op here
|
|
735
|
+
// surfaces much later as a baffling `relation "public.…" does not exist`.
|
|
736
|
+
if (!existsSync(dir) || !(await stat(dir)).isDirectory()) {
|
|
737
|
+
throw new Error(`supabase: migrations path ${JSON.stringify(migrations)} does not ` +
|
|
738
|
+
`resolve to a directory (looked at ${dir}). The path is resolved ` +
|
|
739
|
+
`inside the uploaded project — check it isn't ignored (e.g. via ` +
|
|
740
|
+
`.spectestignore).`);
|
|
741
|
+
}
|
|
742
|
+
const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
|
|
743
|
+
for (const f of files) {
|
|
744
|
+
const sql = await ctx.readProjectFile(join(dir, f));
|
|
745
|
+
await applySqlFile(ctx, sql, `migration ${f}`, "postgres", db);
|
|
746
|
+
}
|
|
747
|
+
// `seed.sql` sits next to the migrations directory in the layout the
|
|
748
|
+
// Supabase CLI creates, so it needs no option of its own — pointing at
|
|
749
|
+
// the migrations is enough to say where the project's SQL lives. Absent
|
|
750
|
+
// is fine: plenty of projects have migrations and no seed.
|
|
751
|
+
const seedPath = join(dirname(dir), "seed.sql");
|
|
752
|
+
if (existsSync(seedPath)) {
|
|
753
|
+
const sql = await ctx.readProjectFile(seedPath);
|
|
754
|
+
await applySqlFile(ctx, sql, "seed.sql", "postgres", db);
|
|
593
755
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Make PostgREST serve the schema the migrations just created, and do not
|
|
759
|
+
* return until it does so **stably**.
|
|
760
|
+
*
|
|
761
|
+
* PostgREST reads the schema once, at boot, and now boots before the
|
|
762
|
+
* project's migrations run. Without this, the first REST call for a new
|
|
763
|
+
* table answers `PGRST205 Could not find the table` — the failure the user
|
|
764
|
+
* would otherwise have to diagnose and work around themselves.
|
|
765
|
+
*
|
|
766
|
+
* Two things make this more than one request:
|
|
767
|
+
*
|
|
768
|
+
* - **A rebuild is not atomic from the outside.** While PostgREST reloads,
|
|
769
|
+
* a request can still miss the table. So a single successful check is not
|
|
770
|
+
* proof; the cache has to answer correctly several times in a row.
|
|
771
|
+
* - **Reloads are already in flight.** The `supabase/postgres` image ships
|
|
772
|
+
* `pgrst_ddl_watch`, an event trigger that notifies on every DDL, so a
|
|
773
|
+
* migration run queues several reloads of its own. Ours goes *first* and
|
|
774
|
+
* the settle loop then waits them all out — checking before sending the
|
|
775
|
+
* notify was a real bug: the poll passed, our own notify landed after it,
|
|
776
|
+
* and a test raced the rebuild it caused.
|
|
777
|
+
*
|
|
778
|
+
* The expectation comes from the database itself: every base table in the
|
|
779
|
+
* exposed `public` schema should be listed. A stack with no public tables
|
|
780
|
+
* has nothing to wait for.
|
|
781
|
+
*/
|
|
782
|
+
async function reloadPostgrestSchema(ctx, db, rest) {
|
|
783
|
+
// Ours first, so every reload — ours and the DDL trigger's — is already
|
|
784
|
+
// queued before we start watching for the result.
|
|
785
|
+
await applySqlFile(ctx, "notify pgrst, 'reload schema';\n", "schema reload", "postgres", db);
|
|
786
|
+
const listed = await ctx.exec(db, [
|
|
787
|
+
"psql", "-U", "postgres", "-d", "postgres", "-t", "-A", "-c",
|
|
788
|
+
"select table_name from information_schema.tables " +
|
|
789
|
+
"where table_schema = 'public' and table_type = 'BASE TABLE' order by 1",
|
|
790
|
+
], { timeoutMs: 60_000 });
|
|
791
|
+
const tables = String(listed.stdout)
|
|
792
|
+
.split("\n")
|
|
793
|
+
.map((t) => t.trim())
|
|
794
|
+
.filter(Boolean);
|
|
795
|
+
if (tables.length === 0)
|
|
796
|
+
return;
|
|
797
|
+
/** One check: are all the tables served right now? */
|
|
798
|
+
const served = async () => {
|
|
799
|
+
const res = await fetch(`http://${rest}:3000/`);
|
|
800
|
+
if (!res.ok)
|
|
801
|
+
return tables;
|
|
802
|
+
const spec = (await res.json());
|
|
803
|
+
const paths = Object.keys(spec.paths ?? {});
|
|
804
|
+
return tables.filter((t) => !paths.includes(`/${t}`));
|
|
805
|
+
};
|
|
806
|
+
const SETTLE = 3;
|
|
807
|
+
const deadline = Date.now() + 30_000;
|
|
808
|
+
let streak = 0;
|
|
809
|
+
let missing = tables;
|
|
810
|
+
while (Date.now() < deadline) {
|
|
811
|
+
try {
|
|
812
|
+
missing = await served();
|
|
813
|
+
streak = missing.length === 0 ? streak + 1 : 0;
|
|
814
|
+
if (streak >= SETTLE)
|
|
815
|
+
return;
|
|
605
816
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
`use the default supabase/seed.sql.`);
|
|
817
|
+
catch {
|
|
818
|
+
// Mid-reload PostgREST can refuse the connection outright.
|
|
819
|
+
streak = 0;
|
|
610
820
|
}
|
|
821
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
611
822
|
}
|
|
823
|
+
throw new Error(`supabase: PostgREST did not settle on the migrated schema within 30s ` +
|
|
824
|
+
`(still missing: ${missing.join(", ") || "nothing, but not stably"}). ` +
|
|
825
|
+
`The migrations applied — this is the REST schema cache, not your SQL.`);
|
|
612
826
|
}
|
|
613
827
|
/**
|
|
614
828
|
* A ready-to-use self-hosted Supabase stack. Mount `.group` at the key
|
|
@@ -642,6 +856,26 @@ export function supabase(opts = {}) {
|
|
|
642
856
|
// `mailKey` is the SMTP host GoTrue mails through — the in-group part
|
|
643
857
|
// (`<name>-mail`) or a user-declared `email()` service shared with the app
|
|
644
858
|
// under test.
|
|
859
|
+
// Edge functions — like `migrations`, a path the caller gives or nothing
|
|
860
|
+
// at all. A directory that doesn't resolve is an error rather than an
|
|
861
|
+
// empty runtime whose every call 500s much later.
|
|
862
|
+
const fnOpts = opts.functions === undefined
|
|
863
|
+
? null
|
|
864
|
+
: typeof opts.functions === "string"
|
|
865
|
+
? { dir: opts.functions }
|
|
866
|
+
: opts.functions;
|
|
867
|
+
const withFunctions = fnOpts !== null;
|
|
868
|
+
const fnDir = fnOpts === null
|
|
869
|
+
? ""
|
|
870
|
+
: fnOpts.dir.startsWith("/")
|
|
871
|
+
? fnOpts.dir
|
|
872
|
+
: join(projectRootPath(), fnOpts.dir);
|
|
873
|
+
if (withFunctions && !existsSync(fnDir)) {
|
|
874
|
+
throw new Error(`supabase(): functions path ${JSON.stringify(fnOpts.dir)} does not ` +
|
|
875
|
+
`resolve to a directory (looked at ${fnDir}). It should hold one ` +
|
|
876
|
+
`subdirectory per function. The path is resolved inside the uploaded ` +
|
|
877
|
+
`project — check it isn't ignored (e.g. via .spectestignore).`);
|
|
878
|
+
}
|
|
645
879
|
const withMail = opts.mail !== false;
|
|
646
880
|
const mailOpts = typeof opts.mail === "object" ? opts.mail : {};
|
|
647
881
|
const mailExternal = mailOpts.service !== undefined;
|
|
@@ -710,18 +944,16 @@ export function supabase(opts = {}) {
|
|
|
710
944
|
// baked init-scripts and starts serving for real.
|
|
711
945
|
readyCheck: { type: "exec", command: "pg_isready -U postgres -h 127.0.0.1 -p 5432", timeoutSecs: 180 },
|
|
712
946
|
setup: async (ctx) => {
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
947
|
+
// Only the database's own bootstrap (role passwords / JWT GUC /
|
|
948
|
+
// `_realtime` schema) runs here: the other services need it before
|
|
949
|
+
// they can start. The project's migrations run later, from the
|
|
950
|
+
// group hook, once the whole stack is up — see the migration
|
|
951
|
+
// section above for why.
|
|
716
952
|
await applySqlFile(ctx, buildBootstrapSql(dbPassword, jwtSecret, withRealtime), "supabase bootstrap",
|
|
717
953
|
// The bootstrap alters reserved roles (authenticator, …) and the
|
|
718
954
|
// database — superuser-only. `postgres` is deliberately NOT a
|
|
719
955
|
// superuser in modern supabase/postgres; `supabase_admin` is.
|
|
720
956
|
"supabase_admin");
|
|
721
|
-
await applyMigrations(ctx, {
|
|
722
|
-
migrations: opts.migrations ?? true,
|
|
723
|
-
seed: opts.seed ?? true,
|
|
724
|
-
});
|
|
725
957
|
},
|
|
726
958
|
};
|
|
727
959
|
parts.db = dbDef;
|
|
@@ -772,11 +1004,16 @@ export function supabase(opts = {}) {
|
|
|
772
1004
|
GOTRUE_JWT_ISSUER: apiExternalUrl,
|
|
773
1005
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true",
|
|
774
1006
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "true",
|
|
775
|
-
// Autoconfirm
|
|
776
|
-
// mail
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
|
|
1007
|
+
// Autoconfirm is ON by default: a signup completes in one step
|
|
1008
|
+
// and sends no confirmation mail, so the always-on mailbox is
|
|
1009
|
+
// purely additive and a suite that only needs accounts to exist
|
|
1010
|
+
// writes nothing. `autoconfirm: false` opts into the real
|
|
1011
|
+
// confirmation flow, which the mailbox is there to drive.
|
|
1012
|
+
//
|
|
1013
|
+
// Always ON when there is no mailbox (`mail: false`), whatever
|
|
1014
|
+
// was asked for: the confirmation would be sent nowhere and no
|
|
1015
|
+
// account could ever be used.
|
|
1016
|
+
GOTRUE_MAILER_AUTOCONFIRM: !withMail || mailAutoconfirm ? "true" : "false",
|
|
780
1017
|
GOTRUE_EXTERNAL_PHONE_ENABLED: "false",
|
|
781
1018
|
GOTRUE_SMS_AUTOCONFIRM: "true",
|
|
782
1019
|
...(withMail
|
|
@@ -892,6 +1129,41 @@ export function supabase(opts = {}) {
|
|
|
892
1129
|
},
|
|
893
1130
|
};
|
|
894
1131
|
}
|
|
1132
|
+
// ── functions (edge runtime) ────────────────────────────────────
|
|
1133
|
+
if (withFunctions) {
|
|
1134
|
+
parts.functions = {
|
|
1135
|
+
image: { type: "registry", reference: IMAGES.functions },
|
|
1136
|
+
// The repo's own function directory, mounted where the runtime
|
|
1137
|
+
// looks. Read-only: the runtime only ever reads the sources, and
|
|
1138
|
+
// a function that writes into its own directory would be writing
|
|
1139
|
+
// into the user's checkout.
|
|
1140
|
+
volumes: [{ source: fnDir, target: "/home/deno/functions", readOnly: true }],
|
|
1141
|
+
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
1142
|
+
// the project never has to carry a `main/` function of ours.
|
|
1143
|
+
files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
|
|
1144
|
+
args: ["start", "--main-service", "/home/deno/main", "--port", "9000"],
|
|
1145
|
+
env: {
|
|
1146
|
+
JWT_SECRET: jwtSecret,
|
|
1147
|
+
VERIFY_JWT: (fnOpts.verifyJwt ?? true) ? "true" : "false",
|
|
1148
|
+
// A function calls back into its own project through the
|
|
1149
|
+
// gateway, exactly as it does when hosted.
|
|
1150
|
+
SUPABASE_URL: `http://${g.name}:8000`,
|
|
1151
|
+
SUPABASE_PUBLIC_URL: url,
|
|
1152
|
+
SUPABASE_ANON_KEY: anonKey,
|
|
1153
|
+
SUPABASE_SERVICE_ROLE_KEY: serviceRoleKey,
|
|
1154
|
+
SUPABASE_DB_URL: `postgresql://postgres:${dbPassword}@${db}:5432/postgres`,
|
|
1155
|
+
...(fnOpts.env ?? {}),
|
|
1156
|
+
},
|
|
1157
|
+
ports: [9000],
|
|
1158
|
+
dependsOn: ["db"],
|
|
1159
|
+
readyCheck: {
|
|
1160
|
+
type: "http",
|
|
1161
|
+
port: 9000,
|
|
1162
|
+
path: "/_spectest/health",
|
|
1163
|
+
timeoutSecs: 120,
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
895
1167
|
// ── meta (postgres-meta) ────────────────────────────────────────
|
|
896
1168
|
if (withMeta) {
|
|
897
1169
|
parts.meta = {
|
|
@@ -966,6 +1238,7 @@ export function supabase(opts = {}) {
|
|
|
966
1238
|
dashboardPassword,
|
|
967
1239
|
extraAnonKeys,
|
|
968
1240
|
extraServiceRoleKeys,
|
|
1241
|
+
functions: withFunctions,
|
|
969
1242
|
}),
|
|
970
1243
|
},
|
|
971
1244
|
],
|
|
@@ -975,6 +1248,16 @@ export function supabase(opts = {}) {
|
|
|
975
1248
|
};
|
|
976
1249
|
return parts;
|
|
977
1250
|
},
|
|
1251
|
+
// The project's own SQL, applied with the whole stack up (the group
|
|
1252
|
+
// hook chains after the primary, and the primary is the group's
|
|
1253
|
+
// dependency sink). Still inside bring-up, so the result is captured
|
|
1254
|
+
// into the cached environment like everything else.
|
|
1255
|
+
setup: async (ctx) => {
|
|
1256
|
+
if (opts.migrations === undefined)
|
|
1257
|
+
return;
|
|
1258
|
+
await applyMigrations(ctx, opts.migrations, dbKey);
|
|
1259
|
+
await reloadPostgrestSchema(ctx, dbKey, `${name}-rest`);
|
|
1260
|
+
},
|
|
978
1261
|
// The group's consolidated handle at `ctx.svc.<name>`.
|
|
979
1262
|
helpers: () => ({
|
|
980
1263
|
sql: new SQL(adminDbUrl, { label: name }),
|
package/dist/daemon.d.ts
CHANGED
|
@@ -1 +1,39 @@
|
|
|
1
|
+
import { type MethodTable } from "./harness/methods.js";
|
|
2
|
+
/**
|
|
3
|
+
* Mutual exclusion between the operations that cannot overlap.
|
|
4
|
+
*
|
|
5
|
+
* One harness process holds one environment, so a second test running
|
|
6
|
+
* beside the first would share its containers, its fake state and its
|
|
7
|
+
* recorder. These flags are what make "already running" a refusal rather
|
|
8
|
+
* than a corruption.
|
|
9
|
+
*/
|
|
10
|
+
interface RouteState {
|
|
11
|
+
inFlightTest: Promise<unknown> | null;
|
|
12
|
+
inFlightBootstrap: Promise<unknown> | null;
|
|
13
|
+
inFlightProjectSetup: Promise<unknown> | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Everything the harness can be asked to do.
|
|
17
|
+
*
|
|
18
|
+
* Transport-neutral by construction: params in, plain result out, failures
|
|
19
|
+
* as {@link HarnessError}. The HTTP router below and the supervisor's frame
|
|
20
|
+
* dispatcher both drive this same table, so a change reaches both — which
|
|
21
|
+
* is the property that makes it safe to run the two side by side during the
|
|
22
|
+
* cutover.
|
|
23
|
+
*/
|
|
24
|
+
export declare function harnessMethods(state: RouteState): MethodTable;
|
|
25
|
+
/**
|
|
26
|
+
* The method table for this process.
|
|
27
|
+
*
|
|
28
|
+
* Built once, so the mutual-exclusion slots are process-wide: `run` refuses
|
|
29
|
+
* a second case while one is in flight, and that guarantee has to hold for
|
|
30
|
+
* the process, not per caller. Two tables would each enforce the rule and
|
|
31
|
+
* together break it — which is why this is a value and not a factory the
|
|
32
|
+
* caller invokes.
|
|
33
|
+
*
|
|
34
|
+
* The harness (`sdk/src/harness/main.ts`) serves this over frames. It used
|
|
35
|
+
* to be served over HTTP as well, by a listener in this file; that was the
|
|
36
|
+
* in-VM daemon, and it is gone.
|
|
37
|
+
*/
|
|
38
|
+
export declare const METHODS: MethodTable;
|
|
1
39
|
export {};
|