@synopackageland/templates 0.0.0-stage → 0.9.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/package.json +11 -4
  4. package/templates/_shared/AGENTS.md +146 -0
  5. package/templates/_shared/CLAUDE.md +1 -0
  6. package/templates/_shared/GO-LICENSE +27 -0
  7. package/templates/_shared/GO-PATENTS +22 -0
  8. package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
  9. package/templates/_shared/third-party-notices.mjs +55 -0
  10. package/templates/binary/LICENSES/GO-LICENSE +27 -0
  11. package/templates/binary/LICENSES/GO-PATENTS +22 -0
  12. package/templates/binary/dsm-package.yaml +46 -0
  13. package/templates/binary/example/main.go +16 -0
  14. package/templates/binary/gitignore +2 -0
  15. package/templates/binary/i18n/messages/de.json +4 -0
  16. package/templates/binary/i18n/messages/en.json +4 -0
  17. package/templates/binary/i18n/messages/es.json +4 -0
  18. package/templates/binary/i18n/messages/fr.json +4 -0
  19. package/templates/binary/i18n/messages/it.json +4 -0
  20. package/templates/binary/i18n/messages/ja.json +4 -0
  21. package/templates/binary/i18n/messages/ko.json +4 -0
  22. package/templates/binary/i18n/messages/zh-CN.json +4 -0
  23. package/templates/binary/i18n/messages/zh-TW.json +4 -0
  24. package/templates/binary/ui/app.css +4 -0
  25. package/templates/binary/ui/app.js +32 -0
  26. package/templates/compose/compose.yaml +8 -0
  27. package/templates/compose/synology-app.yaml +16 -0
  28. package/templates/native-go/README.md +368 -0
  29. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
  30. package/templates/native-go/backend/cmd/app/main.go +101 -0
  31. package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
  32. package/templates/native-go/backend/go.mod +9 -0
  33. package/templates/native-go/backend/go.sum +4 -0
  34. package/templates/native-go/backend/internal/api/permissions.go +26 -0
  35. package/templates/native-go/backend/internal/api/permissions.json +5 -0
  36. package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
  37. package/templates/native-go/backend/internal/app/background.go +249 -0
  38. package/templates/native-go/backend/internal/app/background_none.go +8 -0
  39. package/templates/native-go/backend/internal/app/background_owner.go +38 -0
  40. package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
  41. package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
  42. package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
  43. package/templates/native-go/backend/internal/app/background_test.go +320 -0
  44. package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
  45. package/templates/native-go/backend/internal/app/delegate_config_test.go +28 -0
  46. package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
  47. package/templates/native-go/backend/internal/app/identity.go +20 -0
  48. package/templates/native-go/backend/internal/app/identity_test.go +15 -0
  49. package/templates/native-go/backend/internal/app/migrations.go +41 -0
  50. package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
  51. package/templates/native-go/backend/internal/app/portal.go +30 -0
  52. package/templates/native-go/backend/internal/app/portal_test.go +55 -0
  53. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  54. package/templates/native-go/backend/internal/app/routes.go +137 -0
  55. package/templates/native-go/backend/internal/app/routes_test.go +197 -0
  56. package/templates/native-go/backend/internal/app/session_files.go +9 -0
  57. package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
  58. package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
  59. package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
  60. package/templates/native-go/dsm-package.yaml +71 -0
  61. package/templates/native-go/gitignore +1 -0
  62. package/templates/native-go/i18n/glossary.json +663 -0
  63. package/templates/native-go/i18n/messages/de.json +29 -0
  64. package/templates/native-go/i18n/messages/en.json +29 -0
  65. package/templates/native-go/i18n/messages/es.json +29 -0
  66. package/templates/native-go/i18n/messages/fr.json +29 -0
  67. package/templates/native-go/i18n/messages/it.json +29 -0
  68. package/templates/native-go/i18n/messages/ja.json +29 -0
  69. package/templates/native-go/i18n/messages/ko.json +29 -0
  70. package/templates/native-go/i18n/messages/zh-CN.json +29 -0
  71. package/templates/native-go/i18n/messages/zh-TW.json +29 -0
  72. package/templates/native-go/ui/app.css +45 -0
  73. package/templates/native-go/ui/app.js +111 -0
  74. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
  75. package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
  76. package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
  77. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
  78. package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
  79. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
  80. package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
  81. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
  82. package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
  83. package/templates/native-go-root-ops/backend/internal/app/executor.go +140 -0
  84. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +118 -0
  85. package/templates/native-go-root-ops/root/whoami.sh +3 -0
  86. package/templates/native-go-vue/README.md +51 -0
  87. package/templates/native-go-vue/frontend/DESIGN.md +61 -0
  88. package/templates/native-go-vue/frontend/package-lock.json +2550 -0
  89. package/templates/native-go-vue/frontend/package.json +23 -0
  90. package/templates/native-go-vue/frontend/src/App.vue +90 -0
  91. package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
  92. package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
  93. package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
  94. package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
  95. package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
  96. package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
  97. package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
  98. package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
  99. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
  100. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
  101. package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
  102. package/templates/native-go-vue/frontend/src/main.ts +33 -0
  103. package/templates/native-go-vue/frontend/src/style.css +27 -0
  104. package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
  105. package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
  106. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
  107. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  108. package/templates/native-go-vue/frontend/vite.config.ts +11 -0
  109. package/templates/native-go-vue/gitignore +2 -0
