@synopackageland/cli 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +210 -14
  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 +41 -1
  10. package/dist/compose/analyze.js +458 -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 +67 -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 +3 -0
  20. package/dist/deploy/deploy.js +38 -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 +5 -0
  26. package/dist/deploy/spk-version.js +50 -18
  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 +827 -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 +12 -0
  36. package/dist/host/catalog-install.js +83 -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 +56 -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 +4 -0
  45. package/dist/publish/curated-release.js +3 -3
  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 +14 -1
  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
@@ -0,0 +1,120 @@
1
+ export class DsmCompoundError extends Error {
2
+ code;
3
+ constructor(code, message, options = {}) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "DsmCompoundError";
7
+ this.api = options.api;
8
+ this.method = options.method;
9
+ this.errorCode = options.errorCode;
10
+ this.dsmErrorCode = options.dsmErrorCode;
11
+ this.detail = options.detail;
12
+ }
13
+ api;
14
+ method;
15
+ errorCode;
16
+ dsmErrorCode;
17
+ detail;
18
+ }
19
+ /**
20
+ * Parse the response envelope returned by SYNO.Entry.Request compound calls.
21
+ * A textual DSM error code is intentionally not converted to a number.
22
+ */
23
+ export function parseDsmCompoundResponse(raw) {
24
+ const root = requireRecord(raw, "response");
25
+ if (typeof root.success !== "boolean") {
26
+ throw invalidShape("response.success must be a boolean");
27
+ }
28
+ const data = requireRecord(root.data, "response.data");
29
+ if (typeof data.has_fail !== "boolean") {
30
+ throw invalidShape("response.data.has_fail must be a boolean");
31
+ }
32
+ if (!Array.isArray(data.result)) {
33
+ throw invalidShape("response.data.result must be an array");
34
+ }
35
+ const results = data.result.map((rawResult, index) => {
36
+ const result = requireRecord(rawResult, `response.data.result[${index}]`);
37
+ const api = requireNonEmptyString(result.api, `response.data.result[${index}].api`);
38
+ const method = requireNonEmptyString(result.method, `response.data.result[${index}].method`);
39
+ if (typeof result.success !== "boolean") {
40
+ throw invalidShape(`response.data.result[${index}].success must be a boolean`);
41
+ }
42
+ const error = result.error;
43
+ if (error !== undefined && !isRecord(error)) {
44
+ throw invalidShape(`response.data.result[${index}].error must be an object when present`);
45
+ }
46
+ const errorCode = numericErrorCode(error);
47
+ return errorCode === undefined
48
+ ? { api, method, success: result.success }
49
+ : { api, method, success: result.success, errorCode };
50
+ });
51
+ return { hasFail: data.has_fail, results };
52
+ }
53
+ /**
54
+ * Fail closed on every DSM compound failure signal. The returned parsed value
55
+ * is only reachable when the top-level request and every compound step pass.
56
+ */
57
+ export function assertDsmCompoundResponse(raw) {
58
+ const root = requireRecord(raw, "response");
59
+ if (root.success !== true) {
60
+ const errorCode = numericErrorCode(root.error);
61
+ throw new DsmCompoundError("DSM_REQUEST_FAILED", "DSM SYNO.Entry.Request compound request failed", {
62
+ dsmErrorCode: errorCode,
63
+ detail: root.error ?? root,
64
+ });
65
+ }
66
+ const parsed = parseDsmCompoundResponse(raw);
67
+ const resultRecords = root.data.result;
68
+ const dependencyConflictIndex = parsed.results.findIndex((result) => result.errorCode === 4552);
69
+ if (dependencyConflictIndex >= 0) {
70
+ const result = parsed.results[dependencyConflictIndex];
71
+ throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running`, {
72
+ api: result.api,
73
+ method: result.method,
74
+ errorCode: result.errorCode,
75
+ dsmErrorCode: 4552,
76
+ detail: failureDetail(resultRecords[dependencyConflictIndex]),
77
+ });
78
+ }
79
+ const firstFailureIndex = parsed.results.findIndex((result) => result.success === false);
80
+ if (parsed.hasFail || firstFailureIndex >= 0) {
81
+ const result = firstFailureIndex >= 0 ? parsed.results[firstFailureIndex] : undefined;
82
+ throw new DsmCompoundError("DSM_COMPOUND_FAILED", result
83
+ ? `DSM compound step ${result.api}/${result.method} failed`
84
+ : "DSM compound request reported a failed step", {
85
+ api: result?.api,
86
+ method: result?.method,
87
+ errorCode: result?.errorCode,
88
+ detail: firstFailureIndex >= 0 ? failureDetail(resultRecords[firstFailureIndex]) : root.data,
89
+ });
90
+ }
91
+ return parsed;
92
+ }
93
+ function failureDetail(rawResult) {
94
+ const result = isRecord(rawResult) ? rawResult : undefined;
95
+ return result?.error ?? rawResult;
96
+ }
97
+ function numericErrorCode(error) {
98
+ if (!isRecord(error) || typeof error.code !== "number" || !Number.isFinite(error.code)) {
99
+ return undefined;
100
+ }
101
+ return error.code;
102
+ }
103
+ function requireRecord(value, path) {
104
+ if (!isRecord(value)) {
105
+ throw invalidShape(`${path} must be an object`);
106
+ }
107
+ return value;
108
+ }
109
+ function requireNonEmptyString(value, path) {
110
+ if (typeof value !== "string" || value.length === 0) {
111
+ throw invalidShape(`${path} must be a non-empty string`);
112
+ }
113
+ return value;
114
+ }
115
+ function invalidShape(message) {
116
+ return new TypeError(`Invalid DSM compound response: ${message}`);
117
+ }
118
+ function isRecord(value) {
119
+ return value !== null && typeof value === "object" && !Array.isArray(value);
120
+ }
@@ -2,6 +2,7 @@ import type { AttestationKind, HostInstallRecord, NasHost } from "./types.js";
2
2
  export interface FakeNasHostOptions {
3
3
  curated?: string[];
4
4
  dsmOrigin?: string;
5
+ homesPath?: string;
5
6
  installationId?: string;
6
7
  shares?: Record<string, string>;
7
8
  installedEnv?: Record<string, Record<string, string>>;
@@ -24,6 +24,9 @@ export function createFakeNasHost(options = {}) {
24
24
  async resolveSharePath(logicalName) {
25
25
  return shares[logicalName];
26
26
  },
27
+ async resolveHomesPath() {
28
+ return options.homesPath;
29
+ },
27
30
  async getInstalledProjectEnv(packageIdentity) {
28
31
  const env = installedEnv[packageIdentity];
29
32
  return env ? { ...env } : undefined;
@@ -1,3 +1,4 @@
1
+ import type { InstalledPackageState } from "./catalog-install.js";
1
2
  export type AttestationKind = "local" | "app-package" | "none";
2
3
  export interface DeployIdentity {
3
4
  packageId: string;
@@ -50,9 +51,12 @@ export interface NasHost {
50
51
  */
51
52
  getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
52
53
  resolveSharePath?(logicalName: string): Promise<string | undefined>;
54
+ /** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
55
+ resolveHomesPath?(): Promise<string | undefined>;
53
56
  getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
54
57
  ensurePackageSource?(origin: string): Promise<void>;
55
58
  getInstalledPackageVersion?(packageIdentity: string): Promise<string | undefined>;
59
+ getInstalledPackageState?(packageIdentity: string): Promise<InstalledPackageState | undefined>;
56
60
  getAttestationInfo(packageIdentity: string): Promise<HostAttestationInfo>;
57
61
  isPackageInstalled(packageIdentity: string): Promise<boolean>;
58
62
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { decodeJwt, decodeProtectedHeader, importJWK, importPKCS8, importSPKI, jwtVerify, } from "jose";
7
- import { buildImmutableAttestationKey, buildImmutableSpkKey, } from "@synopackageland/package-source/index-manifest";
7
+ import { CURATED_BUILD_OFFSET, buildImmutableAttestationKey, buildImmutableSpkKey, } from "@synopackageland/package-source/index-manifest";
8
8
  import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopackageland/package-source/publish-spk";
9
9
  import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabilities, signCuratedAppPackageAttestationToken, signCuratedReleaseAttestationToken, } from "../attestation/app-package.js";
10
10
  import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
@@ -496,7 +496,7 @@ function prepareRelease(project, build) {
496
496
  if (/-\d+$/.test(appVersion)) {
497
497
  throw new CuratedReleaseError("CURATED_BUILD_MUST_NOT_BE_IN_SOURCE_VERSION");
498
498
  }
499
- const version = `${appVersion}-${String(build).padStart(4, "0")}`;
499
+ const version = `${appVersion}-${String(CURATED_BUILD_OFFSET + build).padStart(5, "0")}`;
500
500
  const validation = validateProject(project, { profile: "listing" });
501
501
  if (!validation.ok) {
502
502
  throw new CuratedReleaseError("CURATED_VALIDATION_FAILED", validation.errors.map((error) => error.code).join(","));
@@ -805,7 +805,7 @@ function parseBuild(value) {
805
805
  return build;
806
806
  }
807
807
  function isValidBuild(build) {
808
- return Number.isSafeInteger(build) && build > 0;
808
+ return Number.isSafeInteger(build) && build > 0 && build < CURATED_BUILD_OFFSET;
809
809
  }
810
810
  async function loadJwkSigningMaterial(raw, kidFromEnv) {
811
811
  const parsed = JSON.parse(raw);
@@ -0,0 +1,60 @@
1
+ import { type CuratedReleasePublisher } from "@synopackageland/package-source/publish-spk";
2
+ import { type PlatformServiceReleaseErrorCode, type PlatformAttestationSigner } from "../attestation/platform-service.js";
3
+ export declare const SYNOPKGLAND_SOURCE_REVISION_ENV = "SYNOPKGLAND_SOURCE_REVISION";
4
+ export declare const PLATFORM_SERVICE_OS_MIN_VER = "7.2-69057";
5
+ export type PlatformReleaseErrorCode = "PLATFORM_RELEASE_USAGE" | "PLATFORM_BROKER_DIR_INVALID" | "PLATFORM_APP_PROJECT_FORBIDDEN" | "PLATFORM_ARTIFACT_INPUT_FORBIDDEN" | "PLATFORM_BUILD_INVALID" | "PLATFORM_SOURCE_REVISION_MISSING" | "PLATFORM_BUILDER_UNAVAILABLE" | "PLATFORM_BUILD_FAILED" | "PLATFORM_RELEASE_FAILED" | PlatformServiceReleaseErrorCode;
6
+ /** Errors owned by the platform release entry point, separate from signer errors. */
7
+ export declare class PlatformReleaseError extends Error {
8
+ readonly code: PlatformReleaseErrorCode;
9
+ constructor(code: PlatformReleaseErrorCode, detail?: string);
10
+ }
11
+ export interface PlatformServiceReleaseBaseline {
12
+ sourceVersion: string;
13
+ baselineBuild: number;
14
+ }
15
+ export interface PlatformServiceBuildOptions {
16
+ outputPath: string;
17
+ releaseVersion: string;
18
+ }
19
+ /** The broker-side module contract used by production and by release tests. */
20
+ export interface PlatformServiceBuilder {
21
+ buildBrokerSpk(options: PlatformServiceBuildOptions): unknown | Promise<unknown>;
22
+ brokerReleaseBaseline(): PlatformServiceReleaseBaseline;
23
+ }
24
+ export interface PlatformServiceReleaseOptions {
25
+ brokerDir: string;
26
+ build: number;
27
+ sourceRevision: string;
28
+ signer: PlatformAttestationSigner;
29
+ publisher: CuratedReleasePublisher;
30
+ /** Tests may inject a builder; production loads the broker-side module. */
31
+ builder?: PlatformServiceBuilder;
32
+ }
33
+ export interface PlatformServiceReleaseResult {
34
+ packageIdentity: string;
35
+ packageBuild: number;
36
+ version: string;
37
+ artifactKey: string;
38
+ artifactDigest: string;
39
+ attestationKey: string;
40
+ attestationDigest: string;
41
+ }
42
+ export interface PlatformServiceReleaseCliDependencies {
43
+ /** Test seams; production creates these from the environment and R2 target. */
44
+ signer?: PlatformAttestationSigner;
45
+ publisher?: CuratedReleasePublisher;
46
+ builder?: PlatformServiceBuilder;
47
+ /** Optional output seams keep CLI tests independent of process-global streams. */
48
+ writeStdout?: (line: string) => void;
49
+ writeStderr?: (line: string) => void;
50
+ }
51
+ /**
52
+ * Build, attest and publish one immutable Broker SPK release.
53
+ *
54
+ * All cheap input gates run before the broker builder is loaded or invoked.
55
+ * Publication is delegated to the package-source three-phase boundary, which
56
+ * enforces artifact -> attestation -> index ordering.
57
+ */
58
+ export declare function releasePlatformService(options: PlatformServiceReleaseOptions): Promise<PlatformServiceReleaseResult>;
59
+ /** Run the trusted platform release verb and emit exactly one line. */
60
+ export declare function runPlatformServiceReleaseCli(argv?: string[], env?: NodeJS.Dict<string>, dependencies?: PlatformServiceReleaseCliDependencies): Promise<number>;
@@ -0,0 +1,333 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { buildImmutableAttestationKey, buildImmutableSpkKey, } from "@synopackageland/package-source/index-manifest";
7
+ import { createR2CuratedReleasePublisher, publishCuratedRelease, } from "@synopackageland/package-source/publish-spk";
8
+ import { createPlatformAttestationSignerFromEnv, digestPlatformServiceReleaseClaims, PLATFORM_SERVICE_PACKAGE_IDENTITY, PlatformServiceReleaseError, } from "../attestation/platform-service.js";
9
+ export const SYNOPKGLAND_SOURCE_REVISION_ENV = "SYNOPKGLAND_SOURCE_REVISION";
10
+ export const PLATFORM_SERVICE_OS_MIN_VER = "7.2-69057";
11
+ /** Errors owned by the platform release entry point, separate from signer errors. */
12
+ export class PlatformReleaseError extends Error {
13
+ code;
14
+ constructor(code, detail) {
15
+ super(detail ? `${code}: ${detail}` : code);
16
+ this.code = code;
17
+ this.name = "PlatformReleaseError";
18
+ }
19
+ }
20
+ /**
21
+ * Build, attest and publish one immutable Broker SPK release.
22
+ *
23
+ * All cheap input gates run before the broker builder is loaded or invoked.
24
+ * Publication is delegated to the package-source three-phase boundary, which
25
+ * enforces artifact -> attestation -> index ordering.
26
+ */
27
+ export async function releasePlatformService(options) {
28
+ const brokerDir = validatePlatformReleaseRequest(options);
29
+ const builder = options.builder ?? (await loadPlatformServiceBuilder(brokerDir));
30
+ const baseline = readReleaseBaseline(builder);
31
+ if (options.build <= baseline.baselineBuild) {
32
+ throw new PlatformReleaseError("PLATFORM_BUILD_INVALID");
33
+ }
34
+ const version = `${baseline.sourceVersion}-${String(options.build).padStart(4, "0")}`;
35
+ const workspace = mkdtempSync(join(tmpdir(), "synopkgland-platform-release-"));
36
+ try {
37
+ const outputPath = join(workspace, `${PLATFORM_SERVICE_PACKAGE_IDENTITY}.spk`);
38
+ try {
39
+ await builder.buildBrokerSpk({ outputPath, releaseVersion: version });
40
+ if (!existsSync(outputPath)) {
41
+ throw new Error("broker builder did not create the requested output");
42
+ }
43
+ }
44
+ catch (error) {
45
+ rethrowKnownReleaseError(error);
46
+ throw new PlatformReleaseError("PLATFORM_BUILD_FAILED");
47
+ }
48
+ let artifact;
49
+ try {
50
+ artifact = digestArtifact(outputPath);
51
+ }
52
+ catch (error) {
53
+ rethrowKnownReleaseError(error);
54
+ throw new PlatformReleaseError("PLATFORM_BUILD_FAILED");
55
+ }
56
+ const attestationInput = {
57
+ packageIdentity: PLATFORM_SERVICE_PACKAGE_IDENTITY,
58
+ sourceVersion: baseline.sourceVersion,
59
+ packageBuild: options.build,
60
+ packageVersion: version,
61
+ artifactDigest: artifact.sha256,
62
+ sourceRevision: options.sourceRevision,
63
+ osMinVer: PLATFORM_SERVICE_OS_MIN_VER,
64
+ arch: "noarch",
65
+ };
66
+ let signed;
67
+ try {
68
+ signed = await options.signer.sign(attestationInput);
69
+ assertPlatformClaims(signed.claims, attestationInput);
70
+ }
71
+ catch (error) {
72
+ rethrowKnownReleaseError(error);
73
+ throw new PlatformReleaseError("PLATFORM_RELEASE_FAILED");
74
+ }
75
+ let verified;
76
+ try {
77
+ verified = await options.signer.verify(signed.token);
78
+ assertPlatformClaims(verified, attestationInput);
79
+ if (signed.attestationDigest !== digestPlatformServiceReleaseClaims(verified)) {
80
+ throw new PlatformServiceReleaseError("PLATFORM_ATTESTATION_INVALID", "attestation digest does not match verified claims");
81
+ }
82
+ }
83
+ catch (error) {
84
+ rethrowKnownReleaseError(error);
85
+ throw new PlatformReleaseError("PLATFORM_RELEASE_FAILED");
86
+ }
87
+ const artifactKey = buildImmutableSpkKey({
88
+ package: PLATFORM_SERVICE_PACKAGE_IDENTITY,
89
+ version,
90
+ sha256: artifact.sha256,
91
+ });
92
+ const attestationKey = buildImmutableAttestationKey({
93
+ package: PLATFORM_SERVICE_PACKAGE_IDENTITY,
94
+ version,
95
+ artifactDigest: artifact.sha256,
96
+ });
97
+ const tokenSha256 = digestBytes(Buffer.from(signed.token, "utf8"), "sha256");
98
+ const attestation = {
99
+ kind: "platform-service-release",
100
+ packageId: PLATFORM_SERVICE_PACKAGE_IDENTITY,
101
+ version,
102
+ packageBuild: options.build,
103
+ artifactDigest: artifact.sha256,
104
+ key: attestationKey,
105
+ token: signed.token,
106
+ digest: signed.attestationDigest,
107
+ tokenSha256,
108
+ verificationJwk: options.signer.verificationJwk,
109
+ };
110
+ const index = {
111
+ packageId: PLATFORM_SERVICE_PACKAGE_IDENTITY,
112
+ version,
113
+ displayname: PLATFORM_SERVICE_PACKAGE_IDENTITY,
114
+ description: "Trusted control plane for Synology App Packages.",
115
+ maintainer: "Synology",
116
+ osMinVer: PLATFORM_SERVICE_OS_MIN_VER,
117
+ artifact: {
118
+ key: artifactKey,
119
+ size: artifact.size,
120
+ md5: artifact.md5,
121
+ sha256: artifact.sha256,
122
+ },
123
+ attestation: {
124
+ kind: "platform-service-release",
125
+ key: attestationKey,
126
+ digest: signed.attestationDigest,
127
+ packageBuild: options.build,
128
+ artifactDigest: artifact.sha256,
129
+ },
130
+ };
131
+ try {
132
+ await publishCuratedRelease({
133
+ artifact: {
134
+ packageId: PLATFORM_SERVICE_PACKAGE_IDENTITY,
135
+ version,
136
+ path: outputPath,
137
+ key: artifactKey,
138
+ size: artifact.size,
139
+ md5: artifact.md5,
140
+ sha256: artifact.sha256,
141
+ },
142
+ attestation,
143
+ index,
144
+ }, options.publisher);
145
+ }
146
+ catch (error) {
147
+ rethrowKnownReleaseError(error);
148
+ throw new PlatformReleaseError("PLATFORM_RELEASE_FAILED");
149
+ }
150
+ return {
151
+ packageIdentity: PLATFORM_SERVICE_PACKAGE_IDENTITY,
152
+ packageBuild: options.build,
153
+ version,
154
+ artifactKey,
155
+ artifactDigest: artifact.sha256,
156
+ attestationKey,
157
+ attestationDigest: signed.attestationDigest,
158
+ };
159
+ }
160
+ finally {
161
+ rmSync(workspace, { recursive: true, force: true });
162
+ }
163
+ }
164
+ /** Run the trusted platform release verb and emit exactly one line. */
165
+ export async function runPlatformServiceReleaseCli(argv = process.argv, env = process.env, dependencies = {}) {
166
+ const writeStdout = dependencies.writeStdout ?? ((line) => process.stdout.write(line));
167
+ const writeStderr = dependencies.writeStderr ?? ((line) => process.stderr.write(line));
168
+ try {
169
+ if (argv[2] !== "release-platform-service" ||
170
+ !argv[3] ||
171
+ !argv[4] ||
172
+ argv.length > 5) {
173
+ throw new PlatformReleaseError("PLATFORM_RELEASE_USAGE");
174
+ }
175
+ if (argv.slice(2).some((argument) => argument.endsWith(".spk"))) {
176
+ throw new PlatformReleaseError("PLATFORM_ARTIFACT_INPUT_FORBIDDEN");
177
+ }
178
+ const build = parseBuild(argv[4]);
179
+ const sourceRevision = resolveSourceRevision(env);
180
+ const brokerDir = resolve(process.cwd(), argv[3]);
181
+ validatePlatformBrokerDirectory(brokerDir);
182
+ const signer = dependencies.signer ?? (await createPlatformAttestationSignerFromEnv(env));
183
+ const publisher = dependencies.publisher ?? createR2CuratedReleasePublisher();
184
+ const result = await releasePlatformService({
185
+ brokerDir,
186
+ build,
187
+ sourceRevision,
188
+ signer,
189
+ publisher,
190
+ builder: dependencies.builder,
191
+ });
192
+ writeStdout(`${JSON.stringify({
193
+ packageIdentity: result.packageIdentity,
194
+ packageBuild: result.packageBuild,
195
+ version: result.version,
196
+ artifactDigest: result.artifactDigest,
197
+ attestationDigest: result.attestationDigest,
198
+ })}\n`);
199
+ return 0;
200
+ }
201
+ catch (error) {
202
+ writeStderr(`${releaseErrorCode(error)}\n`);
203
+ return 1;
204
+ }
205
+ }
206
+ function validatePlatformReleaseRequest(options) {
207
+ if (typeof options.brokerDir !== "string" ||
208
+ options.brokerDir.endsWith(".spk") ||
209
+ String(options.build).endsWith(".spk") ||
210
+ typeof options.sourceRevision !== "string" ||
211
+ options.sourceRevision.endsWith(".spk")) {
212
+ throw new PlatformReleaseError("PLATFORM_ARTIFACT_INPUT_FORBIDDEN");
213
+ }
214
+ if (!Number.isSafeInteger(options.build) || options.build <= 0) {
215
+ throw new PlatformReleaseError("PLATFORM_BUILD_INVALID");
216
+ }
217
+ if (!/^[a-f0-9]{40}$/.test(options.sourceRevision)) {
218
+ throw new PlatformReleaseError("PLATFORM_SOURCE_REVISION_MISSING");
219
+ }
220
+ const brokerDir = resolve(options.brokerDir);
221
+ validatePlatformBrokerDirectory(brokerDir);
222
+ return brokerDir;
223
+ }
224
+ function validatePlatformBrokerDirectory(brokerDir) {
225
+ if (basename(brokerDir) !== "broker" ||
226
+ basename(dirname(brokerDir)) !== "packages") {
227
+ throw new PlatformReleaseError("PLATFORM_BROKER_DIR_INVALID");
228
+ }
229
+ let packageJson;
230
+ try {
231
+ packageJson = JSON.parse(readFileSync(join(brokerDir, "package.json"), "utf8"));
232
+ }
233
+ catch {
234
+ throw new PlatformReleaseError("PLATFORM_BROKER_DIR_INVALID");
235
+ }
236
+ if (!packageJson ||
237
+ typeof packageJson !== "object" ||
238
+ Array.isArray(packageJson) ||
239
+ packageJson.name !== "@synology/app-broker") {
240
+ throw new PlatformReleaseError("PLATFORM_BROKER_DIR_INVALID");
241
+ }
242
+ if (existsSync(join(brokerDir, "synology-app.yaml"))) {
243
+ throw new PlatformReleaseError("PLATFORM_APP_PROJECT_FORBIDDEN");
244
+ }
245
+ }
246
+ function parseBuild(value) {
247
+ if (!/^\d+$/.test(value)) {
248
+ throw new PlatformReleaseError("PLATFORM_BUILD_INVALID");
249
+ }
250
+ const build = Number(value);
251
+ if (!Number.isSafeInteger(build) || build <= 0) {
252
+ throw new PlatformReleaseError("PLATFORM_BUILD_INVALID");
253
+ }
254
+ return build;
255
+ }
256
+ function resolveSourceRevision(env) {
257
+ const raw = env[SYNOPKGLAND_SOURCE_REVISION_ENV] ?? env.GITHUB_SHA;
258
+ const sourceRevision = typeof raw === "string" ? raw.trim() : "";
259
+ if (!/^[a-f0-9]{40}$/.test(sourceRevision)) {
260
+ throw new PlatformReleaseError("PLATFORM_SOURCE_REVISION_MISSING");
261
+ }
262
+ return sourceRevision;
263
+ }
264
+ async function loadPlatformServiceBuilder(brokerDir) {
265
+ const modulePath = join(brokerDir, "dist", "spk", "build.js");
266
+ let loaded;
267
+ try {
268
+ loaded = (await import(pathToFileURL(modulePath).href));
269
+ }
270
+ catch {
271
+ throw new PlatformReleaseError("PLATFORM_BUILDER_UNAVAILABLE");
272
+ }
273
+ if (typeof loaded.buildBrokerSpk !== "function" ||
274
+ typeof loaded.brokerReleaseBaseline !== "function") {
275
+ throw new PlatformReleaseError("PLATFORM_BUILDER_UNAVAILABLE");
276
+ }
277
+ return {
278
+ buildBrokerSpk: loaded.buildBrokerSpk,
279
+ brokerReleaseBaseline: loaded.brokerReleaseBaseline,
280
+ };
281
+ }
282
+ function readReleaseBaseline(builder) {
283
+ let baseline;
284
+ try {
285
+ baseline = builder.brokerReleaseBaseline();
286
+ }
287
+ catch {
288
+ throw new PlatformReleaseError("PLATFORM_BUILDER_UNAVAILABLE");
289
+ }
290
+ if (!baseline ||
291
+ typeof baseline.sourceVersion !== "string" ||
292
+ !/^\d+\.\d+\.\d+$/.test(baseline.sourceVersion) ||
293
+ !Number.isSafeInteger(baseline.baselineBuild) ||
294
+ baseline.baselineBuild < 0) {
295
+ throw new PlatformReleaseError("PLATFORM_BUILDER_UNAVAILABLE");
296
+ }
297
+ return baseline;
298
+ }
299
+ function digestArtifact(path) {
300
+ const bytes = readFileSync(path);
301
+ return {
302
+ size: bytes.byteLength,
303
+ md5: digestBytes(bytes, "md5"),
304
+ sha256: digestBytes(bytes, "sha256"),
305
+ };
306
+ }
307
+ function digestBytes(bytes, algorithm) {
308
+ return createHash(algorithm).update(bytes).digest("hex");
309
+ }
310
+ function assertPlatformClaims(claims, expected) {
311
+ if (!claims ||
312
+ claims.packageIdentity !== expected.packageIdentity ||
313
+ claims.sourceVersion !== expected.sourceVersion ||
314
+ claims.packageBuild !== expected.packageBuild ||
315
+ claims.packageVersion !== expected.packageVersion ||
316
+ claims.artifactDigest !== expected.artifactDigest ||
317
+ claims.sourceRevision !== expected.sourceRevision ||
318
+ claims.osMinVer !== expected.osMinVer ||
319
+ claims.arch !== expected.arch) {
320
+ throw new PlatformServiceReleaseError("PLATFORM_ATTESTATION_INVALID", "platform release claims do not bind the release input");
321
+ }
322
+ }
323
+ function rethrowKnownReleaseError(error) {
324
+ if (error instanceof PlatformServiceReleaseError || error instanceof PlatformReleaseError) {
325
+ throw error;
326
+ }
327
+ }
328
+ function releaseErrorCode(error) {
329
+ if (error instanceof PlatformServiceReleaseError || error instanceof PlatformReleaseError) {
330
+ return error.code;
331
+ }
332
+ return "PLATFORM_RELEASE_FAILED";
333
+ }
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { isDevelopmentPackageIdentity } from "@synopackageland/package-source/index-manifest";
4
+ import { CURATED_BUILD_OFFSET, isDevelopmentPackageIdentity, } from "@synopackageland/package-source/index-manifest";
5
5
  import { buildSpk } from "../build-spk/generator.js";
6
6
  import { loadCompose } from "../compose/analyze.js";
7
7
  import { loadContract } from "../contract/load.js";
@@ -33,6 +33,19 @@ export async function runPublicReleasePreflight(project) {
33
33
  detail: validation.ok ? undefined : validation.errors.map((error) => error.code).join(", "),
34
34
  });
35
35
  const contract = loadContract(project.contractPath).contract;
36
+ const contractVersion = typeof contract.version === "string" ? contract.version.trim() : "";
37
+ const sourceBuild = /-(\d+)$/.exec(contractVersion);
38
+ const sourceBuildNumber = sourceBuild ? Number(sourceBuild[1]) : undefined;
39
+ add({
40
+ id: "version-namespace",
41
+ title: "App Contract 不手寫 Curated Package Build",
42
+ ok: sourceBuildNumber === undefined ||
43
+ (Number.isSafeInteger(sourceBuildNumber) && sourceBuildNumber < CURATED_BUILD_OFFSET),
44
+ detail: sourceBuildNumber !== undefined &&
45
+ (!Number.isSafeInteger(sourceBuildNumber) || sourceBuildNumber >= CURATED_BUILD_OFFSET)
46
+ ? "CURATED_BUILD_MUST_NOT_BE_IN_SOURCE_VERSION"
47
+ : undefined,
48
+ });
36
49
  const packageIdentity = typeof contract.package === "string" ? contract.package : "";
37
50
  const formalIdentity = packageIdentity.length > 0 && !isDevelopmentPackageIdentity(packageIdentity);
38
51
  add({
@@ -8,8 +8,12 @@
8
8
  */
9
9
  import { loadDotEnv } from "./load-env.js";
10
10
  import { runCuratedReleaseCli } from "./publish/curated-release.js";
11
+ import { runPlatformServiceReleaseCli } from "./publish/platform-service-release.js";
11
12
  loadDotEnv();
12
- runCuratedReleaseCli(["node", "synopkgland-release", "release", ...process.argv.slice(2)])
13
+ const releaseRun = process.argv[2] === "release-platform-service"
14
+ ? runPlatformServiceReleaseCli(process.argv, process.env)
15
+ : runCuratedReleaseCli(["node", "synopkgland-release", "release", ...process.argv.slice(2)]);
16
+ releaseRun
13
17
  .then((status) => {
14
18
  process.exitCode = status;
15
19
  })
@@ -4,5 +4,7 @@ services:
4
4
  command: ["sleep", "infinity"]
5
5
  user: "${RUN_AS_UID}:${RUN_AS_GID}"
6
6
  volumes:
7
+ # Dynamic Shared Folder plus writable /state is Broker-managed and is
8
+ # intentionally outside the zero-config docker-project resource-worker path.
7
9
  - "${MEDIA_SHARE}/data:/data:ro"
8
10
  - "${SYNOPKG_PKGVAR}/state:/state"
@@ -1,7 +1,18 @@
1
1
  services:
2
2
  web:
3
- image: nginx:alpine
3
+ image: busybox:1.36
4
+ # 兩個平台限制,改動前先看 docs/impl/STATUS.md 的 2026-08-24 段:
5
+ # 1) command/entrypoint 陣列不得有純數字元素(例如 "18333")。DSM 的
6
+ # docker-project worker 會在 YAML round-trip 時掉引號、把它變成整數,
7
+ # postinst acquire 直接失敗(error 276 → 套件 broken/150,且 worker_msg
8
+ # 為空,開發者看不到原因)。所以 port 要包在單一字串裡。
9
+ # `synopkgland validate` 已會擋這個寫法。
10
+ # 2) 預設不用 8080:那個 port 在真實 NAS 上很常被既有容器占用,而 port
11
+ # 衝突同樣以那個看不出原因的 broken/150 呈現。
12
+ # 官方 nginx image 也不能用:resource worker 注入任意 package UID/GID,
13
+ # nginx entrypoint 啟動階段需要 root,會在該身分下直接退出。
14
+ command: ["sh","-c","httpd -f -p 18333 -h /usr/share/nginx/html"]
4
15
  ports:
5
- - "8080:80"
16
+ - "18333:18333"
6
17
  volumes:
7
- - "${SYNOPKG_PKGVAR}/html:/usr/share/nginx/html:ro"
18
+ - "./html:/usr/share/nginx/html:ro"
@@ -6,7 +6,7 @@ description: A minimal Syno Package Land web app template.
6
6
  maintainer: Syno Package Land
7
7
  os_min_ver: "7.2-69057"
8
8
  adminprotocol: http
9
- adminport: 8080
9
+ adminport: 18333
10
10
  adminurl: /
11
11
  integration:
12
12
  web: