@synopackageland/cli 0.33.1 → 0.34.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,9 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## 0.33.1 (2026-10-27)
3
+ ## 0.34.0 (2026-10-29)
4
4
 
5
- - Use one NAS connection path for host login, secure session storage, TLS and every DSM request, including package commands, deployment, ctl transfers and e2e login helpers
6
- - Accept self-signed, untrusted CA, hostname-mismatched and expired NAS certificates by default
7
- - Record each leaf SHA-256 for display; warn once and update it when a certificate changes, then continue
8
- - Add opt-in --strict-tls for host profiles and individual commands; --ca also verifies CA trust, hostname, validity and recorded certificates
9
- - Keep passwords on the exact connection whose certificate was inspected, without changing global TLS settings
5
+ - Enforce explicit App entry and reject undeclared App methods, dynamic route
6
+ names and custom HTTP dispatchers during check and build. Every App method
7
+ must appear in `permissions.json`, including administrator-default methods.
8
+ Register HTTP work through fixed SDK route constructors.
9
+ - Validate and bind `identity_gateway.upstream_auth_paths` for client API/share
10
+ paths that use upstream authentication. Other paths retain DSM entry admission;
11
+ SDK RPC methods retain their own permissions. Use SDK 0.32.0.
12
+ - Load `app.module.js` ES modules and lazy chunks from a content-addressed
13
+ snapshot of the entire frontend output. Module entries take precedence over
14
+ classic `app.js`; both require `app.css`. `inspect` verifies the complete tree.
15
+ Reload an open page after an upgrade if an old lazy chunk is unavailable.
16
+ - Use templates 0.32.0 with SDK/scaffold pins at 0.32.0.
17
+
18
+ ## 0.33.2 (2026-10-28)
19
+
20
+ - Load the installed app when a DSM window opens after a package upgrade without a page reload; the loader previously reused the app module the previous build left in the page
21
+ - Give DSM windows and full pages `ctx.uiUpdate.check()`, which reports when the installed package has replaced the UI the page loaded, so the SDK's AppShell can offer a reload after an upgrade
package/README.md CHANGED
@@ -53,6 +53,10 @@ A backend in Python, Node or Rust uses `backend: {runtime: python|node|rust, ...
53
53
  the SDK's Go runtime, which checks the DSM session and `permissions.json` and starts the App
54
54
  from `target/app` as the package account for each request (see `skills/reference/create.md`).
55
55
 
56
+ [App entry and registration](docs/app-entry.md) explains administrator-only and
57
+ Application Privileges admission, explicit method policies, gateway conflicts and
58
+ the build-bound SDK checks.
59
+
56
60
  ## SDK and templates
57
61
 
58
62
  The CLI packages, deploys and checks. The app-side code is the SDK
@@ -70,12 +74,16 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
70
74
  packages. Private mirrors can use explicitly configured Go authentication and
71
75
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
72
76
 
73
- CLI-only releases can differ from the SDK patch. Compose import and SDK defaults
77
+ CLI 0.34.0 uses templates 0.32.0 and SDK 0.32.0. Compose import and SDK defaults
74
78
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
79
+ Every App method must be declared in the permissions table. Dynamic names and
80
+ custom HTTP dispatchers are rejected; use fixed SDK route constructors and
81
+ `identity_gateway.upstream_auth_paths` for client API paths that use upstream
82
+ authentication. See [App entry and registration](docs/app-entry.md).
75
83
 
76
84
  `init` and `add` read their templates from the locally installed
77
- `@synopackageland/templates`. CLI 0.32.1 depends on `~0.31.0`: npm installation or
78
- update can select the latest templates patch in the 0.31 minor line. Templates
85
+ `@synopackageland/templates`. CLI 0.34.0 depends on `~0.32.0`: npm installation or
86
+ update can select the latest templates patch in the 0.32 minor line. Templates
79
87
  patches need only a templates release; update the npm installation to receive them.
80
88
  The CLI never fetches templates at runtime, so offline use works after installation;
81
89
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -94,6 +102,13 @@ templates checkout and `SYNOPKGLAND_SDK_DIR` to an SDK checkout; SPKs built with
94
102
  packs the local SDK into its frontend (`npm install --install-links`) without building it: run
95
103
  `npm ci && npm run build` in `$SYNOPKGLAND_SDK_DIR/ts` first, and again after SDK changes.
96
104
 
105
+ Frontend builds may emit `app.module.js` exporting `mount(root, ctx)`, plus
106
+ `app.css` and relative ES module chunks. The CLI packages that output under a
107
+ content-addressed UI directory, so entry, lazy chunks and CSS share one build
108
+ URL. `inspect` verifies that tree against the build inventory. Classic `app.js`
109
+ Apps keep their existing registration contract and paths. See the SDK's
110
+ [frontend bundle guide](https://github.com/synopackageland/syno-pkg-land-sdk/blob/main/docs/frontend-bundles.md).
111
+
97
112
  To check the CLI's DSM terminology rules against templates without a new templates release:
98
113
 
99
114
  ```sh
@@ -260,11 +275,11 @@ Use matching backend/frontend SDK pins. The profile participates in the
260
275
  normal executor approval digest; a profile change needs administrator approval.
261
276
  Users grant the named profile separately. The permanent denylist, current-user