@@ -0,0 +1,320 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
15
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
16
+ "github.com/synopackageland/syno-pkg-land-sdk/filestation"
17
+ )
18
+
19
+ type fakeFiles struct {
20
+ data []byte
21
+ err error
22
+ calls int
23
+ folder string
24
+ rel string
25
+ offset int
26
+ page filestation.Page
27
+ }
28
+
29
+ func (f *fakeFiles) Browse(_ context.Context, _ api.Caller, _ *http.Request, folder string, offset int) (filestation.Page, error) {
30
+ f.calls++
31
+ f.folder, f.offset = folder, offset
32
+ return f.page, f.err
33
+ }
34
+
35
+ func (f *fakeFiles) ReadFile(_ context.Context, _ api.Caller, _ *http.Request, folder, rel string) ([]byte, error) {
36
+ f.calls++
37
+ f.folder, f.rel = folder, rel
38
+ return f.data, f.err
39
+ }
40
+
41
+ type bgEnv struct {
42
+ t *testing.T
43
+ store *background.Store
44
+ files *fakeFiles
45
+ srv map[string]*api.Server
46
+ }
47
+
48
+ func newBgEnv(t *testing.T) *bgEnv {
49
+ lookupSubmitterUID = func(string) string { return "1024" }
50
+ t.Cleanup(func() { lookupSubmitterUID = osLookupUID })
51
+ store := background.NewStore(filepath.Join(t.TempDir(), "background"))
52
+ files := &fakeFiles{data: []byte("hello")}
53
+ return &bgEnv{t: t, store: store, files: files, srv: map[string]*api.Server{}}
54
+ }
55
+
56
+ func (e *bgEnv) call(auth api.Authenticator, method, params string) (string, map[string]any) {
57
+ e.t.Helper()
58
+ // SDK policy provides owner routes immediately.
59
+ routes := newBackgroundRoutes(e.store, e.files)
60
+ srv, err := api.NewServerWithPermissions(auth, []byte(`{"schema":1,"default":"administrators","methods":{}}`), routes...)
61
+ if err != nil {
62
+ e.t.Fatal(err)
63
+ }
64
+ req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(`{"method":"`+method+`","params":`+params+`}`))
65
+ req.Header.Set("Content-Type", "application/json")
66
+ rec := httptest.NewRecorder()
67
+ srv.ServeHTTP(rec, req)
68
+ var out map[string]any
69
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
70
+ e.t.Fatalf("not JSON: %s", rec.Body.String())
71
+ }
72
+ code := ""
73
+ if er, ok := out["error"].(map[string]any); ok {
74
+ code, _ = er["code"].(string)
75
+ }
76
+ return code, out
77
+ }
78
+
79
+ func data(out map[string]any) map[string]any { return out["data"].(map[string]any) }
80
+
81
+ var bgSample = map[string]string{
82
+ "bg.schedules.list": `{}`,
83
+ "bg.schedules.create": `{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}`,
84
+ "bg.schedules.disable": `{"id":"missing"}`,
85
+ "bg.schedules.delete": `{"id":"missing"}`,
86
+ "bg.schedules.enable": `{"id":"missing","consent":true}`,
87
+ "bg.status": `{}`,
88
+ "bg.jobs.purge": `{"owner":{"user":"bob","uid":"1025"}}`,
89
+ "bg.browse": `{"folder":"/share","offset":0}`,
90
+ "bg.jobs.submit": `{"key":"k1","folder":"/share","relative":"a.txt"}`,
91
+ "bg.jobs.list": `{}`,
92
+ "bg.jobs.get": `{"id":"` + strings.Repeat("a", 32) + `"}`,
93
+ "bg.jobs.cancel": `{"id":"` + strings.Repeat("a", 32) + `"}`,
94
+ "bg.jobs.result": `{"id":"` + strings.Repeat("a", 32) + `"}`,
95
+ }
96
+
97
+ func TestBackgroundSDKPoliciesAuthenticateEveryRoute(t *testing.T) {
98
+ e := newBgEnv(t)
99
+ routes := newBackgroundRoutes(e.store, e.files)
100
+ if len(routes) != len(bgSample) {
101
+ t.Fatalf("%d routes, %d samples", len(routes), len(bgSample))
102
+ }
103
+ for _, r := range routes {
104
+ if rule(t, r) == "public" {
105
+ t.Errorf("%s must not be public", r.Method())
106
+ }
107
+ params, ok := bgSample[r.Method()]
108
+ if !ok {
109
+ t.Fatalf("no sample for %s", r.Method())
110
+ }
111
+ code, _ := e.call(bob, r.Method(), params)
112
+ if r.Method() == "bg.jobs.purge" || r.Method() == "bg.schedules.enable" {
113
+ if code != "admin_required" {
114
+ t.Errorf("%s as ordinary user: %q", r.Method(), code)
115
+ }
116
+ } else if code == "admin_required" || code == "unsupported" || code == "disabled" {
117
+ t.Errorf("%s unexpectedly blocked: %q", r.Method(), code)
118
+ }
119
+ if code, _ := e.call(nobody, r.Method(), params); code != "session_required" {
120
+ t.Errorf("%s without a session: %q", r.Method(), code)
121
+ }
122
+ }
123
+ }
124
+
125
+ func TestBackgroundStatusReportsLimitsAndWorker(t *testing.T) {
126
+ e := newBgEnv(t)
127
+ code, out := e.call(alice, "bg.status", `{}`)
128
+ d := data(out)
129
+ if code != "" || d["algorithm"] != "sha256-v1" || d["worker"] != nil {
130
+ t.Fatalf("%s %v", code, out)
131
+ }
132
+ limits := d["limits"].(map[string]any)
133
+ if limits["maxInputBytes"].(float64) != 512*1024 || limits["maxQueued"].(float64) != 16 || limits["maxTotalBytes"].(float64) != 16<<20 || limits["jobTimeoutSeconds"].(float64) != 60 {
134
+ t.Fatalf("limits: %v", limits)
135
+ }
136
+ if err := e.store.Heartbeat(context.Background(), background.WorkerInfo{PID: 9, UID: 255853, Username: "pkg"}); err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ _, out = e.call(alice, "bg.status", `{}`)
140
+ w := data(out)["worker"].(map[string]any)
141
+ if w["alive"] != true || w["uid"].(float64) != 255853 {
142
+ t.Fatalf("worker: %v", w)
143
+ }
144
+ }
145
+
146
+ func TestBackgroundSubmitAvailableOnFreshStore(t *testing.T) {
147
+ e := newBgEnv(t)
148
+ code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
149
+ if code != "" || e.files.calls != 1 {
150
+ t.Fatalf("%q, NAS reads %d", code, e.files.calls)
151
+ }
152
+ }
153
+
154
+ func TestBackgroundSubmitImportsSelectedFileAndIsIdempotent(t *testing.T) {
155
+ e := newBgEnv(t)
156
+ params := `{"key":"k1","folder":"/share","relative":"docs/a.txt"}`
157
+ code, out := e.call(alice, "bg.jobs.submit", params)
158
+ job := data(out)["job"].(map[string]any)
159
+ if code != "" || job["state"] != "queued" || job["name"] != "a.txt" || data(out)["duplicate"] != false {
160
+ t.Fatalf("%s %v", code, out)
161
+ }
162
+ if e.files.folder != "/share" || e.files.rel != "docs/a.txt" {
163
+ t.Fatalf("adapter saw %q %q", e.files.folder, e.files.rel)
164
+ }
165
+ submitter := job["submitter"].(map[string]any)
166
+ if submitter["user"] != "alice" || submitter["uid"] != "1024" {
167
+ t.Fatalf("submitter: %v", submitter)
168
+ }
169
+ _, again := e.call(alice, "bg.jobs.submit", params)
170
+ if data(again)["duplicate"] != true || data(again)["job"].(map[string]any)["id"] != job["id"] {
171
+ t.Fatalf("duplicate: %v", again)
172
+ }
173
+ e.files.data = []byte("other")
174
+ if code, _ := e.call(alice, "bg.jobs.submit", params); code != "conflict" {
175
+ t.Fatalf("same key with different bytes: %q", code)
176
+ }
177
+ }
178
+
179
+ func TestBackgroundSubmitPassesAdapterRefusalsThrough(t *testing.T) {
180
+ e := newBgEnv(t)
181
+ e.files.err = &api.Error{Code: "limit-exceeded", Message: "x"}
182
+ if code, _ := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"]); code != "limit-exceeded" {
183
+ t.Fatalf("%q", code)
184
+ }
185
+ if jobs, _ := e.store.List(context.Background()); len(jobs) != 0 {
186
+ t.Fatal("a failed import must never enter the queue")
187
+ }
188
+ }
189
+
190
+ func TestBackgroundSubmitValidatesParams(t *testing.T) {
191
+ e := newBgEnv(t)
192
+ for _, p := range []string{
193
+ `{}`, `{"key":"k","folder":"/share","relative":"a","extra":1}`, `{"key":"bad key","folder":"/share","relative":"a"}`,
194
+ `{"key":"k","folder":"/share","relative":""}`,
195
+ } {
196
+ if code, _ := e.call(alice, "bg.jobs.submit", p); code != "bad_request" {
197
+ t.Errorf("%s: %q", p, code)
198
+ }
199
+ }
200
+ }
201
+
202
+ func TestBackgroundQueueFullAndQuotaErrorsHaveStableCodes(t *testing.T) {
203
+ e := newBgEnv(t)
204
+ for i := 0; i < background.MaxQueued; i++ {
205
+ lookupSubmitterUID = func(string) string { return fmt.Sprint(1024 + i/2) }
206
+ key := "k" + string(rune('a'+i))
207
+ if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"`+key+`","folder":"/share","relative":"a.txt"}`); code != "" {
208
+ t.Fatalf("%d: %q", i, code)
209
+ }
210
+ }
211
+ lookupSubmitterUID = func(string) string { return "9999" }
212
+ if code, _ := e.call(alice, "bg.jobs.submit", `{"key":"more","folder":"/share","relative":"a.txt"}`); code != "queue-full" {
213
+ t.Fatalf("%q", code)
214
+ }
215
+ }
216
+
217
+ func TestBackgroundListGetCancelAndResult(t *testing.T) {
218
+ e := newBgEnv(t)
219
+ _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
220
+ id := data(out)["job"].(map[string]any)["id"].(string)
221
+ idp := `{"id":"` + id + `"}`
222
+
223
+ if _, out = e.call(alice, "bg.jobs.list", `{}`); len(data(out)["jobs"].([]any)) != 1 {
224
+ t.Fatalf("%v", out)
225
+ }
226
+ if code, out := e.call(alice, "bg.jobs.get", idp); code != "" || data(out)["job"].(map[string]any)["id"] != id {
227
+ t.Fatalf("%s %v", code, out)
228
+ }
229
+ if code, _ := e.call(alice, "bg.jobs.result", idp); code != "not_ready" {
230
+ t.Fatalf("result of a queued job: %q", code)
231
+ }
232
+ ctx := context.Background()
233
+ if _, err := e.store.Claim(ctx); err != nil {
234
+ t.Fatal(err)
235
+ }
236
+ sum := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
237
+ if err := e.store.Finish(ctx, id, background.Outcome{State: background.StateSucceeded, Result: &background.Result{SHA256: sum, Bytes: 5}}); err != nil {
238
+ t.Fatal(err)
239
+ }
240
+ code, out := e.call(alice, "bg.jobs.result", idp)
241
+ res := data(out)["result"].(map[string]any)
242
+ if code != "" || res["sha256"] != sum || res["bytes"].(float64) != 5 || res["worker"] != "sha256-v1" || res["jobId"] != id {
243
+ t.Fatalf("%s %v", code, out)
244
+ }
245
+ if code, _ := e.call(alice, "bg.jobs.cancel", idp); code != "conflict" {
246
+ t.Fatalf("cancel after completion: %q", code)
247
+ }
248
+ for _, m := range []string{"bg.jobs.get", "bg.jobs.cancel", "bg.jobs.result"} {
249
+ if code, _ := e.call(alice, m, `{"id":"`+strings.Repeat("0", 32)+`"}`); code != "not_found" {
250
+ t.Fatalf("%s unknown id: %q", m, code)
251
+ }
252
+ }
253
+ }
254
+
255
+ func TestBackgroundCancelQueuedJob(t *testing.T) {
256
+ e := newBgEnv(t)
257
+ _, out := e.call(alice, "bg.jobs.submit", `{"key":"k1","folder":"/share","relative":"a.txt"}`)
258
+ id := data(out)["job"].(map[string]any)["id"].(string)
259
+ code, out := e.call(alice, "bg.jobs.cancel", `{"id":"`+id+`"}`)
260
+ if code != "" || data(out)["job"].(map[string]any)["state"] != "canceled" {
261
+ t.Fatalf("%s %v", code, out)
262
+ }
263
+ }
264
+
265
+ func TestBackgroundWorkerAliveExpires(t *testing.T) {
266
+ if workerAlive(background.WorkerInfo{At: time.Now().Add(-time.Minute)}, time.Now()) || !workerAlive(background.WorkerInfo{At: time.Now()}, time.Now()) {
267
+ t.Fatal("a stale heartbeat is not alive")
268
+ }
269
+ }
270
+
271
+ func TestBackgroundBrowseListsThroughTheCallersSession(t *testing.T) {
272
+ e := newBgEnv(t)
273
+ e.files.page = filestation.Page{Files: []filestation.Entry{{Name: "a.txt", Path: "/share/a.txt"}}, Total: 1}
274
+ code, out := e.call(alice, "bg.browse", `{"folder":"/share","offset":100}`)
275
+ if code != "" {
276
+ t.Fatalf("%s %v", code, out)
277
+ }
278
+ if e.files.folder != "/share" || e.files.offset != 100 {
279
+ t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
280
+ }
281
+ files := data(out)["files"].([]any)
282
+ if len(files) != 1 || files[0].(map[string]any)["path"] != "/share/a.txt" {
283
+ t.Fatalf("page %v", out)
284
+ }
285
+ }
286
+
287
+ func TestBackgroundBrowseWithoutFolderListsTheShares(t *testing.T) {
288
+ e := newBgEnv(t)
289
+ if code, out := e.call(alice, "bg.browse", `{}`); code != "" {
290
+ t.Fatalf("%s %v", code, out)
291
+ }
292
+ if e.files.folder != "" || e.files.offset != 0 {
293
+ t.Fatalf("browsed %q at %d", e.files.folder, e.files.offset)
294
+ }
295
+ }
296
+
297
+ func TestBackgroundBrowseValidatesParamsAndPassesRefusalsThrough(t *testing.T) {
298
+ e := newBgEnv(t)
299
+ for _, params := range []string{`{"offset":-1}`, `{"folder":"/share","extra":1}`, `{"offset":"1"}`} {
300
+ if code, _ := e.call(alice, "bg.browse", params); code != "bad_request" {
301
+ t.Errorf("%s: %q", params, code)
302
+ }
303
+ }
304
+ if e.files.calls != 0 {
305
+ t.Fatal("invalid params must not reach the NAS")
306
+ }
307
+ e.files.err = &api.Error{Code: "denied", Message: "no"}
308
+ if code, _ := e.call(alice, "bg.browse", `{"folder":"/share"}`); code != "denied" {
309
+ t.Fatalf("got %q", code)
310
+ }
311
+ }
312
+
313
+ func TestBackgroundListWithoutJobsIsAnEmptyArray(t *testing.T) {
314
+ e := newBgEnv(t)
315
+ _, out := e.call(alice, "bg.jobs.list", `{}`)
316
+ jobs, ok := data(out)["jobs"].([]any)
317
+ if !ok || len(jobs) != 0 {
318
+ t.Fatalf("jobs must be [] not null: %v", out)
319
+ }
320
+ }
@@ -0,0 +1,57 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "regexp"
7
+ )
8
+
9
+ type DelegateSettings struct {
10
+ Scope string
11
+ Operations, Destinations []string
12
+ Lifetime string
13
+ }
14
+
15
+ var destinationKey = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
16
+
17
+ // ParseDelegateConfig consumes only build/startup settings. Legacy declarations
18
+ // retain their TTL/job branch; absent extension fields never imply permanence.
19
+ func ParseDelegateConfig(cfg Config) (DelegateSettings, error) {
20
+ out := DelegateSettings{Scope: cfg.DelegateScope, Lifetime: cfg.DelegateLifetime}
21
+ invalid := errors.New("invalid delegate build configuration")
22
+ if cfg.DelegateOperationsJSON != "" && json.Unmarshal([]byte(cfg.DelegateOperationsJSON), &out.Operations) != nil {
23
+ return DelegateSettings{}, invalid
24
+ }
25
+ if cfg.DelegateDestinationsJSON != "" && json.Unmarshal([]byte(cfg.DelegateDestinationsJSON), &out.Destinations) != nil {
26
+ return DelegateSettings{}, invalid
27
+ }
28
+ if out.Lifetime == "" {
29
+ if len(out.Destinations) != 0 {
30
+ return DelegateSettings{}, invalid
31
+ }
32
+ seen := map[string]bool{}
33
+ for _, op := range out.Operations {
34
+ switch op {
35
+ case "files.list", "files.info", "files.createFolder", "files.checkPermission", "files.write":
36
+ default:
37
+ return DelegateSettings{}, invalid
38
+ }
39
+ if seen[op] {
40
+ return DelegateSettings{}, invalid
41
+ }
42
+ seen[op] = true
43
+ }
44
+ return out, nil
45
+ }
46
+ if out.Lifetime != "until-revoked" || out.Scope == "" || len(out.Operations) != 1 || out.Operations[0] != "files.moveFromStaging" || len(out.Destinations) == 0 || len(out.Destinations) > 64 {
47
+ return DelegateSettings{}, invalid
48
+ }
49
+ seen := map[string]bool{}
50
+ for _, key := range out.Destinations {
51
+ if !destinationKey.MatchString(key) || seen[key] {
52
+ return DelegateSettings{}, invalid
53
+ }
54
+ seen[key] = true
55
+ }
56
+ return out, nil
57
+ }
@@ -0,0 +1,28 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestDelegateBuildConfigStrictlySeparatesLegacyAndPermanent(t *testing.T) {
6
+ valid := Config{DelegateScope: "home", DelegateOperationsJSON: `["files.moveFromStaging"]`, DelegateDestinationsJSON: `["save"]`, DelegateLifetime: "until-revoked"}
7
+ settings, err := ParseDelegateConfig(valid)
8
+ if err != nil || settings.Lifetime != "until-revoked" || len(settings.Destinations) != 1 {
9
+ t.Fatal(settings, err)
10
+ }
11
+ for _, cfg := range []Config{{}, {DelegateScope: "home"}, {DelegateScope: "home", DelegateOperationsJSON: `["files.write"]`}} {
12
+ settings, err = ParseDelegateConfig(cfg)
13
+ if err != nil || settings.Lifetime != "" || len(settings.Destinations) != 0 {
14
+ t.Fatal("legacy changed", settings, err)
15
+ }
16
+ }
17
+ for _, mutate := range []func(*Config){
18
+ func(c *Config) { c.DelegateLifetime = "" }, func(c *Config) { c.DelegateScope = "" }, func(c *Config) { c.DelegateOperationsJSON = `["files.write"]` },
19
+ func(c *Config) { c.DelegateDestinationsJSON = `["save","save"]` }, func(c *Config) { c.DelegateDestinationsJSON = `["Save"]` }, func(c *Config) { c.DelegateDestinationsJSON = `[]` }, func(c *Config) { c.DelegateDestinationsJSON = `null` },
20
+ func(c *Config) { c.DelegateDestinationsJSON = `["save"] {}` },
21
+ } {
22
+ cfg := valid
23
+ mutate(&cfg)
24
+ if _, err = ParseDelegateConfig(cfg); err == nil {
25
+ t.Fatal("invalid trusted config accepted", cfg)
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,5 @@
1
+ package app
2
+
3
+ import "github.com/synopackageland/syno-pkg-land-sdk/api"
4
+
5
+ func (Config) delegateRoutes() []api.Route { return nil }
@@ -0,0 +1,20 @@
1
+ package app
2
+
3
+ import (
4
+ "errors"
5
+ "path"
6
+ "strings"
7
+
8
+ "github.com/synopackageland/syno-pkg-land-sdk/identity"
9
+ )
10
+
11
+ // StandaloneIdentity registers identity.status/session/login/logout through
12
+ // Service.Wrap. Business routes keep their permissions.json policies. Call only
13
+ // for capabilities.identity=standalone; the path comes from the CGI environment.
14
+ func StandaloneIdentity(cfg Config, scriptName string) (*identity.Service, error) {
15
+ if !strings.HasPrefix(scriptName, "/") || strings.HasPrefix(scriptName, "//") || path.Clean(scriptName) != scriptName || !strings.HasSuffix(scriptName, "/cgi/api.cgi") {
16
+ return nil, errors.New("identity requires an absolute CGI script path")
17
+ }
18
+ cookiePath := strings.TrimSuffix(path.Dir(path.Dir(scriptName)), "/") + "/"
19
+ return identity.New(identity.Config{DataDir: cfg.DataDir, CookiePath: cookiePath})
20
+ }
@@ -0,0 +1,15 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestIdentityApplicationPath(t *testing.T) {
6
+ for _, tc := range []struct {
7
+ script string
8
+ valid bool
9
+ }{{"/webman/3rdparty/test/cgi/api.cgi", true}, {"/cgi/api.cgi", true}, {"", false}, {"cgi/api.cgi", false}, {"//evil/cgi/api.cgi", false}} {
10
+ _, err := StandaloneIdentity(Config{DataDir: t.TempDir()}, tc.script)
11
+ if (err == nil) != tc.valid {
12
+ t.Errorf("script %q: %v", tc.script, err)
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,41 @@
1
+ package app
2
+
3
+ import (
4
+ "os"
5
+
6
+ "github.com/synopackageland/syno-pkg-land-sdk/datamigrate"
7
+ )
8
+
9
+ // Migrations returns this app's schema migrations. The template ships one
10
+ // empty example; replace it with your own steps. Versions start at 1 and never
11
+ // change meaning. Every step must be idempotent: a crash between Apply and the
12
+ // recorded version, or two CGI requests racing on the first run after an
13
+ // upgrade, can run one step twice. A failed step stops the run with the
14
+ // previous version still recorded, and the next run resumes from there; the
15
+ // utility itself never deletes or rewrites data.
16
+ func Migrations() []datamigrate.Migration {
17
+ return []datamigrate.Migration{
18
+ {
19
+ Version: 1,
20
+ Name: "example",
21
+ Apply: func(datamigrate.Context) error {
22
+ // Nothing to do yet. Read ctx.DataDir, rewrite files with a
23
+ // temporary file + rename, and keep the step idempotent.
24
+ return nil
25
+ },
26
+ },
27
+ }
28
+ }
29
+
30
+ // Migrate applies Migrations to the package's persistent var directory. The
31
+ // CGI calls it once per request; the recorded data-version makes that a cheap
32
+ // no-op. SYNOPKG_OLD_PKGVER is set when DSM starts the app from a lifecycle
33
+ // script; in a plain CGI request the version of the last successful run is
34
+ // used instead.
35
+ func Migrate(cfg Config) error {
36
+ _, err := datamigrate.Run(cfg.DataDir, datamigrate.Info{
37
+ OldPkgVer: os.Getenv("SYNOPKG_OLD_PKGVER"),
38
+ NewPkgVer: cfg.Version,
39
+ }, Migrations())
40
+ return err
41
+ }
@@ -0,0 +1,41 @@
1
+ package app
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/synopackageland/syno-pkg-land-sdk/datamigrate"
7
+ )
8
+
9
+ func TestMigrationsAreOrderedUniqueAndValid(t *testing.T) {
10
+ migrations := Migrations()
11
+ if len(migrations) == 0 {
12
+ t.Fatal("the template must ship one example migration")
13
+ }
14
+ for i, migration := range migrations {
15
+ if migration.Version < 1 || migration.Apply == nil || migration.Name == "" {
16
+ t.Fatalf("migration %d is invalid: %+v", i, migration)
17
+ }
18
+ if i > 0 && migrations[i-1].Version >= migration.Version {
19
+ t.Fatalf("migrations are not strictly version-ascending: %+v", migrations)
20
+ }
21
+ }
22
+ }
23
+
24
+ func TestMigrateRecordsTheSchemaAndPackageVersion(t *testing.T) {
25
+ dir := t.TempDir()
26
+ t.Setenv("SYNOPKG_OLD_PKGVER", "0.1.0-0001")
27
+ cfg := Config{DataDir: dir, Version: "0.2.0-0002"}
28
+ if err := Migrate(cfg); err != nil {
29
+ t.Fatalf("Migrate: %v", err)
30
+ }
31
+ state, err := datamigrate.Load(dir)
32
+ if err != nil {
33
+ t.Fatalf("Load: %v", err)
34
+ }
35
+ if state.Schema != 1 || state.PackageVersion != "0.2.0-0002" {
36
+ t.Fatalf("recorded state = %+v", state)
37
+ }
38
+ if err := Migrate(cfg); err != nil {
39
+ t.Fatalf("second Migrate must be a no-op: %v", err)
40
+ }
41
+ }
@@ -0,0 +1,30 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+
7
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
8
+ "github.com/synopackageland/syno-pkg-land-sdk/portal"
9
+ )
10
+
11
+ // portalRoutes serves portal.status when dsm-package.yaml declares `portal`.
12
+ // An administrator sets the alias with DSM's own AppPortal API from the UI;
13
+ // the package only reads DSM's saved setting.
14
+ func (cfg Config) portalRoutes() []api.Route {
15
+ if !cfg.Portal {
16
+ return nil
17
+ }
18
+ return []api.Route{api.Handle("portal.status", cfg.portalStatus)}
19
+ }
20
+
21
+ func (cfg Config) portalStatus(_ context.Context, _ api.Caller, params json.RawMessage) (any, error) {
22
+ if err := decodeParams(params, &struct{}{}); err != nil {
23
+ return nil, err
24
+ }
25
+ path := cfg.PortalConfig
26
+ if path == "" {
27
+ path = portal.ConfigPath(cfg.PackageID)
28
+ }
29
+ return portal.Read(path, "SYNO.SDS."+cfg.PackageID+".Instance"), nil
30
+ }
@@ -0,0 +1,55 @@
1
+ package app
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ func portalConfig(t *testing.T, saved string) Config {
10
+ cfg := testConfig(t)
11
+ cfg.Portal = true
12
+ cfg.PortalConfig = filepath.Join(t.TempDir(), "AppPortal.json")
13
+ if saved != "" {
14
+ if err := os.WriteFile(cfg.PortalConfig, []byte(saved), 0o644); err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ }
18
+ return cfg
19
+ }
20
+
21
+ func TestPortalStatusTellsEverySignedInUserTheAlias(t *testing.T) {
22
+ cfg := portalConfig(t, `{"alias":"hello-go","id":"SYNO.SDS.HelloGo.Instance","version":3}`)
23
+ code, out := call(t, cfg, bob, "portal.status", `{}`)
24
+ if code != "" {
25
+ t.Fatalf("%q %v", code, out)
26
+ }
27
+ data := out["data"].(map[string]any)
28
+ if data["state"] != "active" || data["alias"] != "hello-go" {
29
+ t.Fatalf("%v", data)
30
+ }
31
+ if code, _ := call(t, cfg, nobody, "portal.status", `{}`); code != "session_required" {
32
+ t.Fatalf("anonymous: %q", code)
33
+ }
34
+ if code, _ := call(t, cfg, bob, "portal.status", `{"alias":"x"}`); code != "bad_request" {
35
+ t.Fatalf("params: %q", code)
36
+ }
37
+ }
38
+
39
+ func TestPortalStatusFailsClosedOnAnotherAppsSetting(t *testing.T) {
40
+ cfg := portalConfig(t, `{"alias":"hello-go","id":"SYNO.SDS.Other.Instance"}`)
41
+ _, out := call(t, cfg, bob, "portal.status", `{}`)
42
+ if data := out["data"].(map[string]any); data["state"] != "unavailable" || data["alias"] != nil {
43
+ t.Fatalf("%v", data)
44
+ }
45
+ _, out = call(t, portalConfig(t, ""), bob, "portal.status", `{}`)
46
+ if data := out["data"].(map[string]any); data["state"] != "inactive" {
47
+ t.Fatalf("%v", data)
48
+ }
49
+ }
50
+
51
+ func TestPortalStatusExistsOnlyForAPortalBuild(t *testing.T) {
52
+ if code, _ := call(t, testConfig(t), alice, "portal.status", `{}`); code != "unknown_method" {
53
+ t.Fatalf("%q", code)
54
+ }
55
+ }
@@ -0,0 +1,9 @@
1
+ package app
2
+
3
+ import "github.com/synopackageland/syno-pkg-land-sdk/api"
4
+
5
+ // rootRoutes is empty: this package declares privilege: none and never runs
6
+ // anything as root. `synopkgland init -t native-go --privilege root` creates
7
+ // root.go instead, which reads the results of administrator-approved root
8
+ // operations.
9
+ func (Config) rootRoutes() []api.Route { return nil }