@synopackageland/cli 0.3.1 → 0.3.3

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 (69) hide show
  1. package/dist/attestation/app-package.js +8 -1
  2. package/dist/attestation/platform-service.d.ts +70 -0
  3. package/dist/attestation/platform-service.js +250 -0
  4. package/dist/bin.js +0 -0
  5. package/dist/build-spk/generator.js +200 -10
  6. package/dist/build-spk/resource-worker.d.ts +14 -0
  7. package/dist/build-spk/resource-worker.js +140 -0
  8. package/dist/cli.js +69 -10
  9. package/dist/compose/analyze.d.ts +42 -1
  10. package/dist/compose/analyze.js +482 -18
  11. package/dist/contract/load.d.ts +1 -0
  12. package/dist/contract/load.js +1 -1
  13. package/dist/contract/posix-all-shares.d.ts +16 -0
  14. package/dist/contract/posix-all-shares.js +63 -0
  15. package/dist/contract/schema-validate.js +26 -1
  16. package/dist/contract/validate.js +57 -0
  17. package/dist/contract/web-launch.d.ts +6 -0
  18. package/dist/contract/web-launch.js +25 -0
  19. package/dist/deploy/deploy.d.ts +1 -0
  20. package/dist/deploy/deploy.js +94 -2
  21. package/dist/deploy/homes-values.d.ts +19 -0
  22. package/dist/deploy/homes-values.js +29 -0
  23. package/dist/deploy/param-values.d.ts +21 -0
  24. package/dist/deploy/param-values.js +51 -0
  25. package/dist/deploy/spk-version.d.ts +13 -0
  26. package/dist/deploy/spk-version.js +80 -27
  27. package/dist/doctor.d.ts +22 -0
  28. package/dist/doctor.js +594 -0
  29. package/dist/error-collector.d.ts +2 -0
  30. package/dist/error-collector.js +46 -0
  31. package/dist/error-registry.d.ts +7 -0
  32. package/dist/error-registry.js +837 -0
  33. package/dist/host/broker-upgrade-preflight.d.ts +61 -0
  34. package/dist/host/broker-upgrade-preflight.js +156 -0
  35. package/dist/host/catalog-install.d.ts +20 -0
  36. package/dist/host/catalog-install.js +143 -17
  37. package/dist/host/credentials.js +35 -5
  38. package/dist/host/dsm-client.d.ts +12 -0
  39. package/dist/host/dsm-client.js +69 -1
  40. package/dist/host/dsm-compound.d.ts +37 -0
  41. package/dist/host/dsm-compound.js +120 -0
  42. package/dist/host/fake-nas.d.ts +1 -0
  43. package/dist/host/fake-nas.js +3 -0
  44. package/dist/host/types.d.ts +6 -0
  45. package/dist/publish/curated-release.js +8 -4
  46. package/dist/publish/platform-service-release.d.ts +60 -0
  47. package/dist/publish/platform-service-release.js +333 -0
  48. package/dist/publish/preflight.js +24 -2
  49. package/dist/release-bin.js +5 -1
  50. package/dist/templates/hello-files/compose.yaml +2 -0
  51. package/dist/templates/hello-web/compose.yaml +14 -3
  52. package/dist/templates/hello-web/synology-app.yaml +1 -1
  53. package/dist/templates/hello-web-page/compose.yaml +14 -3
  54. package/dist/templates/hello-web-page/synology-app.yaml +1 -1
  55. package/dist/test-host.d.ts +2 -0
  56. package/dist/test-host.js +15 -2
  57. package/dist/types.d.ts +1 -1
  58. package/dist/validate.js +14 -3
  59. package/package.json +2 -2
  60. package/schema/doctor.schema.json +38 -0
  61. package/schema/synology-app.schema.json +1 -1
  62. package/skills/reference/create.md +19 -0
  63. package/skills/reference/diagnose.md +35 -2
  64. package/skills/reference/files.md +10 -0
  65. package/templates/hello-files/compose.yaml +2 -0
  66. package/templates/hello-web/compose.yaml +14 -3
  67. package/templates/hello-web/synology-app.yaml +1 -1
  68. package/templates/hello-web-page/compose.yaml +14 -3
  69. package/templates/hello-web-page/synology-app.yaml +1 -1
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { SignJWT, exportJWK, generateKeyPair, importJWK } from "jose";
5
+ import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
5
6
  export const PLATFORM_ATTESTATION_ISSUER = "synopkgland-curated";
6
7
  export const APP_PACKAGE_ATTESTATION_TYP = "app-package-attestation";
7
8
  export const APP_PACKAGE_ATTESTATION_VERSION = 2;
