wawesome 0.19.0 → 0.20.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/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-4bHyTMIy.mjs";
1
+ import { a as unsupportedGlobals, n as methodRemedy, r as polyfilledGlobals, t as declaredMethods } from "./guest-surface-D5X-IMAN.mjs";
2
2
  import cac from "cac";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
@@ -6,10 +6,10 @@ import { build } from "esbuild";
6
6
  import os from "node:os";
7
7
  import { parse } from "acorn";
8
8
  import { spawnSync } from "node:child_process";
9
+ import crypto from "node:crypto";
9
10
  import http from "node:http";
10
- import readline from "node:readline";
11
11
  import { Readable } from "node:stream";
12
- import crypto from "node:crypto";
12
+ import readline from "node:readline";
13
13
  import { confirm, select } from "@inquirer/prompts";
14
14
  import zlib from "node:zlib";
15
15
  //#region src/errors.ts
@@ -82,21 +82,6 @@ function reportSessionCommandRefused(secret, consequence) {
82
82
  }
83
83
  //#endregion
84
84
  //#region src/config.ts
85
- /**
86
- * Hardcoded Supabase project config for the wawesome.io platform.
87
- * These are public values — the anon key is designed to be embedded in clients.
88
- */
89
- const SUPABASE_URL = "https://vclasavxxoufwymrutai.supabase.co";
90
- /**
91
- * Where the documents accepted at signup are published. Fixed rather than
92
- * resolved per environment: there is one website, and a signup has to be able
93
- * to link at something a person can actually read before they accept it.
94
- */
95
- const LEGAL_URLS = {
96
- terms: "https://wawesome.io/legal/terms-of-service",
97
- privacy: "https://wawesome.io/legal/privacy-policy"
98
- };
99
- const SUPABASE_ANON_KEY = "sb_publishable_ZlgDAI9cNZkefEwVPKIngA_ktuvTpjD";
100
85
  /** Path to the user-level settings file (~/.wawesome/settings.json) */
