@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
@@ -0,0 +1,37 @@
1
+ export interface DsmCompoundResult {
2
+ api: string;
3
+ method: string;
4
+ success: boolean;
5
+ errorCode?: number;
6
+ }
7
+ export interface DsmCompoundResponse {
8
+ hasFail: boolean;
9
+ results: DsmCompoundResult[];
10
+ }
11
+ export type DsmCompoundErrorCode = "DSM_DEPENDENCY_CONFLICT" | "DSM_COMPOUND_FAILED" | "DSM_REQUEST_FAILED";
12
+ export interface DsmCompoundErrorOptions {
13
+ api?: string;
14
+ method?: string;
15
+ errorCode?: number;
16
+ dsmErrorCode?: number;
17
+ detail?: unknown;
18
+ }
19
+ export declare class DsmCompoundError extends Error {
20
+ readonly code: DsmCompoundErrorCode;
21
+ constructor(code: DsmCompoundErrorCode, message: string, options?: DsmCompoundErrorOptions);
22
+ readonly api?: string;
23
+ readonly method?: string;
24
+ readonly errorCode?: number;
25
+ readonly dsmErrorCode?: number;
26
+ readonly detail?: unknown;
27
+ }
28
+ /**
29
+ * Parse the response envelope returned by SYNO.Entry.Request compound calls.
30
+ * A textual DSM error code is intentionally not converted to a number.
31
+ */
32
+ export declare function parseDsmCompoundResponse(raw: unknown): DsmCompoundResponse;
33
+ /**
34
+ * Fail closed on every DSM compound failure signal. The returned parsed value
35
+ * is only reachable when the top-level request and every compound step pass.
36
+ */
37
+ export declare function assertDsmCompoundResponse(raw: unknown): DsmCompoundResponse;
@@ -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;
@@ -43,6 +44,8 @@ export interface NasHost {
43
44
  spkPath: string;
44
45
  extraValues?: Record<string, string>;
45
46
  }): Promise<void>;
47
+ /** Stop and normally uninstall a formal package while preserving App Data. */
48
+ uninstallPackage?(packageIdentity: string): Promise<void>;
46
49
  /**
47
50
  * CPU family of this NAS, for picking a native App's target. Returns
48
51
  * undefined when the DSM answer does not map to a family we ship, so the
@@ -50,9 +53,12 @@ export interface NasHost {
50
53
  */
51
54
  getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
52
55
  resolveSharePath?(logicalName: string): Promise<string | undefined>;
56
+ /** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
57
+ resolveHomesPath?(): Promise<string | undefined>;
53
58
  getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
54
59
  ensurePackageSource?(origin: string): Promise<void>;
55
60
  getInstalledPackageVersion?(packageIdentity: string): Promise<string | undefined>;
61
+ getInstalledPackageState?(packageIdentity: string): Promise<InstalledPackageState | undefined>;
56
62
  getAttestationInfo(packageIdentity: string): Promise<HostAttestationInfo>;
57
63
  isPackageInstalled(packageIdentity: string): Promise<boolean>;
58
64
  fetchContainerLogs(packageIdentity: string): Promise<string[]>;
@@ -4,12 +4,12 @@ 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";
11
11
  import { loadContract } from "../contract/load.js";
12
- import { loadCompose } from "../compose/analyze.js";
12
+ import { classifyComposeRoute, loadCompose } from "../compose/analyze.js";
13
13
  import { loadNative } from "../native/load.js";
14
14
  import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
15
15
  import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.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(","));
@@ -524,6 +524,10 @@ function prepareRelease(project, build) {
524
524
  if (!parsedCompose) {
525
525
  throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
526
526
  }
527
+ const route = classifyComposeRoute(contract, parsedCompose);
528
+ if (route.route === "broker-installer" && contract.adminport !== undefined) {
529
+ throw new CuratedReleaseError("CURATED_BROKER_ROUTE_FORBIDDEN");
530
+ }
527
531
  const composeGate = validateCuratedCompose(parsedCompose.compose);
528
532
  if (!composeGate.ok) {
529
533
  throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
@@ -805,7 +809,7 @@ function parseBuild(value) {
805
809
  return build;
806
810
  }
807
811
  function isValidBuild(build) {
808
- return Number.isSafeInteger(build) && build > 0;
812
+ return Number.isSafeInteger(build) && build > 0 && build < CURATED_BUILD_OFFSET;
809
813
  }
810
814
  async function loadJwkSigningMaterial(raw, kidFromEnv) {
811
815
  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
+ }