@synopackageland/cli 0.5.0 → 0.5.3

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 (114) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization-sdk.d.ts +5 -0
  3. package/dist/authorization-sdk.js +5 -0
  4. package/dist/build-spk/delete-app-data.d.ts +1 -1
  5. package/dist/build-spk/uninstall-wizard.d.ts +1 -1
  6. package/dist/build-spk/uninstall-wizard.js +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/error-registry.js +3 -3
  9. package/dist/host/dsm-port-profile.d.ts +1 -1
  10. package/dist/host/dsm-port-profile.js +1 -1
  11. package/dist/init.js +25 -4
  12. package/dist/standalone/authorization-window.d.ts +2 -0
  13. package/dist/standalone/authorization-window.js +28 -0
  14. package/dist/standalone/authorization.d.ts +48 -0
  15. package/dist/standalone/authorization.js +116 -0
  16. package/dist/standalone/build.js +18 -6
  17. package/dist/standalone/dsm-window.js +8 -1
  18. package/dist/standalone/helper.js +1 -1
  19. package/dist/standalone/inspect-helper.js +12 -2
  20. package/dist/standalone/inspect-spk.js +11 -0
  21. package/dist/standalone/lifecycle-helper.d.ts +1 -1
  22. package/dist/standalone/lifecycle-helper.js +2 -2
  23. package/dist/standalone/lifecycle.d.ts +1 -1
  24. package/dist/standalone/lifecycle.js +3 -3
  25. package/dist/standalone/root-ops.d.ts +2 -0
  26. package/dist/standalone/root-ops.js +10 -0
  27. package/dist/standalone/root-window.js +1 -1
  28. package/dist/standalone/spk.d.ts +2 -0
  29. package/dist/standalone/spk.js +14 -0
  30. package/dist/templates/_authorization/main.go +104 -0
  31. package/dist/templates/_authorization/main_test.go +35 -0
  32. package/dist/templates/native-go/README.md +25 -1
  33. package/dist/templates/native-go/backend/cmd/app/main.go +8 -6
  34. package/dist/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  35. package/dist/templates/native-go/backend/cmd/helper/main.go +18 -1
  36. package/dist/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  37. package/dist/templates/native-go/backend/internal/app/routes.go +5 -2
  38. package/dist/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  39. package/dist/templates/native-go/backend/internal/helper/management.go +8 -1
  40. package/dist/templates/native-go/backend/internal/helper/purge.go +115 -0
  41. package/dist/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  42. package/dist/templates/native-go/backend/internal/helper/revoke.go +109 -0
  43. package/dist/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  44. package/dist/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  45. package/dist/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  46. package/dist/templates/native-go/dsm-package.yaml +1 -1
  47. package/dist/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  48. package/dist/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  49. package/dist/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  50. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  51. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  52. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  53. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  54. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  55. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  56. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  57. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  58. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  59. package/dist/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  60. package/dist/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  61. package/dist/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  62. package/dist/templates/native-go-vue/frontend/src/context.ts +3 -0
  63. package/dist/types.d.ts +1 -1
  64. package/package.json +5 -2
  65. package/skills/reference/create.md +2 -2
  66. package/templates/_authorization/main.go +104 -0
  67. package/templates/_authorization/main_test.go +35 -0
  68. package/templates/native-go/README.md +25 -1
  69. package/templates/native-go/backend/cmd/app/main.go +8 -6
  70. package/templates/native-go/backend/cmd/app/sdk_main.go +8 -6
  71. package/templates/native-go/backend/cmd/helper/main.go +18 -1
  72. package/templates/native-go/backend/internal/app/authorization_none.go +5 -0
  73. package/templates/native-go/backend/internal/app/routes.go +5 -2
  74. package/templates/native-go/backend/internal/app/sdk_routes.go +5 -2
  75. package/templates/native-go/backend/internal/helper/management.go +8 -1
  76. package/templates/native-go/backend/internal/helper/purge.go +115 -0
  77. package/templates/native-go/backend/internal/helper/purge_test.go +59 -0
  78. package/templates/native-go/backend/internal/helper/revoke.go +109 -0
  79. package/templates/native-go/backend/internal/helper/revoke_test.go +121 -0
  80. package/templates/native-go/backend/internal/helper/sdk_server_linux.go +11 -2
  81. package/templates/native-go/backend/internal/helper/server_linux.go +11 -2
  82. package/templates/native-go/dsm-package.yaml +1 -1
  83. package/templates/native-go-authorization/backend/internal/app/authorization.go +298 -0
  84. package/templates/native-go-authorization/backend/internal/app/authorization_test.go +96 -0
  85. package/templates/native-go-authorization/frontend/src/authorization/AuthorizationGate.vue +89 -0
  86. package/templates/native-go-authorization/frontend/src/authorization/i18n/de.json +36 -0
  87. package/templates/native-go-authorization/frontend/src/authorization/i18n/en.json +36 -0
  88. package/templates/native-go-authorization/frontend/src/authorization/i18n/es.json +36 -0
  89. package/templates/native-go-authorization/frontend/src/authorization/i18n/fr.json +36 -0
  90. package/templates/native-go-authorization/frontend/src/authorization/i18n/index.ts +15 -0
  91. package/templates/native-go-authorization/frontend/src/authorization/i18n/it.json +36 -0
  92. package/templates/native-go-authorization/frontend/src/authorization/i18n/ja.json +36 -0
  93. package/templates/native-go-authorization/frontend/src/authorization/i18n/ko.json +36 -0
  94. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-CN.json +36 -0
  95. package/templates/native-go-authorization/frontend/src/authorization/i18n/zh-TW.json +36 -0
  96. package/templates/native-go-authorization/frontend/test/authorization-preview.html +14 -0
  97. package/templates/native-go-authorization/frontend/test/authorization.test.ts +59 -0
  98. package/templates/native-go-vue/frontend/src/context.ts +3 -0
  99. package/dist/templates/hello-web/compose.yaml +0 -24
  100. package/dist/templates/hello-web/html/app.js +0 -1
  101. package/dist/templates/hello-web/html/index.html +0 -13
  102. package/dist/templates/hello-web/synology-app.yaml +0 -16
  103. package/dist/templates/hello-web-page/compose.yaml +0 -24
  104. package/dist/templates/hello-web-page/html/app.js +0 -1
  105. package/dist/templates/hello-web-page/html/index.html +0 -13
  106. package/dist/templates/hello-web-page/synology-app.yaml +0 -16
  107. package/templates/hello-web/compose.yaml +0 -24
  108. package/templates/hello-web/html/app.js +0 -1
  109. package/templates/hello-web/html/index.html +0 -13
  110. package/templates/hello-web/synology-app.yaml +0 -16
  111. package/templates/hello-web-page/compose.yaml +0 -24
  112. package/templates/hello-web-page/html/app.js +0 -1
  113. package/templates/hello-web-page/html/index.html +0 -13
  114. package/templates/hello-web-page/synology-app.yaml +0 -16