101
86
  function getSettingsPath() {
102
87
  return path.join(os.homedir(), ".wawesome", "settings.json");
@@ -149,18 +134,23 @@ function getDashboardUrl(overrideUrl) {
149
134
  /**
150
135
  * The dashboard for the environment this login is going to.
151
136
  *
152
- * The two addresses describe one environment, so the stored rung is read only
153
- * where the gateway being logged into is the one the credentials already name.
154
- * Carrying a dashboard across a change of gateway is the wrong workspace, and
155
- * so is dropping one where the gateway did not change at all.
156
- */
157
- function resolveDashboardUrlForLogin(overrideUrl, gatewayUrl) {
158
- const creds = readStoredCredentials();
159
- const sameEnvironment = creds?.gateway_url === gatewayUrl;
160
- return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", sameEnvironment ? creds?.dashboard_url : void 0, DASHBOARD_URL_FALLBACK);
161
- }
162
- /** Localhost port used during OAuth callback */
163
- const OAUTH_CALLBACK_PORT = 9999;
137
+ * Taken from the address the platform handed back to open, so a login that
138
+ * configures nothing stores the dashboard belonging to the gateway it signed
139
+ * into. An explicit flag, `DASHBOARD_URL` or a settings file still outrank it,
140
+ * on the rung they outrank every other address on.
141
+ */
142
+ function resolveDashboardUrlForLogin(overrideUrl, signInUrl) {
143
+ return resolveAddress(overrideUrl, DASHBOARD_ENV_NAMES, "dashboard_url", originOf(signInUrl), DASHBOARD_URL_FALLBACK);
144
+ }
145
+ /** Undefined rather than throwing: an unreadable address falls to the rung below. */
146
+ function originOf(url) {
147
+ try {
148
+ return new URL(url).origin;
149
+ } catch {
150
+ return;
151
+ }
152
+ }
153
+ const LOGIN_CALLBACK_PORT = 9999;
164
154
  /** Path to the user-level credentials file */
165
155
  function getCredentialsPath() {
166
156
  return path.join(os.homedir(), ".wawesome", "credentials.json");
@@ -905,199 +895,198 @@ async function buildHandler(entryInput, config, options) {
905
895
  * that has to name this version — `--version`, the dependency a scaffolded
906
896
  * project pins — reads it here, so a release bumps one file.
907
897
  */
908
- const CLI_VERSION = "0.19.0";
898
+ const CLI_VERSION = "0.20.0";
909
899
  //#endregion
910
- //#region src/prompt.ts
911
- /**
912
- * Open a prompt session on stdin, queueing lines as they arrive.
913
- *
914
- * Reading one `rl.question` at a time drops piped input: it lands as a single
915
- * chunk, so every line after the first is emitted with no question pending.
916
- * End of input answers the remaining questions with their defaults.
917
- */
918
- function openPromptSession() {
919
- const rl = readline.createInterface({
920
- input: process.stdin,
921
- output: process.stdout
922
- });
923
- const queued = [];
924
- const waiting = [];
925
- let ended = false;
926
- rl.on("line", (line) => {
927
- const next = waiting.shift();
928
- if (next) next(line);
929
- else queued.push(line);
930
- });
931
- rl.on("close", () => {
932
- ended = true;
933
- while (waiting.length > 0) waiting.shift()?.(null);
934
- });
935
- function nextLine() {
936
- if (queued.length > 0) return Promise.resolve(queued.shift());
937
- if (ended) return Promise.resolve(null);
938
- return new Promise((resolve) => waiting.push(resolve));
939
- }
900
+ //#region src/cli-login.ts
901
+ const START_PATH = "/v1/cli/login/start";
902
+ const EXCHANGE_PATH = "/v1/cli/login/exchange";
903
+ function mintProof() {
904
+ const verifier = crypto.randomBytes(32).toString("base64url");
940
905
  return {
941
- async ask(question, defaultVal) {
942
- const offered = defaultVal ? ` (${defaultVal})` : "";
943
- process.stdout.write(`${question}${offered}: `);
944
- const line = await nextLine();
945
- if (line === null) {
946
- process.stdout.write("\n");
947
- return defaultVal;
948
- }
949
- return line.trim() || defaultVal;
950
- },
951
- close() {
952
- rl.close();
953
- }
906
+ verifier,
907
+ challenge: crypto.createHash("sha256").update(verifier).digest("base64url")
954
908
  };
955
909
  }
956
910
  /**
957
- * Whether a question can actually be put to somebody. A piped or redirected
958
- * stdin still answers — with end-of-input — so this only tells a caller whether
959
- * a *default* is going to be what the user gets.
911
+ * The address to open comes back from the platform rather than being composed
912
+ * here: which dashboard belongs to a gateway is the gateway's own fact, and a
913
+ * CLI that spelled it would be a CLI to reinstall whenever it moved.
960
914
  */
961
- function isInteractive() {
962
- return Boolean(process.stdin.isTTY);
915
+ async function startSignIn(gatewayUrl, asked) {
916
+ const started = await post(gatewayUrl, START_PATH, {
917
+ redirect_uri: asked.redirectUri,
918
+ code_challenge: asked.challenge,
919
+ code_challenge_method: "S256",
920
+ state: asked.state
921
+ }, "The platform could not start a sign-in");
922
+ if (!started.sign_in_url) throw new Error("The platform started a sign-in without saying where to finish it.");
923
+ return started;
963
924
  }
964
- //#endregion
965
- //#region ../shared/public-address.ts
966
- const INVOCATION_PREFIX = "/x";
967
- const SLUG_SEPARATOR = "--";
968
- const SURFACE_ROUTE = "/v1/invocation-surface";
969
- const SUBTREE_NOTE = "Every path beneath this address reaches the Function.";
970
- const ROOT_FUNCTION_SLUG = "root";
971
925
  /**
972
- * A gateway that serves no form of the address — and the value a client uses for
973
- * one that did not answer at all, deliberately the same: both name no address,
974
- * and there is nothing else either could honestly do.
926
+ * The verifier travels here and nowhere else: it is what proves this terminal
927
+ * is the one that started the sign-in.
975
928
  */
976
- const NO_INVOCATION_SURFACE = {
977
- contentOrigin: null,
978
- pathFormOrigin: null
979
- };
980
- async function fetchInvocationSurface(gatewayUrl) {
981
- const origin = trimTrailingSlashes(gatewayUrl);
982
- const res = await fetch(`${origin}${SURFACE_ROUTE}`);
983
- if (!res.ok) throw new Error(`Failed to read the gateway's address forms (HTTP ${res.status}).`);
984
- const body = await res.json();
985
- return {
986
- contentOrigin: body.content_origin ?? null,
987
- pathFormOrigin: body.path_form ? origin : null
988
- };
989
- }
990
- /** The one address the Function answers at, `null` where it has none. */
991
- function publicAddress(surface, tenantSlug, appSlug, functionSlug) {
992
- const base = appBase(surface, tenantSlug, appSlug);
993
- if (base === null) return null;
994
- if (functionSlug === "root") return base;
995
- return `${base}/${encodeURIComponent(functionSlug)}`;
929
+ async function exchangeCode(gatewayUrl, code, verifier) {
930
+ const session = await post(gatewayUrl, EXCHANGE_PATH, {
931
+ code,
932
+ code_verifier: verifier
933
+ }, "This sign-in could not be finished");
934
+ if (!session.tenant_jwt || !session.refresh_token) throw new Error("The platform answered this sign-in with half a session.");
935
+ return session;
996
936
  }
997
937
  /**
998
- * Where one of a Function's static files answers, `null` where none of them do.
938
+ * The code, or the sign-in running out first.
999
939
  *
1000
- * A **Static asset** is served on the App's own hostname and on no other
1001
- * address, so a gateway serving only the path form has no address to give for
1002
- * one — see **Static route** in `CONTEXT.md`.
1003
- */
1004
- function assetAddress(surface, tenantSlug, appSlug, functionSlug, assetPath) {
1005
- if (!surface.contentOrigin) return null;
1006
- const mount = publicAddress(surface, tenantSlug, appSlug, functionSlug);
1007
- if (mount === null) return null;
1008
- return `${mount}/${assetPath.split("/").map(encodeURIComponent).join("/")}`;
1009
- }
1010
- /** The same address with the app and function still to be chosen. */
1011
- function publicAddressTemplate(surface, tenantSlug) {
1012
- if (surface.contentOrigin) {
1013
- if (!isLegalDnsLabel(tenantSlug)) return null;
1014
- const hostname = appHostname(surface.contentOrigin, `${tenantSlug}${SLUG_SEPARATOR}<app>`);
1015
- return hostname === null ? null : `${hostname}/<function>`;
1016
- }
1017
- if (surface.pathFormOrigin) return `${pathFormBase(surface.pathFormOrigin, tenantSlug)}/<app>/<function>`;
1018
- return null;
940
+ * Without the deadline, a tab somebody walked away from leaves the terminal
941
+ * waiting on a sign-in the platform has already forgotten.
942
+ */
943
+ function waitForCode(answer, expiresAt) {
944
+ const runsOutIn = new Date(expiresAt).getTime() - Date.now();
945
+ if (!Number.isFinite(runsOutIn)) return answer;
946
+ let timer;
947
+ const ranOut = new Promise((_, reject) => {
948
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("This sign-in ran out before the browser came back. Run 'wawesome login' again.")), Math.max(runsOutIn, 0));
949
+ });
950
+ return Promise.race([answer, ranOut]).finally(() => clearTimeout(timer));
1019
951
  }
1020
- function appBase(surface, tenantSlug, appSlug) {
1021
- if (surface.contentOrigin) {
1022
- if (!isLegalDnsLabel(tenantSlug) || !isLegalDnsLabel(appSlug)) return null;
1023
- return appHostname(surface.contentOrigin, `${tenantSlug}${SLUG_SEPARATOR}${appSlug}`);
1024
- }
1025
- if (surface.pathFormOrigin) return `${pathFormBase(surface.pathFormOrigin, tenantSlug)}/${encodeURIComponent(appSlug)}`;
1026
- return null;
952
+ async function post(gatewayUrl, path, body, fallback) {
953
+ const res = await fetch(`${gatewayUrl}${path}`, {
954
+ method: "POST",
955
+ headers: { "Content-Type": "application/json" },
956
+ body: JSON.stringify(body)
957
+ });
958
+ if (!res.ok) throw await asGatewayError(res, `${fallback} (HTTP ${res.status}).`);
959
+ return await res.json();
1027
960
  }
961
+ //#endregion
962
+ //#region src/login-callback.ts
1028
963
  /**
1029
- * The App's own hostname, built the way the gateway rebuilds it from its own
1030
- * configuration: the label one level beneath the content domain. Callers check
1031
- * the slugs the label is made of, since a slug the gateway would refuse to read
1032
- * a `Host` for names no App there.
1033
- */
1034
- function appHostname(contentOrigin, label) {
1035
- let origin;
1036
- try {
1037
- origin = new URL(contentOrigin);
1038
- } catch {
1039
- return null;
1040
- }
1041
- return `${origin.protocol}//${label}.${origin.host}`;
964
+ * Where a browser hands a finished sign-in back to this terminal.
965
+ *
966
+ * Two things make the answer this server takes the developer's own. It binds
967
+ * loopback, so nothing on the network can reach it. And it mints a `state` that
968
+ * travels out with the sign-in and has to come back with the answer, so a page
969
+ * open in another tab cannot hand this CLI a code belonging to somebody else.
970
+ * Everything that fails either test is turned away in prose, and the login
971
+ * stays open for the tab it did open — a stray request is not a reason to end a
972
+ * sign-in a developer is in the middle of.
973
+ *
974
+ * What arrives is a single-use code bound to a verifier this process never
975
+ * sent anywhere but the exchange, which is why it may ride in a URL where an
976
+ * access token never could.
977
+ */
978
+ /** The address is `localhost` so the platform's loopback rule still matches it. */
979
+ const CALLBACK_HOST = "localhost";
980
+ const LOOPBACK = "127.0.0.1";
981
+ const REFUSED_ELSEWHERE = "This sign-in did not come from the login running in your terminal, so it was refused. Nothing was signed in. Run 'wawesome login' again and finish in the tab it opens.";
982
+ const NO_CODE = "The browser came back from the sign-in without a code. Nothing was signed in — run 'wawesome login' again.";
983
+ async function listenForLoginCallback({ port = LOGIN_CALLBACK_PORT } = {}) {
984
+ const state = crypto.randomBytes(32).toString("base64url");
985
+ let settle = () => {};
986
+ let refuse = () => {};
987
+ const answer = new Promise((resolve, reject) => {
988
+ settle = resolve;
989
+ refuse = reject;
990
+ });
991
+ const server = http.createServer((req, res) => {
992
+ try {
993
+ handle(req, res, {
994
+ state,
995
+ settle,
996
+ refuse
997
+ });
998
+ } catch {
999
+ if (res.headersSent || res.destroyed) return;
1000
+ res.writeHead(500, { "Content-Type": "text/plain" });
1001
+ res.end("This login could not read what the browser sent. Nothing was signed in.");
1002
+ }
1003
+ });
1004
+ const listening = await listen(server, port);
1005
+ return {
1006
+ redirectUrl: `http://${CALLBACK_HOST}:${listening}/callback`,
1007
+ state,
1008
+ answer,
1009
+ close: () => {
1010
+ server.closeAllConnections();
1011
+ server.close();
1012
+ }
1013
+ };
1042
1014
  }
1043
- /** The surface, or none where the gateway did not answer. */
1044
- async function invocationSurfaceOrNone(gatewayUrl) {
1045
- try {
1046
- return await fetchInvocationSurface(gatewayUrl);
1047
- } catch {
1048
- return NO_INVOCATION_SURFACE;
1015
+ function handle(req, res, login) {
1016
+ const url = new URL(req.url || "/", `http://${LOOPBACK}`);
1017
+ if (url.pathname !== "/callback") {
1018
+ res.writeHead(404, { "Content-Type": "text/plain" });
1019
+ res.end("Not Found");
1020
+ return;
1049
1021
  }
1022
+ if (!isThisLogin(url.searchParams.get("state"), login.state)) {
1023
+ reportStranger();
1024
+ res.writeHead(400, { "Content-Type": "text/html" });
1025
+ res.end(page("Sign-in refused", REFUSED_ELSEWHERE));
1026
+ return;
1027
+ }
1028
+ const refused = url.searchParams.get("error_description") || url.searchParams.get("error");
1029
+ if (refused) return report(res, login, `The sign-in was refused: ${refused}`);
1030
+ const code = url.searchParams.get("code");
1031
+ if (!code) return report(res, login, NO_CODE);
1032
+ res.writeHead(200, { "Content-Type": "text/html" });
1033
+ res.end(page("Signed in", "You can close this tab and go back to your terminal."));
1034
+ login.settle(code);
1050
1035
  }
1051
- function pathFormBase(origin, tenantSlug) {
1052
- return `${trimTrailingSlashes(origin)}${INVOCATION_PREFIX}/${encodeURIComponent(tenantSlug)}`;
1053
- }
1054
- /**
1055
- * The legal DNS label closest to what somebody typed, empty where nothing legal
1056
- * survives. What a workspace name suggests as an address, and what the CLI
1057
- * offers as a default — never applied to a typed address, which is refused
1058
- * rather than rewritten into a name nobody chose.
1059
- */
1060
- function slugifyLabel(raw) {
1061
- let label = "";
1062
- for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z" || c >= "0" && c <= "9") label += c;
1063
- else if (label.length > 0 && !label.endsWith("-")) label += "-";
1064
- label = label.slice(0, 63);
1065
- while (label.endsWith("-")) label = label.slice(0, -1);
1066
- return label;
1067
- }
1068
- /** Must match `dns_label::is_legal` in `server/crates/core/src/value_objects.rs`. */
1069
- function isLegalDnsLabel(candidate) {
1070
- return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
1071
- }
1072
- function trimTrailingSlashes(origin) {
1073
- return origin.replace(/\/+$/, "");
1074
- }
1075
- //#endregion
1076
- //#region src/slug.ts
1077
1036
  /**
1078
- * The character rules every slug on the platform obeys, mirrored from the
1079
- * `dns_label` module in `server/crates/core/src/value_objects.rs`.
1037
+ * Say, in the terminal, that a request nobody asked for was turned away.
1080
1038
  *
1081
- * A typed name is checked while the user is still standing there, rather than
1082
- * accepted, sent, and rewritten by the gateway into something else that ends up
1083
- * in a client's dashboard. The rules live beside the address they build, so the
1084
- * CLI and the dashboard cannot come to disagree about what a legal name is.
1085
- */
1086
- /** Maximum length of a single DNS label (RFC 1035 §2.3.4). */
1087
- const MAX_SLUG_LENGTH = 63;
1088
- /**
1089
- * Normalise arbitrary input into a legal DNS label, returning an empty string
1090
- * when nothing legal survives — callers decide whether that is an error.
1091
- */
1092
- function sanitizeSlug(raw) {
1093
- return slugifyLabel(raw);
1039
+ * The login itself is left open on purpose. Ending it here would mean any page
1040
+ * that can reach loopback could end a developer's sign-in by asking once, and
1041
+ * the tab this login did open is still coming.
1042
+ */
1043
+ function reportStranger() {
1044
+ console.error("[wawesome] Refused a sign-in that did not carry this login's state. Nothing was signed in, and this login is still waiting for the tab it opened.");
1045
+ }
1046
+ /** A failure of this login: said on the page, and thrown in the terminal. */
1047
+ function report(res, login, said) {
1048
+ res.writeHead(400, { "Content-Type": "text/html" });
1049
+ res.end(page("Sign-in refused", said));
1050
+ login.refuse(new Error(said));
1051
+ }
1052
+ function isThisLogin(given, expected) {
1053
+ if (!given) return false;
1054
+ const a = Buffer.from(given);
1055
+ const b = Buffer.from(expected);
1056
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
1057
+ }
1058
+ function listen(server, port) {
1059
+ return new Promise((resolve, reject) => {
1060
+ server.once("error", (err) => {
1061
+ if (err.code === "EADDRINUSE") return reject(/* @__PURE__ */ new Error(`Port ${port} is already in use, and that is where the browser hands the sign-in back. Stop whatever is holding it — often an earlier 'wawesome login' that never finished — then run 'wawesome login' again.`));
1062
+ reject(err);
1063
+ });
1064
+ server.listen(port, LOOPBACK, () => {
1065
+ const address = server.address();
1066
+ if (typeof address === "string" || address === null) return reject(/* @__PURE__ */ new Error("The callback server started without an address."));
1067
+ resolve(address.port);
1068
+ });
1069
+ });
1094
1070
  }
1095
- /**
1096
- * Whether `candidate` is already a legal DNS label — i.e. whether
1097
- * {@link sanitizeSlug} would leave it untouched.
1098
- */
1099
- function isLegalSlug(candidate) {
1100
- return candidate.length > 0 && candidate.length <= MAX_SLUG_LENGTH && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
1071
+ const STYLE = "font-family: system-ui; text-align: center; padding: 50px; background: #0f172a; color: #f8fafc;";
1072
+ function page(title, said) {
1073
+ return `<!DOCTYPE html>
1074
+ <html>
1075
+ <head><title>wawesome — ${escapeHtml(title.toLowerCase())}</title></head>
1076
+ <body style="${STYLE}">
1077
+ <h2>${escapeHtml(title)}</h2>
1078
+ <p>${escapeHtml(said)}</p>
1079
+ </body>
1080
+ </html>`;
1081
+ }
1082
+ function escapeHtml(text) {
1083
+ return text.replace(/[&<>"']/g, (char) => ({
1084
+ "&": "&amp;",
1085
+ "<": "&lt;",
1086
+ ">": "&gt;",
1087
+ "\"": "&quot;",
1088
+ "'": "&#39;"
1089
+ })[char] || char);
1101
1090
  }
1102
1091
  //#endregion
1103
1092
  //#region src/session.ts
@@ -1153,9 +1142,9 @@ function forgetRenewedSession() {
1153
1142
  renewedAt = 0;
1154
1143
  }
1155
1144
  /**
1156
- * Supabase rotates the refresh token on every use and revokes the whole family
1157
- * if a spent one comes back, so requests that meet the 401 together share one
1158
- * renewal instead of racing with the same token.
1145
+ * The platform rotates the refresh token on every use, and a token from a
1146
+ * generation it has moved past ends the session, so requests that meet the 401
1147
+ * together share one renewal instead of racing with the same token.
1159
1148
  */
1160
1149
  function renewSession() {
1161
1150
  if (!renewal) {
@@ -1174,50 +1163,114 @@ function renewSession() {
1174
1163
  async function renewOnce() {
1175
1164
  const creds = readCredentials();
1176
1165
  if (!creds?.refresh_token) return null;
1177
- const session = await refreshSupabaseSession(creds.refresh_token);
1178
- const exchanged = await exchangeForTenantJwt(creds, session.access_token);
1166
+ return storeRenewed(creds, await renewedSession(creds));
1167
+ }
1168
+ /**
1169
+ * What this process has learned about the session is written down beside it, so
1170
+ * a request still holding the old token does not spend a second 401 finding
1171
+ * out.
1172
+ */
1173
+ function storeRenewed(creds, session) {
1179
1174
  const renewed = {
1180
1175
  ...creds,
1181
- tenant_jwt: exchanged.tenant_jwt,
1182
- refresh_token: session.refresh_token || creds.refresh_token,
1183
- tenant_slug: exchanged.tenant_slug || creds.tenant_slug,
1184
- tenant_name: exchanged.tenant_name || creds.tenant_name
1176
+ tenant_jwt: session.tenant_jwt,
1177
+ refresh_token: session.refresh_token,
1178
+ tenant_id: session.tenant_id || creds.tenant_id,
1179
+ tenant_slug: session.tenant_slug || creds.tenant_slug,
1180
+ tenant_name: session.tenant_name || creds.tenant_name,
1181
+ user_email: session.email || creds.user_email
1185
1182
  };
1186
1183
  writeCredentials(renewed);
1187
1184
  renewedJwt = renewed.tenant_jwt;
1188
1185
  renewedAt = Date.now();
1189
1186
  return renewed;
1190
1187
  }
1191
- async function refreshSupabaseSession(refreshToken) {
1192
- const res = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
1188
+ /** Where a terminal renews, and where `logout` ends its session on the server. */
1189
+ const SESSION_REFRESH_PATH = "/v1/cli/session/refresh";
1190
+ const SESSION_REVOKE_PATH = "/v1/cli/session/revoke";
1191
+ /** Where the workspaces this session could move to are read. */
1192
+ const SESSION_WORKSPACES_PATH = "/v1/cli/session/workspaces";
1193
+ /**
1194
+ * Renew against this platform. The refresh token is what authenticates the
1195
+ * request, and the only other thing it ever carries is the workspace a
1196
+ * `workspace switch` is moving the session to.
1197
+ */
1198
+ async function renewedSession(creds, tenantId) {
1199
+ const res = await fetch(`${creds.gateway_url}${SESSION_REFRESH_PATH}`, {
1193
1200
  method: "POST",
1194
- headers: {
1195
- apikey: SUPABASE_ANON_KEY,
1196
- "Content-Type": "application/json"
1197
- },
1198
- body: JSON.stringify({ refresh_token: refreshToken })
1201
+ headers: { "Content-Type": "application/json" },
1202
+ body: JSON.stringify({
1203
+ refresh_token: creds.refresh_token,
1204
+ tenant_id: tenantId
1205
+ })
1199
1206
  });
1200
- if (res.status >= 400 && res.status < 500 && res.status !== 429) endSession("Your sign-in was revoked or has expired, so the session could not be renewed.");
1201
- if (!res.ok) throw new Error(`Could not renew your session (Supabase HTTP ${res.status}).`);
1207
+ if (res.status === 429) throw await asGatewayError(res, "The platform is rate limiting this session's renewals.");
1208
+ if (res.status >= 400 && res.status < 500) {
1209
+ const refused = await asGatewayError(res, "Your session could not be renewed.");
1210
+ if (tenantId && refused.reason === "not-a-member") throw refused;
1211
+ endSession(refused.message);
1212
+ }
1213
+ if (!res.ok) throw new Error(`Could not renew your session (HTTP ${res.status}).`);
1202
1214
  const session = await res.json();
1203
- if (!session.access_token) throw new Error("Could not renew your session (Supabase returned no access token).");
1215
+ if (!session.tenant_jwt || !session.refresh_token) throw new Error("Could not renew your session (the platform answered half a session).");
1204
1216
  return session;
1205
1217
  }
1206
- async function exchangeForTenantJwt(creds, supabaseAccessToken) {
1207
- const res = await fetch(`${creds.gateway_url}/api/v1/auth/token-exchange`, {
1218
+ /**
1219
+ * The workspaces this session could move to. On the refresh token, which is
1220
+ * what makes the question answerable at all: the token belongs to the person,
1221
+ * where the tenant token names one workspace.
1222
+ */
1223
+ async function readWorkspaces(creds) {
1224
+ const res = await fetch(`${creds.gateway_url}${SESSION_WORKSPACES_PATH}`, {
1208
1225
  method: "POST",
1209
- headers: {
1210
- Authorization: `Bearer ${supabaseAccessToken}`,
1211
- "Content-Type": "application/json"
1212
- },
1213
- body: JSON.stringify({ tenant_id: creds.tenant_id })
1226
+ headers: { "Content-Type": "application/json" },
1227
+ body: JSON.stringify({ refresh_token: creds.refresh_token })
1214
1228
  });
1215
- if (res.status === 401 || res.status === 403) endSession("Your access to this workspace has been withdrawn.");
1216
- if (!res.ok) throw new Error(`Could not renew your session (token exchange HTTP ${res.status}).`);
1229
+ if (!res.ok) throw await asGatewayError(res, `Your workspaces could not be read (HTTP ${res.status}).`);
1217
1230
  return await res.json();
1218
1231
  }
1219
- function endSession(reason) {
1220
- console.error(`[wawesome] Error: ${reason} Run 'wawesome login' to sign in again.`);
1232
+ /**
1233
+ * Move the session to another workspace, which is a renewal that names it. No
1234
+ * browser: the refresh token is the person's, and the **Membership** the
1235
+ * platform checks is theirs too.
1236
+ */
1237
+ async function switchWorkspaceTo(creds, tenantId) {
1238
+ return storeRenewed(creds, await renewedSession(creds, tenantId));
1239
+ }
1240
+ /**
1241
+ * End the session on the server, which is what makes a copy of the credentials
1242
+ * file worthless once its owner has signed out.
1243
+ *
1244
+ * Answers why the session may still be open, and `null` where it is over. A
1245
+ * refusal is not a session left open — a token the platform will not take names
1246
+ * a session that is already gone — but a platform that could not answer at all
1247
+ * leaves one standing, and the file holding the only copy of the token that
1248
+ * could end it is then worth keeping.
1249
+ */
1250
+ async function revokeSession(creds) {
1251
+ if (!creds.refresh_token) return null;
1252
+ try {
1253
+ const res = await fetch(`${creds.gateway_url}${SESSION_REVOKE_PATH}`, {
1254
+ method: "POST",
1255
+ headers: { "Content-Type": "application/json" },
1256
+ body: JSON.stringify({ refresh_token: creds.refresh_token })
1257
+ });
1258
+ if (res.ok || res.status < 500) return null;
1259
+ return (await asGatewayError(res, `The platform answered HTTP ${res.status}.`)).message;
1260
+ } catch (err) {
1261
+ return err instanceof Error ? err.message : String(err);
1262
+ }
1263
+ }
1264
+ /**
1265
+ * What the platform said, and what to do about it.
1266
+ *
1267
+ * The next action is added only where the platform did not already name one:
1268
+ * its own refusals for a dead session end by saying to sign in again, and
1269
+ * repeating that reads like a stutter.
1270
+ */
1271
+ function endSession(said) {
1272
+ const next = said.includes("wawesome login") ? "" : " Run 'wawesome login' to sign in again.";
1273
+ console.error(`[wawesome] Error: ${said}${next}`);
1221
1274
  process.exit(1);
1222
1275
  }
1223
1276
  //#endregion
@@ -1307,154 +1360,37 @@ async function renameTenantSlug(creds, slug) {
1307
1360
  }
1308
1361
  //#endregion
1309
1362
  //#region src/auth.ts
1310
- /**
1311
- * Ask what to call the workspace being created, and what it should answer at.
1312
- *
1313
- * There is no default name worth offering: a name nobody typed is exactly what
1314
- * this replaced. The address has one — the name, slugified — because it is a
1315
- * DNS label and typing it a second time is work the name already did. A
1316
- * non-interactive login has nobody to ask, so it says what flag to pass rather
1317
- * than inventing a name and minting a workspace under it; an address it is not
1318
- * given is still minted, which is what a pipeline that says nothing gets.
1319
- */
1320
- async function promptForWorkspace(options) {
1321
- const supplied = options.workspace?.trim();
1322
- if (supplied) return {
1323
- name: supplied,
1324
- address: options.address?.trim() || ""
1325
- };
1326
- if (!isInteractive()) throw new Error("No workspace found, and no name to create one with. Re-run with --workspace <name>.");
1327
- console.log("\n[wawesome] You don't have a workspace yet. Let's create one.");
1328
- console.log(" (the name is what you'll see in the dashboard; the address is the first half of every URL you deploy)");
1329
- const session = openPromptSession();
1330
- try {
1331
- for (let attempt = 0; attempt < 3; attempt++) {
1332
- const name = (await session.ask(" Workspace name?", "")).trim();
1333
- if (!name) {
1334
- console.log("[wawesome] A workspace needs a name.");
1335
- continue;
1336
- }
1337
- return {
1338
- name,
1339
- address: (await session.ask(" Public address?", options.address?.trim() || sanitizeSlug(name))).trim()
1340
- };
1341
- }
1342
- } finally {
1343
- session.close();
1344
- }
1345
- throw new Error("No workspace name given. Nothing was created.");
1346
- }
1347
- /**
1348
- * Ask the person to accept the terms, which is what creating a workspace
1349
- * through this door means too.
1350
- *
1351
- * Declining is the default answer, not the one that needs typing: end of input
1352
- * takes the default, so a piped login that says nothing has accepted nothing —
1353
- * the terminal's version of an unticked box. The flag is a word from the person
1354
- * running the command rather than a prompt, so a pipeline reads the same as a
1355
- * terminal, and both documents are linked either way.
1356
- */
1357
- async function confirmTermsAccepted(options) {
1358
- if (options.acceptTerms) return;
1359
- if (!isInteractive()) throw new Error(`Creating a workspace means accepting the terms of service and the privacy policy, and there is no terminal to ask in. Read ${LEGAL_URLS.terms} and ${LEGAL_URLS.privacy}, then re-run with --accept-terms.`);
1360
- console.log("\n[wawesome] Creating a workspace means accepting:");
1361
- console.log(` Terms of Service ${LEGAL_URLS.terms}`);
1362
- console.log(` Privacy Policy ${LEGAL_URLS.privacy}`);
1363
- const session = openPromptSession();
1364
- try {
1365
- const answer = await session.ask(" Accept both?", "n");
1366
- if (/^y(es)?$/i.test(answer.trim())) return;
1367
- } finally {
1368
- session.close();
1369
- }
1370
- throw new Error("The terms were not accepted. Nothing was created.");
1371
- }
1372
- /**
1373
- * Turn a finished OAuth callback into stored credentials.
1374
- *
1375
- * Split from the callback server so what login stores can be exercised without
1376
- * a browser and a port.
1377
- */
1378
- async function completeLogin({ gatewayUrl, dashboardUrl, accessToken, refreshToken, options }) {
1379
- const isVerbose = Boolean(options.verbose);
1380
- console.log("[wawesome] Fetching your workspaces...");
1381
- const tenantsRes = await fetch(`${gatewayUrl}/api/v1/me/tenants`, { headers: { Authorization: `Bearer ${accessToken}` } });
1382
- if (!tenantsRes.ok) throw new Error(`Failed to fetch tenants (HTTP ${tenantsRes.status}). Have you completed onboarding?`);
1383
- const tenants = await tenantsRes.json();
1384
- let primaryTenantId;
1385
- let workspaceName;
1386
- let workspaceSlug;
1387
- if (tenants.length === 0) {
1388
- const chosen = await promptForWorkspace(options);
1389
- await confirmTermsAccepted(options);
1390
- console.log(`[wawesome] Creating workspace "${chosen.name}"...`);
1391
- const initRes = await fetch(`${gatewayUrl}/api/v1/onboarding/init`, {
1392
- method: "POST",
1393
- headers: {
1394
- Authorization: `Bearer ${accessToken}`,
1395
- "Content-Type": "application/json"
1396
- },
1397
- body: JSON.stringify({
1398
- tenant_name: chosen.name,
1399
- tenant_slug: chosen.address || void 0,
1400
- accepted_terms: true
1401
- })
1402
- });
1403
- if (!initRes.ok) throw await asGatewayError(initRes, `Failed to initialize workspace (HTTP ${initRes.status}).`);
1404
- const initData = await initRes.json();
1405
- primaryTenantId = initData.tenant_id;
1406
- workspaceSlug = initData.tenant_slug;
1407
- workspaceName = initData.tenant_name || chosen.name;
1408
- } else primaryTenantId = tenants[0].tenant_id;
1409
- if (isVerbose) console.log(`[wawesome:verbose] Using tenant: ${primaryTenantId}`);
1410
- console.log("[wawesome] Exchanging for tenant-scoped credentials...");
1411
- const exchangeRes = await fetch(`${gatewayUrl}/api/v1/auth/token-exchange`, {
1412
- method: "POST",
1413
- headers: {
1414
- Authorization: `Bearer ${accessToken}`,
1415
- "Content-Type": "application/json"
1416
- },
1417
- body: JSON.stringify({ tenant_id: primaryTenantId })
1418
- });
1419
- if (!exchangeRes.ok) throw new Error(`Token exchange failed (HTTP ${exchangeRes.status}).`);
1420
- const exchangeData = await exchangeRes.json();
1421
- workspaceSlug = exchangeData.tenant_slug || workspaceSlug;
1422
- workspaceName = exchangeData.tenant_name || workspaceName;
1423
- let userEmail = exchangeData.email || "unknown";
1424
- try {
1425
- const payloadPart = exchangeData.tenant_jwt.split(".")[1];
1426
- const decoded = JSON.parse(Buffer.from(payloadPart, "base64url").toString("utf-8"));
1427
- if (decoded.email) userEmail = decoded.email;
1428
- } catch {}
1363
+ function storeSession({ gatewayUrl, dashboardUrl, session }) {
1429
1364
  forgetRenewedSession();
1430
1365
  writeCredentials({
1431
1366
  gateway_url: gatewayUrl,
1432
1367
  dashboard_url: dashboardUrl,
1433
- tenant_jwt: exchangeData.tenant_jwt,
1434
- refresh_token: refreshToken,
1435
- tenant_id: primaryTenantId,
1436
- user_email: userEmail,
1437
- tenant_slug: workspaceSlug,
1438
- tenant_name: workspaceName
1368
+ tenant_jwt: session.tenant_jwt,
1369
+ refresh_token: session.refresh_token,
1370
+ tenant_id: session.tenant_id,
1371
+ user_email: session.email,
1372
+ tenant_slug: session.tenant_slug,
1373
+ tenant_name: session.tenant_name
1439
1374
  });
1440
1375
  console.log("\n======================================================");
1441
1376
  console.log("🎉 \x1B[32mLOGIN SUCCESSFUL!\x1B[0m");
1442
1377
  console.log("======================================================");
1443
- if (workspaceName) console.log(`\n Workspace: ${workspaceName}`);
1444
- if (workspaceSlug) console.log(` Address: ${workspaceSlug}`);
1445
- console.log(` Tenant: ${primaryTenantId}`);
1378
+ if (session.tenant_name) console.log(`\n Workspace: ${session.tenant_name}`);
1379
+ if (session.tenant_slug) console.log(` Address: ${session.tenant_slug}`);
1380
+ console.log(` Tenant: ${session.tenant_id}`);
1446
1381
  console.log(` Gateway: ${gatewayUrl}`);
1447
- console.log(` Email: ${userEmail}`);
1382
+ console.log(` Email: ${session.email}`);
1448
1383
  console.log("\n Credentials saved to ~/.wawesome/credentials.json");
1449
1384
  console.log("======================================================\n");
1450
1385
  }
1451
1386
  /**
1452
- * OAuth login flow:
1453
- * 1. Build Supabase OAuth URL
1454
- * 2. Open browser
1455
- * 3. Listen on localhost for callback with access_token
1456
- * 4. Exchange Supabase JWT for tenant-scoped JWT via gateway
1457
- * 5. Store credentials in ~/.wawesome/credentials.json
1387
+ * Sign in: open the browser at the page built for it, and wait on loopback.
1388
+ *
1389
+ * Everything a person chooses happens there — which identity they sign in with,
1390
+ * which workspace the terminal acts in, and making one where they hold none —
1391
+ * so this end asks for nothing and knows no identity provider (#1113). What the
1392
+ * browser hands back is a single-use code, spent here against a verifier that
1393
+ * left this process only at the exchange.
1458
1394
  */
1459
1395
  async function login(options) {
1460
1396
  const credential = credentialFromEnvironment();
@@ -1463,102 +1399,63 @@ async function login(options) {
1463
1399
  process.exit(1);
1464
1400
  }
1465
1401
  const gatewayUrl = resolveGatewayUrlWithoutSession(options.gateway || options.api);
1466
- const dashboardUrl = resolveDashboardUrlForLogin(options.dashboard, gatewayUrl);
1467
- const provider = options.provider || "github";
1468
1402
  const isVerbose = Boolean(options.verbose);
1469
- const authUrl = `${SUPABASE_URL}/auth/v1/authorize?provider=${provider}&redirect_to=${encodeURIComponent(`http://localhost:${OAUTH_CALLBACK_PORT}/callback`)}`;
1470
- console.log(`[wawesome] Starting ${provider} OAuth login...`);
1471
1403
  console.log(`[wawesome] Gateway: ${gatewayUrl}`);
1472
- if (isVerbose) {
1473
- console.log(`[wawesome:verbose] Supabase URL: ${SUPABASE_URL}`);
1474
- console.log(`[wawesome:verbose] Auth URL: ${authUrl}`);
1475
- }
1476
- return new Promise((resolve, reject) => {
1477
- const server = http.createServer(async (req, res) => {
1478
- const reqUrl = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`);
1479
- if (reqUrl.pathname === "/callback") {
1480
- res.writeHead(200, { "Content-Type": "text/html" });
1481
- res.end(`
1482
- <!DOCTYPE html>
1483
- <html>
1484
- <head><title>wawesome - Authentication</title></head>
1485
- <body style="font-family: system-ui; text-align: center; padding: 50px; background: #0f172a; color: #f8fafc;">
1486
- <h2>🔐 Authenticating with ${provider}...</h2>
1487
- <p>Transferring auth tokens back to CLI terminal...</p>
1488
- <script>
1489
- const hash = window.location.hash.substring(1);
1490
- const params = new URLSearchParams(hash);
1491
- const accessToken = params.get('access_token');
1492
- const refreshToken = params.get('refresh_token');
1493
-
1494
- if (accessToken) {
1495
- fetch('/token?access_token=' + encodeURIComponent(accessToken) + '&refresh_token=' + encodeURIComponent(refreshToken || ''))
1496
- .then(() => {
1497
- document.body.innerHTML = '<h1>✅ Success!</h1><p>You can close this tab and return to your terminal.</p>';
1498
- });
1499
- } else {
1500
- document.body.innerHTML = '<h1>❌ Error</h1><p>No access token found in URL hash.</p>';
1501
- }
1502
- <\/script>
1503
- </body>
1504
- </html>
1505
- `);
1506
- return;
1507
- }
1508
- if (reqUrl.pathname === "/token") {
1509
- const accessToken = reqUrl.searchParams.get("access_token");
1510
- const refreshToken = reqUrl.searchParams.get("refresh_token");
1511
- res.writeHead(200, { "Content-Type": "text/plain" });
1512
- res.end("Token received by CLI.");
1513
- if (!accessToken) {
1514
- console.error("[wawesome] Error: No access token received.");
1515
- server.close();
1516
- reject(/* @__PURE__ */ new Error("No access token received"));
1517
- return;
1518
- }
1519
- if (isVerbose) console.log("[wawesome:verbose] Supabase access token received.");
1520
- try {
1521
- await completeLogin({
1522
- gatewayUrl,
1523
- dashboardUrl,
1524
- accessToken,
1525
- refreshToken: refreshToken || void 0,
1526
- options
1527
- });
1528
- server.close();
1529
- resolve();
1530
- } catch (err) {
1531
- console.error(`[wawesome] Login failed: ${err instanceof Error ? err.message : err}`);
1532
- server.close();
1533
- reject(err);
1534
- }
1535
- } else {
1536
- res.writeHead(404, { "Content-Type": "text/plain" });
1537
- res.end("Not Found");
1538
- }
1404
+ const callback = await listenForLoginCallback().catch((err) => {
1405
+ console.error(`[wawesome] Error: ${errorText(err)}`);
1406
+ process.exit(1);
1407
+ });
1408
+ const proof = mintProof();
1409
+ try {
1410
+ const started = await startSignIn(gatewayUrl, {
1411
+ redirectUri: callback.redirectUrl,
1412
+ challenge: proof.challenge,
1413
+ state: callback.state
1539
1414
  });
1540
- server.listen(OAUTH_CALLBACK_PORT, async () => {
1541
- console.log(`\n🌐 Listening for OAuth callback on http://localhost:${OAUTH_CALLBACK_PORT}/callback ...`);
1542
- console.log(`🚀 Opening browser for ${provider} authentication...`);
1543
- console.log(`👉 ${authUrl}\n`);
1544
- try {
1545
- const open = (await import("open")).default;
1546
- await open(authUrl);
1547
- } catch {
1548
- console.log("[wawesome] Could not open browser automatically. Please visit the URL above manually.");
1549
- }
1415
+ if (isVerbose) console.log(`[wawesome:verbose] Sign-in: ${started.request}`);
1416
+ console.log(`\n🌐 Waiting for the browser to come back to ${callback.redirectUrl} ...`);
1417
+ console.log("🚀 Opening your browser to finish signing in...");
1418
+ console.log(`👉 ${started.sign_in_url}\n`);
1419
+ try {
1420
+ const open = (await import("open")).default;
1421
+ await open(started.sign_in_url);
1422
+ } catch {
1423
+ console.log("[wawesome] Could not open browser automatically. Please visit the URL above manually.");
1424
+ }
1425
+ const session = await exchangeCode(gatewayUrl, await waitForCode(callback.answer, started.expires_at), proof.verifier);
1426
+ storeSession({
1427
+ gatewayUrl,
1428
+ dashboardUrl: resolveDashboardUrlForLogin(options.dashboard, started.sign_in_url),
1429
+ session
1550
1430
  });
1551
- });
1431
+ } catch (err) {
1432
+ console.error(`[wawesome] Login failed: ${errorText(err)}`);
1433
+ throw err;
1434
+ } finally {
1435
+ callback.close();
1436
+ }
1552
1437
  }
1553
1438
  /**
1554
- * Clear stored credentials.
1439
+ * End the session, on the server and then on disk.
1440
+ *
1441
+ * The server first, because that is the half that makes a copy of the
1442
+ * credentials file worthless. Where the platform could not answer at all, the
1443
+ * file stays: it holds the only copy of the token that could end the session,
1444
+ * and deleting it would leave one open with nothing able to close it.
1555
1445
  */
1556
- function logout() {
1446
+ async function logout() {
1557
1447
  const credential = credentialFromEnvironment();
1558
1448
  if (credential) {
1559
1449
  reportSessionCommandRefused(credential, "The next command would authenticate all the same.");
1560
1450
  process.exit(1);
1561
1451
  }
1452
+ const creds = readCredentials();
1453
+ const stillOpen = creds ? await revokeSession(creds) : null;
1454
+ if (stillOpen) {
1455
+ console.error(`[wawesome] Error: your session could not be ended: ${stillOpen}`);
1456
+ console.error("[wawesome] You are still signed in, and your credentials were kept. Run 'wawesome logout' again once you can reach the platform.");
1457
+ process.exit(1);
1458
+ }
1562
1459
  forgetRenewedSession();
1563
1460
  if (deleteCredentials()) console.log("[wawesome] ✅ Logged out. Credentials removed.");
1564
1461
  else console.log("[wawesome] Not logged in (no credentials found).");
@@ -2397,50 +2294,216 @@ function collectAssets(dir) {
2397
2294
  return assets;
2398
2295
  }
2399
2296
  /**
2400
- * A **Static asset** a browser renders as a page. Read off the extension, as the
2401
- * content type the platform records is — mirrors `content_type_for_path` in
2402
- * `server/crates/core/src/features/assets/content_type.rs`.
2297
+ * A **Static asset** a browser renders as a page. Read off the extension, as the
2298
+ * content type the platform records is — mirrors `content_type_for_path` in
2299
+ * `server/crates/core/src/features/assets/content_type.rs`.
2300
+ */
2301
+ function isDocument(assetPath) {
2302
+ const name = assetPath.split("/").pop() ?? "";
2303
+ const dot = name.lastIndexOf(".");
2304
+ const extension = dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
2305
+ return extension === "html" || extension === "htm";
2306
+ }
2307
+ function hashFile(file) {
2308
+ return sha256(fs.readFileSync(file));
2309
+ }
2310
+ function sha256(bytes) {
2311
+ return crypto.createHash("sha256").update(bytes).digest("hex");
2312
+ }
2313
+ /**
2314
+ * What this deploy *is*: the server bundle together with the sorted set of
2315
+ * asset path and content-hash pairs, and the **Fallback document** it declares.
2316
+ *
2317
+ * Computed here, before anything is uploaded, so a deploy that changed nothing
2318
+ * is refused before a byte moves. Mirrors `deploy_digest` in
2319
+ * `server/crates/core/src/features/assets/identity.rs` — the gateway recomputes
2320
+ * it from what actually arrives, so the two have to agree exactly.
2321
+ *
2322
+ * A deploy declaring no fallback contributes nothing here rather than a stated
2323
+ * absence, so every digest computed before fallbacks existed is unmoved.
2324
+ */
2325
+ const DEPLOY_DIGEST_DOMAIN = "wawesome-deploy-v1";
2326
+ function deployDigest(bundle, assets, fallback) {
2327
+ const digest = crypto.createHash("sha256");
2328
+ digest.update(`${DEPLOY_DIGEST_DOMAIN}\n`);
2329
+ digest.update(`${sha256(bundle)}\n`);
2330
+ for (const asset of [...assets].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) digest.update(`${asset.path}\0${asset.content_hash}\n`);
2331
+ if (fallback) digest.update(`fallback\0${fallback.path}\0${fallback.status}\n`);
2332
+ return digest.digest("hex");
2333
+ }
2334
+ /** The manifest as the gateway reads it — the bytes on disk are the CLI's business. */
2335
+ function manifestOf(assets) {
2336
+ return assets.map(({ path, content_hash, size_bytes }) => ({
2337
+ path,
2338
+ content_hash,
2339
+ size_bytes
2340
+ }));
2341
+ }
2342
+ //#endregion
2343
+ //#region src/prompt.ts
2344
+ /**
2345
+ * Open a prompt session on stdin, queueing lines as they arrive.
2346
+ *
2347
+ * Reading one `rl.question` at a time drops piped input: it lands as a single
2348
+ * chunk, so every line after the first is emitted with no question pending.
2349
+ * End of input answers the remaining questions with their defaults.
2350
+ */
2351
+ function openPromptSession() {
2352
+ const rl = readline.createInterface({
2353
+ input: process.stdin,
2354
+ output: process.stdout
2355
+ });
2356
+ const queued = [];
2357
+ const waiting = [];
2358
+ let ended = false;
2359
+ rl.on("line", (line) => {
2360
+ const next = waiting.shift();
2361
+ if (next) next(line);
2362
+ else queued.push(line);
2363
+ });
2364
+ rl.on("close", () => {
2365
+ ended = true;
2366
+ while (waiting.length > 0) waiting.shift()?.(null);
2367
+ });
2368
+ function nextLine() {
2369
+ if (queued.length > 0) return Promise.resolve(queued.shift());
2370
+ if (ended) return Promise.resolve(null);
2371
+ return new Promise((resolve) => waiting.push(resolve));
2372
+ }
2373
+ return {
2374
+ async ask(question, defaultVal) {
2375
+ const offered = defaultVal ? ` (${defaultVal})` : "";
2376
+ process.stdout.write(`${question}${offered}: `);
2377
+ const line = await nextLine();
2378
+ if (line === null) {
2379
+ process.stdout.write("\n");
2380
+ return defaultVal;
2381
+ }
2382
+ return line.trim() || defaultVal;
2383
+ },
2384
+ close() {
2385
+ rl.close();
2386
+ }
2387
+ };
2388
+ }
2389
+ /**
2390
+ * Whether a question can actually be put to somebody. A piped or redirected
2391
+ * stdin still answers — with end-of-input — so this only tells a caller whether
2392
+ * a *default* is going to be what the user gets.
2393
+ */
2394
+ function isInteractive() {
2395
+ return Boolean(process.stdin.isTTY);
2396
+ }
2397
+ //#endregion
2398
+ //#region ../shared/public-address.ts
2399
+ const INVOCATION_PREFIX = "/x";
2400
+ const SLUG_SEPARATOR = "--";
2401
+ const SURFACE_ROUTE = "/v1/invocation-surface";
2402
+ const SUBTREE_NOTE = "Every path beneath this address reaches the Function.";
2403
+ const ROOT_FUNCTION_SLUG = "root";
2404
+ /**
2405
+ * A gateway that serves no form of the address — and the value a client uses for
2406
+ * one that did not answer at all, deliberately the same: both name no address,
2407
+ * and there is nothing else either could honestly do.
2408
+ */
2409
+ const NO_INVOCATION_SURFACE = {
2410
+ contentOrigin: null,
2411
+ pathFormOrigin: null
2412
+ };
2413
+ async function fetchInvocationSurface(gatewayUrl) {
2414
+ const origin = trimTrailingSlashes(gatewayUrl);
2415
+ const res = await fetch(`${origin}${SURFACE_ROUTE}`);
2416
+ if (!res.ok) throw new Error(`Failed to read the gateway's address forms (HTTP ${res.status}).`);
2417
+ const body = await res.json();
2418
+ return {
2419
+ contentOrigin: body.content_origin ?? null,
2420
+ pathFormOrigin: body.path_form ? origin : null
2421
+ };
2422
+ }
2423
+ /** The one address the Function answers at, `null` where it has none. */
2424
+ function publicAddress(surface, tenantSlug, appSlug, functionSlug) {
2425
+ const base = appBase(surface, tenantSlug, appSlug);
2426
+ if (base === null) return null;
2427
+ if (functionSlug === "root") return base;
2428
+ return `${base}/${encodeURIComponent(functionSlug)}`;
2429
+ }
2430
+ /**
2431
+ * Where one of a Function's static files answers, `null` where none of them do.
2432
+ *
2433
+ * A **Static asset** is served on the App's own hostname and on no other
2434
+ * address, so a gateway serving only the path form has no address to give for
2435
+ * one — see **Static route** in `CONTEXT.md`.
2436
+ */
2437
+ function assetAddress(surface, tenantSlug, appSlug, functionSlug, assetPath) {
2438
+ if (!surface.contentOrigin) return null;
2439
+ const mount = publicAddress(surface, tenantSlug, appSlug, functionSlug);
2440
+ if (mount === null) return null;
2441
+ return `${mount}/${assetPath.split("/").map(encodeURIComponent).join("/")}`;
2442
+ }
2443
+ /** The same address with the app and function still to be chosen. */
2444
+ function publicAddressTemplate(surface, tenantSlug) {
2445
+ if (surface.contentOrigin) {
2446
+ if (!isLegalDnsLabel(tenantSlug)) return null;
2447
+ const hostname = appHostname(surface.contentOrigin, `${tenantSlug}${SLUG_SEPARATOR}<app>`);
2448
+ return hostname === null ? null : `${hostname}/<function>`;
2449
+ }
2450
+ if (surface.pathFormOrigin) return `${pathFormBase(surface.pathFormOrigin, tenantSlug)}/<app>/<function>`;
2451
+ return null;
2452
+ }
2453
+ function appBase(surface, tenantSlug, appSlug) {
2454
+ if (surface.contentOrigin) {
2455
+ if (!isLegalDnsLabel(tenantSlug) || !isLegalDnsLabel(appSlug)) return null;
2456
+ return appHostname(surface.contentOrigin, `${tenantSlug}${SLUG_SEPARATOR}${appSlug}`);
2457
+ }
2458
+ if (surface.pathFormOrigin) return `${pathFormBase(surface.pathFormOrigin, tenantSlug)}/${encodeURIComponent(appSlug)}`;
2459
+ return null;
2460
+ }
2461
+ /**
2462
+ * The App's own hostname, built the way the gateway rebuilds it from its own
2463
+ * configuration: the label one level beneath the content domain. Callers check
2464
+ * the slugs the label is made of, since a slug the gateway would refuse to read
2465
+ * a `Host` for names no App there.
2403
2466
  */
2404
- function isDocument(assetPath) {
2405
- const name = assetPath.split("/").pop() ?? "";
2406
- const dot = name.lastIndexOf(".");
2407
- const extension = dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
2408
- return extension === "html" || extension === "htm";
2467
+ function appHostname(contentOrigin, label) {
2468
+ let origin;
2469
+ try {
2470
+ origin = new URL(contentOrigin);
2471
+ } catch {
2472
+ return null;
2473
+ }
2474
+ return `${origin.protocol}//${label}.${origin.host}`;
2409
2475
  }
2410
- function hashFile(file) {
2411
- return sha256(fs.readFileSync(file));
2476
+ /** The surface, or none where the gateway did not answer. */
2477
+ async function invocationSurfaceOrNone(gatewayUrl) {
2478
+ try {
2479
+ return await fetchInvocationSurface(gatewayUrl);
2480
+ } catch {
2481
+ return NO_INVOCATION_SURFACE;
2482
+ }
2412
2483
  }
2413
- function sha256(bytes) {
2414
- return crypto.createHash("sha256").update(bytes).digest("hex");
2484
+ function pathFormBase(origin, tenantSlug) {
2485
+ return `${trimTrailingSlashes(origin)}${INVOCATION_PREFIX}/${encodeURIComponent(tenantSlug)}`;
2415
2486
  }
2416
2487
  /**
2417
- * What this deploy *is*: the server bundle together with the sorted set of
2418
- * asset path and content-hash pairs, and the **Fallback document** it declares.
2419
- *
2420
- * Computed here, before anything is uploaded, so a deploy that changed nothing
2421
- * is refused before a byte moves. Mirrors `deploy_digest` in
2422
- * `server/crates/core/src/features/assets/identity.rs` — the gateway recomputes
2423
- * it from what actually arrives, so the two have to agree exactly.
2424
- *
2425
- * A deploy declaring no fallback contributes nothing here rather than a stated
2426
- * absence, so every digest computed before fallbacks existed is unmoved.
2488
+ * The legal DNS label closest to what somebody typed, empty where nothing legal
2489
+ * survives. What a workspace name suggests as an address, and what the CLI
2490
+ * offers as a default — never applied to a typed address, which is refused
2491
+ * rather than rewritten into a name nobody chose.
2427
2492
  */
2428
- const DEPLOY_DIGEST_DOMAIN = "wawesome-deploy-v1";
2429
- function deployDigest(bundle, assets, fallback) {
2430
- const digest = crypto.createHash("sha256");
2431
- digest.update(`${DEPLOY_DIGEST_DOMAIN}\n`);
2432
- digest.update(`${sha256(bundle)}\n`);
2433
- for (const asset of [...assets].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) digest.update(`${asset.path}\0${asset.content_hash}\n`);
2434
- if (fallback) digest.update(`fallback\0${fallback.path}\0${fallback.status}\n`);
2435
- return digest.digest("hex");
2493
+ function slugifyLabel(raw) {
2494
+ let label = "";
2495
+ for (const c of raw.trim().toLowerCase()) if (c >= "a" && c <= "z" || c >= "0" && c <= "9") label += c;
2496
+ else if (label.length > 0 && !label.endsWith("-")) label += "-";
2497
+ label = label.slice(0, 63);
2498
+ while (label.endsWith("-")) label = label.slice(0, -1);
2499
+ return label;
2436
2500
  }
2437
- /** The manifest as the gateway reads it — the bytes on disk are the CLI's business. */
2438
- function manifestOf(assets) {
2439
- return assets.map(({ path, content_hash, size_bytes }) => ({
2440
- path,
2441
- content_hash,
2442
- size_bytes
2443
- }));
2501
+ /** Must match `dns_label::is_legal` in `server/crates/core/src/value_objects.rs`. */
2502
+ function isLegalDnsLabel(candidate) {
2503
+ return candidate.length > 0 && candidate.length <= 63 && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
2504
+ }
2505
+ function trimTrailingSlashes(origin) {
2506
+ return origin.replace(/\/+$/, "");
2444
2507
  }
2445
2508
  //#endregion
2446
2509
  //#region src/deploy-target.ts
@@ -3885,6 +3948,33 @@ function renderPostDeploy(postDeploy, address) {
3885
3948
  return postDeploy.split("{{url}}").join(address);
3886
3949
  }
3887
3950
  //#endregion
3951
+ //#region src/slug.ts
3952
+ /**
3953
+ * The character rules every slug on the platform obeys, mirrored from the
3954
+ * `dns_label` module in `server/crates/core/src/value_objects.rs`.
3955
+ *
3956
+ * A typed name is checked while the user is still standing there, rather than
3957
+ * accepted, sent, and rewritten by the gateway into something else that ends up
3958
+ * in a client's dashboard. The rules live beside the address they build, so the
3959
+ * CLI and the dashboard cannot come to disagree about what a legal name is.
3960
+ */
3961
+ /** Maximum length of a single DNS label (RFC 1035 §2.3.4). */
3962
+ const MAX_SLUG_LENGTH = 63;
3963
+ /**
3964
+ * Normalise arbitrary input into a legal DNS label, returning an empty string
3965
+ * when nothing legal survives — callers decide whether that is an error.
3966
+ */
3967
+ function sanitizeSlug(raw) {
3968
+ return slugifyLabel(raw);
3969
+ }
3970
+ /**
3971
+ * Whether `candidate` is already a legal DNS label — i.e. whether
3972
+ * {@link sanitizeSlug} would leave it untouched.
3973
+ */
3974
+ function isLegalSlug(candidate) {
3975
+ return candidate.length > 0 && candidate.length <= MAX_SLUG_LENGTH && !candidate.startsWith("-") && !candidate.endsWith("-") && !candidate.includes("--") && /^[a-z0-9-]+$/.test(candidate);
3976
+ }
3977
+ //#endregion
3888
3978
  //#region src/tarball.ts
3889
3979
  /**
3890
3980
  * Reading a repository archive, so scaffolding needs no git.
@@ -5576,7 +5666,7 @@ async function renameWorkspace(slug, options) {
5576
5666
  console.log("✅ \x1B[32mWORKSPACE RENAMED\x1B[0m");
5577
5667
  console.log("======================================================");
5578
5668
  console.log(`\n Address: ${result.tenant_slug}`);
5579
- console.log(` Previous: ${result.previous_slug} (still resolves)`);
5669
+ console.log(` Previous: ${result.previous_slug} (no longer resolves)`);
5580
5670
  console.log("======================================================\n");
5581
5671
  } catch (err) {
5582
5672
  if (err instanceof GatewayError) {
@@ -5591,12 +5681,117 @@ async function renameWorkspace(slug, options) {
5591
5681
  process.exit(1);
5592
5682
  }
5593
5683
  }
5684
+ /**
5685
+ * The workspaces this person belongs to, with the one the session is in
5686
+ * marked.
5687
+ *
5688
+ * Which one that is comes from the platform rather than from the credentials
5689
+ * file: a switch in another terminal moved the session without this one
5690
+ * hearing about it.
5691
+ */
5692
+ async function listWorkspaces(options) {
5693
+ const read = await requireWorkspaces(requireSession("Your workspaces would be read for that credential's Tenant alone."));
5694
+ console.log("\n[wawesome] Your workspaces:\n");
5695
+ const width = Math.max(...read.workspaces.map((workspace) => workspace.name.length));
5696
+ for (const workspace of read.workspaces) {
5697
+ const mark = workspace.tenant_id === read.current_tenant_id ? "\x1B[32m*\x1B[0m" : " ";
5698
+ const tenant = options.verbose ? ` ${workspace.tenant_id}` : "";
5699
+ console.log(` ${mark} ${workspace.name.padEnd(width)} ${workspace.tenant_slug} (${workspace.role})${tenant}`);
5700
+ }
5701
+ if (read.workspaces.length > 1) console.log("\n Move with: \x1B[36mwawesome workspace switch <name>\x1B[0m");
5702
+ console.log("");
5703
+ }
5704
+ /**
5705
+ * Move the session to another workspace.
5706
+ *
5707
+ * The list is read first, so a name is resolved against what the person
5708
+ * actually belongs to and a workspace that is none of theirs is answered with
5709
+ * the ones that are. The platform checks the **Membership** again either way,
5710
+ * so this is where somebody is told rather than where it is decided.
5711
+ */
5712
+ async function switchWorkspace(target, options) {
5713
+ const creds = requireSession("A switch would move a session that credential does not have.");
5714
+ if (!target || !target.trim()) {
5715
+ console.error("[wawesome] Error: No workspace given. Usage: wawesome workspace switch <name>");
5716
+ process.exit(1);
5717
+ return;
5718
+ }
5719
+ const read = await requireWorkspaces(creds);
5720
+ const asked = target.trim();
5721
+ const matched = read.workspaces.filter((workspace) => workspace.tenant_slug === asked || workspace.tenant_id === asked || workspace.name.toLowerCase() === asked.toLowerCase());
5722
+ if (matched.length === 0) return notYours(asked, read);
5723
+ if (matched.length > 1) return sharedName(asked, matched);
5724
+ const [moving] = matched;
5725
+ if (moving.tenant_id === read.current_tenant_id) {
5726
+ console.log(`[wawesome] Already in ${moving.name} (${moving.tenant_slug}). Nothing changed.`);
5727
+ return;
5728
+ }
5729
+ if (options.verbose) console.log(`[wawesome:verbose] Switching to ${moving.tenant_id}`);
5730
+ try {
5731
+ await switchWorkspaceTo(creds, moving.tenant_id);
5732
+ } catch (err) {
5733
+ console.error(`[wawesome] Error: ${errorText(err)}`);
5734
+ process.exit(1);
5735
+ return;
5736
+ }
5737
+ console.log("\n======================================================");
5738
+ console.log("✅ \x1B[32mWORKSPACE SWITCHED\x1B[0m");
5739
+ console.log("======================================================");
5740
+ console.log(`\n Workspace: ${moving.name}`);
5741
+ console.log(` Address: ${moving.tenant_slug}`);
5742
+ console.log(` Role: ${moving.role}`);
5743
+ console.log("======================================================\n");
5744
+ }
5594
5745
  /** Dispatch for `wawesome workspace [action]`. */
5595
5746
  async function workspaceCommand(action, target, options) {
5596
5747
  if (!action || action === "show" || action === "info") return showWorkspace(options);
5597
5748
  if (action === "rename") return renameWorkspace(target, options);
5749
+ if (action === "list" || action === "ls") return listWorkspaces(options);
5750
+ if (action === "switch") return switchWorkspace(target, options);
5598
5751
  console.error(`[wawesome] Error: Unknown workspace action '${action}'.`);
5599
- console.error("[wawesome] Usage: wawesome workspace [show|rename <name>]");
5752
+ console.error("[wawesome] Usage: wawesome workspace [show|list|switch <name>|rename <name>]");
5753
+ process.exit(1);
5754
+ }
5755
+ /**
5756
+ * The session these two commands act on, which is a person's and never a
5757
+ * **Deploy credential**: a credential names one Tenant and holds no
5758
+ * **Membership** anywhere, so there is nothing for it to list or move.
5759
+ */
5760
+ function requireSession(consequence) {
5761
+ const credential = credentialFromEnvironment();
5762
+ if (credential) {
5763
+ reportSessionCommandRefused(credential, consequence);
5764
+ process.exit(1);
5765
+ }
5766
+ const creds = requireCredentials$1();
5767
+ if (!creds.refresh_token) {
5768
+ console.error("[wawesome] Error: This session predates workspace switching.");
5769
+ console.error("[wawesome] Run 'wawesome login' to sign in again.");
5770
+ process.exit(1);
5771
+ }
5772
+ return creds;
5773
+ }
5774
+ async function requireWorkspaces(creds) {
5775
+ try {
5776
+ return await readWorkspaces(creds);
5777
+ } catch (err) {
5778
+ console.error(`[wawesome] Error: ${errorText(err)}`);
5779
+ process.exit(1);
5780
+ }
5781
+ }
5782
+ /** Refused here rather than at the platform, and answered with the ones that are theirs. */
5783
+ function notYours(asked, read) {
5784
+ refuse(`You are not a member of '${asked}'.`, "The workspaces you are in:", read.workspaces.map((workspace) => `${workspace.name} (${workspace.tenant_slug})`));
5785
+ }
5786
+ /** Two workspaces may be called the same thing; their addresses cannot. */
5787
+ function sharedName(asked, matched) {
5788
+ refuse(`More than one of your workspaces is called '${asked}'.`, "Name one by its address instead:", matched.map((workspace) => `wawesome workspace switch ${workspace.tenant_slug}`));
5789
+ }
5790
+ function refuse(headline, next, lines) {
5791
+ console.error(`\n[wawesome] \x1b[31m${headline}\x1b[0m`);
5792
+ console.error(`[wawesome] ${next}`);
5793
+ for (const line of lines) console.error(` ${line}`);
5794
+ console.error("");
5600
5795
  process.exit(1);
5601
5796
  }
5602
5797
  //#endregion
@@ -5657,16 +5852,144 @@ const MINT_PRESETS = [
5657
5852
  "write:apps",
5658
5853
  "write:functions",
5659
5854
  "write:env",
5855
+ "write:domains",
5660
5856
  "write:runs",
5661
5857
  "write:schedules",
5662
5858
  "write:previews"
5663
5859
  ],
5664
- summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, run Functions, pause and resume schedules and create previews. It does not attach domains."
5860
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, run Functions, pause and resume schedules and create previews."
5665
5861
  }
5666
5862
  ];
5667
5863
  function mintPreset(name) {
5668
5864
  return MINT_PRESETS.find((preset) => preset.name === name);
5669
5865
  }
5866
+ [
5867
+ {
5868
+ code: "owner",
5869
+ job: "Own the workspace",
5870
+ capabilities: [
5871
+ "read:tenant",
5872
+ "write:tenant",
5873
+ "read:apps",
5874
+ "write:apps",
5875
+ "read:functions",
5876
+ "write:functions",
5877
+ "read:env",
5878
+ "write:env",
5879
+ "read:domains",
5880
+ "write:domains",
5881
+ "delete:domains",
5882
+ "read:invocations",
5883
+ "read:runs",
5884
+ "write:runs",
5885
+ "read:schedules",
5886
+ "write:schedules",
5887
+ "read:previews",
5888
+ "write:previews",
5889
+ "read:egress",
5890
+ "write:egress",
5891
+ "write:billing",
5892
+ "write:credentials",
5893
+ "write:members"
5894
+ ],
5895
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can rename the workspace, create Apps, deploy Functions, set environment variables, attach domains, detach domains, run Functions, pause and resume schedules, create previews, widen the egress allowlist, change billing, manage credentials and manage members.",
5896
+ withheldFromAPerson: "A workspace has one owner, so 'owner' is not a standing anybody is put at."
5897
+ },
5898
+ {
5899
+ code: "admin",
5900
+ job: "Administer the workspace",
5901
+ capabilities: [
5902
+ "read:tenant",
5903
+ "read:apps",
5904
+ "write:apps",
5905
+ "read:functions",
5906
+ "write:functions",
5907
+ "read:env",
5908
+ "write:env",
5909
+ "read:domains",
5910
+ "write:domains",
5911
+ "delete:domains",
5912
+ "read:invocations",
5913
+ "read:runs",
5914
+ "write:runs",
5915
+ "read:schedules",
5916
+ "write:schedules",
5917
+ "read:previews",
5918
+ "write:previews",
5919
+ "read:egress",
5920
+ "write:egress",
5921
+ "write:credentials",
5922
+ "write:members"
5923
+ ],
5924
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, detach domains, run Functions, pause and resume schedules, create previews, widen the egress allowlist, manage credentials and manage members. It does not rename the workspace or change billing.",
5925
+ withheldFromAPerson: null
5926
+ },
5927
+ {
5928
+ code: "member",
5929
+ job: "Build in the workspace",
5930
+ capabilities: [
5931
+ "read:tenant",
5932
+ "read:apps",
5933
+ "read:functions",
5934
+ "read:env",
5935
+ "read:domains",
5936
+ "read:invocations",
5937
+ "read:runs",
5938
+ "read:schedules",
5939
+ "read:previews",
5940
+ "read:egress",
5941
+ "write:apps",
5942
+ "write:functions",
5943
+ "write:env",
5944
+ "write:domains",
5945
+ "write:runs",
5946
+ "write:schedules",
5947
+ "write:previews"
5948
+ ],
5949
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables, attach domains, run Functions, pause and resume schedules and create previews. It does not rename the workspace, detach domains, widen the egress allowlist, change billing, manage credentials or manage members.",
5950
+ withheldFromAPerson: null
5951
+ },
5952
+ {
5953
+ code: "deployer",
5954
+ job: "Ship and read back",
5955
+ capabilities: [
5956
+ "read:tenant",
5957
+ "read:apps",
5958
+ "read:functions",
5959
+ "read:env",
5960
+ "read:domains",
5961
+ "read:invocations",
5962
+ "read:runs",
5963
+ "read:schedules",
5964
+ "read:previews",
5965
+ "read:egress",
5966
+ "write:apps",
5967
+ "write:functions",
5968
+ "write:env",
5969
+ "write:runs"
5970
+ ],
5971
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist, and can create Apps, deploy Functions, set environment variables and run Functions. It does not rename the workspace, attach domains, detach domains, pause and resume schedules, create previews, widen the egress allowlist, change billing, manage credentials or manage members.",
5972
+ withheldFromAPerson: "'deployer' is the recipe a Deploy credential is cut from. A pipeline gets a credential of its own rather than somebody's account."
5973
+ },
5974
+ {
5975
+ code: "viewer",
5976
+ job: "Read and investigate",
5977
+ capabilities: [
5978
+ "read:tenant",
5979
+ "read:apps",
5980
+ "read:functions",
5981
+ "read:env",
5982
+ "read:domains",
5983
+ "read:invocations",
5984
+ "read:runs",
5985
+ "read:schedules",
5986
+ "read:previews",
5987
+ "read:egress"
5988
+ ],
5989
+ summary: "Reads the workspace, Apps, Functions, environment variables, domains, invocations, runs, schedules, previews and the egress allowlist. It writes nothing.",
5990
+ withheldFromAPerson: "'viewer' reaches every read in the workspace: the plan, what it has spent, the environment variables and every App. Nobody is put at it."
5991
+ }
5992
+ ].filter((role) => role.withheldFromAPerson === null);
5670
5993
  //#endregion
5671
5994
  //#region src/deploy-credentials.ts
5672
5995
  const SECRET_SHOWN_ONCE = "Copy it now. This is the only time it will be shown. If you lose it, revoke this credential and mint another.";
@@ -6127,10 +6450,10 @@ cli.command("env rm <key>", "Delete an environment variable from the current app
6127
6450
  cli.command("domains [action] [hostname]", "Show the custom domain on the current app, its state and its DNS records").usage("domains [action] [hostname]\n\nWith no action, prints the app's domain, every half of it, where each got to, when the platform last checked, and the records to add.\n\nActions:\n adopt Write the attached domain into wawesome-function.json\n detach <hostname> Stop serving the app at a domain you attached\n\nA domain is attached by declaring it in wawesome-function.json and deploying, from the dashboard, or by an agent.").example("wawesome domains").example("wawesome domains adopt").example("wawesome domains detach shop.client.com").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((action, hostname, options) => domainsCommand(action, hostname, options));
6128
6451
  cli.command("domains adopt", "Write the domain the current app answers at into wawesome-function.json").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((options) => adoptDomainCommand(void 0, options));
6129
6452
  cli.command("domains detach <hostname>", "Stop serving the current app at a domain you attached").option("-a, --app <slug>", "App to act on (default: the app in wawesome-function.json)").action((hostname, options) => detachDomainCommand(hostname, options));
6130
- cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--provider <name>", "OAuth provider (default: github)").option("--workspace <name>", "Name for the workspace, when signing up without a terminal to prompt").option("--address <name>", "Public address for the workspace being created (default: one is minted)").option("--accept-terms", "Accept the terms of service and privacy policy, when signing up without a terminal to prompt").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
6131
- cli.command("logout", "Clear stored authentication credentials").action(() => logout());
6453
+ cli.command("login", "Authenticate with the wawesome.io platform").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--gateway <url>", "Alias for --api <url>").option("--dashboard <url>", "Dashboard URL to record (default: the one the platform names)").option("-v, --verbose", "Enable verbose debug output").action((options) => login(options));
6454
+ cli.command("logout", "Clear stored authentication credentials").action(async () => await logout());
6132
6455
  cli.command("whoami", "Show current login session info").action(() => whoami());
6133
- cli.command("workspace [action] [name]", "Show the workspace, or rename its public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
6456
+ cli.command("workspace [action] [name]", "Show the workspace, move between workspaces, or rename the public address").usage("workspace <action> [name]\n\nActions:\n show Show the workspace name, address, and whether it can still change\n list (ls) Show the workspaces you belong to, and which one you are in\n switch <name> Move this terminal to another workspace you belong to\n rename <name> Change the public address, while nothing live depends on it").example("wawesome workspace").example("wawesome workspace list").example("wawesome workspace switch northwind").example("wawesome workspace rename northwind").option("-v, --verbose", "Enable verbose debug output").action((action, name, options) => workspaceCommand(action, name, options));
6134
6457
  cli.command("templates [action]", "Browse the template catalog").usage("templates [action]\n\nActions:\n list (ls) Show every available template (default)").example("wawesome templates").example("wawesome templates list").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("-v, --verbose", "Enable verbose debug output").action((action, options) => templatesCommand(action, options));
6135
6458
  cli.command("credentials [action] [target]", "Mint, list, regenerate, revoke and delete deploy credentials").alias("credential").usage(`credentials <action> [options]
6136
6459
 
@@ -6166,7 +6489,7 @@ cli.command("credentials list", "List deploy credentials, never their secrets").
6166
6489
  cli.command("credentials regenerate <name-or-prefix>", "Replace a deploy credential's secret, keeping its name, capabilities and Apps").option("--expires <days|never>", "Days until the regenerated credential expires, or 'never' (default: 90)").option("-y, --yes", "Regenerate without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => regenerateCredentialCommand(target, options));
6167
6490
  cli.command("credentials revoke <name-or-prefix>", "Revoke a deploy credential, immediately and for good").option("-y, --yes", "Revoke without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => revokeCredentialCommand(target, options));
6168
6491
  cli.command("credentials delete <name-or-prefix>", "Delete a revoked or expired deploy credential, freeing its name").option("-y, --yes", "Delete without being asked to confirm").option("-v, --verbose", "Enable verbose debug output").action((target, options) => deleteCredentialCommand(target, options));
6169
- cli.command("init", "Scaffold a new function project in the current directory").usage("init [options]\n\nWith --template, the project is fetched from the template catalog, wired up\nfrom what the template declares it needs, and deployed. Run 'wawesome templates'\nto see what is available.").example("wawesome init").example("wawesome init --template stripe-webhook").option("-t, --template <name>", "Scaffold from a catalog template and deploy it").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--dashboard <url>", "Dashboard URL (default: https://dashboard.wawesome.io)").option("--no-install", "Skip installing dependencies after scaffolding").option("--root", "Generate a root function router template").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
6492
+ cli.command("init", "Scaffold a new function project in the current directory").usage("init [options]\n\nWith --template, the project is fetched from the template catalog, wired up\nfrom what the template declares it needs, and deployed. Run 'wawesome templates'\nto see what is available.").example("wawesome init").example("wawesome init --template stripe-webhook").option("-t, --template <name>", "Scaffold from a catalog template and deploy it").option("--api <url>", "API URL (default: https://api.wawesome.io)").option("--dashboard <url>", "Dashboard URL to record (default: the one the platform names)").option("--no-install", "Skip installing dependencies after scaffolding").option("--root", "Generate a root function router template").option("-v, --verbose", "Enable verbose debug output").action((options) => init(options));
6170
6493
  cli.command("logs [function-name-or-invocation-id]", "View invocation history, fetch log output, or follow live").usage(`logs [target] [options]
6171
6494
 
6172
6495
  The target argument determines what the command does: