Skip to content

Commit 0c577de

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/security-3-new-alerts
2 parents 7cb4e41 + 839dce6 commit 0c577de

223 files changed

Lines changed: 19282 additions & 246 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4+
fixture_root="$(mktemp -d)"
5+
trap 'rm -rf "$fixture_root"' EXIT
6+
mkdir -p "$fixture_root/bin" "$fixture_root/migrations"
7+
export FAKE_QUERY_LOG="$fixture_root/queries.log"
8+
export MIGRATIONS_DIR="$fixture_root/migrations"
9+
export SUPABASE_ACCESS_TOKEN=test SUPABASE_PROJECT_REF=test
10+
export PATH="$fixture_root/bin:$PATH"
11+
cat >"$fixture_root/bin/curl" <<'CURL'
12+
#!/usr/bin/env bash
13+
payload="$(cat)"
14+
printf '%s\n' "$payload" >>"$FAKE_QUERY_LOG"
15+
if jq -e '.query | startswith("SELECT version, name")' >/dev/null <<<"$payload"; then
16+
printf '%s\n' "$FAKE_INITIAL_RESPONSE"
17+
else
18+
printf '%s\n' '[]'
19+
fi
20+
CURL
21+
chmod +x "$fixture_root/bin/curl"
22+
converge=20260805091000_converge_gigl_tracking_worker_nologin
23+
restore=20260805113000_restore_gigl_tracking_postgrest_capability
24+
isolate=20260805170000_isolate_gigl_tracking_postgrest_capability
25+
for migration in "$converge" "$restore" "$isolate"; do
26+
cp "$script_dir/../../supabase/migrations/$migration.sql" "$MIGRATIONS_DIR/"
27+
done
28+
export FAKE_INITIAL_RESPONSE='[{"version":"20260805091000","name":"enable_least_privilege_gigl_tracking_login"}]'
29+
export MIGRATION_MAX_VERSION=20260805113000
30+
bash "$script_dir/apply-pending-migrations.sh" >"$fixture_root/output"
31+
grep -q "reconciled by repair migration $restore.sql" "$fixture_root/output"
32+
grep -q 'ALTER ROLE gigl_tracking_worker NOLOGIN' "$FAKE_QUERY_LOG"
33+
grep -q 'enforce_gigl_tracking_worker_request_scope' "$FAKE_QUERY_LOG"
34+
if grep -q 'GRANT gigl_tracking_worker TO authenticator' "$FAKE_QUERY_LOG" || \
35+
grep -q 'INSERT INTO supabase_migrations.schema_migrations.*20260805091000' "$FAKE_QUERY_LOG"; then
36+
echo 'Must preserve historical record and defer membership until the hook probe' >&2
37+
exit 1
38+
fi
39+
40+
expect_refusal() {
41+
: >"$FAKE_QUERY_LOG"
42+
if bash "$script_dir/apply-pending-migrations.sh" >"$fixture_root/refusal" 2>&1; then
43+
echo 'Expected invalid history or missing repair to fail closed' >&2
44+
exit 1
45+
fi
46+
[ "$(jq -s length "$FAKE_QUERY_LOG")" = 1 ]
47+
}
48+
export FAKE_INITIAL_RESPONSE='[{"version":"20260805091000","name":"unexpected"}]'
49+
expect_refusal
50+
export FAKE_INITIAL_RESPONSE='[{"version":"20260805091000","name":"enable_least_privilege_gigl_tracking_login"},{"version":"20260805113000","name":"unexpected"}]'
51+
expect_refusal
52+
export FAKE_INITIAL_RESPONSE='[{"version":"20260805091000","name":"enable_least_privilege_gigl_tracking_login"}]'
53+
mv "$MIGRATIONS_DIR/$restore.sql" "$fixture_root/restore.sql"
54+
expect_refusal
55+
56+
# Once the actual repair is recorded, a retry must not replay either old role
57+
# transition or require the historical repair file to remain in a sparse tree.
58+
export FAKE_INITIAL_RESPONSE='[{"version":"20260805091000","name":"enable_least_privilege_gigl_tracking_login"},{"version":"20260805113000","name":"restore_gigl_tracking_postgrest_capability"}]'
59+
: >"$FAKE_QUERY_LOG"
60+
bash "$script_dir/apply-pending-migrations.sh" >"$fixture_root/retry"
61+
[ "$(jq -s length "$FAKE_QUERY_LOG")" = 1 ]
62+
printf '%s\n' 'GIGL migration history reconciliation tests passed'

