@supacloud/admin 0.12.1 → 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.
Files changed (3) hide show
  1. package/README.md +65 -0
  2. package/dist/index.js +829 -30
  3. package/package.json +1 -1
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
@@ -249,6 +273,47 @@ The JSON receipt contains only the project ref, requested type, backup ID,
249
273
  database, timestamps, and size. `platform list_backups --ref abc123` applies the
250
274
  same strict, sanitized inventory validation without creating a backup.
251
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
+
252
317
  Gateway / Caddy commands (config is injected via the Caddy JSON Admin API; requires admin privileges):
253
318
 
254
319
  - `gateway routes` — list custom gateway routes
package/dist/index.js CHANGED
@@ -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
  }
@@ -29224,6 +29276,238 @@ async function createFullPhysicalBackup(http, projectRef) {
29224
29276
  return reconciledCreationResponse(projectRef, beforeRead.inventory, creation, afterRead);
29225
29277
  }
29226
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
+
29227
29511
  // src/shared/tools/advanced-tools.ts
29228
29512
  function registerAdvancedTools(server, http) {
29229
29513
  server.tool("edge_functions", `Edge Function management (Deno/Bun serverless). Server auto-bundles dependencies.
@@ -29367,11 +29651,14 @@ Actions: list, upsert, delete`, {
29367
29651
  return { content: [{ type: "text", text }] };
29368
29652
  });
29369
29653
  server.tool("platform", `Platform monitoring, backups, network, and organizations.
29370
- 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`, {
29371
29655
  action: withDescription(stringEnum([
29372
29656
  "metrics",
29373
29657
  "list_backups",
29374
29658
  "create_backup",
29659
+ "list_logical_backups",
29660
+ "create_logical_backup",
29661
+ "restore_logical_backup",
29375
29662
  "network",
29376
29663
  "update_network",
29377
29664
  "list_orgs",
@@ -29379,10 +29666,22 @@ Actions: metrics, list_backups, create_backup, network, update_network, list_org
29379
29666
  ]), "Action"),
29380
29667
  ref: optional(Type.String(), "[*] Project ref for project-scoped platform actions"),
29381
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"),
29382
29672
  slug: optional(Type.String(), "[get_org] Organization slug"),
29383
29673
  allowed_cidrs: optional(Type.Array(Type.String()), "[update_network] Allowed CIDRs")
29384
29674
  }, async (args) => {
29385
- 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;
29386
29685
  const need = (f, v) => {
29387
29686
  if (!v)
29388
29687
  throw new Error(`'${f}' required for '${action}'`);
@@ -29400,6 +29699,23 @@ Actions: metrics, list_backups, create_backup, network, update_network, list_org
29400
29699
  need("backup_type", backup_type);
29401
29700
  return createFullPhysicalBackup(http, ref);
29402
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
+ });
29403
29719
  case "network":
29404
29720
  need("ref", ref);
29405
29721
  text = JSON.stringify((await http.get(`/v1/projects/${ref}/network-restrictions`)).data, null, 2);
@@ -29904,7 +30220,7 @@ function matchingText(candidate, maxLength, pattern) {
29904
30220
  const candidateText = boundedText(candidate, maxLength);
29905
30221
  return candidateText && pattern.test(candidateText) ? candidateText : null;
29906
30222
  }
29907
- function canonicalTimestamp(candidate) {
30223
+ function canonicalTimestamp2(candidate) {
29908
30224
  const timestamp = boundedText(candidate, 64);
29909
30225
  if (!timestamp)
29910
30226
  return null;
@@ -29919,7 +30235,7 @@ function projectedSummary(project) {
29919
30235
  organization_slug: matchingText(project.organization_slug, 128, SAFE_IDENTIFIER_PATTERN),
29920
30236
  name: boundedText(project.name, 100),
29921
30237
  region: matchingText(project.region, 64, REGION_PATTERN),
29922
- created_at: canonicalTimestamp(project.created_at),
30238
+ created_at: canonicalTimestamp2(project.created_at),
29923
30239
  status: matchingText(project.status, 64, STATUS_PATTERN)
29924
30240
  };
29925
30241
  return Object.values(summary).every((field) => field !== null) ? summary : null;
@@ -30063,7 +30379,7 @@ function projectGetRead(response, expectedRef) {
30063
30379
  // src/shared/tools/project-runtime-snapshot.ts
30064
30380
  var RUNTIME_SNAPSHOT_SCHEMA = "supacloud.runtime-snapshot.v1";
30065
30381
  var ATTESTED_REVISION_PATTERN = /^hmac-sha256:[a-f0-9]{64}$/;
30066
- var SAFE_PROJECT_REF2 = /^[a-z0-9-]{1,20}$/;
30382
+ var SAFE_PROJECT_REF3 = /^[a-z0-9-]{1,20}$/;
30067
30383
  var SNAPSHOT_KEYS = ["schema", "project_ref", "captured_at", "secrets", "postgrest"];
30068
30384
  var SECRETS_KEYS = [
30069
30385
  "desired_revision",
@@ -30200,7 +30516,7 @@ function hasCausalLoadTimestamps(snapshot) {
30200
30516
  return [snapshot.secrets.loaded_at, snapshot.postgrest.loaded_at].every((loadedAt) => loadedAt === null || Date.parse(loadedAt) <= capturedAt);
30201
30517
  }
30202
30518
  function parseProjectRuntimeSnapshot(payload, requestedProjectRef2) {
30203
- 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))
30204
30520
  return null;
30205
30521
  const snapshot = payload;
30206
30522
  return hasCausalLoadTimestamps(snapshot) ? sanitizedSnapshot(snapshot) : null;
@@ -30237,11 +30553,11 @@ var STUDIO_PROJECT_SERVICE_STATUSES = [
30237
30553
  ];
30238
30554
  var AUTH_RUNTIME_MANAGED_BY_OWNER = "AUTH_RUNTIME_MANAGED_BY_OWNER";
30239
30555
  var AUTH_SERVICE_HOST_SUFFIX = "-auth";
30240
- var SAFE_PROJECT_REF3 = /^[a-z0-9-]{1,20}$/;
30556
+ var SAFE_PROJECT_REF4 = /^[a-z0-9-]{1,20}$/;
30241
30557
  var SAFE_AUTHORITY_PROJECT_REF = /^[A-Za-z0-9_-]{1,20}$/;
30242
30558
  var MAX_SERVICE_CONTROL_MESSAGE_LENGTH = 256;
30243
30559
  var MAX_RUNTIME_SNAPSHOT_BYTES = 64 * 1024;
30244
- var RELEASE_CONTROL_RESPONSE_SCHEMA = "supacloud.cli.release-control.v1";
30560
+ var RELEASE_CONTROL_RESPONSE_SCHEMA2 = "supacloud.cli.release-control.v1";
30245
30561
  var PROJECT_CREATE_OPERATION = "project.create";
30246
30562
  var PROJECT_ENVIRONMENTS = ["test", "production"];
30247
30563
  var DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
@@ -30269,7 +30585,7 @@ function failedProjectServiceResponse(message) {
30269
30585
  function projectCreateReceipt(payload) {
30270
30586
  return {
30271
30587
  content: [{ type: "text", text: JSON.stringify({
30272
- schema: RELEASE_CONTROL_RESPONSE_SCHEMA,
30588
+ schema: RELEASE_CONTROL_RESPONSE_SCHEMA2,
30273
30589
  ...payload
30274
30590
  }, null, 2) }]
30275
30591
  };
@@ -30454,7 +30770,7 @@ function projectServiceStatusOutput(status) {
30454
30770
  function projectServicesResponse(projectRef, response) {
30455
30771
  if (!response.ok)
30456
30772
  return failedProjectServiceHttpResponse(response);
30457
- 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) {
30458
30774
  return failedProjectServiceResponse("Project service inventory response is invalid");
30459
30775
  }
30460
30776
  if (!response.data.every((service) => isProjectServiceStatus(service, projectRef))) {
@@ -31052,10 +31368,484 @@ Actions: routes, upsert_route, update_route, delete_route, config, get_certifica
31052
31368
  return { content: [{ type: "text", text }] };
31053
31369
  });
31054
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
+ }
31055
31845
  // package.json
31056
31846
  var package_default = {
31057
31847
  name: "@supacloud/admin",
31058
- version: "0.12.1",
31848
+ version: "0.14.0",
31059
31849
  description: "Platform administration CLI for SupaCloud operators",
31060
31850
  type: "module",
31061
31851
  main: "./dist/index.js",
@@ -31125,6 +31915,7 @@ function unavailableAdminSchemas() {
31125
31915
  project: captureTools((server) => registerAdminProjectCliTools(server, schemaOnlyHttp)).project.schema,
31126
31916
  platform: captureTools((server) => registerAdvancedTools(server, schemaOnlyHttp)).platform.schema,
31127
31917
  gateway: captureTools((server) => registerGatewayTools(server, schemaOnlyHttp)).gateway.schema,
31918
+ frontend: captureTools((server) => registerFrontendTools(server, schemaOnlyHttp)).frontend.schema,
31128
31919
  ssh: captureTools((server) => registerSshTools(server, {})).ssh.schema
31129
31920
  };
31130
31921
  }
@@ -31134,6 +31925,7 @@ function registerUnavailableAdminTools(tools) {
31134
31925
  tools.platform = unavailableTool(schemas.platform, "⚠️ Platform commands require SUPACLOUD_API_URL and SUPACLOUD_API_TOKEN.");
31135
31926
  tools.ssh = unavailableTool(schemas.ssh, "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT.");
31136
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.");
31137
31929
  }
31138
31930
  function printHelp(context = resolveSupaCloudContext()) {
31139
31931
  console.error(`
@@ -31190,10 +31982,16 @@ EXAMPLES
31190
31982
  supacloud-admin project runtime_snapshot --ref abc123
31191
31983
  supacloud-admin project service_control --ref abc123 --service gotrue --service_action stop
31192
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
31193
31988
  supacloud-admin gateway routes --ref abc123
31194
31989
  supacloud-admin gateway upsert_route --ref abc123 --route_id webhook --hosts "api.example.com" --paths "/webhook/*" --upstream 10.0.0.5:8080
31195
31990
  supacloud-admin gateway config --ref abc123 --rate_limit_tier pro
31196
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>
31197
31995
  `);
31198
31996
  }
31199
31997
  function authorizedToolMap(tools, context, confirmProduction) {
@@ -31281,6 +32079,7 @@ function createAdminTools(context = resolveSupaCloudContext(), confirmProduction
31281
32079
  });
31282
32080
  Object.assign(tools, captureTools((server) => registerAdminProjectCliTools(server, http)));
31283
32081
  Object.assign(tools, captureTools((server) => registerGatewayTools(server, http)));
32082
+ Object.assign(tools, captureTools((server) => registerFrontendTools(server, http)));
31284
32083
  const advancedTools = captureTools((server) => registerAdvancedTools(server, http));
31285
32084
  if (advancedTools.platform) {
31286
32085
  tools.platform = advancedTools.platform;
@@ -31331,17 +32130,17 @@ async function main() {
31331
32130
  }
31332
32131
  const cliTools = createAdminTools(context, globalOptions.confirmProduction);
31333
32132
  if (args.length === 1 && cliTools[args[0]]) {
31334
- const toolResponse = await cliTools[args[0]].callback({});
31335
- if (toolResponse?.content && Array.isArray(toolResponse.content)) {
31336
- 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) {
31337
32136
  if (chunk.type === "text") {
31338
32137
  console.log(chunk.text);
31339
32138
  }
31340
32139
  }
31341
32140
  } else {
31342
- console.log(JSON.stringify(toolResponse, null, 2));
32141
+ console.log(JSON.stringify(toolResponse2, null, 2));
31343
32142
  }
31344
- if (cliToolResultIsError(toolResponse))
32143
+ if (cliToolResultIsError(toolResponse2))
31345
32144
  process.exitCode = 1;
31346
32145
  return;
31347
32146
  }
@@ -31351,7 +32150,7 @@ function isDirectRun() {
31351
32150
  if (!process.argv[1])
31352
32151
  return false;
31353
32152
  try {
31354
- return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve3(process.argv[1]));
32153
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve4(process.argv[1]));
31355
32154
  } catch {
31356
32155
  return false;
31357
32156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/admin",
3
- "version": "0.12.1",
3
+ "version": "0.14.0",
4
4
  "description": "Platform administration CLI for SupaCloud operators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",