@@ -141,8 +142,14 @@ export function assertPackageVersionBinding(packageVersion, packageBuild) {
141
142
  if (typeof packageVersion !== "string" || packageVersion.length === 0) {
142
143
  throw new Error("packageVersion must be a non-empty string");
143
144
  }
145
+ if (!Number.isSafeInteger(packageBuild) ||
146
+ packageBuild < 1 ||
147
+ packageBuild >= CURATED_BUILD_OFFSET) {
148
+ throw new Error("packageBuild must be a positive safe integer below the Curated offset");
149
+ }
144
150
  const match = /-(\d+)$/.exec(packageVersion);
145
- if (!match || Number(match[1]) !== packageBuild) {
151
+ const parsed = match ? Number(match[1]) : NaN;
152
+ if (parsed !== packageBuild && parsed !== packageBuild + CURATED_BUILD_OFFSET) {
146
153
  throw new Error("packageVersion and packageBuild must agree");
147
154
  }
148
155
  }
@@ -0,0 +1,70 @@
1
+ import { type JWK } from "jose";
2
+ import type { AppPackageSigningKey } from "./app-package.js";
3
+ /**
4
+ * Trust contract for releasing the platform service (the Broker SPK) itself.
5
+ * This is deliberately a separate issuer, typ and keyring from the curated App
6
+ * chain: the Broker carries the curated trust root as *data*, so that root can
7
+ * never double as the authority that signs the Broker's own releases.
8
+ */
9
+ export declare const PLATFORM_ATTESTATION_ISSUER = "synopkgland-platform";
10
+ export declare const PLATFORM_SERVICE_RELEASE_ATTESTATION_TYP = "platform-service-release-attestation";
11
+ export declare const PLATFORM_SERVICE_RELEASE_ATTESTATION_VERSION: 1;
12
+ export declare const PLATFORM_SERVICE_PACKAGE_IDENTITY = "SynoPkgLandService";
13
+ export declare const PLATFORM_KEYRING_PURPOSE = "platform-service-release";
14
+ export declare const PLATFORM_ATTESTATION_PRIVATE_JWK_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_PRIVATE_JWK";
15
+ export declare const PLATFORM_ATTESTATION_KID_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_KID";
16
+ export declare const PLATFORM_ATTESTATION_KEYRING_PATH_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_KEYRING_PATH";
17
+ export type PlatformServiceReleaseErrorCode = "PLATFORM_KEYRING_INVALID" | "PLATFORM_KEYRING_PURPOSE_MISMATCH" | "PLATFORM_KEYRING_PRIVATE_MATERIAL" | "PLATFORM_KEYRING_NO_ACTIVE_KEY" | "PLATFORM_SIGNING_MATERIAL_MISSING" | "PLATFORM_SIGNING_MATERIAL_INVALID" | "PLATFORM_SIGNING_KEY_UNKNOWN" | "PLATFORM_ATTESTATION_INPUT_INVALID" | "PLATFORM_ATTESTATION_INVALID";
18
+ export declare class PlatformServiceReleaseError extends Error {
19
+ readonly code: PlatformServiceReleaseErrorCode;
20
+ constructor(code: PlatformServiceReleaseErrorCode, detail?: string);
21
+ }
22
+ export interface PlatformServiceReleaseAttestationInput {
23
+ packageIdentity: string;
24
+ /** Source version without any Build suffix; the constants file keeps it. */
25
+ sourceVersion: string;
26
+ packageBuild: number;
27
+ /** `${sourceVersion}-<zero-padded Build>`; must agree with packageBuild. */
28
+ packageVersion: string;
29
+ /** SHA-256 of the final SPK bytes. */
30
+ artifactDigest: string;
31
+ /** Git commit the trusted workflow checked out and built. */
32
+ sourceRevision: string;
33
+ osMinVer: string;
34
+ arch: "noarch";
35
+ }
36
+ export interface PlatformServiceReleaseAttestationClaims extends PlatformServiceReleaseAttestationInput {
37
+ typ: typeof PLATFORM_SERVICE_RELEASE_ATTESTATION_TYP;
38
+ version: typeof PLATFORM_SERVICE_RELEASE_ATTESTATION_VERSION;
39
+ issuedAt: string;
40
+ }
41
+ export type PlatformKeyStatus = "active" | "verify-only" | "revoked";
42
+ export interface PlatformKeyringDocument {
43
+ version: 1;
44
+ purpose: typeof PLATFORM_KEYRING_PURPOSE;
45
+ keys: Array<{
46
+ kid: string;
47
+ status: PlatformKeyStatus;
48
+ publicJwk: JWK;
49
+ }>;
50
+ }
51
+ export interface PlatformAttestationSigner {
52
+ sign(input: PlatformServiceReleaseAttestationInput): Promise<{
53
+ token: string;
54
+ attestationDigest: string;
55
+ claims: PlatformServiceReleaseAttestationClaims;
56
+ }>;
57
+ verify(token: string): Promise<PlatformServiceReleaseAttestationClaims>;
58
+ verificationJwk: JWK;
59
+ }
60
+ export declare function parsePlatformKeyringDocument(raw: unknown): PlatformKeyringDocument;
61
+ export declare function readPlatformKeyringFile(path: string): PlatformKeyringDocument;
62
+ export declare function canonicalPlatformServiceReleaseClaims(claims: PlatformServiceReleaseAttestationClaims): string;
63
+ export declare function digestPlatformServiceReleaseClaims(claims: PlatformServiceReleaseAttestationClaims): string;
64
+ export declare function signPlatformServiceReleaseAttestationToken(input: PlatformServiceReleaseAttestationInput, key: AppPackageSigningKey): Promise<{
65
+ token: string;
66
+ attestationDigest: string;
67
+ claims: PlatformServiceReleaseAttestationClaims;
68
+ }>;
69
+ export declare function verifyPlatformServiceReleaseAttestationToken(token: string, keyring: PlatformKeyringDocument): Promise<PlatformServiceReleaseAttestationClaims>;
70
+ export declare function createPlatformAttestationSignerFromEnv(env?: NodeJS.Dict<string>): Promise<PlatformAttestationSigner>;
@@ -0,0 +1,250 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { SignJWT, importJWK, jwtVerify } from "jose";
4
+ /**
5
+ * Trust contract for releasing the platform service (the Broker SPK) itself.
6
+ * This is deliberately a separate issuer, typ and keyring from the curated App
7
+ * chain: the Broker carries the curated trust root as *data*, so that root can
8
+ * never double as the authority that signs the Broker's own releases.
9
+ */
10
+ export const PLATFORM_ATTESTATION_ISSUER = "synopkgland-platform";
11
+ export const PLATFORM_SERVICE_RELEASE_ATTESTATION_TYP = "platform-service-release-attestation";
12
+ export const PLATFORM_SERVICE_RELEASE_ATTESTATION_VERSION = 1;
13
+ export const PLATFORM_SERVICE_PACKAGE_IDENTITY = "SynoPkgLandService";
14
+ export const PLATFORM_KEYRING_PURPOSE = "platform-service-release";
15
+ export const PLATFORM_ATTESTATION_PRIVATE_JWK_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_PRIVATE_JWK";
16
+ export const PLATFORM_ATTESTATION_KID_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_KID";
17
+ export const PLATFORM_ATTESTATION_KEYRING_PATH_ENV = "SYNOPKGLAND_PLATFORM_ATTESTATION_KEYRING_PATH";
18
+ export class PlatformServiceReleaseError extends Error {
19
+ code;
20
+ constructor(code, detail) {
21
+ super(detail ? `${code}: ${detail}` : code);
22
+ this.code = code;
23
+ this.name = "PlatformServiceReleaseError";
24
+ }
25
+ }
26
+ const PRIVATE_JWK_FIELDS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
27
+ const KEY_STATUSES = new Set(["active", "verify-only", "revoked"]);
28
+ export function parsePlatformKeyringDocument(raw) {
29
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
30
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", "not an object");
31
+ }
32
+ const document = raw;
33
+ if (document.version !== 1 || !Array.isArray(document.keys)) {
34
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", "unsupported document shape");
35
+ }
36
+ if (document.purpose !== PLATFORM_KEYRING_PURPOSE) {
37
+ // A curated keyring document has no purpose field at all; refusing here is
38
+ // what keeps the Broker's bundled trust root from signing the Broker.
39
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_PURPOSE_MISMATCH");
40
+ }
41
+ for (const entry of document.keys) {
42
+ if (!entry ||
43
+ typeof entry.kid !== "string" ||
44
+ entry.kid.length === 0 ||
45
+ !KEY_STATUSES.has(entry.status) ||
46
+ !entry.publicJwk ||
47
+ typeof entry.publicJwk !== "object") {
48
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", "invalid key entry");
49
+ }
50
+ if (PRIVATE_JWK_FIELDS.some((field) => field in entry.publicJwk)) {
51
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_PRIVATE_MATERIAL");
52
+ }
53
+ if (entry.publicJwk.kty !== "RSA" || typeof entry.publicJwk.n !== "string") {
54
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", "not a public RSA key");
55
+ }
56
+ }
57
+ if (!document.keys.some((entry) => entry.status === "active")) {
58
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_NO_ACTIVE_KEY");
59
+ }
60
+ return document;
61
+ }
62
+ export function readPlatformKeyringFile(path) {
63
+ let raw;
64
+ try {
65
+ raw = readFileSync(path, "utf8");
66
+ }
67
+ catch (error) {
68
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", `unable to read keyring: ${error instanceof Error ? error.message : String(error)}`);
69
+ }
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ }
74
+ catch {
75
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", "keyring is not valid JSON");
76
+ }
77
+ return parsePlatformKeyringDocument(parsed);
78
+ }
79
+ export function canonicalPlatformServiceReleaseClaims(claims) {
80
+ return JSON.stringify({
81
+ arch: claims.arch,
82
+ artifactDigest: claims.artifactDigest,
83
+ issuedAt: claims.issuedAt,
84
+ osMinVer: claims.osMinVer,
85
+ packageBuild: claims.packageBuild,
86
+ packageIdentity: claims.packageIdentity,
87
+ packageVersion: claims.packageVersion,
88
+ sourceRevision: claims.sourceRevision,
89
+ sourceVersion: claims.sourceVersion,
90
+ typ: claims.typ,
91
+ version: claims.version,
92
+ });
93
+ }
94
+ export function digestPlatformServiceReleaseClaims(claims) {
95
+ return createHash("sha256").update(canonicalPlatformServiceReleaseClaims(claims)).digest("hex");
96
+ }
97
+ export async function signPlatformServiceReleaseAttestationToken(input, key) {
98
+ assertPlatformInput(input);
99
+ if (typeof key.kid !== "string" || key.kid.length === 0) {
100
+ throw new PlatformServiceReleaseError("PLATFORM_SIGNING_MATERIAL_INVALID", "kid required");
101
+ }
102
+ const claims = {
103
+ typ: PLATFORM_SERVICE_RELEASE_ATTESTATION_TYP,
104
+ version: PLATFORM_SERVICE_RELEASE_ATTESTATION_VERSION,
105
+ packageIdentity: input.packageIdentity,
106
+ sourceVersion: input.sourceVersion,
107
+ packageBuild: input.packageBuild,
108
+ packageVersion: input.packageVersion,
109
+ artifactDigest: input.artifactDigest,
110
+ sourceRevision: input.sourceRevision,
111
+ osMinVer: input.osMinVer,
112
+ arch: input.arch,
113
+ issuedAt: new Date().toISOString(),
114
+ };
115
+ const attestationDigest = digestPlatformServiceReleaseClaims(claims);
116
+ const token = await new SignJWT({ ...claims })
117
+ .setProtectedHeader({ alg: "RS256", kid: key.kid, typ: "JWT" })
118
+ .setIssuer(PLATFORM_ATTESTATION_ISSUER)
119
+ .setSubject(claims.packageIdentity)
120
+ .setJti(attestationDigest)
121
+ .sign(key.privateKey);
122
+ return { token, attestationDigest, claims };
123
+ }
124
+ export async function verifyPlatformServiceReleaseAttestationToken(token, keyring) {
125
+ parsePlatformKeyringDocument(keyring);
126
+ try {
127
+ const header = decodeHeader(token);
128
+ const entry = keyring.keys.find((candidate) => candidate.kid === header.kid && candidate.status !== "revoked");
129
+ if (!entry) {
130
+ throw new Error("unknown or revoked kid");
131
+ }
132
+ const publicKey = (await importJWK(entry.publicJwk, "RS256"));
133
+ const result = await jwtVerify(token, publicKey, {
134
+ algorithms: ["RS256"],
135
+ issuer: PLATFORM_ATTESTATION_ISSUER,
136
+ });
137
+ if (result.protectedHeader.alg !== "RS256" ||
138
+ result.protectedHeader.typ !== "JWT" ||
139
+ result.protectedHeader.kid !== entry.kid) {
140
+ throw new Error("token header mismatch");
141
+ }
142
+ const payload = result.payload;
143
+ if (payload.typ !== PLATFORM_SERVICE_RELEASE_ATTESTATION_TYP ||
144
+ payload.version !== PLATFORM_SERVICE_RELEASE_ATTESTATION_VERSION) {
145
+ throw new Error("token typ mismatch");
146
+ }
147
+ const claims = {
148
+ typ: payload.typ,
149
+ version: payload.version,
150
+ packageIdentity: payload.packageIdentity,
151
+ sourceVersion: payload.sourceVersion,
152
+ packageBuild: payload.packageBuild,
153
+ packageVersion: payload.packageVersion,
154
+ artifactDigest: payload.artifactDigest,
155
+ sourceRevision: payload.sourceRevision,
156
+ osMinVer: payload.osMinVer,
157
+ arch: payload.arch,
158
+ issuedAt: payload.issuedAt,
159
+ };
160
+ assertPlatformInput(claims);
161
+ if (typeof claims.issuedAt !== "string" || Number.isNaN(Date.parse(claims.issuedAt))) {
162
+ throw new Error("issuedAt is not a timestamp");
163
+ }
164
+ if (result.payload.jti !== digestPlatformServiceReleaseClaims(claims)) {
165
+ throw new Error("jti does not bind the claims");
166
+ }
167
+ return claims;
168
+ }
169
+ catch (error) {
170
+ if (error instanceof PlatformServiceReleaseError) {
171
+ throw new PlatformServiceReleaseError("PLATFORM_ATTESTATION_INVALID", error.message);
172
+ }
173
+ throw new PlatformServiceReleaseError("PLATFORM_ATTESTATION_INVALID", error instanceof Error ? error.message : String(error));
174
+ }
175
+ }
176
+ export async function createPlatformAttestationSignerFromEnv(env = process.env) {
177
+ const jwkRaw = env[PLATFORM_ATTESTATION_PRIVATE_JWK_ENV]?.trim();
178
+ const kidFromEnv = env[PLATFORM_ATTESTATION_KID_ENV]?.trim();
179
+ const keyringPath = env[PLATFORM_ATTESTATION_KEYRING_PATH_ENV]?.trim();
180
+ if (!jwkRaw) {
181
+ throw new PlatformServiceReleaseError("PLATFORM_SIGNING_MATERIAL_MISSING");
182
+ }
183
+ if (!keyringPath) {
184
+ throw new PlatformServiceReleaseError("PLATFORM_KEYRING_INVALID", `${PLATFORM_ATTESTATION_KEYRING_PATH_ENV} is required`);
185
+ }
186
+ const keyring = readPlatformKeyringFile(keyringPath);
187
+ let privateJwk;
188
+ let privateKey;
189
+ try {
190
+ privateJwk = JSON.parse(jwkRaw);
191
+ privateKey = (await importJWK(privateJwk, "RS256"));
192
+ }
193
+ catch {
194
+ throw new PlatformServiceReleaseError("PLATFORM_SIGNING_MATERIAL_INVALID");
195
+ }
196
+ const kid = kidFromEnv || (typeof privateJwk.kid === "string" ? privateJwk.kid : "");
197
+ if (!kid) {
198
+ throw new PlatformServiceReleaseError("PLATFORM_SIGNING_MATERIAL_INVALID", "kid required");
199
+ }
200
+ const trustEntry = keyring.keys.find((entry) => entry.kid === kid && entry.status === "active");
201
+ if (!trustEntry) {
202
+ throw new PlatformServiceReleaseError("PLATFORM_SIGNING_KEY_UNKNOWN");
203
+ }
204
+ const signingKey = { kid, privateKey };
205
+ return {
206
+ sign: (input) => signPlatformServiceReleaseAttestationToken(input, signingKey),
207
+ verify: (token) => verifyPlatformServiceReleaseAttestationToken(token, keyring),
208
+ verificationJwk: trustEntry.publicJwk,
209
+ };
210
+ }
211
+ function assertPlatformInput(input) {
212
+ const fail = (detail) => {
213
+ throw new PlatformServiceReleaseError("PLATFORM_ATTESTATION_INPUT_INVALID", detail);
214
+ };
215
+ if (input.packageIdentity !== PLATFORM_SERVICE_PACKAGE_IDENTITY) {
216
+ fail("packageIdentity must be the platform service identity");
217
+ }
218
+ if (!/^\d+\.\d+\.\d+$/.test(input.sourceVersion)) {
219
+ fail("sourceVersion must be plain semver without a Build suffix");
220
+ }
221
+ if (!Number.isSafeInteger(input.packageBuild) || input.packageBuild < 1) {
222
+ fail("packageBuild must be a positive safe integer");
223
+ }
224
+ if (input.packageVersion !== `${input.sourceVersion}-${String(input.packageBuild).padStart(4, "0")}`) {
225
+ fail("packageVersion must be sourceVersion plus the zero-padded Build");
226
+ }
227
+ if (!/^[a-f0-9]{64}$/.test(input.artifactDigest)) {
228
+ fail("artifactDigest must be a lowercase SHA-256 digest");
229
+ }
230
+ if (!/^[a-f0-9]{40}$/.test(input.sourceRevision)) {
231
+ fail("sourceRevision must be a full git commit SHA");
232
+ }
233
+ if (!/^\d+\.\d+-\d+$/.test(input.osMinVer)) {
234
+ fail("osMinVer must look like 7.2-69057");
235
+ }
236
+ if (input.arch !== "noarch") {
237
+ fail("the platform service is noarch");
238
+ }
239
+ }
240
+ function decodeHeader(token) {
241
+ const parts = token.split(".");
242
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
243
+ throw new Error("not a compact JWT");
244
+ }
245
+ const header = JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"));
246
+ if (!header || typeof header !== "object" || Array.isArray(header)) {
247
+ throw new Error("invalid JWT header");
248
+ }
249
+ return header;
250
+ }
package/dist/bin.js CHANGED
File without changes
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import * as tar from "tar";
6
6
  import { loadContract, collectInstallParameters } from "../contract/load.js";