‎.github/scripts/historical-migration-repair-spec.sh‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ historical_collision_repair_spec() {
6868
20260805090000:complete_merchant_invoice_partial_payments)
6969
printf '%s\t%s\n' '20260805090002' 'reapply_complete_merchant_invoice_partial_payment'
7070
;;
71+
# Production recorded the interim LOGIN revision under this version.
72+
# These are different role contracts, not spelling aliases: require the
73+
# later NOLOGIN + scope-hook repair before treating history as reconciled.
74+
# The applier preserves the old row; deploy still probes the loaded hook
75+
# before the separate isolate migration grants authenticator membership.
76+
20260805091000:enable_least_privilege_gigl_tracking_login | \
77+
20260805091000:converge_gigl_tracking_worker_nologin)
78+
printf '%s\t%s\n' '20260805113000' 'restore_gigl_tracking_postgrest_capability'
79+
;;
7180
20260811120000:quiz_leaderboard_and_claim_projections_v2 | \
7281
20260811120000:allow_reviewed_paystack_email_mismatch)
7382
printf '%s\t%s\n' '20260813144355' 'reapply_allow_reviewed_paystack_email_mismatch'
@@ -78,7 +87,7 @@ historical_collision_repair_spec() {
7887

7988
historical_collision_version_is_known() {
8089
case "$1" in
81-
20260615120000 | 20260713130000 | 20260805090000 | 20260811120000) return 0 ;;
90+
20260615120000 | 20260713130000 | 20260805090000 | 20260805091000 | 20260811120000) return 0 ;;
8291
*) return 1 ;;
8392
esac
8493
}
@@ -91,6 +100,8 @@ historical_collision_name_is_valid() {
91100
20260713130000:quiz_finalize_rank_winners | \
92101
20260805090000:add_least_privilege_gigl_tracking_worker | \
93102
20260805090000:complete_merchant_invoice_partial_payments | \
103+
20260805091000:enable_least_privilege_gigl_tracking_login | \
104+
20260805091000:converge_gigl_tracking_worker_nologin | \
94105
20260811120000:quiz_leaderboard_and_claim_projections_v2 | \
95106
20260811120000:allow_reviewed_paystack_email_mismatch)
96107
return 0

‎.github/scripts/inject-prebuilt-env-secret.jwk.test.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,10 @@ test('generated JWK stand-in accepts a fully resolved nonempty fallback', () =>
173173
assert.match(stdout, /verified.*legacy signing-secret fallback/i);
174174
});
175175

