@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.
- package/CHANGELOG.md +26 -0
- package/README.md +29 -6
- package/assets/go/route-audit/main.go +526 -0
- package/dist/error-registry.js +7 -2
- package/dist/init.js +6 -2
- package/dist/standalone/api-description.js +3 -0
- package/dist/standalone/bridge-runtime.js +2 -0
- package/dist/standalone/build.js +19 -0
- package/dist/standalone/dsm-window.d.ts +3 -3
- package/dist/standalone/dsm-window.js +23 -6
- package/dist/standalone/identity-gateway.d.ts +14 -0
- package/dist/standalone/identity-gateway.js +55 -3
- package/dist/standalone/inspect-spk.js +29 -1
- package/dist/standalone/manifest.js +50 -1
- package/dist/standalone/route-audit.d.ts +12 -0
- package/dist/standalone/route-audit.js +29 -0
- package/dist/standalone/spk.js +15 -20
- package/dist/standalone/ui-assets.d.ts +3 -0
- package/dist/standalone/ui-assets.js +27 -0
- package/package.json +2 -2
- package/skills/SKILL.md +18 -1
- package/skills/reference/create.md +1 -1
- package/skills/reference/files.md +5 -0
- package/skills/reference/identity-gateway.md +20 -0
- package/skills/reference/identity.md +8 -0
|
@@ -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,
|
package/dist/standalone/build.js
CHANGED
|
@@ -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
|
|
43
|
-
* app
|
|
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
|
|
207
|
-
* app
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
||
|
|
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
|
+
}
|
package/dist/standalone/spk.js
CHANGED
|
@@ -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
|
|
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,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
|
+
}
|