@synopackageland/cli 0.33.2 → 0.35.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.
@@ -33,6 +33,7 @@ export async function buildApiDescription(m, bridgeMethods, run) {
33
33
  if (!Array.isArray(methods) || methods.some(item => typeof item.method !== 'string' || typeof item.permission !== 'string'))
34
34
  throw new StandaloneBuildError('CTL_DESCRIBE_FAILED', 'Invalid registry snapshot.');
35
35
  const registry = new Map(methods.map(item => [item.method, item.permission]));
36
+ const rawHTTP = new Set(methods.filter(item => item.kind === 'http').map(item => item.method));
36
37
  // Identity transport routes are outside the business registry but have immutable SDK ownership.
37
38
  if (m.capabilities.identity) {
38
39
  registry.set('identity.session', 'public');
@@ -46,6 +47,8 @@ export async function buildApiDescription(m, bridgeMethods, run) {
46
47
  const names = flowMethods(command.flow);
47
48
  let permission = 'public';
48
49
  for (const name of names) {
50
+ if (rawHTTP.has(name))
51
+ throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references raw HTTP route ${name}; ctl commands require RPC methods.`);
49
52
  if (!registry.has(name))
50
53
  throw new StandaloneBuildError('CTL_COMMAND_UNREGISTERED', `Command ${id} references unregistered method ${name}.`);
51
54
  const effective = m.entry === 'admin-only' ? 'administrators' : registry.get(name);
@@ -29,6 +29,8 @@ export async function buildBridgeRuntime(m, arch, work, run, digests) {
29
29
  `-X main.permissionsDigest=${digests.permissions}`,
30
30
  ...(m.portal && portalByExecutor(m.os_min_ver) ? ["-X main.portalMode=executor"] : []),
31
31
  `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${m.apiSDKPermissionsDigest}`,
32
+ `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${digests.permissions}`,
33
+ ...(m.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${m.entry}`] : []),
32
34
  ].join(" ");
33
35
  const result = await run("go", ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", out, RUNTIME_MAIN], {
34
36
  cwd: sdk.goDir,
@@ -1,4 +1,5 @@
1
1
  import { buildInstallSettings } from './install-settings.js';
2
+ import { auditGoRoutes, httpRoutesDigest } from './route-audit.js';
2
3
  import { PORTAL_REQUIRES_EXECUTOR } from "../portal/alias.js";
3
4
  import { buildApiDescription } from "./api-description.js";
4
5
  import { goChildEnv } from "../runtime/go-env.js";
@@ -232,6 +233,11 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
232
233
  manifest.apiSDKPermissionsDigest) {
233
234
  throw new StandaloneBuildError("STANDALONE_API_PERMISSIONS_CHANGED", "API permissions changed during build; check and rebuild.");
234
235
  }
236
+ const policy = readApiPermissions(backend, manifest.capabilities);
237
+ const audit = auditGoRoutes(backend.dir, policy.policy, policy.sdk, [backend.main], [goBuildEnv(arch).GOARCH]);
238
+ if (audit.problems.length || manifest.entry && !audit.entryGate) {
239
+ throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n") || "App entry gate was removed during build.");
240
+ }
235
241
  const module = goModulePath(backend.dir);
236
242
  if (!module)
237
243
  throw new StandaloneBuildError("STANDALONE_GO_BUILD_FAILED", `${join(backend.dir, "go.mod")} has no module line; backend must be a Go module.`);
@@ -245,6 +251,10 @@ async function buildGoCgi(manifest, backend, arch, work, run) {
245
251
  `-X main.requestTimeout=${requestTimeoutSeconds(backend)}`,
246
252
  `-X ${module}/internal/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
247
253
  `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
254
+ `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
255
+ `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
256
+ `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
257
+ ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
248
258
  ...delegateBuildFlags(manifest),
249
259
  ...(manifest.executor
250
260
  ? [
@@ -294,11 +304,20 @@ async function buildBackgroundWorker(manifest, arch, work, run) {
294
304
  if (!manifest.capabilities.jobs || manifest.backend.kind !== "go")
295
305
  return undefined;
296
306
  const out = join(work, `background-worker-${arch}`);
307
+ const policy = readApiPermissions(manifest.backend, manifest.capabilities);
308
+ const audit = auditGoRoutes(manifest.backend.dir, policy.policy, policy.sdk, [BACKGROUND_WORKER_MAIN], [goBuildEnv(arch).GOARCH]);
309
+ if (audit.problems.length)
310
+ throw new StandaloneBuildError("STANDALONE_API_REGISTRY_INVALID", audit.problems.join("\n"));
297
311
  const ldflags = [
298
312
  "-s",
299
313
  "-w",
300
314
  "-buildid=",
301
315
  `-X main.packageID=${manifest.package}`,
316
+ `-X ${SDK_MODULE}/ipc.packageID=${manifest.package}`,
317
+ `-X ${SDK_MODULE}/api.expectedHTTPRoutesDigest=${httpRoutesDigest(audit)}`,
318
+ `-X ${SDK_MODULE}/api.expectedPermissionsDigest=${manifest.apiPermissionsDigest}`,
319
+ `-X ${SDK_MODULE}/api.expectedSDKPermissionsDigest=${manifest.apiSDKPermissionsDigest}`,
320
+ ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
302
321
  ...delegateBuildFlags(manifest),
303
322
  ...(manifest.executor
304
323
  ? [
@@ -39,8 +39,8 @@ export declare function windowAppId(manifest: Pick<StandaloneManifest, "package"
39
39
  */
40
40
  export declare function renderUiConfig(manifest: StandaloneManifest, icons?: PackageIcons, loginStyle?: Record<string, string>): string;
41
41
  /**
42
- * The DSM AppWindow that hosts the project's UI. It loads app/app.js and
43
- * app/app.css once, then calls the app's mount(root, ctx). `ctx.call(method,
42
+ * The DSM AppWindow that hosts the project's UI. It loads the classic app.js
43
+ * or the versioned app.module.js module and app.css, then calls mount(root, ctx). `ctx.call(method,
44
44
  * params, {signal})` is the only transport: same-origin POST with DSM's session
45
45
  * cookie and SynoToken, given up past `ctx.requestTimeout` like the SDK's.
46
46
  * `ctx.t` / `ctx.locale` translate with the catalogs embedded at build time.
@@ -55,4 +55,4 @@ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: Pac
55
55
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
56
56
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
57
57
  */
58
- export declare function renderWindowScript(manifest: StandaloneManifest, assetVersion: string, icons?: PackageIcons, portalText?: PortalCatalogs): string;
58
+ export declare function renderWindowScript(manifest: StandaloneManifest, assetVersion: string, icons?: PackageIcons, portalText?: PortalCatalogs, moduleVersion?: string): string;
@@ -203,8 +203,8 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
203
203
  }, null, 2)}\n`;
204
204
  }
205
205
  /**
206
- * The DSM AppWindow that hosts the project's UI. It loads app/app.js and
207
- * app/app.css once, then calls the app's mount(root, ctx). `ctx.call(method,
206
+ * The DSM AppWindow that hosts the project's UI. It loads the classic app.js
207
+ * or the versioned app.module.js module and app.css, then calls mount(root, ctx). `ctx.call(method,
208
208
  * params, {signal})` is the only transport: same-origin POST with DSM's session
209
209
  * cookie and SynoToken, given up past `ctx.requestTimeout` like the SDK's.
210
210
  * `ctx.t` / `ctx.locale` translate with the catalogs embedded at build time.
@@ -219,7 +219,7 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
219
219
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
220
220
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
221
221
  */
222
- export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText) {
222
+ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText, moduleVersion) {
223
223
  const pkg = manifest.package;
224
224
  const ns = `SYNO.SDS.${pkg}`;
225
225
  const p = JSON.stringify(pkg);
@@ -407,16 +407,32 @@ ${ns}.loadApp = function (done) {
407
407
  done(apps[${p}]);
408
408
  return;
409
409
  }
410
- var base = "webman/3rdparty/${pkg}/app/";
410
+ ${moduleVersion ? ` var loads = window.DsmPackageAppLoads = window.DsmPackageAppLoads || {};
411
+ var key = ${p} + ":${moduleVersion}";
412
+ if (loads[key]) { loads[key].then(function (app) { done(app); }, function (error) { done(null, error); }); return; }
413
+ ` : ""} var base = "webman/3rdparty/${pkg}/app/${moduleVersion ? `${moduleVersion}/` : ""}";
411
414
  var css = document.createElement("link");
412
415
  css.rel = "stylesheet";
413
416
  css.href = base + "app.css?v=${assetVersion}";
414
417
  document.head.appendChild(css);
415
- var js = document.createElement("script");
418
+ ${moduleVersion ? ` loads[key] = import(new URL(base + "app.module.js", document.baseURI).href).then(function (app) {
419
+ if (typeof app.mount !== "function") throw new Error("The UI module must export mount(root, ctx)");
420
+ window.DsmPackageApps = window.DsmPackageApps || {};
421
+ window.DsmPackageApps[${p}] = app;
422
+ versions[${p}] = "${assetVersion}";
423
+ return app;
424
+ });
425
+ loads[key].then(function (app) { done(app); }, function (error) {
426
+ delete loads[key];
427
+ css.remove();
428
+ done(null, error);
429
+ });
430
+ ` : ` var js = document.createElement("script");
416
431
  js.src = base + "app.js?v=${assetVersion}";
417
432
  js.dataset.package = ${p};
418
433
  js.onload = function () { versions[${p}] = "${assetVersion}"; done((window.DsmPackageApps || {})[${p}]); };
419
434
  document.head.appendChild(js);
435
+ `}
420
436
  };
