@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.
Files changed (170) hide show
  1. package/README.md +13 -0
  2. package/dist/attestation/app-package.d.ts +86 -0
  3. package/dist/attestation/app-package.js +199 -0
  4. package/dist/attestation/install-on-host.d.ts +14 -0
  5. package/dist/attestation/install-on-host.js +52 -0
  6. package/dist/attestation/keyring.d.ts +21 -0
  7. package/dist/attestation/keyring.js +168 -0
  8. package/dist/bin.d.ts +2 -0
  9. package/dist/bin.js +14 -0
  10. package/dist/build-spk/dsm-arch.d.ts +4 -0
  11. package/dist/build-spk/dsm-arch.js +32 -0
  12. package/dist/build-spk/dsm-icons.d.ts +2 -0
  13. package/dist/build-spk/dsm-icons.js +67 -0
  14. package/dist/build-spk/generator.d.ts +43 -0
  15. package/dist/build-spk/generator.js +653 -0
  16. package/dist/build-spk/native-payload.d.ts +22 -0
  17. package/dist/build-spk/native-payload.js +654 -0
  18. package/dist/build-spk/reproducible-archive.d.ts +8 -0
  19. package/dist/build-spk/reproducible-archive.js +34 -0
  20. package/dist/cli.d.ts +1 -0
  21. package/dist/cli.js +345 -0
  22. package/dist/compose/analyze.d.ts +25 -0
  23. package/dist/compose/analyze.js +346 -0
  24. package/dist/compose/env.d.ts +7 -0
  25. package/dist/compose/env.js +36 -0
  26. package/dist/compose/privilege.d.ts +14 -0
  27. package/dist/compose/privilege.js +102 -0
  28. package/dist/compose/runner.d.ts +32 -0
  29. package/dist/compose/runner.js +84 -0
  30. package/dist/contract/load.d.ts +25 -0
  31. package/dist/contract/load.js +87 -0
  32. package/dist/contract/schema-validate.d.ts +3 -0
  33. package/dist/contract/schema-validate.js +50 -0
  34. package/dist/contract/validate.d.ts +3 -0
  35. package/dist/contract/validate.js +80 -0
  36. package/dist/contract/web-launch.d.ts +19 -0
  37. package/dist/contract/web-launch.js +94 -0
  38. package/dist/deploy/catalog-publish.d.ts +3 -0
  39. package/dist/deploy/catalog-publish.js +7 -0
  40. package/dist/deploy/deploy.d.ts +22 -0
  41. package/dist/deploy/deploy.js +136 -0
  42. package/dist/deploy/identity-shared.d.ts +3 -0
  43. package/dist/deploy/identity-shared.js +14 -0
  44. package/dist/deploy/identity.d.ts +7 -0
  45. package/dist/deploy/identity.js +20 -0
  46. package/dist/deploy/package-source.d.ts +7 -0
  47. package/dist/deploy/package-source.js +17 -0
  48. package/dist/deploy/run-as-values.d.ts +18 -0
  49. package/dist/deploy/run-as-values.js +48 -0
  50. package/dist/deploy/share-values.d.ts +20 -0
  51. package/dist/deploy/share-values.js +60 -0
  52. package/dist/deploy/spk-info.d.ts +12 -0
  53. package/dist/deploy/spk-info.js +41 -0
  54. package/dist/deploy/spk-version.d.ts +4 -0
  55. package/dist/deploy/spk-version.js +64 -0
  56. package/dist/dev.d.ts +13 -0
  57. package/dist/dev.js +31 -0
  58. package/dist/discovery.d.ts +8 -0
  59. package/dist/discovery.js +22 -0
  60. package/dist/errors.d.ts +6 -0
  61. package/dist/errors.js +18 -0
  62. package/dist/host/broker-paths.d.ts +1 -0
  63. package/dist/host/broker-paths.js +1 -0
  64. package/dist/host/catalog-install.d.ts +59 -0
  65. package/dist/host/catalog-install.js +326 -0
  66. package/dist/host/credentials.d.ts +2 -0
  67. package/dist/host/credentials.js +37 -0
  68. package/dist/host/digests.d.ts +5 -0
  69. package/dist/host/digests.js +55 -0
  70. package/dist/host/dsm-client.d.ts +6 -0
  71. package/dist/host/dsm-client.js +290 -0
  72. package/dist/host/dsm-http.d.ts +1 -0
  73. package/dist/host/dsm-http.js +87 -0
  74. package/dist/host/fake-nas.d.ts +16 -0
  75. package/dist/host/fake-nas.js +116 -0
  76. package/dist/host/open-url.d.ts +5 -0
  77. package/dist/host/open-url.js +13 -0
  78. package/dist/host/share-lookup.d.ts +3 -0
  79. package/dist/host/share-lookup.js +144 -0
  80. package/dist/host/types.d.ts +67 -0
  81. package/dist/host/types.js +1 -0
  82. package/dist/index.d.ts +19 -0
  83. package/dist/index.js +14 -0
  84. package/dist/info.d.ts +17 -0
  85. package/dist/info.js +34 -0
  86. package/dist/init.d.ts +4 -0
  87. package/dist/init.js +31 -0
  88. package/dist/inspect.d.ts +13 -0
  89. package/dist/inspect.js +102 -0
  90. package/dist/load-env.d.ts +1 -0
  91. package/dist/load-env.js +31 -0
  92. package/dist/log.d.ts +13 -0
  93. package/dist/log.js +20 -0
  94. package/dist/mock-broker/bootstrap.d.ts +2 -0
  95. package/dist/mock-broker/bootstrap.js +8 -0
  96. package/dist/mock-broker/files.d.ts +60 -0
  97. package/dist/mock-broker/files.js +408 -0
  98. package/dist/mock-broker/oidc.d.ts +31 -0
  99. package/dist/mock-broker/oidc.js +161 -0
  100. package/dist/mock-broker/path.d.ts +9 -0
  101. package/dist/mock-broker/path.js +50 -0
  102. package/dist/mock-broker/server.d.ts +19 -0
  103. package/dist/mock-broker/server.js +350 -0
  104. package/dist/publish/build-ledger.d.ts +22 -0
  105. package/dist/publish/build-ledger.js +26 -0
  106. package/dist/publish/community-path.d.ts +6 -0
  107. package/dist/publish/community-path.js +41 -0
  108. package/dist/publish/curated-release.d.ts +78 -0
  109. package/dist/publish/curated-release.js +693 -0
  110. package/dist/publish/file-build-ledger.d.ts +18 -0
  111. package/dist/publish/file-build-ledger.js +268 -0
  112. package/dist/publish/github-pr.d.ts +25 -0
  113. package/dist/publish/github-pr.js +85 -0
  114. package/dist/publish/github-repo.d.ts +8 -0
  115. package/dist/publish/github-repo.js +21 -0
  116. package/dist/publish/publish.d.ts +23 -0
  117. package/dist/publish/publish.js +128 -0
  118. package/dist/publish/sync-definition.d.ts +4 -0
  119. package/dist/publish/sync-definition.js +48 -0
  120. package/dist/skill.d.ts +10 -0
  121. package/dist/skill.js +50 -0
  122. package/dist/templates/compose-import/compose.yaml +11 -0
  123. package/dist/templates/compose-import/synology-app.yaml +15 -0
  124. package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  125. package/dist/templates/hello-files/compose.yaml +8 -0
  126. package/dist/templates/hello-files/synology-app.yaml +13 -0
  127. package/dist/templates/hello-web/compose.yaml +7 -0
  128. package/dist/templates/hello-web/html/app.js +23 -0
  129. package/dist/templates/hello-web/html/index.html +15 -0
  130. package/dist/templates/hello-web/html/sdk.js +68 -0
  131. package/dist/templates/hello-web/synology-app.yaml +13 -0
  132. package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
  133. package/dist/templates/hello-web-page/compose.yaml +7 -0
  134. package/dist/templates/hello-web-page/html/app.js +23 -0
  135. package/dist/templates/hello-web-page/html/index.html +15 -0
  136. package/dist/templates/hello-web-page/html/sdk.js +68 -0
  137. package/dist/templates/hello-web-page/synology-app.yaml +13 -0
  138. package/dist/test-command.d.ts +13 -0
  139. package/dist/test-command.js +26 -0
  140. package/dist/test-host.d.ts +13 -0
  141. package/dist/test-host.js +24 -0
  142. package/dist/types.d.ts +59 -0
  143. package/dist/types.js +1 -0
  144. package/dist/validate.d.ts +8 -0
  145. package/dist/validate.js +103 -0
  146. package/package.json +38 -0
  147. package/schema/synology-app.schema.json +137 -0
  148. package/skills/SKILL.md +51 -0
  149. package/skills/reference/create.md +34 -0
  150. package/skills/reference/deploy.md +41 -0
  151. package/skills/reference/diagnose.md +42 -0
  152. package/skills/reference/files.md +37 -0
  153. package/skills/reference/identity.md +31 -0
  154. package/skills/reference/wrap.md +35 -0
  155. package/templates/compose-import/compose.yaml +11 -0
  156. package/templates/compose-import/synology-app.yaml +15 -0
  157. package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
  158. package/templates/hello-files/compose.yaml +8 -0
  159. package/templates/hello-files/synology-app.yaml +13 -0
  160. package/templates/hello-web/compose.yaml +7 -0
  161. package/templates/hello-web/html/app.js +23 -0
  162. package/templates/hello-web/html/index.html +15 -0
  163. package/templates/hello-web/html/sdk.js +68 -0
  164. package/templates/hello-web/synology-app.yaml +13 -0
  165. package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
  166. package/templates/hello-web-page/compose.yaml +7 -0
  167. package/templates/hello-web-page/html/app.js +23 -0
  168. package/templates/hello-web-page/html/index.html +15 -0
  169. package/templates/hello-web-page/html/sdk.js +68 -0
  170. package/templates/hello-web-page/synology-app.yaml +13 -0
