@supacloud/cli 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,10 +108,12 @@ loopback development origins, with the default `:80` likewise omitted. Use
108
108
 
109
109
  ### Verified release controls
110
110
 
111
- `release` is an official CLI entry point for the existing Management API
112
- logical-backup and PostgREST lifecycle capabilities. It requires the Management
113
- API context above; it does not promote an application `service_role` key to
114
- Management authority.
111
+ `release` is an official CLI entry point for verified Management API controls
112
+ and the release-canary fixture receipt replay. Management actions require the
113
+ Management API context above. `release_canary_fixture_stage_replay` additionally
114
+ requires the same project's `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`; the
115
+ key is sent only to that application origin and is never promoted to Management
116
+ authority.
115
117
 
116
118
  Project pause and restore are explicit lifecycle commands. A logical backup
117
119
  restore requires an operator to pause the selected project first and then use
@@ -138,6 +140,8 @@ supacloud-cli release logical_backup_restore --ref abc123 \
138
140
  --restore_confirmation RESTORE_PROJECT:abc123:logical-full_abc123_<backup-id-suffix>:<64-lowercase-hex>
139
141
  supacloud-cli release postgrest_status --ref abc123
140
142
  supacloud-cli release postgrest_restart --ref abc123
143
+ supacloud-cli release release_canary_fixture_stage_replay --ref abc123 \
144
+ --subject <central-subject-uuid> --request_id <stage-request-uuid>
141
145
  ```
142
146
 
143
147
  Backup creation reports success only after the CLI verifies exactly one new
@@ -158,6 +162,15 @@ receipt and reads back `desired=running`, `actual=running`, and
158
162
  `health=healthy`. Both mutating controls follow the normal production
159
163
  confirmation and read-only protections.
160
164
 
165
+ `release_canary_fixture_stage_replay` performs one non-retried, response-bounded
166
+ call to the fixed `fa_release_canary_fixture_stage` PostgREST RPC with both
167
+ bearer and `apikey` service-role headers. It accepts only canonical subject and
168
+ request UUIDs, requires a strict `staged` and `idempotent=true` four-field
169
+ receipt, and emits only that safe projection. Before and after the call, the CLI
170
+ reads the selected project's authoritative endpoint projection and requires the
171
+ configured application origin to match its API origin or alias. Production
172
+ confirmation is mandatory.
173
+
161
174
  The legacy `.env` fallback is unclassified and therefore does not enable the
162
175
  production confirmation gate. Production automation must select a `prod` or
163
176
  `production` profile, or set `SUPACLOUD_ENV=production` together with a complete
package/dist/index.js CHANGED
@@ -6491,7 +6491,7 @@ var ACTION_POLICY = {
6491
6491
  mutations: { read: ["status"] },
6492
6492
  release: {
6493
6493
  read: ["logical_backup_list", "postgrest_status"],
6494
- write: ["logical_backup_create", "logical_backup_restore", "postgrest_restart"]
6494
+ write: ["logical_backup_create", "logical_backup_restore", "postgrest_restart", "release_canary_fixture_stage_replay"]
6495
6495
  },
6496
6496
  secrets: { read: ["list"], write: ["upsert", "delete"] },
6497
6497
  frontend: {
@@ -6805,14 +6805,17 @@ async function responseJsonOrNull(response) {
6805
6805
  class HttpTransport {
6806
6806
  baseUrl;
6807
6807
  token;
6808
+ apiKey;
6808
6809
  constructor(config) {
6809
6810
  this.baseUrl = config.baseUrl.replace(/\/$/, "");
6810
6811
  this.token = config.token;
6812
+ this.apiKey = config.apiKey ?? "";
6811
6813
  }
6812
6814
  headers() {
6813
6815
  return {
6814
6816
  Authorization: `Bearer ${this.token}`,
6815
- "Content-Type": "application/json"
6817
+ "Content-Type": "application/json",
6818
+ ...this.apiKey ? { apikey: this.apiKey } : {}
6816
6819
  };
6817
6820
  }
6818
6821
  async mutationWithResponseReader(method, path, serializedBody, responseReader, timeoutMs = DEFAULT_TIMEOUT) {
@@ -7971,22 +7974,20 @@ var safeAuthMutationCodes = new Set([
7971
7974
  var MAX_AUTH_READ_BYTES = 64 * 1024;
7972
7975
  var AUTH_READ_TIMEOUT_MS = 5000;
7973
7976
  var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7974
- var AUTH_LINK_TYPES = [
7975
- "signup",
7976
- "magiclink",
7977
- "recovery",
7978
- "invite",
7979
- "email_change",
7980
- "email_change_current",
7981
- "email_change_new"
7982
- ];
7977
+ var AUTH_LINK_TYPES = ["magiclink", "recovery", "invite"];
7983
7978
  var SAFE_USER_FIELDS = [
7984
- "id",
7985
7979
  "email",
7986
7980
  "phone",
7987
7981
  "created_at",
7988
7982
  "last_sign_in_at"
7989
7983
  ];
7984
+ var MAX_AUTH_SEARCH_LENGTH = 256;
7985
+ var MAX_AUTH_EMAIL_LENGTH = 320;
7986
+ var MAX_AUTH_PHONE_LENGTH = 64;
7987
+ var MAX_AUTH_TIMESTAMP_LENGTH = 64;
7988
+ var MAX_AUTH_REDIRECT_LENGTH = 4096;
7989
+ var MAX_AUTH_ACTION_LINK_LENGTH = 8192;
7990
+ var INVALID_FIELD = Symbol("invalid-auth-field");
7990
7991
  function parseAuthConfig(input) {
7991
7992
  if (typeof input !== "string")
7992
7993
  return input;
@@ -8065,18 +8066,32 @@ function boundedPerPage(candidate) {
8065
8066
  }
8066
8067
  return candidate;
8067
8068
  }
8069
+ function boundedText(candidate, field, maxLength) {
8070
+ if (typeof candidate !== "string")
8071
+ throw new Error(`'${field}' must be a string`);
8072
+ const value = candidate.trim();
8073
+ if (!value || value.length > maxLength || /[\u0000-\u001f\u007f]/u.test(value)) {
8074
+ throw new Error(`'${field}' is invalid or exceeds ${maxLength} characters`);
8075
+ }
8076
+ return value;
8077
+ }
8078
+ function boundedSearch(candidate, field) {
8079
+ return boundedText(candidate, field, MAX_AUTH_SEARCH_LENGTH);
8080
+ }
8081
+ function requiredEmail(candidate) {
8082
+ return boundedText(candidate, "email", MAX_AUTH_EMAIL_LENGTH);
8083
+ }
8068
8084
  function safeRedirectTo(candidate) {
8069
8085
  if (candidate === undefined)
8070
8086
  return;
8071
- if (typeof candidate !== "string" || !candidate.trim())
8072
- throw new Error("'redirect_to' must be an absolute HTTPS or loopback HTTP URL");
8087
+ const value = boundedText(candidate, "redirect_to", MAX_AUTH_REDIRECT_LENGTH);
8073
8088
  let uri;
8074
8089
  try {
8075
- uri = new URL(candidate.trim());
8090
+ uri = new URL(value);
8076
8091
  } catch {
8077
8092
  throw new Error("'redirect_to' must be an absolute HTTPS or loopback HTTP URL");
8078
8093
  }
8079
- const loopback = uri.hostname === "127.0.0.1" || uri.hostname === "[::1]";
8094
+ const loopback = uri.hostname === "localhost" || uri.hostname.endsWith(".localhost") || uri.hostname === "127.0.0.1" || uri.hostname === "[::1]";
8080
8095
  const validProtocol = uri.protocol === "https:" || uri.protocol === "http:" && loopback && Boolean(uri.port);
8081
8096
  if (!validProtocol || uri.username || uri.password || uri.hash) {
8082
8097
  throw new Error("'redirect_to' must be an absolute HTTPS or loopback HTTP URL without credentials or fragment");
@@ -8086,28 +8101,66 @@ function safeRedirectTo(candidate) {
8086
8101
  function isRecord(candidate) {
8087
8102
  return candidate !== null && typeof candidate === "object" && !Array.isArray(candidate);
8088
8103
  }
8104
+ function safeOptionalUserField(candidate, field, maxLength) {
8105
+ if (!(field in candidate))
8106
+ return;
8107
+ const value = candidate[field];
8108
+ if (value === null)
8109
+ return null;
8110
+ if (typeof value !== "string" || value.length > maxLength || /[\u0000-\u001f\u007f]/u.test(value))
8111
+ return INVALID_FIELD;
8112
+ return value;
8113
+ }
8089
8114
  function projectUser(candidate) {
8090
- if (!isRecord(candidate) || typeof candidate.id !== "string")
8115
+ if (!isRecord(candidate) || typeof candidate.id !== "string" || !USER_ID_PATTERN.test(candidate.id))
8091
8116
  return null;
8092
- const projectedUser = {};
8117
+ const projectedUser = { id: candidate.id.toLowerCase() };
8118
+ const fieldLimits = {
8119
+ email: MAX_AUTH_EMAIL_LENGTH,
8120
+ phone: MAX_AUTH_PHONE_LENGTH,
8121
+ created_at: MAX_AUTH_TIMESTAMP_LENGTH,
8122
+ last_sign_in_at: MAX_AUTH_TIMESTAMP_LENGTH
8123
+ };
8093
8124
  for (const field of SAFE_USER_FIELDS) {
8094
- if (field in candidate)
8095
- projectedUser[field] = candidate[field];
8125
+ const value = safeOptionalUserField(candidate, field, fieldLimits[field]);
8126
+ if (value === INVALID_FIELD)
8127
+ return null;
8128
+ if (value !== undefined)
8129
+ projectedUser[field] = value;
8096
8130
  }
8097
8131
  return projectedUser;
8098
8132
  }
8099
- function projectUserList(candidate) {
8100
- if (!isRecord(candidate) || !Array.isArray(candidate.users))
8133
+ function safePaginationField(candidate) {
8134
+ if (candidate === undefined)
8135
+ return;
8136
+ if (candidate === null)
8137
+ return null;
8138
+ return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 ? candidate : INVALID_FIELD;
8139
+ }
8140
+ function projectUserList(candidate, expectedPage, expectedPerPage) {
8141
+ if (!isRecord(candidate) || !Array.isArray(candidate.users) || candidate.users.length > expectedPerPage)
8101
8142
  return null;
8102
8143
  const users = candidate.users.map(projectUser);
8103
8144
  if (users.some((user) => user === null))
8104
8145
  return null;
8105
- const projectedFields = { users };
8146
+ const projectedUsers = users;
8147
+ const userIds = projectedUsers.map((user) => user.id);
8148
+ if (new Set(userIds).size !== userIds.length)
8149
+ return null;
8150
+ const projectedFields = { users: projectedUsers };
8106
8151
  for (const field of ["total", "page", "per_page", "next_page", "last_page"]) {
8107
- if (field in candidate && (typeof candidate[field] === "number" || candidate[field] === null)) {
8108
- projectedFields[field] = candidate[field];
8109
- }
8152
+ const value = safePaginationField(candidate[field]);
8153
+ if (value === INVALID_FIELD)
8154
+ return null;
8155
+ if (value !== undefined)
8156
+ projectedFields[field] = value;
8110
8157
  }
8158
+ if (projectedFields.page !== undefined && projectedFields.page !== expectedPage)
8159
+ return null;
8160
+ if (projectedFields.per_page !== undefined && projectedFields.per_page !== expectedPerPage)
8161
+ return null;
8162
+ if (typeof projectedFields.total === "number" && projectedFields.total < projectedUsers.length)
8163
+ return null;
8111
8164
  return projectedFields;
8112
8165
  }
8113
8166
  function actionLink(candidate) {
@@ -8117,10 +8170,17 @@ function actionLink(candidate) {
8117
8170
  if (isRecord(candidate.data))
8118
8171
  candidates.push(candidate.data.properties);
8119
8172
  }
8120
- for (const candidate2 of candidates) {
8121
- if (isRecord(candidate2) && typeof candidate2.action_link === "string" && candidate2.action_link.length > 0) {
8122
- return candidate2.action_link;
8123
- }
8173
+ for (const nested of candidates) {
8174
+ if (!isRecord(nested) || typeof nested.action_link !== "string")
8175
+ continue;
8176
+ const link = nested.action_link;
8177
+ if (!link || link.length > MAX_AUTH_ACTION_LINK_LENGTH || /[\u0000-\u001f\u007f]/u.test(link))
8178
+ continue;
8179
+ try {
8180
+ const uri = new URL(link);
8181
+ if ((uri.protocol === "https:" || uri.protocol === "http:") && !uri.username && !uri.password && !uri.hash && uri.toString() === link)
8182
+ return link;
8183
+ } catch {}
8124
8184
  }
8125
8185
  return null;
8126
8186
  }
@@ -8144,11 +8204,8 @@ async function listUsers(http, args) {
8144
8204
  const perPage = boundedPerPage(args.per_page);
8145
8205
  const params = new URLSearchParams({ page: String(page), per_page: String(perPage) });
8146
8206
  for (const key of ["search", "email_like"]) {
8147
- if (args[key] !== undefined) {
8148
- if (typeof args[key] !== "string" || !args[key].trim())
8149
- throw new Error(`'${key}' must be a non-empty string`);
8150
- params.set(key, args[key].trim());
8151
- }
8207
+ if (args[key] !== undefined)
8208
+ params.set(key, boundedSearch(args[key], key));
8152
8209
  }
8153
8210
  const response = await http.get(`/v1/projects/${ref}/auth/users?${params.toString()}`, {
8154
8211
  maxJsonBytes: MAX_AUTH_READ_BYTES,
@@ -8156,7 +8213,7 @@ async function listUsers(http, args) {
8156
8213
  });
8157
8214
  if (!response.ok)
8158
8215
  return safeAuthReadFailure("auth.list_users", response);
8159
- const users = projectUserList(response.data);
8216
+ const users = projectUserList(response.data, page, perPage);
8160
8217
  if (!users)
8161
8218
  return safeAuthReadFailure("auth.list_users", { ...response, responseReadError: true });
8162
8219
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, operation: "auth.list_users", project_ref: ref, ...users }, null, 2) }] };
@@ -8171,27 +8228,43 @@ async function getUser(http, args) {
8171
8228
  if (!response.ok)
8172
8229
  return safeAuthReadFailure("auth.get_user", response);
8173
8230
  const user = projectUser(response.data);
8174
- if (!user)
8231
+ if (!user || user.id !== userId) {
8175
8232
  return safeAuthReadFailure("auth.get_user", { ...response, responseReadError: true });
8233
+ }
8176
8234
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, operation: "auth.get_user", project_ref: ref, user }, null, 2) }] };
8177
8235
  }
8236
+ function generateLinkFailure(response) {
8237
+ const outcomeUnknown = response.responseReadError || response.transportError || response.status === 408 || response.status >= 500;
8238
+ return {
8239
+ isError: true,
8240
+ content: [{
8241
+ type: "text",
8242
+ text: JSON.stringify({
8243
+ ok: false,
8244
+ operation: "auth.generate_link",
8245
+ error: {
8246
+ code: outcomeUnknown ? "OUTCOME_UNKNOWN" : "HTTP_ERROR",
8247
+ http_status: response.transportError ? null : response.status
8248
+ }
8249
+ }, null, 2)
8250
+ }]
8251
+ };
8252
+ }
8178
8253
  async function generateLink(http, args) {
8179
8254
  const ref = requiredRef(args.ref);
8180
8255
  if (typeof args.type !== "string" || !AUTH_LINK_TYPES.includes(args.type)) {
8181
- throw new Error("'type' is invalid for 'generate_link'");
8256
+ throw new Error("'type' must be one of magiclink, recovery, or invite for 'generate_link'");
8182
8257
  }
8183
- if (typeof args.email !== "string" || !args.email.trim())
8184
- throw new Error("'email' is required for 'generate_link'");
8185
- const body = { type: args.type, email: args.email.trim() };
8258
+ const body = { type: args.type, email: requiredEmail(args.email) };
8186
8259
  const redirectTo = safeRedirectTo(args.redirect_to);
8187
8260
  if (redirectTo)
8188
8261
  body.redirect_to = redirectTo;
8189
8262
  const response = await http.postReleaseMutation(`/v1/projects/${ref}/auth/generate_link`, body);
8190
8263
  if (!response.ok)
8191
- return safeAuthReadFailure("auth.generate_link", response);
8264
+ return generateLinkFailure(response);
8192
8265
  const link = actionLink(response.data);
8193
8266
  if (!link)
8194
- return safeAuthReadFailure("auth.generate_link", { ...response, responseReadError: true });
8267
+ return generateLinkFailure({ ...response, responseReadError: true });
8195
8268
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, operation: "auth.generate_link", action_link: link }, null, 2) }] };
8196
8269
  }
8197
8270
  function formatProviders(data) {
@@ -8251,7 +8324,7 @@ Actions: list_users, get_user, generate_link, list_providers, get_provider, conf
8251
8324
  per_page: optional(Type.Integer({ minimum: 1, maximum: 100 }), "[list_users] Users per page (1-100)"),
8252
8325
  search: optional(Type.String(), "[list_users] Search user email, phone, or UUID"),
8253
8326
  email_like: optional(Type.String(), "[list_users] Search user email or phone"),
8254
- type: optional(stringEnum(AUTH_LINK_TYPES), "[generate_link] GoTrue link type"),
8327
+ type: optional(stringEnum(AUTH_LINK_TYPES), "[generate_link] magiclink, recovery, or invite"),
8255
8328
  email: optional(Type.String(), "[generate_link] User email"),
8256
8329
  redirect_to: optional(Type.String(), "[generate_link] Absolute HTTPS or loopback callback"),
8257
8330
  provider: optional(Type.String(), "[*_provider] Provider name (github, google, wechat, etc.)"),
@@ -10246,15 +10319,15 @@ function hasWellFormedUnicode(text) {
10246
10319
  }
10247
10320
  return true;
10248
10321
  }
10249
- function boundedText(candidate, maxLength) {
10322
+ function boundedText2(candidate, maxLength) {
10250
10323
  return typeof candidate === "string" && candidate.length > 0 && candidate.length <= maxLength && !/[\u0000-\u001f\u007f]/u.test(candidate) && hasWellFormedUnicode(candidate) ? candidate : null;
10251
10324
  }
10252
10325
  function matchingText(candidate, maxLength, pattern) {
10253
- const candidateText = boundedText(candidate, maxLength);
10326
+ const candidateText = boundedText2(candidate, maxLength);
10254
10327
  return candidateText && pattern.test(candidateText) ? candidateText : null;
10255
10328
  }
10256
10329
  function canonicalTimestamp(candidate) {
10257
- const timestamp = boundedText(candidate, 64);
10330
+ const timestamp = boundedText2(candidate, 64);
10258
10331
  if (!timestamp)
10259
10332
  return null;
10260
10333
  const milliseconds = Date.parse(timestamp);
@@ -10266,7 +10339,7 @@ function projectedSummary(project) {
10266
10339
  ref: matchingText(project.ref, 20, PROJECT_REF_PATTERN3),
10267
10340
  organization_id: matchingText(project.organization_id, 128, SAFE_IDENTIFIER_PATTERN),
10268
10341
  organization_slug: matchingText(project.organization_slug, 128, SAFE_IDENTIFIER_PATTERN),
10269
- name: boundedText(project.name, 100),
10342
+ name: boundedText2(project.name, 100),
10270
10343
  region: matchingText(project.region, 64, REGION_PATTERN),
10271
10344
  created_at: canonicalTimestamp(project.created_at),
10272
10345
  status: matchingText(project.status, 64, STATUS_PATTERN)
@@ -10274,7 +10347,7 @@ function projectedSummary(project) {
10274
10347
  return Object.values(summary).every((field) => field !== null) ? summary : null;
10275
10348
  }
10276
10349
  function databaseHost(candidate) {
10277
- const host = boundedText(candidate, 255);
10350
+ const host = boundedText2(candidate, 255);
10278
10351
  if (!host)
10279
10352
  return null;
10280
10353
  if (host.startsWith("[") && host.endsWith("]")) {
@@ -10314,7 +10387,7 @@ function projectEndpoint(candidate) {
10314
10387
  const endpoint = plainRecord(candidate);
10315
10388
  if (!endpoint || !hasOnlyKeys(endpoint, PROJECT_ENDPOINT_KEYS))
10316
10389
  return null;
10317
- const endpointUrl = boundedText(endpoint.url, 2048);
10390
+ const endpointUrl = boundedText2(endpoint.url, 2048);
10318
10391
  if (!endpointUrl || !rawUrlHasNoPath(endpointUrl))
10319
10392
  return null;
10320
10393
  try {
@@ -10331,7 +10404,7 @@ function projectEndpoint(candidate) {
10331
10404
  function discardedDetailFieldsAreValid(project) {
10332
10405
  if (project.config !== undefined && plainRecord(project.config) === null)
10333
10406
  return false;
10334
- if (project.anon_key !== undefined && boundedText(project.anon_key, 16384) === null)
10407
+ if (project.anon_key !== undefined && boundedText2(project.anon_key, 16384) === null)
10335
10408
  return false;
10336
10409
  return project.services === undefined || Array.isArray(project.services);
10337
10410
  }
@@ -10410,11 +10483,11 @@ function plainRecord2(candidate) {
10410
10483
  function hasOnlyKeys2(record, allowedKeys) {
10411
10484
  return Object.keys(record).every((key) => allowedKeys.has(key));
10412
10485
  }
10413
- function boundedText2(candidate, maxLength) {
10486
+ function boundedText3(candidate, maxLength) {
10414
10487
  return typeof candidate === "string" && candidate.length > 0 && candidate.length <= maxLength && !/[\u0000-\u001f\u007f]/u.test(candidate) ? candidate : null;
10415
10488
  }
10416
10489
  function canonicalHost(candidate, scheme) {
10417
- const host = boundedText2(candidate, 255);
10490
+ const host = boundedText3(candidate, 255);
10418
10491
  if (!host)
10419
10492
  return null;
10420
10493
  try {
@@ -10429,8 +10502,8 @@ function projectEndpoint2(candidate) {
10429
10502
  if (!endpoint || !hasOnlyKeys2(endpoint, ENDPOINT_KEYS))
10430
10503
  return null;
10431
10504
  const scheme = endpoint.scheme === "http" || endpoint.scheme === "https" ? endpoint.scheme : null;
10432
- const origin = boundedText2(endpoint.origin, 2048);
10433
- const source = boundedText2(endpoint.source, 64);
10505
+ const origin = boundedText3(endpoint.origin, 2048);
10506
+ const source = boundedText3(endpoint.source, 64);
10434
10507
  if (!scheme || !origin || !source || !PROJECT_ENDPOINT_SOURCES.has(source))
10435
10508
  return null;
10436
10509
  let parsedOrigin;
@@ -10492,6 +10565,15 @@ function projectEndpointRead(response, expectedRef) {
10492
10565
  const projection = projectEndpointProjection(response.data);
10493
10566
  return projection && projection.project_ref === expectedRef ? successfulResult2(projection) : failedResult2("Invalid project endpoint response");
10494
10567
  }
10568
+ function projectApiOrigins(response, expectedRef) {
10569
+ if (!successfulResponse2(response))
10570
+ return null;
10571
+ const projection = projectEndpointProjection(response.data);
10572
+ if (!projection || projection.project_ref !== expectedRef)
10573
+ return null;
10574
+ const api = projection.endpoints.api;
10575
+ return [api.origin, ...api.aliases.map((alias) => `${api.scheme}://${alias}`)];
10576
+ }
10495
10577
 