@@ -0,0 +1,121 @@
1
+ package helper
2
+
3
+ import (
4
+ "context"
5
+ "net"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "syscall"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestPackageRevokeDurablyDisablesExactInventory(t *testing.T) {
15
+ c, _ := controlFixture(t)
16
+ r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
17
+ record(t, c.Client, r)
18
+ if err := packageRevoke(c.Client, Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ got, err := readRecord(c.StateDir, c.Owner)
22
+ if err != nil || got.Enabled || !got.Residual || got.Lifecycle.Phase != "fenced" {
23
+ t.Fatalf("not durably revoked: %+v %v", got, err)
24
+ }
25
+ if c.CheckStopped(context.Background()) == nil {
26
+ t.Fatal("revocation acknowledgement accepted as completion")
27
+ }
28
+ if err := c.Control(context.Background(), "recover"); err == nil {
29
+ t.Fatal("revoked approval recovered")
30
+ }
31
+ }
32
+
33
+ func TestRevokeAcknowledgementCannotCertifyQuiescence(t *testing.T) {
34
+ c, _ := controlFixture(t)
35
+ live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: req.Nonce, OK: true} })
36
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
37
+ defer cancel()
38
+ if c.Revoke(ctx) == nil {
39
+ t.Fatal("socket acknowledgement certified live process")
40
+ }
41
+ }
42
+
43
+ func TestRevokeRejectsUntrustedPeerAndWrongNonce(t *testing.T) {
44
+ for _, which := range []string{"peer", "nonce"} {
45
+ t.Run(which, func(t *testing.T) {
46
+ c, _ := controlFixture(t)
47
+ live(t, c.Client, c.Expected, func(req Request) Response { return Response{Inventory: c.Expected, Nonce: "wrong", OK: true} })
48
+ if which == "peer" {
49
+ c.Peer = func(*net.UnixConn) error { return errTrust }
50
+ }
51
+ if c.Revoke(context.Background()) == nil {
52
+ t.Fatal("untrusted response accepted")
53
+ }
54
+ })
55
+ }
56
+ }
57
+
58
+ func TestRevokeNewBuildConsumesOldDisabledTerminalWithoutMutation(t *testing.T) {
59
+ c, f := controlFixture(t)
60
+ l := testLifecycle("fenced")
61
+ if err := createLifetime(c.Client, l); err != nil {
62
+ t.Fatal(err)
63
+ }
64
+ record(t, c.Client, Record{Schema: 2, Inventory: c.Expected, Enabled: false, Residual: true, Lifecycle: l})
65
+ if err := cleanRuntime(c.Client, true); err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ if err := completeRecord(c.Client, l.Epoch, f.current); err != nil {
69
+ t.Fatal(err)
70
+ }
71
+ before, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
72
+ c.Expected.Version = "1.0.0-0002"
73
+ if err := c.Revoke(context.Background()); err != nil {
74
+ t.Fatal(err)
75
+ }
76
+ after, _ := os.ReadFile(filepath.Join(c.StateDir, "approval.json"))
77
+ if string(before) != string(after) {
78
+ t.Fatal("new build rewrote old publication")
79
+ }
80
+ if err := os.Remove(filepath.Join(c.StateDir, "completion-"+l.Epoch+".json")); err != nil {
81
+ t.Fatal(err)
82
+ }
83
+ if c.Revoke(context.Background()) == nil {
84
+ t.Fatal("missing terminal proof accepted")
85
+ }
86
+ }
87
+
88
+ func TestPackageRevokeRejectsWrongBuildInvalidFrameAndConcurrentEnable(t *testing.T) {
89
+ for _, kind := range []string{"build", "nonce", "operation", "busy"} {
90
+ t.Run(kind, func(t *testing.T) {
91
+ c, _ := controlFixture(t)
92
+ r := Record{Inventory: c.Expected, Enabled: true, Lifecycle: testLifecycle("running")}
93
+ record(t, c.Client, r)
94
+ req := Request{Inventory: c.Expected, Op: "revoke", Nonce: strings.Repeat("a", 64)}
95
+ switch kind {
96
+ case "build":
97
+ req.Inventory.Version = "0.1.0-0002"
98
+ case "nonce":
99
+ req.Nonce = ""
100
+ case "operation":
101
+ req.Op = "enable"
102
+ case "busy":
103
+ f, err := os.OpenFile(filepath.Join(c.StateDir, "control.lock"), os.O_CREATE|os.O_RDWR, 0600)
104
+ if err != nil {
105
+ t.Fatal(err)
106
+ }
107
+ defer f.Close()
108
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
109
+ t.Fatal(err)
110
+ }
111
+ }
112
+ if packageRevoke(c.Client, req) == nil {
113
+ t.Fatal("unsafe revoke accepted")
114
+ }
115
+ got, _ := readRecord(c.StateDir, c.Owner)
116
+ if !got.Enabled || got.Residual || got.Lifecycle.Phase != "running" {
117
+ t.Fatal("rejected request changed approval")
118
+ }
119
+ })
120
+ }
121
+ }
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
53
53
  if err := os.Chmod(socket, 0660); err != nil {
54
54
  return err
55
55
  }