7
7
  import { renderDsmWindowConfig, resolvedWebLaunch, trustedAppEntryPath, } from "../contract/web-launch.js";
8
- import { loadCompose } from "../compose/analyze.js";
8
+ import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
9
9
  import { scanComposePrivilege } from "../compose/privilege.js";
10
10
  import { validateProject } from "../validate.js";
11
11
  import { writeDsmWindowIcons, writePackageCenterIcons } from "./dsm-icons.js";
@@ -16,6 +16,7 @@ import { BROKER_INSTALLER_VARS, BROKER_SOCKET_REQUIRED_SCRIPT, renderBrokerStatu
16
16
  import { loadNative } from "../native/load.js";
17
17
  import { materializeNativePayload } from "./native-payload.js";
18
18
  import { renderNativePrivilege, renderNativeStartStopStatus, renderNativePostuninst, } from "./native-lifecycle.js";
19
+ import { renderResourceConfig, renderResourceWorkerStartStopStatus } from "./resource-worker.js";
19
20
  const FILE_MODE = 0o644;
20
21
  const SCRIPT_MODE = 0o755;
21
22
  const INSTALL_UIFILE = "install_uifile";
@@ -129,30 +130,54 @@ export async function buildSpk(options) {
129
130
  rmSync(staging, { recursive: true, force: true });
130
131
  }
131
132
  }