421
437
 
422
438
  // DSM keeps a loaded window script after a package upgrade until the page
@@ -504,10 +520,11 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
504
520
  var ctx = { requestTimeout: ${requestTimeout} };
505
521
  var call = function (method, params, options) { return ${ns}.call(method, params, options, ctx.requestTimeout); };
506
522
  var batch = ${batchRuntime ? `function (calls, options) { return ${ns}.batch(calls, options, ctx.requestTimeout); }` : "undefined"};
507
- ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
523
+ ${root ? root.mount : ""} ${ns}.loadApp(function (app, error) {
508
524
  if (me.mountToken !== mountToken) return;
509
525
  me.mountPending = false;
510
526
  var root = me.body && me.body.dom.querySelector(".dsmapp-root");
527
+ if (error && root) { root.textContent = "Unable to load the package UI. Reload this page to try again."; return; }
511
528
  if (app && root && !me.mounted) {
512
529
  var i18n = ${ns}.createI18n(lang, ${ns}.messages);
513
530
  me.hostAccess = ${ns}.createHost({ appWindow: me, locale: i18n.locale });
@@ -64,6 +64,7 @@ export interface IdentityGateway {
64
64
  https?: number;
65
65
  };
66
66
  passthrough: string[];
67
+ upstream_auth_paths?: string[];
67
68
  optional_passthrough?: Record<string, {
68
69
  prefixes: string[];
69
70
  }>;
@@ -121,5 +122,18 @@ export declare function identityGatewayConfig(m: StandaloneManifest): string;
121
122
  export declare function gatewayDaemon(daemon: Daemon): Daemon;
122
123
  /** Builds the gateway from the declared SDK release with the pinned Go, binding its config path. */
123
124
  export declare function buildIdentityGateway(m: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner): Promise<string | undefined>;
125
+ /** Exact JSON contract shared with idgateway.Config.EntryPolicyDigest. */
126
+ export declare function gatewayEntryPolicyDigest(cfg: {
127
+ entry?: string;
128
+ access?: string;
129
+ upstream_access?: string;
130
+ upstream_auth_paths?: string[];
131
+ groups?: string[];
132
+ compose_blocked?: boolean;
133
+ passthrough?: string[];
134
+ optional_passthrough?: Record<string, {
135
+ prefixes: string[];
136
+ }>;
137
+ }): string;
124
138
  /** The manifest form of a normalized gateway, retained for artifact inspection. */
125
139
  export declare function identityGatewayDeclaration(g: IdentityGateway | undefined): unknown;
@@ -1,4 +1,5 @@
1
1
  import { nativeGatewaySupported } from './native-gateway.js';
2
+ import { createHash } from 'node:crypto';
2
3
  import { operationBudgetSeconds, runtimeBudgetSdkSupported } from "../runtime/runtime-budgets.js";
3
4
  import { existsSync, lstatSync, readFileSync } from "node:fs";
4
5
  import { join } from "node:path";
@@ -41,6 +42,17 @@ const localPath = (v) => typeof v === "string" &&
41
42
  /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(v) &&
42
43
  !v.startsWith("//") &&
43
44
  !v.split("/").some((p) => p === "." || p === "..");
45
+ const pathPrefix = (path, prefix) => {
46
+ const base = prefix.replace(/\/$/, "");
47
+ return path === base || path.startsWith(base + "/");
48
+ };
49
+ const upstreamPrefix = (v) => typeof v === "string" && v.length <= 512 && /^\/[A-Za-z0-9._~/-]+$/.test(v) &&
50
+ v !== "/" && !v.includes("//") && !v.split("/").some(p => p === "." || p === "..") &&
51
+ !pathPrefix(v, "/_sso") && !pathPrefix(v, "/_spl");
52
+ function upstreamAuthSdkSupported(dir) {
53
+ const source = join(dir, "idgateway/upstream_auth.go");
54
+ return existsSync(source) && /const UpstreamAuthPathsVersion = 1\b/.test(readFileSync(source, "utf8"));
55
+ }
44
56
  const strings = (v, ok, max = 64) => Array.isArray(v) && v.length <= max && v.every(ok);
45
57
  const name = (v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(v);
46
58
  const headerName = (v) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(v);
@@ -151,6 +163,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
151
163
  "upstream",
152
164
  "listen",
153
165
  "passthrough",
166
+ "upstream_auth_paths",
154
167
  "optional_passthrough",
155
168
  "redirects",
156
169
  "email_domain",
@@ -161,7 +174,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
161
174
  "token",
162
175
  "account",
163
176
  ])) {
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.");
177
+ return invalid("accepts only sdk, external_access, mode, access, upstream_access, groups, upstream, listen, passthrough, upstream_auth_paths, optional_passthrough, redirects, email_domain, session_ttl, roles, headers, oidc, token and account.");
165
178
  }
166
179
  const v = value;
167
180
  let externalAccess;
@@ -270,6 +283,25 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
270
283
  }
