@fluid-app/fluid-cli-droplets 0.1.198 → 0.1.199
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 +592 -1
- package/jest.mocks/fluid-cli.ts +5 -0
- package/package.json +2 -1
- package/src/__tests__/manifest.test.ts +344 -0
- package/src/commands/manifest.ts +113 -0
- package/src/commands/rollback.ts +67 -0
- package/src/index.ts +4 -4
- package/src/manifest/canonical.ts +34 -0
- package/src/manifest/publish.ts +116 -0
- package/src/manifest/validate.ts +163 -0
- package/tsconfig.json +7 -3
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { manifestDigest } from "./canonical.js";
|
|
2
|
+
import type { Finding, Manifest } from "./validate.js";
|
|
3
|
+
|
|
4
|
+
export class ManifestError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public code: string,
|
|
7
|
+
public exitCode = 1,
|
|
8
|
+
public details?: unknown,
|
|
9
|
+
) {
|
|
10
|
+
super(code);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function request(
|
|
15
|
+
url: string,
|
|
16
|
+
token: string,
|
|
17
|
+
body?: unknown,
|
|
18
|
+
): Promise<Record<string, unknown>> {
|
|
19
|
+
let response: Response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetch(url, {
|
|
22
|
+
method: body === undefined ? "GET" : "POST",
|
|
23
|
+
redirect: "error",
|
|
24
|
+
signal: AbortSignal.timeout(30_000),
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${token}`,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
throw new ManifestError("Publisher request failed");
|
|
33
|
+
}
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = await response.json();
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ManifestError("Invalid publisher response");
|
|
39
|
+
}
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const code = result?.error?.details?.code ?? `HTTP ${response.status}`;
|
|
42
|
+
throw new ManifestError(
|
|
43
|
+
code,
|
|
44
|
+
code === "widening_not_allowed" ? 3 : code === "manifest_invalid" ? 2 : 1,
|
|
45
|
+
code === "manifest_invalid" &&
|
|
46
|
+
Array.isArray(result?.error?.details?.details)
|
|
47
|
+
? result.error.details.details.map(
|
|
48
|
+
({ pointer, code, detail }: Finding) => ({ pointer, code, detail }),
|
|
49
|
+
)
|
|
50
|
+
: undefined,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Public: Publish or preview using only GitHub OIDC and a single-use grant. */
|
|
57
|
+
export async function publishManifest(
|
|
58
|
+
manifest: Manifest,
|
|
59
|
+
baseUrl: string,
|
|
60
|
+
dryRun: boolean,
|
|
61
|
+
allowWidening: boolean,
|
|
62
|
+
): Promise<Record<string, unknown>> {
|
|
63
|
+
const base = new URL(baseUrl);
|
|
64
|
+
if (
|
|
65
|
+
!(
|
|
66
|
+
(base.origin === "https://api.fluid.app" &&
|
|
67
|
+
!base.username &&
|
|
68
|
+
!base.password) ||
|
|
69
|
+
(["http:", "https:"].includes(base.protocol) &&
|
|
70
|
+
["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
throw new ManifestError(
|
|
74
|
+
`Rejected publisher host ${base.hostname}; allowed: https://api.fluid.app; HTTP/HTTPS localhost, 127.0.0.1, [::1]`,
|
|
75
|
+
);
|
|
76
|
+
const required = [
|
|
77
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
78
|
+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
79
|
+
];
|
|
80
|
+
const missing = required.filter((key) => !process.env[key]);
|
|
81
|
+
if (process.env.GITHUB_ACTIONS !== "true")
|
|
82
|
+
missing.unshift("GITHUB_ACTIONS=true");
|
|
83
|
+
if (missing.length)
|
|
84
|
+
throw new ManifestError(
|
|
85
|
+
`Actions only: missing ${missing.join(", ")}; configure permissions: id-token: write`,
|
|
86
|
+
);
|
|
87
|
+
const digest = manifestDigest(manifest);
|
|
88
|
+
const oidcUrl = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL!);
|
|
89
|
+
oidcUrl.searchParams.set(
|
|
90
|
+
"audience",
|
|
91
|
+
"https://api.fluid.app/droplets/manifest",
|
|
92
|
+
);
|
|
93
|
+
const oidc = await request(
|
|
94
|
+
oidcUrl.toString(),
|
|
95
|
+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN!,
|
|
96
|
+
);
|
|
97
|
+
if (typeof oidc.value !== "string" || !oidc.value)
|
|
98
|
+
throw new ManifestError("Missing OIDC token");
|
|
99
|
+
const prefix = `${baseUrl.replace(/\/$/, "")}/api/droplets/v2026-10`;
|
|
100
|
+
const grant = await request(`${prefix}/publish-grants`, oidc.value, {
|
|
101
|
+
droplet_uuid: manifest.droplet,
|
|
102
|
+
digest,
|
|
103
|
+
});
|
|
104
|
+
const token = (grant.publish_grant as { token?: unknown } | undefined)?.token;
|
|
105
|
+
if (typeof token !== "string" || !token)
|
|
106
|
+
throw new ManifestError("Missing publish grant");
|
|
107
|
+
return request(
|
|
108
|
+
`${prefix}/manifest-publications${dryRun ? "?dry_run=true" : ""}`,
|
|
109
|
+
token,
|
|
110
|
+
{
|
|
111
|
+
manifest,
|
|
112
|
+
digest,
|
|
113
|
+
...(dryRun || allowWidening ? { allow_widening: true } : {}),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
2
|
+
import schema from "../../../../../apps/rails/config/droplet_manifest/v1.schema.json";
|
|
3
|
+
|
|
4
|
+
export { schema };
|
|
5
|
+
export const AUTHORITY =
|
|
6
|
+
"The server is authoritative and checks additional catalogs, scope ceilings and publisher identity.";
|
|
7
|
+
export interface Finding {
|
|
8
|
+
pointer: string;
|
|
9
|
+
code: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Manifest {
|
|
13
|
+
droplet: string;
|
|
14
|
+
version: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
const checkSchema = new Ajv2020({
|
|
18
|
+
allErrors: true,
|
|
19
|
+
strict: false,
|
|
20
|
+
}).compile<Manifest>(schema);
|
|
21
|
+
|
|
22
|
+
function compareVersion(a: string, b: string): number {
|
|
23
|
+
const split = (v: string) =>
|
|
24
|
+
v
|
|
25
|
+
.split("+")[0]!
|
|
26
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.*))?$/)!
|
|
27
|
+
.slice(1);
|
|
28
|
+
const aa = split(a),
|
|
29
|
+
bb = split(b);
|
|
30
|
+
const compare = (x: string, y: string): number => {
|
|
31
|
+
if (x === y) return 0;
|
|
32
|
+
const xn = /^\d+$/.test(x),
|
|
33
|
+
yn = /^\d+$/.test(y);
|
|
34
|
+
if (xn && yn) return BigInt(x) < BigInt(y) ? -1 : 1;
|
|
35
|
+
return xn !== yn ? (xn ? -1 : 1) : x < y ? -1 : 1;
|
|
36
|
+
};
|
|
37
|
+
for (let i = 0; i < 3; i++) {
|
|
38
|
+
const n = compare(aa[i]!, bb[i]!);
|
|
39
|
+
if (n) return n;
|
|
40
|
+
}
|
|
41
|
+
const ap = aa[3],
|
|
42
|
+
bp = bb[3];
|
|
43
|
+
if (ap === undefined || bp === undefined)
|
|
44
|
+
return ap === bp ? 0 : ap === undefined ? 1 : -1;
|
|
45
|
+
const ax = ap.split("."),
|
|
46
|
+
bx = bp.split(".");
|
|
47
|
+
for (let i = 0; i < Math.max(ax.length, bx.length); i++) {
|
|
48
|
+
if (ax[i] === undefined || bx[i] === undefined)
|
|
49
|
+
return ax.length < bx.length ? -1 : 1;
|
|
50
|
+
const n = compare(ax[i]!, bx[i]!);
|
|
51
|
+
if (n) return n;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Public: Validate locally expressible manifest rules; the server checks the rest. */
|
|
57
|
+
export function validateManifest(text: string): {
|
|
58
|
+
manifest?: Manifest;
|
|
59
|
+
errors: Finding[];
|
|
60
|
+
} {
|
|
61
|
+
const errors: Finding[] = [];
|
|
62
|
+
const add = (pointer: string, code: string, detail = code) =>
|
|
63
|
+
errors.push({ pointer, code, detail });
|
|
64
|
+
if (Buffer.byteLength(text) > 256 * 1024) {
|
|
65
|
+
add("/", "manifest_too_large");
|
|
66
|
+
return { errors };
|
|
67
|
+
}
|
|
68
|
+
let value: unknown;
|
|
69
|
+
try {
|
|
70
|
+
value = JSON.parse(text);
|
|
71
|
+
const stack: Set<string>[] = [];
|
|
72
|
+
for (const token of text.matchAll(/"(?:[^"\\]|\\.)*"\s*:?|[{}]/g)) {
|
|
73
|
+
const t = token[0];
|
|
74
|
+
if (t === "{") stack.push(new Set());
|
|
75
|
+
else if (t === "}") stack.pop();
|
|
76
|
+
else if (t.endsWith(":")) {
|
|
77
|
+
const key: string = JSON.parse(t.slice(0, -1));
|
|
78
|
+
if (stack.at(-1)?.has(key)) throw new Error("duplicate key");
|
|
79
|
+
stack.at(-1)?.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
add("", "invalid_json", "Invalid JSON or duplicate object keys");
|
|
84
|
+
return { errors };
|
|
85
|
+
}
|
|
86
|
+
function walk(node: unknown, pointer: string): void {
|
|
87
|
+
if (
|
|
88
|
+
typeof node === "number" &&
|
|
89
|
+
(!Number.isFinite(node) || Math.abs(node) > Number.MAX_SAFE_INTEGER)
|
|
90
|
+
)
|
|
91
|
+
add(pointer, "invalid_number");
|
|
92
|
+
if (typeof node === "string" && !node.isWellFormed())
|
|
93
|
+
add(pointer, "invalid_json", "Invalid Unicode");
|
|
94
|
+
if (node && typeof node === "object")
|
|
95
|
+
for (const [key, item] of Object.entries(node)) {
|
|
96
|
+
if (!key.isWellFormed())
|
|
97
|
+
add(pointer, "invalid_json", "Invalid Unicode key");
|
|
98
|
+
walk(
|
|
99
|
+
item,
|
|
100
|
+
`${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
typeof node === "string" &&
|
|
105
|
+
/^\/(webhooks\/\d+\/url_path|callbacks\/\d+\/url_path|embeds\/(detail_page|drop_zones\/\d+)|health\/path|lifecycle\/(install|uninstall|events)_webhook_path)$/.test(
|
|
106
|
+
pointer,
|
|
107
|
+
)
|
|
108
|
+
) {
|
|
109
|
+
if (
|
|
110
|
+
!/^\/(?!\/)[^@\\?#\s]*$/.test(node) ||
|
|
111
|
+
/[^\x21-\x7e]|%(?![\da-f]{2})|[<>"{}|^`[\]]/i.test(node) ||
|
|
112
|
+
/%(2f|5c)/i.test(node) ||
|
|
113
|
+
/(?:^|\/)(?:\.|%2e){1,2}(?:\/|$)/i.test(node)
|
|
114
|
+
)
|
|
115
|
+
add(pointer, "invalid_path");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
walk(value, "");
|
|
119
|
+
if (
|
|
120
|
+
value &&
|
|
121
|
+
typeof value === "object" &&
|
|
122
|
+
"manifest_version" in value &&
|
|
123
|
+
value.manifest_version !== 1
|
|
124
|
+
) {
|
|
125
|
+
add("/manifest_version", "unsupported_manifest_version");
|
|
126
|
+
return { errors };
|
|
127
|
+
}
|
|
128
|
+
if (!checkSchema(value)) {
|
|
129
|
+
for (const e of checkSchema.errors ?? [])
|
|
130
|
+
add(e.instancePath, "schema_violation", e.message ?? e.keyword);
|
|
131
|
+
return { errors };
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const origin = new URL(String(value.origin));
|
|
135
|
+
if (
|
|
136
|
+
origin.protocol !== "https:" ||
|
|
137
|
+
origin.username ||
|
|
138
|
+
origin.password ||
|
|
139
|
+
!/^https:\/\/[^/]+$/.test(String(value.origin))
|
|
140
|
+
)
|
|
141
|
+
throw new Error();
|
|
142
|
+
} catch {
|
|
143
|
+
add("/origin", "origin_not_https");
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
typeof value.min_supported_version === "string" &&
|
|
147
|
+
compareVersion(value.min_supported_version, value.version) > 0
|
|
148
|
+
)
|
|
149
|
+
add("/min_supported_version", "min_supported_version_exceeds_version");
|
|
150
|
+
for (const [field, key, code] of [
|
|
151
|
+
["callbacks", "definition", "definition_not_unique"],
|
|
152
|
+
["webhooks", "topic", "webhook_not_unique"],
|
|
153
|
+
]) {
|
|
154
|
+
const seen = new Set<unknown>();
|
|
155
|
+
const rows = value[field!];
|
|
156
|
+
if (Array.isArray(rows))
|
|
157
|
+
rows.forEach((row: Record<string, unknown>, i) => {
|
|
158
|
+
if (seen.has(row[key!])) add(`/${field}/${i}/${key}`, code!);
|
|
159
|
+
seen.add(row[key!]);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return { manifest: errors.length ? undefined : value, errors };
|
|
163
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
"extends": "@fluid-app/typescript-config/base.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"outDir": "./dist",
|
|
5
|
-
"rootDir": "
|
|
6
|
-
"incremental": false
|
|
5
|
+
"rootDir": "../../..",
|
|
6
|
+
"incremental": false,
|
|
7
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"]
|
|
7
8
|
},
|
|
8
|
-
"include": [
|
|
9
|
+
"include": [
|
|
10
|
+
"src/**/*",
|
|
11
|
+
"../../../apps/rails/config/droplet_manifest/v1.schema.json"
|
|
12
|
+
],
|
|
9
13
|
"exclude": ["node_modules", "dist"]
|
|
10
14
|
}
|