133
+ const parsedCompose = loadCompose(options.project.composePath);
134
+ const route = parsedCompose?.compose
135
+ ? classifyComposeRoute(contract, parsedCompose)
136
+ : undefined;
137
+ if (route && route.errors.length > 0) {
138
+ return { ok: false, errors: route.errors.map((error) => error.code) };
139
+ }
140
+ const rwEligibility = route?.eligibility;
141
+ const isDynamicRoute = route?.route === "dynamic";
142
+ const isResourceWorkerRoute = route?.route === "static-zero-config" ||
143
+ isDynamicRoute;
132
144
  const staging = mkdtempSync(join(tmpdir(), "syno-spk-"));
133
145
  try {
134
- writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily), "utf8");
146
+ writeFileSync(join(staging, "INFO"), renderInfo(contract, options.archFamily, isResourceWorkerRoute), "utf8");
135
147
  writePackageCenterIcons(staging);
136
148
  mkdirSync(join(staging, "conf"), { recursive: true });
137
149
  writeFileSync(join(staging, "conf", "privilege"), renderPrivilege(), "utf8");
138
150
  writeFileSync(join(staging, "privilege"), renderPrivilege(), "utf8");
151
+ if (isResourceWorkerRoute) {
152
+ const resourceConfig = renderResourceConfig(contract, rwEligibility);
153
+ writeFileSync(join(staging, "conf", "resource"), resourceConfig, "utf8");
154
+ chmodSync(join(staging, "conf", "resource"), FILE_MODE);
155
+ }
139
156
  mkdirSync(join(staging, "WIZARD_UIFILES"), { recursive: true });
