@synopackageland/templates 0.9.0 → 0.9.2

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.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/templates/_shared/AGENTS.md +13 -4
  4. package/templates/native-go/README.md +5 -1
  5. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  6. package/templates/native-go/backend/go.mod +1 -1
  7. package/templates/native-go/backend/go.sum +2 -2
  8. package/templates/native-go/backend/internal/app/delegate_config.go +1 -1
  9. package/templates/native-go/backend/internal/app/delegate_config_test.go +8 -0
  10. package/templates/native-go/backend/internal/app/routes_test.go +13 -3
  11. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +2 -1
  12. package/templates/native-go-root-ops/backend/internal/app/executor.go +29 -41
  13. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +130 -11
  14. package/templates/native-go-vue/README.md +2 -2
  15. package/templates/native-go-vue/frontend/DESIGN.md +1 -0
  16. package/templates/native-go-vue/frontend/package-lock.json +12 -12
  17. package/templates/native-go-vue/frontend/package.json +2 -2
  18. package/templates/native-go-vue/frontend/src/App.vue +16 -9
  19. package/templates/native-go-vue/frontend/src/i18n/de.json +0 -1
  20. package/templates/native-go-vue/frontend/src/i18n/en.json +0 -1
  21. package/templates/native-go-vue/frontend/src/i18n/es.json +0 -1
  22. package/templates/native-go-vue/frontend/src/i18n/fr.json +0 -1
  23. package/templates/native-go-vue/frontend/src/i18n/it.json +0 -1
  24. package/templates/native-go-vue/frontend/src/i18n/ja.json +0 -1
  25. package/templates/native-go-vue/frontend/src/i18n/ko.json +0 -1
  26. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +0 -1
  27. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +0 -1
  28. package/templates/native-go-vue/frontend/test/apps.test.ts +37 -0