10496
10578
  // src/shared/tools/project-cli-tools.ts
10497
10579
  function projectReadResponse(readResult) {
@@ -12759,6 +12841,9 @@ var INVENTORY_MAX_BYTES = 1024 * 1024;
12759
12841
  var MUTATION_MAX_BYTES = 64 * 1024;
12760
12842
  var BACKUP_TIMEOUT_MS = 36 * 60000;
12761
12843
  var RELEASE_READ_RESPONSE_TIMEOUT_MS = 5000;
12844
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12845
+ var RELEASE_CANARY_TENANT_KEY = /^release-canary-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12846
+ var RELEASE_CANARY_STAGE_RECEIPT_KEYS = new Set(["fixtureId", "tenantKey", "state", "idempotent"]);
12762
12847
  function isRecord3(value) {
12763
12848
  return value !== null && typeof value === "object" && !Array.isArray(value);
12764
12849
  }
@@ -12891,20 +12976,44 @@ function readPostgrestFailure(operation, read) {
12891
12976
  function isRestartReceipt(value) {
12892
12977
  return isRecord3(value) && value.service === "postgrest" && value.action === "restart" && value.success === true;
12893
12978
  }
12979
+ function releaseCanaryStageInput(subject, requestId) {
12980
+ if (typeof subject !== "string" || !UUID.test(subject))
12981
+ throw new Error("'subject' must be a canonical UUID");
12982
+ if (typeof requestId !== "string" || !UUID.test(requestId))
12983
+ throw new Error("'request_id' must be a canonical UUID");
12984
+ return { p_subject: subject, p_request_id: requestId };
12985
+ }
12986
+ function releaseCanaryStageReceipt(value) {
12987
+ if (!isRecord3(value) || Object.keys(value).some((key) => !RELEASE_CANARY_STAGE_RECEIPT_KEYS.has(key)) || Object.keys(value).length !== RELEASE_CANARY_STAGE_RECEIPT_KEYS.size || typeof value.fixtureId !== "string" || !UUID.test(value.fixtureId) || typeof value.tenantKey !== "string" || !RELEASE_CANARY_TENANT_KEY.test(value.tenantKey) || value.state !== "staged" || value.idempotent !== true)
12988
+ return null;
12989
+ return {
12990
+ fixtureId: value.fixtureId,
12991
+ tenantKey: value.tenantKey,
12992
+ state: "staged",
12993
+ idempotent: true
12994
+ };
12995
+ }
12996
+ async function applicationOriginMatches(http, projectRef2, applicationOrigin) {
12997
+ const endpointRead = await http.get(`${endpoint(projectRef2)}/endpoint/projection`, { maxResponseBytes: PROJECT_ENDPOINT_RESPONSE_MAX_BYTES });
12998
+ return projectApiOrigins(endpointRead, projectRef2)?.includes(applicationOrigin) === true;
12999
+ }
12894
13000
  function registerReleaseTools(server, http, options = {}) {
12895
- server.tool("release", "Verified release controls using a Management API credential. Actions: logical_backup_list, logical_backup_create, logical_backup_restore, postgrest_status, postgrest_restart", {
13001
+ server.tool("release", "Verified release controls. Management actions use the Management API; release_canary_fixture_stage_replay additionally requires the selected project's SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.", {
12896
13002
  action: withDescription(stringEnum([
12897
13003
  "logical_backup_list",
12898
13004
  "logical_backup_create",
12899
13005
  "logical_backup_restore",
12900
13006
  "postgrest_status",
12901
- "postgrest_restart"
13007
+ "postgrest_restart",
13008
+ "release_canary_fixture_stage_replay"
12902
13009
  ]), "Release control action"),
12903
13010
  ref: optional(Type.String(), options.projectRef ? "Optional override when not auto-linked" : "Project ref"),
12904
13011
  backup_id: optional(Type.String(), "[logical_backup_restore] Exact verified logical-full backup ID from the selected project inventory"),
12905
13012
  expected_sha256: optional(Type.String(), "[logical_backup_restore] Exact lowercase SHA-256 from the selected project inventory"),
12906
- restore_confirmation: optional(Type.String(), "[logical_backup_restore] Exact RESTORE_PROJECT:<ref>:<backup_id>:<sha256> confirmation")
12907
- }, async ({ action, ref, backup_id, expected_sha256, restore_confirmation }) => {
13013
+ restore_confirmation: optional(Type.String(), "[logical_backup_restore] Exact RESTORE_PROJECT:<ref>:<backup_id>:<sha256> confirmation"),
13014
+ subject: optional(Type.String(), "[release_canary_fixture_stage_replay] Exact central subject UUID"),
13015
+ request_id: optional(Type.String(), "[release_canary_fixture_stage_replay] Exact idempotent stage request UUID")
13016
+ }, async ({ action, ref, backup_id, expected_sha256, restore_confirmation, subject, request_id }) => {
12908
13017
  const projectRef2 = typeof ref === "string" && ref || options.projectRef;
12909
13018
  if (!projectRef2)
12910
13019
  throw new Error("'ref' is required for release controls");
@@ -12975,6 +13084,27 @@ function registerReleaseTools(server, http, options = {}) {
12975
13084
  postgrest: read2.status
12976
13085
  });
12977
13086
  }
13087
+ if (action === "release_canary_fixture_stage_replay") {
13088
+ if (!options.applicationHttp || !options.applicationOrigin) {
13089
+ throw new Error("release_canary_fixture_stage_replay requires SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY");
13090
+ }
13091
+ const request = releaseCanaryStageInput(subject, request_id);
13092
+ if (!await applicationOriginMatches(http, projectRef2, options.applicationOrigin)) {
13093
+ return releaseControlFailure("release.release_canary.fixture_stage_replay", "INVALID_RESPONSE", null);
13094
+ }
13095
+ const response = await options.applicationHttp.postReleaseMutation("/rest/v1/rpc/fa_release_canary_fixture_stage", request);
13096
+ if (!response.ok || response.status !== 200) {
13097
+ return mutationFailure("release.release_canary.fixture_stage_replay", response);
13098
+ }
13099
+ const receipt = releaseCanaryStageReceipt(response.data);
13100
+ if (!receipt || !await applicationOriginMatches(http, projectRef2, options.applicationOrigin)) {
13101
+ return releaseControlFailure("release.release_canary.fixture_stage_replay", "OUTCOME_UNKNOWN", response.status);
13102
+ }
13103
+ return releaseControlSuccess("release.release_canary.fixture_stage_replay", {
13104
+ project_ref: projectRef2,
13105
+ receipt
13106
+ });
13107
+ }
12978
13108
  if (action !== "postgrest_restart")
12979
13109
  throw new Error("Unknown release control action");
12980
13110
  const mutation = await http.postReleaseMutation(`${endpoint(projectRef2)}/services/postgrest/restart`);
@@ -12995,7 +13125,7 @@ function registerReleaseTools(server, http, options = {}) {
12995
13125
  // package.json
12996
13126
  var package_default = {
12997
13127
  name: "@supacloud/cli",
12998
- version: "0.28.0",
13128
+ version: "0.29.0",
12999
13129
  description: "Project-scoped CLI for SupaCloud users",
13000
13130
  type: "module",
13001
13131
  main: "./dist/index.js",
@@ -13224,7 +13354,8 @@ DEFAULT CONTEXT
13224
13354
  Without a selector or project variables, runs use the current project's legacy .env.
13225
13355
  Application status accepts SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY.
13226
13356
  Management-backed project commands require SUPACLOUD_API_URL +
13227
- SUPACLOUD_API_TOKEN. These credential scopes are never mixed.
13357
+ SUPACLOUD_API_TOKEN. Only release release_canary_fixture_stage_replay may additionally use
13358
+ the selected project's SUPABASE_* pair for one application-origin RPC.
13228
13359
  SUPACLOUD_PROJECT_REF is required when it cannot be inferred from <ref>.api.*.
13229
13360
 
13230
13361
  SUPACLOUD_READ_ONLY=true blocks remote writes. Production writes require an
@@ -13245,6 +13376,7 @@ EXAMPLES
13245
13376
  ${preferredCommand} release logical_backup_restore --ref abc123 --backup_id <backup_id> --expected_sha256 <sha256> --restore_confirmation RESTORE_PROJECT:abc123:<backup_id>:<sha256>
13246
13377
  ${preferredCommand} release postgrest_status --ref abc123
13247
13378
  ${preferredCommand} release postgrest_restart --ref abc123
13379
+ ${preferredCommand} release release_canary_fixture_stage_replay --ref abc123 --subject <uuid> --request_id <uuid>
13248
13380
  ${preferredCommand} queue stats --queue emails
13249
13381
  ${preferredCommand} queue dlq --queue emails --limit 20
13250
13382
  ${preferredCommand} frontend list --ref abc123
@@ -13403,6 +13535,11 @@ function createCliTools(context, confirmProduction) {
13403
13535
  baseUrl: context.apiUrl,
13404
13536
  token: context.apiToken
13405
13537
  });
13538
+ const applicationHttp = context.inferredSupabaseUrl && context.inferredServiceRoleKey ? new HttpTransport({
13539
+ baseUrl: context.inferredSupabaseUrl,
13540
+ token: context.inferredServiceRoleKey,
13541
+ apiKey: context.inferredServiceRoleKey
13542
+ }) : undefined;
13406
13543
  const assign = (extra) => Object.assign(tools, extra);
13407
13544
  assign(captureTools((server) => registerUserProjectCliTools(server, http, {
13408
13545
  projectRef: context.projectRef || undefined
@@ -13424,7 +13561,9 @@ function createCliTools(context, confirmProduction) {
13424
13561
  })));
13425
13562
  assign(captureTools((server) => registerMutationTools(server, http)));
13426
13563
  assign(captureTools((server) => registerReleaseTools(server, http, {
13427
- projectRef: context.projectRef || undefined
13564
+ projectRef: context.projectRef || undefined,
13565
+ applicationHttp,
13566
+ applicationOrigin: context.inferredSupabaseUrl || undefined
13428
13567
  })));
13429
13568
  assign(captureTools((server) => registerFrontendTools(server, http)));
13430
13569
  assign(captureTools((server) => registerGatewayTools(server, http, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Project-scoped CLI for SupaCloud users",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,9 +15,10 @@ Load this reference when selecting a command surface or when a user asks an AI t
15
15
  | Generate migration from local schema changes | `supabase db_diff` | Review generated SQL before use |
16
16
  | Rebuild local database | `supabase db_reset` | Local only; preserve required seed behavior |
17
17
  | Inspect or back up a remote database | `supabase db_pull`, `migration_list`, `db_dump`, `gen_types` | Requires explicit PostgreSQL DSN; redact it |
18
+ | Replay the release-canary fixture stage receipt | `release release_canary_fixture_stage_replay` | Dual-bind Management project context and that project's service-role application origin; accepts only the exact subject/request UUID pair and a strict idempotent receipt |
18
19
  | Preview/apply migrations remotely | `supabase push` | Always dry-run first; production needs explicit approval |
19
20
  | Mark proven-equivalent historical migrations as applied | `database baseline_migrations` | Dry-run, schema-equivalence proof, backup, explicit approval |
20
- | Inspect auth users or generate a controlled login link | `auth list_users`, `auth get_user`, `auth generate_link` | User reads are bounded; login-link generation is a production-confirmed write and action links stay in the calling process |
21
+ | Inspect auth users or generate a controlled login link | `auth list_users`, `auth get_user`, `auth generate_link` | User reads are bounded; generation supports only `magiclink`, `recovery`, and `invite`, requires production confirmation, and returns only a validated action URL |
21
22
  | Manage Auth/Storage/Edge Functions/frontend/secrets | Corresponding project module | Keep deployable config/code in version control |
22
23
  | Configure project gateway routes | `gateway` | Requires an admin-capable project token; inspect before write |
23
24
  | Install/upgrade/debug SupaCloud servers | `supacloud-admin` | Platform boundary; not a project CLI action |
@@ -32,13 +33,14 @@ until a project-scoped context is resolved.
32
33
  - `project`: selected-project metadata, authoritative endpoint projection, health, logs, API keys/settings, background tasks, retry/cancel, DLQ, and background settings. `project list` deliberately redirects to `supacloud-admin`; cross-project enumeration is not a project CLI capability.
33
34
  - `database`: read/query, schema inspection, extensions, indexes, RLS, stats, migration push, controlled historical baseline, and SQL-file execution.
34
35
  - `supabase`: allowlisted official CLI adapter for migration authoring, local reset/diff, explicit-DSN inspection/backup/type generation, and SupaCloud-controlled migration push.
35
- - `auth`: provider/configuration plus bounded user lookup and production-confirmed login-link generation.
36
+ - `auth`: provider/configuration plus bounded user lookup and production-confirmed `magiclink`, `recovery`, or `invite` generation. Search/email/redirect inputs and returned action URLs are bounded and validated before use.
36
37
  - `storage`: buckets and object-management workflows.
37
38
  - `edge_functions`: list, atomically read one active or deleted identity with `get_config`, read immutable source, deploy, activate, configure, and delete Edge Functions. For every mutation, pass the `activation_id` read from the same `list` or `get_config` snapshot as `--expected-activation-id`; use `legacy` only for a never-created or listed legacy Function, not for a deleted slug with a tombstone UUID. Deploy and activate actions also require the non-negative observed version as `--expected-active-version`; use `absent` for a never-created slug or a `get_config` tombstone. Version `0` is a legacy version token and cannot be used as a source or activation target.
38
39
  - `frontend`: list, build/deploy, domain, and deployment workflows.
39
40
  - `secrets`: project secret management; never print values after write.
40
41
  - `queue`, `task_events`, `diagnostics`: asynchronous workload operations and bounded diagnostics.
41
42
  - `gateway`: project route/config/rebuild operations.
43
+ - `release`: verified backup/PostgREST lifecycle controls plus a production-confirmed, non-retried, strict release-canary fixture stage replay. The replay requires both the selected Management project binding and that project's `SUPABASE_URL`/`SUPABASE_SERVICE_ROLE_KEY`.
42
44
  - `ai`: inspect or install this packaged Skill.
43
45
 
44
46
  ## Safe inspection pattern