Skip to content

Commit 0e2e126

Browse files
authored
fix(cli): push oauth server enabled and path together (CLI-2691) (#7367)
## TL;DR fixes `config push` failing when the oauth server path changes or the server is turned on or off ## whats improved/fixed? the api only accepts `oauth_server_enabled` and `oauth_server_authorization_path` together, but `config push` sent just the changed one ## how? send the missing one too, using the linked project's current value (so enabling keeps a stored custom path), else the config value or default ## ref: - closes: #7087
1 parent 8d94420 commit 0e2e126

5 files changed

Lines changed: 272 additions & 19 deletions

File tree

‎apps/cli/src/commands/config/push/SIDE_EFFECTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ for a handful of groups the target endpoint requires together even when only
7878
one member changed — the network-restriction CIDR pair, each storage feature
7979
container (`image_transformation`, `s3_protocol`, `analytics`, `vector`), the
8080
SMTP key set (or just `smtp_host: ""` to disable it), captcha, each auth
81-
hook, each OAuth provider, and the currently-active SMS provider. An
81+
hook, each OAuth provider, the OAuth server's `enabled` and
82+
`authorization_url_path` pair, and the currently-active SMS provider. An
8283
undeclared member of one of these groups is sent with the project's CURRENT
8384
value — read from the same row-1 response, so it does not change — never a
8485
value your file doesn't declare. Only when that response did not report a

‎apps/cli/src/commands/config/push/push.encoders.ts‎

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
* always prefers the project's current (`remote`) value over a schema-materialized local
1010
* default, so an undeclared companion never gets clobbered with a default the user never asked
1111
* for. Only when `remote` doesn't report a companion at all does resolution fall through to
12-
* `local` (and, for the storage feature containers only, the decoded `config`) — and that
13-
* fallback is disclosed via `forced`, never applied silently.
12+
* `local` (and, for the storage feature containers and the OAuth server pair only, the decoded
13+
* `config`) — and that fallback is disclosed via `forced`, never applied silently.
1414
*/
1515

1616
import type { CliConfig, ConfigChange, ProjectConfig } from "@supabase/config";
@@ -53,7 +53,8 @@ export interface PushEncoderInput {
5353
/**
5454
* The storage encoder's own input: adds the decoded `config` as the last resort for the
5555
* `storage.analytics`/`storage.vector` containers' required inner keys, which the local
56-
* projection prunes entirely once disabled. No other encoder needs this fourth tier.
56+
* projection prunes entirely once disabled. Only the auth encoder's OAuth server pair also
57+
* needs this fourth tier.
5758
*/
5859
export interface StorageEncoderInput extends PushEncoderInput {
5960
readonly config: CliConfig;
@@ -125,8 +126,11 @@ export interface AuthEncoderInput extends PushEncoderInput {
125126
readonly secrets: ReadonlyArray<PushSecretDecision>;
126127
/** HTML bodies loaded from `content_path`, for the unmapped mailer `*_content` keys. */
127128
readonly emailContent: AuthEmailContent;
128-
/** Raw `data.attributes.auth` — for comparing the 13 unmapped mailer `*_content` keys. */
129+
/** Raw `data.attributes.auth` — for comparing the 13 unmapped mailer `*_content` keys, and
130+
* for the OAuth server's stored path that `remote` prunes while disabled. */
129131
readonly remoteAuthAttributes: Readonly<Record<string, unknown>>;
132+
/** Decoded config — the OAuth server pair's last fallback when the projection pruned it. */
133+
readonly config: CliConfig;
130134
/** Clock value for `sms_test_otp_valid_until` (+10 calendar years). */
131135
readonly now: DateTime.Utc;
132136
}
@@ -201,12 +205,11 @@ function resolveLeaf(
201205
}
202206

203207
/**
204-
* {@link resolveLeaf}, with a fourth fallback tier for the storage feature
205-
* containers only: the decoded `config`'s schema-materialized value, for the
206-
* `max_*` keys the local projection prunes entirely once a feature is
207-
* disabled.
208+
* {@link resolveLeaf}, with a fourth fallback tier: the decoded `config`'s
209+
* schema-materialized value, for keys the local projection prunes entirely
210+
* once a feature is disabled.
208211
*/
209-
function resolveStorageFeatureLeaf(
212+
function resolvePrunedLeaf(
210213
changes: ReadonlyArray<ConfigChange>,
211214
path: ReadonlyArray<string>,
212215
remote: ProjectConfig,
@@ -642,28 +645,28 @@ function encodeStorageBodyImpl(input: StorageEncoderInput): PushEncoded<StorageU
642645
const analyticsChanges = changesUnderPrefix(changes, ["storage", "analytics"]);
643646
if (analyticsChanges.length > 0) {
644647
const analyticsPaths = analyticsChanges.map((change) => change.path);
645-
const enabledR = resolveStorageFeatureLeaf(
648+
const enabledR = resolvePrunedLeaf(
646649
changes,
647650
["storage", "analytics", "enabled"],
648651
remote,
649652
local,
650653
config,
651654
);
652-
const maxNamespacesR = resolveStorageFeatureLeaf(
655+
const maxNamespacesR = resolvePrunedLeaf(
653656
changes,
654657
["storage", "analytics", "max_namespaces"],
655658
remote,
656659
local,
657660
config,
658661
);
659-
const maxTablesR = resolveStorageFeatureLeaf(
662+
const maxTablesR = resolvePrunedLeaf(
660663
changes,
661664
["storage", "analytics", "max_tables"],
662665
remote,
663666
local,
664667
config,
665668
);
666-
const maxCatalogsR = resolveStorageFeatureLeaf(
669+
const maxCatalogsR = resolvePrunedLeaf(
667670
changes,
668671
["storage", "analytics", "max_catalogs"],
669672
remote,
@@ -697,21 +700,21 @@ function encodeStorageBodyImpl(input: StorageEncoderInput): PushEncoded<StorageU
697700
const vectorChanges = changesUnderPrefix(changes, ["storage", "vector"]);
698701
if (vectorChanges.length > 0) {
699702
const vectorPaths = vectorChanges.map((change) => change.path);
700-
const enabledR = resolveStorageFeatureLeaf(
703+
const enabledR = resolvePrunedLeaf(
701704
changes,
702705
["storage", "vector", "enabled"],
703706
remote,
704707
local,
705708
config,
706709
);
707-
const maxBucketsR = resolveStorageFeatureLeaf(
710+
const maxBucketsR = resolvePrunedLeaf(
708711
changes,
709712
["storage", "vector", "max_buckets"],
710713
remote,
711714
local,
712715
config,
713716
);
714-
const maxIndexesR = resolveStorageFeatureLeaf(
717+
const maxIndexesR = resolvePrunedLeaf(
715718
changes,
716719
["storage", "vector", "max_indexes"],
717720
remote,
@@ -1373,7 +1376,8 @@ export const PUSH_AUTH_LEAF_MAP: ReadonlyArray<PushAuthLeafSpec> = [
13731376
function encodeAuthBodyImpl(
13741377
input: AuthEncoderInput,
13751378
): PushEncoded<Readonly<Record<string, unknown>>> {
1376-
const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, now } = input;
1379+
const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, config, now } =
1380+
input;
13771381
const body: Record<string, unknown> = {};
13781382
const encoded: Array<ReadonlyArray<string>> = [];
13791383
const unencodable: Array<{ path: ReadonlyArray<string>; reason: string }> = [];
@@ -1388,6 +1392,24 @@ function encodeAuthBodyImpl(
13881392
for (const spec of PUSH_AUTH_LEAF_MAP) {
13891393
leaf(spec.configPath, spec.apiKey, spec.transform, spec.reason);
13901394
}
1395+
/* The API takes `oauth_server_enabled` and `oauth_server_authorization_path` only together, so
1396+
when one of them ships the other rides along with its resolved value. */
1397+
const oauthPair = PUSH_AUTH_LEAF_MAP.filter(
1398+
(spec) =>
1399+
spec.apiKey === "oauth_server_enabled" || spec.apiKey === "oauth_server_authorization_path",
1400+
);
1401+
const oauthSent = oauthPair.find((spec) => spec.apiKey in body);
1402+
const oauthUnsent = oauthPair.find((spec) => !(spec.apiKey in body));
1403+
if (oauthSent !== undefined && oauthUnsent !== undefined) {
1404+
/* `remote` prunes a disabled server's stored path, so the raw attribute is read first. */
1405+
const stored = oauthUnsent.transform(remoteAuthAttributes[oauthUnsent.apiKey]);
1406+
const resolved: LeafResolution =
1407+
stored === undefined
1408+
? resolvePrunedLeaf(changes, oauthUnsent.configPath, remote, local, config)
1409+
: { value: stored, source: "remote" };
1410+
body[oauthUnsent.apiKey] = resolved.value;
1411+
pushForced(forced, oauthUnsent.configPath, resolved);
1412+
}
13911413

13921414
// smtp (container)
13931415
const smtpChanges = changesUnderPrefix(changes, ["auth", "email", "smtp"]);

‎apps/cli/src/commands/config/push/push.encoders.unit.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function authInput(overrides: Partial<AuthEncoderInput> = {}): AuthEncoderInput
6060
secrets: [],
6161
emailContent: EMPTY_EMAIL_CONTENT,
6262
remoteAuthAttributes: {},
63+
config: getDefaultCliConfig(),
6364
now: DateTime.makeUnsafe("2030-01-01T00:00:00.000Z"),
6465
...overrides,
6566
};
@@ -939,6 +940,45 @@ describe("encodeAuthBody", () => {
939940
});
940941
});
941942

943+
describe("oauth server enabled + authorization path pair", () => {
944+
it("a path-only change against a project that doesn't report enabled sends the local flag and discloses it", () => {
945+
const result = encodeAuthBody(
946+
authInput({
947+
changes: [change(["auth", "oauth_server", "authorization_url_path"], "/new/consent")],
948+
remote: { auth: { oauth_server: { authorization_url_path: "/oauth/consent" } } },
949+
local: {
950+
auth: { oauth_server: { enabled: true, authorization_url_path: "/new/consent" } },
951+
},
952+
}),
953+
);
954+
expect(result.body).toEqual({
955+
oauth_server_enabled: true,
956+
oauth_server_authorization_path: "/new/consent",
957+
});
958+
expect(result.forced).toEqual([{ path: ["auth", "oauth_server", "enabled"], value: true }]);
959+
});
960+
961+
it("a disable on a project with no stored path sends the config's path and discloses it", () => {
962+
const result = encodeAuthBody(
963+
authInput({
964+
changes: [change(["auth", "oauth_server", "enabled"], false, true)],
965+
remote: { auth: { oauth_server: { enabled: true } } },
966+
remoteAuthAttributes: {
967+
oauth_server_enabled: true,
968+
oauth_server_authorization_path: null,
969+
},
970+
}),
971+
);
972+
expect(result.body).toEqual({
973+
oauth_server_enabled: false,
974+
oauth_server_authorization_path: "/oauth/consent",
975+
});
976+
expect(result.forced).toEqual([
977+
{ path: ["auth", "oauth_server", "authorization_url_path"], value: "/oauth/consent" },
978+
]);
979+
});
980+
});
981+
942982
describe("external provider containers", () => {
943983
it("disabled → only external_<id>_enabled: false", () => {
944984
const result = encodeAuthBody(

‎apps/cli/src/commands/config/push/push.handler.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF
619619
secrets,
620620
emailContent: authEmailContent,
621621
remoteAuthAttributes,
622+
config,
622623
now,
623624
});
624625
const result = yield* applyResource("auth", changes, encoded, secrets, (body) =>

0 commit comments

Comments
 (0)