271
284
  }
272
285
  const redirects = v.redirects ?? {};
286
+ const upstreamAuth = v.upstream_auth_paths;
287
+ if (upstreamAuth !== undefined) {
288
+ if (!strings(upstreamAuth, upstreamPrefix, 16) || !upstreamAuth.length)
289
+ return invalid("upstream_auth_paths needs 1-16 fixed path prefixes outside /_sso and /_spl, without wildcards or encoded paths.");
290
+ for (let i = 0; i < upstreamAuth.length; i++) {
291
+ if (upstreamAuth.slice(0, i).some(p => pathPrefix(p, upstreamAuth[i]) || pathPrefix(upstreamAuth[i], p)))
292
+ return invalid("upstream_auth_paths prefixes must not overlap.");
293
+ }
294
+ const all = [...passthrough, ...Object.values((optional ?? {})).flatMap(rule => rule.prefixes)];
295
+ if (all.some(p => !upstreamPrefix(p) || !upstreamAuth.some(prefix => pathPrefix(p, prefix))))
296
+ return invalid("passthrough must be contained in upstream_auth_paths.");
297
+ try {
298
+ if (!upstreamAuthSdkSupported(sdkSource(v.sdk).goDir))
299
+ return invalid("The selected SDK lacks upstream_auth_paths and its build-bound entry policy; use matching SDK sources.");
300
+ }
301
+ catch {
302
+ return invalid("The SDK upstream_auth_paths source is unavailable; use matching SDK sources.");
303
+ }
304
+ }
273
305
  if (!record(redirects) ||
274
306
  Object.entries(redirects).some(([from, to]) => !localPath(from) || !localPath(to)))