262
277
  permission checks, bounded JSON and uncertain-outcome rules are in the SDK's
263
- executor/README.md. Binary downloads require later SDK support.
278
+ executor/README.md.
264
279
 
265
280
  ## Raw WebAPI profiles
266
281
 
267
- SDK 0.12.1 add opt-in raw responses. In `capabilities.webapi.<profile>`,
282
+ WebAPI profiles support opt-in raw responses. In `capabilities.webapi.<profile>`,
268
283
  set `raw_max_bytes: 26214400` for a 25 MiB MIME limit and explicitly allow
269
284
  `SYNO.MailClient.Message.get_original` v10. Omitting the limit preserves JSON-only
270
285
  profiles. Changing it changes the approved policy and requires fresh user consent.
@@ -274,6 +289,11 @@ package's private executor-staging folder with size/SHA-256 and immediate cleanu
274
289
  Do not use Attachment.download or follow X-Accel-Redirect. See the SDK raw response
275
290
  contract for the complete transport and CGI validation rules.
276
291
 
292
+ `webapi.call` request parameters are JSON-only; it has no request upload field.
293
+ Use streaming `files.write` for uploads, up to 100 MiB per file. Reads and URL
294
+ downloads retain their separate 64 MiB limit. Rebuild with SDK 0.32.0 and approve
295
+ the refreshed executor payload.
296
+
277
297
  ## App launch mode
278
298
 
279
299
  Native Apps declare `ui.launch: dsm-window | new-page` in `dsm-package.yaml`.
@@ -282,8 +302,10 @@ for every other frontend. Plain/binary scaffolds explicitly choose new-page; Vue
282
302
  native `ui.launch` resolves to `dsm-window`; the CLI does not guess a frontend's
283
303
  framework from its build command. `new-page` registers DSM `type: "standalone"`,
284
304
  so the main menu and Package Center open a full page on the DSM origin.
285
- `ctx.fullPage`, `ctx.appIcon`, session/SynoToken, launch parameters and entry
286
- permissions use the same wrapper in both modes. The wrapper sets the standalone
305
+ `ctx.fullPage`, `ctx.appIcon`, `ctx.uiUpdate`, session/SynoToken, launch parameters and entry
306
+ permissions use the same wrapper in both modes. `ctx.uiUpdate.check()` reports when
307
+ a package upgrade replaced the UI the page loaded; AppShell then shows
308
+ a one-line reload notice. The wrapper sets the standalone
287
309
  page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
288
310
  not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
289
311
 
