@synopackageland/cli 0.21.1 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cli.js +20 -1
- package/dist/deploy/native-install-values.js +10 -2
- package/dist/error-registry.js +3 -0
- package/dist/runtime/executor-digests.json +1 -12
- package/dist/runtime/sdk-source.js +1 -1
- package/dist/standalone/build.js +3 -0
- package/dist/standalone/compose-errors.js +5 -0
- package/dist/standalone/compose.d.ts +12 -2
- package/dist/standalone/compose.js +22 -1
- package/dist/standalone/identity-gateway.d.ts +20 -0
- package/dist/standalone/identity-gateway.js +67 -4
- package/dist/standalone/inspect-spk.d.ts +5 -0
- package/dist/standalone/inspect-spk.js +95 -14
- package/dist/standalone/install-settings.d.ts +34 -0
- package/dist/standalone/install-settings.js +57 -0
- package/dist/standalone/lifecycle.js +3 -1
- package/dist/standalone/manifest.d.ts +2 -0
- package/dist/standalone/manifest.js +6 -0
- package/dist/standalone/shared-folder.js +3 -1
- package/dist/standalone/spk.d.ts +1 -0
- package/dist/standalone/spk.js +13 -3
- package/package.json +2 -2
- package/skills/reference/compose.md +13 -0
- package/skills/reference/identity-gateway.md +67 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0 (2026-10-21)
|
|
4
|
+
|
|
5
|
+
- Validate the explicit OIDC `browser_redirect` contract before packaging and pin templates/SDK 0.23.0.
|
|
6
|
+
- Reuse executor/v0.20.0 with unchanged executor and lifecycle payloads.
|
|
7
|
+
|
|
8
|
+
## 0.22.0 (2026-10-21)
|
|
9
|
+
|
|
10
|
+
- Support SDK/App 0.22.x and pin templates 0.22.0 with published UI 0.12.5.
|
|
11
|
+
- Register actionable diagnostics for invalid, unsupported or unbuildable native installation settings.
|
|
12
|
+
- Validate provider-only public upstream access, host UDP listeners, fixed launch paths and native installation settings.
|
|
13
|
+
- Reuse immutable executor/v0.20.0 with exact SDK-version digest checks.
|
|
14
|
+
|
|
3
15
|
## 0.21.1 (2026-10-21)
|
|
4
16
|
|
|
5
17
|
- Resolve SDK/App 0.21.1 to the verified immutable executor/v0.20.0 release while keeping exact SDK-version digest checks.
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { contractIconPath } from "./contract/icon.js";
|
|
|
8
8
|
import { codedMessage, formatErrors } from "./errors.js";
|
|
9
9
|
import { getErrorCode } from "./error-registry.js";
|
|
10
10
|
import { formatInspectReport, inspectProject } from "./inspect.js";
|
|
11
|
+
import { gatewayAdmissionReport } from "./standalone/identity-gateway.js";
|
|
11
12
|
import { formatPinReport, pinProjectImages } from "./images/pin.js";
|
|
12
13
|
import { initFromTemplate, initNextSteps, isTemplateName, listTemplates, } from "./init.js";
|
|
13
14
|
import { templatesRoot } from "./template-paths.js";
|
|
@@ -116,9 +117,14 @@ function formatRootSteps(report) {
|
|
|
116
117
|
];
|
|
117
118
|
return steps.length ? [`Root steps: ${steps.join(", ")}`] : [];
|
|
118
119
|
}
|
|
120
|
+
function formatInstallSettings(settings) {
|
|
121
|
+
return settings ? Object.entries(settings).map(([key, s]) => `Installation setting ${key}: ${s.type}, ${s.required ? 'required' : 'optional'} (${s.label}); values are private`) : [];
|
|
122
|
+
}
|
|
119
123
|
function formatSpkReport(spk, report) {
|
|
120
124
|
return [
|
|
121
125
|
`SPK: ${spk}`,
|
|
126
|
+
...formatInstallSettings(report.installSettings),
|
|
127
|
+
...(report.identityGateway ? [report.identityGateway.notice, `OIDC issuer access: ${report.identityGateway.issuerAccess}`] : []),
|
|
122
128
|
`package: ${report.package ?? "?"} version: ${report.version ?? "?"}`,
|
|
123
129
|
report.synopkgland
|
|
124
130
|
? `arch: ${report.arch ?? "?"} runtime: ${report.kind ?? "?"}`
|
|
@@ -126,6 +132,9 @@ function formatSpkReport(spk, report) {
|
|
|
126
132
|
...(report.compose
|
|
127
133
|
? [
|
|
128
134
|
`Compose readiness: ${report.compose.readiness.profile}`,
|
|
135
|
+
...(report.compose.hostUdp?.length
|
|
136
|
+
? [`Host UDP listeners: ${report.compose.hostUdp.map(l => `${l.port} (${l.purpose})`).join(", ")}`]
|
|
137
|
+
: []),
|
|
129
138
|
report.compose.readiness.limitation,
|
|
130
139
|
]
|
|
131
140
|
: []),
|
|
@@ -338,6 +347,8 @@ export async function runCli(argv) {
|
|
|
338
347
|
: undefined;
|
|
339
348
|
emit("check", {
|
|
340
349
|
package: loaded.manifest.package,
|
|
350
|
+
...("installSettings" in loaded.manifest && loaded.manifest.installSettings ? { installSettings: loaded.manifest.installSettings } : {}),
|
|
351
|
+
...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) ? { identityGateway: gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) } : {}),
|
|
341
352
|
capabilities: "capabilities" in loaded.manifest
|
|
342
353
|
? loaded.manifest.capabilities
|
|
343
354
|
: {},
|
|
@@ -348,6 +359,8 @@ export async function runCli(argv) {
|
|
|
348
359
|
images: loaded.manifest.compose.plan.images,
|
|
349
360
|
services: loaded.manifest.compose.plan.services,
|
|
350
361
|
mountsBlocked: loaded.manifest.compose.plan.mounts_blocked,
|
|
362
|
+
...(loaded.manifest.compose.plan.host_udp?.length
|
|
363
|
+
? { hostUdp: loaded.manifest.compose.plan.host_udp } : {}),
|
|
351
364
|
acceptanceBlockers: loaded.manifest.compose.plan.acceptance_blockers,
|
|
352
365
|
},
|
|
353
366
|
}
|
|
@@ -356,6 +369,8 @@ export async function runCli(argv) {
|
|
|
356
369
|
...(loginStyle ? { loginStyle } : {}),
|
|
357
370
|
}, [
|
|
358
371
|
"Validation passed.",
|
|
372
|
+
...formatInstallSettings("installSettings" in loaded.manifest ? loaded.manifest.installSettings : undefined),
|
|
373
|
+
...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway) ? [gatewayAdmissionReport(loaded.manifest.identityGateway).notice] : []),
|
|
359
374
|
...("compose" in loaded.manifest && loaded.manifest.compose
|
|
360
375
|
? [
|
|
361
376
|
"Containers use the fixed package identity; signed-in File Station ACLs are not inherited.",
|
|
@@ -690,9 +705,13 @@ export async function runCli(argv) {
|
|
|
690
705
|
}),
|
|
691
706
|
});
|
|
692
707
|
const builtManifest = standalone && !isNativeLayout(standalone) ? loadStandaloneManifest(standalone) : undefined;
|
|
708
|
+
const installSettings = builtManifest && builtManifest.ok ? builtManifest.manifest.installSettings : undefined;
|
|
693
709
|
const devices = builtManifest && builtManifest.ok ? builtManifest.manifest.compose?.plan.devices : undefined;
|
|
710
|
+
const identityGateway = builtManifest && builtManifest.ok ? gatewayAdmissionReport(builtManifest.manifest.identityGateway, builtManifest.manifest.entry) : undefined;
|
|
694
711
|
const approval = await Promise.all(outputs.map(async (output) => ({ output, ...await spkApprovalDiff(output, options.baseline && resolve(options.baseline)) })));
|
|
695
712
|
const lines = [
|
|
713
|
+
...formatInstallSettings(installSettings),
|
|
714
|
+
...(identityGateway ? [identityGateway.notice] : []),
|
|
696
715
|
...approval.map(item => item.output + "\n" + formatApprovalDiff(item)),
|
|
697
716
|
...outputs.map((output) => `Built ${output}`),
|
|
698
717
|
...(devices?.gpu ? [`GPU: ${devices.gpu.paths}; GID source: ${devices.gpu.gid_source}; fallback: ${devices.gpu.fallback}`] : []),
|
|
@@ -700,7 +719,7 @@ export async function runCli(argv) {
|
|
|
700
719
|
];
|
|
701
720
|
if (reproducible && !reproducible.identical)
|
|
702
721
|
throw Object.assign(failure("BUILD_NOT_REPRODUCIBLE", lines.join("\n"), "Find the payload files that differ between builds (timestamps, random paths, unpinned dependencies) and retry after fixing them."), { report: { outputs, reproducible } });
|
|
703
|
-
emit("build", { outputs, approval, ...(devices ? { compose: { devices } } : {}), ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
|
|
722
|
+
emit("build", { outputs, approval, ...(installSettings ? { installSettings } : {}), ...(identityGateway ? { identityGateway } : {}), ...(devices ? { compose: { devices } } : {}), ...(reproducible ? { reproducible } : {}) }, lines.join("\n"));
|
|
704
723
|
});
|
|
705
724
|
if (json)
|
|
706
725
|
console.log = (...args) => console.error(...args);
|
|
@@ -16,10 +16,11 @@ export function resolveNativeInstallValues(manifest, provided, state) {
|
|
|
16
16
|
const folder = manifest.sharedFolder?.mode === "select" ? manifest.sharedFolder : undefined;
|
|
17
17
|
const key = folder?.key;
|
|
18
18
|
const changeKey = key ? `CHANGE_${key}` : undefined;
|
|
19
|
+
const settingNames = Object.keys(manifest.installSettings ?? {}).map(key => `pkgwizard_setting_${key}`);
|
|
19
20
|
const names = Object.keys(provided);
|
|
20
|
-
if (!selected && !folder && names.length)
|
|
21
|
+
if (!selected && !folder && !settingNames.length && names.length)
|
|
21
22
|
return fail("DEPLOY_PARAM_UNDECLARED", `${manifest.package} has no install wizard values; remove --param ${names[0]}.`);
|
|
22
|
-
const unknown = names.find((name) => !(selected && (name === "delegate_share" || name === "delegate_home")) && name !== key && name !== changeKey);
|
|
23
|
+
const unknown = names.find((name) => !(selected && (name === "delegate_share" || name === "delegate_home")) && name !== key && name !== changeKey && !settingNames.includes(name));
|
|
23
24
|
if (unknown)
|
|
24
25
|
return fail("DEPLOY_PARAM_UNDECLARED", `--param ${unknown} is not an install wizard value. Use: ${folder ? `--param ${folder.key}=<shared folder>` : USAGE}.`);
|
|
25
26
|
if (key && changeKey) {
|
|
@@ -42,5 +43,12 @@ export function resolveNativeInstallValues(manifest, provided, state) {
|
|
|
42
43
|
return fail("DEPLOY_PARAM_TYPE_INVALID", "Choose either --param delegate_share or --param delegate_home=true, not both.");
|
|
43
44
|
if (selected && !state.installed && share === undefined && home !== "true")
|
|
44
45
|
return fail("DEPLOY_PARAM_REQUIRED", `The first install of ${manifest.package} must select the delegate scope: ${USAGE}.`);
|
|
46
|
+
for (const [key, setting] of Object.entries(manifest.installSettings ?? {})) {
|
|
47
|
+
const name = `pkgwizard_setting_${key}`, value = provided[name];
|
|
48
|
+
if (value !== undefined && (value.length > 2048 || /[\x00-\x1f\x7f]/.test(value)))
|
|
49
|
+
return fail('DEPLOY_PARAM_TYPE_INVALID', `--param ${name} must be a bounded literal HTTPS origin.`);
|
|
50
|
+
if (setting.required && !state.installed && !value)
|
|
51
|
+
return fail('DEPLOY_PARAM_REQUIRED', `Supply --param ${name}=<HTTPS origin>. SDK hooks validate it on the NAS.`);
|
|
52
|
+
}
|
|
45
53
|
return { ok: true, extraValues: { ...provided } };
|
|
46
54
|
}
|
package/dist/error-registry.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
|
|
2
2
|
export const ERROR_REGISTRY = {
|
|
3
3
|
...COMPOSE_ERRORS,
|
|
4
|
+
INSTALL_SETTINGS_INVALID: { "code": "INSTALL_SETTINGS_INVALID", "description": "The installation setting declaration is invalid.", "suggestion": "Declare 1–32 lowercase identifier keys in install.settings, each with type https-origin, a plain label and an explicit boolean required; omit defaults and values." },
|
|
5
|
+
SDK_INSTALL_SETTINGS_UNSUPPORTED: { "code": "SDK_INSTALL_SETTINGS_UNSUPPORTED", "description": "The selected SDK does not provide native installation settings.", "suggestion": "Pin SDK 0.23.0 or a matching supported release with installsettings/cmd/control, then rebuild with the matching CLI." },
|
|
6
|
+
SDK_INSTALL_SETTINGS_BUILD_FAILED: { "code": "SDK_INSTALL_SETTINGS_BUILD_FAILED", "description": "The SDK installation settings controller could not be built.", "suggestion": "Install the pinned Go toolchain, inspect the compiler diagnostics and rebuild with a complete matching SDK release." },
|
|
4
7
|
INIT_HEALTH_INVALID: { code: "INIT_HEALTH_INVALID", description: "The Compose readiness selection is invalid.", suggestion: "Supply both --health-path and --health-status with the actual endpoint and expected 2xx response; redirects are not followed." },
|
|
5
8
|
INIT_UPSTREAM_IMAGE_REQUIRED: { code: "INIT_UPSTREAM_IMAGE_REQUIRED", description: "The official upstream image repository was not selected.", suggestion: "Use init --upstream-fork --upstream-image <official-repository> for Compose." },
|
|
6
9
|
INIT_UPSTREAM_IMAGE_INVALID: { code: "INIT_UPSTREAM_IMAGE_INVALID", description: "The upstream image repository is invalid.", suggestion: "Supply the official repository without a tag or digest; upgrade.sh selects the matching release and resolves its digest." },
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"0.
|
|
3
|
-
"x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
|
|
4
|
-
"aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
|
|
5
|
-
"armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
|
|
6
|
-
"lifecycle": {
|
|
7
|
-
"x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
|
|
8
|
-
"aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
|
|
9
|
-
"armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
|
|
10
|
-
},
|
|
11
|
-
"goToolchain": "go1.26.9"
|
|
12
|
-
},
|
|
13
|
-
"0.21.1": {
|
|
2
|
+
"0.23.0": {
|
|
14
3
|
"x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
|
|
15
4
|
"aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
|
|
16
5
|
"armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
|
|
@@ -76,7 +76,7 @@ function goMod(backendDir) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
/** SDK releases that reuse an immutable executor release with identical payload bytes. */
|
|
79
|
-
const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0' };
|
|
79
|
+
const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0' };
|
|
80
80
|
/**
|
|
81
81
|
* The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
|
|
82
82
|
* local sdk checkout (development and gates only); otherwise modules come from
|
package/dist/standalone/build.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildInstallSettings } from './install-settings.js';
|
|
1
2
|
import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
|
|
2
3
|
import { buildApiDescription } from "./api-description.js";
|
|
3
4
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
@@ -153,6 +154,7 @@ export async function buildStandalone(options) {
|
|
|
153
154
|
const daemonControl = await buildDaemonControl(manifest, arch, archWork, run);
|
|
154
155
|
const identityGateway = await buildIdentityGateway(manifest, arch, archWork, run);
|
|
155
156
|
const composeControl = await buildComposeControl(manifest, arch, archWork, run);
|
|
157
|
+
const installSettingsControl = await buildInstallSettings(manifest, arch, archWork, run);
|
|
156
158
|
const composeStorageInit = await buildComposeStorageInit(manifest, arch, archWork, run);
|
|
157
159
|
// A portal needs the executor, so the executor's SDK carries the panel text.
|
|
158
160
|
const portalText = manifest.portal && executor
|
|
@@ -167,6 +169,7 @@ export async function buildStandalone(options) {
|
|
|
167
169
|
identityGateway,
|
|
168
170
|
composeControl,
|
|
169
171
|
composeStorageInit,
|
|
172
|
+
installSettingsControl,
|
|
170
173
|
backgroundWorker,
|
|
171
174
|
executor,
|
|
172
175
|
app,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/** SDK planner errors accepted at this versioned tool boundary. */
|
|
2
2
|
export const COMPOSE_ERRORS = {
|
|
3
|
+
COMPOSE_HOST_UDP_INVALID: {
|
|
4
|
+
code: "COMPOSE_HOST_UDP_INVALID",
|
|
5
|
+
description: "The host UDP listener declaration is invalid.",
|
|
6
|
+
suggestion: "Declare at most 32 distinct compose.host_udp ports from 1024 to 65535, each with a lowercase purpose label. Use the verified nonroot host Web topology; no added capabilities are required.",
|
|
7
|
+
},
|
|
3
8
|
COMPOSE_GPU_INVALID: {
|
|
4
9
|
code: "COMPOSE_GPU_INVALID",
|
|
5
10
|
description: "The GPU declaration requires bounded package-UID services.",
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import type { InstallSettings } from './install-settings.js';
|
|
1
2
|
import { type ComposeAppData } from "../runtime/compose-data.js";
|
|
2
3
|
import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
3
4
|
import { type Daemon } from "./daemon.js";
|
|
4
5
|
import type { SharedFolder } from "./shared-folder.js";
|
|
5
6
|
import type { StandaloneError, StandaloneManifest } from "./manifest.js";
|
|
6
7
|
export interface ComposePlan {
|
|
8
|
+
host_udp?: Array<{
|
|
9
|
+
port: number;
|
|
10
|
+
purpose: string;
|
|
11
|
+
}>;
|
|
7
12
|
devices?: {
|
|
8
13
|
gpu?: {
|
|
9
14
|
services: string[];
|
|
@@ -81,13 +86,14 @@ export interface ComposeApp {
|
|
|
81
86
|
document: Record<string, any>;
|
|
82
87
|
plan: ComposePlan;
|
|
83
88
|
}
|
|
84
|
-
export declare const COMPOSE_FIELDS: readonly ["devices", "startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
|
|
89
|
+
export declare const COMPOSE_FIELDS: readonly ["host_udp", "devices", "startup_timeout_seconds", "resource_prefix", "files", "env_files", "profiles", "web", "data", "shares", "network", "services", "helpers", "sdk"];
|
|
85
90
|
export declare function readComposeApp(value: unknown, root: string, arches: NativeArchFamily[], fail: (e: StandaloneError) => void, context?: {
|
|
86
91
|
package?: string;
|
|
87
92
|
sdk?: string;
|
|
88
93
|
mode?: string;
|
|
89
94
|
composeMounts?: boolean;
|
|
90
95
|
sharedFolder?: SharedFolder;
|
|
96
|
+
installSettings?: InstallSettings;
|
|
91
97
|
resolveImages?: boolean;
|
|
92
98
|
architecture?: NativeArchFamily;
|
|
93
99
|
imageProofs?: unknown;
|
|
@@ -106,8 +112,12 @@ export declare function composeNetwork(m: Pick<StandaloneManifest, "compose" | "
|
|
|
106
112
|
ingress?: {
|
|
107
113
|
candidate: number;
|
|
108
114
|
} | undefined;
|
|
109
|
-
mode: string;
|
|
110
115
|
ports: number[];
|
|
116
|
+
host_udp?: {
|
|
117
|
+
port: number;
|
|
118
|
+
purpose: string;
|
|
119
|
+
}[] | undefined;
|
|
120
|
+
mode: string;
|
|
111
121
|
};
|
|
112
122
|
export declare function composeSource(c: ComposeApp): string;
|
|
113
123
|
/** Retain upstream bytes, the true override and a lock without secret values. */
|
|
@@ -12,6 +12,7 @@ import { goChildEnv } from "../runtime/go-env.js";
|
|
|
12
12
|
import { sdkSource } from "../runtime/sdk-source.js";
|
|
13
13
|
import { readDaemon } from "./daemon.js";
|
|
14
14
|
export const COMPOSE_FIELDS = [
|
|
15
|
+
"host_udp",
|
|
15
16
|
"devices",
|
|
16
17
|
"startup_timeout_seconds",
|
|
17
18
|
"resource_prefix",
|
|
@@ -69,7 +70,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
69
70
|
if (!record(value) ||
|
|
70
71
|
Object.keys(value).some((k) => !COMPOSE_FIELDS.includes(k)) ||
|
|
71
72
|
!Array.isArray(value.files))
|
|
72
|
-
return invalid("compose requires files, with optional env_files, profiles, web, data, shares, devices, network, services, helpers and sdk; old file/targets syntax is removed.");
|
|
73
|
+
return invalid("compose requires files, with optional env_files, profiles, web, data, shares, devices, host_udp, network, services, helpers and sdk; old file/targets syntax is removed.");
|
|
73
74
|
if (value.sdk !== undefined &&
|
|
74
75
|
(typeof value.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(value.sdk)))
|
|
75
76
|
return invalid("compose.sdk must name the matching SDK release.", "SDK_COMPOSE_UNSUPPORTED");
|
|
@@ -77,6 +78,20 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
77
78
|
(!Number.isInteger(value.startup_timeout_seconds) ||
|
|
78
79
|
value.startup_timeout_seconds < 2 || value.startup_timeout_seconds > 900))
|
|
79
80
|
return invalid("compose.startup_timeout_seconds must be an integer from 2 to 900 seconds (default 120).", "COMPOSE_STARTUP_INVALID");
|
|
81
|
+
let hostUdp;
|
|
82
|
+
if (value.host_udp !== undefined) {
|
|
83
|
+
const ports = new Set();
|
|
84
|
+
if (!Array.isArray(value.host_udp) || value.host_udp.length > 32 || value.host_udp.some((listener) => {
|
|
85
|
+
if (!record(listener) || Object.keys(listener).some(k => k !== "port" && k !== "purpose") ||
|
|
86
|
+
!Number.isInteger(listener.port) || listener.port < 1024 || listener.port > 65535 ||
|
|
87
|
+
typeof listener.purpose !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(listener.purpose) || ports.has(listener.port))
|
|
88
|
+
return true;
|
|
89
|
+
ports.add(listener.port);
|
|
90
|
+
return false;
|
|
91
|
+
}))
|
|
92
|
+
return invalid("compose.host_udp allows at most 32 distinct ports 1024-65535 with lowercase purpose labels of 1-64 characters.", "COMPOSE_HOST_UDP_INVALID");
|
|
93
|
+
hostUdp = [...value.host_udp].map(l => ({ port: l.port, purpose: l.purpose })).sort((a, b) => a.port - b.port);
|
|
94
|
+
}
|
|
80
95
|
const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? templateSdkVersion());
|
|
81
96
|
let targets = {};
|
|
82
97
|
if (value.helpers !== undefined) {
|
|
@@ -95,6 +110,7 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
95
110
|
identity_mode: context.mode ?? "none",
|
|
96
111
|
compose_mounts: context.composeMounts === true,
|
|
97
112
|
...(context.sharedFolder?.mode === "select" ? { install_shared_folder: context.sharedFolder } : {}),
|
|
113
|
+
install_settings: context.installSettings,
|
|
98
114
|
resolve_images: context.resolveImages === true,
|
|
99
115
|
registry_mirrors: context.resolveImages &&
|
|
100
116
|
process.env.SYNOPKGLAND_COMPOSE_REGISTRY_MIRRORS
|
|
@@ -119,6 +135,9 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
|
|
|
119
135
|
if (value.startup_timeout_seconds !== undefined &&
|
|
120
136
|
plan.startup_timeout_seconds !== value.startup_timeout_seconds)
|
|
121
137
|
throw new Error("[SDK_COMPOSE_UNSUPPORTED] This SDK does not implement the declared startup budget; use the matching SDK/CLI release.");
|
|
138
|
+
if (JSON.stringify(plan.host_udp ?? []) !== JSON.stringify(hostUdp ?? []) ||
|
|
139
|
+
JSON.stringify(plan.lock?.host_udp ?? []) !== JSON.stringify(hostUdp ?? []))
|
|
140
|
+
throw new Error("[SDK_COMPOSE_UNSUPPORTED] The SDK planner and lock must preserve the exact host_udp declaration; use the matching SDK/CLI release.");
|
|
122
141
|
const document = parse(plan.effective);
|
|
123
142
|
const volumes = Object.entries(document?.volumes ?? {});
|
|
124
143
|
if ((!validComposeAppData(plan.app_data, context.package ?? "ComposeApp")
|
|
@@ -169,6 +188,7 @@ export function composeNetwork(m) {
|
|
|
169
188
|
"host";
|
|
170
189
|
return {
|
|
171
190
|
mode: c.plan.mounts_blocked ? "settings" : host ? "host" : "bridge",
|
|
191
|
+
...(c.plan.host_udp?.length ? { host_udp: c.plan.host_udp } : {}),
|
|
172
192
|
ports: c.web ? [c.plan.ingress?.port ?? c.web.host_port ?? c.web.port] : [],
|
|
173
193
|
...(c.plan.ingress
|
|
174
194
|
? { ingress: { candidate: c.plan.ingress.candidate } }
|
|
@@ -271,6 +291,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
|
|
|
271
291
|
mode: m.identityGateway?.mode ?? "none",
|
|
272
292
|
composeMounts: m.capabilities.composeMounts,
|
|
273
293
|
sharedFolder: m.sharedFolder,
|
|
294
|
+
installSettings: m.installSettings,
|
|
274
295
|
resolveImages,
|
|
275
296
|
architecture: arch,
|
|
276
297
|
imageProofs,
|
|
@@ -35,7 +35,11 @@ export interface IdentityGateway {
|
|
|
35
35
|
/** The SDK release the gateway is built from. */
|
|
36
36
|
sdk: string;
|
|
37
37
|
mode: GatewayMode;
|
|
38
|
+
/** Fixed same-origin menu target, including optional query and fragment. */
|
|
39
|
+
launch_path?: string;
|
|
38
40
|
access?: WebAccessMode;
|
|
41
|
+
/** Public App routes keep upstream authentication; access still protects OIDC issuance. */
|
|
42
|
+
upstream_access?: "protected" | "public";
|
|
39
43
|
groups?: string[];
|
|
40
44
|
max_upload_bytes?: number;
|
|
41
45
|
response_header_timeout_seconds?: number;
|
|
@@ -55,6 +59,10 @@ export interface IdentityGateway {
|
|
|
55
59
|
roles?: GatewayRoles;
|
|
56
60
|
headers?: Partial<Record<"user" | "email" | "name" | "groups" | "uid", string>>;
|
|
57
61
|
oidc?: {
|
|
62
|
+
browser_redirect?: {
|
|
63
|
+
html_paths?: string[];
|
|
64
|
+
same_origin_referrer?: boolean;
|
|
65
|
+
};
|
|
58
66
|
native_login?: boolean;
|
|
59
67
|
issuer_port: number;
|
|
60
68
|
browser?: GatewayBrowser;
|
|
@@ -81,6 +89,18 @@ export type DeleteAction = {
|
|
|
81
89
|
exec: string[];
|
|
82
90
|
};
|
|
83
91
|
export declare function readIdentityGateway(value: unknown, daemon: Pick<Daemon, "targets"> | undefined, fail: (error: StandaloneError) => void, packagedPayload?: boolean, composeBridge?: boolean): IdentityGateway | undefined;
|
|
92
|
+
export interface GatewayAdmissionReport {
|
|
93
|
+
mode: "oidc";
|
|
94
|
+
issuerAccess: WebAccessMode;
|
|
95
|
+
issuerEntry: "admin-only" | "app-privilege";
|
|
96
|
+
issuerGroups?: string[];
|
|
97
|
+
upstreamAccess: "public";
|
|
98
|
+
upstreamProtected: false;
|
|
99
|
+
notice: string;
|
|
100
|
+
}
|
|
101
|
+
export declare function gatewayAdmissionReport(g?: IdentityGateway, entry?: string): GatewayAdmissionReport | undefined;
|
|
102
|
+
/** Stable build contract; human-facing wording is not part of the policy digest. */
|
|
103
|
+
export declare function gatewayAdmissionPolicy(g?: IdentityGateway, entry?: string): Omit<GatewayAdmissionReport, "notice"> | undefined;
|
|
84
104
|
/** target/sso/gateway.json, the SDK gateway's configuration (idgateway.Config). */
|
|
85
105
|
export declare function identityGatewayConfig(m: StandaloneManifest): string;
|
|
86
106
|
/** The daemon as the controller runs it: the gateway, with the App as its child. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { nativeGatewaySupported } from './native-gateway.js';
|
|
2
2
|
import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
|
|
3
|
-
import { existsSync, lstatSync } from "node:fs";
|
|
3
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
|
|
6
6
|
import { goChildEnv } from "../runtime/go-env.js";
|
|
@@ -127,6 +127,8 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
127
127
|
"sdk",
|
|
128
128
|
"mode",
|
|
129
129
|
"access",
|
|
130
|
+
"upstream_access",
|
|
131
|
+
"launch_path",
|
|
130
132
|
"groups",
|
|
131
133
|
"max_upload_bytes",
|
|
132
134
|
"response_header_timeout_seconds",
|
|
@@ -143,10 +145,27 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
143
145
|
"token",
|
|
144
146
|
"account",
|
|
145
147
|
])) {
|
|
146
|
-
return invalid("accepts only sdk, mode, upstream, listen, passthrough, redirects, email_domain, session_ttl, roles, headers, oidc, token and account.");
|
|
148
|
+
return invalid("accepts only sdk, mode, access, upstream_access, groups, upstream, listen, passthrough, optional_passthrough, redirects, email_domain, session_ttl, roles, headers, oidc, token and account.");
|
|
147
149
|
}
|
|
148
150
|
const v = value;
|
|
151
|
+
if (v.launch_path !== undefined) {
|
|
152
|
+
try {
|
|
153
|
+
const raw = v.launch_path;
|
|
154
|
+
if (typeof raw !== "string" || raw.length > 2048 || !raw.startsWith("/") || raw.startsWith("//") || /[\\\x00-\x1f\x7f]/.test(decodeURIComponent(raw)))
|
|
155
|
+
throw new Error();
|
|
156
|
+
const path = decodeURIComponent(raw.split(/[?#]/, 1)[0]);
|
|
157
|
+
if (path.startsWith("//") || path.split("/").some(p => p === "." || p === "..") || path === "/_sso" || path.startsWith("/_sso/"))
|
|
158
|
+
throw new Error();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return invalid("launch_path must be a bounded local App URL outside /_sso.");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
149
164
|
const access = v.access ?? "dsm-users";
|
|
165
|
+
if (v.upstream_access !== undefined && v.upstream_access !== "protected" && v.upstream_access !== "public")
|
|
166
|
+
return invalid("upstream_access must be protected or public.");
|
|
167
|
+
if (v.upstream_access === "public" && (v.mode !== "oidc" || access === "public" || v.optional_passthrough !== undefined))
|
|
168
|
+
return invalid("public upstream_access requires oidc with DSM issuer access and cannot combine with optional_passthrough.");
|
|
150
169
|
if (!isWebAccessMode(access) || (access === "public" && v.mode !== "none"))
|
|
151
170
|
return invalid("access must be dsm-users, dsm-admins, dsm-groups or public (none mode only).");
|
|
152
171
|
if (access === "dsm-groups" &&
|
|
@@ -244,7 +263,9 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
244
263
|
const gateway = {
|
|
245
264
|
sdk: v.sdk,
|
|
246
265
|
mode: v.mode,
|
|
266
|
+
...(v.launch_path !== undefined ? { launch_path: v.launch_path } : {}),
|
|
247
267
|
access,
|
|
268
|
+
...(v.upstream_access !== undefined ? { upstream_access: v.upstream_access } : {}),
|
|
248
269
|
...(Array.isArray(v.groups) ? { groups: v.groups } : {}),
|
|
249
270
|
...(v.max_upload_bytes !== undefined
|
|
250
271
|
? { max_upload_bytes: v.max_upload_bytes }
|
|
@@ -270,7 +291,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
270
291
|
if (v.mode === "oidc") {
|
|
271
292
|
const o = v.oidc;
|
|
272
293
|
if (!record(o) ||
|
|
273
|
-
!only(o, ["issuer_port", "clients", "rewrite_json", "browser", "native_login"]) ||
|
|
294
|
+
!only(o, ["issuer_port", "clients", "rewrite_json", "browser", "native_login", "browser_redirect"]) ||
|
|
274
295
|
(o.native_login !== undefined && typeof o.native_login !== "boolean") ||
|
|
275
296
|
!port(o.issuer_port) ||
|
|
276
297
|
!Array.isArray(o.clients) ||
|
|
@@ -291,6 +312,14 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
291
312
|
name(r.field))) {
|
|
292
313
|
return invalid("oidc.rewrite_json entries are { method, path, field }.");
|
|
293
314
|
}
|
|
315
|
+
const br = o.browser_redirect;
|
|
316
|
+
if (br !== undefined) {
|
|
317
|
+
const path = (p) => localPath(p) && p.length <= 512 && !/[%\s]/.test(p) && !p.startsWith("/_sso");
|
|
318
|
+
if (!record(br) || !only(br, ["html_paths", "same_origin_referrer"]) ||
|
|
319
|
+
(br.same_origin_referrer !== undefined && typeof br.same_origin_referrer !== "boolean") ||
|
|
320
|
+
(br.html_paths !== undefined && (!strings(br.html_paths, path, 16) || new Set(br.html_paths).size !== br.html_paths.length)))
|
|
321
|
+
return invalid("oidc.browser_redirect accepts only optional exact html_paths outside /_sso and same_origin_referrer.");
|
|
322
|
+
}
|
|
294
323
|
const b = o.browser;
|
|
295
324
|
if (b !== undefined) {
|
|
296
325
|
const [major, minor] = v.sdk.split(".").map(Number);
|
|
@@ -313,6 +342,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
313
342
|
return invalid("oidc.browser needs fixed local start, login_paths and callback_paths outside /_sso.");
|
|
314
343
|
}
|
|
315
344
|
gateway.oidc = {
|
|
345
|
+
...(br !== undefined ? { browser_redirect: br } : {}),
|
|
316
346
|
...(o.native_login !== undefined ? { native_login: o.native_login } : {}),
|
|
317
347
|
issuer_port: o.issuer_port,
|
|
318
348
|
...(b !== undefined ? { browser: b } : {}),
|
|
@@ -354,6 +384,22 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
|
|
|
354
384
|
return gateway;
|
|
355
385
|
}
|
|
356
386
|
const packageDest = (m) => `/var/packages/${m.package}/target`;
|
|
387
|
+
export function gatewayAdmissionReport(g, entry) {
|
|
388
|
+
return g?.upstream_access === "public" ? {
|
|
389
|
+
mode: "oidc", issuerAccess: g.access ?? "dsm-users", upstreamAccess: "public", upstreamProtected: false,
|
|
390
|
+
issuerEntry: entry === "admin-only" ? "admin-only" : "app-privilege",
|
|
391
|
+
...(g.access === "dsm-groups" ? { issuerGroups: g.groups } : {}),
|
|
392
|
+
notice: "Upstream routes are not protected by the gateway; the App owns authentication, CSRF, native clients and WebSockets. OIDC issuance still requires DSM sign-in and App privileges.",
|
|
393
|
+
} : undefined;
|
|
394
|
+
}
|
|
395
|
+
/** Stable build contract; human-facing wording is not part of the policy digest. */
|
|
396
|
+
export function gatewayAdmissionPolicy(g, entry) {
|
|
397
|
+
const report = gatewayAdmissionReport(g, entry);
|
|
398
|
+
if (!report)
|
|
399
|
+
return;
|
|
400
|
+
const { notice: _, ...policy } = report;
|
|
401
|
+
return policy;
|
|
402
|
+
}
|
|
357
403
|
/** target/sso/gateway.json, the SDK gateway's configuration (idgateway.Config). */
|
|
358
404
|
export function identityGatewayConfig(m) {
|
|
359
405
|
const g = m.identityGateway;
|
|
@@ -365,9 +411,11 @@ export function identityGatewayConfig(m) {
|
|
|
365
411
|
stop_seconds: m.daemon?.timeouts.stop ?? m.lifecycleTimeouts?.stop ?? 30,
|
|
366
412
|
} : {}),
|
|
367
413
|
mode: g.mode,
|
|
414
|
+
...(g.launch_path ? { launch_path: g.launch_path } : {}),
|
|
368
415
|
access: g.access ?? "dsm-users",
|
|
416
|
+
...(g.upstream_access ? { upstream_access: g.upstream_access } : {}),
|
|
369
417
|
...(g.groups ? { groups: g.groups } : {}),
|
|
370
|
-
...(m.entry ? { entry: m.entry } : {}),
|
|
418
|
+
...(m.entry || g.upstream_access === "public" ? { entry: m.entry ?? "app-privilege" } : {}),
|
|
371
419
|
...(m.compose ? { container_app: true } : {}),
|
|
372
420
|
...(m.compose?.plan.mounts_blocked ? { compose_blocked: true } : {}),
|
|
373
421
|
...(g.max_upload_bytes ? { max_upload_bytes: g.max_upload_bytes } : {}),
|
|
@@ -442,6 +490,21 @@ export async function buildIdentityGateway(m, arch, work, run) {
|
|
|
442
490
|
throw new StandaloneBuildError("SDK_DOWNLOAD_FAILED", error.message.replace(/^\[[A-Z_]+\] /, ""));
|
|
443
491
|
}
|
|
444
492
|
runtimeBudgetSource(sdk.goDir, m);
|
|
493
|
+
if (m.identityGateway.oidc?.browser_redirect) {
|
|
494
|
+
const capability = join(sdk.goDir, "idgateway/browser_redirect.go");
|
|
495
|
+
if (!existsSync(capability) || !/const BrowserRedirectVersion = 2\b/.test(readFileSync(capability, "utf8")))
|
|
496
|
+
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "The selected SDK lacks per-request OIDC browser redirect support; use a matching SDK release or an explicit development source override.");
|
|
497
|
+
}
|
|
498
|
+
if (m.identityGateway.upstream_access === "public") {
|
|
499
|
+
const capability = join(sdk.goDir, "idgateway/provider_only.go");
|
|
500
|
+
if (!existsSync(capability) || !/const ProviderOnlyOIDCVersion = 1\b/.test(readFileSync(capability, "utf8")))
|
|
501
|
+
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "The selected SDK lacks provider-only OIDC support; use a matching SDK release or an explicit development source override.");
|
|
502
|
+
}
|
|
503
|
+
if (m.identityGateway.launch_path) {
|
|
504
|
+
const capability = join(sdk.goDir, "idgateway/launch_path.go");
|
|
505
|
+
if (!existsSync(capability) || !/const LauncherPathVersion = 1\b/.test(readFileSync(capability, "utf8")))
|
|
506
|
+
throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "The selected SDK lacks fixed launch_path support; use a matching SDK release or an explicit development source override.");
|
|
507
|
+
}
|
|
445
508
|
const out = join(work, `identity-gateway-${arch}`);
|
|
446
509
|
const ldflags = `-s -w -buildid= -X main.configPath=${packageDest(m)}/${GATEWAY_CONFIG_PATH}`;
|
|
447
510
|
const result = await run("go", [
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { type InstallSettings } from './install-settings.js';
|
|
1
2
|
import { type WebLaunchMode } from "../contract/web-launch.js";
|
|
2
3
|
import { type ComposeApp } from "./compose.js";
|
|
3
4
|
import { type ExecutorInspection } from "../runtime/executor-inspect.js";
|
|
4
5
|
import { type AppScriptName } from "./app-scripts.js";
|
|
6
|
+
import { type GatewayAdmissionReport } from "./identity-gateway.js";
|
|
5
7
|
import type { EntryMode } from "./manifest.js";
|
|
6
8
|
import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
|
|
7
9
|
export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
|
|
8
10
|
export interface SpkInspection {
|
|
11
|
+
installSettings?: InstallSettings;
|
|
12
|
+
identityGateway?: GatewayAdmissionReport;
|
|
9
13
|
kind?: SpkKind;
|
|
10
14
|
package?: string;
|
|
11
15
|
version?: string;
|
|
@@ -25,6 +29,7 @@ export interface SpkInspection {
|
|
|
25
29
|
requiresAdminApproval: boolean;
|
|
26
30
|
}>;
|
|
27
31
|
compose?: {
|
|
32
|
+
hostUdp?: ComposeApp["plan"]["host_udp"];
|
|
28
33
|
devices?: ComposeApp["plan"]["devices"];
|
|
29
34
|
ingress?: ComposeApp["plan"]["ingress"];
|
|
30
35
|
images: unknown[];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readInstallSettings, settingsInstallSteps, settingsConfig, renderSettingsHook } from './install-settings.js';
|
|
1
2
|
import { nativePortal } from "./portal.js";
|
|
2
3
|
import { nativeGatewaySupported, renderNativeGatewayConfig, renderNativeGatewayPrepare, NATIVE_GATEWAY_CONFIG, gatewayEntryPath } from './native-gateway.js';
|
|
3
4
|
import { inspectPortalInfo } from "../portal/info.js";
|
|
@@ -11,8 +12,8 @@ import { createHash } from "node:crypto";
|
|
|
11
12
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
12
13
|
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
13
14
|
import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
|
|
14
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
15
|
-
import { unwrapAppScripts } from "./app-scripts.js";
|
|
15
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { sdkScriptOf, unwrapAppScripts } from "./app-scripts.js";
|
|
16
17
|
import { tmpdir } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import * as tar from "tar";
|
|
@@ -33,7 +34,7 @@ import { renderDelegateScopeSteps, } from "../build-spk/delegate-scope.js";
|
|
|
33
34
|
import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
|
|
34
35
|
import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
|
|
35
36
|
import { renderUiConfig } from "./dsm-window.js";
|
|
36
|
-
import { identityGatewayConfig, readIdentityGateway, } from "./identity-gateway.js";
|
|
37
|
+
import { identityGatewayConfig, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
|
|
37
38
|
import { checkCgiBinary } from "./binary.js";
|
|
38
39
|
import { renderStandaloneScript } from "./lifecycle.js";
|
|
39
40
|
import { listPortExposures, loadCompose } from "../compose/analyze.js";
|
|
@@ -123,8 +124,14 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
123
124
|
findings.push("sdk-package.json does not match the schema 2 capability inventory.");
|
|
124
125
|
}
|
|
125
126
|
}
|
|
127
|
+
const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
|
|
128
|
+
const installSettings = await inspectInstallSettings(dir, payload, metadata, findings);
|
|
126
129
|
if (metadata.compose !== undefined) {
|
|
127
|
-
const result = await inspectUnifiedCompose(dir, payload, metadata, findings);
|
|
130
|
+
const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
|
|
131
|
+
if (installSettings)
|
|
132
|
+
result.installSettings = installSettings;
|
|
133
|
+
if (identityGateway)
|
|
134
|
+
result.identityGateway = identityGateway;
|
|
128
135
|
result.icon = inspectPackageIcons(dir, findings);
|
|
129
136
|
return result;
|
|
130
137
|
}
|
|
@@ -270,7 +277,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
270
277
|
? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
|
|
271
278
|
: undefined;
|
|
272
279
|
if (standalone)
|
|
273
|
-
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver);
|
|
280
|
+
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor, nativeGateway, info.os_min_ver, installSettings);
|
|
274
281
|
const icon = inspectPackageIcons(dir, findings);
|
|
275
282
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
276
283
|
if (standalone && !cgiEntry) {
|
|
@@ -281,6 +288,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
281
288
|
}
|
|
282
289
|
return {
|
|
283
290
|
kind,
|
|
291
|
+
...(identityGateway ? { identityGateway } : {}),
|
|
292
|
+
...(installSettings ? { installSettings } : {}),
|
|
284
293
|
...(launch ? { launch } : {}),
|
|
285
294
|
icon,
|
|
286
295
|
...(executor ? { executor } : {}),
|
|
@@ -598,6 +607,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
|
598
607
|
mode: cfg?.mode ?? "none",
|
|
599
608
|
composeMounts: metadata.capabilities?.includes("composeMounts"),
|
|
600
609
|
sharedFolder: recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))),
|
|
610
|
+
installSettings: metadata.installSettings,
|
|
601
611
|
architecture: arch,
|
|
602
612
|
imageProofs: JSON.parse(readFileSync(join(root, "compose/images.json"), "utf8")),
|
|
603
613
|
});
|
|
@@ -655,8 +665,8 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
|
655
665
|
throw new Error("gateway configuration differs from the declared package contract");
|
|
656
666
|
if (readOptional(join(dir, "conf/privilege")) !== renderNativePrivilege())
|
|
657
667
|
throw new Error("Compose hooks must run as the package account");
|
|
658
|
-
if (manifest.sharedFolder) {
|
|
659
|
-
for (const [name, text] of Object.entries(expectedWizardFiles(info.package, manifest.sharedFolder)))
|
|
668
|
+
if (manifest.sharedFolder || metadata.installSettings) {
|
|
669
|
+
for (const [name, text] of Object.entries(expectedWizardFiles(info.package, manifest.sharedFolder, false, undefined, metadata.installSettings)))
|
|
660
670
|
if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
|
|
661
671
|
throw new Error(`unexpected WIZARD_UIFILES/${name}; native share selection must match the declaration`);
|
|
662
672
|
}
|
|
@@ -734,6 +744,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
|
734
744
|
if (storageProblem)
|
|
735
745
|
throw new Error(`[${storageProblem.code}] ${storageProblem.message}`);
|
|
736
746
|
base.compose = {
|
|
747
|
+
...(c.plan.host_udp?.length ? { hostUdp: c.plan.host_udp } : {}),
|
|
737
748
|
...(c.plan.devices ? { devices: c.plan.devices } : {}),
|
|
738
749
|
...(c.plan.ingress ? { ingress: c.plan.ingress } : {}),
|
|
739
750
|
images: c.plan.images,
|
|
@@ -975,6 +986,40 @@ async function checkContainerExposure(dir, packageTgz, findings) {
|
|
|
975
986
|
function readOptional(path) {
|
|
976
987
|
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
977
988
|
}
|
|
989
|
+
async function inspectProviderOnlyAdmission(dir, paths, saved, findings) {
|
|
990
|
+
try {
|
|
991
|
+
if (!paths.has("sso/gateway.json")) {
|
|
992
|
+
if (saved !== undefined)
|
|
993
|
+
throw new Error("OIDC admission policy has no gateway configuration");
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const root = join(dir, "gateway-admission-inspection");
|
|
997
|
+
mkdirSync(root);
|
|
998
|
+
await tar.x({ file: join(dir, "package.tgz"), cwd: root, filter: p => p.replace(/^\.\//, "") === "sso/gateway.json" });
|
|
999
|
+
const cfg = JSON.parse(readFileSync(join(root, "sso/gateway.json"), "utf8"));
|
|
1000
|
+
if (cfg.upstream_access !== undefined && !["public", "protected"].includes(cfg.upstream_access))
|
|
1001
|
+
throw new Error("invalid OIDC upstream admission policy");
|
|
1002
|
+
if (cfg.upstream_access !== "public") {
|
|
1003
|
+
if (saved !== undefined)
|
|
1004
|
+
throw new Error("OIDC admission policy differs from its build declaration");
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (cfg.mode !== "oidc" || !["dsm-users", "dsm-admins", "dsm-groups"].includes(cfg.access)
|
|
1008
|
+
|| !["app-privilege", "admin-only"].includes(cfg.entry) || cfg.compose_blocked
|
|
1009
|
+
|| Object.keys(cfg.optional_passthrough ?? {}).length)
|
|
1010
|
+
throw new Error("OIDC admission policy weakens issuer or Share protection");
|
|
1011
|
+
if (cfg.access === "dsm-groups" && (!Array.isArray(cfg.groups) || !cfg.groups.length || !cfg.groups.every((g) => typeof g === "string" && g.length > 0)))
|
|
1012
|
+
throw new Error("OIDC admission policy has no valid issuer groups");
|
|
1013
|
+
const report = gatewayAdmissionReport(cfg, cfg.entry);
|
|
1014
|
+
if (!isDeepStrictEqual(gatewayAdmissionPolicy(cfg, cfg.entry), saved))
|
|
1015
|
+
throw new Error("OIDC admission policy differs from its build declaration");
|
|
1016
|
+
return report;
|
|
1017
|
+
}
|
|
1018
|
+
catch (e) {
|
|
1019
|
+
findings.push(e.message);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
978
1023
|
async function recoverNativeGateway(dir, value, info, findings) {
|
|
979
1024
|
if (value === undefined)
|
|
980
1025
|
return undefined;
|
|
@@ -1114,7 +1159,7 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
|
|
|
1114
1159
|
* declaration (without one, any present file must be the default native
|
|
1115
1160
|
* install wizard), and no other install/upgrade wizard file may exist.
|
|
1116
1161
|
*/
|
|
1117
|
-
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer) {
|
|
1162
|
+
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false, nativeGateway, osMinVer, installSettings) {
|
|
1118
1163
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
1119
1164
|
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
|
|
1120
1165
|
.filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
|
|
@@ -1140,7 +1185,7 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
|
|
|
1140
1185
|
: {}),
|
|
1141
1186
|
...portalManifest(portal),
|
|
1142
1187
|
};
|
|
1143
|
-
const wizards = expectedWizardFiles(pkg, folder, selected, portal);
|
|
1188
|
+
const wizards = expectedWizardFiles(pkg, folder, selected, portal, installSettings);
|
|
1144
1189
|
const source = selected
|
|
1145
1190
|
? "the install-selected policy"
|
|
1146
1191
|
: folder
|
|
@@ -1149,7 +1194,7 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
|
|
|
1149
1194
|
? "the portal declaration"
|
|
1150
1195
|
: "the native-go default install wizard";
|
|
1151
1196
|
// The default native wizard is optional; a declared shared folder or portal needs all of its files.
|
|
1152
|
-
for (const name of folder || selected || portal
|
|
1197
|
+
for (const name of folder || selected || portal || installSettings
|
|
1153
1198
|
? Object.keys(wizards)
|
|
1154
1199
|
: present.filter((n) => n in wizards)) {
|
|
1155
1200
|
if (readOptional(join(wizardDir, name)) !== wizards[name])
|
|
@@ -1173,7 +1218,45 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
|
|
|
1173
1218
|
}
|
|
1174
1219
|
}
|
|
1175
1220
|
}
|
|
1176
|
-
|
|
1221
|
+
/** Validate SDK settings inventory and hook prefix before existing backend inspectors.
|
|
1222
|
+
* All reads and temporary rewrites are in the extracted inspection directory. */
|
|
1223
|
+
async function inspectInstallSettings(dir, payload, metadata, findings) {
|
|
1224
|
+
const settings = readInstallSettings(metadata.installSettings, e => findings.push(e.message));
|
|
1225
|
+
const present = payload.filter(p => p.path.startsWith('install/'));
|
|
1226
|
+
if (!settings) {
|
|
1227
|
+
if (present.length)
|
|
1228
|
+
findings.push('INSTALL_SETTINGS_INVALID unexpected installation settings payload');
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const root = join(dir, 'install-settings-inspection');
|
|
1232
|
+
mkdirSync(root);
|
|
1233
|
+
await tar.x({ file: join(dir, 'package.tgz'), cwd: root });
|
|
1234
|
+
const info = readInfo(join(dir, 'INFO'), findings, 'binary', Boolean(metadata.compose));
|
|
1235
|
+
const gateway = metadata.compose?.gateway ?? (metadata.native_gateway ? { listen: metadata.native_gateway.listen } : undefined);
|
|
1236
|
+
const m = { package: info.package, installSettings: settings, identityGateway: gateway };
|
|
1237
|
+
try {
|
|
1238
|
+
if (readOptional(join(root, 'install/settings.json')) !== JSON.stringify(settingsConfig(m), null, 2) + '\n')
|
|
1239
|
+
throw new Error('settings inventory differs from its declaration or gateway port');
|
|
1240
|
+
if (present.length !== 2 || !present.some(p => p.path === 'install/control' && p.mode === '0755') || present.some(p => !['install/control', 'install/settings.json'].includes(p.path)))
|
|
1241
|
+
throw new Error('only SDK control and declarations may be packaged; values must never ship');
|
|
1242
|
+
const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? 'x86_64';
|
|
1243
|
+
const error = checkCgiBinary(root, join(root, 'install/control'), arch);
|
|
1244
|
+
if (error)
|
|
1245
|
+
throw new Error(error.message);
|
|
1246
|
+
// App wrappers remain intact here; remove exactly the SDK prefix within them.
|
|
1247
|
+
for (const name of ['preinst', 'postinst', 'postupgrade', 'preuninst']) {
|
|
1248
|
+
const file = join(dir, 'scripts', name), text = readOptional(file), prefix = renderSettingsHook(name, m);
|
|
1249
|
+
if (!text || !sdkScriptOf(name, text)?.sdk.startsWith('#!/bin/sh\n' + prefix) || text.split(prefix).length !== 2)
|
|
1250
|
+
throw new Error(`scripts/${name} lacks the exact SDK settings hook`);
|
|
1251
|
+
writeFileSync(file, text.replace(prefix, ''));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
findings.push('INSTALL_SETTINGS_INVALID ' + error.message);
|
|
1256
|
+
}
|
|
1257
|
+
return settings;
|
|
1258
|
+
}
|
|
1259
|
+
function expectedWizardFiles(pkg, folder, selected = false, portal, installSettings) {
|
|
1177
1260
|
const steps = selected
|
|
1178
1261
|
? (t) => [
|
|
1179
1262
|
...renderDelegateScopeSteps(t),
|
|
@@ -1182,9 +1265,7 @@ function expectedWizardFiles(pkg, folder, selected = false, portal) {
|
|
|
1182
1265
|
: folder
|
|
1183
1266
|
? sharedFolderInstallSteps(pkg, folder)
|
|
1184
1267
|
: renderNativeInstallWizard;
|
|
1185
|
-
const install = renderInstallWizardFiles(pkg, portal
|
|
1186
|
-
? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)]
|
|
1187
|
-
: steps);
|
|
1268
|
+
const install = renderInstallWizardFiles(pkg, t => [...steps(t), ...settingsInstallSteps(installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : [])]);
|
|
1188
1269
|
return {
|
|
1189
1270
|
...install.static,
|
|
1190
1271
|
...install.dynamic,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { StandaloneError, StandaloneManifest } from './manifest.js';
|
|
2
|
+
import type { Translate } from '../build-spk/wizard-i18n.js';
|
|
3
|
+
import type { NativeArchFamily } from '../build-spk/dsm-arch.js';
|
|
4
|
+
import type { CommandRunner } from './build.js';
|
|
5
|
+
export interface InstallSetting {
|
|
6
|
+
type: 'https-origin';
|
|
7
|
+
label: string;
|
|
8
|
+
required: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type InstallSettings = Record<string, InstallSetting>;
|
|
11
|
+
export declare function readInstallSettings(value: unknown, fail: (e: StandaloneError) => void): InstallSettings | undefined;
|
|
12
|
+
export declare function settingsInstallSteps(settings: InstallSettings | undefined): (t: Translate) => {
|
|
13
|
+
step_title: string;
|
|
14
|
+
items: {
|
|
15
|
+
type: string;
|
|
16
|
+
desc: string;
|
|
17
|
+
subitems: {
|
|
18
|
+
key: string;
|
|
19
|
+
desc: string;
|
|
20
|
+
defaultValue: string;
|
|
21
|
+
validator: {
|
|
22
|
+
allowBlank: boolean;
|
|
23
|
+
};
|
|
24
|
+
}[];
|
|
25
|
+
}[];
|
|
26
|
+
}[];
|
|
27
|
+
export declare function settingsConfig(m: Pick<StandaloneManifest, 'package' | 'installSettings' | 'identityGateway'>): {
|
|
28
|
+
settings: InstallSettings | undefined;
|
|
29
|
+
https_port?: number | undefined;
|
|
30
|
+
schema: number;
|
|
31
|
+
package: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function renderSettingsHook(name: string, m: StandaloneManifest): string;
|
|
34
|
+
export declare function buildInstallSettings(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { sdkSource } from '../runtime/sdk-source.js';
|
|
4
|
+
import { templateSdkVersion } from '../template-paths.js';
|
|
5
|
+
import { goChildEnv } from '../runtime/go-env.js';
|
|
6
|
+
import { pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
|
|
7
|
+
import { checkCgiBinary } from './binary.js';
|
|
8
|
+
import { StandaloneBuildError } from './spk.js';
|
|
9
|
+
export function readInstallSettings(value, fail) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return;
|
|
12
|
+
const bad = () => { fail({ code: 'INSTALL_SETTINGS_INVALID', message: 'install.settings requires 1-32 lowercase identifier keys, each with type: https-origin, a plain label (1-160 characters) and boolean required; no defaults or values.' }); return undefined; };
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
return bad();
|
|
15
|
+
const entries = Object.entries(value);
|
|
16
|
+
if (entries.length === 0 || entries.length > 32)
|
|
17
|
+
return bad();
|
|
18
|
+
for (const [key, s] of entries) {
|
|
19
|
+
if (!/^[a-z][a-z0-9_]{0,31}$/.test(key) || !s || typeof s !== 'object' || Array.isArray(s) ||
|
|
20
|
+
Object.keys(s).some(k => !['type', 'label', 'required'].includes(k)) || s.type !== 'https-origin' ||
|
|
21
|
+
typeof s.label !== 'string' || !s.label.trim() || s.label.length > 160 || /[\x00-\x1f\x7f]/.test(s.label) || typeof s.required !== 'boolean')
|
|
22
|
+
return bad();
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)));
|
|
25
|
+
}
|
|
26
|
+
export function settingsInstallSteps(settings) {
|
|
27
|
+
return (t) => settings ? [{ step_title: t('install.step'), items: Object.entries(settings).map(([key, s]) => ({
|
|
28
|
+
type: 'textfield', desc: `${s.label}${s.required ? ' (required on first install)' : ''}. Blank reuses retained settings.`, subitems: [{ key: `pkgwizard_setting_${key}`, desc: s.label, defaultValue: '', validator: { allowBlank: true } }],
|
|
29
|
+
})) }] : [];
|
|
30
|
+
}
|
|
31
|
+
export function settingsConfig(m) {
|
|
32
|
+
return { schema: 1, package: m.package, ...(m.identityGateway?.listen.https ? { https_port: m.identityGateway.listen.https } : {}), settings: m.installSettings };
|
|
33
|
+
}
|
|
34
|
+
export function renderSettingsHook(name, m) {
|
|
35
|
+
if (!m.installSettings)
|
|
36
|
+
return '';
|
|
37
|
+
const action = name === 'preinst' ? 'prepare' : ['postinst', 'postupgrade'].includes(name) ? 'remember' : name === 'preuninst' ? 'purge' : undefined;
|
|
38
|
+
if (!action)
|
|
39
|
+
return '';
|
|
40
|
+
const root = name === 'preinst' ? '${SYNOPKG_PKGINST_TEMP_DIR:?}' : `\${SYNOPKG_PKGDEST:-/var/packages/${m.package}/target}`;
|
|
41
|
+
return `"${root}/install/control" ${action} --root "${root}" --package '${m.package}' || exit $?\n`;
|
|
42
|
+
}
|
|
43
|
+
export async function buildInstallSettings(m, arch, work, run) {
|
|
44
|
+
if (!m.installSettings)
|
|
45
|
+
return;
|
|
46
|
+
const sdk = m.compose?.sdk ?? m.identityGateway?.sdk ?? templateSdkVersion(), source = sdkSource(sdk);
|
|
47
|
+
if (!existsSync(join(source.goDir, 'installsettings/cmd/control/main.go')))
|
|
48
|
+
throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_UNSUPPORTED', 'Use an SDK release with native install.settings support.');
|
|
49
|
+
const out = join(work, `install-settings-${arch}`);
|
|
50
|
+
const result = await run('go', ['build', '-trimpath', '-buildvcs=false', '-ldflags', '-s -w -buildid=', '-o', out, './installsettings/cmd/control'], { cwd: source.goDir, env: goChildEnv({ ...process.env, ...pinnedGoBuildEnv(arch) }) });
|
|
51
|
+
if (result.status !== 0)
|
|
52
|
+
throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_BUILD_FAILED', `Cannot build SDK installation settings: ${result.stderr}`);
|
|
53
|
+
const error = checkCgiBinary(work, out, arch), toolchain = pinnedGoProblem(out);
|
|
54
|
+
if (error || toolchain)
|
|
55
|
+
throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_BUILD_FAILED', error?.message ?? toolchain);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { renderSettingsHook } from './install-settings.js';
|
|
1
2
|
import { renderComposeRetainedData } from "../build-spk/compose-retained-data.js";
|
|
2
3
|
import { renderNativeGatewayPrepare } from './native-gateway.js';
|
|
3
4
|
import { renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
|
|
@@ -15,7 +16,8 @@ const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_u
|
|
|
15
16
|
export function renderStandaloneScript(name, manifest) {
|
|
16
17
|
const script = renderStandaloneScriptInternal(name, manifest);
|
|
17
18
|
const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
|
|
18
|
-
const
|
|
19
|
+
const prefix = renderSettingsHook(name, manifest) + prepared;
|
|
20
|
+
const sdk = prefix ? script.replace('#!/bin/sh\n', () => '#!/bin/sh\n' + prefix) : script;
|
|
19
21
|
return manifest.appScripts?.some((app) => app === name)
|
|
20
22
|
? composeAppScript(name, sdk)
|
|
21
23
|
: sdk;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type InstallSettings } from './install-settings.js';
|
|
1
2
|
import { type WebLaunchMode } from "../contract/web-launch.js";
|
|
2
3
|
import { type WebAPIProfiles } from "../runtime/webapi-profile.js";
|
|
3
4
|
import { type PackageMetadata } from "../package-metadata.js";
|
|
@@ -94,6 +95,7 @@ export interface StandaloneManifest {
|
|
|
94
95
|
uninstall?: Uninstall;
|
|
95
96
|
/** `install.sharedFolder`: the Shared Folder the install wizard selects or `data-share` creates. */
|
|
96
97
|
sharedFolder?: SharedFolder;
|
|
98
|
+
installSettings?: InstallSettings;
|
|
97
99
|
/** Who may use the app at all; absent keeps the route policy alone. */
|
|
98
100
|
entry?: EntryMode;
|
|
99
101
|
/** `portal`: registers the DSM Application Portal entry; an administrator activates it. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readInstallSettings } from './install-settings.js';
|
|
1
2
|
import { isWebLaunchMode } from "../contract/web-launch.js";
|
|
2
3
|
import { validWebAPIProfiles, } from "../runtime/webapi-profile.js";
|
|
3
4
|
import { validateMetadata } from "../package-metadata.js";
|
|
@@ -243,6 +244,7 @@ function readManifest(raw, c) {
|
|
|
243
244
|
const sharedFolder = raw.install === undefined
|
|
244
245
|
? undefined
|
|
245
246
|
: readSharedFolder(raw.install, (e) => c.fail(e.code, e.message));
|
|
247
|
+
const installSettings = readInstallSettings(isRecord(raw.install) ? raw.install.settings : undefined, e => c.fail(e.code, e.message));
|
|
246
248
|
const compose = arches &&
|
|
247
249
|
readComposeApp(raw.compose, c.root, arches, (e) => c.fail(e.code, e.message), {
|
|
248
250
|
package: packageId,
|
|
@@ -250,6 +252,7 @@ function readManifest(raw, c) {
|
|
|
250
252
|
mode: typeof gatewayRaw.mode === "string" ? gatewayRaw.mode : "none",
|
|
251
253
|
composeMounts: capabilities.composeMounts,
|
|
252
254
|
sharedFolder,
|
|
255
|
+
installSettings,
|
|
253
256
|
});
|
|
254
257
|
if (raw.compose !== undefined &&
|
|
255
258
|
((raw.daemon !== undefined && !timeoutOnly) ||
|
|
@@ -278,6 +281,8 @@ function readManifest(raw, c) {
|
|
|
278
281
|
identityGateway?.access === "public" &&
|
|
279
282
|
(raw.entry !== undefined || compose.plan.mounts_blocked))
|
|
280
283
|
c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "public ingress cannot bypass entry controls or expose blocked Shared Folder settings.");
|
|
284
|
+
if (compose?.plan.mounts_blocked && identityGateway?.upstream_access === "public")
|
|
285
|
+
c.fail("STANDALONE_IDENTITY_GATEWAY_INVALID", "public upstream access cannot expose blocked Shared Folder settings.");
|
|
281
286
|
if (compose &&
|
|
282
287
|
identityGateway &&
|
|
283
288
|
(!Object.values(compose.services).some((s) => s.port === identityGateway.upstream) ||
|
|
@@ -446,6 +451,7 @@ function readManifest(raw, c) {
|
|
|
446
451
|
...(compose ? { compose } : {}),
|
|
447
452
|
...(uninstall ? { uninstall } : {}),
|
|
448
453
|
...(sharedFolder ? { sharedFolder } : {}),
|
|
454
|
+
...(installSettings ? { installSettings } : {}),
|
|
449
455
|
...(entry ? { entry } : {}),
|
|
450
456
|
...(portal ? { portal } : {}),
|
|
451
457
|
...(icon ? { icon } : {}),
|
|
@@ -13,9 +13,11 @@ export function readSharedFolder(value, fail) {
|
|
|
13
13
|
fail({ code: "STANDALONE_SHARED_FOLDER_INVALID", message: `install.sharedFolder ${why}` });
|
|
14
14
|
return undefined;
|
|
15
15
|
};
|
|
16
|
-
if (!isRecord(value) || Object.keys(value).some((key) => key !== "sharedFolder")) {
|
|
16
|
+
if (!isRecord(value) || Object.keys(value).some((key) => key !== "sharedFolder" && key !== "settings")) {
|
|
17
17
|
return refuse(": only sharedFolder is allowed.");
|
|
18
18
|
}
|
|
19
|
+
if (value.sharedFolder === undefined && value.settings !== undefined)
|
|
20
|
+
return undefined;
|
|
19
21
|
const folder = value.sharedFolder;
|
|
20
22
|
if (!isRecord(folder) || Object.keys(folder).some((key) => !FIELDS.has(key))) {
|
|
21
23
|
return refuse(" accepts only mode, key, name and permission.");
|
package/dist/standalone/spk.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface AssembleOptions {
|
|
|
17
17
|
daemonControl?: string;
|
|
18
18
|
composeControl?: string;
|
|
19
19
|
composeStorageInit?: string;
|
|
20
|
+
installSettingsControl?: string;
|
|
20
21
|
/** The SDK identity gateway, required exactly when the manifest declares identity_gateway. */
|
|
21
22
|
identityGateway?: string;
|
|
22
23
|
/** The compiled background worker, required exactly when capabilities.jobs is enabled. */
|
package/dist/standalone/spk.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { settingsInstallSteps, settingsConfig } from './install-settings.js';
|
|
1
2
|
import { nativeGatewaySupported, nativeGatewaySdk, renderNativeGatewayConfig, NATIVE_GATEWAY_CONFIG, legacyGatewayNotice, gatewayEntryPath } from './native-gateway.js';
|
|
2
3
|
import { portalInfoFields } from "../portal/info.js";
|
|
3
4
|
import { sdkSource } from "../runtime/sdk-source.js";
|
|
4
5
|
import { resolvedSdkLaunch } from "../contract/app-launch.js";
|
|
6
|
+
import { gatewayAdmissionPolicy } from "./identity-gateway.js";
|
|
5
7
|
import { metadataInfoFields } from "../package-metadata.js";
|
|
6
8
|
import { createHash } from "node:crypto";
|
|
7
9
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
|
|
@@ -203,6 +205,14 @@ export async function assembleStandaloneSpk(options) {
|
|
|
203
205
|
chmodSync(join(target, "storage-init"), EXEC_MODE);
|
|
204
206
|
}
|
|
205
207
|
}
|
|
208
|
+
if (manifest.installSettings) {
|
|
209
|
+
if (!options.installSettingsControl)
|
|
210
|
+
throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_BUILD_FAILED', 'SDK settings controller required.');
|
|
211
|
+
const target = join(staging, 'package/install');
|
|
212
|
+
mkdirSync(target, { recursive: true });
|
|
213
|
+
copyExecutable(options.installSettingsControl, join(target, 'control'));
|
|
214
|
+
writeFile(join(target, 'settings.json'), JSON.stringify(settingsConfig(manifest), null, 2) + '\n');
|
|
215
|
+
}
|
|
206
216
|
const packageTgz = join(staging, "package.tgz");
|
|
207
217
|
await createReproducibleTar(join(staging, "package"), packageTgz, {
|
|
208
218
|
gzip: true,
|
|
@@ -213,10 +223,12 @@ export async function assembleStandaloneSpk(options) {
|
|
|
213
223
|
.digest("hex");
|
|
214
224
|
writeFile(join(staging, "sdk-package.json"), JSON.stringify({
|
|
215
225
|
schema: 2,
|
|
226
|
+
...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
|
|
216
227
|
...(manifest.lifecycleTimeouts ? { lifecycleTimeouts: manifest.lifecycleTimeouts } : {}),
|
|
217
228
|
...("timeoutSeconds" in manifest.backend && manifest.backend.timeoutSeconds ? { operationSeconds: manifest.backend.timeoutSeconds } : {}),
|
|
218
229
|
...(options.apiDescription ? { ctl: { hash: options.apiDescription.sha256, registry: options.apiRegistry } } : {}),
|
|
219
230
|
backend: manifest.backend.kind,
|
|
231
|
+
...(gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) ? { provider_only_oidc: gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) } : {}),
|
|
220
232
|
...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
|
|
221
233
|
launch: resolvedSdkLaunch(manifest),
|
|
222
234
|
capabilities: Object.keys(manifest.capabilities).sort(),
|
|
@@ -263,9 +275,7 @@ export async function assembleStandaloneSpk(options) {
|
|
|
263
275
|
: folder
|
|
264
276
|
? sharedFolderInstallSteps(manifest.package, folder)
|
|
265
277
|
: renderNativeInstallWizard;
|
|
266
|
-
const install = renderInstallWizardFiles(manifest.package, portal
|
|
267
|
-
? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)]
|
|
268
|
-
: steps);
|
|
278
|
+
const install = renderInstallWizardFiles(manifest.package, (t) => [...steps(t), ...settingsInstallSteps(manifest.installSettings)(t), ...(portal ? [renderPortalWizardStep(t, portal.alias)] : [])]);
|
|
269
279
|
writeWizardUiFiles(staging, {
|
|
270
280
|
...install.static,
|
|
271
281
|
...install.dynamic,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "0.
|
|
30
|
+
"@synopackageland/templates": "0.23.0",
|
|
31
31
|
"ajv": "^8.17.1",
|
|
32
32
|
"commander": "^13.1.0",
|
|
33
33
|
"json-canonicalize": "^3.0.1",
|
|
@@ -470,3 +470,16 @@ Administrator cleanup procedures, executor-tier Apps and storage redesign are
|
|
|
470
470
|
out of scope. Final DSM native UI acceptance remains required.
|
|
471
471
|
|
|
472
472
|
See the SDK's [Compose App data contract](https://github.com/synopackageland/syno-pkg-land-sdk/blob/main/docs/compose-app-data.md).
|
|
473
|
+
# Explicit host UDP listeners
|
|
474
|
+
|
|
475
|
+
For a verified nonroot host Web service, declare optional non-loopback UDP
|
|
476
|
+
listeners with `compose.host_udp: [{port: 5353, purpose: mdns}, {port: 1900,
|
|
477
|
+
purpose: ssdp}]`. Use up to 32 distinct integer ports, 1024–65535, with lowercase
|
|
478
|
+
purpose labels of 1–64 characters beginning with a letter (letters, digits and
|
|
479
|
+
hyphens only). This applies to the package UID across IPv4/IPv6, not one service.
|
|
480
|
+
It never permits TCP, changes Web loopback/health requirements, or adds root or
|
|
481
|
+
capabilities. Undeclared non-loopback listeners still fail readiness. The CLI
|
|
482
|
+
verifies planner/lock agreement and `inspect --json` reports `compose.hostUdp`.
|
|
483
|
+
Rebuild with a CLI and SDK that support the declaration; older planners reject
|
|
484
|
+
it. Discovery and multicast coexistence need real LAN verification; this guard
|
|
485
|
+
is not a firewall and does not reserve ports or configure reuse.
|
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Wrap an OSS app with DSM accounts
|
|
2
2
|
|
|
3
|
+
## Per-request OIDC browser origins (development)
|
|
4
|
+
|
|
5
|
+
`oidc.browser_redirect: {}` opts into per-request browser origins for SDK/CLI
|
|
6
|
+
0.23. Optional `html_paths` and `same_origin_referrer` configure HTML behavior.
|
|
7
|
+
No host list is accepted or stored in the SPK: users can open the same install
|
|
8
|
+
by IP, hostname, DDNS or a new DNS alias. Each request must use a syntactically
|
|
9
|
+
valid IP/DNS authority and the declared gateway scheme/port; forwarding headers
|
|
10
|
+
cannot select the destination. DSM native ingress uses its HTTPS port.
|
|
11
|
+
Host selects only this browser response's navigation URL, never an upstream
|
|
12
|
+
dial target or an authorization decision. DSM admission, transaction origin
|
|
13
|
+
and browser binding, exact redirect registration and PKCE remain required.
|
|
14
|
+
Gateway cookies are host-only. This does not make public upstream routes a
|
|
15
|
+
network security boundary; upstream retains its authentication/CSRF duties.
|
|
16
|
+
Use trusted DNS and TLS, as a user signing in on an attacker-controlled origin
|
|
17
|
+
exposes that origin's session to its owner. Rewritten responses use no-store
|
|
18
|
+
and remove validators/digests; issuer/discovery never depend on Host.
|
|
19
|
+
|
|
20
|
+
Location and existing `rewrite_json` rules already map the loopback authorization
|
|
21
|
+
endpoint. For clients returning it in HTML, add `html_paths: ["/auth/redirect"]`:
|
|
22
|
+
only those exact GET/200 HTML responses are rewritten, up to 1 MiB using identity
|
|
23
|
+
encoding, with raw/escaped/percent-encoded URL support. Query bytes and upstream
|
|
24
|
+
CSP stay intact; inline CSP hashes over modified scripts are incompatible.
|
|
25
|
+
If an upstream native form uses `no-referrer` and browsers send `Origin: null`,
|
|
26
|
+
explicit `same_origin_referrer: true` restores same-origin metadata only on
|
|
27
|
+
the declared HTML paths. Include the form's page in that list. Referrers never
|
|
28
|
+
go to other origins; null/foreign Origin checks are not relaxed.
|
|
29
|
+
Issuer/ID token `iss`, discovery, token, userinfo and JWKS remain on loopback.
|
|
30
|
+
Redirect registration, PKCE and DSM browser binding are unchanged. This needs
|
|
31
|
+
matching SDK/CLI support; builds reject an SDK without the capability, including
|
|
32
|
+
old releases selected without a development source override.
|
|
33
|
+
|
|
34
|
+
## Provider-only OIDC
|
|
35
|
+
|
|
36
|
+
For an upstream with native authentication for browsers and native clients,
|
|
37
|
+
declare `mode: oidc`, `upstream_access: public`, and retain `access: dsm-users`,
|
|
38
|
+
`dsm-admins` or `dsm-groups` for the issuer. This capability requires
|
|
39
|
+
SDK/CLI 0.23.0 or later. A build with an old SDK
|
|
40
|
+
without that capability fails rather than claiming support.
|
|
41
|
+
|
|
42
|
+
The gateway does not require DSM cookies or browser Origin on upstream requests.
|
|
43
|
+
The upstream owns authentication, authorization, CSRF, CORS and WebSocket policy;
|
|
44
|
+
no DSM identity is injected. Reserved `/_sso/*` paths retain gateway protection.
|
|
45
|
+
The issuer still verifies DSM sign-in, App Privileges and its role/group access
|
|
46
|
+
before issuing a browser-bound grant. Client authentication, exact redirect,
|
|
47
|
+
PKCE, code expiry and single use remain required at token exchange. App sessions
|
|
48
|
+
and offline data do not become instantly revoked when DSM access changes.
|
|
49
|
+
|
|
50
|
+
An omitted entry acquires `app-privilege` for issuer verification; explicit
|
|
51
|
+
`admin-only` stays stricter. This cannot combine with optional passthrough rules
|
|
52
|
+
or unapproved Share activation. Omission preserves protected behavior. Use
|
|
53
|
+
`check`, `build` and `inspect`: the admission summary explicitly states
|
|
54
|
+
**Upstream routes are not protected by the gateway**. Inspect compares that
|
|
55
|
+
summary with the packaged policy and rejects inconsistent declarations.
|
|
56
|
+
|
|
3
57
|
Use this checklist before selecting an image or writing glue. Discover the
|
|
4
58
|
installed CLI (`--help`, `skill show`, `check --json`) and SDK contracts. Use the
|
|
5
59
|
app's supported API/configuration; do not patch upstream or write its database.
|
|
@@ -122,7 +176,7 @@ URL is accepted as the return destination. The transaction expires in five minut
|
|
|
122
176
|
This declares navigation only; upstream account provisioning/role mapping must
|
|
123
177
|
use that upstream's supported configuration APIs and be tested independently.
|
|
124
178
|
|
|
125
|
-
## Server-managed OIDC navigation
|
|
179
|
+
## Server-managed OIDC navigation
|
|
126
180
|
|
|
127
181
|
For an upstream that automatically starts OIDC (for example a native web-server
|
|
128
182
|
authentication module), leave `oidc.browser` unset and declare
|
|
@@ -132,3 +186,15 @@ capability; released SDK 0.18.0 does not yet implement this field. Native Compos
|
|
|
132
186
|
URL entries resume only a bounded transaction and declared public gateway port.
|
|
133
187
|
Normal menu entry remains the classless SDK CGI. Existing browser consumers keep
|
|
134
188
|
their previous behavior. Never invent fake login paths just to enable DSM login.
|
|
189
|
+
|
|
190
|
+
## Fixed DSM menu target
|
|
191
|
+
|
|
192
|
+
`identity_gateway.launch_path` is a fixed local App URL, including a query or SPA
|
|
193
|
+
fragment, for example `/#/sso?identifier=fixed-organization`. It requires matching
|
|
194
|
+
SDK support; build rejects sources without that capability. Omission opens `/`.
|
|
195
|
+
The setting does not change issuer redirects or sign-in transaction resume.
|
|
196
|
+
External/network URLs, reserved `/_sso` paths, dot segments, backslashes and
|
|
197
|
+
control characters are rejected. Caller query values cannot override it.
|
|
198
|
+
Use the upstream's supported entry route; this does not modify upstream UI,
|
|
199
|
+
produce an App session, or unlock encrypted data. HTTPS and App-specific
|
|
200
|
+
fragment/query semantics still require deployment acceptance.
|