@synopackageland/cli 0.28.0 → 0.30.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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0 (2026-10-24)
4
+
5
+ - Package and forward `identity_gateway.external_access.single_origin`; use SDK 0.30.0 for one-URL Apps, provider-aware DSM menu launches and launch probes.
6
+ - Pin templates 0.30.x and its SDK/App 0.30.0 defaults; keep immutable executor/v0.27.0.
7
+
8
+ ## Unreleased
9
+
10
+ - Accept and package `identity_gateway.external_access.single_origin` (boolean) for one-URL upstreams. Build requires SDK sources with `single_origin` support, and inspect accepts the packaged `single_origin: true` declaration.
11
+
12
+ ## 0.29.0 (2026-10-24)
13
+
14
+ - Depend on templates `~0.29.0` with SDK/App 0.29.0 and UI 0.14.10. SDK 0.29.0 reuses executor/v0.27.0 through executor manifest format 1; no executor digests change.
15
+ - Package `identity_gateway.external_access` (`connect: optional | none`, `origin_change: none | confirm`) for SDK 0.29.0 or later gateways; check refuses it for older SDKs and inspect verifies the packaged external declaration and `settings.cgi` gateway entry.
16
+
3
17
  ## 0.28.0 (2026-10-24)
4
18
 
5
19
  - Depend on templates `~0.28.0` with SDK/App 0.28.0 and UI 0.14.10. SDK 0.28.0 reuses executor/v0.27.0 through executor manifest format 1; no executor digests change.
package/README.md CHANGED
@@ -66,8 +66,8 @@ CLI-only patch releases can differ from the SDK patch. Compose import and SDK de
66
66
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
67
67
 
68
68
  `init` and `add` read their templates from the locally installed
69
- `@synopackageland/templates`. CLI 0.28.0 depends on `~0.28.0`: npm installation or
70
- update can select the latest templates patch in the 0.28 minor line. Templates
69
+ `@synopackageland/templates`. CLI 0.30.0 depends on `~0.30.0`: npm installation or
70
+ update can select the latest templates patch in the 0.29 minor line. Templates
71
71
  patches need only a templates release; update the npm installation to receive them.
72
72
  The CLI never fetches templates at runtime, so offline use works after installation;
73
73
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -303,6 +303,17 @@ or `portal`, or with `capabilities.rootOps`, `delegate`, `webapi` or
303
303
  `identity`, whose approval or sign-in is shown in the SDK window
304
304
  (`STANDALONE_UI_NO_WINDOW_INVALID`). A missing `ui` is still an error.
305
305
 
