@synopackageland/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/attestation/app-package.d.ts +86 -0
- package/dist/attestation/app-package.js +199 -0
- package/dist/attestation/install-on-host.d.ts +14 -0
- package/dist/attestation/install-on-host.js +52 -0
- package/dist/attestation/keyring.d.ts +21 -0
- package/dist/attestation/keyring.js +168 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +14 -0
- package/dist/build-spk/dsm-arch.d.ts +4 -0
- package/dist/build-spk/dsm-arch.js +32 -0
- package/dist/build-spk/dsm-icons.d.ts +2 -0
- package/dist/build-spk/dsm-icons.js +67 -0
- package/dist/build-spk/generator.d.ts +43 -0
- package/dist/build-spk/generator.js +653 -0
- package/dist/build-spk/native-payload.d.ts +22 -0
- package/dist/build-spk/native-payload.js +654 -0
- package/dist/build-spk/reproducible-archive.d.ts +8 -0
- package/dist/build-spk/reproducible-archive.js +34 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +345 -0
- package/dist/compose/analyze.d.ts +25 -0
- package/dist/compose/analyze.js +346 -0
- package/dist/compose/env.d.ts +7 -0
- package/dist/compose/env.js +36 -0
- package/dist/compose/privilege.d.ts +14 -0
- package/dist/compose/privilege.js +102 -0
- package/dist/compose/runner.d.ts +32 -0
- package/dist/compose/runner.js +84 -0
- package/dist/contract/load.d.ts +25 -0
- package/dist/contract/load.js +87 -0
- package/dist/contract/schema-validate.d.ts +3 -0
- package/dist/contract/schema-validate.js +50 -0
- package/dist/contract/validate.d.ts +3 -0
- package/dist/contract/validate.js +80 -0
- package/dist/contract/web-launch.d.ts +19 -0
- package/dist/contract/web-launch.js +94 -0
- package/dist/deploy/catalog-publish.d.ts +3 -0
- package/dist/deploy/catalog-publish.js +7 -0
- package/dist/deploy/deploy.d.ts +22 -0
- package/dist/deploy/deploy.js +136 -0
- package/dist/deploy/identity-shared.d.ts +3 -0
- package/dist/deploy/identity-shared.js +14 -0
- package/dist/deploy/identity.d.ts +7 -0
- package/dist/deploy/identity.js +20 -0
- package/dist/deploy/package-source.d.ts +7 -0
- package/dist/deploy/package-source.js +17 -0
- package/dist/deploy/run-as-values.d.ts +18 -0
- package/dist/deploy/run-as-values.js +48 -0
- package/dist/deploy/share-values.d.ts +20 -0
- package/dist/deploy/share-values.js +60 -0
- package/dist/deploy/spk-info.d.ts +12 -0
- package/dist/deploy/spk-info.js +41 -0
- package/dist/deploy/spk-version.d.ts +4 -0
- package/dist/deploy/spk-version.js +64 -0
- package/dist/dev.d.ts +13 -0
- package/dist/dev.js +31 -0
- package/dist/discovery.d.ts +8 -0
- package/dist/discovery.js +22 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +18 -0
- package/dist/host/broker-paths.d.ts +1 -0
- package/dist/host/broker-paths.js +1 -0
- package/dist/host/catalog-install.d.ts +59 -0
- package/dist/host/catalog-install.js +326 -0
- package/dist/host/credentials.d.ts +2 -0
- package/dist/host/credentials.js +37 -0
- package/dist/host/digests.d.ts +5 -0
- package/dist/host/digests.js +55 -0
- package/dist/host/dsm-client.d.ts +6 -0
- package/dist/host/dsm-client.js +290 -0
- package/dist/host/dsm-http.d.ts +1 -0
- package/dist/host/dsm-http.js +87 -0
- package/dist/host/fake-nas.d.ts +16 -0
- package/dist/host/fake-nas.js +116 -0
- package/dist/host/open-url.d.ts +5 -0
- package/dist/host/open-url.js +13 -0
- package/dist/host/share-lookup.d.ts +3 -0
- package/dist/host/share-lookup.js +144 -0
- package/dist/host/types.d.ts +67 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/info.d.ts +17 -0
- package/dist/info.js +34 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +31 -0
- package/dist/inspect.d.ts +13 -0
- package/dist/inspect.js +102 -0
- package/dist/load-env.d.ts +1 -0
- package/dist/load-env.js +31 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +20 -0
- package/dist/mock-broker/bootstrap.d.ts +2 -0
- package/dist/mock-broker/bootstrap.js +8 -0
- package/dist/mock-broker/files.d.ts +60 -0
- package/dist/mock-broker/files.js +408 -0
- package/dist/mock-broker/oidc.d.ts +31 -0
- package/dist/mock-broker/oidc.js +161 -0
- package/dist/mock-broker/path.d.ts +9 -0
- package/dist/mock-broker/path.js +50 -0
- package/dist/mock-broker/server.d.ts +19 -0
- package/dist/mock-broker/server.js +350 -0
- package/dist/publish/build-ledger.d.ts +22 -0
- package/dist/publish/build-ledger.js +26 -0
- package/dist/publish/community-path.d.ts +6 -0
- package/dist/publish/community-path.js +41 -0
- package/dist/publish/curated-release.d.ts +78 -0
- package/dist/publish/curated-release.js +693 -0
- package/dist/publish/file-build-ledger.d.ts +18 -0
- package/dist/publish/file-build-ledger.js +268 -0
- package/dist/publish/github-pr.d.ts +25 -0
- package/dist/publish/github-pr.js +85 -0
- package/dist/publish/github-repo.d.ts +8 -0
- package/dist/publish/github-repo.js +21 -0
- package/dist/publish/publish.d.ts +23 -0
- package/dist/publish/publish.js +128 -0
- package/dist/publish/sync-definition.d.ts +4 -0
- package/dist/publish/sync-definition.js +48 -0
- package/dist/skill.d.ts +10 -0
- package/dist/skill.js +50 -0
- package/dist/templates/compose-import/compose.yaml +11 -0
- package/dist/templates/compose-import/synology-app.yaml +15 -0
- package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/dist/templates/hello-files/compose.yaml +8 -0
- package/dist/templates/hello-files/synology-app.yaml +13 -0
- package/dist/templates/hello-web/compose.yaml +7 -0
- package/dist/templates/hello-web/html/app.js +23 -0
- package/dist/templates/hello-web/html/index.html +15 -0
- package/dist/templates/hello-web/html/sdk.js +68 -0
- package/dist/templates/hello-web/synology-app.yaml +13 -0
- package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/dist/templates/hello-web-page/compose.yaml +7 -0
- package/dist/templates/hello-web-page/html/app.js +23 -0
- package/dist/templates/hello-web-page/html/index.html +15 -0
- package/dist/templates/hello-web-page/html/sdk.js +68 -0
- package/dist/templates/hello-web-page/synology-app.yaml +13 -0
- package/dist/test-command.d.ts +13 -0
- package/dist/test-command.js +26 -0
- package/dist/test-host.d.ts +13 -0
- package/dist/test-host.js +24 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +103 -0
- package/package.json +38 -0
- package/schema/synology-app.schema.json +137 -0
- package/skills/SKILL.md +51 -0
- package/skills/reference/create.md +34 -0
- package/skills/reference/deploy.md +41 -0
- package/skills/reference/diagnose.md +42 -0
- package/skills/reference/files.md +37 -0
- package/skills/reference/identity.md +31 -0
- package/skills/reference/wrap.md +35 -0
- package/templates/compose-import/compose.yaml +11 -0
- package/templates/compose-import/synology-app.yaml +15 -0
- package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/templates/hello-files/compose.yaml +8 -0
- package/templates/hello-files/synology-app.yaml +13 -0
- package/templates/hello-web/compose.yaml +7 -0
- package/templates/hello-web/html/app.js +23 -0
- package/templates/hello-web/html/index.html +15 -0
- package/templates/hello-web/html/sdk.js +68 -0
- package/templates/hello-web/synology-app.yaml +13 -0
- package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/templates/hello-web-page/compose.yaml +7 -0
- package/templates/hello-web-page/html/app.js +23 -0
- package/templates/hello-web-page/html/index.html +15 -0
- package/templates/hello-web-page/html/sdk.js +68 -0
- package/templates/hello-web-page/synology-app.yaml +13 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
4
|
+
import { MOCK_ISSUER } from "@synopackageland/dev-sdk/node";
|
|
5
|
+
import { FileStoreError, MockFileStore } from "./files.js";
|
|
6
|
+
import { createMockOidcContext, handleMockOidc } from "./oidc.js";
|
|
7
|
+
export const FAKE_USER = {
|
|
8
|
+
subject: "mock-user-001",
|
|
9
|
+
username: "mock.user",
|
|
10
|
+
displayName: "Mock User",
|
|
11
|
+
};
|
|
12
|
+
let keyPairPromise = null;
|
|
13
|
+
async function getKeyPair() {
|
|
14
|
+
if (!keyPairPromise) {
|
|
15
|
+
keyPairPromise = generateKeyPair("RS256");
|
|
16
|
+
}
|
|
17
|
+
return keyPairPromise;
|
|
18
|
+
}
|
|
19
|
+
function sendJson(res, status, body) {
|
|
20
|
+
res.statusCode = status;
|
|
21
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
}
|
|
24
|
+
function sendError(res, status, code, message, requestId) {
|
|
25
|
+
sendJson(res, status, {
|
|
26
|
+
error: { code, message, requestId, details: {} },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async function issueToken(packageId) {
|
|
30
|
+
const { privateKey } = await getKeyPair();
|
|
31
|
+
return new SignJWT({
|
|
32
|
+
username: FAKE_USER.username,
|
|
33
|
+
displayName: FAKE_USER.displayName,
|
|
34
|
+
mock: true,
|
|
35
|
+
})
|
|
36
|
+
.setProtectedHeader({ alg: "RS256" })
|
|
37
|
+
.setIssuer(MOCK_ISSUER)
|
|
38
|
+
.setAudience(packageId)
|
|
39
|
+
.setSubject(FAKE_USER.subject)
|
|
40
|
+
.setIssuedAt()
|
|
41
|
+
.setExpirationTime("1h")
|
|
42
|
+
.sign(privateKey);
|
|
43
|
+
}
|
|
44
|
+
async function verifyToken(token, packageId) {
|
|
45
|
+
const { importJWK, jwtVerify } = await import("jose");
|
|
46
|
+
const { publicKey } = await getKeyPair();
|
|
47
|
+
const jwk = await exportJWK(publicKey);
|
|
48
|
+
const key = await importJWK(jwk, "RS256");
|
|
49
|
+
return jwtVerify(token, key, {
|
|
50
|
+
issuer: MOCK_ISSUER,
|
|
51
|
+
audience: packageId,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function readBody(req) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
58
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
59
|
+
req.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function readBodyBuffer(req) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
66
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
67
|
+
req.on("error", reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function bearer(header) {
|
|
71
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
72
|
+
const match = value?.match(/^Bearer\s+(.+)$/i);
|
|
73
|
+
return match?.[1];
|
|
74
|
+
}
|
|
75
|
+
function parseRange(header) {
|
|
76
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
77
|
+
if (!value) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const match = value.match(/^bytes=(\d+)-(\d+)?$/);
|
|
81
|
+
if (!match) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
return { start: Number(match[1]), end: match[2] !== undefined ? Number(match[2]) : undefined };
|
|
85
|
+
}
|
|
86
|
+
function mapRoute(method, url) {
|
|
87
|
+
const path = url.pathname.replace(/\/$/, "") || "/";
|
|
88
|
+
if (method === "GET" && path === "/api/v1/files") {
|
|
89
|
+
return { operation: "list", needsJson: false };
|
|
90
|
+
}
|
|
91
|
+
if (method === "GET" && path.endsWith("/files/stat")) {
|
|
92
|
+
return { operation: "stat", needsJson: false };
|
|
93
|
+
}
|
|
94
|
+
if (method === "GET" && path.endsWith("/files/download")) {
|
|
95
|
+
return { operation: "download", needsJson: false };
|
|
96
|
+
}
|
|
97
|
+
if ((method === "PUT" || method === "POST") && path.endsWith("/files/upload")) {
|
|
98
|
+
return { operation: "upload", needsJson: false };
|
|
99
|
+
}
|
|
100
|
+
if (method === "POST" && path.endsWith("/files/mkdir")) {
|
|
101
|
+
return { operation: "mkdir", needsJson: true };
|
|
102
|
+
}
|
|
103
|
+
if (method === "POST" && path.endsWith("/files/rename")) {
|
|
104
|
+
return { operation: "rename", needsJson: true };
|
|
105
|
+
}
|
|
106
|
+
if (method === "POST" && path.endsWith("/files/move")) {
|
|
107
|
+
return { operation: "move", needsJson: true };
|
|
108
|
+
}
|
|
109
|
+
if (method === "POST" && path.endsWith("/files/copy")) {
|
|
110
|
+
return { operation: "copy", needsJson: true };
|
|
111
|
+
}
|
|
112
|
+
if (method === "POST" && path.endsWith("/files/trash")) {
|
|
113
|
+
return { operation: "trash", needsJson: true };
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
async function requireAuth(req, res, packageId, requestId) {
|
|
118
|
+
const token = bearer(req.headers.authorization);
|
|
119
|
+
if (!token) {
|
|
120
|
+
sendError(res, 401, "UNAUTHENTICATED", "需要 User Context。", requestId);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
await verifyToken(token, packageId);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
sendError(res, 401, "UNAUTHENTICATED", "需要 User Context。", requestId);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function handleStoreError(res, error, requestId) {
|
|
133
|
+
if (error instanceof FileStoreError) {
|
|
134
|
+
sendError(res, error.status, error.code, error.message, requestId);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
sendError(res, 500, "INTERNAL_ERROR", "Mock Broker 發生錯誤。", requestId);
|
|
138
|
+
}
|
|
139
|
+
async function handleFiles(req, res, url, store, packageId, requestId) {
|
|
140
|
+
const mapped = mapRoute(req.method ?? "GET", url);
|
|
141
|
+
if (!mapped) {
|
|
142
|
+
sendError(res, 404, "NOT_FOUND", "找不到資源。", requestId);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!(await requireAuth(req, res, packageId, requestId))) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const overwrite = url.searchParams.get("overwrite") === "true";
|
|
149
|
+
const json = mapped.needsJson
|
|
150
|
+
? JSON.parse((await readBody(req)) || "{}")
|
|
151
|
+
: {};
|
|
152
|
+
const bodyOverwrite = json.overwrite === true;
|
|
153
|
+
const effectiveOverwrite = overwrite || bodyOverwrite;
|
|
154
|
+
const path = String(json.path ?? url.searchParams.get("path") ?? "");
|
|
155
|
+
const to = json.to !== undefined ? String(json.to) : undefined;
|
|
156
|
+
try {
|
|
157
|
+
switch (mapped.operation) {
|
|
158
|
+
case "list": {
|
|
159
|
+
const items = store.list(path);
|
|
160
|
+
sendJson(res, 200, { items });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
case "stat": {
|
|
164
|
+
sendJson(res, 200, store.stat(path));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case "download": {
|
|
168
|
+
const range = parseRange(req.headers.range);
|
|
169
|
+
const result = store.download(path, range);
|
|
170
|
+
const hasRange = Boolean(range);
|
|
171
|
+
res.statusCode = hasRange ? 206 : 200;
|
|
172
|
+
res.setHeader("content-type", "application/octet-stream");
|
|
173
|
+
res.setHeader("accept-ranges", "bytes");
|
|
174
|
+
res.setHeader("content-length", String(result.size));
|
|
175
|
+
if (hasRange && range) {
|
|
176
|
+
const end = range.end ?? range.start + result.size - 1;
|
|
177
|
+
res.setHeader("content-range", `bytes ${range.start}-${end}/${result.totalSize}`);
|
|
178
|
+
}
|
|
179
|
+
if (result.stream) {
|
|
180
|
+
result.stream.pipe(res);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
res.end(result.buffer);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
case "upload": {
|
|
187
|
+
const body = await readBodyBuffer(req);
|
|
188
|
+
store.upload(path, body, effectiveOverwrite);
|
|
189
|
+
sendJson(res, 200, { path });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
case "mkdir": {
|
|
193
|
+
store.mkdir(path);
|
|
194
|
+
sendJson(res, 200, { path });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
case "rename": {
|
|
198
|
+
const destination = to ?? "";
|
|
199
|
+
store.rename(path, destination, effectiveOverwrite);
|
|
200
|
+
sendJson(res, 200, { path: destination });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case "move": {
|
|
204
|
+
const destination = to ?? "";
|
|
205
|
+
store.move(path, destination, effectiveOverwrite);
|
|
206
|
+
sendJson(res, 200, { path: destination });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
case "copy": {
|
|
210
|
+
const destination = to ?? "";
|
|
211
|
+
store.copy(path, destination, effectiveOverwrite);
|
|
212
|
+
sendJson(res, 200, { path: destination });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case "trash": {
|
|
216
|
+
store.trash(path);
|
|
217
|
+
sendJson(res, 200, { path });
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
handleStoreError(res, error, requestId);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function isFilesRoute(pathname, method) {
|
|
227
|
+
const path = pathname.replace(/\/$/, "") || "/";
|
|
228
|
+
if (method === "GET" && path === "/api/v1/files") {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
return path.startsWith("/api/v1/files/");
|
|
232
|
+
}
|
|
233
|
+
export async function startMockBroker(options) {
|
|
234
|
+
const fileStore = new MockFileStore({
|
|
235
|
+
filesRoot: options.filesRoot,
|
|
236
|
+
recycleBinDisabled: options.recycleBinDisabled,
|
|
237
|
+
});
|
|
238
|
+
const oidc = createMockOidcContext(options.packageId, FAKE_USER, options.oidc);
|
|
239
|
+
const server = createServer(async (req, res) => {
|
|
240
|
+
const requestId = randomUUID();
|
|
241
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
242
|
+
const pathname = url.pathname;
|
|
243
|
+
const method = req.method ?? "GET";
|
|
244
|
+
try {
|
|
245
|
+
if (await handleMockOidc(req, res, url, oidc)) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (req.method === "GET" && pathname === "/.well-known/jwks.json") {
|
|
249
|
+
const { publicKey } = await getKeyPair();
|
|
250
|
+
const jwk = await exportJWK(publicKey);
|
|
251
|
+
jwk.alg = "RS256";
|
|
252
|
+
jwk.use = "sig";
|
|
253
|
+
jwk.kid = createHash("sha256").update(JSON.stringify(jwk)).digest("hex").slice(0, 16);
|
|
254
|
+
sendJson(res, 200, { keys: [jwk] });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (pathname === "/api/v1/auth/login" && req.method === "POST") {
|
|
258
|
+
await readBody(req);
|
|
259
|
+
const accessToken = await issueToken(options.packageId);
|
|
260
|
+
sendJson(res, 200, { user: FAKE_USER, accessToken });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if ((pathname === "/.well-known/jwks.json" || pathname === "/api/v1/.well-known/jwks.json") &&
|
|
264
|
+
req.method === "GET") {
|
|
265
|
+
const { publicKey } = await getKeyPair();
|
|
266
|
+
const jwk = await exportJWK(publicKey);
|
|
267
|
+
jwk.alg = "RS256";
|
|
268
|
+
jwk.use = "sig";
|
|
269
|
+
jwk.kid = createHash("sha256").update(JSON.stringify(jwk)).digest("hex").slice(0, 16);
|
|
270
|
+
sendJson(res, 200, { keys: [jwk] });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (pathname === "/api/v1/oauth/device_authorization" && req.method === "POST") {
|
|
274
|
+
await readBody(req);
|
|
275
|
+
sendJson(res, 200, {
|
|
276
|
+
device_code: "mock-device-code",
|
|
277
|
+
user_code: "WDJB-MJHT",
|
|
278
|
+
verification_uri: `${`http://127.0.0.1`}/oauth/device`,
|
|
279
|
+
verification_uri_complete: "/oauth/device?user_code=WDJB-MJHT",
|
|
280
|
+
expires_in: 600,
|
|
281
|
+
interval: 1,
|
|
282
|
+
});
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (pathname === "/api/v1/oauth/device/approve" && req.method === "POST") {
|
|
286
|
+
await readBody(req);
|
|
287
|
+
sendJson(res, 200, { ok: true, user: FAKE_USER });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (pathname === "/api/v1/oauth/token" && req.method === "POST") {
|
|
291
|
+
await readBody(req);
|
|
292
|
+
const accessToken = await issueToken(options.packageId);
|
|
293
|
+
sendJson(res, 200, {
|
|
294
|
+
access_token: accessToken,
|
|
295
|
+
refresh_token: "mock-refresh",
|
|
296
|
+
token_type: "Bearer",
|
|
297
|
+
expires_in: 300,
|
|
298
|
+
user: FAKE_USER,
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (pathname === "/api/v1/session/renew" && req.method === "POST") {
|
|
303
|
+
const accessToken = await issueToken(options.packageId);
|
|
304
|
+
sendJson(res, 200, { user: FAKE_USER, accessToken });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (pathname === "/api/v1/session/logout" && req.method === "POST") {
|
|
308
|
+
sendJson(res, 200, { ok: true });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (pathname === "/api/v1/session" && req.method === "GET") {
|
|
312
|
+
const auth = req.headers.authorization ?? "";
|
|
313
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
314
|
+
if (!match) {
|
|
315
|
+
sendError(res, 401, "UNAUTHENTICATED", "需要 User Context。", requestId);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
await verifyToken(match[1], options.packageId);
|
|
319
|
+
sendJson(res, 200, { user: FAKE_USER });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (isFilesRoute(pathname, method)) {
|
|
323
|
+
await handleFiles(req, res, url, fileStore, options.packageId, requestId);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
sendError(res, 404, "NOT_FOUND", "找不到資源。", requestId);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
sendError(res, 500, "INTERNAL_ERROR", "Mock Broker 發生錯誤。", requestId);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
await new Promise((resolve, reject) => {
|
|
333
|
+
server.once("error", reject);
|
|
334
|
+
server.listen(options.port ?? 0, "127.0.0.1", () => resolve());
|
|
335
|
+
});
|
|
336
|
+
const address = server.address();
|
|
337
|
+
if (!address || typeof address === "string") {
|
|
338
|
+
throw new Error("無法啟動 mock Broker。");
|
|
339
|
+
}
|
|
340
|
+
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
341
|
+
return {
|
|
342
|
+
baseUrl,
|
|
343
|
+
jwksUrl: `${baseUrl}/.well-known/jwks.json`,
|
|
344
|
+
packageId: options.packageId,
|
|
345
|
+
server,
|
|
346
|
+
close: () => new Promise((resolve, reject) => {
|
|
347
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
348
|
+
}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only Package Build ledger (Curated CI authority).
|
|
3
|
+
* InMemoryBuildLedger is the disk-free stub. Durable merge-CI persistence is FileBuildLedger.
|
|
4
|
+
*/
|
|
5
|
+
export interface BuildLedger {
|
|
6
|
+
/** Atomically allocate the next Package Build for an App Package identity. */
|
|
7
|
+
allocate(packageIdentity: string): number;
|
|
8
|
+
/** Record a failed publish after allocation. The build number is not recycled. */
|
|
9
|
+
markPublishFailed(packageIdentity: string, build: number): void;
|
|
10
|
+
}
|
|
11
|
+
export interface BuildLedgerSnapshot {
|
|
12
|
+
nextBuild: number;
|
|
13
|
+
consumed: readonly number[];
|
|
14
|
+
}
|
|
15
|
+
/** In-memory stub for unit tests that must not touch disk. */
|
|
16
|
+
export declare class InMemoryBuildLedger implements BuildLedger {
|
|
17
|
+
private nextByIdentity;
|
|
18
|
+
private consumedByIdentity;
|
|
19
|
+
allocate(packageIdentity: string): number;
|
|
20
|
+
markPublishFailed(_packageIdentity: string, _build: number): void;
|
|
21
|
+
snapshot(packageIdentity: string): BuildLedgerSnapshot;
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only Package Build ledger (Curated CI authority).
|
|
3
|
+
* InMemoryBuildLedger is the disk-free stub. Durable merge-CI persistence is FileBuildLedger.
|
|
4
|
+
*/
|
|
5
|
+
/** In-memory stub for unit tests that must not touch disk. */
|
|
6
|
+
export class InMemoryBuildLedger {
|
|
7
|
+
nextByIdentity = new Map();
|
|
8
|
+
consumedByIdentity = new Map();
|
|
9
|
+
allocate(packageIdentity) {
|
|
10
|
+
const next = (this.nextByIdentity.get(packageIdentity) ?? 0) + 1;
|
|
11
|
+
this.nextByIdentity.set(packageIdentity, next);
|
|
12
|
+
const consumed = this.consumedByIdentity.get(packageIdentity) ?? [];
|
|
13
|
+
consumed.push(next);
|
|
14
|
+
this.consumedByIdentity.set(packageIdentity, consumed);
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
markPublishFailed(_packageIdentity, _build) {
|
|
18
|
+
// Append-only: allocation already consumed the build number.
|
|
19
|
+
}
|
|
20
|
+
snapshot(packageIdentity) {
|
|
21
|
+
return {
|
|
22
|
+
nextBuild: this.nextByIdentity.get(packageIdentity) ?? 0,
|
|
23
|
+
consumed: [...(this.consumedByIdentity.get(packageIdentity) ?? [])],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const COMMUNITY_APPS_DIR = "synopkgland-community-apps";
|
|
2
|
+
export declare const COMMUNITY_APPS_APPS_DIR = "apps";
|
|
3
|
+
export declare function resolveCommunityAppsRoot(startDir: string): string | null;
|
|
4
|
+
export declare function communityAppDirectory(communityRoot: string, packageIdentity: string): string;
|
|
5
|
+
export declare function assertDirectoryMatchesPackage(packageIdentity: string, appDirectory: string): void;
|
|
6
|
+
export declare function listCommunityAppDirectories(communityRoot: string): string[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
export const COMMUNITY_APPS_DIR = "synopkgland-community-apps";
|
|
4
|
+
export const COMMUNITY_APPS_APPS_DIR = "apps";
|
|
5
|
+
export function resolveCommunityAppsRoot(startDir) {
|
|
6
|
+
const fromEnv = process.env.SYNOPKGLAND_COMMUNITY_APPS_ROOT?.trim();
|
|
7
|
+
if (fromEnv) {
|
|
8
|
+
return resolve(fromEnv);
|
|
9
|
+
}
|
|
10
|
+
let current = resolve(startDir);
|
|
11
|
+
while (true) {
|
|
12
|
+
const candidate = join(current, COMMUNITY_APPS_DIR);
|
|
13
|
+
if (existsSync(join(candidate, COMMUNITY_APPS_APPS_DIR))) {
|
|
14
|
+
return candidate;
|
|
15
|
+
}
|
|
16
|
+
const parent = dirname(current);
|
|
17
|
+
if (parent === current) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function communityAppDirectory(communityRoot, packageIdentity) {
|
|
24
|
+
return join(communityRoot, COMMUNITY_APPS_APPS_DIR, packageIdentity);
|
|
25
|
+
}
|
|
26
|
+
export function assertDirectoryMatchesPackage(packageIdentity, appDirectory) {
|
|
27
|
+
const directoryName = basename(appDirectory);
|
|
28
|
+
if (directoryName !== packageIdentity) {
|
|
29
|
+
throw new Error(`Community Apps 目錄名必須等於 package:預期 ${packageIdentity},實際 ${directoryName}。`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function listCommunityAppDirectories(communityRoot) {
|
|
33
|
+
const appsRoot = join(communityRoot, COMMUNITY_APPS_APPS_DIR);
|
|
34
|
+
if (!existsSync(appsRoot)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return readdirSync(appsRoot, { withFileTypes: true })
|
|
38
|
+
.filter((entry) => entry.isDirectory())
|
|
39
|
+
.map((entry) => join(appsRoot, entry.name))
|
|
40
|
+
.filter((dir) => existsSync(join(dir, "synology-app.yaml")));
|
|
41
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type JWK } from "jose";
|
|
2
|
+
import { type CuratedReleasePublisher } from "@synopackageland/package-source/publish-spk";
|
|
3
|
+
import { type AppPackageAttestationClaims, type CuratedAppPackageAttestationInput, type CuratedReleaseAttestationClaims, type CuratedReleaseAttestationInput } from "../attestation/app-package.js";
|
|
4
|
+
import { type BuildSpkOptions, type BuildSpkResult } from "../build-spk/generator.js";
|
|
5
|
+
import { type ProjectRoot } from "../discovery.js";
|
|
6
|
+
import type { ComposeDocument } from "../types.js";
|
|
7
|
+
import type { BuildLedger } from "./build-ledger.js";
|
|
8
|
+
export declare const CURATED_ATTESTATION_PRIVATE_JWK_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_PRIVATE_JWK";
|
|
9
|
+
export declare const CURATED_ATTESTATION_PRIVATE_PKCS8_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_PRIVATE_PKCS8";
|
|
10
|
+
export declare const CURATED_ATTESTATION_KID_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_KID";
|
|
11
|
+
export { CURATED_ATTESTATION_KEYRING_ENV, CURATED_ATTESTATION_KEYRING_PATH_ENV } from "../attestation/keyring.js";
|
|
12
|
+
export declare class CuratedReleaseError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
constructor(code: string, message?: string);
|
|
15
|
+
}
|
|
16
|
+
export interface CuratedAttestationSignInput extends CuratedAppPackageAttestationInput {
|
|
17
|
+
}
|
|
18
|
+
export interface CuratedReleaseSignInput extends CuratedReleaseAttestationInput {
|
|
19
|
+
}
|
|
20
|
+
export interface CuratedAttestationSigner {
|
|
21
|
+
/** Sign the v2 token that will be embedded before the SPK is built. */
|
|
22
|
+
sign(input: CuratedAttestationSignInput): Promise<{
|
|
23
|
+
token: string;
|
|
24
|
+
attestationDigest: string;
|
|
25
|
+
claims?: AppPackageAttestationClaims;
|
|
26
|
+
}>;
|
|
27
|
+
/** Sign the v1 detached token after the final SPK digest is known. */
|
|
28
|
+
signRelease?: (input: CuratedReleaseSignInput) => Promise<{
|
|
29
|
+
token: string;
|
|
30
|
+
attestationDigest: string;
|
|
31
|
+
claims?: CuratedReleaseAttestationClaims;
|
|
32
|
+
}>;
|
|
33
|
+
/** Production signers use this to verify the actual JWT signature before publish. */
|
|
34
|
+
verify?: (token: string, kind: "embedded" | "release") => Promise<void>;
|
|
35
|
+
/** Public trust-root JWK passed to the Package Source publication boundary. */
|
|
36
|
+
verificationJwk?: JWK;
|
|
37
|
+
}
|
|
38
|
+
export interface CuratedReleaseOptions {
|
|
39
|
+
project: ProjectRoot;
|
|
40
|
+
/** A Build already allocated by protected-main CI. This function never allocates. */
|
|
41
|
+
build: number;
|
|
42
|
+
signer: CuratedAttestationSigner;
|
|
43
|
+
publisher: CuratedReleasePublisher;
|
|
44
|
+
ledger?: BuildLedger;
|
|
45
|
+
buildSpk?: (options: BuildSpkOptions) => Promise<BuildSpkResult>;
|
|
46
|
+
}
|
|
47
|
+
export interface CuratedReleaseResult {
|
|
48
|
+
packageIdentity: string;
|
|
49
|
+
packageBuild: number;
|
|
50
|
+
version: string;
|
|
51
|
+
artifactKey: string;
|
|
52
|
+
artifactDigest: string;
|
|
53
|
+
attestationKey: string;
|
|
54
|
+
/** Digest of the embedded token's canonical claims. */
|
|
55
|
+
embeddedAttestationDigest: string;
|
|
56
|
+
/** Digest/jti of the detached token's canonical claims. */
|
|
57
|
+
attestationDigest: string;
|
|
58
|
+
}
|
|
59
|
+
export interface CuratedComposeGateResult {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
errors: string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The release-only Compose gate. Development Compose may use tags and build:;
|
|
65
|
+
* a trusted release may contain neither and every service must pin a digest.
|
|
66
|
+
*/
|
|
67
|
+
export declare function validateCuratedCompose(compose: ComposeDocument): CuratedComposeGateResult;
|
|
68
|
+
export declare function isPublicOciDigest(image: string): boolean;
|
|
69
|
+
/** Execute the trusted dual-envelope release state machine for one allocated Build. */
|
|
70
|
+
export declare function releaseCurated(options: CuratedReleaseOptions): Promise<CuratedReleaseResult>;
|
|
71
|
+
export declare const runCuratedRelease: typeof releaseCurated;
|
|
72
|
+
export declare function createCuratedAttestationSignerFromEnv(env?: NodeJS.Dict<string>): Promise<CuratedAttestationSigner>;
|
|
73
|
+
export declare function runCuratedReleaseCli(argv?: string[], env?: NodeJS.Dict<string>): Promise<number>;
|
|
74
|
+
/**
|
|
75
|
+
* Canonical App Bundle digest. Only the three declared bundle areas are
|
|
76
|
+
* included; archive metadata and presentation files can never affect it.
|
|
77
|
+
*/
|
|
78
|
+
export declare function digestReleaseBundle(project: ProjectRoot): string;
|