@@ -0,0 +1,195 @@
1
+ // Command route-audit inspects registrations without running application code.
2
+ package main
3
+
4
+ import (
5
+ "encoding/json"
6
+ "fmt"
7
+ "go/ast"
8
+ "go/parser"
9
+ "go/token"
10
+ "os"
11
+ "path/filepath"
12
+ "strconv"
13
+ "strings"
14
+ )
15
+
16
+ type input struct {
17
+ Dir string
18
+ Permissions map[string]string
19
+ SDK map[string]map[string]string
20
+ }
21
+
22
+ type result struct {
23
+ Problems []string `json:"problems"`
24
+ EntryGate bool `json:"entryGate"`
25
+ }
26
+
27
+ func main() {
28
+ var in input
29
+ if err := json.NewDecoder(os.Stdin).Decode(&in); err != nil {
30
+ fmt.Fprintln(os.Stderr, err)
31
+ os.Exit(1)
32
+ }
33
+ out := result{Problems: []string{}}
34
+ sdk := map[string]bool{}
35
+ for _, group := range in.SDK {
36
+ for method := range group {
37
+ sdk[method] = true
38
+ }
39
+ }
40
+ set := token.NewFileSet()
41
+ err := filepath.WalkDir(in.Dir, func(path string, d os.DirEntry, err error) error {
42
+ if err != nil {
43
+ return err
44
+ }
45
+ if d.IsDir() {
46
+ if path != in.Dir && (strings.HasPrefix(d.Name(), ".") || d.Name() == "vendor" || d.Name() == "node_modules") {
47
+ return filepath.SkipDir
48
+ }
49
+ return nil
50
+ }
51
+ if d.Type()&os.ModeSymlink != 0 {
52
+ target, err := os.Stat(path)
53
+ if err == nil && target.IsDir() {
54
+ shown, _ := filepath.Rel(in.Dir, path)
55
+ out.Problems = append(out.Problems, filepath.ToSlash(shown)+":0: Go source directories cannot be symlinks")
56
+ return nil
57
+ }
58
+ }
59
+ if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
60
+ return nil
61
+ }
62
+ shown, _ := filepath.Rel(in.Dir, path)
63
+ problem := func(pos token.Pos, message string) {
64
+ out.Problems = append(out.Problems, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(shown), set.Position(pos).Line, message))
65
+ }
66
+ if d.Type()&os.ModeSymlink != 0 {
67
+ problem(token.NoPos, "Go source symlinks cannot be audited")
68
+ return nil
69
+ }
70
+ file, err := parser.ParseFile(set, path, nil, 0)
71
+ if err != nil {
72
+ return fmt.Errorf("%s: invalid Go source", shown)
73
+ }
74
+ imports := map[string]string{}
75
+ for _, spec := range file.Imports {
76
+ module, _ := strconv.Unquote(spec.Path.Value)
77
+ alias := filepath.Base(module)
78
+ if spec.Name != nil {
79
+ alias = spec.Name.Name
80
+ }
81
+ if alias == "." {
82
+ problem(spec.Pos(), "dot imports cannot be audited")
83
+ }
84
+ imports[alias] = module
85
+ }
86
+ constants := map[string]string{}
87
+ direct := map[*ast.SelectorExpr]bool{}
88
+ ast.Inspect(file, func(n ast.Node) bool {
89
+ if call, ok := n.(*ast.CallExpr); ok {
90
+ if selector, ok := call.Fun.(*ast.SelectorExpr); ok {
91
+ direct[selector] = true
92
+ }
93
+ }
94
+ return true
95
+ })
96
+ for _, decl := range file.Decls {
97
+ if g, ok := decl.(*ast.GenDecl); ok && g.Tok == token.CONST {
98
+ for _, spec := range g.Specs {
99
+ v := spec.(*ast.ValueSpec)
100
+ for i, name := range v.Names {
101
+ if i < len(v.Values) {
102
+ if lit, ok := v.Values[i].(*ast.BasicLit); ok && lit.Kind == token.STRING {
103
+ constants[name.Name], _ = strconv.Unquote(lit.Value)
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ ast.Inspect(file, func(n ast.Node) bool {
111
+ if fn, ok := n.(*ast.FuncDecl); ok && fn.Recv != nil && fn.Name.Name == "ServeHTTP" {
112
+ problem(fn.Pos(), "custom ServeHTTP bypasses the SDK registry; use api.HandleRequest, HandleResponse or HandleStream")
113
+ }
114
+ selector, ok := n.(*ast.SelectorExpr)
115
+ if !ok {
116
+ return true
117
+ }
118
+ pkg, ok := selector.X.(*ast.Ident)
119
+ if !ok {
120
+ return true
121
+ }
122
+ module := imports[pkg.Name]
123
+ name := selector.Sel.Name
124
+ if module == "github.com/synopackageland/syno-pkg-land-sdk/idgateway" && (name == "NewFront" || name == "Build" || name == "Run") {
125
+ problem(selector.Pos(), "gateway listeners must use identity_gateway in the manifest; upstream_auth_paths never exempts SDK RPC or custom HTTP handlers")
126
+ }
127
+ if module == "github.com/synopackageland/syno-pkg-land-sdk/api" {
128
+ switch name {
129
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
130
+ if !direct[selector] {
131
+ problem(selector.Pos(), "route constructor aliases cannot be audited; register methods directly")
132
+ }
133
+ }
134
+ }
135
+ if module == "net/http" {
136
+ switch name {
137
+ case "Handle", "HandleFunc", "HandlerFunc", "NewServeMux", "Server", "Serve", "ServeTLS", "ListenAndServe", "ListenAndServeTLS":
138
+ problem(selector.Pos(), "HTTP handler outside the SDK registry: "+name)
139
+ }
140
+ }
141
+ return true
142
+ })
143
+ ast.Inspect(file, func(n ast.Node) bool {
144
+ call, ok := n.(*ast.CallExpr)
145
+ if !ok {
146
+ return true
147
+ }
148
+ selector, ok := call.Fun.(*ast.SelectorExpr)
149
+ if !ok {
150
+ return true
151
+ }
152
+ pkg, ok := selector.X.(*ast.Ident)
153
+ if !ok {
154
+ return true
155
+ }
156
+ module := imports[pkg.Name]
157
+ if module == "github.com/synopackageland/syno-pkg-land-sdk/dsm" && selector.Sel.Name == "NewEntryGate" {
158
+ out.EntryGate = true
159
+ }
160
+ if module != "github.com/synopackageland/syno-pkg-land-sdk/api" {
161
+ return true
162
+ }
163
+ switch selector.Sel.Name {
164
+ case "Handle", "HandleRequest", "HandleResponse", "HandleStream", "HandleRequestBatch":
165
+ default:
166
+ return true
167
+ }
168
+ method := ""
169
+ if len(call.Args) > 0 {
170
+ switch arg := call.Args[0].(type) {
171
+ case *ast.BasicLit:
172
+ if arg.Kind == token.STRING {
173
+ method, _ = strconv.Unquote(arg.Value)
174
+ }
175
+ case *ast.Ident:
176
+ method = constants[arg.Name]
177
+ }
178
+ }
179
+ if method == "" {
180
+ problem(call.Pos(), "dynamic route name cannot be audited; register literal method names")
181
+ } else if _, declared := in.Permissions[method]; !declared && !sdk[method] {
182
+ problem(call.Pos(), "registered method missing from permissions.json: "+method)
183
+ }
184
+ return true
185
+ })
186
+ return nil
187
+ })
188
+ if err != nil {
189
+ fmt.Fprintln(os.Stderr, err)
190
+ os.Exit(1)
191
+ }
192
+ if err := json.NewEncoder(os.Stdout).Encode(out); err != nil {
193
+ os.Exit(1)
194
+ }
195
+ }
@@ -550,8 +550,8 @@ export const ERROR_REGISTRY = {
550
550
  },
551
551
  STANDALONE_UI_ENTRY_MISSING: {
552
552
  code: "STANDALONE_UI_ENTRY_MISSING",
553
- description: "The UI directory is missing app.js or app.css.",
554
- suggestion: "Make ui.dir (or the UI build output) contain app.js and app.css.",
553
+ description: "The UI directory is missing its JavaScript entry or app.css.",
554
+ suggestion: "Make ui.dir contain app.js (classic) or app.module.js (ES module), plus app.css.",
555
555
  },
556
556
  STANDALONE_UI_SYMLINK: {
557
557
  code: "STANDALONE_UI_SYMLINK",
@@ -828,6 +828,11 @@ export const ERROR_REGISTRY = {
828
828
  description: "The display name is invalid.",
829
829
  suggestion: "Use single-line text without quotes or backslashes.",
830
830
  },
831
+ STANDALONE_API_REGISTRY_INVALID: {
832
+ code: "STANDALONE_API_REGISTRY_INVALID",
833
+ description: "An App handler is outside the central permissions registry.",
834
+ suggestion: "Declare every App method in permissions.json and use SDK route constructors; remove custom HTTP dispatch.",
835
+ },
831
836
  STANDALONE_API_PERMISSIONS_CHANGED: {
832
837
  code: "STANDALONE_API_PERMISSIONS_CHANGED",
833
838
  description: "The API policy changed during the build.",
package/dist/init.js CHANGED
@@ -240,10 +240,14 @@ export function initFromTemplate(template, targetDir, options = {}) {
240
240
  }
241
241
  for (const file of capability.removed)
242
242
  rmSync(join(targetDir, file));
243
- if (Object.keys(capability.permissions).length &&
244
- isNativeTemplate(template)) {
243
+ if (isNativeTemplate(template)) {
245
244
  const path = join(targetDir, PERMISSIONS_FILE[template]);
246
245
  const policy = JSON.parse(readFileSync(path, "utf8"));
246
+ // Older template releases relied on the administrator default. Materialize
247
+ // their three example routes so the generated registry is explicit.
248
+ for (const method of ["app.status", "greeting.get", "greeting.set"]) {
249
+ policy.methods[method] ??= "administrators";
250
+ }
247
251
  Object.assign(policy.methods, capability.permissions);
248
252
  writeFileSync(path, JSON.stringify(policy, null, 2) + "\n");
249
253
  }
@@ -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 } 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);
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,8 @@ 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
+ ...(manifest.entry ? [`-X ${SDK_MODULE}/api.expectedEntryMode=${manifest.entry}`] : []),
248
256
  ...delegateBuildFlags(manifest),
249
257
  ...(manifest.executor
250
258
  ? [
@@ -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.
@@ -49,8 +49,10 @@ export declare function renderUiConfig(manifest: StandaloneManifest, icons?: Pac
49
49
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
50
50
  * full-page shell shows beside the name, where a DSM window has a title bar.
51
51
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
52
+ * `ctx.uiUpdate.check()` answers whether the installed package replaced this
53
+ * window's UI (the SDK's AppShell then offers a reload).
52
54
  * `ctx.launchParams` holds the App's own values from the launch that opened
53
55
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
54
56
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
55
57
  */
56
- 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.
@@ -213,11 +213,13 @@ export function renderUiConfig(manifest, icons = packageIcons(manifest.icon), lo
213
213
  * (and add a device-width viewport), and `ctx.appIcon` is the package icon a
214
214
  * full-page shell shows beside the name, where a DSM window has a title bar.
215
215
  * A portal App's `ctx.portal` shows `portalText`, the SDK's panel text.
216
+ * `ctx.uiUpdate.check()` answers whether the installed package replaced this
217
+ * window's UI (the SDK's AppShell then offers a reload).
216
218
  * `ctx.launchParams` holds the App's own values from the launch that opened
217
219
  * the window (a `launchParam` desktop URL or `SYNO.SDS.AppLaunch(id, param)`),
218
220
  * and `ctx.onLaunch(fn)` hands fn those of each later launch with values.
219
221
  */
220
- export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText) {
222
+ export function renderWindowScript(manifest, assetVersion, icons = packageIcons(manifest.icon), portalText, moduleVersion) {
221
223
  const pkg = manifest.package;
222
224
  const ns = `SYNO.SDS.${pkg}`;
223
225
  const p = JSON.stringify(pkg);
@@ -394,22 +396,62 @@ ${ns}.sessionUser = function () {
394
396
  return { name: typeof session.user === "string" ? session.user : "", isAdmin: session.is_admin === true };
395
397
  };
396
398
 
397
- ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}${ns}.loadApp = function (done) {
399
+ ${root ? root.definitions : ""}${manifest.portal ? renderPortalDefinition(ns, portalText) : ""}// DSM loads the installed build's window script when a window opens after a
400
+ // package upgrade, but keeps the page and the app module the previous build
401
+ // registered. Reuse it only when this build loaded it; an older loader records
402
+ // no build, so its app is replaced as well.
403
+ ${ns}.loadApp = function (done) {
398
404
  var apps = window.DsmPackageApps || {};
399
- if (apps[${p}]) {
405
+ var versions = window.DsmPackageAppVersions = window.DsmPackageAppVersions || {};
406
+ if (apps[${p}] && versions[${p}] === "${assetVersion}") {
400
407
  done(apps[${p}]);
401
408
  return;
402
409
  }
403
- 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}/` : ""}";
404
414
  var css = document.createElement("link");
405
415
  css.rel = "stylesheet";
406
416
  css.href = base + "app.css?v=${assetVersion}";
407
417
  document.head.appendChild(css);
408
- 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");
409
431
  js.src = base + "app.js?v=${assetVersion}";
410
432
  js.dataset.package = ${p};
411
- js.onload = function () { done((window.DsmPackageApps || {})[${p}]); };
433
+ js.onload = function () { versions[${p}] = "${assetVersion}"; done((window.DsmPackageApps || {})[${p}]); };
412
434
  document.head.appendChild(js);
435
+ `}
436
+ };
437
+
438
+ // DSM keeps a loaded window script after a package upgrade until the page
439
+ // reloads, so a window opened again runs the old UI. ui/config names the
440
+ // installed build's window file: once it no longer names this one, the
441
+ // installed UI replaced this page's. no-store, not no-cache: DSM tags
442
+ // ui/config by size alone, so a revalidation can keep an older build's copy.
443
+ // An uninstalled package or a failed read answers false.
444
+ ${ns}.uiUpdate = {
445
+ check: function (signal) {
446
+ if (typeof fetch !== "function") return Promise.resolve(false);
447
+ return fetch("webman/3rdparty/${pkg}/config", { cache: "no-store", credentials: "same-origin", signal: signal })
448
+ .then(function (response) { return response.ok ? response.json() : null; })
449
+ .then(function (config) {
450
+ return !!config && typeof config === "object" && !Array.isArray(config) &&
451
+ !Object.prototype.hasOwnProperty.call(config, ${JSON.stringify(windowScriptName(manifest))});
452
+ })
453
+ .catch(function () { return false; });
454
+ }
413
455
  };
414
456
 
415
457
  // A Portal/standalone launch is the whole page, on a phone as well: no desktop
@@ -478,10 +520,11 @@ Ext.define(${JSON.stringify(`${ns}.MainWindow`)}, {
478
520
  var ctx = { requestTimeout: ${requestTimeout} };
479
521
  var call = function (method, params, options) { return ${ns}.call(method, params, options, ctx.requestTimeout); };
480
522
  var batch = ${batchRuntime ? `function (calls, options) { return ${ns}.batch(calls, options, ctx.requestTimeout); }` : "undefined"};
481
- ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
523
+ ${root ? root.mount : ""} ${ns}.loadApp(function (app, error) {
482
524
  if (me.mountToken !== mountToken) return;
483
525
  me.mountPending = false;
484
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; }
485
528
  if (app && root && !me.mounted) {
486
529
  var i18n = ${ns}.createI18n(lang, ${ns}.messages);
487
530
  me.hostAccess = ${ns}.createHost({ appWindow: me, locale: i18n.locale });
@@ -501,6 +544,7 @@ ${root ? root.mount : ""} ${ns}.loadApp(function (app) {
501
544
  appWindow: me,
502
545
  fullPage: ${ns}.fullPage(),
503
546
  appIcon: ${ns}.appIcon,
547
+ uiUpdate: ${ns}.uiUpdate,
504
548
  lang: lang,
505
549
  locale: i18n.locale,
506
550
  t: i18n.t${root ? `,\n executor: access` : ""}
@@ -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,5 @@
1
1
  import { readInstallSettings } from './install-settings.js';
2
+ import { auditGoRoutes } from './route-audit.js';
2
3
  import { isWebLaunchMode } from "../contract/web-launch.js";
3
4
  import { validWebAPIProfiles, } from "../runtime/webapi-profile.js";
4
5
  import { validateMetadata } from "../package-metadata.js";
@@ -24,7 +25,7 @@ import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration, }
24
25
  import { renderPackageIcons } from "../build-spk/dsm-icons.js";
25
26
  import { LOGIN_COLOR, loginImageProblem, } from "../build-spk/login-style.js";
26
27
  import { validateReproducible } from "../reproducible-config.js";
27
- import { appSdkVersion, SDK_MODULE, } from "../runtime/sdk-source.js";
28
+ import { appSdkVersion, SDK_MODULE, sdkSource, } from "../runtime/sdk-source.js";
28
29
  import { sdkProblems } from "./sdk-check.js";
29
30
  import { readWindowSize } from "./window-size.js";
30
31
  import { appSdkTimeouts } from "../runtime/sdk-timeouts.js";
@@ -337,6 +338,32 @@ function readManifest(raw, c) {
337
338
  (raw.entry === "admin-only" || raw.entry === "app-privilege")
338
339
  ? raw.entry
339
340
  : readEntry(raw.entry, backend, permissions?.policy, c);
341
+ if (entry && identityGateway && (identityGateway.access === "public" || identityGateway.upstream_access === "public" ||
342
+ !identityGateway.upstream_auth_paths?.length &&
343
+ (identityGateway.passthrough.length || Object.keys(identityGateway.optional_passthrough ?? {}).length)))
344
+ c.fail("STANDALONE_ENTRY_WIDENED", "App entry requires protected gateway access and passthrough contained in explicit upstream_auth_paths.");
345
+ if (entry && raw.compose !== undefined && identityGateway) {
346
+ try {
347
+ if (!readFileSync(join(sdkSource(identityGateway.sdk).goDir, "api/server.go"), "utf8").includes("expectedEntryMode")) {
348
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "Update the SDK: App entry requires the build-bound entry floor and gateway admission.");
349
+ }
350
+ }
351
+ catch {
352
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "The SDK entry floor source is unavailable; update the SDK and retry.");
353
+ }
354
+ }
355
+ if (backend?.kind === "go" && permissions && sdkVersionOrUndefined(backend) && (raw.entry === undefined || entry)) {
356
+ try {
357
+ const audit = auditGoRoutes(backend.dir, permissions.policy, permissions.sdk);
358
+ for (const problem of audit.problems)
359
+ c.fail("STANDALONE_API_REGISTRY_INVALID", problem);
360
+ if (entry && !audit.entryGate)
361
+ c.fail("STANDALONE_ENTRY_UNSUPPORTED", "App entry requires an actual dsm.NewEntryGate call; comments do not enforce admission.");
362
+ }
363
+ catch (error) {
364
+ c.fail("STANDALONE_API_REGISTRY_INVALID", error.message);
365
+ }
366
+ }
340
367
  if (entry === "admin-only" && permissions) {
341
368
  const floored = floorAtAdministrators(permissions.policy, permissions.sdk);
342
369
  if (floored.widened.length) {
@@ -498,6 +525,9 @@ export function bridgeMethodProblems(methods, permissions, file) {
498
525
  problems.push(`App methods must not use an SDK method name: ${taken.join(", ")}.`);
499
526
  if (!methods)
500
527
  return problems;
528
+ const missing = methods.filter(method => !sdk.has(method) && !Object.hasOwn(permissions.policy.methods, method));
529
+ if (missing.length)
530
+ problems.push(`${file} must declare every registered App method: ${missing.join(", ")}.`);
501
531
  const unlisted = Object.keys(permissions.policy.methods).filter((method) => !sdk.has(method) && !methods.includes(method));
502
532
  if (unlisted.length)
503
533
  problems.push(`${file} sets methods the App does not have: ${unlisted.join(", ")}; the runtime refuses to start.`);
@@ -645,6 +675,17 @@ function readEntry(value, backend, policy, c) {
645
675
  const shown = main ? relative(c.root, main) : "the backend.main main.go";
646
676
  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
677
  }
678
+ const version = sdkVersionOrUndefined(backend);
679
+ if (version) {
680
+ try {
681
+ if (!readFileSync(join(sdkSource(version).goDir, "api/server.go"), "utf8").includes("expectedEntryMode")) {
682
+ return c.fail("STANDALONE_ENTRY_UNSUPPORTED", "Update the SDK: App entry requires the build-bound entry floor in api.Server.");
683
+ }
684
+ }
685
+ catch {
686
+ return c.fail("STANDALONE_ENTRY_UNSUPPORTED", "The SDK entry floor source is unavailable; update the SDK and retry.");
687
+ }
688
+ }
648
689
  if (value === "app-privilege" && !permitsOrdinaryUsers(policy)) {
649
690
  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
691
  }
@@ -0,0 +1,6 @@
1
+ import type { ApiPermissions } from './api-permissions.js';
2
+ /** Parse Go syntax without executing an App's main, init functions or --describe. */
3
+ export declare function auditGoRoutes(dir: string, policy: ApiPermissions, sdk: Record<string, Record<string, string>>): {
4
+ problems: string[];
5
+ entryGate: boolean;
6
+ };
@@ -0,0 +1,25 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { goChildEnv } from '../runtime/go-env.js';
7
+ let binary;
8
+ /** Parse Go syntax without executing an App's main, init functions or --describe. */
9
+ export function auditGoRoutes(dir, policy, sdk) {
10
+ const env = goChildEnv({ ...process.env, GOOS: undefined, GOARCH: undefined, GOARM: undefined, GOWORK: 'off', CGO_ENABLED: '0' });
11
+ if (!binary) {
12
+ const work = mkdtempSync(join(tmpdir(), 'synopkgland-route-audit-'));
13
+ process.once('exit', () => rmSync(work, { recursive: true, force: true }));
14
+ const out = join(work, process.platform === 'win32' ? 'route-audit.exe' : 'route-audit');
15
+ const source = fileURLToPath(new URL('../../assets/go/route-audit/main.go', import.meta.url));
16
+ const build = spawnSync('go', ['build', '-trimpath', '-o', out, source], { env: goChildEnv(env), encoding: 'utf8', timeout: 120_000 });
17
+ if (build.error || build.status !== 0)
18
+ throw new Error('Cannot build the Go route auditor; install Go and retry.');
19
+ binary = out;
20
+ }
21
+ const result = spawnSync(binary, [], { env, input: JSON.stringify({ Dir: realpathSync(dir), Permissions: policy.methods, SDK: sdk }), encoding: 'utf8', timeout: 30_000, maxBuffer: 4 << 20 });
22
+ if (result.error || result.status !== 0)
23
+ throw new Error(`Cannot audit Go routes: ${result.stderr?.trim() || 'auditor unavailable'}`);
24
+ return JSON.parse(result.stdout);
25
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "synopkgland": "dist/bin.js"
7
+ "synopkgland": "./dist/bin.js"
8
8
  },
9
9
  "engines": {
10
10
  "node": ">=22.12"
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "~0.31.0",
30
+ "@synopackageland/templates": "~0.32.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
package/skills/SKILL.md CHANGED
@@ -24,6 +24,13 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
24
24
 
25
25
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
26
26
 
27
+ Use SDK 0.32.0 and CLI 0.34.0 for App entry enforcement. Declare every App method
28
+ in the permissions table; dynamic names and custom HTTP dispatchers are rejected.
29
+ Use fixed SDK routes and `identity_gateway.upstream_auth_paths` for client API
30
+ paths that rely on upstream authentication. Read
31
+ [reference/identity.md](reference/identity.md) and
32
+ [reference/identity-gateway.md](reference/identity-gateway.md) before migration.
33
+
27
34
  **If you package someone else's open-source project, set `maintainer` to the
28
35
  original project/owner (prefer the project's own team/org name, else the owner's
29
36
  display name, else login), `metadata.maintainer_url` to the upstream repository,
@@ -184,7 +191,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
184
191
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
185
192
 
186
193
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
187
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
194
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
188
195
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
189
196
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
190
197
 
@@ -56,7 +56,7 @@ backend: {runtime: rust, dir: app, entry: hello, methods: [hello.greet]}
56
56
  - Python:DSM 的 `/usr/bin/python3`;SDK 放進 `app/vendor/synopkgland_app/`,`PYTHONPATH=vendor`。
57
57
  - Node:Package Center 的 Node.js_v18/v20/v22 成為 INFO `install_dep_packages`;SDK 的 `package.json` 與 `dist/` 放進 `node_modules/@synopackageland/app`,其他 dependencies 先在 App 目錄 `npm ci --omit=dev`。
58
58
  - Rust:Cargo.toml 以 git+tag 相依 `syno-pkg-land-app`;`cargo zigbuild` 交叉編譯(x86_64/aarch64 musl、armv7 glibc),`panic = "unwind"`;binary 在 `app/bin/<entry>`。需要 rustup 的 cargo、三個 target、cargo-zigbuild 與 zig。
59
- - methods:沒寫時 build 在建置機執行 `<entry> --describe`;Rust 必須寫。`permissions.json` 只能設定 methods 內的 method。
59
+ - methods:沒寫時 build 在建置機執行 `<entry> --describe`;Rust 必須寫。每個 App method 都必須明列於 `permissions.json`,且表內只能設定 methods 內的 method。動態名稱與自訂 HTTP dispatcher 會被拒絕;請使用固定 SDK routes。
60
60
  - capabilities:`files`、`identity`、`jobs`、`rootOps`、`delegate`(僅 `until-revoked`+`files.moveFromStaging`+destinations)與 `portal`;daemon 需要 Go。jobs 或 delegate 時,同一個 runtime 另裝為 `target/background/worker`,DSM 以套件帳號執行 `worker --worker`;rootOps、delegate 附 executor,`bridge.json` 的 delegate digest 必須等於 SPK 的 executor。
61
61
  - transfers:`backend.transfers: {<method>: upload|download}`(method 必須在 methods 內),`backend.max_upload_bytes` 上限 16 GiB。
62
62
 
@@ -231,7 +231,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
231
231
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
232
232
 
233
233
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
234
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
234
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
235
235
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
236
236
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
237
237
 
@@ -2,6 +2,11 @@
2
2
 
3
3
  Native-go 的 B 能力使用 `init --with files` 或 `add files`,對應 schema 2 `capabilities.files: session`。File tree 與共用 NasPicker 以 TreeView/DataGrid 顯示本人可見內容;一般選取不建立授權;delegate destination 選取例外,確認即授權。FilePicker 選瀏覽器本機檔案。上傳/下載使用 streaming transport,不把內容放進 JSON。每次以呼叫者有效 DSM session 由 File Station 判權,不需管理員進行能力設定。
4
4
 
5
+ SDK 0.32.0 的 delegated `files.write` 逐檔上限為 100 MiB;`files.read` 與
6
+ URL 下載另維持 64 MiB 上限。`webapi.call` 的 request params 只接受 JSON,
7
+ 不提供 request upload;檔案上傳使用串流 `files.write`。更新後重建套件,
8
+ 並重新核准變更的 executor payload。
9
+
5
10
  只有 App session 沒有有效 DSM cookie 時回 `dsm_session_required`,須在同 hostname 登入 DSM。不能以套件帳號或 root 作後備存取。
6
11
 
7
12
  A 能力 `--with jobs` 使用套件帳號處理已匯入的私有副本,依可信 username/UID 綁定;一般使用者不需 admin 設定即可提交、查詢與取消自己的工作。關閉瀏覽器後繼續,不保存 cookie/SID/密碼。匯入需要本人在線 session;私有副本不是離線讀取原檔案的權限。Jobs 與 daemon 互斥。
@@ -1,5 +1,25 @@
1
1
  # Wrap an OSS app with DSM accounts
2
2
 
3
+ ## Upstream-authenticated paths
4
+
5
+ Build with SDK 0.32.0 and CLI 0.34.0. Use `upstream_auth_paths` for client API
6
+ paths that use upstream authentication.
7
+
8
+ Keep browser routes behind `entry: admin-only` or `entry: app-privilege`; list
9
+ only upstream API/share prefixes in `identity_gateway.upstream_auth_paths`, for
10
+ example `[/rest, /share]`. Prefixes are case-sensitive and segment-bounded;
11
+ `/rest` does not match `/restore`. Use 1-16 nonoverlapping absolute prefixes with
12
+ URL unreserved characters and `/`, no root, dot segments, repeated slashes,
13
+ percent escapes, wildcards or reserved `/_sso` / `/_spl` paths.
14
+
15
+ Permanent/optional passthrough must be contained in those prefixes. Requests
16
+ preserve upstream credentials but lose DSM/SDK cookies and trusted identity
17
+ headers. Upstream auth owns authentication, CSRF, authorization and WebSockets;
18
+ public upstream share links remain public. Other paths keep the DSM entry gate.
19
+ SDK RPC methods cannot use this exception. The CLI checks matching SDK capability
20
+ and links the gateway policy digest; editing the static paths requires rebuilding.
21
+ See `docs/app-entry.md` for the complete contract.
22
+
3
23
  ## External access
4
24
 
5
25
  Use SDK 0.30.0 or later with matching gateway sources. Add this to the existing
@@ -2,6 +2,14 @@
2
2
 
3
3
  Native-go validates the caller's DSM session per package. Business route permissions come from backend/internal/api/permissions.json; SDK-owned route policy is protected separately. Capabilities describe execution/resources and do not grant business route access. Never trust a request username as owner.
4
4
 
5
+ With SDK 0.32.0 and CLI 0.34.0, every App method must appear in the permissions
6
+ table, including administrator-default methods. Dynamic names and custom HTTP
7
+ dispatchers are rejected. Use fixed SDK route constructors and declare
8
+ `identity_gateway.upstream_auth_paths` for client API paths authenticated by the
9
+ upstream. These paths never exempt SDK RPC methods from their own permissions.
10
+ Read [App entry and registration](https://github.com/synopackageland/syno-pkg-land-cli/blob/main/docs/app-entry.md)
11
+ for migration and build binding.
12
+
5
13
  Entry: omit `entry` for legacy access; `entry: admin-only` admits administrators only and is the floor of every route: SDK routes a capability adds (files, jobs, portal, delegate, rootOps) refuse ordinary users with `admin_required`, the launcher is hidden, and an app route marked `authenticated` or `public` in permissions.json fails validate/build with `STANDALONE_ENTRY_WIDENED` (only the public `identity: standalone` login routes stay public; they admit no one by themselves). The SPK records the mode in `sdk-package.json`; `inspect` prints it and flags `ENTRY_LAUNCHER_MISMATCH`; `entry: app-privilege` also admits accounts DSM Application Privileges allows for `SYNO.SDS.<package>.Instance` (native-go only, needs an `authenticated` route). DSM evaluates user/group/everyone rules, allow/deny precedence and IP ranges; one CGI check covers every route, failures refuse with `app_privilege_denied`, and decisions are cached at most 30 seconds. DSM's install default allows administrators only, so declaring or upgrading never opens the app to everyone. DSM keeps those rules after uninstall and applies them again to a later install under the same name: an App with the executor (rootOps or delegate) removes them automatically at uninstall (failures go to the system log entry `synopkgland-executor`); a no-privilege App never uses root, so an administrator removes them in Control Panel > Application Privileges. The delegate executor applies the same entry rule to every protected and producer move: once DSM no longer allows a user, new moves under that user's still-valid grant are refused (`APP_DENIED`, grant invalidated); files already saved stay. Guard global settings in shared routes with Go `api.RequireAdmin(caller)` (`admin_required`); the frontend `ctx.user?.isAdmin` only hides controls.
6
14
 
7
15
  I capability: init --with login or add login enables Go-native standalone identity and Vue login. Same-host DSM cookies can be reused across ports after backend verification. Missing cookies use an HTTPS DSM account/password/OTP fallback; passwords and DSM credentials are not persisted. Plain UI is unsupported. App sessions last at most 15 minutes; sensitive operations revalidate live DSM cookies and local UID. Password-only sessions permit only basic app.status/greeting.get reads. Missing revalidation returns session_revalidation_required; files return dsm_session_required. Sign in to DSM on the same hostname. Web Station portal/proxy acceptance remains pending.
@@ -49,7 +49,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
49
49
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
50
50
 
51
51
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
52
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
52
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
53
53
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
54
54
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
55
55
 
@@ -173,7 +173,7 @@ Scaffold 明確寫入此選擇,plain/binary 使用 `new-page`,Vue UI SDK
173
173
  預設的 `dsm-window`(省略 `ui.launch`)。原生 manifest 省略 `ui.launch` 時解析為 `dsm-window`,CLI 不猜測前端依賴。
174
174
 
175
175
  `new-page` 產生 DSM `type: "standalone"` launcher,主選單及套件中心皆在 DSM
176
- 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、SynoToken/session、
176
+ 本身的 origin 開啟全頁。`ctx.fullPage`、`ctx.appIcon`、`ctx.uiUpdate`、SynoToken/session、
177
177
  `ctx.launchParams`/`onLaunch`、`entry` 權限沿用共用 wrapper;全頁的標題、
178
178
  favicon 與手機 viewport 由 wrapper 設定。不需要 `pureEnv` 或 `allowAltPort`。
179
179