306
+ Gateway Apps built with SDK 0.29.0 or later include DSM-origin `sso/settings.cgi`
307
+ and default to `external_access: {connect: optional, origin_change: none}`.
308
+ Declare `identity_gateway.external_access.connect: none` to ignore Connect's
309
+ public URL projection; manual URLs remain available without Connect. Use
310
+ `origin_change: confirm` for Apps that need confirmation when their public
311
+ origin changes, and `single_origin: true` (SDK 0.30.0+) for an upstream that
312
+ works on one URL only. `connect: required` is rejected, and declaring `external_access`
313
+ with SDK older than 0.29.0 is rejected. Compose and gateway SDK pins must match.
314
+ See [external access configuration](skills/reference/identity-gateway.md#external-access)
315
+ for the manifest, settings entry, URL rules and cold-login handoff.
316
+
306
317
  Compose uses schema-2 `dsm-package.yaml` and `ui.launch`; the default is new-page.
307
318
  Classless gateways use type url via the shared contract/app-launch resolver;
308
319
  AppWindow wrappers use standalone. Headless projects have no launcher.
@@ -362,6 +362,16 @@ export const ERROR_REGISTRY = {
362
362
  description: "The identity_gateway declaration does not match the gateway contract.",
363
363
  suggestion: "Declare a daemon; set sdk, mode (oidc, header or token), the App's loopback upstream port and distinct listen ports other than 5000/5001; keep passthrough and redirects local paths outside /_sso; oidc needs issuer_port and clients, header needs headers.user, token needs token.exec in the daemon payload.",
364
364
  },
365
+ STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID: {
366
+ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID",
367
+ description: "The external access declaration does not match the gateway contract.",
368
+ suggestion: "Set identity_gateway.external_access.connect to optional or none, origin_change to none or confirm, and single_origin to a boolean; required is not supported.",
369
+ },
370
+ STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED: {
371
+ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED",
372
+ description: "External access requires SDK 0.29.0 or later and matching gateway sources.",
373
+ suggestion: "Update identity_gateway.sdk and compose.sdk together to SDK 0.29.0 or later; ensure any development SDK override includes external access and settings.cgi support.",
374
+ },
365
375
  STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED: {
366
376
  code: "STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED",
367
377
  description: "The SDK identity gateway could not be built from identity_gateway.sdk.",
@@ -7,11 +7,24 @@ import type { StandaloneError, StandaloneManifest } from "./manifest.js";
7
7
  export declare const GATEWAY_MAIN = "./idgateway/cmd/gateway";
8
8
  /** Served by DSM at /webman/3rdparty/<package>/sso/authorize.cgi. */
9
9
  export declare const GATEWAY_CGI_PATH = "ui/sso/authorize.cgi";
10
+ export declare const GATEWAY_SETTINGS_CGI_PATH = "ui/sso/settings.cgi";
10
11
  /** The gateway inside the daemon payload; the App's payload must leave _sso/ free. */
11
12
  export declare const GATEWAY_PAYLOAD_DIR = "_sso";
12
13
  export declare const GATEWAY_EXEC = "_sso/gateway";
13
14
  export declare const GATEWAY_CONFIG_PATH = "sso/gateway.json";
14
15
  export type GatewayMode = "none" | "oidc" | "header" | "token";
16
+ export interface GatewayExternalAccess {
17
+ connect: "optional" | "none";
18
+ origin_change: "none" | "confirm";
19
+ /** One-URL upstream: DSM launch and local navigations go to the primary external origin. */
20
+ single_origin?: true;
21
+ }
22
+ export declare const EXTERNAL_ACCESS_MIN_SDK = "0.29.0";
23
+ /** SDK 0.28 has the diagnostic ping only; older gateways reject external JSON. */
24
+ export declare function externalAccessSdkSupported(sdk: string): boolean;
25
+ export declare function gatewayExternalAccess(gateway: IdentityGateway): GatewayExternalAccess | undefined;
26
+ /** The SDK executable dispatches these entry points by SCRIPT_NAME. */
27
+ export declare function gatewayCgiPaths(gateway: IdentityGateway): string[];
15
28
  export interface GatewayRoles {
16
29
  claim: string;
17
30
  admin: string;
@@ -34,6 +47,7 @@ export interface GatewayBrowser {
34
47
  export interface IdentityGateway {
35
48
  /** The SDK release the gateway is built from. */
36
49
  sdk: string;
50
+ external_access?: GatewayExternalAccess;
37
51
  mode: GatewayMode;
38
52
  /** Fixed same-origin menu target, including optional query and fragment. */
39
53
  launch_path?: string;
@@ -13,10 +13,25 @@ import { StandaloneBuildError } from "./spk.js";
13
13
  export const GATEWAY_MAIN = "./idgateway/cmd/gateway";
14
14
  /** Served by DSM at /webman/3rdparty/<package>/sso/authorize.cgi. */
15
15
  export const GATEWAY_CGI_PATH = "ui/sso/authorize.cgi";
16
+ export const GATEWAY_SETTINGS_CGI_PATH = "ui/sso/settings.cgi";
16
17
  /** The gateway inside the daemon payload; the App's payload must leave _sso/ free. */
17
18
  export const GATEWAY_PAYLOAD_DIR = "_sso";
18
19
  export const GATEWAY_EXEC = `${GATEWAY_PAYLOAD_DIR}/gateway`;
19
20
  export const GATEWAY_CONFIG_PATH = "sso/gateway.json";
21
+ export const EXTERNAL_ACCESS_MIN_SDK = "0.29.0";
22
+ /** SDK 0.28 has the diagnostic ping only; older gateways reject external JSON. */
23
+ export function externalAccessSdkSupported(sdk) {
24
+ const [major, minor] = sdk.split(".").map(Number);
25
+ return major > 0 || minor >= 29;
26
+ }
27
+ export function gatewayExternalAccess(gateway) {
28
+ return gateway.external_access ?? (externalAccessSdkSupported(gateway.sdk)
29
+ ? { connect: "optional", origin_change: "none" } : undefined);
30
+ }
31
+ /** The SDK executable dispatches these entry points by SCRIPT_NAME. */
32
+ export function gatewayCgiPaths(gateway) {
33
+ return [GATEWAY_CGI_PATH, "ui/sso/open.cgi", ...(gatewayExternalAccess(gateway) ? [GATEWAY_SETTINGS_CGI_PATH] : [])];
34
+ }
20
35
  const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
21
36
  const only = (v, keys) => Object.keys(v).every((k) => keys.includes(k));
22
37
  const port = (v) => Number.isInteger(v) && v > 0 && v < 65536;
@@ -125,6 +140,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
125
140
  if (!record(value) ||
126
141
  !only(value, [
127
142
  "sdk",
143
+ "external_access",
128
144
  "mode",
129
145
  "access",
130
146
  "upstream_access",
@@ -145,9 +161,21 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
145
161
  "token",
146
162
  "account",
147
163
  ])) {
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.");
164
+ return invalid("accepts only sdk, external_access, mode, access, upstream_access, groups, upstream, listen, passthrough, optional_passthrough, redirects, email_domain, session_ttl, roles, headers, oidc, token and account.");
149
165
  }
150
166
  const v = value;
167
+ let externalAccess;
168
+ if (v.external_access !== undefined) {
169
+ const external = v.external_access;
170
+ if (!record(external) || !only(external, ["connect", "origin_change", "single_origin"]) ||
171
+ (external.connect !== undefined && external.connect !== "optional" && external.connect !== "none") ||
172
+ (external.origin_change !== undefined && external.origin_change !== "none" && external.origin_change !== "confirm") ||
173
+ (external.single_origin !== undefined && typeof external.single_origin !== "boolean")) {
174
+ fail({ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_INVALID", message: "identity_gateway.external_access accepts only connect (optional or none), origin_change (none or confirm) and single_origin (boolean); required is not supported." });
175
+ return undefined;
176
+ }
177
+ externalAccess = { connect: external.connect ?? "optional", origin_change: external.origin_change ?? "none", ...(external.single_origin ? { single_origin: true } : {}) };
178
+ }
151
179
  if (v.launch_path !== undefined) {
152
180
  try {
153
181
  const raw = v.launch_path;
@@ -187,6 +215,12 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
187
215
  return invalid("response_header_timeout_seconds must be 1–3600.");
188
216
  if (typeof v.sdk !== "string" || !/^\d+\.\d+\.\d+$/.test(v.sdk))
189
217
  return invalid("sdk must be the SDK release (x.y.z) to build the gateway from.");
218
+ if (externalAccess && !externalAccessSdkSupported(v.sdk)) {
219
+ fail({ code: "STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", message: `identity_gateway.external_access requires SDK ${EXTERNAL_ACCESS_MIN_SDK} or later; SDK 0.28.0 provides the diagnostic ping only.` });
220
+ return undefined;
221
+ }
222
+ if (!externalAccess && externalAccessSdkSupported(v.sdk))
223
+ externalAccess = { connect: "optional", origin_change: "none" };
190
224
  if (v.mode !== "none" &&
191
225
  v.mode !== "oidc" &&
192
226
  v.mode !== "header" &&
@@ -262,6 +296,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
262
296
  return invalid("header mode needs headers.user.");
263
297
  const gateway = {
264
298
  sdk: v.sdk,
299
+ ...(externalAccess ? { external_access: externalAccess } : {}),
265
300
  mode: v.mode,
266
301
  ...(v.launch_path !== undefined ? { launch_path: v.launch_path } : {}),
267
302
  access,
@@ -405,6 +440,7 @@ export function identityGatewayConfig(m) {
405
440
  const g = m.identityGateway;
406
441
  return (JSON.stringify({
407
442
  package: m.package,
443
+ ...(gatewayExternalAccess(g) ? { external: gatewayExternalAccess(g) } : {}),
408
444
  ...(nativeGatewaySupported(m) ? { native_ingress: true } : {}),
409
445
  ...(runtimeBudgetSdkSupported(g.sdk) ? {
410
446
  operation_seconds: operationBudgetSeconds(m),
@@ -490,6 +526,17 @@ export async function buildIdentityGateway(m, arch, work, run) {
490
526
  throw new StandaloneBuildError("SDK_DOWNLOAD_FAILED", error.message.replace(/^\[[A-Z_]+\] /, ""));
491
527
  }
492
528
  runtimeBudgetSource(sdk.goDir, m);
529
+ if (gatewayExternalAccess(m.identityGateway)) {
530
+ const config = join(sdk.goDir, "idgateway/config.go");
531
+ const main = join(sdk.goDir, "idgateway/cmd/gateway/main.go");
532
+ if (!existsSync(config) || !/`json:"external(?:,omitempty)?"`/.test(readFileSync(config, "utf8")) ||
533
+ !existsSync(main) || !/["']\/?settings\.cgi["']/.test(readFileSync(main, "utf8")))
534
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", `The selected SDK source lacks external access or settings.cgi support; use SDK ${EXTERNAL_ACCESS_MIN_SDK} or later with matching sources.`);
535
+ const external = join(sdk.goDir, "idgateway/external.go");
536
+ if (gatewayExternalAccess(m.identityGateway)?.single_origin &&
537
+ (!existsSync(external) || !/`json:"single_origin(?:,omitempty)?"`/.test(readFileSync(external, "utf8"))))
538
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_EXTERNAL_SDK_REQUIRED", "The selected SDK source lacks external_access.single_origin support; use a matching SDK release or an explicit development source override.");
539
+ }
493
540
  if (m.identityGateway.oidc?.browser_redirect) {
494
541
  const capability = join(sdk.goDir, "idgateway/browser_redirect.go");
495
542
  if (!existsSync(capability) || !/const BrowserRedirectVersion = 2\b/.test(readFileSync(capability, "utf8")))
@@ -36,7 +36,7 @@ import { renderDelegateScopeSteps, } from "../build-spk/delegate-scope.js";
36
36
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
37
37
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
38
38
  import { renderUiConfig } from "./dsm-window.js";
39
- import { identityGatewayConfig, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
39
+ import { identityGatewayConfig, gatewayCgiPaths, GATEWAY_SETTINGS_CGI_PATH, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
40
40
  import { checkCgiBinary } from "./binary.js";
41
41
  import { matchesStandaloneScript, renderStandaloneScript, matchesStandaloneScriptPrefix } from "./lifecycle.js";
42
42
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
@@ -133,6 +133,7 @@ export async function inspectSpk(spkPath, expectedKind) {
133
133
  if (metadata.daemon_restart !== undefined && (metadata.compose !== undefined || !paths.has("daemon/control")))
134
134
  findings.push("DAEMON_RESTART_INVALID daemon restart requires a native daemon controller, without Compose supervision");
135
135
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
136
+ await inspectExternalGateway(dir, paths, payload, metadata.gateway_external, findings);
136
137
  const installSettings = await inspectInstallSettings(dir, payload, metadata, findings);
137
138
  if (metadata.compose !== undefined) {
138
139
  const result = await inspectUnifiedCompose(dir, payload, { ...metadata, installSettings }, findings);
@@ -801,8 +802,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
801
802
  const binaries = [
802
803
  "daemon/control",
803
804
  "daemon/payload/_sso/gateway",
804
- "ui/sso/authorize.cgi",
805
- "ui/sso/open.cgi",
805
+ ...(g ? gatewayCgiPaths(g) : []),
806
806
  ];
807
807
  for (const path of g ? binaries : []) {
808
808
  const problem = checkCgiBinary(root, join(root, path), arch);
@@ -1120,6 +1120,40 @@ async function inspectProviderOnlyAdmission(dir, paths, saved, findings) {
1120
1120
  return;
1121
1121
  }
1122
1122
  }
1123
+ /** External settings are the same package-account gateway binary as the existing CGI entries. */
1124
+ async function inspectExternalGateway(dir, paths, payload, saved, findings) {
1125
+ if (!paths.has("sso/gateway.json") && saved === undefined && !paths.has(GATEWAY_SETTINGS_CGI_PATH))
1126
+ return;
1127
+ try {
1128
+ const info = readInfo(join(dir, "INFO"), [], "binary");
1129
+ const root = join(dir, "external-gateway-inspection");
1130
+ mkdirSync(root);
1131
+ const entries = ["sso/gateway.json", GATEWAY_SETTINGS_CGI_PATH, "ui/sso/open.cgi"];
1132
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root, filter: p => entries.includes(p.replace(/^\.\//, "")) });
1133
+ const cfg = JSON.parse(readFileSync(join(root, "sso/gateway.json"), "utf8"));
1134
+ if (saved === undefined && cfg.external === undefined && !paths.has(GATEWAY_SETTINGS_CGI_PATH))
1135
+ return;
1136
+ const external = saved;
1137
+ if (!external || typeof external !== "object" || Array.isArray(external) ||
1138
+ Object.keys(external).length !== (external.single_origin === undefined ? 2 : 3) || (external.connect !== "optional" && external.connect !== "none") ||
1139
+ (external.origin_change !== "none" && external.origin_change !== "confirm") ||
1140
+ (external.single_origin !== undefined && external.single_origin !== true) ||
1141
+ !isDeepStrictEqual(cfg.external, external) || cfg.package !== info.package)
1142
+ throw new Error("external configuration differs from its package declaration");
1143
+ const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? "x86_64";
1144
+ const path = join(root, GATEWAY_SETTINGS_CGI_PATH);
1145
+ const problem = checkCgiBinary(root, path, arch);
1146
+ if (problem)
1147
+ throw new Error(problem.message);
1148
+ if (payload.find(p => p.path === GATEWAY_SETTINGS_CGI_PATH)?.mode !== "0755")
1149
+ throw new Error("settings.cgi must be executable");
1150
+ if (!readFileSync(path).equals(readFileSync(join(root, "ui/sso/open.cgi"))))
1151
+ throw new Error("settings.cgi must be the packaged gateway executable");
1152
+ }
1153
+ catch (e) {
1154
+ findings.push(`GATEWAY_EXTERNAL_INVALID ${e.message}`);
1155
+ }
1156
+ }
1123
1157
  async function recoverNativeGateway(dir, value, info, findings) {
1124
1158
  if (value === undefined)
1125
1159
  return undefined;
@@ -31,7 +31,7 @@ import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
31
31
  import { writeComposePayload } from "./compose.js";
32
32
  import { copyDaemonPayload, daemonSdkVersion, nodeDependency } from "./daemon.js";
33
33
  import { DAEMON_INGRESS_CONFIG, renderDaemonIngressConfig } from "./daemon-ingress.js";
34
- import { GATEWAY_CGI_PATH, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
34
+ import { gatewayCgiPaths, gatewayExternalAccess, GATEWAY_CONFIG_PATH, GATEWAY_EXEC, identityGatewayConfig, identityGatewayDeclaration, } from "./identity-gateway.js";
35
35
  import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit, BRIDGE_WORKER_ARGS, } from "./background.js";
36
36
  import { renderStandaloneResource } from "./resource.js";
37
37
  import { nativePortal } from "./portal.js";
@@ -112,8 +112,7 @@ export async function assembleStandaloneSpk(options) {
112
112
  if (!options.identityGateway)
113
113
  throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_ARTIFACT_REQUIRED", "identity gateway artifact required.");
114
114
  for (const path of [
115
- GATEWAY_CGI_PATH,
116
- "ui/sso/open.cgi",
115
+ ...gatewayCgiPaths(manifest.identityGateway),
117
116
  `daemon/payload/${GATEWAY_EXEC}`,
118
117
  ]) {
119
118
  const target = join(staging, "package", path);
@@ -232,6 +231,8 @@ export async function assembleStandaloneSpk(options) {
232
231
  ...(options.apiDescription ? { ctl: { hash: options.apiDescription.sha256, registry: options.apiRegistry } } : {}),
233
232
  backend: manifest.backend.kind,
234
233
  ...(gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) ? { provider_only_oidc: gatewayAdmissionPolicy(manifest.identityGateway, manifest.entry) } : {}),
234
+ ...(manifest.identityGateway && gatewayExternalAccess(manifest.identityGateway)
235
+ ? { gateway_external: gatewayExternalAccess(manifest.identityGateway) } : {}),
235
236
  ...(nativeGatewaySupported(manifest) ? { native_gateway: { sdk: manifest.identityGateway.sdk, listen: manifest.identityGateway.listen } } : {}),
236
237
  ...(manifest.daemon?.ingress ? { daemon_ingress: manifest.daemon.ingress } : {}),
237
238
  ...(manifest.daemon?.node ? { daemon_node: manifest.daemon.node } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.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.28.0",
30
+ "@synopackageland/templates": "~0.30.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -35,6 +35,14 @@ and `identity_gateway` for headless projects. Optional `compose.helpers` names
35
35
  architecture-specific native payloads for token/account adapters. All lifecycle
36
36
  scripts run as the package account; containers are managed by Container Manager.
37
37
 
38
+ For external access, pin both `compose.sdk` and `identity_gateway.sdk` to
39
+ 0.29.0 or later and use the gateway's
40
+ [`external_access` policy and DSM settings entry](identity-gateway.md#external-access).
41
+ There are no installation-wizard URL fields or post-install state initialization.
42
+ Manual URL configuration does not require Connect. The SDK resolves the public
43
+ origin for Compose; restart the App after changing an origin used by its
44
+ environment or a generated private snapshot.
45
+
38
46
  ## Startup budget
39
47
 
40
48
  Omit `compose.startup_timeout_seconds`: the default of 900 seconds lets a loaded
@@ -1,5 +1,75 @@
1
1
  # Wrap an OSS app with DSM accounts
2
2
 
3
+ ## External access
4
+
5
+ Use SDK 0.30.0 or later with matching gateway sources. Add this to the existing
6
+ complete gateway declaration; keep the App's upstream, listener and auth policy:
7
+
8
+ ```yaml
9
+ identity_gateway:
10
+ sdk: 0.30.0
11
+ external_access:
12
+ connect: optional # optional | none
13
+ origin_change: none # none | confirm
14
+ single_origin: false # true for an upstream that works on one URL only
15
+ ```
16
+
17
+ Omitting the block or either member uses `connect: optional` and
18
+ `origin_change: none`. `connect: required`, unknown members and other values
19
+ are rejected. SDKs below 0.29.0 reject an explicit block, including an empty
20
+ one. SDK 0.28.0 supplies the diagnostic ping without external access. On
21
+ Compose Apps, `compose.sdk` and `identity_gateway.sdk` must name the same
22
+ release. Build also rejects a development SDK override without the external
23
+ configuration and settings CGI dispatch support.
24
+
25
+ The generated `target/sso/gateway.json` uses
26
+ `"external":{"connect":"optional","origin_change":"none"}`. Older SDK targets
27
+ omit this member and the settings entry. The SPK packages the gateway
28
+ executable as `ui/sso/settings.cgi` with mode 0755, alongside `open.cgi` and
29
+ `authorize.cgi`; it runs as the package account. `inspect` checks the policy
30
+ inventory, executable mode and matching gateway bytes.
31
+
32
+ An administrator opens
33
+ `https://<NAS>:<DSM port>/webman/3rdparty/<package>/sso/settings.cgi` to view the
34
+ LAN, manual, Connect and private URLs. The initial GET only bootstraps the DSM
35
+ token; protected data and writes require a token-bearing POST, a valid DSM
36
+ session and administrator access. Manual configuration requires no Connect
37
+ installation. It edits only `manual_origin` and `dsm_origin` in private
38
+ `<StateDir>/external.json`; do not package initial state or installation-wizard
39
+ URL fields.
40
+
41
+ Public origins must use HTTPS and a DNS hostname. Port 443 is normalized;
42
+ other ports (except a single-origin App's `manual_origin`), IP addresses, paths, queries and user information are rejected,
43
+ as is an origin equal to the DSM origin. Manual takes precedence, then the
44
+ Connect projection when `connect: optional`; the first effective origin is
45
+ primary. `connect: none` ignores that projection. Private URLs are display-only.
46
+ Use `origin_change: confirm` for an App that must confirm public-origin changes.
47
+ Declare `single_origin: true` (with `origin_change: confirm`) for an App whose
48
+ upstream sign-in works on one fixed URL, such as Vaultwarden's `DOMAIN`. While
49
+ an external origin is set, the DSM launcher opens the primary origin and local
50
+ browser navigations redirect to it; the settings page says that only that
51
+ address can sign in. Without an external origin nothing changes. The generated
52
+ `external` object then carries `"single_origin":true`, and build rejects a
53
+ development SDK source without `single_origin` support.
54
+
55
+ Cold DSM login preserves external resume tokens
56
+ `<txn>:x<n>:<r1>.<r2>` in the fixed `authorize.cgi?resume=<encoded token>`
57
+ handoff. The transaction contains 1–128 ASCII letters, digits, underscores or
58
+ hyphens; `n` is 0 or 1 and each revision is a 1–19 digit decimal string. The
59
+ renderer never converts revisions through JavaScript Number or constructs an
60
+ external URL. The SDK checks origin, browser binding and current revisions;
61
+ stale revisions and unavailable indices fall back to LAN login. Local
62
+ `<txn>:<declared port>` resume remains supported. The SDK's `open.cgi` owns
63
+ resume handling for classless URL launchers, including Compose and `ui: false`.
64
+
65
+ Connect forwards to an administrator-approved App name and port. The ping is
66
+ a routing diagnostic; other services on that same listener may also be
67
+ reachable. It does not prove that all content belongs to the App. Existing
68
+ App authentication and passthrough policy still apply. Public provider limits
69
+ also apply: Cloudflare edge enforces the zone's upload limit, and long
70
+ responses need heartbeats within 60 seconds; use SSE or
71
+ `Cache-Control: no-transform` for streaming.
72
+
3
73
  ## Per-request OIDC browser origins (development)
4
74
 
5
75
  `oidc.browser_redirect: {}` opts into per-request browser origins for SDK/CLI
@@ -198,3 +268,5 @@ control characters are rejected. Caller query values cannot override it.
198
268
  Use the upstream's supported entry route; this does not modify upstream UI,
199
269
  produce an App session, or unlock encrypted data. HTTPS and App-specific
200
270
  fragment/query semantics still require deployment acceptance.
271
+
272
+ Single-origin Apps require SDK 0.30.0 or later. Their `manual_origin` may include an explicit HTTPS port (for example, `https://example.ts.net:29443`). The DSM launcher resolves the destination for the provider used to open DSM, probes `/_spl/ping` for up to eight seconds, and offers **Open anyway** if the probe fails. A single-origin App always uses its configured primary origin. Other Apps and the `external-origin` install setting continue to require HTTPS DNS origins on port 443.