140
- const parsedCompose = loadCompose(options.project.composePath);
141
157
  writeFileSync(join(staging, "WIZARD_UIFILES", INSTALL_UIFILE), JSON.stringify(renderInstallWizard(contract, parsedCompose?.compose), null, 2), "utf8");
142
158
  writeFileSync(join(staging, "WIZARD_UIFILES", UPGRADE_UIFILE), JSON.stringify(renderUpgradeWizard(contract, parsedCompose?.compose), null, 2), "utf8");
143
159
  writeFileSync(join(staging, "WIZARD_UIFILES", UNINSTALL_UIFILE), JSON.stringify(renderUninstallWizard(), null, 2), "utf8");
144
160
  mkdirSync(join(staging, "scripts"), { recursive: true });
145
161
  for (const name of SCRIPT_NAMES) {
146
162
  const scriptPath = join(staging, "scripts", name);
147
- writeFileSync(scriptPath, renderManagedScript(name, contract), "utf8");
163
+ writeFileSync(scriptPath, renderManagedScript(name, contract, undefined, isResourceWorkerRoute, rwEligibility?.adminport, isDynamicRoute), "utf8");
148
164
  chmodSync(scriptPath, SCRIPT_MODE);
149
165
  }
150
166
  mkdirSync(join(staging, "package"), { recursive: true });