56
- // Bound concurrency and each frame's size/time. No privileged mutations are
57
- // accepted over this socket; lifecycle control remains fixed admin tasks.
56
+ // Bound concurrency and each frame. The authenticated package UID may only
57
+ // withdraw authority; activation remains an exact approved root task.
58
58
  slots := make(chan struct{}, 8)
59
59
  for {
60
60
  conn, err := l.AcceptUnix()
@@ -105,6 +105,15 @@ func serveLinux(c Client) error {
105
105
  if health.Decode(&req) != nil {
106
106
  return
107
107
  }
108
+ if req.Op == "revoke" {
109
+ err := packageRevoke(c, req)
110
+ res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
111
+ if err != nil {
112
+ res.Error = "revoke-refused"
113
+ }
114
+ json.NewEncoder(conn).Encode(res)
115
+ return
116
+ }
108
117
  r, err := readRecord(c.StateDir, 0)
109
118
  readiness := "fenced"
110
119
  if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
@@ -53,8 +53,8 @@ func serveLinux(c Client) error {
53
53
  if err := os.Chmod(socket, 0660); err != nil {
54
54
  return err
55
55
  }
56
- // Bound concurrency and each frame's size/time. No privileged mutations are
57
- // accepted over this socket; lifecycle control remains fixed admin tasks.
56
+ // Bound concurrency and each frame. The authenticated package UID may only
57
+ // withdraw authority; activation remains an exact approved root task.
58
58
  slots := make(chan struct{}, 8)
59
59
  for {
60
60
  conn, err := l.AcceptUnix()
@@ -80,6 +80,15 @@ func serveLinux(c Client) error {
80
80
  if d.Decode(&req) != nil {
81
81
  return
82
82
  }
83
+ if req.Op == "revoke" {
84
+ err := packageRevoke(c, req)
85
+ res := Response{Inventory: c.Expected, Nonce: req.Nonce, OK: err == nil}
86
+ if err != nil {
87
+ res.Error = "revoke-refused"
88
+ }
89
+ json.NewEncoder(conn).Encode(res)
90
+ return
91
+ }
83
92
  r, err := readRecord(c.StateDir, 0)
84
93
  readiness := "fenced"
85
94
  if err == nil && r.Enabled && r.Inventory == c.Expected && r.Lifecycle != nil && r.Lifecycle.Phase == "running" {
@@ -35,7 +35,7 @@ ui:
35
35
  # UI languages: i18n/messages/<locale>.json for en, zh-TW, zh-CN, ja, ko, de,
36
36
  # fr, es and it (DSM's other languages fall back to English). build-spk checks
37
37
  # that every language has the en.json keys and placeholders, and that the keys
38
- # listed in the glossary use Synology's own DSM wording.
38
+ # listed in the glossary use DSM's own wording.
39
39
  i18n:
40
40
  messages: i18n/messages
41
41
  glossary: i18n/glossary.json
@@ -0,0 +1,298 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "dsmapp/internal/api"
7
+ "encoding/base64"
8
+ "encoding/hex"
9
+ "encoding/json"
10
+ "errors"
11
+ "io"
12
+ "net"
13
+ "net/http"
14
+ "net/url"
15
+ "os"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+ )
20
+
21
+ // Compiled into the CGI. Package-owned JSON is never the source of trust.
22
+ type authorizationTask struct {
23
+ Op string `json:"op"`
24
+ Name string `json:"name"`
25
+ Header string `json:"header"`
26
+ Script string `json:"script"`
27
+ Output bool `json:"output"`
28
+ }
29
+ type authorizationBuild struct {
30
+ Schema int `json:"schema"`
31
+ Package string `json:"package"`
32
+ Version string `json:"version"`
33
+ Pin string `json:"bootstrapSha256"`
34
+ Inventory struct {
35
+ Files []struct {
36
+ Path string `json:"path"`
37
+ Size int64 `json:"size"`
38
+ Hash string `json:"sha256"`
39
+ } `json:"files"`
40
+ } `json:"inventory"`
41
+ Tasks []authorizationTask `json:"tasks"`
42
+ }
43
+
44
+ func (cfg Config) authorizationRoutes() []api.Route {
45
+ if cfg.RootAuthorization == "" {
46
+ return nil
47
+ }
48
+ return []api.Route{api.UserRequest("authorization.status", cfg.authorizationStatus), api.Admin("authorization.request", cfg.authorizationRequest), api.Admin("authorization.revoke", cfg.authorizationRevoke)}
49
+ }
50
+ func authorizationDocument(state string, reason any) any {
51
+ return map[string]any{"command": "authorization", "schema_version": 1, "state": state, "reason": reason}
52
+ }
53
+ func (cfg Config) authorizationPlan() (authorizationBuild, error) {
54
+ var p authorizationBuild
55
+ raw, decode := base64.StdEncoding.DecodeString(cfg.RootAuthorization)
56
+ if decode != nil || json.Unmarshal(raw, &p) != nil || p.Schema != 2 || p.Package != cfg.PackageID || p.Version != cfg.Version || len(p.Tasks) == 0 || len(p.Pin) != 64 {
57
+ return p, errors.New("invalid build authorization")
58
+ }
59
+ if b, e := hex.DecodeString(p.Pin); e != nil || len(b) != 32 {
60
+ return p, errors.New("invalid bootstrap pin")
61
+ }
62
+ return p, nil
63
+ }
64
+ func (cfg Config) authorizationRequest(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
65
+ if e := decodeParams(params, &struct{}{}); e != nil {
66
+ return nil, e
67
+ }
68
+ p, e := cfg.authorizationPlan()
69
+ if e != nil {
70
+ return nil, e
71
+ }
72
+ if cfg.authorizationTrusted(p) != nil {
73
+ return nil, &api.Error{Code: "authorization_required", Message: "Build payload cannot be trusted; reinstall this package"}
74
+ }
75
+ ops := []string{}
76
+ for _, t := range p.Tasks {
77
+ if t.Op == "helper_enable" {
78
+ ops = []string{t.Op}
79
+ break
80
+ }
81
+ ops = append(ops, t.Op)
82
+ }
83
+ return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "run": cfg.HelperInventory.Package != ""}, nil
84
+ }
85
+ func (cfg Config) authorizationRevoke(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
86
+ if e := decodeParams(params, &struct{}{}); e != nil {
87
+ return nil, e
88
+ }
89
+ p, e := cfg.authorizationPlan()
90
+ if e != nil {
91
+ return nil, e
92
+ }
93
+ // Root task still pins bootstrap. Corrupt UI must not prevent safe cleanup.
94
+ ops := []string{}
95
+ purge := false
96
+ for _, t := range p.Tasks {
97
+ ops = append(ops, t.Op)
98
+ if cfg.HelperInventory.Package != "" && t.Op == "helper_purge" {
99
+ purge = true
100
+ }
101
+ }
102
+ return map[string]any{"command": "authorization", "schema_version": 1, "phase": "plan", "operations": ops, "disable": cfg.HelperInventory.Package != "", "purge": purge}, nil
103
+ }
104
+ func (cfg Config) authorizationTrusted(p authorizationBuild) error {
105
+ root, e := os.OpenRoot("/var/packages/" + cfg.PackageID + "/target")
106
+ if e != nil {
107
+ return e
108
+ }
109
+ defer root.Close()
110
+ verify := func(path string, size int64, want string) error {
111
+ f, e := root.Open(path)
112
+ if e != nil {
113
+ return e
114
+ }
115
+ defer f.Close()
116
+ st, e := f.Stat()
117
+ if e != nil || !st.Mode().IsRegular() || st.Size() > 64<<20 || size >= 0 && st.Size() != size {
118
+ return errors.New("untrusted payload")
119
+ }
120
+ h := sha256.New()
121
+ _, e = io.Copy(h, io.LimitReader(f, (64<<20)+1))
122
+ if e != nil || hex.EncodeToString(h.Sum(nil)) != want {
123
+ return errors.New("untrusted payload")
124
+ }
125
+ return nil
126
+ }
127
+ if e = verify("authorization/bootstrap", -1, p.Pin); e != nil {
128
+ return e
129
+ }
130
+ for _, f := range p.Inventory.Files {
131
+ if e = verify(f.Path, f.Size, f.Hash); e != nil {
132
+ return e
133
+ }
134
+ }
135
+ return nil
136
+ }
137
+ func (cfg Config) authorizationStatus(ctx context.Context, caller api.Caller, r *http.Request, params json.RawMessage) (any, error) {
138
+ if e := decodeParams(params, &struct{}{}); e != nil {
139
+ return nil, e
140
+ }
141
+ p, e := cfg.authorizationPlan()
142
+ if e != nil {
143
+ return authorizationDocument("unavailable", "service_unavailable"), nil
144
+ }
145
+ if cfg.authorizationTrusted(p) != nil {
146
+ return authorizationDocument("authorization_required", "helper_untrusted"), nil
147
+ }
148
+ if cfg.HelperInventory.Package != "" {
149
+ s := cfg.integrationState(ctx)
150
+ if s.State == "ready" {
151
+ return authorizationDocument("ready", nil), nil
152
+ }
153
+ if !caller.Admin() {
154
+ return authorizationDocument("not_admin", "not_admin"), nil
155
+ }
156
+ switch s.State {
157
+ case "incompatible":
158
+ return authorizationDocument("authorization_required", "grant_incompatible"), nil
159
+ case "pending":
160
+ return authorizationDocument("unavailable", "health_probe_timeout"), nil
161
+ case "failed":
162
+ return authorizationDocument("unavailable", "service_unavailable"), nil
163
+ }
164
+ }
165
+ if !caller.Admin() {
166
+ return authorizationDocument("not_admin", "not_admin"), nil
167
+ }
168
+ state, reason := authorizationScheduler(ctx, r, p.Tasks)
169
+ if cfg.HelperInventory.Package != "" && state == "ready" {
170
+ state = "authorization_required"
171
+ reason = "grant_missing"
172
+ }
173
+ return authorizationDocument(state, reason), nil
174
+ }
175
+
176
+ // Read-only scheduler observations; never accept caller-selected URLs or task IDs.
177
+ // One modern probe; legacy fallback only for explicit missing method (103).
178
+ func authorizationScheduler(ctx context.Context, r *http.Request, tasks []authorizationTask) (string, any) {
179
+ return authorizationSchedulerAt(ctx, r, tasks, "http://127.0.0.1:5000")
180
+ }
181
+ func authorizationSchedulerAt(ctx context.Context, r *http.Request, tasks []authorizationTask, endpoint string) (string, any) {
182
+ tr := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second}).DialContext, DisableKeepAlives: true, DisableCompression: true, ResponseHeaderTimeout: 5 * time.Second, MaxResponseHeaderBytes: 32 << 10}
183
+ defer tr.CloseIdleConnections()
184
+ client := &http.Client{Transport: tr, Timeout: 8 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
185
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
186
+ defer cancel()
187
+ call := func(method string, version int, params url.Values) (json.RawMessage, int, error) {
188
+ params.Set("api", "SYNO.Core.TaskScheduler")
189
+ params.Set("method", method)
190
+ params.Set("version", strconv.Itoa(version))
191
+ req, e := http.NewRequestWithContext(ctx, "GET", endpoint+"/webapi/entry.cgi?"+params.Encode(), nil)
192
+ if e != nil {
193
+ return nil, 0, e
194
+ }
195
+ cookies := r.CookiesNamed("id")
196
+ if len(cookies) != 1 {
197
+ return nil, 0, errors.New("session")
198
+ }
199
+ req.AddCookie(cookies[0])
200
+ req.Header.Set("X-SYNO-TOKEN", r.Header.Get("X-SYNO-TOKEN"))
201
+ response, e := client.Do(req)
202
+ if e != nil {
203
+ return nil, 0, e
204
+ }
205
+ defer response.Body.Close()
206
+ b, e := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
207
+ if e != nil || len(b) > 1<<20 || response.StatusCode != 200 {
208
+ return nil, 0, errors.New("scheduler unavailable")
209
+ }
210
+ var wire struct {
211
+ Success bool `json:"success"`
212
+ Data json.RawMessage `json:"data"`
213
+ Error struct {
214
+ Code int `json:"code"`
215
+ } `json:"error"`
216
+ }
217
+ if json.Unmarshal(b, &wire) != nil {
218
+ return nil, 0, errors.New("scheduler schema")
219
+ }
220
+ if !wire.Success {
221
+ return nil, wire.Error.Code, errors.New("scheduler refused")
222
+ }
223
+ return wire.Data, 0, nil
224
+ }
225
+ data, code, e := call("list", 3, url.Values{"start": {"0"}, "limit": {"500"}})
226
+ legacy := code == 103
227
+ if legacy {
228
+ data, code, e = call("list", 2, url.Values{"offset": {"0"}, "limit": {"500"}})
229
+ }
230
+ if e != nil {
231
+ if code == 106 || code == 107 || code == 119 {
232
+ return "session_expired", "session_expired"
233
+ }
234
+ return "unavailable", "scheduler_unavailable"
235
+ }
236
+ type item struct {
237
+ ID json.Number `json:"id"`
238
+ Name string `json:"name"`
239
+ Owner string `json:"owner"`
240
+ RealOwner string `json:"real_owner"`
241
+ Enable *bool `json:"enable"`
242
+ Type string `json:"type"`
243
+ Extra struct {
244
+ Script string `json:"script"`
245
+ } `json:"extra"`
246
+ }
247
+ var list struct {
248
+ Tasks []item `json:"tasks"`
249
+ Total *int `json:"total"`
250
+ }
251
+ if json.Unmarshal(data, &list) != nil || list.Total == nil || *list.Total != len(list.Tasks) || len(list.Tasks) > 500 {
252
+ return "unavailable", "scheduler_unavailable"
253
+ }
254
+ for _, expected := range tasks {
255
+ // Helper control tasks are individually approved on demand. Enable is the
256
+ // activation anchor; live helper health remains required separately.
257
+ if strings.HasPrefix(expected.Op, "helper_") && expected.Op != "helper_enable" {
258
+ continue
259
+ }
260
+ var found *item
261
+ for n := range list.Tasks {
262
+ if list.Tasks[n].Name == expected.Name {
263
+ if found != nil {
264
+ return "authorization_required", "maintenance_task_mismatch"
265
+ }
266
+ found = &list.Tasks[n]
267
+ }
268
+ }
269
+ if found == nil {
270
+ return "authorization_required", "grant_missing"
271
+ }
272
+ valid := func(t item) bool {
273
+ return t.Owner == "root" && t.RealOwner == "root" && t.Enable != nil && !*t.Enable && t.Type == "script"
274
+ }
275
+ if !valid(*found) {
276
+ return "authorization_required", "maintenance_task_mismatch"
277
+ }
278
+ version := 4
279
+ if legacy {
280
+ version = 3
281
+ }
282
+ detail, _, e := call("get", version, url.Values{"id": {string(found.ID)}, "real_owner": {"root"}})
283
+ if e != nil {
284
+ return "unavailable", "scheduler_unavailable"
285
+ }
286
+ var full item
287
+ if json.Unmarshal(detail, &full) != nil || full.ID != found.ID || full.Name != expected.Name || !valid(full) {
288
+ return "authorization_required", "maintenance_task_mismatch"
289
+ }
290
+ if full.Extra.Script != expected.Script {
291
+ if strings.HasPrefix(full.Extra.Script, expected.Header) {
292
+ return "authorization_required", "grant_incompatible"
293
+ }
294
+ return "authorization_required", "maintenance_task_mismatch"
295
+ }
296
+ }
297
+ return "ready", nil
298
+ }
@@ -0,0 +1,96 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "dsmapp/internal/api"
6
+ "encoding/json"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "slices"
11
+ "testing"
12
+ )
13
+
14
+ func TestAuthorizationFailClosed(t *testing.T) {
15
+ cfg := Config{PackageID: "RootDemo", Version: "0.1.0-0001", RootAuthorization: `{"schema":2}`}
16
+ if _, e := cfg.authorizationPlan(); e == nil {
17
+ t.Fatal("accepted missing pin")
18
+ }
19
+ if _, e := cfg.authorizationRequest(context.Background(), api.Caller{}, json.RawMessage(`{"op":"arbitrary"}`)); e == nil {
20
+ t.Fatal("accepted arbitrary params")
21
+ }
22
+ if len((Config{}).authorizationRoutes()) != 0 {
23
+ t.Fatal("default registered root routes")
24
+ }
25
+ }
26
+ func TestAuthorizationDocuments(t *testing.T) {
27
+ for _, state := range []string{"ready", "authorization_required", "not_admin", "unavailable"} {
28
+ b, e := json.Marshal(authorizationDocument(state, nil))
29
+ if e != nil {
30
+ t.Fatal(e)
31
+ }
32
+ var d map[string]any
33
+ json.Unmarshal(b, &d)
34
+ if d["command"] != "authorization" || d["schema_version"] != float64(1) || d["state"] != state {
35
+ t.Fatal(string(b))
36
+ }
37
+ }
38
+ }
39
+
40
+ func TestSchedulerRejectsChangedTaskAndFallsBackOnlyOn103(t *testing.T) {
41
+ for _, legacy := range []bool{false, true} {
42
+ altered := false
43
+ var versions []string
44
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45
+ q := r.URL.Query()
46
+ versions = append(versions, q.Get("method")+":"+q.Get("version"))
47
+ if q.Get("method") == "list" && q.Get("version") == "3" && legacy {
48
+ io.WriteString(w, `{"success":false,"error":{"code":103}}`)
49
+ return
50
+ }
51
+ script := "approved"
52
+ if altered {
53
+ script = "unapproved"
54
+ }
55
+ item := map[string]any{"id": 1, "name": "RootDemo root: whoami", "owner": "root", "real_owner": "root", "enable": false, "type": "script", "extra": map[string]string{"script": script}}
56
+ var data any = item
57
+ if q.Get("method") == "list" {
58
+ data = map[string]any{"tasks": []any{item}, "total": 1}
59
+ }
60
+ json.NewEncoder(w).Encode(map[string]any{"success": true, "data": data})
61
+ }))
62
+ request := httptest.NewRequest("POST", "/", nil)
63
+ request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
64
+ tasks := []authorizationTask{{Op: "whoami", Name: "RootDemo root: whoami", Script: "approved", Header: "header"}}
65
+ state, _ := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
66
+ if state != "ready" {
67
+ t.Fatal(state)
68
+ }
69
+ if legacy && !slices.Contains(versions, "get:3") {
70
+ t.Fatal(versions)
71
+ }
72
+ if !legacy && slices.Contains(versions, "get:3") {
73
+ t.Fatal(versions)
74
+ }
75
+ altered = true
76
+ state, reason := authorizationSchedulerAt(context.Background(), request, tasks, server.URL)
77
+ if state != "authorization_required" || reason != "maintenance_task_mismatch" {
78
+ t.Fatal(state, reason)
79
+ }
80
+ server.Close()
81
+ }
82
+ }
83
+ func TestSchedulerPermissionFailureIsNotLegacyFallback(t *testing.T) {
84
+ calls := 0
85
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86
+ calls++
87
+ io.WriteString(w, `{"success":false,"error":{"code":105}}`)
88
+ }))
89
+ defer server.Close()
90
+ request := httptest.NewRequest("POST", "/", nil)
91
+ request.AddCookie(&http.Cookie{Name: "id", Value: "fixture-session"})
92
+ state, _ := authorizationSchedulerAt(context.Background(), request, nil, server.URL)
93
+ if state != "unavailable" || calls != 1 {
94
+ t.Fatal(state, calls)
95
+ }
96
+ }
@@ -0,0 +1,89 @@
1
+ <script setup lang="ts">
2
+ import {computed,onMounted,onUnmounted,ref} from 'vue'
3
+ import {Button,Callout,Dialog,Surface} from '@synopackageland/ui'
4
+ import {ShieldCheck,TriangleAlert} from '@lucide/vue'
5
+ import type {AuthorizationStatus,Context} from '../context'
6
+ import {authorizationCopy} from './i18n'
7
+ const props=defineProps<{ctx:Context}>()
8
+ const emit=defineEmits<{ready:[]}>()
9
+ const state=ref<AuthorizationStatus>()
10
+ const pendingState=ref<AuthorizationStatus>()
11
+ const busy=ref(false)
12
+ const failure=ref(false)
13
+ const confirmRevoke=ref(false)
14
+ let disposed=false,sequence=0,pendingRecheck=false
15
+ let unsubscribe:(()=>void)|undefined
16
+ const text=computed(()=>authorizationCopy(props.ctx.locale,props.ctx.displayName||'__DISPLAY_NAME__'))
17
+ const ready=computed(()=>state.value?.state==='ready')
18
+ const shown=computed(()=>state.value||pendingState.value)
19
+ const required=computed(()=>shown.value?.state==='authorization_required')
20
+ const untrusted=computed(()=>['helper_untrusted','cli_untrusted'].includes(shown.value?.reason||''))
21
+ const canAuthorize=computed(()=>required.value&&!untrusted.value)
22
+ const unavailableKey=computed(()=>shown.value?.reason==='credential_not_ready'?'credential':'unavailable')
23
+ const title=computed(()=>!shown.value?text.value('checking'):required.value?text.value('title'):shown.value.state==='ready'?text.value('ready'):shown.value.state==='not_admin'?text.value('not_admin.title'):shown.value.state==='session_expired'?text.value('session_expired.title'):text.value(unavailableKey.value+'.title'))
24
+ const body=computed(()=>required.value?text.value('purpose'):shown.value?.state==='not_admin'?text.value('not_admin.body'):shown.value?.state==='session_expired'?text.value('session_expired.body'):shown.value?.state==='unavailable'?text.value(unavailableKey.value+'.body'):text.value('purpose'))
25
+ const reasons=['grant_missing','helper_untrusted','cli_untrusted','grant_incompatible','package_identity_changed','maintenance_task_missing','maintenance_task_mismatch','helper_health_rejected','capability_missing']
26
+ const reason=computed(()=>reasons.includes(shown.value?.reason||'')?shown.value!.reason!:'unknown')
27
+ const next=computed(()=>untrusted.value?'next.reinstall':reason.value==='grant_missing'?'next.install':reason.value==='grant_incompatible'?'next.update':'next.repair')
28
+ async function action(method:'status'|'request'|'revoke'){
29
+ if(busy.value||disposed)return
30
+ const current=++sequence
31
+ pendingState.value=method==='request'&&state.value?.state==='authorization_required'?state.value:undefined
32
+ busy.value=true;failure.value=false
33
+ // Mutation/revalidation immediately hides application content. A request
34
+ // keeps its prior reason visible while DSM's confirmation is pending.
35
+ state.value=undefined
36
+ try {
37
+ const result=await props.ctx.authorization?.[method]()
38
+ if(disposed||current!==sequence)return
39
+ if(!result||result.command!=='authorization'||result.schema_version!==1||!['ready','authorization_required','not_admin','session_expired','unavailable'].includes(result.state))throw Error('invalid status')
40
+ state.value=result
41
+ if(result.state==='ready')emit('ready')
42
+ } catch(e){if(!disposed&&current===sequence){
43
+ state.value=(e as {code?:string}).code==='session_required'?{command:'authorization',schema_version:1,state:'session_expired',reason:'session_expired'}:pendingState.value||{command:'authorization',schema_version:1,state:'unavailable',reason:'service_unavailable'}
44
+ failure.value=true
45
+ }} finally{if(!disposed&&current===sequence){busy.value=false;pendingState.value=undefined;if(pendingRecheck){pendingRecheck=false;void action('status')}}}
46
+ }
47
+ onMounted(()=>{unsubscribe=props.ctx.reopen(()=>{if(busy.value)pendingRecheck=true;else void action('status')});void action('status')})
48
+ onUnmounted(()=>{disposed=true;sequence++;if(typeof unsubscribe==='function')unsubscribe()})
49
+ </script>
50
+ <template>
51
+ <p class="authorization-announcement" role="status" aria-live="polite" aria-atomic="true">{{ busy ? text('authorizing') : title }}</p>
52
+ <div v-if="!ready" class="authorization-view" :aria-busy="busy" :data-authorization-state="state?.state || 'checking'">
53
+ <Surface class="authorization-card" padding="lg" raised :aria-label="title">
54
+ <span class="authorization-icon" aria-hidden="true"><component :is="shown?.state==='unavailable'||shown?.state==='session_expired'?TriangleAlert:ShieldCheck" class="authorization-icon-glyph" /></span>
55
+ <h1 class="authorization-title">{{ title }}</h1>
56
+ <p class="authorization-body">{{ body }}</p>
57
+ <Callout v-if="required" class="authorization-status" :tone="reason==='grant_missing'?'info':'warning'" :title="text('reason.'+reason)">{{ text(next) }}</Callout>
58
+ <Callout v-if="failure" class="authorization-failed" tone="warning">{{ text('failed') }}</Callout>
59
+ <div class="authorization-actions">
60
+ <Button v-if="canAuthorize" variant="primary" :loading="busy" :disabled="busy" @click="action('request')">{{ busy ? text('authorizing') : text('authorize') }}</Button>
61
+ <Button v-else variant="primary" :loading="busy" :disabled="busy" @click="action('status')">{{ busy ? text('checking') : text('retry') }}</Button>
62
+ </div>
63
+ </Surface>
64
+ </div>
65
+ <div v-else class="authorization-authorized">
66
+ <div class="authorization-ready"><ShieldCheck :size="16" aria-hidden="true"/><span>{{ text('ready') }}</span><Button v-if="ctx.authorization?.canManage?.()" variant="secondary" :disabled="busy" @click="confirmRevoke=true">{{ text('revoke') }}</Button></div>
67
+ <slot />
68
+ </div>
69
+ <Dialog v-model:open="confirmRevoke" :title="text('revoke.title')" :description="text('revoke.body')">
70
+ <template #footer><Button variant="secondary" @click="confirmRevoke=false">{{ text('cancel') }}</Button><Button variant="danger" :disabled="busy" @click="confirmRevoke=false;action('revoke')">{{ text('revoke') }}</Button></template>
71
+ </Dialog>
72
+ </template>
73
+ <style scoped>
74
+ .authorization-announcement{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
75
+ /* T0156: auto margins center the whole card but preserve top scrolling in a short window. */
76
+ .authorization-view{display:flex;align-items:flex-start;justify-content:center;min-height:100%;height:100%;box-sizing:border-box;padding:24px;overflow-y:auto;color:var(--ui-text);font-family:var(--ui-font-sans)}
77
+ .authorization-card{display:flex;flex-direction:column;align-items:center;gap:12px;width:100%;max-width:28rem;margin:auto;text-align:center}
78
+ .authorization-icon{display:flex;align-items:center;justify-content:center;width:48px;height:48px;flex-shrink:0;border-radius:9999px;background:var(--ui-primary-soft);color:var(--ui-primary)}
79
+ .authorization-icon-glyph{width:24px;height:24px}
80
+ .authorization-title{font-size:16px;font-weight:600;line-height:1.4;color:var(--ui-text);margin:0;text-wrap:balance}
81
+ .authorization-body{max-width:24rem;font-size:13px;line-height:1.6;color:var(--ui-text);margin:0;text-align:center;text-wrap:pretty}
82
+ .authorization-status,.authorization-failed{width:100%;text-align:left}
83
+ /* Essential recovery text uses the full foreground; DSM's muted token is below 4.5:1. */
84
+ .authorization-status,.authorization-failed{color:var(--ui-text)}
85
+ .authorization-actions{display:flex;justify-content:center;width:100%;margin-top:4px}
86
+ .authorization-authorized{display:flex;flex-direction:column;height:100%;min-height:0}.authorization-authorized :deep(.app-shell){flex:1;min-height:0;height:auto}
87
+ .authorization-ready{display:flex;align-items:center;justify-content:flex-end;gap:8px;padding:8px 16px;color:var(--ui-text);font:13px var(--ui-font-sans)}
88
+ @media(max-width:480px){.authorization-view{padding:16px}}
89
+ </style>