@synopackageland/cli 0.3.9 → 0.4.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 (286) hide show
  1. package/README.md +20 -9
  2. package/dist/bin.js +1 -2
  3. package/dist/build-spk/generator.d.ts +0 -3
  4. package/dist/build-spk/generator.js +135 -287
  5. package/dist/build-spk/native-lifecycle.js +0 -3
  6. package/dist/build-spk/native-payload.js +0 -1
  7. package/dist/cli.js +171 -151
  8. package/dist/compose/analyze.d.ts +1 -2
  9. package/dist/compose/analyze.js +5 -48
  10. package/dist/contract/unsupported-types.d.ts +10 -0
  11. package/dist/contract/unsupported-types.js +49 -0
  12. package/dist/contract/validate.js +25 -2
  13. package/dist/contract/web-launch.js +0 -2
  14. package/dist/deploy/deploy.d.ts +1 -4
  15. package/dist/deploy/deploy.js +57 -156
  16. package/dist/deploy/homes-values.js +1 -1
  17. package/dist/deploy/plan.d.ts +18 -0
  18. package/dist/deploy/plan.js +54 -0
  19. package/dist/deploy/port-preflight.d.ts +21 -0
  20. package/dist/deploy/port-preflight.js +42 -0
  21. package/dist/deploy/spk-version.d.ts +2 -14
  22. package/dist/deploy/spk-version.js +25 -111
  23. package/dist/dev.d.ts +1 -3
  24. package/dist/dev.js +14 -28
  25. package/dist/error-registry.js +810 -857
  26. package/dist/host/dsm-client.js +23 -263
  27. package/dist/host/dsm-port-profile.d.ts +47 -0
  28. package/dist/host/dsm-port-profile.js +206 -0
  29. package/dist/host/port-info-lookup.d.ts +9 -0
  30. package/dist/host/port-info-lookup.js +87 -0
  31. package/dist/host/port-info.d.ts +33 -0
  32. package/dist/host/port-info.js +66 -0
  33. package/dist/host/types.d.ts +6 -78
  34. package/dist/index.d.ts +0 -9
  35. package/dist/index.js +0 -6
  36. package/dist/info.d.ts +0 -1
  37. package/dist/info.js +3 -10
  38. package/dist/init.d.ts +11 -1
  39. package/dist/init.js +112 -4
  40. package/dist/log.js +2 -3
  41. package/dist/native/validate.js +0 -1
  42. package/dist/standalone/binary.d.ts +9 -0
  43. package/dist/standalone/binary.js +67 -0
  44. package/dist/standalone/build.d.ts +25 -0
  45. package/dist/standalone/build.js +108 -0
  46. package/dist/standalone/dsm-window.d.ts +26 -0
  47. package/dist/standalone/dsm-window.js +251 -0
  48. package/dist/standalone/helper.d.ts +31 -0
  49. package/dist/standalone/helper.js +104 -0
  50. package/dist/standalone/i18n.d.ts +28 -0
  51. package/dist/standalone/i18n.js +161 -0
  52. package/dist/standalone/inspect-spk.d.ts +21 -0
  53. package/dist/standalone/inspect-spk.js +160 -0
  54. package/dist/standalone/manifest.d.ts +71 -0
  55. package/dist/standalone/manifest.js +276 -0
  56. package/dist/standalone/root-messages.d.ts +7 -0
  57. package/dist/standalone/root-messages.js +250 -0
  58. package/dist/standalone/root-ops.d.ts +42 -0
  59. package/dist/standalone/root-ops.js +101 -0
  60. package/dist/standalone/root-window.d.ts +35 -0
  61. package/dist/standalone/root-window.js +290 -0
  62. package/dist/standalone/spk.d.ts +22 -0
  63. package/dist/standalone/spk.js +205 -0
  64. package/dist/templates/binary/synology-app.yaml +7 -0
  65. package/dist/templates/hello-files/compose.yaml +0 -1
  66. package/dist/templates/hello-web/compose.yaml +13 -10
  67. package/dist/templates/hello-web/html/app.js +1 -23
  68. package/dist/templates/hello-web/html/index.html +1 -3
  69. package/dist/templates/hello-web-page/compose.yaml +13 -10
  70. package/dist/templates/hello-web-page/html/app.js +1 -23
  71. package/dist/templates/hello-web-page/html/index.html +1 -3
  72. package/dist/templates/native-go/README.md +268 -0
  73. package/dist/templates/native-go/backend/cmd/app/main.go +49 -0
  74. package/dist/templates/native-go/backend/cmd/helper/main.go +38 -0
  75. package/dist/templates/native-go/backend/go.mod +3 -0
  76. package/dist/templates/native-go/backend/internal/api/errors.go +33 -0
  77. package/dist/templates/native-go/backend/internal/api/registry.go +94 -0
  78. package/dist/templates/native-go/backend/internal/api/registry_test.go +329 -0
  79. package/dist/templates/native-go/backend/internal/api/server.go +155 -0
  80. package/dist/templates/native-go/backend/internal/app/root_none.go +9 -0
  81. package/dist/templates/native-go/backend/internal/app/routes.go +127 -0
  82. package/dist/templates/native-go/backend/internal/app/routes_test.go +167 -0
  83. package/dist/templates/native-go/backend/internal/dsm/auth.go +118 -0
  84. package/dist/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  85. package/dist/templates/native-go/backend/internal/helper/control.go +219 -0
  86. package/dist/templates/native-go/backend/internal/helper/control_test.go +242 -0
  87. package/dist/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  88. package/dist/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  89. package/dist/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  90. package/dist/templates/native-go/backend/internal/helper/process_other.go +5 -0
  91. package/dist/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  92. package/dist/templates/native-go/backend/internal/helper/status.go +271 -0
  93. package/dist/templates/native-go/backend/internal/helper/status_test.go +205 -0
  94. package/dist/templates/native-go/backend/internal/helper/supervision.go +84 -0
  95. package/dist/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  96. package/dist/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  97. package/dist/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  98. package/dist/templates/native-go/dsm-package.yaml +58 -0
  99. package/dist/templates/native-go/gitignore +1 -0
  100. package/dist/templates/native-go/i18n/glossary.json +662 -0
  101. package/dist/templates/native-go/i18n/messages/de.json +28 -0
  102. package/dist/templates/native-go/i18n/messages/en.json +28 -0
  103. package/dist/templates/native-go/i18n/messages/es.json +28 -0
  104. package/dist/templates/native-go/i18n/messages/fr.json +28 -0
  105. package/dist/templates/native-go/i18n/messages/it.json +28 -0
  106. package/dist/templates/native-go/i18n/messages/ja.json +28 -0
  107. package/dist/templates/native-go/i18n/messages/ko.json +28 -0
  108. package/dist/templates/native-go/i18n/messages/zh-CN.json +28 -0
  109. package/dist/templates/native-go/i18n/messages/zh-TW.json +28 -0
  110. package/dist/templates/native-go/ui/app.css +35 -0
  111. package/dist/templates/native-go/ui/app.js +94 -0
  112. package/dist/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  113. package/dist/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  114. package/dist/templates/native-go-better-auth/node/app.mjs +72 -0
  115. package/dist/templates/native-go-better-auth/node/build.mjs +2 -0
  116. package/dist/templates/native-go-better-auth/node/package-lock.json +862 -0
  117. package/dist/templates/native-go-better-auth/node/package.json +1 -0
  118. package/dist/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  119. package/dist/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  120. package/dist/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  121. package/dist/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  122. package/dist/templates/native-go-root/backend/internal/app/root.go +107 -0
  123. package/dist/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  124. package/dist/templates/native-go-root/root/whoami.sh +6 -0
  125. package/dist/templates/native-go-vue/README.md +110 -0
  126. package/dist/templates/native-go-vue/frontend/DESIGN.md +78 -0
  127. package/dist/templates/native-go-vue/frontend/package-lock.json +2507 -0
  128. package/dist/templates/native-go-vue/frontend/package.json +6 -0
  129. package/dist/templates/native-go-vue/frontend/src/App.vue +132 -0
  130. package/dist/templates/native-go-vue/frontend/src/context.ts +17 -0
  131. package/dist/templates/native-go-vue/frontend/src/main.ts +26 -0
  132. package/dist/templates/native-go-vue/frontend/src/style.css +14 -0
  133. package/dist/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  134. package/dist/templates/native-go-vue/frontend/tsconfig.json +1 -0
  135. package/dist/templates/native-go-vue/frontend/vite.config.ts +8 -0
  136. package/dist/templates/native-go-vue/gitignore +2 -0
  137. package/dist/types.d.ts +1 -1
  138. package/dist/validate.js +11 -7
  139. package/package.json +3 -7
  140. package/schema/synology-app.schema.json +36 -37
  141. package/skills/SKILL.md +4 -48
  142. package/skills/reference/create.md +11 -83
  143. package/skills/reference/deploy.md +7 -78
  144. package/skills/reference/diagnose.md +4 -101
  145. package/skills/reference/files.md +5 -41
  146. package/skills/reference/identity.md +3 -29
  147. package/skills/reference/wrap.md +0 -2
  148. package/templates/binary/synology-app.yaml +7 -0
  149. package/templates/hello-files/compose.yaml +0 -1
  150. package/templates/hello-web/compose.yaml +13 -10
  151. package/templates/hello-web/html/app.js +1 -23
  152. package/templates/hello-web/html/index.html +1 -3
  153. package/templates/hello-web-page/compose.yaml +13 -10
  154. package/templates/hello-web-page/html/app.js +1 -23
  155. package/templates/hello-web-page/html/index.html +1 -3
  156. package/templates/native-go/README.md +268 -0
  157. package/templates/native-go/backend/cmd/app/main.go +49 -0
  158. package/templates/native-go/backend/cmd/helper/main.go +38 -0
  159. package/templates/native-go/backend/go.mod +3 -0
  160. package/templates/native-go/backend/internal/api/errors.go +33 -0
  161. package/templates/native-go/backend/internal/api/registry.go +94 -0
  162. package/templates/native-go/backend/internal/api/registry_test.go +329 -0
  163. package/templates/native-go/backend/internal/api/server.go +155 -0
  164. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  165. package/templates/native-go/backend/internal/app/routes.go +127 -0
  166. package/templates/native-go/backend/internal/app/routes_test.go +167 -0
  167. package/templates/native-go/backend/internal/dsm/auth.go +118 -0
  168. package/templates/native-go/backend/internal/dsm/auth_test.go +139 -0
  169. package/templates/native-go/backend/internal/helper/control.go +219 -0
  170. package/templates/native-go/backend/internal/helper/control_test.go +242 -0
  171. package/templates/native-go/backend/internal/helper/peer_linux.go +44 -0
  172. package/templates/native-go/backend/internal/helper/peer_other.go +7 -0
  173. package/templates/native-go/backend/internal/helper/process_linux.go +167 -0
  174. package/templates/native-go/backend/internal/helper/process_other.go +5 -0
  175. package/templates/native-go/backend/internal/helper/server_linux.go +83 -0
  176. package/templates/native-go/backend/internal/helper/status.go +271 -0
  177. package/templates/native-go/backend/internal/helper/status_test.go +205 -0
  178. package/templates/native-go/backend/internal/helper/supervision.go +84 -0
  179. package/templates/native-go/backend/internal/helper/supervision_test.go +124 -0
  180. package/templates/native-go/backend/internal/helper/supervisor_linux.go +192 -0
  181. package/templates/native-go/backend/internal/helper/supervisor_linux_test.go +302 -0
  182. package/templates/native-go/dsm-package.yaml +58 -0
  183. package/templates/native-go/gitignore +1 -0
  184. package/templates/native-go/i18n/glossary.json +662 -0
  185. package/templates/native-go/i18n/messages/de.json +28 -0
  186. package/templates/native-go/i18n/messages/en.json +28 -0
  187. package/templates/native-go/i18n/messages/es.json +28 -0
  188. package/templates/native-go/i18n/messages/fr.json +28 -0
  189. package/templates/native-go/i18n/messages/it.json +28 -0
  190. package/templates/native-go/i18n/messages/ja.json +28 -0
  191. package/templates/native-go/i18n/messages/ko.json +28 -0
  192. package/templates/native-go/i18n/messages/zh-CN.json +28 -0
  193. package/templates/native-go/i18n/messages/zh-TW.json +28 -0
  194. package/templates/native-go/ui/app.css +35 -0
  195. package/templates/native-go/ui/app.js +94 -0
  196. package/templates/native-go-better-auth/backend/internal/app/identity.go +208 -0
  197. package/templates/native-go-better-auth/backend/internal/app/identity_test.go +113 -0
  198. package/templates/native-go-better-auth/node/app.mjs +72 -0
  199. package/templates/native-go-better-auth/node/build.mjs +2 -0
  200. package/templates/native-go-better-auth/node/package-lock.json +862 -0
  201. package/templates/native-go-better-auth/node/package.json +1 -0
  202. package/templates/native-go-better-auth/node/test/auth.test.mjs +31 -0
  203. package/templates/native-go-integration/backend/internal/app/identity_none.go +6 -0
  204. package/templates/native-go-integration/backend/internal/app/personal.go +203 -0
  205. package/templates/native-go-integration/backend/internal/app/personal_test.go +119 -0
  206. package/templates/native-go-root/backend/internal/app/root.go +107 -0
  207. package/templates/native-go-root/backend/internal/app/root_test.go +124 -0
  208. package/templates/native-go-root/root/whoami.sh +6 -0
  209. package/templates/native-go-vue/README.md +110 -0
  210. package/templates/native-go-vue/frontend/DESIGN.md +78 -0
  211. package/templates/native-go-vue/frontend/package-lock.json +2507 -0
  212. package/templates/native-go-vue/frontend/package.json +6 -0
  213. package/templates/native-go-vue/frontend/src/App.vue +132 -0
  214. package/templates/native-go-vue/frontend/src/context.ts +17 -0
  215. package/templates/native-go-vue/frontend/src/main.ts +26 -0
  216. package/templates/native-go-vue/frontend/src/style.css +14 -0
  217. package/templates/native-go-vue/frontend/test/apps.test.ts +148 -0
  218. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  219. package/templates/native-go-vue/frontend/vite.config.ts +8 -0
  220. package/templates/native-go-vue/gitignore +2 -0
  221. package/dist/attestation/app-package.d.ts +0 -86
  222. package/dist/attestation/app-package.js +0 -206
  223. package/dist/attestation/install-on-host.d.ts +0 -14
  224. package/dist/attestation/install-on-host.js +0 -52
  225. package/dist/attestation/keyring.d.ts +0 -21
  226. package/dist/attestation/keyring.js +0 -168
  227. package/dist/attestation/platform-service.d.ts +0 -70
  228. package/dist/attestation/platform-service.js +0 -250
  229. package/dist/build-spk/broker-glue.d.ts +0 -16
  230. package/dist/build-spk/broker-glue.js +0 -40
  231. package/dist/deploy/catalog-publish.d.ts +0 -3
  232. package/dist/deploy/catalog-publish.js +0 -7
  233. package/dist/doctor.d.ts +0 -22
  234. package/dist/doctor.js +0 -721
  235. package/dist/error-collector.d.ts +0 -2
  236. package/dist/error-collector.js +0 -46
  237. package/dist/host/broker-paths.d.ts +0 -1
  238. package/dist/host/broker-paths.js +0 -1
  239. package/dist/host/broker-upgrade-preflight.d.ts +0 -61
  240. package/dist/host/broker-upgrade-preflight.js +0 -156
  241. package/dist/host/digests.d.ts +0 -5
  242. package/dist/host/digests.js +0 -63
  243. package/dist/host/fake-nas.d.ts +0 -41
  244. package/dist/host/fake-nas.js +0 -174
  245. package/dist/mock-broker/bootstrap.d.ts +0 -2
  246. package/dist/mock-broker/bootstrap.js +0 -8
  247. package/dist/mock-broker/files.d.ts +0 -60
  248. package/dist/mock-broker/files.js +0 -408
  249. package/dist/mock-broker/oidc.d.ts +0 -31
  250. package/dist/mock-broker/oidc.js +0 -161
  251. package/dist/mock-broker/path.d.ts +0 -9
  252. package/dist/mock-broker/path.js +0 -50
  253. package/dist/mock-broker/server.d.ts +0 -19
  254. package/dist/mock-broker/server.js +0 -350
  255. package/dist/publish/build-ledger.d.ts +0 -22
  256. package/dist/publish/build-ledger.js +0 -26
  257. package/dist/publish/community-path.d.ts +0 -6
  258. package/dist/publish/community-path.js +0 -41
  259. package/dist/publish/curated-release.d.ts +0 -78
  260. package/dist/publish/curated-release.js +0 -883
  261. package/dist/publish/file-build-ledger.d.ts +0 -18
  262. package/dist/publish/file-build-ledger.js +0 -268
  263. package/dist/publish/github-pr.d.ts +0 -25
  264. package/dist/publish/github-pr.js +0 -85
  265. package/dist/publish/github-repo.d.ts +0 -8
  266. package/dist/publish/github-repo.js +0 -21
  267. package/dist/publish/platform-service-release.d.ts +0 -60
  268. package/dist/publish/platform-service-release.js +0 -333
  269. package/dist/publish/preflight.d.ts +0 -24
  270. package/dist/publish/preflight.js +0 -138
  271. package/dist/publish/publish.d.ts +0 -27
  272. package/dist/publish/publish.js +0 -136
  273. package/dist/publish/sync-definition.d.ts +0 -4
  274. package/dist/publish/sync-definition.js +0 -48
  275. package/dist/release-bin.d.ts +0 -2
  276. package/dist/release-bin.js +0 -23
  277. package/dist/templates/hello-web/html/sdk.js +0 -68
  278. package/dist/templates/hello-web-page/html/sdk.js +0 -68
  279. package/dist/templates/native/synology-app.yaml +0 -7
  280. package/dist/test-host.d.ts +0 -15
  281. package/dist/test-host.js +0 -37
  282. package/templates/hello-web/html/sdk.js +0 -68
  283. package/templates/hello-web-page/html/sdk.js +0 -68
  284. package/templates/native/synology-app.yaml +0 -7
  285. /package/dist/templates/{native → binary}/native.yaml +0 -0
  286. /package/templates/{native → binary}/native.yaml +0 -0
