@supacloud/admin 0.12.0 → 0.14.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
@@ -59,6 +59,30 @@ npx @supacloud/admin --env production ssh upgrade \
59
59
  --confirm-production host:production.example.com:2201
60
60
  ```
61
61
 
62
+ Immutable prebuilt frontend releases use a content-addressed ZIP and an explicit
63
+ compare-and-swap activation. Upload and activation are separate writes, and
64
+ production requires the exact project ref confirmation:
65
+
66
+ ```bash
67
+ supacloud-admin frontend list_releases --ref abc123 --id web
68
+ supacloud-admin frontend get_release --ref abc123 --id web --release_id <sha256>
69
+ supacloud-admin frontend upload_release --ref abc123 --id web \
70
+ --zip_path /secure/site.zip --confirm-production abc123
71
+ supacloud-admin frontend activate_release --ref abc123 --id web \
72
+ --release_id <sha256> --expected_active_release_id absent \
73
+ --expected_activation_id absent --mutation_id <uuid-v4> \
74
+ --confirm-production abc123
75
+ ```
76
+
77
+ The CLI reads the archive through a no-follow file descriptor, verifies its
78
+ identity and SHA-256 before upload, validates bounded Management API receipts,
79
+ and reads the immutable release or active inventory back before reporting
80
+ success. Reuse the same `mutation_id` only when retrying the exact activation.
81
+ Listing and release readback work on every Management API platform. Upload and
82
+ activation mutations require the Linux held-directory-FD implementation; on
83
+ other platforms, Management API returns HTTP 503 before it reads the upload
84
+ body, creates release directories, or writes a mutation journal.
85
+
62
86
  SSH host keys are fail-closed: setting `SUPACLOUD_HOST` and credentials is not
63
87
  enough to enable SSH actions. Obtain the fingerprint through a trusted channel,
64
88
  compare it out of band, then set it explicitly. For example, the discovery
@@ -89,7 +113,9 @@ npx @supacloud/admin project service_control --ref abc123 --service gotrue --ser
89
113
  Each component reports `status` as `ok`, `unknown`, or `error`; a failed probe
90
114
  never substitutes a guessed version. Binary evidence is bound to the active
91
115
  systemd `ExecStart`, and Web Console evidence comes from its component marker
92
- plus an explicit `tree_sha256` digest.
116
+ plus an explicit `tree_sha256` digest. An `unknown` component remains a valid
117
+ inventory result, while any `error` component makes the CLI exit non-zero after
118
+ printing the structured report.
93
119
 
94
120
  ## Verified platform upgrades
95
121
 
@@ -247,6 +273,47 @@ The JSON receipt contains only the project ref, requested type, backup ID,
247
273
  database, timestamps, and size. `platform list_backups --ref abc123` applies the
248
274
  same strict, sanitized inventory validation without creating a backup.
249
275
 
276
+ ## Verified logical backups
277
+
278
+ Use the official Admin CLI to list or create project-scoped verified logical
279
+ backups:
280
+
281
+ ```bash
282
+ supacloud-admin platform list_logical_backups --ref abc123
283
+ supacloud-admin platform create_logical_backup --ref abc123
284
+ ```
285
+
286
+ The create command reads the verified inventory before and after the one-shot
287
+ request. It reports success only when the Management response and exactly one
288
+ new inventory identity agree on the project, database, backup ID, kind,
289
+ timestamps, byte count, and SHA-256. The fixed JSON projection never includes
290
+ the server's receipt HMAC, archive path, subprocess output, or response-only
291
+ fields. Do not retry an `OUTCOME_UNKNOWN` create until the inventory has been
292
+ reconciled.
293
+
294
+ Logical restore is destructive and Management also requires the project to be
295
+ paused. Copy an exact identity from `list_logical_backups`, then bind all of its
296
+ recovery-critical fields:
297
+
298
+ ```bash
299
+ supacloud-admin platform restore_logical_backup \
300
+ --ref abc123 \
301
+ --backup_id logical-full_abc123_0123456789abcdef0123456789abcdef \
302
+ --expected_sha256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
303
+ --confirmation RESTORE_PROJECT:abc123:logical-full_abc123_0123456789abcdef0123456789abcdef:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
304
+ ```
305
+
306
+ Both create and restore are classified as remote writes. They require an
307
+ explicit `SUPACLOUD_ENV`, honor `SUPACLOUD_READ_ONLY=true`, and in production
308
+ also require the global exact project confirmation
309
+ `--confirm-production abc123`. A timeout, transport loss, 5xx response, or
310
+ malformed success receipt exits non-zero without reflecting the remote body.
311
+ Logical mutation requests have a bounded 36-minute client deadline so the
312
+ Management request has time to return a terminal response before the client
313
+ gives up.
314
+ Credentials remain environment-only; logical backup commands accept no token,
315
+ password, or service-role flags.
316
+
250
317
  Gateway / Caddy commands (config is injected via the Caddy JSON Admin API; requires admin privileges):
251
318
 
252
319
  - `gateway routes` — list custom gateway routes
package/dist/index.js CHANGED
@@ -4759,7 +4759,7 @@ var require_utils = __commonJS((exports, module) => {
4759
4759
 
4760
4760
  // node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
4761
4761
  var require_sshcrypto = __commonJS((exports, module) => {
4762
- module.exports = __require("./sshcrypto-vd2k5hq9.node");
4762
+ module.exports = __require("./sshcrypto-8m50vnmb.node");
4763
4763
  });
4764
4764
 
4765
4765
  // node_modules/ssh2/lib/protocol/crypto/poly1305.js
@@ -12196,7 +12196,7 @@ var require_SFTP = __commonJS((exports, module) => {
12196
12196
  var fs = __require("fs");
12197
12197
  var { constants } = fs;
12198
12198
  var {
12199
- Readable: ReadableStream,
12199
+ Readable: ReadableStream2,
12200
12200
  Writable: WritableStream
12201
12201
  } = __require("stream");
12202
12202
  var { inherits, types: { isDate } } = __require("util");
@@ -14807,7 +14807,7 @@ var require_SFTP = __commonJS((exports, module) => {
14807
14807
  options.highWaterMark = 64 * 1024;
14808
14808
  options.emitClose = false;
14809
14809
  options.autoDestroy = false;
14810
- ReadableStream.call(this, options);
14810
+ ReadableStream2.call(this, options);
14811
14811
  this.path = path;
14812
14812
  this.flags = options.flags === undefined ? "r" : options.flags;
14813
14813
  this.mode = options.mode === undefined ? 438 : options.mode;
@@ -14839,7 +14839,7 @@ var require_SFTP = __commonJS((exports, module) => {
14839
14839
  if (!Buffer.isBuffer(this.handle))
14840
14840
  this.open();
14841
14841
  }
14842
- inherits(ReadStream, ReadableStream);
14842
+ inherits(ReadStream, ReadableStream2);
14843
14843
  ReadStream.prototype.open = function() {
14844
14844
  if (this._opening)
14845
14845
  return;
@@ -15097,7 +15097,7 @@ var require_SFTP = __commonJS((exports, module) => {
15097
15097
  var require_Channel = __commonJS((exports, module) => {
15098
15098
  var {
15099
15099
  Duplex: DuplexStream,
15100
- Readable: ReadableStream,
15100
+ Readable: ReadableStream2,
15101
15101
  Writable: WritableStream
15102
15102
  } = __require("stream");
15103
15103
  var {
@@ -15108,7 +15108,7 @@ var require_Channel = __commonJS((exports, module) => {
15108
15108
  var MAX_WINDOW = 2 * 1024 * 1024;
15109
15109
  var WINDOW_THRESHOLD = MAX_WINDOW / 2;
15110
15110
 
15111
- class ClientStderr extends ReadableStream {
15111
+ class ClientStderr extends ReadableStream2 {
15112
15112
  constructor(channel, streamOpts) {
15113
15113
  super(streamOpts);
15114
15114
  this._channel = channel;
@@ -18848,7 +18848,7 @@ var require_lib3 = __commonJS((exports, module) => {
18848
18848
  });
18849
18849
 
18850
18850
  // src/index.ts
18851
- import { resolve as resolve3 } from "node:path";
18851
+ import { resolve as resolve4 } from "node:path";
18852
18852
  import { fileURLToPath } from "node:url";
18853
18853
  import { realpathSync } from "node:fs";
18854
18854
 
@@ -25802,8 +25802,8 @@ var ACTION_POLICY = {
25802
25802
  write: ["create", "delete", "pause", "restore", "restart", "update_settings"]
25803
25803
  },
25804
25804
  platform: {
25805
- read: ["metrics", "list_backups", "network", "list_orgs", "get_org"],
25806
- write: ["create_backup", "update_network"]
25805
+ read: ["metrics", "list_backups", "list_logical_backups", "network", "list_orgs", "get_org"],
25806
+ write: ["create_backup", "create_logical_backup", "restore_logical_backup", "update_network"]
25807
25807
  },
25808
25808
  gateway: {
25809
25809
  read: ["routes", "get_certificate", "custom_hostname"],
@@ -25821,6 +25821,10 @@ var ACTION_POLICY = {
25821
25821
  "verify_custom_hostname"
25822
25822
  ]
25823
25823
  },
25824
+ frontend: {
25825
+ read: ["list_releases", "get_release"],
25826
+ write: ["upload_release", "activate_release"]
25827
+ },
25824
25828
  ssh: {
25825
25829
  read: [
25826
25830
  "ping",
@@ -25850,6 +25854,9 @@ var SSH_PROJECT_REF_ACTIONS = new Set(["tenant_manage", "tenant_inspect", "tenan
25850
25854
  var PLATFORM_PROJECT_REF_ACTIONS = new Set([
25851
25855
  "list_backups",
25852
25856
  "create_backup",
25857
+ "list_logical_backups",
25858
+ "create_logical_backup",
25859
+ "restore_logical_backup",
25853
25860
  "network",
25854
25861
  "update_network"
25855
25862
  ]);
@@ -25898,7 +25905,7 @@ function requestedProjectRef(moduleName, action, args) {
25898
25905
  if (moduleName === "platform") {
25899
25906
  return PLATFORM_PROJECT_REF_ACTIONS.has(action) ? stringArgument(args, "ref") : null;
25900
25907
  }
25901
- return moduleName === "gateway" ? stringArgument(args, "ref") : null;
25908
+ return ["gateway", "frontend"].includes(moduleName) ? stringArgument(args, "ref") : null;
25902
25909
  }
25903
25910
  function requiresProjectRef(moduleName, action) {
25904
25911
  return !REFLESS_WRITE_ACTIONS.has(`${moduleName}.${action}`);
@@ -26010,7 +26017,7 @@ function validateExecutionPolicyCoverage(tools) {
26010
26017
 
26011
26018
  // src/shared/transports/http.ts
26012
26019
  var DEFAULT_TIMEOUT = 30000;
26013
- var MAX_POST_TIMEOUT_MS = 35 * 60000;
26020
+ var MAX_POST_TIMEOUT_MS = 36 * 60000;
26014
26021
  var MAX_RETRIES = 2;
26015
26022
  var RETRY_BASE_DELAY = 500;
26016
26023
  function isRetryableMethod(method) {
@@ -26206,14 +26213,59 @@ class HttpTransport {
26206
26213
  }
26207
26214
  async post(path, body, options) {
26208
26215
  const timeoutMs = validatedPostTimeout(options);
26216
+ const maxJsonBytes = validatedStrictJsonLimit({ maxJsonBytes: options?.maxJsonBytes });
26217
+ let response;
26209
26218
  try {
26210
- const res = await fetchWithRetry(`${this.baseUrl}${path}`, {
26219
+ response = await fetchWithRetry(`${this.baseUrl}${path}`, {
26211
26220
  method: "POST",
26212
26221
  headers: this.headers(),
26213
26222
  body: body ? JSON.stringify(body) : undefined
26214
26223
  }, timeoutMs);
26215
- const data = await res.json().catch(() => null);
26216
- return { ok: res.ok, status: res.status, data };
26224
+ } catch (error) {
26225
+ return transportFailure(error);
26226
+ }
26227
+ if (maxJsonBytes !== undefined) {
26228
+ try {
26229
+ const data2 = await strictBoundedResponseJson(response, maxJsonBytes);
26230
+ return { ok: response.ok, status: response.status, data: data2 };
26231
+ } catch {
26232
+ return responseBodyFailure(response.status);
26233
+ }
26234
+ }
26235
+ const data = await response.json().catch(() => null);
26236
+ return { ok: response.ok, status: response.status, data };
26237
+ }
26238
+ async postBinary(path, body, options) {
26239
+ if (options.contentType !== "application/zip") {
26240
+ throw new Error("Binary HTTP content type is invalid");
26241
+ }
26242
+ if (!Number.isSafeInteger(options.contentLength) || options.contentLength < 1 || options.contentLength !== body.byteLength) {
26243
+ throw new RangeError("Binary HTTP body length is invalid");
26244
+ }
26245
+ if (!/^[0-9a-f]{64}$/u.test(options.contentSha256)) {
26246
+ throw new Error("Binary HTTP body SHA-256 is invalid");
26247
+ }
26248
+ const maxJsonBytes = validatedStrictJsonLimit({ maxJsonBytes: options.maxJsonBytes });
26249
+ const timeoutMs = validatedPostTimeout(options.timeoutMs === undefined ? undefined : { timeoutMs: options.timeoutMs });
26250
+ try {
26251
+ const request = {
26252
+ method: "POST",
26253
+ headers: {
26254
+ Authorization: `Bearer ${this.token}`,
26255
+ "Content-Type": options.contentType,
26256
+ "Content-Length": String(options.contentLength),
26257
+ "x-supacloud-content-sha256": options.contentSha256
26258
+ },
26259
+ body: body.stream,
26260
+ duplex: "half"
26261
+ };
26262
+ const response = await fetchWithRetry(`${this.baseUrl}${path}`, request, timeoutMs);
26263
+ try {
26264
+ const data = await strictBoundedResponseJson(response, maxJsonBytes);
26265
+ return { ok: response.ok, status: response.status, data };
26266
+ } catch {
26267
+ return responseBodyFailure(response.status);
26268
+ }
26217
26269
  } catch (error) {
26218
26270
  return transportFailure(error);
26219
26271
  }
@@ -28724,6 +28776,13 @@ async function platformVersions(ssh) {
28724
28776
  }
28725
28777
  };
28726
28778
  }
28779
+ function platformVersionsToolResult(report) {
28780
+ const isError = Object.values(report.components).some((component) => component.status === "error");
28781
+ return {
28782
+ content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
28783
+ ...isError ? { isError: true } : {}
28784
+ };
28785
+ }
28727
28786
  function registerSshTools(server, ssh) {
28728
28787
  server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
28729
28788
  Actions: ping, setup, install, upgrade, versions, diagnose, exec, troubleshoot, container_logs, tenant_manage, tenant_list, tenant_inspect, tenant_diagnose, tenant_migrate`, {
@@ -28910,8 +28969,7 @@ ${upgradeExecution.stdout.slice(-300)}${edgeBoundary}`;
28910
28969
  break;
28911
28970
  }
28912
28971
  case "versions": {
28913
- text = JSON.stringify(await platformVersions(ssh), null, 2);
28914
- break;
28972
+ return platformVersionsToolResult(await platformVersions(ssh));
28915
28973
  }
28916
28974
  case "diagnose": {
28917
28975
  const cmds = [
@@ -29218,6 +29276,238 @@ async function createFullPhysicalBackup(http, projectRef) {
29218
29276
  return reconciledCreationResponse(projectRef, beforeRead.inventory, creation, afterRead);
29219
29277
  }
29220
29278
 
29279
+ // src/shared/tools/logical-backup-control.ts
29280
+ var RELEASE_CONTROL_RESPONSE_SCHEMA = "supacloud.cli.release-control.v1";
29281
+ var SAFE_PROJECT_REF2 = /^[A-Za-z0-9_-]{1,64}$/;
29282
+ var BACKUP_ID = /^logical-full_[A-Za-z0-9_-]{1,64}_[a-f0-9]{32}$/;
29283
+ var BACKUP_ID_SUFFIX = /^[a-f0-9]{32}$/;
29284
+ var SHA256 = /^[a-f0-9]{64}$/;
29285
+ var SAFE_DATABASE = /^[^\u0000-\u001f\u007f]{1,128}$/;
29286
+ var LOGICAL_BACKUP_REQUEST_TIMEOUT_MS = 36 * 60000;
29287
+ var LOGICAL_BACKUP_INVENTORY_MAX_BYTES = 1024 * 1024;
29288
+ var LOGICAL_BACKUP_MUTATION_MAX_BYTES = 64 * 1024;
29289
+ function logicalBackupResponse(payload) {
29290
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
29291
+ }
29292
+ function logicalBackupSuccess(operation, payload) {
29293
+ return logicalBackupResponse({
29294
+ ...payload,
29295
+ schema: RELEASE_CONTROL_RESPONSE_SCHEMA,
29296
+ ok: true,
29297
+ operation
29298
+ });
29299
+ }
29300
+ function logicalBackupFailure(operation, code, httpStatus) {
29301
+ return {
29302
+ ...logicalBackupResponse({
29303
+ schema: RELEASE_CONTROL_RESPONSE_SCHEMA,
29304
+ ok: false,
29305
+ operation,
29306
+ error: { code, http_status: httpStatus }
29307
+ }),
29308
+ isError: true
29309
+ };
29310
+ }
29311
+ function isPlainRecord(candidate) {
29312
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate))
29313
+ return false;
29314
+ const prototype = Object.getPrototypeOf(candidate);
29315
+ return prototype === Object.prototype || prototype === null;
29316
+ }
29317
+ function canonicalTimestamp(candidate) {
29318
+ if (typeof candidate !== "string")
29319
+ return false;
29320
+ const timestamp = new Date(candidate);
29321
+ return Number.isFinite(timestamp.valueOf()) && timestamp.toISOString() === candidate;
29322
+ }
29323
+ function backupBelongsToProject(backupId, projectRef) {
29324
+ const prefix = `logical-full_${projectRef}_`;
29325
+ return BACKUP_ID.test(backupId) && backupId.startsWith(prefix) && BACKUP_ID_SUFFIX.test(backupId.slice(prefix.length));
29326
+ }
29327
+ function validBackupProject(candidate, projectRef) {
29328
+ return typeof candidate.backup_id === "string" && backupBelongsToProject(candidate.backup_id, projectRef) && candidate.project_ref === projectRef;
29329
+ }
29330
+ function validBackupDatabase(candidate) {
29331
+ return typeof candidate.database === "string" && SAFE_DATABASE.test(candidate.database);
29332
+ }
29333
+ function validBackupTimestamps(candidate) {
29334
+ return canonicalTimestamp(candidate.created_at) && canonicalTimestamp(candidate.completed_at) && new Date(candidate.completed_at).valueOf() >= new Date(candidate.created_at).valueOf();
29335
+ }
29336
+ function validBackupEvidence(candidate) {
29337
+ return Number.isSafeInteger(candidate.bytes) && Number(candidate.bytes) > 0 && typeof candidate.sha256 === "string" && SHA256.test(candidate.sha256);
29338
+ }
29339
+ function logicalBackupIdentity(candidate, projectRef) {
29340
+ if (!isPlainRecord(candidate))
29341
+ return null;
29342
+ if (!validBackupProject(candidate, projectRef) || !validBackupDatabase(candidate) || candidate.kind !== "logical-full" || !validBackupTimestamps(candidate) || !validBackupEvidence(candidate)) {
29343
+ return null;
29344
+ }
29345
+ return {
29346
+ backup_id: candidate.backup_id,
29347
+ project_ref: projectRef,
29348
+ database: candidate.database,
29349
+ kind: "logical-full",
29350
+ created_at: candidate.created_at,
29351
+ completed_at: candidate.completed_at,
29352
+ bytes: Number(candidate.bytes),
29353
+ sha256: candidate.sha256
29354
+ };
29355
+ }
29356
+ function logicalBackupInventory(payload, projectRef) {
29357
+ if (!isPlainRecord(payload) || !Array.isArray(payload.backups))
29358
+ return null;
29359
+ const backups = payload.backups.map((candidate) => logicalBackupIdentity(candidate, projectRef));
29360
+ if (backups.some((backup) => backup === null))
29361
+ return null;
29362
+ const inventory = backups;
29363
+ if (new Set(inventory.map((backup) => backup.backup_id)).size !== inventory.length)
29364
+ return null;
29365
+ return inventory;
29366
+ }
29367
+ function assertProjectRef(projectRef) {
29368
+ if (!SAFE_PROJECT_REF2.test(projectRef)) {
29369
+ throw new Error("'ref' is invalid for verified logical backup");
29370
+ }
29371
+ }
29372
+ function logicalBackupEndpoint(projectRef) {
29373
+ assertProjectRef(projectRef);
29374
+ return `/v1/projects/${encodeURIComponent(projectRef)}/database/backups/logical`;
29375
+ }
29376
+ async function readLogicalBackupInventory(http, projectRef) {
29377
+ const response = await http.get(logicalBackupEndpoint(projectRef), {
29378
+ maxJsonBytes: LOGICAL_BACKUP_INVENTORY_MAX_BYTES
29379
+ });
29380
+ return {
29381
+ response,
29382
+ inventory: response.ok ? logicalBackupInventory(response.data, projectRef) ?? undefined : undefined
29383
+ };
29384
+ }
29385
+ function mutationFailureCode2(response) {
29386
+ return response.transportError || response.responseError || response.status === 408 || response.status >= 500 ? "OUTCOME_UNKNOWN" : "HTTP_ERROR";
29387
+ }
29388
+ function identicalBackup(left, right) {
29389
+ return left.backup_id === right.backup_id && left.project_ref === right.project_ref && left.database === right.database && left.kind === right.kind && left.created_at === right.created_at && left.completed_at === right.completed_at && left.bytes === right.bytes && left.sha256 === right.sha256;
29390
+ }
29391
+ function createdBackup(previous, current) {
29392
+ const currentById = new Map(current.map((backup) => [backup.backup_id, backup]));
29393
+ const previousIds = new Set;
29394
+ for (const previousBackup of previous) {
29395
+ previousIds.add(previousBackup.backup_id);
29396
+ const currentBackup = currentById.get(previousBackup.backup_id);
29397
+ if (!currentBackup || !identicalBackup(previousBackup, currentBackup))
29398
+ return null;
29399
+ }
29400
+ const additions = current.filter((backup) => !previousIds.has(backup.backup_id));
29401
+ return additions.length === 1 ? additions[0] : null;
29402
+ }
29403
+ function exactBackupEnvelope(payload, field, projectRef) {
29404
+ return isPlainRecord(payload) ? logicalBackupIdentity(payload[field], projectRef) : null;
29405
+ }
29406
+ function inventoryReadFailure(operation, read) {
29407
+ const { response, inventory } = read;
29408
+ if (!response.ok || response.status !== 200) {
29409
+ const code = response.ok || response.responseError ? "INVALID_RESPONSE" : "HTTP_ERROR";
29410
+ const status = response.transportError ? null : response.status;
29411
+ return logicalBackupFailure(operation, code, status);
29412
+ }
29413
+ return inventory ? null : logicalBackupFailure(operation, "INVALID_RESPONSE", response.status);
29414
+ }
29415
+ function createReceipt(projectRef, previous, mutation, afterRead) {
29416
+ const operation = "platform.create_logical_backup";
29417
+ if (!mutation.ok) {
29418
+ const status = mutation.transportError ? null : mutation.status;
29419
+ return logicalBackupFailure(operation, mutationFailureCode2(mutation), status);
29420
+ }
29421
+ if (mutation.status !== 200)
29422
+ return logicalBackupFailure(operation, "OUTCOME_UNKNOWN", mutation.status);
29423
+ const responseBackup = exactBackupEnvelope(mutation.data, "backup", projectRef);
29424
+ const addedBackup = afterRead.response.ok && afterRead.response.status === 200 && afterRead.inventory ? createdBackup(previous, afterRead.inventory) : null;
29425
+ if (!responseBackup || !addedBackup || !identicalBackup(responseBackup, addedBackup)) {
29426
+ return logicalBackupFailure(operation, "OUTCOME_UNKNOWN", mutation.status);
29427
+ }
29428
+ return logicalBackupSuccess(operation, { project_ref: projectRef, backup: addedBackup });
29429
+ }
29430
+ async function listVerifiedLogicalBackups(http, projectRef) {
29431
+ const operation = "platform.list_logical_backups";
29432
+ const read = await readLogicalBackupInventory(http, projectRef);
29433
+ const failure = inventoryReadFailure(operation, read);
29434
+ return failure ?? logicalBackupSuccess(operation, {
29435
+ project_ref: projectRef,
29436
+ backups: read.inventory
29437
+ });
29438
+ }
29439
+ async function createVerifiedLogicalBackup(http, projectRef) {
29440
+ const operation = "platform.create_logical_backup";
29441
+ const beforeRead = await readLogicalBackupInventory(http, projectRef);
29442
+ const failure = inventoryReadFailure(operation, beforeRead);
29443
+ if (failure)
29444
+ return failure;
29445
+ const mutation = await http.post(logicalBackupEndpoint(projectRef), {}, {
29446
+ timeoutMs: LOGICAL_BACKUP_REQUEST_TIMEOUT_MS,
29447
+ maxJsonBytes: LOGICAL_BACKUP_MUTATION_MAX_BYTES
29448
+ });
29449
+ const afterRead = await readLogicalBackupInventory(http, projectRef);
29450
+ return createReceipt(projectRef, beforeRead.inventory, mutation, afterRead);
29451
+ }
29452
+ function expectedRestoreConfirmation(request) {
29453
+ return [
29454
+ "RESTORE_PROJECT",
29455
+ request.projectRef,
29456
+ request.backupId,
29457
+ request.expectedSha256
29458
+ ].join(":");
29459
+ }
29460
+ function assertRestoreRequest(request) {
29461
+ assertProjectRef(request.projectRef);
29462
+ if (!backupBelongsToProject(request.backupId, request.projectRef)) {
29463
+ throw new Error("'backup_id' must belong to the requested project");
29464
+ }
29465
+ if (!SHA256.test(request.expectedSha256)) {
29466
+ throw new Error("'expected_sha256' must be exactly 64 lowercase hexadecimal characters");
29467
+ }
29468
+ if (request.confirmation !== expectedRestoreConfirmation(request)) {
29469
+ throw new Error("'confirmation' must exactly bind the project ref, backup ID, and SHA-256");
29470
+ }
29471
+ }
29472
+ function restoreReceipt(request, requestedBackup, mutation) {
29473
+ const operation = "platform.restore_logical_backup";
29474
+ if (!mutation.ok) {
29475
+ const status = mutation.transportError ? null : mutation.status;
29476
+ return logicalBackupFailure(operation, mutationFailureCode2(mutation), status);
29477
+ }
29478
+ if (mutation.status !== 200)
29479
+ return logicalBackupFailure(operation, "OUTCOME_UNKNOWN", mutation.status);
29480
+ const restoredBackup = exactBackupEnvelope(mutation.data, "restored_backup", request.projectRef);
29481
+ if (!restoredBackup || !identicalBackup(restoredBackup, requestedBackup)) {
29482
+ return logicalBackupFailure(operation, "OUTCOME_UNKNOWN", mutation.status);
29483
+ }
29484
+ return logicalBackupSuccess(operation, {
29485
+ project_ref: request.projectRef,
29486
+ restored_backup: restoredBackup
29487
+ });
29488
+ }
29489
+ async function restoreVerifiedLogicalBackup(http, request) {
29490
+ const operation = "platform.restore_logical_backup";
29491
+ assertRestoreRequest(request);
29492
+ const beforeRead = await readLogicalBackupInventory(http, request.projectRef);
29493
+ const failure = inventoryReadFailure(operation, beforeRead);
29494
+ if (failure)
29495
+ return failure;
29496
+ const requestedBackup = beforeRead.inventory.find((backup) => backup.backup_id === request.backupId);
29497
+ if (!requestedBackup || requestedBackup.sha256 !== request.expectedSha256) {
29498
+ return logicalBackupFailure(operation, "INVALID_RESPONSE", beforeRead.response.status);
29499
+ }
29500
+ const mutation = await http.post(`${logicalBackupEndpoint(request.projectRef)}/restore`, {
29501
+ backup_id: request.backupId,
29502
+ expected_sha256: request.expectedSha256,
29503
+ confirmation: request.confirmation
29504
+ }, {
29505
+ timeoutMs: LOGICAL_BACKUP_REQUEST_TIMEOUT_MS,
29506
+ maxJsonBytes: LOGICAL_BACKUP_MUTATION_MAX_BYTES
29507
+ });
29508
+ return restoreReceipt(request, requestedBackup, mutation);
29509
+ }
29510
+
29221
29511
  // src/shared/tools/advanced-tools.ts
29222
29512
  function registerAdvancedTools(server, http) {
29223
29513
  server.tool("edge_functions", `Edge Function management (Deno/Bun serverless). Server auto-bundles dependencies.
@@ -29361,11 +29651,14 @@ Actions: list, upsert, delete`, {
29361
29651
  return { content: [{ type: "text", text }] };
29362
29652
  });
29363
29653
  server.tool("platform", `Platform monitoring, backups, network, and organizations.
29364
- Actions: metrics, list_backups, create_backup, network, update_network, list_orgs, get_org`, {
29654
+ Actions: metrics, list_backups, create_backup, list_logical_backups, create_logical_backup, restore_logical_backup, network, update_network, list_orgs, get_org`, {
29365
29655
  action: withDescription(stringEnum([
29366
29656
  "metrics",
29367
29657
  "list_backups",
29368
29658
  "create_backup",
29659
+ "list_logical_backups",
29660
+ "create_logical_backup",
29661
+ "restore_logical_backup",
29369
29662
  "network",
29370
29663
  "update_network",
29371
29664
  "list_orgs",
@@ -29373,10 +29666,22 @@ Actions: metrics, list_backups, create_backup, network, update_network, list_org
29373
29666
  ]), "Action"),
29374
29667
  ref: optional(Type.String(), "[*] Project ref for project-scoped platform actions"),
29375
29668
  backup_type: optional(stringEnum(["full"]), "[create_backup] Explicit physical backup type"),
29669
+ backup_id: optional(Type.String(), "[restore_logical_backup] Verified logical backup ID"),
29670
+ expected_sha256: optional(Type.String(), "[restore_logical_backup] Exact lowercase backup SHA-256"),
29671
+ confirmation: optional(Type.String(), "[restore_logical_backup] Exact RESTORE_PROJECT:<ref>:<backup_id>:<sha256> confirmation"),
29376
29672
  slug: optional(Type.String(), "[get_org] Organization slug"),
29377
29673
  allowed_cidrs: optional(Type.Array(Type.String()), "[update_network] Allowed CIDRs")
29378
29674
  }, async (args) => {
29379
- const { action, ref, backup_type, slug, allowed_cidrs } = args;
29675
+ const {
29676
+ action,
29677
+ ref,
29678
+ backup_type,
29679
+ backup_id,
29680
+ expected_sha256,
29681
+ confirmation,
29682
+ slug,
29683
+ allowed_cidrs
29684
+ } = args;
29380
29685
  const need = (f, v) => {
29381
29686
  if (!v)
29382
29687
  throw new Error(`'${f}' required for '${action}'`);
@@ -29394,6 +29699,23 @@ Actions: metrics, list_backups, create_backup, network, update_network, list_org
29394
29699
  need("backup_type", backup_type);
29395
29700
  return createFullPhysicalBackup(http, ref);
29396
29701
  }
29702
+ case "list_logical_backups":
29703
+ need("ref", ref);
29704
+ return listVerifiedLogicalBackups(http, ref);
29705
+ case "create_logical_backup":
29706
+ need("ref", ref);
29707
+ return createVerifiedLogicalBackup(http, ref);
29708
+ case "restore_logical_backup":
29709
+ need("ref", ref);
29710
+ need("backup_id", backup_id);
29711
+ need("expected_sha256", expected_sha256);
29712
+ need("confirmation", confirmation);
29713
+ return restoreVerifiedLogicalBackup(http, {
29714
+ projectRef: ref,
29715
+ backupId: backup_id,
29716
+ expectedSha256: expected_sha256,
29717
+ confirmation
29718
+ });
29397
29719
  case "network":
29398
29720
  need("ref", ref);
29399
29721
  text = JSON.stringify((await http.get(`/v1/projects/${ref}/network-restrictions`)).data, null, 2);
@@ -29898,7 +30220,7 @@ function matchingText(candidate, maxLength, pattern) {
29898
30220
  const candidateText = boundedText(candidate, maxLength);
29899
30221
  return candidateText && pattern.test(candidateText) ? candidateText : null;
29900
30222
  }
29901
- function canonicalTimestamp(candidate) {
30223
+ function canonicalTimestamp2(candidate) {
29902
30224
  const timestamp = boundedText(candidate, 64);
29903
30225
  if (!timestamp)
29904
30226
  return null;
@@ -29913,7 +30235,7 @@ function projectedSummary(project) {
29913
30235
  organization_slug: matchingText(project.organization_slug, 128, SAFE_IDENTIFIER_PATTERN),
29914
30236
  name: boundedText(project.name, 100),
29915
30237
  region: matchingText(project.region, 64, REGION_PATTERN),
29916
- created_at: canonicalTimestamp(project.created_at),
30238
+ created_at: canonicalTimestamp2(project.created_at),
29917
30239
  status: matchingText(project.status, 64, STATUS_PATTERN)
29918
30240
  };
29919
30241
  return Object.values(summary).every((field) => field !== null) ? summary : null;
@@ -30057,7 +30379,7 @@ function projectGetRead(response, expectedRef) {
30057
30379
  // src/shared/tools/project-runtime-snapshot.ts
30058
30380
  var RUNTIME_SNAPSHOT_SCHEMA = "supacloud.runtime-snapshot.v1";
30059
30381
  var ATTESTED_REVISION_PATTERN = /^hmac-sha256:[a-f0-9]{64}$/;
30060
- var SAFE_PROJECT_REF2 = /^[a-z0-9-]{1,20}$/;
30382
+ var SAFE_PROJECT_REF3 = /^[a-z0-9-]{1,20}$/;
30061
30383
  var SNAPSHOT_KEYS = ["schema", "project_ref", "captured_at", "secrets", "postgrest"];
30062
30384
  var SECRETS_KEYS = [
30063
30385
  "desired_revision",
@@ -30194,7 +30516,7 @@ function hasCausalLoadTimestamps(snapshot) {
30194
30516
  return [snapshot.secrets.loaded_at, snapshot.postgrest.loaded_at].every((loadedAt) => loadedAt === null || Date.parse(loadedAt) <= capturedAt);
30195
30517
  }
30196
30518
  function parseProjectRuntimeSnapshot(payload, requestedProjectRef2) {
30197
- if (!SAFE_PROJECT_REF2.test(requestedProjectRef2) || !isRecord2(payload) || !hasExactKeys(payload, SNAPSHOT_KEYS) || payload.schema !== RUNTIME_SNAPSHOT_SCHEMA || payload.project_ref !== requestedProjectRef2 || !isIsoTimestampOrNull(payload.captured_at) || payload.captured_at === null || !isRuntimeSecretsSnapshot(payload.secrets) || !isPostgrestRuntimeSnapshot(payload.postgrest, requestedProjectRef2))
30519
+ if (!SAFE_PROJECT_REF3.test(requestedProjectRef2) || !isRecord2(payload) || !hasExactKeys(payload, SNAPSHOT_KEYS) || payload.schema !== RUNTIME_SNAPSHOT_SCHEMA || payload.project_ref !== requestedProjectRef2 || !isIsoTimestampOrNull(payload.captured_at) || payload.captured_at === null || !isRuntimeSecretsSnapshot(payload.secrets) || !isPostgrestRuntimeSnapshot(payload.postgrest, requestedProjectRef2))
30198
30520
  return null;
30199
30521
  const snapshot = payload;
30200
30522
  return hasCausalLoadTimestamps(snapshot) ? sanitizedSnapshot(snapshot) : null;
@@ -30231,11 +30553,11 @@ var STUDIO_PROJECT_SERVICE_STATUSES = [
30231
30553
  ];
30232
30554
  var AUTH_RUNTIME_MANAGED_BY_OWNER = "AUTH_RUNTIME_MANAGED_BY_OWNER";
30233
30555
  var AUTH_SERVICE_HOST_SUFFIX = "-auth";
30234
- var SAFE_PROJECT_REF3 = /^[a-z0-9-]{1,20}$/;
30556
+ var SAFE_PROJECT_REF4 = /^[a-z0-9-]{1,20}$/;
30235
30557
  var SAFE_AUTHORITY_PROJECT_REF = /^[A-Za-z0-9_-]{1,20}$/;
30236
30558
  var MAX_SERVICE_CONTROL_MESSAGE_LENGTH = 256;
30237
30559
  var MAX_RUNTIME_SNAPSHOT_BYTES = 64 * 1024;
30238
- var RELEASE_CONTROL_RESPONSE_SCHEMA = "supacloud.cli.release-control.v1";
30560
+ var RELEASE_CONTROL_RESPONSE_SCHEMA2 = "supacloud.cli.release-control.v1";
30239
30561
  var PROJECT_CREATE_OPERATION = "project.create";
30240
30562
  var PROJECT_ENVIRONMENTS = ["test", "production"];
30241
30563
  var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -30263,7 +30585,7 @@ function failedProjectServiceResponse(message) {
30263
30585
  function projectCreateReceipt(payload) {
30264
30586
  return {
30265
30587
  content: [{ type: "text", text: JSON.stringify({
30266
- schema: RELEASE_CONTROL_RESPONSE_SCHEMA,
30588
+ schema: RELEASE_CONTROL_RESPONSE_SCHEMA2,
30267
30589
  ...payload
30268
30590
  }, null, 2) }]
30269
30591
  };
@@ -30448,7 +30770,7 @@ function projectServiceStatusOutput(status) {
30448
30770
  function projectServicesResponse(projectRef, response) {
30449
30771
  if (!response.ok)
30450
30772
  return failedProjectServiceHttpResponse(response);
30451
- if (!SAFE_PROJECT_REF3.test(projectRef) || !Array.isArray(response.data) || response.data.length !== 5) {
30773
+ if (!SAFE_PROJECT_REF4.test(projectRef) || !Array.isArray(response.data) || response.data.length !== 5) {
30452
30774
  return failedProjectServiceResponse("Project service inventory response is invalid");
30453
30775
  }
30454
30776
  if (!response.data.every((service) => isProjectServiceStatus(service, projectRef))) {
@@ -31046,10 +31368,484 @@ Actions: routes, upsert_route, update_route, delete_route, config, get_certifica
31046
31368
  return { content: [{ type: "text", text }] };
31047
31369
  });
31048
31370
  }
31371
+
31372
+ // src/shared/tools/frontend-release-control.ts
31373
+ import { createHash as createHash3 } from "node:crypto";
31374
+ import { constants as fsConstants2 } from "node:fs";
31375
+ import { open as open2 } from "node:fs/promises";
31376
+ import { resolve as resolve3 } from "node:path";
31377
+ var PROJECT_REF_PATTERN2 = /^[A-Za-z0-9_-]{1,20}$/u;
31378
+ var DEPLOYMENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/u;
31379
+ var RELEASE_ID_PATTERN = /^[0-9a-f]{64}$/u;
31380
+ var MUTATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
31381
+ var TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
31382
+ var ARCHIVE_MAX_BYTES = 100 * 1024 * 1024;
31383
+ var ARCHIVE_CHUNK_BYTES = 64 * 1024;
31384
+ var RESPONSE_MAX_BYTES = 1024 * 1024;
31385
+ var UPLOAD_REQUEST_TIMEOUT_MS = 10 * 60000;
31386
+ var RELEASE_LIST_LIMIT_MAX = 100;
31387
+ function toolResponse(payload) {
31388
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31389
+ }
31390
+ function releaseFailure(operation, code, status) {
31391
+ return {
31392
+ isError: true,
31393
+ content: [{
31394
+ type: "text",
31395
+ text: JSON.stringify({ ok: false, operation, error: { code, http_status: status } })
31396
+ }]
31397
+ };
31398
+ }
31399
+ function exactKeys(candidate, keys) {
31400
+ const actual = Object.keys(candidate).sort();
31401
+ const expected = [...keys].sort();
31402
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
31403
+ }
31404
+ function releaseRecord(candidate) {
31405
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
31406
+ return null;
31407
+ const record = candidate;
31408
+ const keys = [
31409
+ "schema",
31410
+ "project_ref",
31411
+ "deployment_id",
31412
+ "release_id",
31413
+ "sha256",
31414
+ "tree_sha256",
31415
+ "size_bytes",
31416
+ "file_count",
31417
+ "created_at",
31418
+ "kind"
31419
+ ];
31420
+ if (!exactKeys(record, keys) || record.schema !== "supacloud.frontend-release.v1" || typeof record.project_ref !== "string" || !PROJECT_REF_PATTERN2.test(record.project_ref) || typeof record.deployment_id !== "string" || !DEPLOYMENT_ID_PATTERN.test(record.deployment_id) || typeof record.release_id !== "string" || !RELEASE_ID_PATTERN.test(record.release_id) || record.sha256 !== record.release_id || typeof record.tree_sha256 !== "string" || !RELEASE_ID_PATTERN.test(record.tree_sha256) || !Number.isSafeInteger(record.size_bytes) || Number(record.size_bytes) < 1 || !Number.isSafeInteger(record.file_count) || Number(record.file_count) < 1 || !canonicalTimestamp3(record.created_at) || record.kind !== "prebuilt_static")
31421
+ return null;
31422
+ return {
31423
+ project_ref: record.project_ref,
31424
+ deployment_id: record.deployment_id,
31425
+ release_id: record.release_id,
31426
+ sha256: record.release_id,
31427
+ tree_sha256: record.tree_sha256,
31428
+ size_bytes: Number(record.size_bytes),
31429
+ file_count: Number(record.file_count),
31430
+ created_at: record.created_at,
31431
+ kind: "prebuilt_static"
31432
+ };
31433
+ }
31434
+ function releaseEnvelope(candidate, expected) {
31435
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
31436
+ return null;
31437
+ const envelope = candidate;
31438
+ const release = exactKeys(envelope, ["project_ref", "deployment_id", "release"]) ? releaseRecord(envelope.release) : null;
31439
+ if (!release || envelope.project_ref !== expected.projectRef || envelope.deployment_id !== expected.deploymentId || release.project_ref !== expected.projectRef || release.deployment_id !== expected.deploymentId || release.release_id !== expected.releaseId)
31440
+ return null;
31441
+ return release;
31442
+ }
31443
+ function canonicalTimestamp3(candidate) {
31444
+ if (typeof candidate !== "string" || !TIMESTAMP_PATTERN.test(candidate))
31445
+ return false;
31446
+ const milliseconds = Date.parse(candidate);
31447
+ return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === candidate;
31448
+ }
31449
+ function nullableIdentity(candidate, pattern) {
31450
+ if (candidate === null)
31451
+ return null;
31452
+ return typeof candidate === "string" && pattern.test(candidate) ? candidate : undefined;
31453
+ }
31454
+ function releaseInventory(candidate, expected) {
31455
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
31456
+ return null;
31457
+ const record = candidate;
31458
+ const keys = [
31459
+ "project_ref",
31460
+ "deployment_id",
31461
+ "active_release_id",
31462
+ "active_activation_id",
31463
+ "releases",
31464
+ "next_cursor"
31465
+ ];
31466
+ if (!exactKeys(record, keys) || typeof record.project_ref !== "string" || !PROJECT_REF_PATTERN2.test(record.project_ref) || typeof record.deployment_id !== "string" || !DEPLOYMENT_ID_PATTERN.test(record.deployment_id) || !Array.isArray(record.releases))
31467
+ return null;
31468
+ const activeReleaseId = nullableIdentity(record.active_release_id, RELEASE_ID_PATTERN);
31469
+ const activeActivationId = nullableIdentity(record.active_activation_id, MUTATION_ID_PATTERN);
31470
+ const nextCursor = nullableIdentity(record.next_cursor, RELEASE_ID_PATTERN);
31471
+ const releases = record.releases.map(releaseRecord);
31472
+ if (activeReleaseId === undefined || activeActivationId === undefined || nextCursor === undefined || activeReleaseId === null !== (activeActivationId === null) || releases.some((release) => release === null))
31473
+ return null;
31474
+ const verified = releases;
31475
+ if (new Set(verified.map((release) => release.release_id)).size !== verified.length || verified.some((release) => release.project_ref !== record.project_ref || release.deployment_id !== record.deployment_id) || expected && (record.project_ref !== expected.projectRef || record.deployment_id !== expected.deploymentId))
31476
+ return null;
31477
+ return {
31478
+ project_ref: record.project_ref,
31479
+ deployment_id: record.deployment_id,
31480
+ active_release_id: activeReleaseId,
31481
+ active_activation_id: activeActivationId,
31482
+ releases: verified,
31483
+ next_cursor: nextCursor
31484
+ };
31485
+ }
31486
+ function releaseEndpoint(projectRef, deploymentId) {
31487
+ if (!PROJECT_REF_PATTERN2.test(projectRef))
31488
+ throw new Error("'ref' is invalid for frontend releases");
31489
+ if (!DEPLOYMENT_ID_PATTERN.test(deploymentId))
31490
+ throw new Error("'id' is invalid for frontend releases");
31491
+ return `/v1/projects/${encodeURIComponent(projectRef)}/frontend/deployments/${encodeURIComponent(deploymentId)}/releases`;
31492
+ }
31493
+ function releasePath(projectRef, deploymentId, releaseId) {
31494
+ if (!RELEASE_ID_PATTERN.test(releaseId))
31495
+ throw new Error("'release_id' must be a SHA-256 digest");
31496
+ return `${releaseEndpoint(projectRef, deploymentId)}/${releaseId}`;
31497
+ }
31498
+ function sameArchiveIdentity(left, right) {
31499
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
31500
+ }
31501
+ async function archiveSha256(handle, sizeBytes) {
31502
+ const hash2 = createHash3("sha256");
31503
+ const chunk = new Uint8Array(Math.min(sizeBytes, ARCHIVE_CHUNK_BYTES));
31504
+ for (let offset = 0;offset < sizeBytes; ) {
31505
+ const requested = Math.min(chunk.byteLength, sizeBytes - offset);
31506
+ const { bytesRead } = await handle.read(chunk, 0, requested, offset);
31507
+ if (bytesRead < 1)
31508
+ throw new Error("Frontend release archive changed while it was hashed");
31509
+ hash2.update(chunk.subarray(0, bytesRead));
31510
+ offset += bytesRead;
31511
+ }
31512
+ return hash2.digest("hex");
31513
+ }
31514
+ async function verifiedArchive(path2) {
31515
+ const archivePath = resolve3(path2);
31516
+ const handle = await open2(archivePath, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
31517
+ try {
31518
+ const before = await handle.stat({ bigint: true });
31519
+ const sizeBytes = Number(before.size);
31520
+ if (!before.isFile() || sizeBytes < 1 || sizeBytes > ARCHIVE_MAX_BYTES) {
31521
+ throw new Error(`Frontend release archive must be a 1-${ARCHIVE_MAX_BYTES} byte regular file`);
31522
+ }
31523
+ const sha2562 = await archiveSha256(handle, sizeBytes);
31524
+ const after = await handle.stat({ bigint: true });
31525
+ if (!sameArchiveIdentity(before, after)) {
31526
+ throw new Error("Frontend release archive identity changed while it was hashed");
31527
+ }
31528
+ return { handle, sizeBytes, sha256: sha2562 };
31529
+ } catch (error) {
31530
+ await handle.close();
31531
+ throw error;
31532
+ }
31533
+ }
31534
+ function archiveStream(archive) {
31535
+ let offset = 0;
31536
+ return new ReadableStream({
31537
+ async pull(controller) {
31538
+ if (offset === archive.sizeBytes) {
31539
+ controller.close();
31540
+ return;
31541
+ }
31542
+ const length = Math.min(ARCHIVE_CHUNK_BYTES, archive.sizeBytes - offset);
31543
+ const chunk = new Uint8Array(length);
31544
+ const { bytesRead } = await archive.handle.read(chunk, 0, length, offset);
31545
+ if (bytesRead < 1)
31546
+ throw new Error("Frontend release archive changed while it was uploaded");
31547
+ offset += bytesRead;
31548
+ controller.enqueue(bytesRead === length ? chunk : chunk.subarray(0, bytesRead));
31549
+ }
31550
+ });
31551
+ }
31552
+ function releaseReadFailure(operation, response) {
31553
+ return releaseFailure(operation, response.ok ? "INVALID_RESPONSE" : "HTTP_ERROR", response.status);
31554
+ }
31555
+ async function listFrontendReleases(http, projectRef, deploymentId, cursor, limit = 50) {
31556
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > RELEASE_LIST_LIMIT_MAX) {
31557
+ throw new Error("'limit' must be 1-100");
31558
+ }
31559
+ if (cursor !== undefined && !RELEASE_ID_PATTERN.test(cursor))
31560
+ throw new Error("'cursor' is invalid");
31561
+ const query = new URLSearchParams({ limit: String(limit) });
31562
+ if (cursor)
31563
+ query.set("cursor", cursor);
31564
+ const response = await http.get(`${releaseEndpoint(projectRef, deploymentId)}?${query}`, {
31565
+ maxJsonBytes: RESPONSE_MAX_BYTES
31566
+ });
31567
+ const inventory = response.ok ? releaseInventory(response.data, { projectRef, deploymentId }) : null;
31568
+ if (!inventory || inventory.releases.length > limit) {
31569
+ return releaseReadFailure("frontend.list_releases", response);
31570
+ }
31571
+ return toolResponse(inventory);
31572
+ }
31573
+ async function getFrontendRelease(http, projectRef, deploymentId, releaseId) {
31574
+ const response = await http.get(releasePath(projectRef, deploymentId, releaseId), {
31575
+ maxJsonBytes: RESPONSE_MAX_BYTES
31576
+ });
31577
+ const release = response.ok ? releaseEnvelope(response.data, { projectRef, deploymentId, releaseId }) : null;
31578
+ if (!release) {
31579
+ return releaseReadFailure("frontend.get_release", response);
31580
+ }
31581
+ return toolResponse({ project_ref: projectRef, deployment_id: deploymentId, release });
31582
+ }
31583
+ async function uploadFrontendRelease(http, projectRef, deploymentId, archivePath) {
31584
+ const endpoint = releaseEndpoint(projectRef, deploymentId);
31585
+ const archive = await verifiedArchive(archivePath);
31586
+ let response;
31587
+ try {
31588
+ response = await http.postBinary(endpoint, {
31589
+ stream: archiveStream(archive),
31590
+ byteLength: archive.sizeBytes
31591
+ }, {
31592
+ contentType: "application/zip",
31593
+ contentLength: archive.sizeBytes,
31594
+ contentSha256: archive.sha256,
31595
+ maxJsonBytes: RESPONSE_MAX_BYTES,
31596
+ timeoutMs: UPLOAD_REQUEST_TIMEOUT_MS
31597
+ });
31598
+ } finally {
31599
+ await archive.handle.close();
31600
+ }
31601
+ const release = response.ok ? releaseEnvelope(response.data, { projectRef, deploymentId, releaseId: archive.sha256 }) : null;
31602
+ if (!release) {
31603
+ if (response.status >= 400 && response.status < 500 && response.status !== 408 && !response.transportError) {
31604
+ return releaseFailure("frontend.upload_release", "HTTP_ERROR", response.status);
31605
+ }
31606
+ return uploadReadback(http, projectRef, deploymentId, archive.sha256, response.status);
31607
+ }
31608
+ const readback = await http.get(`${endpoint}/${release.release_id}`, { maxJsonBytes: RESPONSE_MAX_BYTES });
31609
+ const verified = readback.ok ? releaseEnvelope(readback.data, { projectRef, deploymentId, releaseId: release.release_id }) : null;
31610
+ if (!verified || verified.tree_sha256 !== release.tree_sha256) {
31611
+ return releaseFailure("frontend.upload_release", "OUTCOME_UNKNOWN", readback.status);
31612
+ }
31613
+ return toolResponse({ project_ref: projectRef, deployment_id: deploymentId, release: verified });
31614
+ }
31615
+ async function uploadReadback(http, projectRef, deploymentId, releaseId, uploadStatus) {
31616
+ const response = await http.get(releasePath(projectRef, deploymentId, releaseId), {
31617
+ maxJsonBytes: RESPONSE_MAX_BYTES
31618
+ });
31619
+ const release = response.ok ? releaseEnvelope(response.data, { projectRef, deploymentId, releaseId }) : null;
31620
+ if (!release) {
31621
+ return releaseFailure("frontend.upload_release", "OUTCOME_UNKNOWN", uploadStatus);
31622
+ }
31623
+ return toolResponse({ project_ref: projectRef, deployment_id: deploymentId, release });
31624
+ }
31625
+ function stableJson(candidate) {
31626
+ if (candidate === null || typeof candidate !== "object")
31627
+ return JSON.stringify(candidate);
31628
+ if (Array.isArray(candidate))
31629
+ return `[${candidate.map(stableJson).join(",")}]`;
31630
+ const record = candidate;
31631
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
31632
+ }
31633
+ function activationFingerprint(identity) {
31634
+ return createHash3("sha256").update(stableJson({
31635
+ project_ref: identity.projectRef,
31636
+ deployment_id: identity.deploymentId,
31637
+ release_id: identity.releaseId,
31638
+ expected_active_release_id: identity.expectedActiveReleaseId,
31639
+ activation_id: identity.mutationId,
31640
+ expected_activation_id: identity.expectedActivationId
31641
+ })).digest("hex");
31642
+ }
31643
+ function activationResourceKey(deploymentId) {
31644
+ return `v1/frontend_release/${Buffer.from(deploymentId, "utf8").toString("base64url")}`;
31645
+ }
31646
+ function publicMutationReceipt(candidate, expected) {
31647
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
31648
+ return null;
31649
+ const envelope = candidate;
31650
+ const mutation = envelope.mutation;
31651
+ const expectedKeys = [
31652
+ "project_ref",
31653
+ "mutation_id",
31654
+ "operation",
31655
+ "resource_key",
31656
+ "request_fingerprint",
31657
+ "principal",
31658
+ "status",
31659
+ "checkpoint",
31660
+ "receipt",
31661
+ "response_status",
31662
+ "failure_code",
31663
+ "lease",
31664
+ "completed_at",
31665
+ "created_at",
31666
+ "updated_at"
31667
+ ];
31668
+ if (!mutation || !exactKeys(envelope, ["project_ref", "mutation"]) || !exactKeys(mutation, expectedKeys) || envelope.project_ref !== expected.projectRef || mutation.project_ref !== expected.projectRef || mutation.mutation_id !== expected.mutationId || mutation.resource_key !== expected.resourceKey || mutation.request_fingerprint !== expected.requestFingerprint || typeof mutation.operation !== "string" || typeof mutation.status !== "string" || !(mutation.response_status === null || Number.isSafeInteger(mutation.response_status)) || !(mutation.failure_code === null || typeof mutation.failure_code === "string"))
31669
+ return null;
31670
+ return {
31671
+ operation: mutation.operation,
31672
+ status: mutation.status,
31673
+ responseStatus: mutation.response_status,
31674
+ failureCode: mutation.failure_code
31675
+ };
31676
+ }
31677
+ function activationReceipt(candidate, input) {
31678
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
31679
+ return null;
31680
+ const record = candidate;
31681
+ const release = releaseRecord(record.release);
31682
+ const mutation = record.mutation;
31683
+ if (!release || !mutation || !exactKeys(record, [
31684
+ "project_ref",
31685
+ "deployment_id",
31686
+ "active_release_id",
31687
+ "activation_id",
31688
+ "release",
31689
+ "mutation"
31690
+ ]) || !exactKeys(mutation, ["mutation_id", "status", "replayed"]) || record.project_ref !== input.projectRef || record.deployment_id !== input.deploymentId || record.active_release_id !== input.releaseId || record.activation_id !== input.mutationId || release.project_ref !== input.projectRef || release.deployment_id !== input.deploymentId || release.release_id !== input.releaseId || mutation.mutation_id !== input.mutationId || mutation.status !== "succeeded" || typeof mutation.replayed !== "boolean")
31691
+ return null;
31692
+ return release;
31693
+ }
31694
+ async function activeReleaseReadback(http, identity) {
31695
+ const endpoint = releaseEndpoint(identity.projectRef, identity.deploymentId);
31696
+ const inventoryRead = await http.get(`${endpoint}?limit=${RELEASE_LIST_LIMIT_MAX}`, {
31697
+ maxJsonBytes: RESPONSE_MAX_BYTES
31698
+ });
31699
+ const inventory = inventoryRead.ok ? releaseInventory(inventoryRead.data, {
31700
+ projectRef: identity.projectRef,
31701
+ deploymentId: identity.deploymentId
31702
+ }) : null;
31703
+ if (!inventory || inventory.releases.length > RELEASE_LIST_LIMIT_MAX || inventory.active_release_id !== identity.releaseId || inventory.active_activation_id !== identity.mutationId) {
31704
+ return { release: null, status: inventoryRead.status };
31705
+ }
31706
+ const releaseRead = await http.get(releasePath(identity.projectRef, identity.deploymentId, identity.releaseId), { maxJsonBytes: RESPONSE_MAX_BYTES });
31707
+ const release = releaseRead.ok ? releaseEnvelope(releaseRead.data, {
31708
+ projectRef: identity.projectRef,
31709
+ deploymentId: identity.deploymentId,
31710
+ releaseId: identity.releaseId
31711
+ }) : null;
31712
+ return { release, status: releaseRead.status };
31713
+ }
31714
+ async function activateFrontendRelease(http, input) {
31715
+ const mutationId = input.mutationId;
31716
+ if (!MUTATION_ID_PATTERN.test(mutationId))
31717
+ throw new Error("'mutation_id' must be a UUIDv4");
31718
+ if (input.expectedActiveReleaseId !== "absent" && !RELEASE_ID_PATTERN.test(input.expectedActiveReleaseId)) {
31719
+ throw new Error("'expected_active_release_id' is invalid");
31720
+ }
31721
+ if (input.expectedActivationId !== "absent" && !MUTATION_ID_PATTERN.test(input.expectedActivationId)) {
31722
+ throw new Error("'expected_activation_id' is invalid");
31723
+ }
31724
+ const endpoint = `${releasePath(input.projectRef, input.deploymentId, input.releaseId)}/activate`;
31725
+ const response = await http.post(endpoint, {
31726
+ expected_active_release_id: input.expectedActiveReleaseId,
31727
+ expected_activation_id: input.expectedActivationId,
31728
+ mutation_id: mutationId
31729
+ }, { maxJsonBytes: RESPONSE_MAX_BYTES });
31730
+ const release = activationReceipt(response.data, { ...input, mutationId });
31731
+ if (!response.ok || !release) {
31732
+ if (response.status >= 400 && response.status < 500 && response.status !== 408 && !response.transportError) {
31733
+ return releaseFailure("frontend.activate_release", "HTTP_ERROR", response.status);
31734
+ }
31735
+ return activationReadback(http, input, mutationId, response.status);
31736
+ }
31737
+ const readback = await activeReleaseReadback(http, { ...input, mutationId });
31738
+ if (!readback.release || readback.release.tree_sha256 !== release.tree_sha256) {
31739
+ return releaseFailure("frontend.activate_release", "OUTCOME_UNKNOWN", readback.status);
31740
+ }
31741
+ return toolResponse({
31742
+ project_ref: input.projectRef,
31743
+ deployment_id: input.deploymentId,
31744
+ active_release_id: input.releaseId,
31745
+ activation_id: mutationId,
31746
+ release: readback.release
31747
+ });
31748
+ }
31749
+ async function activationReadback(http, input, mutationId, activationStatus) {
31750
+ const mutationRead = await http.get(`/v1/projects/${encodeURIComponent(input.projectRef)}/mutations/${mutationId}`, { maxJsonBytes: RESPONSE_MAX_BYTES });
31751
+ const mutationEnvelope = mutationRead.data;
31752
+ const mutation = mutationRead.ok ? publicMutationReceipt(mutationRead.data, {
31753
+ projectRef: input.projectRef,
31754
+ mutationId,
31755
+ resourceKey: activationResourceKey(input.deploymentId),
31756
+ requestFingerprint: activationFingerprint({ ...input, mutationId })
31757
+ }) : null;
31758
+ if (!mutation || mutationEnvelope?.project_ref !== input.projectRef || mutation.operation !== "frontend.release.activate" || mutation.status !== "succeeded" || mutation.responseStatus !== 200 || mutation.failureCode !== null) {
31759
+ return releaseFailure("frontend.activate_release", "OUTCOME_UNKNOWN", activationStatus);
31760
+ }
31761
+ const readback = await activeReleaseReadback(http, { ...input, mutationId });
31762
+ if (!readback.release) {
31763
+ return releaseFailure("frontend.activate_release", "OUTCOME_UNKNOWN", activationStatus);
31764
+ }
31765
+ return toolResponse({
31766
+ project_ref: input.projectRef,
31767
+ deployment_id: input.deploymentId,
31768
+ active_release_id: input.releaseId,
31769
+ activation_id: mutationId,
31770
+ release: readback.release
31771
+ });
31772
+ }
31773
+
31774
+ // src/shared/tools/frontend-tools.ts
31775
+ function registerFrontendTools(server, http) {
31776
+ server.tool("frontend", `Immutable prebuilt frontend release control.
31777
+ Actions: list_releases, get_release, upload_release, activate_release`, {
31778
+ action: withDescription(stringEnum([
31779
+ "list_releases",
31780
+ "get_release",
31781
+ "upload_release",
31782
+ "activate_release"
31783
+ ]), "Action"),
31784
+ ref: optional(Type.String(), "Project ref"),
31785
+ id: optional(Type.String(), "Deployment ID"),
31786
+ zip_path: optional(Type.String(), "[upload_release] Local ZIP file path"),
31787
+ release_id: optional(Type.String(), "[get_release/activate_release] SHA-256 release ID"),
31788
+ expected_active_release_id: optional(Type.String(), "[activate_release] Current release SHA-256 or absent"),
31789
+ expected_activation_id: optional(Type.String(), "[activate_release] Current activation UUIDv4 or absent"),
31790
+ mutation_id: optional(Type.String(), "[activate_release] Required retry-stable UUIDv4"),
31791
+ cursor: optional(Type.String(), "[list_releases] Last release SHA-256 cursor"),
31792
+ limit: optional(Type.Number(), "[list_releases] Page size, 1-100 (default 50)")
31793
+ }, async (args) => {
31794
+ const {
31795
+ action,
31796
+ ref,
31797
+ id,
31798
+ zip_path,
31799
+ release_id,
31800
+ expected_active_release_id,
31801
+ expected_activation_id,
31802
+ mutation_id,
31803
+ cursor,
31804
+ limit
31805
+ } = args;
31806
+ const need = (f, v) => {
31807
+ if (!v)
31808
+ throw new Error(`'${f}' required for '${action}'`);
31809
+ };
31810
+ switch (action) {
31811
+ case "list_releases":
31812
+ need("ref", ref);
31813
+ need("id", id);
31814
+ return listFrontendReleases(http, ref, id, cursor, limit);
31815
+ case "get_release":
31816
+ need("ref", ref);
31817
+ need("id", id);
31818
+ need("release_id", release_id);
31819
+ return getFrontendRelease(http, ref, id, release_id);
31820
+ case "upload_release":
31821
+ need("ref", ref);
31822
+ need("id", id);
31823
+ need("zip_path", zip_path);
31824
+ return uploadFrontendRelease(http, ref, id, zip_path);
31825
+ case "activate_release":
31826
+ need("ref", ref);
31827
+ need("id", id);
31828
+ need("release_id", release_id);
31829
+ need("expected_active_release_id", expected_active_release_id);
31830
+ need("expected_activation_id", expected_activation_id);
31831
+ need("mutation_id", mutation_id);
31832
+ return activateFrontendRelease(http, {
31833
+ projectRef: ref,
31834
+ deploymentId: id,
31835
+ releaseId: release_id,
31836
+ expectedActiveReleaseId: expected_active_release_id,
31837
+ expectedActivationId: expected_activation_id,
31838
+ mutationId: mutation_id
31839
+ });
31840
+ default:
31841
+ throw new Error("Unknown immutable frontend release action");
31842
+ }
31843
+ });
31844
+ }
31049
31845
  // package.json
31050
31846
  var package_default = {
31051
31847
  name: "@supacloud/admin",
31052
- version: "0.12.0",
31848
+ version: "0.14.0",
31053
31849
  description: "Platform administration CLI for SupaCloud operators",
31054
31850
  type: "module",
31055
31851
  main: "./dist/index.js",
@@ -31119,6 +31915,7 @@ function unavailableAdminSchemas() {
31119
31915
  project: captureTools((server) => registerAdminProjectCliTools(server, schemaOnlyHttp)).project.schema,
31120
31916
  platform: captureTools((server) => registerAdvancedTools(server, schemaOnlyHttp)).platform.schema,
31121
31917
  gateway: captureTools((server) => registerGatewayTools(server, schemaOnlyHttp)).gateway.schema,
31918
+ frontend: captureTools((server) => registerFrontendTools(server, schemaOnlyHttp)).frontend.schema,
31122
31919
  ssh: captureTools((server) => registerSshTools(server, {})).ssh.schema
31123
31920
  };
31124
31921
  }
@@ -31128,6 +31925,7 @@ function registerUnavailableAdminTools(tools) {
31128
31925
  tools.platform = unavailableTool(schemas.platform, "⚠️ Platform commands require SUPACLOUD_API_URL and SUPACLOUD_API_TOKEN.");
31129
31926
  tools.ssh = unavailableTool(schemas.ssh, "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT.");
31130
31927
  tools.gateway = unavailableTool(schemas.gateway, "⚠️ Gateway / Caddy commands require SUPACLOUD_API_URL and SUPACLOUD_API_TOKEN (admin privileges).");
31928
+ tools.frontend = unavailableTool(schemas.frontend, "⚠️ Frontend commands require SUPACLOUD_API_URL and SUPACLOUD_API_TOKEN.");
31131
31929
  }
31132
31930
  function printHelp(context = resolveSupaCloudContext()) {
31133
31931
  console.error(`
@@ -31184,10 +31982,16 @@ EXAMPLES
31184
31982
  supacloud-admin project runtime_snapshot --ref abc123
31185
31983
  supacloud-admin project service_control --ref abc123 --service gotrue --service_action stop
31186
31984
  supacloud-admin platform metrics
31985
+ supacloud-admin platform list_logical_backups --ref abc123
31986
+ supacloud-admin platform create_logical_backup --ref abc123
31987
+ supacloud-admin platform restore_logical_backup --ref abc123 --backup_id BACKUP_ID --expected_sha256 SHA256 --confirmation RESTORE_PROJECT:abc123:BACKUP_ID:SHA256
31187
31988
  supacloud-admin gateway routes --ref abc123
31188
31989
  supacloud-admin gateway upsert_route --ref abc123 --route_id webhook --hosts "api.example.com" --paths "/webhook/*" --upstream 10.0.0.5:8080
31189
31990
  supacloud-admin gateway config --ref abc123 --rate_limit_tier pro
31190
31991
  supacloud-admin gateway rebuild --ref abc123 --clean
31992
+ supacloud-admin frontend list_releases --ref abc123 --id web
31993
+ supacloud-admin frontend upload_release --ref abc123 --id web --zip_path /secure/site.zip
31994
+ supacloud-admin frontend activate_release --ref abc123 --id web --release_id <sha256> --expected_active_release_id absent --expected_activation_id absent --mutation_id <uuid-v4>
31191
31995
  `);
31192
31996
  }
31193
31997
  function authorizedToolMap(tools, context, confirmProduction) {
@@ -31275,6 +32079,7 @@ function createAdminTools(context = resolveSupaCloudContext(), confirmProduction
31275
32079
  });
31276
32080
  Object.assign(tools, captureTools((server) => registerAdminProjectCliTools(server, http)));
31277
32081
  Object.assign(tools, captureTools((server) => registerGatewayTools(server, http)));
32082
+ Object.assign(tools, captureTools((server) => registerFrontendTools(server, http)));
31278
32083
  const advancedTools = captureTools((server) => registerAdvancedTools(server, http));
31279
32084
  if (advancedTools.platform) {
31280
32085
  tools.platform = advancedTools.platform;
@@ -31325,17 +32130,17 @@ async function main() {
31325
32130
  }
31326
32131
  const cliTools = createAdminTools(context, globalOptions.confirmProduction);
31327
32132
  if (args.length === 1 && cliTools[args[0]]) {
31328
- const toolResponse = await cliTools[args[0]].callback({});
31329
- if (toolResponse?.content && Array.isArray(toolResponse.content)) {
31330
- for (const chunk of toolResponse.content) {
32133
+ const toolResponse2 = await cliTools[args[0]].callback({});
32134
+ if (toolResponse2?.content && Array.isArray(toolResponse2.content)) {
32135
+ for (const chunk of toolResponse2.content) {
31331
32136
  if (chunk.type === "text") {
31332
32137
  console.log(chunk.text);
31333
32138
  }
31334
32139
  }
31335
32140
  } else {
31336
- console.log(JSON.stringify(toolResponse, null, 2));
32141
+ console.log(JSON.stringify(toolResponse2, null, 2));
31337
32142
  }
31338
- if (cliToolResultIsError(toolResponse))
32143
+ if (cliToolResultIsError(toolResponse2))
31339
32144
  process.exitCode = 1;
31340
32145
  return;
31341
32146
  }
@@ -31345,7 +32150,7 @@ function isDirectRun() {
31345
32150
  if (!process.argv[1])
31346
32151
  return false;
31347
32152
  try {
31348
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve3(process.argv[1]));
32153
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve4(process.argv[1]));
31349
32154
  } catch {
31350
32155
  return false;
31351
32156
  }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/admin",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Platform administration CLI for SupaCloud operators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
Binary file