@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 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
  }
@@ -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.21.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
@@ -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
- function expectedWizardFiles(pkg, folder, selected = false, portal) {
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 sdk = prepared ? script.replace('#!/bin/sh\n', '#!/bin/sh\n' + prepared) : script;
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.");
@@ -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. */
@@ -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.21.1",
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.21.1",
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 (development)
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.