@supacloud/cli 0.108.1 → 0.108.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +126 -93
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -27016,125 +27016,152 @@ import { ${className}Fixture } from "./${name}.fixture";
27016
27016
  const baseUrl = process.env["SUPACLOUD_FEATURE_E2E_URL"];
27017
27017
  const authorization = process.env["SUPACLOUD_FEATURE_E2E_AUTHORIZATION"];
27018
27018
  const otherAuthorization = process.env["SUPACLOUD_FEATURE_E2E_OTHER_AUTHORIZATION"];
27019
- const fixtureId = process.env["SUPACLOUD_FEATURE_E2E_ID"] ?? ${className}Fixture.id;
27019
+ const fixtureId = process.env["SUPACLOUD_FEATURE_E2E_ID"];
27020
+ const raceId = process.env["SUPACLOUD_FEATURE_E2E_RACE_ID"];
27020
27021
  const idempotencyKey = process.env["SUPACLOUD_FEATURE_E2E_IDEMPOTENCY_KEY"];
27021
- const enabled = Boolean(baseUrl && authorization && idempotencyKey);
27022
- const tenantIsolationEnabled = Boolean(enabled && otherAuthorization);
27023
-
27024
- function requireConfig(): { baseUrl: string; authorization: string; idempotencyKey: string } {
27025
- if (!baseUrl || !authorization || !idempotencyKey) {
27026
- throw new Error(
27027
- "Set SUPACLOUD_FEATURE_E2E_URL, SUPACLOUD_FEATURE_E2E_AUTHORIZATION and " +
27028
- "SUPACLOUD_FEATURE_E2E_IDEMPOTENCY_KEY before running this generated E2E test.",
27029
- );
27022
+ const required = process.env["SUPACLOUD_FEATURE_E2E_REQUIRED"] === "1";
27023
+ const enabled = required || [baseUrl, authorization, otherAuthorization, fixtureId, raceId, idempotencyKey]
27024
+ .some(value => value !== undefined);
27025
+
27026
+ function requireConfig() {
27027
+ if (![baseUrl, authorization, otherAuthorization, fixtureId, raceId, idempotencyKey]
27028
+ .every(value => typeof value === "string" && value.trim().length > 0)) {
27029
+ throw new Error("Feature acceptance requires URL, AUTHORIZATION, OTHER_AUTHORIZATION, ID, RACE_ID and IDEMPOTENCY_KEY (SUPACLOUD_FEATURE_E2E_*).");
27030
+ }
27031
+ if (fixtureId === raceId) throw new Error("Feature acceptance requires two distinct draft fixtures.");
27032
+ const url = new URL(baseUrl!);
27033
+ if (url.username || url.password || url.search || url.hash
27034
+ || (url.protocol !== "https:" && !(url.protocol === "http:"
27035
+ && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
27036
+ throw new Error("Feature acceptance requires HTTPS or a loopback HTTP endpoint without embedded credentials.");
27030
27037
  }
27031
- return { baseUrl, authorization, idempotencyKey };
27038
+ return { baseUrl: url, authorization: authorization!, otherAuthorization: otherAuthorization!,
27039
+ fixtureId: fixtureId!, raceId: raceId!, idempotencyKey: idempotencyKey! };
27032
27040
  }
27033
27041
 
27034
- async function json(response: Response): Promise<unknown> {
27035
- const text = await response.text();
27036
- if (!text) return undefined;
27037
- try {
27038
- return JSON.parse(text) as unknown;
27039
- } catch {
27040
- return text;
27041
- }
27042
+ function request(config: ReturnType<typeof requireConfig>, path: string, init: RequestInit = {}) {
27043
+ return fetch(new URL(path, config.baseUrl), {
27044
+ ...init, redirect: "error", signal: AbortSignal.timeout(10_000),
27045
+ });
27042
27046
  }
27043
27047
 
27044
- function containsFixture(value: unknown): boolean {
27045
- return JSON.stringify(value).includes(fixtureId);
27048
+ async function object(response: Response): Promise<Record<string, unknown>> {
27049
+ const value: unknown = await response.json();
27050
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
27051
+ throw new Error("Feature acceptance expected a JSON object.");
27052
+ }
27053
+ return value as Record<string, unknown>;
27046
27054
  }
27047
27055
 
27048
- test.skipIf(!enabled)("${name} authenticated list/detail/approval E2E", async () => {
27056
+ test.skipIf(!enabled)("${name} authenticated approval, replay, recovery and concurrency acceptance", async () => {
27049
27057
  const config = requireConfig();
27050
27058
  const headers = { authorization: config.authorization, "content-type": "application/json" };
27051
- const list = await fetch(new URL("/${name}", config.baseUrl), { headers });
27059
+ const path = (id: string) => "/${name}/" + encodeURIComponent(id);
27060
+ const list = await request(config, "/${name}", { headers });
27052
27061
  expect(list.ok).toBe(true);
27053
- const detail = await fetch(
27054
- new URL("/${name}/" + encodeURIComponent(fixtureId), config.baseUrl),
27055
- { headers },
27056
- );
27062
+ const listed: unknown = await list.json();
27063
+ expect(Array.isArray(listed)).toBe(true);
27064
+ expect(listed).toContainEqual(expect.objectContaining({ id: config.fixtureId }));
27065
+ const detail = await request(config, path(config.fixtureId), { headers });
27057
27066
  expect(detail.ok).toBe(true);
27058
- const approve = (key: string, body = ${className}Fixture.approve) => fetch(
27059
- new URL("/${name}/" + encodeURIComponent(fixtureId) + "/approve", config.baseUrl),
27067
+ const before = await object(detail);
27068
+ expect(before).toMatchObject({ id: config.fixtureId, state: "draft" });
27069
+ expect(Number.isSafeInteger(before["version"])).toBe(true);
27070
+ expect(Number(before["version"])).toBeGreaterThanOrEqual(1);
27071
+ const body = { ...${className}Fixture.approve, expectedVersion: Number(before["version"]) };
27072
+ const approve = (id: string, key: string, input = body) => request(
27073
+ config, path(id) + "/approve",
27060
27074
  {
27061
- method: "POST",
27062
- headers: { ...headers, "idempotency-key": key },
27063
- body: JSON.stringify(body),
27075
+ method: "POST", headers: { ...headers, "idempotency-key": key },
27076
+ body: JSON.stringify(input),
27064
27077
  },
27065
27078
  );
27066
- const first = await approve(config.idempotencyKey);
27067
- const firstBody = await json(first);
27068
- expect([200, 409, 422]).toContain(first.status);
27069
-
27070
- // A lost response is recovered through the durable receipt, never by
27071
- // submitting the approval again.
27072
- const recovered = await fetch(
27073
- new URL("/${name}/" + encodeURIComponent(fixtureId) + "/operations/" + encodeURIComponent(config.idempotencyKey), config.baseUrl),
27074
- { headers },
27075
- );
27079
+ const first = await approve(config.fixtureId, config.idempotencyKey);
27080
+ expect(first.status).toBe(200);
27081
+ const firstBody = await object(first);
27082
+ expect(firstBody).toMatchObject({ id: config.fixtureId, state: "approved" });
27083
+ expect(Number.isSafeInteger(firstBody["version"])).toBe(true);
27084
+ expect(Number(firstBody["version"])).toBeGreaterThan(Number(before["version"]));
27085
+
27086
+ // Read-only reconciliation is exercised here; this is not a network-loss or worker-crash test.
27087
+ const operationPath = path(config.fixtureId) + "/operations/" + encodeURIComponent(config.idempotencyKey);
27088
+ const recovered = await request(config, operationPath, { headers });
27076
27089
  expect(recovered.ok).toBe(true);
27077
- const recoveredBody = await json(recovered);
27078
- expect(recoveredBody).toMatchObject({ operationId: config.idempotencyKey });
27079
- const jobStatus = await fetch(
27080
- new URL("/${name}/" + encodeURIComponent(fixtureId) + "/operations/" + encodeURIComponent(config.idempotencyKey) + "/status", config.baseUrl),
27081
- { headers },
27082
- );
27090
+ expect(await object(recovered)).toMatchObject({
27091
+ id: config.fixtureId, operationId: config.idempotencyKey,
27092
+ status: "confirmed", state: firstBody["state"], version: firstBody["version"],
27093
+ });
27094
+ const jobStatus = await request(config, operationPath + "/status", { headers });
27083
27095
  expect(jobStatus.ok).toBe(true);
27084
- expect(await json(jobStatus)).toMatchObject({ operationId: config.idempotencyKey });
27096
+ const job = await object(jobStatus);
27097
+ expect(job).toMatchObject({ id: config.fixtureId, operationId: config.idempotencyKey });
27098
+ expect(typeof job["status"]).toBe("string");
27099
+ expect(["queued", "running", "completed"].some(status => status === job["status"])).toBe(true);
27085
27100
 
27086
- // Replaying an identical command may return the original success or a
27087
- // domain conflict, but it must never create a second successful transition.
27088
- const replay = await approve(config.idempotencyKey);
27089
- const replayBody = await json(replay);
27101
+ const replay = await approve(config.fixtureId, config.idempotencyKey);
27090
27102
  expect(replay.status).toBe(first.status);
27091
- expect(replayBody).toEqual(firstBody);
27103
+ expect(await object(replay)).toEqual(firstBody);
27092
27104
 
27093
- // Reusing a key with a different payload must be rejected by the receipt
27094
- // boundary instead of being treated as a new approval.
27105
+ // Conflicting replay and stale version must fail without changing the authoritative row.
27095
27106
  const conflicting = await approve(
27096
- config.idempotencyKey,
27097
- { ...${className}Fixture.approve, reason: "conflicting replay" },
27107
+ config.fixtureId, config.idempotencyKey, { ...body, reason: "conflicting replay" },
27098
27108
  );
27099
27109
  expect([400, 409, 422]).toContain(conflicting.status);
27100
-
27101
- // Two distinct keys racing on one version may commit at most one state
27102
- // transition. A fixture that is already terminal may reject both.
27110
+ const stale = await approve(config.fixtureId, config.idempotencyKey + "-stale");
27111
+ expect([409, 422]).toContain(stale.status);
27112
+ const after = await request(config, path(config.fixtureId), { headers });
27113
+ expect(after.ok).toBe(true);
27114
+ expect(await object(after)).toMatchObject(firstBody);
27115
+
27116
+ // Race on a separate fresh draft; racing the already-approved row proves nothing.
27117
+ const raceDetail = await request(config, path(config.raceId), { headers });
27118
+ expect(raceDetail.ok).toBe(true);
27119
+ const raceBefore = await object(raceDetail);
27120
+ expect(raceBefore).toMatchObject({ id: config.raceId, state: "draft" });
27121
+ expect(Number.isSafeInteger(raceBefore["version"])).toBe(true);
27122
+ expect(Number(raceBefore["version"])).toBeGreaterThanOrEqual(1);
27123
+ const raceBody = { ...body, expectedVersion: Number(raceBefore["version"]) };
27103
27124
  const [raceA, raceB] = await Promise.all([
27104
- approve(config.idempotencyKey + "-race-a"),
27105
- approve(config.idempotencyKey + "-race-b"),
27125
+ approve(config.raceId, config.idempotencyKey + "-race-a", raceBody),
27126
+ approve(config.raceId, config.idempotencyKey + "-race-b", raceBody),
27106
27127
  ]);
27107
- expect([raceA.status, raceB.status].filter((status) => status >= 200 && status < 300).length)
27108
- .toBeLessThanOrEqual(1);
27109
- });
27110
-
27111
- test.skipIf(!tenantIsolationEnabled)("${name} rejects cross-tenant access", async () => {
27128
+ const statuses = [raceA.status, raceB.status];
27129
+ expect(statuses.filter(status => status === 200)).toHaveLength(1);
27130
+ expect(statuses.filter(status => status === 409 || status === 422)).toHaveLength(1);
27131
+ const winner = await object(raceA.status === 200 ? raceA : raceB);
27132
+ expect(winner).toMatchObject({ id: config.raceId, state: "approved" });
27133
+ expect(Number.isSafeInteger(winner["version"])).toBe(true);
27134
+ expect(Number(winner["version"])).toBeGreaterThan(Number(raceBefore["version"]));
27135
+ const raceAfter = await request(config, path(config.raceId), { headers });
27136
+ expect(raceAfter.ok).toBe(true);
27137
+ expect(await object(raceAfter)).toMatchObject(winner);
27138
+ }, { timeout: 120_000 });
27139
+
27140
+ test.skipIf(!enabled)("${name} rejects cross-tenant reads, writes and receipt disclosure", async () => {
27112
27141
  const config = requireConfig();
27113
- if (!otherAuthorization) throw new Error("Missing SUPACLOUD_FEATURE_E2E_OTHER_AUTHORIZATION");
27114
27142
  const headers = {
27115
- authorization: otherAuthorization,
27143
+ authorization: config.otherAuthorization,
27116
27144
  "content-type": "application/json",
27117
27145
  };
27118
- const list = await fetch(new URL("/${name}", config.baseUrl), { headers });
27146
+ const list = await request(config, "/${name}", { headers });
27119
27147
  expect(list.ok).toBe(true);
27120
- expect(containsFixture(await json(list))).toBe(false);
27121
-
27122
- const detail = await fetch(
27123
- new URL("/${name}/" + encodeURIComponent(fixtureId), config.baseUrl),
27124
- { headers },
27125
- );
27126
- expect([403, 404]).toContain(detail.status);
27127
-
27128
- const approval = await fetch(
27129
- new URL("/${name}/" + encodeURIComponent(fixtureId) + "/approve", config.baseUrl),
27130
- {
27131
- method: "POST",
27132
- headers: { ...headers, "idempotency-key": config.idempotencyKey + "-other-tenant" },
27133
- body: JSON.stringify(${className}Fixture.approve),
27134
- },
27135
- );
27148
+ const listed: unknown = await list.json();
27149
+ expect(Array.isArray(listed)).toBe(true);
27150
+ expect(listed).not.toContainEqual(expect.objectContaining({ id: config.fixtureId }));
27151
+ expect(listed).not.toContainEqual(expect.objectContaining({ id: config.raceId }));
27152
+ const path = "/${name}/" + encodeURIComponent(config.fixtureId);
27153
+ const operationPath = path + "/operations/" + encodeURIComponent(config.idempotencyKey);
27154
+ for (const target of [path, operationPath, operationPath + "/status"]) {
27155
+ const response = await request(config, target, { headers });
27156
+ expect([403, 404]).toContain(response.status);
27157
+ }
27158
+ // Reuse the successful key: authorization must run before a cached success is disclosed.
27159
+ const approval = await request(config, path + "/approve", {
27160
+ method: "POST", headers: { ...headers, "idempotency-key": config.idempotencyKey },
27161
+ body: JSON.stringify(${className}Fixture.approve),
27162
+ });
27136
27163
  expect([403, 404]).toContain(approval.status);
27137
- });
27164
+ }, { timeout: 120_000 });
27138
27165
  `,
27139
27166
  [`${name}.deployment.md`]: `# ${className} feature delivery checklist
27140
27167
 
@@ -27162,10 +27189,16 @@ Add request fields in the contract, not a second handwritten Writer interface.
27162
27189
  Typecheck application adapters after changing a field type; the derived Port
27163
27190
  must expose incompatible assumptions in business code.
27164
27191
 
27165
- For authenticated tenant-isolation acceptance, also provide
27166
- \`SUPACLOUD_FEATURE_E2E_OTHER_AUTHORIZATION\` for a principal from another tenant.
27167
- The generated E2E then verifies that the fixture is absent from list results and that
27168
- cross-tenant detail and approval are rejected.
27192
+ For release acceptance, set \`SUPACLOUD_FEATURE_E2E_REQUIRED=1\` and supply URL,
27193
+ AUTHORIZATION, OTHER_AUTHORIZATION, ID, RACE_ID and IDEMPOTENCY_KEY under the
27194
+ \`SUPACLOUD_FEATURE_E2E_\` prefix. Use two distinct fresh draft fixtures and a fresh
27195
+ key on every run. Missing or partial configuration fails; only an entirely
27196
+ unconfigured developer run skips. Use HTTPS, or loopback HTTP for local tests.
27197
+ The first approval must succeed, its receipt must match, replay must preserve the
27198
+ result, and exactly one of two concurrent writes on the second draft must succeed.
27199
+ Cross-tenant detail, receipt, job status and replay must be denied. This HTTP test
27200
+ does not prove audit/outbox counts, worker completion, crashes, browser interaction,
27201
+ revoked membership or deployment; verify those separately against durable sources.
27169
27202
 
27170
27203
  The named RPC adapter must invoke the continuation in the same database transaction
27171
27204
  used by the request-scoped Writer, or own the complete command instead; never execute
@@ -33105,7 +33138,7 @@ async function runProjectDeployment(http, args) {
33105
33138
  // package.json
33106
33139
  var package_default = {
33107
33140
  name: "@supacloud/cli",
33108
- version: "0.108.1",
33141
+ version: "0.108.2",
33109
33142
  description: "Project-scoped CLI for SupaCloud users",
33110
33143
  type: "module",
33111
33144
  main: "./dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/cli",
3
- "version": "0.108.1",
3
+ "version": "0.108.2",
4
4
  "description": "Project-scoped CLI for SupaCloud users",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",