package/README.md CHANGED
@@ -21,7 +21,7 @@ An overlay may replace `internal/app/*_none.go` (the CLI deletes the matching
21
21
  The placeholders `__PACKAGE__` (also the Go module path), `__DISPLAY_NAME__` and
22
22
  `__BACKGROUND_ENABLED__` are substituted by `init`.
23
23
 
24
- `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.0`; that line is
24
+ `backend/go.mod` requires `github.com/synopackageland/syno-pkg-land-sdk v0.9.3`; that line is
25
25
  the single source of the SDK version and `frontend/package.json` pins
26
26
  `@synopackageland/sdk` to the same version.
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/templates",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Project templates for synopkgland init: native-go (plain or Vue, with jobs, delegate and root-ops overlays), binary and compose.",
5
5
  "license": "MIT",
6
6
  "repository": {"type": "git", "url": "git+https://github.com/synopackageland/syno-pkg-land-templates.git"},
@@ -11,6 +11,12 @@ For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
11
11
  Read its prompt and exported Vue docs/types before choosing component APIs.
12
12
  Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
13
13
 
14
+ Open the App at once: render its shell immediately and run session, permission
15
+ and status checks in the background, never a full-window "Checking…" placeholder.
16
+ Only a confirmed refusal (signed out, expired session, not an administrator)
17
+ shows the sign-in page or a `@synopackageland/ui` Dialog; loading text inside a
18
+ section is fine.
19
+
14
20
  From the project root, run `synopkgland check --json`,
15
21
  `synopkgland build --json`, then `synopkgland inspect <spk> --json` on the emitted SPK.
16
22
  For Vue, first run `npm ci`, `npm test` and `npm run build` in frontend.
@@ -32,10 +38,13 @@ call `greeting.set`.
32
38
 
33
39
  ## DSM Application Portal (`portal`)
34
40
 
35
- `synopkgland add portal` (or `portal: {}` / `portal: {alias: my-app}` in
36
- dsm-package.yaml; Compose: `integration.web.portal` for a DSM-window app behind
37
- the auth gateway) registers `https://<nas>/<alias>/`, which opens the app after
38
- DSM sign-in. Read `synopkgland skill show portal` before changing it.
41
+ Only an App with the root executor (`rootOps` or `delegate`) may declare a
42
+ portal: its uninstall then reloads nginx so the alias stops answering, which a
43
+ no-privilege App cannot do without root. `synopkgland add portal` (with
44
+ `root-ops` or `delegate` in the same call or already declared; or `portal: {}` /
45
+ `portal: {alias: my-app}` in dsm-package.yaml) registers
46
+ `https://<nas>/<alias>/`, which opens the app after DSM sign-in. Compose Apps
47
+ cannot declare a portal. Read `synopkgland skill show portal` before changing it.
39
48
  The install wizard only proposes the alias; an administrator activates it from
40
49
  the app's portal panel (`ctx.portal`, DSM's own `SYNO.Core.AppPortal` in the
41
50
  admin's session) or Control Panel > Login Portal > Applications. Ordinary users
@@ -297,7 +297,11 @@ refusal for unknown methods.
297
297
 
298
298
  ## DSM Application Portal link
299
299
 
300
- `synopkgland add portal` adds `portal: {}` to dsm-package.yaml so
300
+ Only an App with the root executor (`rootOps` or `delegate`) can declare a
301
+ portal; `synopkgland add root-ops portal` declares both. Uninstall then
302
+ removes the App's Application Privileges rules and reloads nginx once DSM has
303
+ removed the alias, so it stops answering. `synopkgland add portal` adds
304
+ `portal: {}` to dsm-package.yaml so
301
305
  `https://<nas>/<alias>/` opens this app right after DSM sign-in. The install
302
306
  wizard proposes the alias (the package name in lowercase by default); an
303
307
  administrator turns it on from the app's portal panel or Control Panel >
@@ -1,6 +1,6 @@
1
1
  Native Go package build dependencies
2
2
 
3
- github.com/synopackageland/syno-pkg-land-sdk v0.9.0 (MIT)
3
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.3 (MIT)
4
4
 
5
5
  Copyright (c) 2026 synopkgland
6
6
 
@@ -4,6 +4,6 @@ go 1.25.0
4
4
 
5
5
  toolchain go1.26.9
6
6
 
7
- require github.com/synopackageland/syno-pkg-land-sdk v0.9.0
7
+ require github.com/synopackageland/syno-pkg-land-sdk v0.9.3
8
8
 
9
9
  require golang.org/x/sys v0.44.0 // indirect
@@ -1,4 +1,4 @@
1
- github.com/synopackageland/syno-pkg-land-sdk v0.9.0 h1:dAQcppyVC14P1mXhixgo5905suPf02cDXqMOq6J8AR0=
2
- github.com/synopackageland/syno-pkg-land-sdk v0.9.0/go.mod h1:JNoqISvR4biA9IuLi0CFe4c272tsKzCN9Nyj71nrXsQ=
1
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.3 h1:lr0OREzdTKGHLoIgN4nCL1PdXriD756AuaDCpUXvdNw=
2
+ github.com/synopackageland/syno-pkg-land-sdk v0.9.3/go.mod h1:JNoqISvR4biA9IuLi0CFe4c272tsKzCN9Nyj71nrXsQ=
3
3
  golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
4
4
  golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -32,7 +32,7 @@ func ParseDelegateConfig(cfg Config) (DelegateSettings, error) {
32
32
  seen := map[string]bool{}
33
33
  for _, op := range out.Operations {
34
34
  switch op {
35
- case "files.list", "files.info", "files.createFolder", "files.checkPermission", "files.write":
35
+ case "files.list", "files.info", "files.createFolder", "files.checkPermission", "files.write", "files.read", "files.rename", "files.copy", "files.move", "files.delete":
36
36
  default:
37
37
  return DelegateSettings{}, invalid
38
38
  }
@@ -26,3 +26,11 @@ func TestDelegateBuildConfigStrictlySeparatesLegacyAndPermanent(t *testing.T) {
26
26
  }
27
27
  }
28
28
  }
29
+
30
+ func TestDelegateBuildConfigAcceptsEveryTimeLimitedFileOperation(t *testing.T) {
31
+ ops := `["files.checkPermission","files.copy","files.createFolder","files.delete","files.info","files.list","files.move","files.read","files.rename","files.write"]`
32
+ settings, err := ParseDelegateConfig(Config{DelegateScope: "shared:media", DelegateOperationsJSON: ops, DelegateDestinationsJSON: `[]`})
33
+ if err != nil || len(settings.Operations) != 10 || settings.Lifetime != "" {
34
+ t.Fatal(settings, err)
35
+ }
36
+ }
@@ -60,13 +60,23 @@ func call(t *testing.T, cfg Config, auth api.Authenticator, method, params strin
60
60
  if len(permissionJSON) != 0 {
61
61
  permissions = permissionJSON[0]
62
62
  }
63
+ return serve(t, cfg, auth, apiRequest(method, params), permissions)
64
+ }
65
+
66
+ func apiRequest(method, params string) *http.Request {
67
+ body := `{"method":"` + method + `","params":` + params + `}`
68
+ req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(body))
69
+ req.Header.Set("Content-Type", "application/json")
70
+ return req
71
+ }
72
+
73
+ // serve runs req through the App's server and returns as call does.
74
+ func serve(t *testing.T, cfg Config, auth api.Authenticator, req *http.Request, permissions []byte) (string, map[string]any) {
75
+ t.Helper()
63
76
  srv, err := api.NewServerWithPermissions(auth, permissions, Routes(cfg)...)
64
77
  if err != nil {
65
78
  t.Fatal(err)
66
79
  }
67
- body := `{"method":"` + method + `","params":` + params + `}`
68
- req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(body))
69
- req.Header.Set("Content-Type", "application/json")
70
80
  rec := httptest.NewRecorder()
