@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.
- package/README.md +65 -0
- package/dist/index.js +829 -30
- 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:
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
26216
|
-
return
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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:
|
|
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 (!
|
|
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.
|
|
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
|
|
31335
|
-
if (
|
|
31336
|
-
for (const chunk of
|
|
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(
|
|
32141
|
+
console.log(JSON.stringify(toolResponse2, null, 2));
|
|
31343
32142
|
}
|
|
31344
|
-
if (cliToolResultIsError(
|
|
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(
|
|
32153
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve4(process.argv[1]));
|
|
31355
32154
|
} catch {
|
|
31356
32155
|
return false;
|
|
31357
32156
|
}
|