@@ -0,0 +1,49 @@
1
+ // Command app is the package backend. DSM runs it as a CGI (ui/cgi/api.cgi in
2
+ // the package) under the package account, once per request.
3
+ package main
4
+
5
+ import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "log"
9
+ "net/http/cgi"
10
+ "os"
11
+
12
+ "dsmapp/internal/api"
13
+ "dsmapp/internal/app"
14
+ "dsmapp/internal/dsm"
15
+ "dsmapp/internal/helper"
16
+ )
17
+
18
+ // Set by `synopkgland build-spk` through -ldflags -X; never read from a request.
19
+ var (
20
+ packageID = "dev"
21
+ version = "0.0.0"
22
+ helperInventory = ""
23
+ )
24
+
25
+ func main() {
26
+ log.SetFlags(0)
27
+ log.SetPrefix(packageID + ": ")
28
+ if os.Getenv("GATEWAY_INTERFACE") == "" {
29
+ fmt.Fprintf(os.Stderr, "%s %s: this program runs as the package's DSM CGI; open the app from the DSM main menu.\n", packageID, version)
30
+ os.Exit(2)
31
+ }
32
+ cfg := app.Config{
33
+ PackageID: packageID,
34
+ Version: version,
35
+ DataDir: "/var/packages/" + packageID + "/var",
36
+ }
37
+ if helperInventory != "" {
38
+ if json.Unmarshal([]byte(helperInventory), &cfg.HelperInventory) != nil || cfg.HelperInventory.Package != packageID || cfg.HelperInventory.Version != version {
39
+ cfg.HelperInventory = helper.Inventory{Package: packageID}
40
+ }
41
+ }
42
+ srv, err := api.NewServer(dsm.NewAuthenticator(), app.Routes(cfg)...)
43
+ if err != nil {
44
+ log.Fatal(err)
45
+ }
46
+ if err := cgi.Serve(srv); err != nil {
47
+ log.Fatal(err)
48
+ }
49
+ }
@@ -0,0 +1,38 @@
1
+ // Built only when dsm-package.yaml explicitly opts into integration.
2
+ package main
3
+
4
+ import (
5
+ "context"
6
+ "dsmapp/internal/helper"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ )
11
+
12
+ var helperInventory string
13
+
14
+ func main() {
15
+ var i helper.Inventory
16
+ if len(os.Args) != 2 || json.Unmarshal([]byte(helperInventory), &i) != nil {
17
+ os.Exit(2)
18
+ }
19
+ if os.Args[1] == "check-stopped" || os.Args[1] == "diagnose" {
20
+ c, err := helper.LocalClient(i)
21
+ if err != nil {
22
+ os.Exit(1)
23
+ }
24
+ if os.Args[1] == "diagnose" {
25
+ json.NewEncoder(os.Stdout).Encode(c.Status(context.Background()))
26
+ return
27
+ }
28
+ if c.CheckStopped(context.Background()) != nil {
29
+ os.Exit(1)
30
+ }
31
+ return
32
+ }
33
+ if err := helper.RunLinux(i, os.Args[1]); err != nil {
34
+ fmt.Fprintln(os.Stderr, "helper lifecycle refused")
35
+ os.Exit(1)
36
+ }
37
+ fmt.Println(`{"success":true}`)
38
+ }
@@ -0,0 +1,3 @@
1
+ module dsmapp
2
+
3
+ go 1.24
@@ -0,0 +1,33 @@
1
+ package api
2
+
3
+ import (
4
+ "errors"
5
+ "log"
6
+ )
7
+
8
+ // Error is an API error the browser may see. Any other error a handler
9
+ // returns is logged and reported only as "internal". Errors travel in the
10
+ // JSON envelope with HTTP 200, as DSM's own WebAPI does: DSM's web server
11
+ // replaces the body of a CGI's 403, 404 and 5xx replies with its own page.
12
+ type Error struct {
13
+ Code string
14
+ Message string
15
+ }
16
+
17
+ func (e *Error) Error() string { return e.Code + ": " + e.Message }
18
+
19
+ // BadRequest reports invalid params.
20
+ func BadRequest(message string) *Error {
21
+ return &Error{"bad_request", message}
22
+ }
23
+
24
+ var internalError = &Error{"internal", "internal error"}
25
+
26
+ func asError(err error) *Error {
27
+ var apiErr *Error
28
+ if errors.As(err, &apiErr) {
29
+ return apiErr
30
+ }
31
+ log.Printf("handler error: %v", err)
32
+ return internalError
33
+ }
@@ -0,0 +1,94 @@
1
+ // Package api is the package's one HTTP entry point. Every request passes the
2
+ // same transport guards and, unless its route was explicitly declared Public,
3
+ // one DSM authentication before any handler runs. The policy is part of the
4
+ // route itself: a Route can only be made through User, Admin or Public, so a
5
+ // handler cannot be registered without saying who may call it.
6
+ package api
7
+
8
+ import (
9
+ "context"
10
+ "encoding/json"
11
+ "errors"
12
+ "fmt"
13
+ "regexp"
14
+ )
15
+
16
+ type policy int
17
+
18
+ const (
19
+ policyUnset policy = iota
20
+ policyUser
21
+ policyAdmin
22
+ policyPublic
23
+ )
24
+
25
+ // Handler runs one business operation. It receives the caller the server
26
+ // verified and the raw `params` object; it decodes params itself.
27
+ type Handler func(ctx context.Context, caller Caller, params json.RawMessage) (any, error)
28
+
29
+ // Route binds a method name to its handler and access policy. Its fields are
30
+ // private: build routes with User, Admin or Public.
31
+ type Route struct {
32
+ method string
33
+ policy policy
34
+ handler Handler
35
+ }
36
+
37
+ // User declares an operation any signed-in DSM user may call.
38
+ func User(method string, h Handler) Route { return Route{method, policyUser, h} }
39
+
40
+ // Admin declares an operation only members of DSM's administrators group may call.
41
+ func Admin(method string, h Handler) Route { return Route{method, policyAdmin, h} }
42
+
43
+ // Public declares an operation that runs WITHOUT a DSM session. Use it only for
44
+ // data that is safe to show anyone who can reach the NAS's DSM port.
45
+ func Public(method string, h Handler) Route { return Route{method, policyPublic, h} }
46
+
47
+ // Identity is what an Authenticator established about the requester.
48
+ type Identity struct {
49
+ User string
50
+ Admin bool
51
+ }
52
+
53
+ // Caller is the verified requester handed to handlers. Only the server sets
54
+ // its fields, so a handler cannot fabricate an authenticated caller; the zero
55
+ // value carries no authority.
56
+ type Caller struct {
57
+ identity Identity
58
+ authenticated bool
59
+ }
60
+
61
+ func (c Caller) Authenticated() bool { return c.authenticated }
62
+ func (c Caller) User() string { return c.identity.User }
63
+ func (c Caller) Admin() bool { return c.authenticated && c.identity.Admin }
64
+
65
+ var methodName = regexp.MustCompile(`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$`)
66
+
67
+ func buildRegistry(routes []Route) (map[string]Route, error) {
68
+ registry := make(map[string]Route, len(routes))
69
+ for _, r := range routes {
70
+ if r.policy == policyUnset {
71
+ return nil, errors.New("route without access policy: build it with api.User, api.Admin or api.Public")
72
+ }
73
+ if !methodName.MatchString(r.method) {
74
+ return nil, fmt.Errorf("route %q: method must look like \"area.action\" (lowercase)", r.method)
75
+ }
76
+ if r.handler == nil {
77
+ return nil, fmt.Errorf("route %q has no handler", r.method)
78
+ }
79
+ if _, dup := registry[r.method]; dup {
80
+ return nil, fmt.Errorf("route %q is registered twice", r.method)
81
+ }
82
+ registry[r.method] = r
83
+ }
84
+ return registry, nil
85
+ }
86
+
87
+ // Method is the route's method name, e.g. "greeting.set".
88
+ func (r Route) Method() string { return r.method }
89
+
90
+ // Public reports whether the route was declared with Public.
91
+ func (r Route) Public() bool { return r.policy == policyPublic }
92
+
93
+ // AdminOnly reports whether the route was declared with Admin.
94
+ func (r Route) AdminOnly() bool { return r.policy == policyAdmin }
@@ -0,0 +1,329 @@
1
+ package api
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "strings"
11
+ "testing"
12
+ )
13
+
14
+ // fakeAuth returns a fixed outcome and counts how often it was consulted, so
15
+ // tests can prove each request authenticates at most once.
16
+ type fakeAuth struct {
17
+ user string
18
+ admin bool
19
+ err error
20
+ calls int
21
+ }
22
+
23
+ func (f *fakeAuth) Authenticate(*http.Request) (Identity, error) {
24
+ f.calls++
25
+ if f.err != nil {
26
+ return Identity{}, f.err
27
+ }
28
+ return Identity{User: f.user, Admin: f.admin}, nil
29
+ }
30
+
31
+ func post(t *testing.T, srv http.Handler, body string, mutate ...func(*http.Request)) (*httptest.ResponseRecorder, envelope) {
32
+ t.Helper()
33
+ req := httptest.NewRequest(http.MethodPost, "http://nas.local/webman/3rdparty/App/cgi/api.cgi", strings.NewReader(body))
34
+ req.Header.Set("Content-Type", "application/json")
35
+ for _, m := range mutate {
36
+ m(req)
37
+ }
38
+ rec := httptest.NewRecorder()
39
+ srv.ServeHTTP(rec, req)
40
+ var env envelope
41
+ if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
42
+ t.Fatalf("response is not JSON: %q", rec.Body.String())
43
+ }
44
+ return rec, env
45
+ }
46
+
47
+ type envelope struct {
48
+ Success bool `json:"success"`
49
+ Data json.RawMessage `json:"data"`
50
+ Error *struct {
51
+ Code string `json:"code"`
52
+ } `json:"error"`
53
+ }
54
+
55
+ func countingRoutes(counter *int) []Route {
56
+ h := func(_ context.Context, c Caller, _ json.RawMessage) (any, error) {
57
+ *counter++
58
+ return map[string]string{"user": c.User()}, nil
59
+ }
60
+ return []Route{
61
+ User("demo.user", h),
62
+ Admin("demo.admin", h),
63
+ }
64
+ }
65
+
66
+ func newTestServer(t *testing.T, auth Authenticator, routes ...Route) *Server {
67
+ t.Helper()
68
+ srv, err := NewServer(auth, routes...)
69
+ if err != nil {
70
+ t.Fatal(err)
71
+ }
72
+ return srv
73
+ }
74
+
75
+ func TestUserRouteRunsForAuthenticatedUserWithVerifiedCaller(t *testing.T) {
76
+ var executed int
77
+ auth := &fakeAuth{user: "alice"}
78
+ srv := newTestServer(t, auth, countingRoutes(&executed)...)
79
+
80
+ rec, env := post(t, srv, `{"method":"demo.user"}`)
81
+
82
+ if rec.Code != http.StatusOK || !env.Success {
83
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
84
+ }
85
+ if string(env.Data) != `{"user":"alice"}` {
86
+ t.Fatalf("data = %s", env.Data)
87
+ }
88
+ if executed != 1 || auth.calls != 1 {
89
+ t.Fatalf("executed=%d auth=%d, want 1/1", executed, auth.calls)
90
+ }
91
+ }
92
+
93
+ func TestEveryProtectedRouteRefusesMissingInvalidAndUnavailableAuth(t *testing.T) {
94
+ cases := []struct {
95
+ name string
96
+ err error
97
+ code string
98
+ }{
99
+ {"missing session", ErrNoSession, "session_required"},
100
+ {"invalid session", ErrNoSession, "session_required"},
101
+ {"auth unavailable", errors.New("authenticate.cgi crashed"), "auth_unavailable"},
102
+ }
103
+ for _, method := range []string{"demo.user", "demo.admin"} {
104
+ for _, tc := range cases {
105
+ t.Run(method+"/"+tc.name, func(t *testing.T) {
106
+ var executed int
107
+ auth := &fakeAuth{err: tc.err}
108
+ srv := newTestServer(t, auth, countingRoutes(&executed)...)
109
+
110
+ rec, env := post(t, srv, `{"method":"`+method+`"}`)
111
+
112
+ if rec.Code != http.StatusOK || env.Success || env.Error.Code != tc.code {
113
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
114
+ }
115
+ if executed != 0 || auth.calls != 1 {
116
+ t.Fatalf("executed=%d auth=%d, want 0/1", executed, auth.calls)
117
+ }
118
+ })
119
+ }
120
+ }
121
+ }
122
+
123
+ func TestAdminRouteRefusesNonAdminWithoutExecuting(t *testing.T) {
124
+ var executed int
125
+ srv := newTestServer(t, &fakeAuth{user: "bob"}, countingRoutes(&executed)...)
126
+
127
+ rec, env := post(t, srv, `{"method":"demo.admin"}`)
128
+
129
+ if rec.Code != http.StatusOK || env.Error.Code != "admin_required" || executed != 0 {
130
+ t.Fatalf("got %d %s executed=%d", rec.Code, rec.Body.String(), executed)
131
+ }
132
+ }
133
+
134
+ func TestAdminRouteRunsForAdmin(t *testing.T) {
135
+ var executed int
136
+ srv := newTestServer(t, &fakeAuth{user: "root-admin", admin: true}, countingRoutes(&executed)...)
137
+
138
+ rec, _ := post(t, srv, `{"method":"demo.admin"}`)
139
+
140
+ if rec.Code != http.StatusOK || executed != 1 {
141
+ t.Fatalf("got %d executed=%d", rec.Code, executed)
142
+ }
143
+ }
144
+
145
+ func TestUnknownMethodNeedsSessionThenIsNotFound(t *testing.T) {
146
+ var executed int
147
+ srv := newTestServer(t, &fakeAuth{err: ErrNoSession}, countingRoutes(&executed)...)
148
+ _, env := post(t, srv, `{"method":"demo.missing"}`)
149
+ if env.Error == nil || env.Error.Code != "session_required" {
150
+ t.Fatalf("anonymous unknown method: got %+v, want session_required (no route probing)", env.Error)
151
+ }
152
+
153
+ srv = newTestServer(t, &fakeAuth{user: "alice", admin: true}, countingRoutes(&executed)...)
154
+ rec, env := post(t, srv, `{"method":"demo.missing"}`)
155
+ if rec.Code != http.StatusOK || env.Error.Code != "unknown_method" || executed != 0 {
156
+ t.Fatalf("got %d %s executed=%d", rec.Code, rec.Body.String(), executed)
157
+ }
158
+ }
159
+
160
+ func TestPublicRouteIsExplicitAndSkipsAuthentication(t *testing.T) {
161
+ var executed int
162
+ auth := &fakeAuth{err: ErrNoSession}
163
+ srv := newTestServer(t, auth, Public("demo.ping", func(_ context.Context, c Caller, _ json.RawMessage) (any, error) {
164
+ executed++
165
+ if c.Authenticated() {
166
+ t.Error("public handler received an authenticated caller")
167
+ }
168
+ return "pong", nil
169
+ }))
170
+
171
+ rec, _ := post(t, srv, `{"method":"demo.ping"}`)
172
+
173
+ if rec.Code != http.StatusOK || executed != 1 || auth.calls != 0 {
174
+ t.Fatalf("got %d executed=%d auth=%d", rec.Code, executed, auth.calls)
175
+ }
176
+ }
177
+
178
+ func TestTransportGuardsRunBeforeAuthenticationAndDispatch(t *testing.T) {
179
+ cases := []struct {
180
+ name string
181
+ body string
182
+ mutate func(*http.Request)
183
+ code string
184
+ }{
185
+ {"GET", `{"method":"demo.user"}`, func(r *http.Request) { r.Method = http.MethodGet }, "method_not_allowed"},
186
+ {"form post", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Content-Type", "text/plain") }, "json_required"},
187
+ {"cross-site fetch", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") }, "cross_origin"},
188
+ {"foreign origin", `{"method":"demo.user"}`, func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }, "cross_origin"},
189
+ {"oversized body", `{"method":"demo.user","params":"` + strings.Repeat("x", MaxRequestBytes) + `"}`, nil, "request_too_large"},
190
+ {"malformed JSON", `{"method":`, nil, "bad_request"},
191
+ {"unknown field", `{"method":"demo.user","user":"admin"}`, nil, "bad_request"},
192
+ {"trailing data", `{"method":"demo.user"}{}`, nil, "bad_request"},
193
+ }
194
+ for _, tc := range cases {
195
+ t.Run(tc.name, func(t *testing.T) {
196
+ var executed int
197
+ auth := &fakeAuth{user: "alice", admin: true}
198
+ srv := newTestServer(t, auth, countingRoutes(&executed)...)
199
+ mutate := []func(*http.Request){}
200
+ if tc.mutate != nil {
201
+ mutate = append(mutate, tc.mutate)
202
+ }
203
+
204
+ rec, env := post(t, srv, tc.body, mutate...)
205
+
206
+ if rec.Code != http.StatusOK || env.Error == nil || env.Error.Code != tc.code {
207
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
208
+ }
209
+ if executed != 0 || auth.calls != 0 {
210
+ t.Fatalf("executed=%d auth=%d, want 0/0", executed, auth.calls)
211
+ }
212
+ })
213
+ }
214
+ }
215
+
216
+ // countingReader records how much of the request body the server consumed.
217
+ type countingReader struct {
218
+ r io.Reader
219
+ read int
220
+ }
221
+
222
+ func (c *countingReader) Read(p []byte) (int, error) {
223
+ n, err := c.r.Read(p)
224
+ c.read += n
225
+ return n, err
226
+ }
227
+
228
+ // DSM's CGI gateway cuts the reply short when a CGI exits without reading
229
+ // its stdin, so every refusal still consumes the request body.
230
+ func TestRefusalsConsumeTheWholeRequestBody(t *testing.T) {
231
+ cases := map[string]func(*http.Request){
232
+ "form post": func(r *http.Request) { r.Header.Set("Content-Type", "text/plain") },
233
+ "cross-site": func(r *http.Request) { r.Header.Set("Sec-Fetch-Site", "cross-site") },
234
+ "GET": func(r *http.Request) { r.Method = http.MethodGet },
235
+ "oversized": func(*http.Request) {},
236
+ }
237
+ for name, mutate := range cases {
238
+ t.Run(name, func(t *testing.T) {
239
+ var executed int
240
+ srv := newTestServer(t, &fakeAuth{user: "alice"}, countingRoutes(&executed)...)
241
+ body := `{"method":"demo.user"}`
242
+ if name == "oversized" {
243
+ body = `{"method":"demo.user","params":"` + strings.Repeat("x", 2*MaxRequestBytes) + `"}`
244
+ }
245
+ reader := &countingReader{r: strings.NewReader(body)}
246
+ req := httptest.NewRequest(http.MethodPost, "http://nas.local/api.cgi", reader)
247
+ req.Header.Set("Content-Type", "application/json")
248
+ mutate(req)
249
+ srv.ServeHTTP(httptest.NewRecorder(), req)
250
+ if reader.read != len(body) {
251
+ t.Fatalf("read %d of %d body bytes", reader.read, len(body))
252
+ }
253
+ })
254
+ }
255
+ }
256
+
257
+ func TestSameOriginRequestIsAccepted(t *testing.T) {
258
+ var executed int
259
+ srv := newTestServer(t, &fakeAuth{user: "alice"}, countingRoutes(&executed)...)
260
+ rec, _ := post(t, srv, `{"method":"demo.user"}`, func(r *http.Request) {
261
+ r.Header.Set("Origin", "http://nas.local")
262
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
263
+ })
264
+ if rec.Code != http.StatusOK {
265
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
266
+ }
267
+ }
268
+
269
+ func TestResponsesAreUncachedJSON(t *testing.T) {
270
+ var executed int
271
+ srv := newTestServer(t, &fakeAuth{err: ErrNoSession}, countingRoutes(&executed)...)
272
+ rec, _ := post(t, srv, `{"method":"demo.user"}`)
273
+ if got := rec.Header().Get("Cache-Control"); got != "no-store" {
274
+ t.Errorf("Cache-Control = %q", got)
275
+ }
276
+ if got := rec.Header().Get("Content-Type"); got != "application/json" {
277
+ t.Errorf("Content-Type = %q", got)
278
+ }
279
+ }
280
+
281
+ func TestHandlerErrorsKeepTheirCodeButInternalErrorsAreHidden(t *testing.T) {
282
+ srv := newTestServer(t, &fakeAuth{user: "alice"},
283
+ User("demo.invalid", func(context.Context, Caller, json.RawMessage) (any, error) {
284
+ return nil, BadRequest("text must not be empty")
285
+ }),
286
+ User("demo.crash", func(context.Context, Caller, json.RawMessage) (any, error) {
287
+ return nil, errors.New("disk path /secret/xyz failed")
288
+ }),
289
+ User("demo.panic", func(context.Context, Caller, json.RawMessage) (any, error) {
290
+ panic("boom")
291
+ }),
292
+ )
293
+
294
+ rec, env := post(t, srv, `{"method":"demo.invalid"}`)
295
+ if rec.Code != http.StatusOK || env.Error.Code != "bad_request" || !strings.Contains(rec.Body.String(), "text must not be empty") {
296
+ t.Fatalf("got %d %s", rec.Code, rec.Body.String())
297
+ }
298
+ for _, method := range []string{"demo.crash", "demo.panic"} {
299
+ rec, env = post(t, srv, `{"method":"`+method+`"}`)
300
+ if rec.Code != http.StatusOK || env.Error.Code != "internal" || strings.Contains(rec.Body.String(), "secret") || strings.Contains(rec.Body.String(), "boom") {
301
+ t.Fatalf("%s: got %d %s", method, rec.Code, rec.Body.String())
302
+ }
303
+ }
304
+ }
305
+
306
+ func TestRegistryRefusesUnusableRoutes(t *testing.T) {
307
+ h := func(context.Context, Caller, json.RawMessage) (any, error) { return nil, nil }
308
+ cases := map[string][]Route{
309
+ "zero route (no policy)": {{}},
310
+ "nil handler": {User("demo.nil", nil)},
311
+ "bad method name": {User("Demo Method", h)},
312
+ "duplicate method": {User("demo.x", h), Admin("demo.x", h)},
313
+ }
314
+ for name, routes := range cases {
315
+ if _, err := NewServer(&fakeAuth{}, routes...); err == nil {
316
+ t.Errorf("%s: NewServer accepted it", name)
317
+ }
318
+ }
319
+ if _, err := NewServer(nil, User("demo.x", h)); err == nil {
320
+ t.Error("NewServer accepted a nil authenticator")
321
+ }
322
+ }
323
+
324
+ func TestZeroCallerIsNotAuthenticated(t *testing.T) {
325
+ var c Caller
326
+ if c.Authenticated() || c.Admin() || c.User() != "" {
327
+ t.Fatal("a Caller built outside the server must carry no authority")
328
+ }
329
+ }