71
81
  srv.ServeHTTP(rec, req)
72
82
  var out map[string]any
@@ -44,7 +44,8 @@ test('the default surface binds the real routes and the session-only picker',asy
44
44
  })
45
45
  const root=render({call,locale:'en'});await settle()
46
46
  button(root,'Choose save destination (save)').click();await settle()
47
- const select=[...root.querySelectorAll('button')].find(n=>n.getAttribute('aria-label')==='Select home')!;expect(select).toBeTruthy();select.click();await settle()
47
+ // The SDK picker selects a folder with a row click.
48
+ const row=root.querySelector<HTMLElement>('tr[data-row-key="/home"]')!;expect(row).toBeTruthy();row.dispatchEvent(new MouseEvent('click',{bubbles:true}));await settle()
48
49
  button(root,'Confirm selection').click();await settle()
49
50
  expect(call).toHaveBeenCalledWith('delegate.select_destination',{key:'save',path:'/home'})
50
51
  })
@@ -1,19 +1,17 @@
1
1
  package app
2
2
 
3
3
  import (
4
- "bufio"
5
4
  "context"
6
5
  "encoding/json"
7
6
  "errors"
8
- "net"
9
7
  "net/http"
10
8
  "os"
11
9
  "path/filepath"
12
10
  "strings"
13
- "syscall"
14
11
  "time"
15
12
 
16
13
  "github.com/synopackageland/syno-pkg-land-sdk/api"
14
+ "github.com/synopackageland/syno-pkg-land-sdk/delegate"
17
15
  )
18
16
 
