@synopackageland/cli 0.33.2 → 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 +15 -0
- package/README.md +26 -6
- package/assets/go/route-audit/main.go +195 -0
- package/dist/error-registry.js +7 -2
- package/dist/init.js +6 -2
- package/dist/standalone/bridge-runtime.js +2 -0
- package/dist/standalone/build.js +8 -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 +42 -1
- package/dist/standalone/route-audit.d.ts +6 -0
- package/dist/standalone/route-audit.js +25 -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 +7 -0
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.34.0 (2026-10-29)
|
|
4
|
+
|
|
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
|
+
|
|
3
18
|
## 0.33.2 (2026-10-28)
|
|
4
19
|
|
|
5
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
|
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
|
|
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.
|
|
78
|
-
update can select the latest templates patch in the 0.
|
|
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.
|
|
278
|
+
executor/README.md.
|
|
264
279
|
|
|
265
280
|
## Raw WebAPI profiles
|
|
266
281
|
|
|
267
|
-
|
|
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`.
|
|
@@ -284,7 +304,7 @@ 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
305
|
`ctx.fullPage`, `ctx.appIcon`, `ctx.uiUpdate`, session/SynoToken, launch parameters and entry
|
|
286
306
|
permissions use the same wrapper in both modes. `ctx.uiUpdate.check()` reports when
|
|
287
|
-
a package upgrade replaced the UI the page loaded;
|
|
307
|
+
a package upgrade replaced the UI the page loaded; AppShell then shows
|
|
288
308
|
a one-line reload notice. The wrapper sets the standalone
|
|
289
309
|
page title, favicon and device-width viewport. It retains ExtJS; `pureEnv` is
|
|
290
310
|
not appropriate, and alternate Portal ports are not needed (`allowAltPort` is omitted).
|
|
@@ -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
|
+
}
|
package/dist/error-registry.js
CHANGED
|
@@ -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
|
|
554
|
-
suggestion: "Make ui.dir
|
|
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 (
|
|
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,
|
package/dist/standalone/build.js
CHANGED
|
@@ -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
|
|
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,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
|
+
}
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@napi-rs/keyring": "^2.1.1",
|
|
30
|
-
"@synopackageland/templates": "~0.
|
|
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,
|
|
@@ -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
|
|
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
|
|
|
@@ -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.
|