176-
test('generated JWK stand-in accepts only explicit blank dotenv forms', () => {
176+
test('generated JWK stand-in accepts explicit blanks and the CLI sensitive marker', () => {
177177
const generatedValues = [];
178178

179-
for (const blankValue of ['', "''", '""']) {
179+
for (const blankValue of ['', "''", '""', '"[SENSITIVE]"']) {
180180
const file = makePulledJwkEnvFile(blankValue);
181181
const stdout = run([JWK_KEY, file, GENERATE_ES256_JWK_STANDIN]);
182182
const generatedValue = parseSingleQuotedValue(file, JWK_KEY);

‎.github/scripts/inject-prebuilt-env-secret.mjs‎

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
#!/usr/bin/env node
22
// Ensure a single key is present in a Vercel-pulled dotenv file so the local
33
// prebuilt `vercel build` passes env.ts's build-time presence validation for a
4-
// *sensitive* (write-only) Vercel env var — which `vercel pull` returns EMPTY
5-
// (`KEY=""`). The value is consumed only at RUNTIME, where Vercel injects the
6-
// real sensitive value and env.ts re-validates against it. The injected key is
4+
// *sensitive* (write-only) Vercel env var — which `vercel pull` redacts as
5+
// `KEY=""` or, since CLI 57, `KEY="[SENSITIVE]"`. The value is consumed only at
6+
// RUNTIME, where Vercel injects the real value and env.ts re-validates it.
7+
// The injected key is
78
// server-only (not `NEXT_PUBLIC_`), so it is never bundled client-side.
89
//
910
// Usage: node inject-prebuilt-env-secret.mjs <KEY> <ENV_FILE> [STANDIN]
1011
// process.env[<KEY>] real value (for example, a GitHub Actions secret). If
1112
// non-empty, it is injected unconditionally.
1213
// [STANDIN] optional build-time stand-in. Used only when the real
1314
// value is empty and Vercel pulled exactly one explicitly
14-
// blank `<KEY>=`, `<KEY>=''`, or `<KEY>=""` entry.
15+
// blank `<KEY>=`, `<KEY>=''`, or `<KEY>=""` entry, or
16+
// the exact CLI marker `<KEY>="[SENSITIVE]"`.
1517
// --generate-es256-jwk-standin
1618
// generate an ephemeral ES256 private JWK only after the
17-
// same explicit-blank check. When the optional key is
19+
// same redaction check. When the optional key is
1820
// absent, leave the file unchanged so production can use
1921
// the configured legacy signing-secret fallback. The JWK
2022
// is written directly to the pulled file and is never
@@ -50,8 +52,10 @@ function findDotenvAssignments(lines, targetKey) {
5052
});
5153
}
5254

53-
function isExplicitlyBlankDotenvValue(value) {
54-
return value === '' || value === "''" || value === '""';
55+
function isRedactedDotenvValue(value) {
56+
return (
57+
value === '' || value === "''" || value === '""' || value === '"[SENSITIVE]"'
58+
);
5559
}
5660

5761
function readExpandedDotenvValue(contents, file, targetKey) {
@@ -143,19 +147,19 @@ if (usingGeneratedStandin && assignments.length === 0) {
143147
process.exit(0);
144148
}
145149

146-
// A stand-in may only substitute for Vercel's write-only blank placeholder.
147-
// Refusing absent, duplicated, nonblank, or malformed-looking entries avoids
150+
// A stand-in may only substitute for Vercel's exact write-only placeholders.
151+
// Refusing absent, duplicated, non-redacted, or malformed-looking entries avoids
148152
// replacing a value that Vercel pull did expose or an opaque dotenv construct.
149153
if (
150154
usingStandin &&
151-
(assignments.length !== 1 || !isExplicitlyBlankDotenvValue(assignments[0].value))
155+
(assignments.length !== 1 || !isRedactedDotenvValue(assignments[0].value))
152156
) {
153157
const state =
154158
assignments.length === 0
155159
? 'absent'
156160
: assignments.length > 1
157161
? 'ambiguous'
158-
: 'not explicitly blank';
162+
: 'not explicitly blank or a recognized sensitive marker';
159163
console.error(
160164
`${key} is ${state} in ${file}. Refusing to replace it with a build-time ` +
161165
'stand-in; configure Vercel with a write-only sensitive value or provide a real value.',

‎.github/scripts/inject-prebuilt-env-secret.test.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ test('stand-in is refused when the sensitive var is absent from Vercel', () => {
9999
assert.equal(fs.readFileSync(file, 'utf8'), before);
100100
});
101101

102+
test('refuses marker lookalikes, real values, and duplicate redacted entries', () => {
103+
for (const assignment of [
104+
`${KEY}="[SENSITIVE]extra"`,
105+
`${KEY}=" [SENSITIVE] "`,
106+
`${KEY}="[sensitive]"`,
107+
`${KEY}="actual-runtime-value"`,
108+
`${KEY}="[SENSITIVE]"\n${KEY}=""`,
109+
]) {
110+
const contents = `${assignment}\n`;
111+
const file = makeEnvFile(contents);
112+
const { status } = runExpectFailure([KEY, file, STANDIN]);
113+
assert.equal(status, 1);
114+
assert.equal(fs.readFileSync(file, 'utf8'), contents);
115+
}
116+
});
117+
102118
test('refuses a value containing a dollar sign because dotenv-expand can mangle it', () => {
103119
const file = makeEnvFile();
104120
const before = fs.readFileSync(file, 'utf8');

‎.github/scripts/refuse-publish-on-promote-overlap.sh‎

100644100755
File mode changed.

‎.github/scripts/refuse-publish-on-promote-overlap.test.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ esac
4545
`
4646
);
4747
chmodSync(stubPath, 0o755);
48-
const result = spawnSync('bash', [scriptPath], {
48+
// Execute exactly as deploy.yml does so a missing executable bit fails CI.
49+
const result = spawnSync(scriptPath, [], {
4950
cwd: workDir,
5051
encoding: 'utf8',
5152
env: {

‎.github/scripts/run-migration-applier-tests.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ node --test "$repo_root/supabase/tests/serialized_variant_inventory_concurrency_
5858
node --test "$repo_root/supabase/tests/serialized_variant_inventory_concurrency_fixture_functions.test.mjs"
5959
bash "$script_dir/apply-pending-migrations.test.sh"
6060
bash "$script_dir/apply-pending-migrations-max-version.test.sh"
61+
bash "$script_dir/apply-pending-migrations-gigl-history.test.sh"
6162
node --test "$script_dir/repair-sales-migration-collision.test.mjs"
6263
node --test "$script_dir/repair-sales-migration-collision.sql.test.mjs"
6364
node --test "$script_dir/shipping-policy-audit-repair.test.mjs" "$script_dir/shipping-policy-audit-repair.sql.test.mjs"

‎.github/scripts/tools-worker-typecheck-contract.test.mjs‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,34 @@ async function readWebFilter(filterPath = '.github/filters/ci.yml') {
2727
return { webFilter, webFilterIndex, workflow };
2828
}
2929

30-
test('the Quality Gate generates route types and reaches the tools and worker TypeScript project', async () => {
30+
test('the Quality Gate generates route types and reaches the tools, worker, and MCP TypeScript projects', async () => {
3131
const pkg = JSON.parse(await readFile('apps/web/package.json', 'utf8'));
3232
const toolsTsconfig = JSON.parse(await readFile('apps/web/tsconfig.tools-workers.json', 'utf8'));
33+
const mcpTsconfig = JSON.parse(await readFile('apps/web/tsconfig.mcp.json', 'utf8'));
34+
const webTsconfig = JSON.parse(await readFile('apps/web/tsconfig.json', 'utf8'));
3335
const configTest = await readFile('.github/scripts/resolve-ci-test-plan-config.test.mjs', 'utf8');
3436
const { webFilter, webFilterIndex, workflow } = await readWebFilter();
3537

3638
assert.notEqual(webFilterIndex, -1);
37-
assert.equal(pkg.scripts.typecheck, 'next typegen && tsc --noEmit && pnpm typecheck:tools-workers');
39+
assert.equal(pkg.scripts.typecheck, 'next typegen && tsc --noEmit && pnpm typecheck:tools-workers && pnpm typecheck:mcp');
3840
assert.equal(pkg.scripts['typecheck:tools-workers'], 'tsc --noEmit -p tsconfig.tools-workers.json');
41+
assert.equal(pkg.scripts['typecheck:mcp'], 'tsc --noEmit -p tsconfig.mcp.json');
42+
assert.equal(mcpTsconfig.extends, './tsconfig.json');
43+
assert.equal(webTsconfig.compilerOptions.strict, true);
44+
assert.notEqual(mcpTsconfig.compilerOptions.strict, false);
45+
assert.deepEqual(mcpTsconfig.include, [
46+
'mcp-server/server.ts',
47+
'mcp-server/server-output-schema.test.ts',
48+
'mcp-server/product-variants-output.test.ts',
49+
'mcp-server/product-variants-color-evidence.test.ts',
50+
'mcp-server/browse-catalog-facets.test.ts',
51+
'mcp-server/server-cart-handoff.test.ts',
52+
'mcp-server/variant-attribute-text-value.test.ts',
53+
'mcp-server/delivery-fee-quotes.test.ts',
54+
'mcp-server/delivery-gigl-quotes.test.ts',
55+
'mcp-server/server-delivery-gigl.test.ts',
56+
]);
57+
assert.ok(!mcpTsconfig.exclude.includes('mcp-server'));
3958
assert.deepEqual(toolsTsconfig.compilerOptions.types, [
4059
'node', 'vitest/globals', '@testing-library/jest-dom', 'google.maps',
4160
]);

‎.github/scripts/verify-gigl-fallback-token.sh‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
# Mirror isExplicitlyDisabledEnv: only 0/false/off bypass this gate; an
66
# unset flag counts as enabled, matching the runtime default.
77
# The injector proves the Production key is DEFINED (Vercel pulls sensitive
8-
# values blank); it cannot prove the value is real or unexpired. Operators
8+
# values blank or as the CLI 57 [SENSITIVE] marker); it cannot prove the value
9+
# is real or unexpired. Operators
910
# must keep the same rotated worker JWT in Vercel Production and the VPS
1011
# worker .env, or the retained manual fallback route returns 500.
1112
# Rotation procedure (expiry check + rotate steps):

0 commit comments

Comments
 (0)