151
167
  copyFileSync(options.project.composePath, join(staging, "package", "compose.yaml"));
152
168
  copyFileSync(options.project.contractPath, join(staging, "package", "synology-app.yaml"));
169
+ if (isResourceWorkerRoute) {
170
+ const resourceProjectDir = join(staging, "package", "compose");
171
+ mkdirSync(resourceProjectDir, { recursive: true });
172
+ copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
173
+ chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
174
+ }
153
175
  const htmlDir = join(options.project.root, "html");
154
176
  if (existsSync(htmlDir)) {
155
177
  cpSync(htmlDir, join(staging, "package", "html"), { recursive: true });
178
+ if (isResourceWorkerRoute) {
179
+ cpSync(htmlDir, join(staging, "package", "compose", "html"), { recursive: true });
180
+ }
156
181
  }
157
182
  if (options.embeddedAttestationToken !== undefined) {
158
183
  const attestationPath = join(staging, "package", EMBEDDED_ATTESTATION_RELATIVE_PATH);
@@ -190,7 +215,7 @@ function quoteInfo(value) {
190
215
  function spkVersion(version) {
191
216
  return /-\d+$/.test(version) ? version : `${version}-0001`;
192
217
  }
193
- function renderInfo(contract, archFamily) {
218
+ function renderInfo(contract, archFamily, isResourceWorker = false) {
194
219
  const lines = [];
195
220
  const add = (key, value) => {
196
221
  lines.push(`${key}=${quoteInfo(String(value))}`);
@@ -202,10 +227,22 @@ function renderInfo(contract, archFamily) {
202
227
  add("maintainer", String(contract.maintainer));
203
228
  add("arch", archFamily === undefined ? "noarch" : infoArchValue(archFamily));
204
229
  add("os_min_ver", String(contract.os_min_ver));
205
- // synopkg's Container Manager dep gate reports "not ready" even when CM is
206
- // running. Depend on Broker only; Broker's adapter.probe() checks CM at
207
- // App install time (CM_CAPABILITY_MISSING).
208
- add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
230
+ if (isResourceWorker) {
231
+ // DSM reports Container Manager as "not ready" through the package
232
+ // dependency gate even while pkgctl-ContainerManager is running. The
233
+ // docker-project worker itself remains the authoritative live prerequisite
234
+ // (ContainerManager >= 1432); keep only the reliable Web Station package
235
+ // dependency here so a valid resource-worker SPK can reach acquisition.
236
+ if (contract.adminport !== undefined) {
237
+ add("install_dep_packages", "WebStation");
238
+ }
239
+ }
240
+ else {
241
+ // synopkg's Container Manager dep gate reports "not ready" even when CM is
242
+ // running. Depend on Broker only; Broker's adapter.probe() checks CM at
243
+ // App install time (CM_CAPABILITY_MISSING).
244
+ add("install_dep_packages", "SynoPkgLandService>=0.1.0-0016");
245
+ }
209
246
  add("startable", "yes");
210
247
  const launch = resolvedWebLaunch(contract);
211
248
  if (contract.adminprotocol) {
@@ -243,6 +280,7 @@ function renderPrivilege() {
243
280
  }, null, 2)}\n`;
244
281
  }
245
282
  const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
283
+ const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
246
284
  const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
247
285
  const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
248
286
  const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
@@ -328,6 +366,7 @@ function renderInstallWizard(contract, compose) {
328
366
  const items = [];
329
367
  const params = collectInstallParameters(contract);
330
368
  const hasSharedFolder = params.some((item) => item.param.type === "shared_folder");
369
+ const hasHomes = params.some((item) => item.param.type === "homes");
331
370
  const hasDsmUser = params.some((item) => item.param.type === "dsm_user");
332
371
  const capabilityText = renderFilesCapability(contract);
333
372
  if (compose && scanComposePrivilege(compose).length > 0) {
@@ -345,6 +384,11 @@ function renderInstallWizard(contract, compose) {
345
384
  desc: SHARED_FOLDER_MOUNT_CONSENT,
346
385
  });
347
386
  }
387
+ if (hasHomes) {
388
+ items.push({
389
+ desc: HOMES_MOUNT_CONSENT,
390
+ });
391
+ }
348
392
  if (hasDsmUser) {
349
393
  items.push({
350
394
  desc: DSM_USER_RUN_AS_CONSENT,
@@ -515,7 +559,128 @@ function renderUninstallWizard() {
515
559
  ],
516
560
  }];
517
561
  }
518
- function renderManagedScript(name, contract, nativeContext) {
562
+ function renderDynamicPreinstMaterializer(contract) {
563
+ const params = collectInstallParameters(contract).filter(({ param }) => param.type === "string" || param.type === "boolean");
564
+ const lines = [
565
+ "#!/bin/sh",
566
+ "set -eu",
567
+ "umask 077",
568
+ "# D3 package-only carrier; values are wizard answers, canonical values, or defaults.",
569
+ "die() { echo \"dynamic route materialization failed: $1\" >&2; exit 1; }",
570
+ 'T="${SYNOPKG_PKGINST_TEMP_DIR:?}"',
571
+ 'STATUS="${SYNOPKG_PKG_STATUS:?}"',
572
+ 'PROJECT="$T/compose"',
573
+ 'RUNTIME_ENV="$PROJECT/.env"',
574
+ 'CANONICAL_ENV="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}/.env"',
575
+ 'PLATFORM_PKGVAR="${SYNOPKG_PKGDEST_VOL:?}/@appdata/${SYNOPKG_PKGNAME:?}"',
576
+ 'PLATFORM_SECRETS="${SYNOPKG_PKGDEST_VOL:?}/@apphome/${SYNOPKG_PKGNAME:?}/secrets"',
577
+ '[ -d "$PROJECT" ] || die "compose directory is missing"',
578
+ '[ ! -L "$PROJECT" ] || die "compose directory is a symlink"',
579
+ '[ "$(stat -c %u "$PROJECT")" = "$(id -u)" ] || die "compose directory owner is not the package user"',
580
+ '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
581
+ 'if [ -e "$RUNTIME_ENV" ] && [ ! -f "$RUNTIME_ENV" ]; then die "runtime path is not a regular file"; fi',
582
+ "read_env_value() {",
583
+ " file=\"$1\"",
584
+ " key=\"$2\"",
585
+ " awk -F= -v key=\"$key\" '$1 == key { value=substr($0, index($0,\"=\")+1); if (value ~ /^\".*\"$/) value=substr(value,2,length(value)-2); print value; found=1; exit } END { if (!found) exit 1 }' \"$file\"",
586
+ "}",
587
+ "append_env() {",
588
+ " key=\"$1\"",
589
+ " value=\"$2\"",
590
+ " type=\"${3:-string}\"",
591
+ " if printf '%s' \"$value\" | LC_ALL=C grep -q '[[:cntrl:]]'; then",
592
+ " die \"control character in environment value $key\"",
593
+ " fi",
594
+ " case \"$value\" in",
595
+ " *[[:cntrl:]]*) die \"control character in environment value $key\" ;;",
596
+ " esac",
597
+ " if [ \"$type\" = \"boolean\" ]; then",
598
+ " normalized=\"$(printf '%s' \"$value\" | LC_ALL=C tr '[:upper:]' '[:lower:]')\"",
599
+ " case \"$normalized\" in",
600
+ " true|false) value=\"$normalized\" ;;",
601
+ " *) die \"invalid boolean value $key\" ;;",
602
+ " esac",
603
+ " fi",
604
+ " case \"$value\" in",
605
+ " *[!A-Za-z0-9_./:@-]*)",
606
+ " escaped=\"$(printf '%s' \"$value\" | sed 's/\\\\/\\\\\\\\/g; s/\"/\\\\\"/g')\"",
607
+ " printf '%s=\"%s\"\\n' \"$key\" \"$escaped\"",
608
+ " ;;",
609
+ " *) printf '%s=%s\\n' \"$key\" \"$value\" ;;",
610
+ " esac",
611
+ "}",
612
+ 'tmp="$(mktemp "$PROJECT/.env.tmp.XXXXXX")"',
613
+ 'chmod 600 "$tmp"',
614
+ '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
615
+ '[ "$(stat -c %u "$tmp")" = "$(id -u)" ] || die "temporary env owner is not the package user"',
616
+ "trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
617
+ "{",
618
+ ];
619
+ const valueVars = [];
620
+ for (const { name, param } of params) {
621
+ const type = param.type;
622
+ const valueVar = "D3_VALUE_" + name;
623
+ valueVars.push({ name, valueVar, type });
624
+ const fallback = param.default !== undefined
625
+ ? String(param.default)
626
+ : type === "boolean"
627
+ ? "false"
628
+ : "";
629
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${valueVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
630
+ `${valueVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", " " + valueVar + "=" + shellQuote(fallback), " fi");
631
+ }
632
+ lines.push(' append_env "SYNOPKG_PKGVAR" "$PLATFORM_PKGVAR"', ' append_env "SYNO_APP_SECRETS_DIR" "$PLATFORM_SECRETS"');
633
+ for (const { name, valueVar, type } of valueVars) {
634
+ lines.push(` append_env "${name}" "$${valueVar}" "${type}"`);
635
+ }
636
+ lines.push(" } > \"$tmp\"", " mv \"$tmp\" \"$RUNTIME_ENV\"", " trap - EXIT HUP INT TERM", " [ ! -L \"$RUNTIME_ENV\" ] || die \"runtime file became a symlink\"", " [ \"$(stat -c %a \"$RUNTIME_ENV\")\" = \"600\" ] || die \"runtime env mode is not 0600\"", "exit 0");
637
+ return lines.join("\n") + "\n";
638
+ }
639
+ function renderDynamicCanonicalMirror() {
640
+ const lines = [
641
+ "#!/bin/sh",
642
+ "set -eu",
643
+ "umask 077",
644
+ "# D3 post-worker mirror; the runtime .env is the only source of truth here.",
645
+ "die() { echo \"dynamic route canonical mirror failed: $1\" >&2; exit 1; }",
646
+ 'PKGVAR="${SYNOPKG_PKGVAR:?}"',
647
+ 'PKGHOME="${SYNOPKG_PKGHOME:?}"',
648
+ 'PKGDEST="${SYNOPKG_PKGDEST:?}"',
649
+ 'PROJECT="${PKGDEST}/compose"',
650
+ 'RUNTIME_ENV="${PROJECT}/.env"',
651
+ 'CANONICAL_ENV="${PKGVAR}/.env"',
652
+ 'SECRETS="${PKGHOME}/secrets"',
653
+ 'mkdir -p "$PKGVAR"',
654
+ '[ -d "$PKGVAR" ] || die "PKGVAR is not a directory"',
655
+ 'if [ -L "$SECRETS" ]; then die "secret root is a symlink"; fi',
656
+ 'mkdir -p "$SECRETS"',
657
+ '[ -d "$SECRETS" ] || die "secret root is not a directory"',
658
+ 'chmod 700 "$SECRETS"',
659
+ '[ "$(stat -c %a "$SECRETS")" = "700" ] || die "secret root mode is not 0700"',
660
+ '[ -d "$PROJECT" ] || die "project directory is missing"',
661
+ '[ ! -L "$PROJECT" ] || die "project directory is a symlink"',
662
+ '[ ! -L "$RUNTIME_ENV" ] || die "runtime file is a symlink"',
663
+ '[ -f "$RUNTIME_ENV" ] || die "runtime file is not a regular file"',
664
+ '[ ! -L "$CANONICAL_ENV" ] || die "canonical file is a symlink"',
665
+ 'if [ -e "$CANONICAL_ENV" ] && [ ! -f "$CANONICAL_ENV" ]; then die "canonical path is not a regular file"; fi',
666
+ 'tmp="$(mktemp "$PKGVAR/.env.tmp.XXXXXX")"',
667
+ 'chmod 600 "$tmp"',
668
+ '[ "$(stat -c %a "$tmp")" = "600" ] || die "temporary env mode is not 0600"',
669
+ 'trap \'rm -f "$tmp"\' EXIT HUP INT TERM',
670
+ 'cp "$RUNTIME_ENV" "$tmp"',
671
+ 'chmod 600 "$tmp"',
672
+ 'mv "$tmp" "$CANONICAL_ENV"',
673
+ 'trap - EXIT HUP INT TERM',
674
+ '[ ! -L "$CANONICAL_ENV" ] || die "canonical file became a symlink"',
675
+ '[ "$(stat -c %a "$CANONICAL_ENV")" = "600" ] || die "canonical env mode is not 0600"',
676
+ "exit 0",
677
+ ];
678
+ return lines.join("\n") + "\n";
679
+ }
680
+ function shellQuote(value) {
681
+ return "'" + value.replace(/'/g, "'\\''") + "'";
682
+ }
683
+ function renderManagedScript(name, contract, nativeContext, isResourceWorker = false, resourceWorkerAdminport, isDynamicRoute = false) {
519
684
  if (nativeContext) {
520
685
  const lifecycleInput = {
521
686
  packageIdentity: String(contract.package),
@@ -553,6 +718,31 @@ fi
553
718
  }
554
719
  return `#!/bin/sh\nexit 0\n`;
555
720
  }
721
+ if (isResourceWorker) {
722
+ if (name === "start-stop-status") {
723
+ return renderResourceWorkerStartStopStatus(resourceWorkerAdminport);
724
+ }
725
+ if (isDynamicRoute && name === "preinst") {
726
+ return renderDynamicPreinstMaterializer(contract);
727
+ }
728
+ if (isDynamicRoute && (name === "postinst" || name === "postupgrade")) {
729
+ return renderDynamicCanonicalMirror();
730
+ }
731
+ if (name === "postuninst") {
732
+ const projectRoot = isDynamicRoute ? 'PROJECT="${SYNOPKG_PKGDEST}/compose"\n' : "";
733
+ const deleteRoots = isDynamicRoute
734
+ ? ["PKGVAR", "PROJECT", "SECRETS"]
735
+ : ["PKGVAR", "SECRETS"];
736
+ return `#!/bin/sh
737
+ set -eu
738
+ PKGVAR="\${SYNOPKG_PKGVAR}"
739
+ PKGHOME="\${SYNOPKG_PKGHOME}"
740
+ SECRETS="\${PKGHOME}/secrets"
741
+ ${projectRoot}${renderDeleteAppDataBlock(deleteRoots)}exit 0
742
+ `;
743
+ }
744
+ return `#!/bin/sh\nexit 0\n`;
745
+ }
556
746
  if (name === "start-stop-status") {
557
747
  return `#!/bin/sh
558
748
  ${BROKER_INSTALLER_VARS}
@@ -0,0 +1,14 @@
1
+ import type { AppContract } from "../types.js";
2
+ import type { ResourceWorkerEligibility } from "../compose/analyze.js";
3
+ export { classifyComposeRoute, resourceWorkerEligibility, type ComposeRoute, type ComposeRouteClassification, type ResourceWorkerEligibility, type ResourceWorkerIneligibleReason, } from "../compose/analyze.js";
4
+ export declare function composeProjectName(packageId: string): string;
5
+ export declare function renderDockerProjectResource(packageId: string, projectPath?: string): Record<string, unknown>;
6
+ export declare function renderWebserviceResource(contract: AppContract, adminport: number): Record<string, unknown>;
7
+ export declare function renderResourceConfig(contract: AppContract, eligibility: ResourceWorkerEligibility): string;
8
+ /**
9
+ * Renders the lifecycle control script for the Docker Project resource-worker
10
+ * route. ContainerManager owns the actual project start/stop operation through
11
+ * the worker timing hooks; the package script must wait for that operation to
12
+ * leave a reachable service and must not claim running unconditionally.
13
+ */
14
+ export declare function renderResourceWorkerStartStopStatus(adminport?: number): string;