275
307
  return invalid("redirects maps local paths to local paths.");
@@ -316,6 +348,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
316
348
  ...(listen.https ? { https: listen.https } : {}),
317
349
  },
318
350
  passthrough: passthrough,
351
+ ...(upstreamAuth !== undefined ? { upstream_auth_paths: upstreamAuth } : {}),
319
352
  ...(optional !== undefined ? { optional_passthrough: optional } : {}),
320
353
  redirects: redirects,
321
354
  ...(v.email_domain ? { email_domain: v.email_domain } : {}),
@@ -451,7 +484,7 @@ export function identityGatewayConfig(m) {
451
484
  access: g.access ?? "dsm-users",
452
485
  ...(g.upstream_access ? { upstream_access: g.upstream_access } : {}),
453
486
  ...(g.groups ? { groups: g.groups } : {}),
454
- ...(m.entry || g.upstream_access === "public" ? { entry: m.entry ?? "app-privilege" } : {}),
487
+ ...(m.entry ? { entry: m.entry } : {}),
455
488
  ...(m.compose ? { container_app: true } : {}),
456
489
  ...(m.compose?.plan.mounts_blocked ? { compose_blocked: true } : {}),
457
490
  ...(g.max_upload_bytes ? { max_upload_bytes: g.max_upload_bytes } : {}),
@@ -468,6 +501,7 @@ export function identityGatewayConfig(m) {
468
501
  authorize_cgi: `/webman/3rdparty/${m.package}/sso/authorize.cgi`,
469
502
  state_dir: `/var/packages/${m.package}/var/sso`,
470
503
  passthrough: g.passthrough,
504
+ ...(g.upstream_auth_paths ? { upstream_auth_paths: g.upstream_auth_paths } : {}),
471
505
  ...(g.optional_passthrough ? { optional_passthrough: g.optional_passthrough } : {}),
472
506
  redirects: g.redirects,
473
507
  ...(g.email_domain ? { email_domain: g.email_domain } : {}),
@@ -526,6 +560,8 @@ export async function buildIdentityGateway(m, arch, work, run) {
526
560
  throw new StandaloneBuildError("SDK_DOWNLOAD_FAILED", error.message.replace(/^\[[A-Z_]+\] /, ""));
527
561
  }
528
562
  runtimeBudgetSource(sdk.goDir, m);
563
+ if (m.identityGateway.upstream_auth_paths && !upstreamAuthSdkSupported(sdk.goDir))
564
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "The selected SDK lacks upstream_auth_paths and its build-bound entry policy; use matching SDK sources.");
529
565
  if (gatewayExternalAccess(m.identityGateway)) {
530
566
  const config = join(sdk.goDir, "idgateway/config.go");
531
567
  const main = join(sdk.goDir, "idgateway/cmd/gateway/main.go");
@@ -553,7 +589,10 @@ export async function buildIdentityGateway(m, arch, work, run) {
553
589
  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.");
554
590
  }
555
591
  const out = join(work, `identity-gateway-${arch}`);
556
- const ldflags = `-s -w -buildid= -X main.configPath=${packageDest(m)}/${GATEWAY_CONFIG_PATH}`;
592
+ const policyFlag = upstreamAuthSdkSupported(sdk.goDir)
593
+ ? ` -X github.com/synopackageland/syno-pkg-land-sdk/idgateway.expectedEntryPolicyDigest=${gatewayEntryPolicyDigest(JSON.parse(identityGatewayConfig(m)))}`
594
+ : "";
595
+ const ldflags = `-s -w -buildid= -X main.configPath=${packageDest(m)}/${GATEWAY_CONFIG_PATH}${policyFlag}`;
557
596
  const result = await run("go", [
558
597
  "build",
559
598
  "-trimpath",
@@ -578,6 +617,19 @@ export async function buildIdentityGateway(m, arch, work, run) {
578
617
  throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_BUILD_FAILED", toolchain);
579
618
  return out;
580
619
  }
620
+ /** Exact JSON contract shared with idgateway.Config.EntryPolicyDigest. */
621
+ export function gatewayEntryPolicyDigest(cfg) {
622
+ // Serialize keys explicitly: JSON.stringify reorders integer-like object keys.
623
+ const optional = Object.entries(cfg.optional_passthrough ?? {})
624
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
625
+ .map(([id, rule]) => `${JSON.stringify(id)}:${JSON.stringify({ prefixes: rule.prefixes })}`).join(",");
626
+ const base = JSON.stringify({
627
+ entry: cfg.entry ?? "", access: cfg.access ?? "", upstream_access: cfg.upstream_access ?? "",
628
+ groups: cfg.groups ?? [], compose_blocked: cfg.compose_blocked ?? false,
629
+ upstream_auth_paths: cfg.upstream_auth_paths ?? [], passthrough: cfg.passthrough ?? [],
630
+ });
631
+ return createHash("sha256").update(`${base.slice(0, -1)},"optional_passthrough":{${optional}}}`).digest("hex");
632
+ }
581
633
  /** The manifest form of a normalized gateway, retained for artifact inspection. */
582
634
  export function identityGatewayDeclaration(g) {
583
635
  if (!g)
@@ -1,4 +1,5 @@
1
1
  import { readInstallSettings, settingsInstallSteps, settingsConfig, renderSettingsHook } from './install-settings.js';
2
+ import { MODULE_UI_ENTRY, uiTreeDigest } from "./ui-assets.js";
2
3
  import { nativePortal } from "./portal.js";
3
4
  import { nativeGatewaySupported, renderNativeGatewayConfig, renderNativeGatewayDiagnostic, renderNativeGatewayPrepare, NATIVE_GATEWAY_CONFIG, gatewayEntryPath } from './native-gateway.js';
4
5
  import { inspectPortalInfo } from "../portal/info.js";
@@ -101,6 +102,7 @@ export async function inspectSpk(spkPath, expectedKind) {
101
102
  const raw = JSON.parse(metadataText);
102
103
  if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) ||
103
104
  raw.schema !== 2 ||
105
+ (raw.ui_module !== undefined && (typeof raw.ui_module !== "string" || !/^[a-f0-9]{64}$/.test(raw.ui_module) || raw.window === false || raw.compose !== undefined)) ||
104
106
  !["go", "python", "node", "rust", "binary"].includes(raw.backend) ||
105
107
  !Array.isArray(raw.capabilities) ||
106
108
  raw.capabilities.some((name) => ![
@@ -130,6 +132,7 @@ export async function inspectSpk(spkPath, expectedKind) {
130
132
  }
131
133
  }
132
134
  const daemonRestart = recoverDaemonRestart(metadata.daemon_restart, metadata.daemon_sdk, findings);
135
+ await inspectModuleUi(dir, paths, metadata.ui_module, findings);
133
136
  if (metadata.daemon_restart !== undefined && (metadata.compose !== undefined || !paths.has("daemon/control")))
134
137
  findings.push("DAEMON_RESTART_INVALID daemon restart requires a native daemon controller, without Compose supervision");
135
138
  const identityGateway = await inspectProviderOnlyAdmission(dir, paths, metadata.provider_only_oidc, findings);
@@ -344,6 +347,31 @@ export async function inspectSpk(spkPath, expectedKind) {
344
347
  rmSync(dir, { recursive: true, force: true });
345
348
  }
346
349
  }
350
+ async function inspectModuleUi(dir, paths, version, findings) {
351
+ if (version === undefined) {
352
+ if ([...paths].some(path => /^ui\/app\/[a-f0-9]{64}\/app\.module\.js$/.test(path)))
353
+ findings.push("UI_MODULE_INVALID module UI requires its build record");
354
+ return;
355
+ }
356
+ try {
357
+ if (!/^[a-f0-9]{64}$/.test(version))
358
+ throw new Error("invalid UI tree digest");
359
+ const prefix = `ui/app/${version}/`;
360
+ for (const entry of [MODULE_UI_ENTRY, "app.css"])
361
+ if (!paths.has(prefix + entry))
362
+ throw new Error(`missing ${entry}`);
363
+ if ([...paths].some(path => path.startsWith("ui/app/") && path !== `ui/app/${version}` && !path.startsWith(prefix)))
364
+ throw new Error("module UI assets must all belong to the recorded tree");
365
+ const root = join(dir, "module-ui-inspection");
366
+ mkdirSync(root);
367
+ await tar.x({ file: join(dir, "package.tgz"), cwd: root, filter: path => path.replace(/^\.\//, "").startsWith(prefix) });
368
+ if (uiTreeDigest(join(root, "ui/app", version), true) !== version)
369
+ throw new Error("UI assets differ from their recorded digest");
370
+ }
371
+ catch (error) {
372
+ findings.push(`UI_MODULE_INVALID ${error.message}`);
373
+ }
374
+ }
347
375
  const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
348
376
  const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
349
377
  /** Compare the actual launcher and Portal fields to the packaged declaration. */
@@ -1110,7 +1138,7 @@ async function inspectProviderOnlyAdmission(dir, paths, saved, findings) {
1110
1138
  return;
1111
1139
  }
1112
1140
  if (cfg.mode !== "oidc" || !["dsm-users", "dsm-admins", "dsm-groups"].includes(cfg.access)
1113
- || !["app-privilege", "admin-only"].includes(cfg.entry) || cfg.compose_blocked
1141
+ || cfg.entry !== undefined || cfg.compose_blocked
1114
1142
  || Object.keys(cfg.optional_passthrough ?? {}).length)
1115
1143
  throw new Error("OIDC admission policy weakens issuer or Share protection");
1116
1144
  if (cfg.access === "dsm-groups" && (!Array.isArray(cfg.groups) || !cfg.groups.length || !cfg.groups.every((g) => typeof g === "string" && g.length > 0)))
@@ -1,4 +1,6 @@
1
1
  import { readInstallSettings } from './install-settings.js';
2
+ import { auditGoRoutes } from './route-audit.js';
3
+ import { BACKGROUND_WORKER_MAIN } from './background.js';
2
4
  import { isWebLaunchMode } from "../contract/web-launch.js";
3
5
  import { validWebAPIProfiles, } from "../runtime/webapi-profile.js";
4
6
  import { validateMetadata } from "../package-metadata.js";
@@ -24,7 +26,7 @@ import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration, }
24
26
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
25
27
  import { LOGIN_COLOR, loginImageProblem, } from "../build-spk/login-style.js";
26
28
  import { validateReproducible } from "../reproducible-config.js";
27
- import { appSdkVersion, SDK_MODULE, } from "../runtime/sdk-source.js";
29
+ import { appSdkVersion, SDK_MODULE, sdkSource, } from "../runtime/sdk-source.js";
28
30
  import { sdkProblems } from "./sdk-check.js";
29
31
  import { readWindowSize } from "./window-size.js";
30
32
  import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
@@ -337,6 +339,39 @@ function readManifest(raw, c) {
337
339
  (raw.entry === "admin-only" || raw.entry === "app-privilege")
338
340
  ? raw.entry
339
341
  : readEntry(raw.entry, backend, permissions?.policy, c);
342
+ if (entry && identityGateway && (identityGateway.access === "public" || identityGateway.upstream_access === "public" ||
343
+ !identityGateway.upstream_auth_paths?.length &&
344
+ (identityGateway.passthrough.length || Object.keys(identityGateway.optional_passthrough ?? {}).length)))
345
+ c.fail("STANDALONE_ENTRY_WIDENED", "App entry requires protected gateway access and passthrough contained in explicit upstream_auth_paths.");
346
+ if (entry && raw.compose !== undefined && identityGateway) {
347
+ try {
348
+ if (!readFileSync(join(sdkSource(identityGateway.sdk).goDir, "api/server.go"), "utf8").includes("expectedEntryMode")) {
349
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "Update the SDK: App entry requires the build-bound entry floor and gateway admission.");
350
+ }
351
+ }
352
+ catch {
353
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "The SDK entry floor source is unavailable; update the SDK and retry.");
354
+ }
355
+ }
356
+ if (backend?.kind === "go" && permissions && sdkVersionOrUndefined(backend) && (raw.entry === undefined || entry)) {
357
+ try {
358
+ const problems = new Set();
359
+ for (const main of [backend.main, ...(capabilities.jobs ? [BACKGROUND_WORKER_MAIN] : [])]) {
360
+ for (const arch of arches ?? []) {
361
+ const audit = auditGoRoutes(backend.dir, permissions.policy, permissions.sdk, [main], [({ x86_64: 'amd64', aarch64: 'arm64', armv7: 'arm' })[arch]]);
362
+ for (const problem of audit.problems)
363
+ problems.add(problem);
364
+ if (main === backend.main && entry && !audit.entryGate)
365
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
366
+ }
367
+ }
368
+ for (const problem of problems)
369
+ c.fail("STANDALONE_API_REGISTRY_INVALID", problem);
370
+ }
371
+ catch (error) {
372
+ c.fail("STANDALONE_API_REGISTRY_INVALID", error.message);
373
+ }
374
+ }
340
375
  if (entry === "admin-only" && permissions) {
341
376
  const floored = floorAtAdministrators(permissions.policy, permissions.sdk);
342
377
  if (floored.widened.length) {
@@ -498,6 +533,9 @@ export function bridgeMethodProblems(methods, permissions, file) {
498
533
  problems.push(`App methods must not use an SDK method name: ${taken.join(", ")}.`);
499
534
  if (!methods)
500
535
  return problems;
536
+ const missing = methods.filter(method => !sdk.has(method) && !Object.hasOwn(permissions.policy.methods, method));
537
+ if (missing.length)
538
+ problems.push(`${file} must declare every registered App method: ${missing.join(", ")}.`);
501
539
  const unlisted = Object.keys(permissions.policy.methods).filter((method) => !sdk.has(method) && !methods.includes(method));
502
540
  if (unlisted.length)
503
541
  problems.push(`${file} sets methods the App does not have: ${unlisted.join(", ")}; the runtime refuses to start.`);
@@ -645,6 +683,17 @@ function readEntry(value, backend, policy, c) {
645
683
  const shown = main ? relative(c.root, main) : "the backend.main main.go";
646
684
  return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry needs the SDK entry gate: ${gate?.sdk ?? `backend/go.mod must require ${SDK_MODULE}`}, and wrap the authenticator with ${gate?.call.slice(0, -1) ?? "dsm.NewEntryGate"} in ${shown}.`);
647
685
  }
686
+ const version = sdkVersionOrUndefined(backend);
687
+ if (version) {
688
+ try {
689
+ if (!readFileSync(join(sdkSource(version).goDir, "api/server.go"), "utf8").includes("expectedEntryMode")) {
690
+ return c.fail("STANDALONE_ENTRY_UNSUPPORTED", "Update the SDK: App entry requires the build-bound entry floor in api.Server.");
691
+ }
692
+ }
693
+ catch {
694
+ return c.fail("STANDALONE_ENTRY_UNSUPPORTED", "The SDK entry floor source is unavailable; update the SDK and retry.");
695
+ }
696
+ }
648
697
  if (value === "app-privilege" && !permitsOrdinaryUsers(policy)) {
649
698
  return c.fail("STANDALONE_ENTRY_INVALID", "entry: app-privilege needs at least one route marked authenticated in permissions.json, otherwise regular users have nothing to use.");
650
699
  }
@@ -0,0 +1,12 @@
1
+ import type { ApiPermissions } from './api-permissions.js';
2
+ export interface RouteAudit {
3
+ problems: string[];
4
+ entryGate: boolean;
5
+ httpRoutes: {
6
+ prefix: string;
7
+ method: string;
8
+ }[];
9
+ }
10
+ export declare function httpRoutesDigest(audit: RouteAudit): string;
11
+ /** Parse Go syntax without executing an App's main, init functions or --describe. */
12
+ export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>, roots?: string[], arches?: string[]): RouteAudit;
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { goChildEnv } from '../runtime/go-env.js';
8
+ let binary;
9
+ export function httpRoutesDigest(audit) {
10
+ return createHash('sha256').update(JSON.stringify(audit.httpRoutes)).digest('hex');
11
+ }
12
+ /** Parse Go syntax without executing an App's main, init functions or --describe. */
13
+ export function auditGoRoutes(dir, policy, sdk, roots = ['.'], arches = ['amd64', 'arm64', 'arm']) {
14
+ const env = goChildEnv({ ...process.env, GOOS: undefined, GOARCH: undefined, GOARM: undefined, GOWORK: 'off', CGO_ENABLED: '0' });
15
+ if (!binary) {
16
+ const work = mkdtempSync(join(tmpdir(), 'synopkgland-route-audit-'));
17
+ process.once('exit', () => rmSync(work, { recursive: true, force: true }));
18
+ const out = join(work, process.platform === 'win32' ? 'route-audit.exe' : 'route-audit');
19
+ const source = fileURLToPath(new URL('../../assets/go/route-audit/main.go', import.meta.url));
20
+ const build = spawnSync('go', ['build', '-trimpath', '-o', out, source], { env: goChildEnv(env), encoding: 'utf8', timeout: 120_000 });
21
+ if (build.error || build.status !== 0)
22
+ throw new Error('Cannot build the Go route auditor; install Go and retry.');
23
+ binary = out;
24
+ }
25
+ const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Roots: roots, Arches: arches, Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout: 120_000, maxBuffer: 4 << 20 });
26
+ if (result.error || result.status !== 0)
27
+ throw new Error(`Cannot audit Go routes: ${result.stderr?.trim() || 'auditor unavailable'}`);
28
+ return JSON.parse(result.stdout);
29
+ }
@@ -6,7 +6,7 @@ import { resolvedSdkLaunch } from "../contract/app-launch.js";
6
6
  import { gatewayAdmissionPolicy } from "./identity-gateway.js";
7
7
  import { metadataInfoFields } from "../package-metadata.js";
8
8
  import { createHash } from "node:crypto";
9
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync, } from "node:fs";
9
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, realpathSync, } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { dirname, join, sep } from "node:path";
12
12
  import { infoArchValue } from "../build-spk/dsm-arch.js";
@@ -19,6 +19,7 @@ import { renderDelegateScopeSteps } from "../build-spk/delegate-scope.js";
19
19
  import { createReproducibleTar } from "../build-spk/reproducible-archive.js";
20
20
  import { renderStandaloneScript } from "./lifecycle.js";
21
21
  import { APP_SCRIPTS_DIR, APP_SCRIPTS_SPK_DIR } from "./app-scripts.js";
22
+ import { MODULE_UI_ENTRY, uiTreeDigest } from "./ui-assets.js";
22
23
  import { codedMessage } from "../errors.js";
23
24
  import { DSM_LANGS, LOCALES } from "./i18n.js";
24
25
  import { appTitle, CGI_PAYLOAD_PATH, hasSdkWindow, renderUiConfig, renderWindowScript, windowScriptName, } from "./dsm-window.js";
@@ -81,7 +82,16 @@ export async function assembleStandaloneSpk(options) {
81
82
  copyUiTree(manifest.ui.dir, appDir, manifest.root);
82
83
  else
83
84
  mkdirSync(appDir, { recursive: true });
84
- const assetVersion = digestTree(appDir).slice(0, 12);
85
+ const moduleUi = hasSdkWindow(manifest) && existsSync(join(appDir, MODULE_UI_ENTRY));
86
+ const uiDigest = uiTreeDigest(appDir, moduleUi);
87
+ const assetVersion = uiDigest.slice(0, 12);
88
+ const moduleVersion = moduleUi ? uiDigest : undefined;
89
+ if (moduleVersion) {
90
+ const snapshot = join(uiRoot, "app-snapshot");
91
+ renameSync(appDir, snapshot);
92
+ mkdirSync(appDir);
93
+ renameSync(snapshot, join(appDir, moduleVersion));
94
+ }
85
95
  const login = manifest.portal
86
96
  ? loginStyleAssets(manifest.loginStyle)
87
97
  : undefined;
@@ -89,7 +99,7 @@ export async function assembleStandaloneSpk(options) {
89
99
  for (const [path, bytes] of login?.files ?? [])
90
100
  writeFile(join(uiRoot, path), bytes);
91
101
  if (hasSdkWindow(manifest))
92
- writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText));
102
+ writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion, icons, options.portalText, moduleVersion));
93
103
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
94
104
  writeDsmWindowIcons(uiRoot, icons);
95
105
  writeUiTexts(uiRoot, appTitle(manifest), manifest.console);
@@ -225,6 +235,7 @@ export async function assembleStandaloneSpk(options) {
225
235
  .digest("hex");
226
236
  writeFile(join(staging, "sdk-package.json"), JSON.stringify({
227
237
  schema: 2,
238
+ ...(moduleVersion ? { ui_module: moduleVersion } : {}),
228
239
  ...(manifest.installSettings ? { installSettings: manifest.installSettings } : {}),
229
240
  ...(manifest.lifecycleTimeouts ? { lifecycleTimeouts: manifest.lifecycleTimeouts } : {}),
230
241
  ...("timeoutSeconds" in manifest.backend && manifest.backend.timeoutSeconds ? { operationSeconds: manifest.backend.timeoutSeconds } : {}),
@@ -387,7 +398,7 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
387
398
  return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
388
399
  }
389
400
  function copyUiTree(source, target, root) {
390
- for (const entry of ["app.js", "app.css"]) {
401
+ for (const entry of [existsSync(join(source, MODULE_UI_ENTRY)) ? MODULE_UI_ENTRY : "app.js", "app.css"]) {
391
402
  if (!existsSync(join(source, entry))) {
392
403
  throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `The UI directory is missing ${entry}: ${source}`);
393
404
  }
@@ -425,19 +436,3 @@ function copyUiTree(source, target, root) {
425
436
  };
426
437
  walk(source, target);
427
438
  }
428
- function digestTree(root) {
429
- const hash = createHash("sha256");
430
- const walk = (dir, prefix) => {
431
- for (const name of readdirSync(dir).sort()) {
432
- const path = join(dir, name);
433
- if (lstatSync(path).isDirectory()) {
434
- walk(path, `${prefix}${name}/`);
435
- }
436
- else {
437
- hash.update(`${prefix}${name}\0`).update(readFileSync(path));
438
- }
439
- }
440
- };
441
- walk(root, "");
442
- return hash.digest("hex");
443
- }
@@ -0,0 +1,3 @@
1
+ export declare const MODULE_UI_ENTRY = "app.module.js";
2
+ /** Include paths and bytes: moving a chunk changes its import URL too. */
3
+ export declare function uiTreeDigest(root: string, framed?: boolean): string;
@@ -0,0 +1,27 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ export const MODULE_UI_ENTRY = "app.module.js";
5
+ /** Include paths and bytes: moving a chunk changes its import URL too. */
6
+ export function uiTreeDigest(root, framed = false) {
7
+ const hash = createHash("sha256");
8
+ const walk = (dir, prefix) => {
9
+ for (const name of readdirSync(dir).sort()) {
10
+ const path = join(dir, name);
11
+ const stat = lstatSync(path);
12
+ if (stat.isDirectory())
13
+ walk(path, `${prefix}${name}/`);
14
+ else if (stat.isFile()) {
15
+ hash.update(`${prefix}${name}\0`);
16
+ // Module snapshots frame bytes; classic cache tokens keep their old digest.
17
+ if (framed)
18
+ hash.update(`${stat.size}\0`);
19
+ hash.update(readFileSync(path));
20
+ }
21
+ else
22
+ throw new Error("UI assets must be regular files and directories");
23
+ }
24
+ };
25
+ walk(root, "");
26
+ return hash.digest("hex");
27
+ }