@@ -0,0 +1,693 @@
1
+ import { createHash, createPublicKey } from "node:crypto";
2
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { decodeJwt, decodeProtectedHeader, importJWK, importPKCS8, importSPKI, jwtVerify, } from "jose";
7
+ import { buildImmutableAttestationKey, buildImmutableSpkKey, } from "@synopackageland/package-source/index-manifest";
8
+ import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopackageland/package-source/publish-spk";
9
+ import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabilities, signCuratedAppPackageAttestationToken, signCuratedReleaseAttestationToken, } from "../attestation/app-package.js";
10
+ import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
11
+ import { loadContract } from "../contract/load.js";
12
+ import { loadCompose } from "../compose/analyze.js";
13
+ import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
14
+ import { findProjectRoot } from "../discovery.js";
15
+ import { validateProject } from "../validate.js";
16
+ export const CURATED_ATTESTATION_PRIVATE_JWK_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_PRIVATE_JWK";
17
+ export const CURATED_ATTESTATION_PRIVATE_PKCS8_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_PRIVATE_PKCS8";
18
+ export const CURATED_ATTESTATION_KID_ENV = "SYNOPKGLAND_CURATED_ATTESTATION_KID";
19
+ export { CURATED_ATTESTATION_KEYRING_ENV, CURATED_ATTESTATION_KEYRING_PATH_ENV } from "../attestation/keyring.js";
20
+ export class CuratedReleaseError extends Error {
21
+ code;
22
+ constructor(code, message = code) {
23
+ super(message);
24
+ this.code = code;
25
+ this.name = "CuratedReleaseError";
26
+ }
27
+ }
28
+ /**
29
+ * The release-only Compose gate. Development Compose may use tags and build:;
30
+ * a trusted release may contain neither and every service must pin a digest.
31
+ */
32
+ export function validateCuratedCompose(compose) {
33
+ const errors = [];
34
+ const services = compose.services;
35
+ if (!services || Object.keys(services).length === 0) {
36
+ return { ok: false, errors: ["CURATED_COMPOSE_SERVICES_REQUIRED"] };
37
+ }
38
+ for (const [serviceName, service] of Object.entries(services)) {
39
+ const path = `services.${serviceName}`;
40
+ if (Object.prototype.hasOwnProperty.call(service, "build")) {
41
+ errors.push(`CURATED_COMPOSE_BUILD_FORBIDDEN:${path}.build`);
42
+ }
43
+ if (typeof service.image !== "string" || service.image.trim().length === 0) {
44
+ errors.push(`CURATED_COMPOSE_IMAGE_REQUIRED:${path}.image`);
45
+ }
46
+ else if (!isPublicOciDigest(service.image)) {
47
+ errors.push(`CURATED_COMPOSE_IMAGE_DIGEST_REQUIRED:${path}.image`);
48
+ }
49
+ }
50
+ return { ok: errors.length === 0, errors };
51
+ }
52
+ export function isPublicOciDigest(image) {
53
+ const trimmed = image.trim();
54
+ const at = trimmed.lastIndexOf("@");
55
+ if (at <= 0 || at === trimmed.length - 1 || /\s/.test(trimmed)) {
56
+ return false;
57
+ }
58
+ const name = trimmed.slice(0, at);
59
+ const digest = trimmed.slice(at + 1);
60
+ if (!/^sha256:[a-f0-9]{64}$/.test(digest)) {
61
+ return false;
62
+ }
63
+ if (name.includes("://") ||
64
+ name.includes("$") ||
65
+ name.includes("\\") ||
66
+ name.startsWith("/") ||
67
+ name.endsWith("/") ||
68
+ name.split("/").some((part) => part.length === 0)) {
69
+ return false;
70
+ }
71
+ const registry = name.includes("/") ? name.split("/")[0] ?? "" : "";
72
+ if (registry === "localhost" ||
73
+ registry.startsWith("127.") ||
74
+ registry.startsWith("0.0.0.0") ||
75
+ registry === "[::1]" ||
76
+ registry.endsWith(".local")) {
77
+ return false;
78
+ }
79
+ return /^[a-z0-9][a-z0-9._:/-]*$/.test(name);
80
+ }
81
+ /** Execute the trusted dual-envelope release state machine for one allocated Build. */
82
+ export async function releaseCurated(options) {
83
+ let packageIdentity;
84
+ try {
85
+ const prepared = prepareRelease(options.project, options.build);
86
+ packageIdentity = prepared.packageIdentity;
87
+ const contractDigest = digestFile(options.project.contractPath);
88
+ const bundleDigest = digestReleaseBundle(options.project);
89
+ const capabilities = collectCapabilities(prepared.contract, prepared.compose);
90
+ const embeddedInput = {
91
+ packageIdentity,
92
+ packageVersion: prepared.version,
93
+ packageBuild: options.build,
94
+ contractDigest,
95
+ bundleDigest,
96
+ capabilities,
97
+ };
98
+ // This token deliberately contains no final archive digest. It must exist
99
+ // before the SPK is built because it is itself part of that archive.
100
+ let embedded;
101
+ try {
102
+ embedded = await options.signer.sign(embeddedInput);
103
+ }
104
+ catch (error) {
105
+ if (error instanceof CuratedReleaseError) {
106
+ throw error;
107
+ }
108
+ throw new CuratedReleaseError("CURATED_ATTESTATION_BINDING_FAILED");
109
+ }
110
+ assertEmbeddedAttestation(embedded, embeddedInput);
111
+ await verifySignedToken(options.signer, embedded.token, "embedded");
112
+ const workspace = mkdtempSync(join(tmpdir(), "synopkgland-curated-release-"));
113
+ try {
114
+ const releaseProject = copyReleaseProject(options.project, workspace, packageIdentity);
115
+ const outputPath = join(workspace, `${packageIdentity}.spk`);
116
+ const buildFn = options.buildSpk ?? buildSpk;
117
+ const built = await buildFn({
118
+ project: releaseProject,
119
+ outputPath,
120
+ packageIdentity,
121
+ displayname: prepared.displayname,
122
+ spkVersion: prepared.version,
123
+ embeddedAttestationToken: embedded.token,
124
+ });
125
+ if (!built.ok || !built.outputPath) {
126
+ throw new CuratedReleaseError("CURATED_BUILD_FAILED");
127
+ }
128
+ await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
129
+ const artifact = digestArtifact(built.outputPath);
130
+ if (!options.signer.signRelease) {
131
+ throw new CuratedReleaseError("CURATED_RELEASE_SIGNER_MISSING");
132
+ }
133
+ const detachedInput = {
134
+ packageIdentity,
135
+ packageVersion: prepared.version,
136
+ packageBuild: options.build,
137
+ artifactDigest: artifact.sha256,
138
+ embeddedAttestationDigest: embedded.attestationDigest,
139
+ };
140
+ let detached;
141
+ try {
142
+ detached = await options.signer.signRelease(detachedInput);
143
+ }
144
+ catch (error) {
145
+ if (error instanceof CuratedReleaseError) {
146
+ throw error;
147
+ }
148
+ throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
149
+ }
150
+ assertDetachedAttestation(detached, detachedInput);
151
+ await verifySignedToken(options.signer, detached.token, "release");
152
+ const artifactKey = buildImmutableSpkKey({
153
+ package: packageIdentity,
154
+ version: prepared.version,
155
+ sha256: artifact.sha256,
156
+ });
157
+ const attestationKey = buildImmutableAttestationKey({
158
+ package: packageIdentity,
159
+ version: prepared.version,
160
+ artifactDigest: artifact.sha256,
161
+ });
162
+ const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
163
+ if (!options.signer.verificationJwk) {
164
+ throw new CuratedReleaseError("CURATED_ATTESTATION_SIGNATURE_INVALID");
165
+ }
166
+ const attestation = {
167
+ packageId: packageIdentity,
168
+ version: prepared.version,
169
+ packageBuild: options.build,
170
+ artifactDigest: artifact.sha256,
171
+ embeddedAttestationDigest: embedded.attestationDigest,
172
+ key: attestationKey,
173
+ token: detached.token,
174
+ digest: detached.attestationDigest,
175
+ tokenSha256,
176
+ verificationJwk: options.signer.verificationJwk,
177
+ };
178
+ const index = {
179
+ packageId: packageIdentity,
180
+ version: prepared.version,
181
+ displayname: prepared.displayname,
182
+ description: prepared.description,
183
+ ...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
184
+ osMinVer: prepared.osMinVer,
185
+ artifact: {
186
+ key: artifactKey,
187
+ size: artifact.size,
188
+ md5: artifact.md5,
189
+ sha256: artifact.sha256,
190
+ },
191
+ attestation: {
192
+ key: attestationKey,
193
+ digest: detached.attestationDigest,
194
+ packageBuild: options.build,
195
+ artifactDigest: artifact.sha256,
196
+ embeddedAttestationDigest: embedded.attestationDigest,
197
+ },
198
+ };
199
+ // This is the only transition to external publication. Both tokens have
200
+ // been claim-checked, signature-checked when the signer supplies a key,
201
+ // and the embedded token was read back from the final SPK first.
202
+ await publishCuratedRelease({
203
+ artifact: {
204
+ packageId: packageIdentity,
205
+ version: prepared.version,
206
+ path: built.outputPath,
207
+ key: artifactKey,
208
+ size: artifact.size,
209
+ md5: artifact.md5,
210
+ sha256: artifact.sha256,
211
+ },
212
+ attestation,
213
+ index,
214
+ }, options.publisher);
215
+ return {
216
+ packageIdentity,
217
+ packageBuild: options.build,
218
+ version: prepared.version,
219
+ artifactKey,
220
+ artifactDigest: artifact.sha256,
221
+ attestationKey,
222
+ embeddedAttestationDigest: embedded.attestationDigest,
223
+ attestationDigest: detached.attestationDigest,
224
+ };
225
+ }
226
+ finally {
227
+ rmSync(workspace, { recursive: true, force: true });
228
+ }
229
+ }
230
+ catch (error) {
231
+ if (packageIdentity && options.ledger && isValidBuild(options.build)) {
232
+ try {
233
+ options.ledger.markPublishFailed(packageIdentity, options.build);
234
+ }
235
+ catch {
236
+ // The allocated number remains consumed even if failure bookkeeping
237
+ // itself is unavailable. Never hide the release failure or allocate again.
238
+ }
239
+ }
240
+ throw error;
241
+ }
242
+ }
243
+ export const runCuratedRelease = releaseCurated;
244
+ export async function createCuratedAttestationSignerFromEnv(env = process.env) {
245
+ const jwkRaw = env[CURATED_ATTESTATION_PRIVATE_JWK_ENV]?.trim();
246
+ const pkcs8Raw = env[CURATED_ATTESTATION_PRIVATE_PKCS8_ENV]?.trim();
247
+ const kidFromEnv = env[CURATED_ATTESTATION_KID_ENV]?.trim();
248
+ if (!jwkRaw && !pkcs8Raw) {
249
+ throw new CuratedReleaseError("CURATED_SIGNING_MATERIAL_MISSING");
250
+ }
251
+ if (jwkRaw && pkcs8Raw) {
252
+ throw new CuratedReleaseError("CURATED_SIGNING_MATERIAL_AMBIGUOUS");
253
+ }
254
+ try {
255
+ const loaded = jwkRaw
256
+ ? await loadJwkSigningMaterial(jwkRaw, kidFromEnv)
257
+ : await loadPkcs8SigningMaterial(pkcs8Raw, kidFromEnv);
258
+ const trustRoot = await loadCuratedTrustRoot(env);
259
+ const trustPublicKey = await resolveCuratedSigningTrust(trustRoot, loaded.signingKey.kid, loaded.publicJwk);
260
+ const trustEntry = trustRoot.keys.find((entry) => entry.kid === loaded.signingKey.kid);
261
+ if (!trustEntry) {
262
+ throw new CuratedReleaseError("CURATED_SIGNING_KEY_UNKNOWN");
263
+ }
264
+ return {
265
+ sign: async (input) => signCuratedAppPackageAttestationToken(input, loaded.signingKey),
266
+ signRelease: async (input) => signCuratedReleaseAttestationToken(input, loaded.signingKey),
267
+ verify: async (token, kind) => {
268
+ const result = await jwtVerify(token, trustPublicKey, {
269
+ algorithms: ["RS256"],
270
+ issuer: "synopkgland-curated",
271
+ });
272
+ if (result.protectedHeader.alg !== "RS256" ||
273
+ result.protectedHeader.typ !== "JWT" ||
274
+ result.protectedHeader.kid !== loaded.signingKey.kid ||
275
+ result.payload.typ !==
276
+ (kind === "embedded" ? "app-package-attestation" : "curated-release-attestation")) {
277
+ throw new Error("attestation signature envelope mismatch");
278
+ }
279
+ },
280
+ verificationJwk: trustEntry.publicJwk,
281
+ };
282
+ }
283
+ catch (error) {
284
+ if (error instanceof CuratedReleaseError) {
285
+ throw error;
286
+ }
287
+ if (error instanceof CuratedTrustRootError) {
288
+ throw new CuratedReleaseError(error.code, error.message);
289
+ }
290
+ throw new CuratedReleaseError("CURATED_SIGNING_MATERIAL_INVALID");
291
+ }
292
+ }
293
+ export async function runCuratedReleaseCli(argv = process.argv, env = process.env) {
294
+ if (argv[2] !== "release" || !argv[3] || !argv[4] || argv.length > 5) {
295
+ process.stderr.write("CURATED_RELEASE_USAGE\n");
296
+ return 1;
297
+ }
298
+ try {
299
+ const build = parseBuild(argv[4]);
300
+ const signer = await createCuratedAttestationSignerFromEnv(env);
301
+ const projectPath = resolve(process.cwd(), argv[3]);
302
+ const project = findProjectRoot(projectPath);
303
+ if (!project) {
304
+ throw new CuratedReleaseError("CURATED_APP_NOT_FOUND");
305
+ }
306
+ const result = await releaseCurated({
307
+ project,
308
+ build,
309
+ signer,
310
+ publisher: createR2CuratedReleasePublisher(),
311
+ });
312
+ process.stdout.write(`${JSON.stringify({
313
+ packageIdentity: result.packageIdentity,
314
+ packageBuild: result.packageBuild,
315
+ version: result.version,
316
+ artifactDigest: result.artifactDigest,
317
+ embeddedAttestationDigest: result.embeddedAttestationDigest,
318
+ attestationDigest: result.attestationDigest,
319
+ })}\n`);
320
+ return 0;
321
+ }
322
+ catch (error) {
323
+ process.stderr.write(`${error instanceof CuratedReleaseError ? error.code : "CURATED_RELEASE_FAILED"}\n`);
324
+ return 1;
325
+ }
326
+ }
327
+ function prepareRelease(project, build) {
328
+ const parsed = loadContract(project.contractPath);
329
+ const contract = parsed.contract;
330
+ const packageIdentity = typeof contract.package === "string" ? contract.package.trim() : "";
331
+ if (!packageIdentity) {
332
+ throw new CuratedReleaseError("CURATED_PACKAGE_IDENTITY_REQUIRED");
333
+ }
334
+ if (packageIdentity.endsWith(".dev")) {
335
+ throw new CuratedReleaseError("CURATED_DEVELOPMENT_IDENTITY_FORBIDDEN");
336
+ }
337
+ if (basename(project.root) !== packageIdentity) {
338
+ throw new CuratedReleaseError("CURATED_PACKAGE_DIRECTORY_MISMATCH");
339
+ }
340
+ if (!isValidBuild(build)) {
341
+ throw new CuratedReleaseError("CURATED_BUILD_INVALID");
342
+ }
343
+ const appVersion = typeof contract.version === "string" ? contract.version.trim() : "";
344
+ if (!appVersion) {
345
+ throw new CuratedReleaseError("CURATED_APP_VERSION_REQUIRED");
346
+ }
347
+ if (/-\d+$/.test(appVersion)) {
348
+ throw new CuratedReleaseError("CURATED_BUILD_MUST_NOT_BE_IN_SOURCE_VERSION");
349
+ }
350
+ const version = `${appVersion}-${String(build).padStart(4, "0")}`;
351
+ const validation = validateProject(project, { profile: "listing" });
352
+ if (!validation.ok) {
353
+ throw new CuratedReleaseError("CURATED_VALIDATION_FAILED", validation.errors.map((error) => error.code).join(","));
354
+ }
355
+ const parsedCompose = loadCompose(project.composePath);
356
+ if (!parsedCompose) {
357
+ throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
358
+ }
359
+ const composeGate = validateCuratedCompose(parsedCompose.compose);
360
+ if (!composeGate.ok) {
361
+ throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
362
+ }
363
+ return {
364
+ contract,
365
+ compose: parsedCompose.compose,
366
+ packageIdentity,
367
+ version,
368
+ displayname: String(contract.displayname),
369
+ description: String(contract.description),
370
+ maintainer: typeof contract.maintainer === "string" ? contract.maintainer : undefined,
371
+ osMinVer: String(contract.os_min_ver),
372
+ };
373
+ }
374
+ function copyReleaseProject(source, workspace, packageIdentity) {
375
+ const root = join(workspace, packageIdentity);
376
+ mkdirSync(root, { recursive: true });
377
+ const contractPath = join(root, "synology-app.yaml");
378
+ const composePath = join(root, "compose.yaml");
379
+ writeFileSync(contractPath, readFileSync(source.contractPath));
380
+ writeFileSync(composePath, readFileSync(source.composePath));
381
+ for (const name of ["icon.png", "README.md", "html"]) {
382
+ const sourcePath = join(source.root, name);
383
+ if (existsSync(sourcePath)) {
384
+ cpSync(sourcePath, join(root, name), { recursive: true });
385
+ }
386
+ }
387
+ return { root, contractPath, composePath };
388
+ }
389
+ function digestArtifact(path) {
390
+ const bytes = readFileSync(path);
391
+ return {
392
+ size: bytes.byteLength,
393
+ md5: digestBytes(bytes, "md5"),
394
+ sha256: digestBytes(bytes),
395
+ };
396
+ }
397
+ function digestFile(path) {
398
+ return digestBytes(readFileSync(path));
399
+ }
400
+ /**
401
+ * Canonical App Bundle digest. Only the three declared bundle areas are
402
+ * included; archive metadata and presentation files can never affect it.
403
+ */
404
+ export function digestReleaseBundle(project) {
405
+ const files = [];
406
+ for (const name of ["compose.yaml", "synology-app.yaml"]) {
407
+ if (!existsSync(join(project.root, name)) || !statSync(join(project.root, name)).isFile()) {
408
+ throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", name);
409
+ }
410
+ files.push(name);
411
+ }
412
+ const htmlRoot = join(project.root, "html");
413
+ if (existsSync(htmlRoot)) {
414
+ collectBundleFiles(htmlRoot, "html", files);
415
+ }
416
+ files.sort();
417
+ const hash = createHash("sha256");
418
+ for (const relative of files) {
419
+ hash.update(relative, "utf8");
420
+ hash.update(Buffer.from([0]));
421
+ hash.update(readFileSync(join(project.root, relative)));
422
+ hash.update(Buffer.from([0]));
423
+ }
424
+ return hash.digest("hex");
425
+ }
426
+ function collectBundleFiles(root, relativeRoot, files) {
427
+ for (const name of readdirSync(root).sort()) {
428
+ const absolute = join(root, name);
429
+ const relative = `${relativeRoot}/${name}`;
430
+ const stat = statSync(absolute);
431
+ if (stat.isDirectory()) {
432
+ collectBundleFiles(absolute, relative, files);
433
+ }
434
+ else if (stat.isFile()) {
435
+ files.push(relative);
436
+ }
437
+ }
438
+ }
439
+ function collectCapabilities(contract, compose) {
440
+ const capabilities = [];
441
+ const files = contract.permissions?.files;
442
+ for (const name of ["read", "write", "delete"]) {
443
+ if (files?.[name] === true) {
444
+ capabilities.push(`files.${name}`);
445
+ }
446
+ }
447
+ const identity = contract.integration?.identity;
448
+ if (identity?.mode === "broker-managed") {
449
+ capabilities.push("identity");
450
+ }
451
+ for (const [name, parameter] of Object.entries(contract.install ?? {})) {
452
+ if (parameter && typeof parameter === "object" && typeof parameter.type === "string") {
453
+ capabilities.push(`install.${name}:${parameter.type}`);
454
+ }
455
+ }
456
+ if (Object.values(compose.services ?? {}).some((service) => service.privileged === true)) {
457
+ capabilities.push("compose.privileged");
458
+ }
459
+ return normalizeCapabilities(capabilities);
460
+ }
461
+ function assertEmbeddedAttestation(signed, expected) {
462
+ if (!signed.token || !signed.attestationDigest) {
463
+ throw new CuratedReleaseError("CURATED_ATTESTATION_BINDING_FAILED");
464
+ }
465
+ try {
466
+ assertProtectedHeader(signed.token);
467
+ const payload = decodeJwt(signed.token);
468
+ const rawCapabilities = readStringArray(payload.capabilities);
469
+ const capabilities = normalizeCapabilities(rawCapabilities);
470
+ const expectedCapabilities = normalizeCapabilities(expected.capabilities);
471
+ if (payload.typ !== "app-package-attestation" ||
472
+ payload.version !== 2 ||
473
+ payload.iss !== "synopkgland-curated" ||
474
+ payload.sub !== expected.packageIdentity ||
475
+ payload.packageIdentity !== expected.packageIdentity ||
476
+ payload.packageVersion !== expected.packageVersion ||
477
+ payload.packageBuild !== expected.packageBuild ||
478
+ payload.contractDigest !== expected.contractDigest ||
479
+ payload.bundleDigest !== expected.bundleDigest ||
480
+ JSON.stringify(rawCapabilities) !== JSON.stringify(capabilities) ||
481
+ JSON.stringify(capabilities) !== JSON.stringify(expectedCapabilities) ||
482
+ Object.prototype.hasOwnProperty.call(payload, "artifactDigest") ||
483
+ !isCanonicalIssuedAt(payload.issuedAt) ||
484
+ new Date(String(payload.issuedAt)).getTime() > Date.now() ||
485
+ payload.jti !== signed.attestationDigest) {
486
+ throw new Error("embedded claims do not bind release");
487
+ }
488
+ const claims = {
489
+ typ: "app-package-attestation",
490
+ version: 2,
491
+ packageIdentity: String(payload.packageIdentity),
492
+ packageVersion: String(payload.packageVersion),
493
+ packageBuild: Number(payload.packageBuild),
494
+ contractDigest: String(payload.contractDigest),
495
+ bundleDigest: String(payload.bundleDigest),
496
+ capabilities,
497
+ issuedAt: String(payload.issuedAt),
498
+ };
499
+ if (digestAppPackageClaims(claims) !== signed.attestationDigest) {
500
+ throw new Error("embedded attestation digest does not match claims");
501
+ }
502
+ }
503
+ catch {
504
+ throw new CuratedReleaseError("CURATED_ATTESTATION_BINDING_FAILED");
505
+ }
506
+ }
507
+ function assertDetachedAttestation(signed, expected) {
508
+ if (!signed.token || !signed.attestationDigest) {
509
+ throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
510
+ }
511
+ try {
512
+ assertProtectedHeader(signed.token);
513
+ const payload = decodeJwt(signed.token);
514
+ if (payload.typ !== "curated-release-attestation" ||
515
+ payload.version !== 1 ||
516
+ payload.iss !== "synopkgland-curated" ||
517
+ payload.sub !== expected.packageIdentity ||
518
+ payload.packageIdentity !== expected.packageIdentity ||
519
+ payload.packageVersion !== expected.packageVersion ||
520
+ payload.packageBuild !== expected.packageBuild ||
521
+ payload.artifactDigest !== expected.artifactDigest ||
522
+ payload.embeddedAttestationDigest !== expected.embeddedAttestationDigest ||
523
+ !isCanonicalIssuedAt(payload.issuedAt) ||
524
+ new Date(String(payload.issuedAt)).getTime() > Date.now() ||
525
+ payload.jti !== signed.attestationDigest) {
526
+ throw new Error("detached claims do not bind release");
527
+ }
528
+ const claims = {
529
+ typ: "curated-release-attestation",
530
+ version: 1,
531
+ packageIdentity: String(payload.packageIdentity),
532
+ packageVersion: String(payload.packageVersion),
533
+ packageBuild: Number(payload.packageBuild),
534
+ artifactDigest: String(payload.artifactDigest),
535
+ embeddedAttestationDigest: String(payload.embeddedAttestationDigest),
536
+ issuedAt: String(payload.issuedAt),
537
+ };
538
+ if (digestCuratedReleaseClaims(claims) !== signed.attestationDigest) {
539
+ throw new Error("detached attestation digest does not match claims");
540
+ }
541
+ }
542
+ catch {
543
+ throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
544
+ }
545
+ }
546
+ function assertProtectedHeader(token) {
547
+ const header = decodeProtectedHeader(token);
548
+ if (header.alg !== "RS256" ||
549
+ header.typ !== "JWT" ||
550
+ typeof header.kid !== "string" ||
551
+ header.kid.length === 0) {
552
+ throw new Error("attestation protected header is invalid");
553
+ }
554
+ }
555
+ async function assertEmbeddedAttestationInSpk(spkPath, expectedToken, expectedInput, signer) {
556
+ const staging = mkdtempSync(join(tmpdir(), "synopkgland-curated-attestation-check-"));
557
+ try {
558
+ try {
559
+ await extractSpk(spkPath, staging);
560
+ }
561
+ catch {
562
+ throw new CuratedReleaseError("CURATED_EMBEDDED_ATTESTATION_MISSING");
563
+ }
564
+ const path = join(staging, "package", EMBEDDED_ATTESTATION_RELATIVE_PATH);
565
+ if (!existsSync(path) || !statSync(path).isFile()) {
566
+ throw new CuratedReleaseError("CURATED_EMBEDDED_ATTESTATION_MISSING");
567
+ }
568
+ const token = readFileSync(path, "utf8");
569
+ if (token !== expectedToken) {
570
+ throw new CuratedReleaseError("CURATED_EMBEDDED_ATTESTATION_BINDING_FAILED");
571
+ }
572
+ const signed = {
573
+ token,
574
+ attestationDigest: decodeJwt(token).jti ?? "",
575
+ };
576
+ assertEmbeddedAttestation(signed, expectedInput);
577
+ await verifySignedToken(signer, token, "embedded");
578
+ }
579
+ finally {
580
+ rmSync(staging, { recursive: true, force: true });
581
+ }
582
+ }
583
+ async function verifySignedToken(signer, token, kind) {
584
+ if (!signer.verify) {
585
+ throw new CuratedReleaseError("CURATED_ATTESTATION_SIGNATURE_INVALID");
586
+ }
587
+ try {
588
+ await signer.verify(token, kind);
589
+ }
590
+ catch {
591
+ throw new CuratedReleaseError("CURATED_ATTESTATION_SIGNATURE_INVALID");
592
+ }
593
+ }
594
+ function readStringArray(value) {
595
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
596
+ throw new Error("attestation capabilities must be strings");
597
+ }
598
+ return value;
599
+ }
600
+ function isCanonicalIssuedAt(value) {
601
+ if (typeof value !== "string" ||
602
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) {
603
+ return false;
604
+ }
605
+ const date = new Date(value);
606
+ return Number.isFinite(date.getTime()) && date.toISOString() === value;
607
+ }
608
+ function digestBytes(bytes, algorithm = "sha256") {
609
+ return createHash(algorithm).update(bytes).digest("hex");
610
+ }
611
+ function parseBuild(value) {
612
+ if (!/^\d+$/.test(value)) {
613
+ throw new CuratedReleaseError("CURATED_BUILD_INVALID");
614
+ }
615
+ const build = Number(value);
616
+ if (!isValidBuild(build)) {
617
+ throw new CuratedReleaseError("CURATED_BUILD_INVALID");
618
+ }
619
+ return build;
620
+ }
621
+ function isValidBuild(build) {
622
+ return Number.isSafeInteger(build) && build > 0;
623
+ }
624
+ async function loadJwkSigningMaterial(raw, kidFromEnv) {
625
+ const parsed = JSON.parse(raw);
626
+ const privateJwk = parsed.privateJwk ?? parsed;
627
+ const kid = kidFromEnv ?? parsed.kid ?? privateJwk.kid;
628
+ if (typeof kid !== "string" || kid.length === 0 || !privateJwk.d) {
629
+ throw new Error("invalid private JWK");
630
+ }
631
+ if (kidFromEnv && parsed.kid && parsed.kid !== kidFromEnv) {
632
+ throw new Error("private JWK kid does not match custody kid");
633
+ }
634
+ if (privateJwk.kid && privateJwk.kid !== kid) {
635
+ throw new Error("private JWK kid does not match custody kid");
636
+ }
637
+ if (privateJwk.kty !== "RSA" || privateJwk.alg !== "RS256") {
638
+ throw new Error("private signing material must be an RSA RS256 JWK");
639
+ }
640
+ if (typeof privateJwk.n !== "string" ||
641
+ typeof privateJwk.e !== "string") {
642
+ throw new Error("private signing material did not produce a public RSA key");
643
+ }
644
+ const privateKey = (await importJWK({ ...privateJwk, kid }, "RS256"));
645
+ const publicJwk = {
646
+ kty: privateJwk.kty,
647
+ n: privateJwk.n,
648
+ e: privateJwk.e,
649
+ alg: "RS256",
650
+ use: "sig",
651
+ kid,
652
+ };
653
+ const suppliedPublicJwk = parsed.publicJwk;
654
+ if (suppliedPublicJwk &&
655
+ (suppliedPublicJwk.kty !== publicJwk.kty ||
656
+ suppliedPublicJwk.n !== publicJwk.n ||
657
+ suppliedPublicJwk.e !== publicJwk.e)) {
658
+ throw new Error("private and public signing material do not match");
659
+ }
660
+ return {
661
+ signingKey: {
662
+ kid,
663
+ privateKey,
664
+ },
665
+ publicKey: (await importJWK(publicJwk, "RS256")),
666
+ publicJwk,
667
+ };
668
+ }
669
+ async function loadPkcs8SigningMaterial(raw, kid) {
670
+ if (!kid) {
671
+ throw new Error("missing kid");
672
+ }
673
+ const encoded = raw.includes("BEGIN") ? raw : Buffer.from(raw, "base64").toString("utf8");
674
+ const privateKey = await importPKCS8(encoded, "RS256");
675
+ const publicKeyObject = createPublicKey(encoded);
676
+ const publicJwk = {
677
+ ...publicKeyObject.export({ format: "jwk" }),
678
+ alg: "RS256",
679
+ use: "sig",
680
+ kid,
681
+ };
682
+ const publicPem = publicKeyObject.export({ type: "spki", format: "pem" }).toString();
683
+ const publicKey = await importSPKI(publicPem, "RS256");
684
+ return { signingKey: { kid, privateKey }, publicKey, publicJwk };
685
+ }
686
+ function isMainModule() {
687
+ return process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
688
+ }
689
+ if (isMainModule()) {
690
+ runCuratedReleaseCli().then((status) => {
691
+ process.exitCode = status;
692
+ });
693
+ }