19
17
  func (cfg Config) rootRoutes() []api.Route {
@@ -56,8 +54,10 @@ func (cfg Config) executorControl(_ context.Context, caller api.Caller, raw json
56
54
  // The executor closes its socket while paused: the package is stopped, or an
57
55
  // upgrade changed the pins and awaits administrator approval.
58
56
  func (cfg Config) executorStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
59
- status, err := cfg.executorRequest(ctx, "status", nil, nil)
60
- if errors.Is(err, syscall.ENOENT) || errors.Is(err, syscall.ECONNREFUSED) {
57
+ var status json.RawMessage
58
+ err := cfg.executor().Call(ctx, "status", nil, nil, &status)
59
+ var refusal *api.Error
60
+ if errors.As(err, &refusal) && refusal.Code == delegate.CodeExecutorUnavailable {
61
61
  next := "Start the package."
62
62
  if cfg.startRequested() {
63
63
  next = "The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
@@ -95,46 +95,34 @@ func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *h
95
95
  if err != nil {
96
96
  return nil, err
97
97
  }
98
- return cfg.executorRequest(ctx, "root.run", auth, map[string]string{"name": in.Name})
99
- }
100
-
101
- // executorSocketBase is a variable so tests can use a temporary directory.
102
- var executorSocketBase = "/run/synopkgland"
103
-
104
- func (cfg Config) executorRequest(ctx context.Context, op string, auth, params any) (any, error) {
105
- conn, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "unix", filepath.Join(executorSocketBase, cfg.PackageID, "executor.sock"))
106
- if err != nil {
107
- return nil, err
108
- }
109
- defer conn.Close()
110
- if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil {
98
+ if err := cfg.executorHoldsThisBuild(ctx); err != nil {
111
99
  return nil, err
112
100
  }
113
- if auth == nil {
114
- auth = map[string]any{}
115
- }
116
- if params == nil {
117
- params = map[string]any{}
118
- }
119
- if err := json.NewEncoder(conn).Encode(map[string]any{"v": 1, "op": op, "auth": auth, "params": params}); err != nil {
101
+ var out json.RawMessage
102
+ if err := cfg.executor().Call(ctx, "root.run", auth, map[string]string{"name": in.Name}, &out); err != nil {
120
103
  return nil, err
121
104
  }
122
- scanner := bufio.NewScanner(conn)
123
- scanner.Buffer(make([]byte, 4096), 64*1024)
124
- if !scanner.Scan() {
125
- return nil, errors.New("executor_response_invalid")
126
- }
127
- var response struct {
128
- OK bool `json:"ok"`
129
- Result json.RawMessage `json:"result"`
130
- Code string `json:"code"`
131
- Message string `json:"message"`
132
- }
133
- if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
134
- return nil, err
105
+ return out, nil
106
+ }
107
+
108
+ // executorHoldsThisBuild refuses, as delegate does, an executor pinned to
109
+ // another build's policy or executor: its approved scripts are not this build's.
110
+ func (cfg Config) executorHoldsThisBuild(ctx context.Context) error {
111
+ var pins delegate.ExecutorStatus
112
+ if err := cfg.executor().Call(ctx, "status", nil, nil, &pins); err != nil {
113
+ return err
135
114
  }
136
- if !response.OK {
137
- return nil, errors.New(response.Code + ": " + response.Message)
115
+ if (cfg.ExecutorDigest != "" && pins.ExecutorDigest != cfg.ExecutorDigest) || (cfg.PolicyDigest != "" && pins.PolicyDigest != cfg.PolicyDigest) {
116
+ return &api.Error{Code: delegate.CodePolicyDenied, Message: "the executor holds another build's approved policy; an administrator approves this build in Administrator setup"}
138
117
  }
139
- return response.Result, nil
118
+ return nil
119
+ }
120
+
121
+ // executorSocketBase is a variable so tests can use a temporary directory.
122
+ var executorSocketBase = "/run/synopkgland"
123
+
124
+ // executor keeps the executor's protocol v1 refusal codes (POLICY_DENIED,
125
+ // EXECUTOR_UNAVAILABLE, ...) as the API error code.
126
+ func (cfg Config) executor() *delegate.Client {
127
+ return &delegate.Client{Socket: filepath.Join(executorSocketBase, cfg.PackageID, "executor.sock"), Timeout: 30 * time.Second}
140
128
  }
@@ -1,11 +1,16 @@
1
1
  package app
2
2
 
3
3
  import (
4
+ "bufio"
5
+ "encoding/json"
4
6
  "net"
5
7
  "os"
6
8
  "path/filepath"
7
9
  "strings"
10
+ "sync"
8
11
  "testing"
12
+
13
+ appapi "__PACKAGE__/internal/api"
9
14
  )
10
15
 
11
16
  func init() {
@@ -59,14 +64,7 @@ func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
59
64
  // awaiting administrator approval). Status then answers, not "internal".
60
65
  func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
61
66
  cfg := testConfig(t)
62
- previous := executorSocketBase
63
- // A short base: unix socket paths are limited to about 100 bytes.
64
- base, err := os.MkdirTemp("/tmp", "exec")
65
- if err != nil {
66
- t.Fatal(err)
67
- }
68
- executorSocketBase = base
69
- defer func() { executorSocketBase = previous; os.RemoveAll(base) }()
67
+ useExecutorSocketBase(t)
70
68
  check := func(name string) {
71
69
  t.Helper()
72
70
  code, envelope := call(t, cfg, alice, "executor.status", `{}`)
@@ -94,9 +92,7 @@ func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
94
92
  // again, not a start.
95
93
  func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
96
94
  cfg := testConfig(t)
97
- previous := executorSocketBase
98
- executorSocketBase = t.TempDir()
99
- defer func() { executorSocketBase = previous }()
95
+ useExecutorSocketBase(t)
100
96
  next := func() string {
101
97
  t.Helper()
102
98
  _, envelope := call(t, cfg, alice, "executor.status", `{}`)
@@ -116,3 +112,126 @@ func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
116
112
  }
117
113
  }
118
114
  }
115
+
116
+ // fakeExecutor answers each v1 request with replies[op] and records the ops.
117
+ type fakeExecutor struct {
118
+ mu sync.Mutex
119
+ ops []string
120
+ }
121
+
122
+ func (f *fakeExecutor) received() []string {
123
+ f.mu.Lock()
124
+ defer f.mu.Unlock()
125
+ return append([]string(nil), f.ops...)
126
+ }
127
+
128
+ func startFakeExecutor(t *testing.T, cfg Config, replies map[string]string) *fakeExecutor {
129
+ t.Helper()
130
+ useExecutorSocketBase(t)
131
+ dir := filepath.Join(executorSocketBase, cfg.PackageID)
132
+ if err := os.MkdirAll(dir, 0700); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ ln, err := net.Listen("unix", filepath.Join(dir, "executor.sock"))
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ t.Cleanup(func() { ln.Close() })
140
+ f := &fakeExecutor{}
141
+ go func() {
142
+ for {
143
+ conn, err := ln.Accept()
144
+ if err != nil {
145
+ return
146
+ }
147
+ var req struct{ Op string }
148
+ line, _ := bufio.NewReader(conn).ReadBytes('\n')
149
+ json.Unmarshal(line, &req)
150
+ f.mu.Lock()
151
+ f.ops = append(f.ops, req.Op)
152
+ f.mu.Unlock()
153
+ conn.Write([]byte(replies[req.Op] + "\n"))
154
+ conn.Close()
155
+ }
156
+ }()
157
+ return f
158
+ }
159
+
160
+ // useExecutorSocketBase points the CGI at a short temporary directory: unix
161
+ // socket paths are limited to about 100 bytes.
162
+ func useExecutorSocketBase(t *testing.T) {
163
+ t.Helper()
164
+ previous := executorSocketBase
165
+ base, err := os.MkdirTemp("/tmp", "exec")
166
+ if err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ executorSocketBase = base
170
+ t.Cleanup(func() { executorSocketBase = previous; os.RemoveAll(base) })
171
+ }
172
+
173
+ // rootRun calls root.run as an administrator with a current DSM session.
174
+ func rootRun(t *testing.T, cfg Config, name string) (string, map[string]any) {
175
+ t.Helper()
176
+ permissions, err := appapi.Permissions()
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ req := apiRequest("root.run", `{"name":"`+name+`"}`)
181
+ req.Header.Set("Cookie", "id=session")
182
+ req.Header.Set("X-SYNO-TOKEN", "token")
183
+ return serve(t, cfg, alice, req, permissions)
184
+ }
185
+
186
+ const readyStatus = `{"ok":true,"result":{"executor_digest":"e1","policy_digest":"p1","ready":true,"disabled":false,"version":"1"}}`
187
+
188
+ func pinnedConfig(t *testing.T) Config {
189
+ cfg := testConfig(t)
190
+ cfg.ExecutorDigest, cfg.PolicyDigest = "e1", "p1"
191
+ return cfg
192
+ }
193
+
194
+ func TestExecutorRootRunRunsWhenTheExecutorHoldsThisBuildsPins(t *testing.T) {
195
+ cfg := pinnedConfig(t)
196
+ startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
197
+ code, out := rootRun(t, cfg, "whoami")
198
+ if data, _ := out["data"].(map[string]any); code != "" || data["output"] != "0\n" {
199
+ t.Fatalf("%s %v", code, out)
200
+ }
201
+ }
202
+
203
+ // Like delegate: an executor pinned to another build's policy or executor
204
+ // refuses the operation, it does not run it.
205
+ func TestExecutorRootRunRefusesAnExecutorPinnedToAnotherBuild(t *testing.T) {
206
+ for name, status := range map[string]string{
207
+ "policy": strings.Replace(readyStatus, `"p1"`, `"p2"`, 1),
208
+ "executor": strings.Replace(readyStatus, `"e1"`, `"e2"`, 1),
209
+ } {
210
+ cfg := pinnedConfig(t)
211
+ f := startFakeExecutor(t, cfg, map[string]string{"status": status, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
212
+ if code, out := rootRun(t, cfg, "whoami"); code != "POLICY_DENIED" {
213
+ t.Errorf("%s digest mismatch: %q %v", name, code, out)
214
+ }
215
+ for _, op := range f.received() {
216
+ if op == "root.run" {
217
+ t.Errorf("%s digest mismatch: root.run reached the executor", name)
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Refusals keep the protocol v1 code (spec/protocol/v1/errors.json), never "internal".
224
+ func TestExecutorRootRunReportsTheExecutorsRefusalCode(t *testing.T) {
225
+ cfg := pinnedConfig(t)
226
+ useExecutorSocketBase(t)
227
+ if code, _ := rootRun(t, cfg, "whoami"); code != "EXECUTOR_UNAVAILABLE" {
228
+ t.Errorf("paused or awaiting approval (no socket): %q", code)
229
+ }
230
+ for _, refusal := range []string{"POLICY_DENIED", "INVALID_REQUEST", "EXECUTOR_PAUSED", "ROOT_FAILED", "ADMIN_REQUIRED"} {
231
+ cfg := pinnedConfig(t)
232
+ startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":false,"code":"` + refusal + `","message":"refused"}`})
233
+ if code, out := rootRun(t, cfg, "whoami"); code != refusal {
234
+ t.Errorf("%s: %q %v", refusal, code, out)
235
+ }
236
+ }
237
+ }
@@ -9,7 +9,7 @@ files, portal, jobs and delegate components come from `@synopackageland/sdk/vue`
9
9
 
10
10
  ```sh
11
11
  cd frontend
12
- npm install
12
+ npm ci
13
13
  npm test
14
14
  npm run build
15
15
  ```
@@ -22,7 +22,7 @@ subscriptions; unmount disposes subscriptions and ignores late responses.
22
22
  operations. `--with jobs` adds package-account jobs and schedules; it combines with root-ops and files. `--with files` enables current-session File Station access,
23
23
  and the background picker uses the retained session provider.
24
24
 
25
- With `portal:` declared (`synopkgland add portal`), the status page shows
25
+ With `portal:` declared (`synopkgland add portal`, executor Apps only), the status page shows
26
26
  the SDK's `PortalSettings`: the DSM Application Portal link, the alias form
27
27
  for administrators, and the Control Panel shortcut.
28
28
 
@@ -56,6 +56,7 @@ Evidence: `.herdr-project/syno-package-land-sdk-t-0129/library/ui-desktop.png` a
56
56
 
57
57
  - Do use existing public UI components, embedded theme tokens and state/recovery copy.
58
58
  - Do retain permission gating, readable loading/empty/error states and per-instance cleanup.
59
+ - Do open the App at once: render `AppShell` immediately and run session, permission and status checks in the background, never a full-window "Checking…" placeholder. Only a confirmed refusal (signed out, expired session, not an administrator) shows the sign-in page or a library `Dialog`; loading text inside a section is fine.
59
60
  - Do use the library's `Select`, `Checkbox`, `Input`, `Textarea`, `Form`, `FormItem`, `FilePicker` (`variant="compact"`, in the app locale) and `Link` for controls; there is no native `<input>` or `<a>`. Leave `Form` on its automatic direction (labels stack below 520px of container width) and let a `FormItem` bind its control, with no `direction="vertical"`, `role="group"` or `field.controlId`.
60
61
  - Do not infer authority from frontend observations or scheduler history.
61
62
  - Don't introduce a separate palette, component system or modal workaround around the library contract.
@@ -7,8 +7,8 @@
7
7
  "name": "native-dsm-vue-app",
8
8
  "dependencies": {
9
9
  "@lucide/vue": "1.48.0",
10
- "@synopackageland/sdk": "0.9.0",
11
- "@synopackageland/ui": "0.9.0",
10
+ "@synopackageland/sdk": "0.9.3",
11
+ "@synopackageland/ui": "0.9.1",
12
12
  "vue": "3.5.43"
13
13
  },
14
14
  "devDependencies": {
@@ -1148,13 +1148,13 @@
1148
1148
  }
1149
1149
  },
1150
1150
  "node_modules/@synopackageland/sdk": {
1151
- "version": "0.9.0",
1152
- "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.9.0.tgz",
1153
- "integrity": "sha512-dtk+F2KBQbdNs200Iz18pRQNYOyzc3EaUL5+c2JnmEEUysSTZFpEqu9gD37aQXUfH402EUsOBUtcCqDpRPMUvg==",
1151
+ "version": "0.9.3",
1152
+ "resolved": "https://registry.npmjs.org/@synopackageland/sdk/-/sdk-0.9.3.tgz",
1153
+ "integrity": "sha512-imKU1EPg2oqa2aGvDQ2ceJMhphq+9mPlaKLIf+yUpKKDhJ2JZs6QPf0RMjLN9n267S0/TKzYcxM7hW8EZfBXQQ==",
1154
1154
  "license": "MIT",
1155
1155
  "peerDependencies": {
1156
1156
  "@lucide/vue": "^1.48.0",
1157
- "@synopackageland/ui": "^0.9.0",
1157
+ "@synopackageland/ui": "^0.9.1",
1158
1158
  "vue": "^3.5.0"
1159
1159
  },
1160
1160
  "peerDependenciesMeta": {
@@ -1167,9 +1167,9 @@
1167
1167
  }
1168
1168
  },
1169
1169
  "node_modules/@synopackageland/ui": {
1170
- "version": "0.9.0",
1171
- "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.0.tgz",
1172
- "integrity": "sha512-0OnItM4pJ8zYHafpWkAdbVb4x8alqkz9FKwOR1Y3DWdelIxR6DTiTrCOLmqat1GwjtuyqLuebhoPX5rejZjWUA==",
1170
+ "version": "0.9.1",
1171
+ "resolved": "https://registry.npmjs.org/@synopackageland/ui/-/ui-0.9.1.tgz",
1172
+ "integrity": "sha512-L2k0eeN9+TLPMh5+kMbq6pV8n0qqUq6Ynme/KSp3mCqDb1MXBdr8MbAGtxdbNYMj9QepbA9mccgygwI9fFoG5A==",
1173
1173
  "license": "MIT",
1174
1174
  "dependencies": {
1175
1175
  "@internationalized/date": "3.12.4",
@@ -2538,9 +2538,9 @@
2538
2538
  "license": "MIT"
2539
2539
  },
2540
2540
  "node_modules/zod": {
2541
- "version": "4.6.5",
2542
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
2543
- "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
2541
+ "version": "4.6.6",
2542
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz",
2543
+ "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
2544
2544
  "license": "MIT",
2545
2545
  "funding": {
2546
2546
  "url": "https://github.com/sponsors/colinhacks"
@@ -8,8 +8,8 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "@lucide/vue": "1.48.0",
11
- "@synopackageland/sdk": "0.9.0",
12
- "@synopackageland/ui": "0.9.0",
11
+ "@synopackageland/sdk": "0.9.3",
12
+ "@synopackageland/ui": "0.9.1",
13
13
  "vue": "3.5.43"
14
14
  },
15
15
  "devDependencies": {
@@ -25,14 +25,21 @@ function message(e: unknown) {
25
25
  const errors = copy.value.errors as Record<string, string>
26
26
  return (code !== 'request_failed' && errors[code]) || errors.request_failed!.replace('{code}', code)
27
27
  }
28
+ let again = false
28
29
  async function refresh() {
29
- if(disposed || busy.value || (login.enabled.value && !login.subject.value)) return
30
+ if(disposed || (login.enabled.value && !login.subject.value)) return
31
+ if(busy.value) {again = true; return}
32
+ // Sent before the session check settles: a session failure must not sign the user
33
+ // out, and the confirmed session refreshes again.
34
+ const early = !login.ready.value
35
+ const call: typeof login.call = early ? (method, params) => props.ctx.call(method, params) : login.call
30
36
  busy.value = true; error.value = ''
31
37
  try {
32
- const a = await login.call<AppStatus>('app.status')
38
+ const a = await call<AppStatus>('app.status')
33
39
  if(!disposed) app.value = a
34
- if(a.admin) {const greeting = await login.call<{text:string}>('greeting.get'); if(!disposed) text.value = greeting.text}
35
- } catch(e) {if(!disposed) error.value = message(e)} finally {if(!disposed) busy.value=false}
40
+ if(a.admin) {const greeting = await call<{text:string}>('greeting.get'); if(!disposed) text.value = greeting.text}
41
+ } catch(e) {if(!disposed && !(early && login.enabled.value)) error.value = message(e)}
42
+ finally {if(!disposed) {busy.value=false; if(again) {again=false; void refresh()}}}
36
43
  }
37
44
  async function save() {
38
45
  if(disposed || busy.value) return
@@ -47,10 +54,11 @@ watch(login.subject, (subject, previous) => {
47
54
  async function signOut() {
48
55
  try {await login.logout()} catch(e) {if(!disposed) error.value=message(e)}
49
56
  }
50
- onMounted(async()=>{
57
+ onMounted(()=>{
51
58
  unsubscribe = props.ctx.reopen(()=>{if(!disposed) void refresh()})
52
- await login.start()
53
- if((!login.enabled.value || login.subject.value)) await refresh()
59
+ // The session check runs in the background; only a confirmed sign-in need replaces the App.
60
+ void login.start()
61
+ void refresh()
54
62
  })
55
63
  onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
56
64
  </script>
@@ -58,8 +66,7 @@ onUnmounted(()=>{disposed=true;unsubscribe?.();login.dispose();text.value=''})
58
66
  <template>
59
67
  <AppRoot modal-scope="app" style="height: 100%">
60
68
  <ConfigProvider :locale="uiLocale(ctx.locale)">
61
- <p v-if="!login.ready.value" role="status">{{ copy.checking }}</p>
62
- <LoginPage v-else-if="login.enabled.value && !login.subject.value" :session="login" :locale="ctx.locale" />
69
+ <LoginPage v-if="login.ready.value && login.enabled.value && !login.subject.value" :session="login" :locale="ctx.locale" />
63
70
  <AppShell v-else v-model="page" :locale="ctx.locale" :pages="pages" :description="subtitle">
64
71
  <template #actions><Button variant="secondary" :disabled="busy" @click="refresh">{{ copy.refresh }}</Button><Button v-if="login.enabled.value" variant="secondary" :disabled="busy" @click="signOut">{{ copy.signOut }}</Button></template>
65
72
  <div class="integration-app" :aria-busy="busy" :aria-label="copy.settings">
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "Sitzung wird geprüft…",
3
2
  "refresh": "Aktualisieren",
4
3
  "signOut": "Abmelden",
5
4
  "settings": "Paketeinstellungen",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "Checking session…",
3
2
  "refresh": "Refresh",
4
3
  "signOut": "Sign out",
5
4
  "settings": "Package settings",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "Comprobando la sesión…",
3
2
  "refresh": "Actualizar",
4
3
  "signOut": "Cerrar sesión",
5
4
  "settings": "Configuración del paquete",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "Vérification de la session…",
3
2
  "refresh": "Actualiser",
4
3
  "signOut": "Se déconnecter",
5
4
  "settings": "Paramètres du paquet",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "Verifica della sessione…",
3
2
  "refresh": "Aggiorna",
4
3
  "signOut": "Disconnetti",
5
4
  "settings": "Impostazioni del pacchetto",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "セッションを確認しています…",
3
2
  "refresh": "最新の情報に更新",
4
3
  "signOut": "サインアウト",
5
4
  "settings": "パッケージ設定",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "세션 확인 중…",
3
2
  "refresh": "새로 고침",
4
3
  "signOut": "로그아웃",
5
4
  "settings": "패키지 설정",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "正在检查登录状态…",
3
2
  "refresh": "刷新",
4
3
  "signOut": "注销",
5
4
  "settings": "套件设置",
@@ -1,5 +1,4 @@
1
1
  {
2
- "checking": "正在檢查登入狀態…",
3
2
  "refresh": "重新整理",
4
3
  "signOut": "登出",
5
4
  "settings": "套件設定",
@@ -55,3 +55,40 @@ test('a portal app shows the Application Portal section on the status page',asyn
55
55
  app.unmount()
56
56
  }finally{without.close();f.root.remove()}
57
57
  })
58
+ test('the app renders and loads status while the session check is still pending',async()=>{
59
+ const call=vi.fn(async(method:string)=>{
60
+ if(method==='identity.status')return new Promise(()=>{})
61
+ if(method==='app.status')return {package:'Eager',version:'1',user:'admin',admin:true}
62
+ if(method==='greeting.get')return {text:'Hello Eager'}
63
+ throw {code:'unknown_method'}
64
+ })
65
+ const ctx={locale:'en',call,reopen(){return ()=>{}}} as unknown as Context
66
+ const root=document.createElement('div');document.body.append(root)
67
+ const app=mount(root,ctx)
68
+ try{
69
+ await settle()
70
+ expect(root.textContent).not.toContain('Checking session')
71
+ expect(root.textContent).toContain('Eager · admin · 1')
72
+ }finally{app.unmount();root.remove()}
73
+ })
74
+ test('a status request that raced the session check is retried once the session is confirmed',async()=>{
75
+ let session=false,rejectEarly:(e:unknown)=>void=()=>{}
76
+ const call=vi.fn(async(method:string)=>{
77
+ if(method==='identity.status')return {enabled:true}
78
+ if(method==='identity.session'){session=true;return {user:'admin',uid:'1024',expiresAt:new Date(Date.now()+3600000).toISOString()}}
79
+ if(method==='app.status'){
80
+ if(!session)return new Promise((_,reject)=>{rejectEarly=reject})
81
+ return {package:'Raced',version:'1',user:'admin',admin:true}
82
+ }
83
+ if(method==='greeting.get')return {text:'Hello Raced'}
84
+ throw {code:'unknown_method'}
85
+ })
86
+ const ctx={locale:'en',call,reopen(){return ()=>{}}} as unknown as Context
87
+ const root=document.createElement('div');document.body.append(root)
88
+ const app=mount(root,ctx)
89
+ try{
90
+ await settle();rejectEarly({code:'session_required'});await settle()
91
+ expect(root.textContent).not.toContain('Your DSM session expired')
92
+ expect(root.textContent).toContain('Raced · admin · 1')
93
+ }finally{app.unmount();root.remove()}
94
+ })