@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,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
+ }
@@ -0,0 +1,155 @@
1
+ package api
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "errors"
7
+ "io"
8
+ "log"
9
+ "mime"
10
+ "net/http"
11
+ "net/url"
12
+ )
13
+
14
+ // MaxRequestBytes bounds the request body the CGI reads (CONTENT_LENGTH is
15
+ // caller-controlled).
16
+ const MaxRequestBytes = 1 << 20
17
+
18
+ // ErrNoSession means the request carries no valid DSM session.
19
+ var ErrNoSession = errors.New("no valid DSM session")
20
+
21
+ // Authenticator verifies the DSM session behind a request.
22
+ type Authenticator interface {
23
+ Authenticate(r *http.Request) (Identity, error)
24
+ }
25
+
26
+ // Server is the http.Handler behind the package CGI.
27
+ type Server struct {
28
+ auth Authenticator
29
+ routes map[string]Route
30
+ }
31
+
32
+ // NewServer refuses an empty authenticator and any route that is not usable,
33
+ // so a mistake fails at startup instead of opening a route.
34
+ func NewServer(auth Authenticator, routes ...Route) (*Server, error) {
35
+ if auth == nil {
36
+ return nil, errors.New("api.NewServer needs an Authenticator")
37
+ }
38
+ registry, err := buildRegistry(routes)
39
+ if err != nil {
40
+ return nil, err
41
+ }
42
+ return &Server{auth: auth, routes: registry}, nil
43
+ }
44
+
45
+ type request struct {
46
+ Method string `json:"method"`
47
+ Params json.RawMessage `json:"params"`
48
+ }
49
+
50
+ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
51
+ // DSM's CGI gateway cuts the reply short if the CGI exits with request
52
+ // bytes unread, so drain whatever the handling below did not consume.
53
+ defer io.Copy(io.Discard, r.Body)
54
+ defer func() {
55
+ if p := recover(); p != nil {
56
+ log.Printf("handler panic: %v", p)
57
+ writeError(w, internalError)
58
+ }
59
+ }()
60
+ req, apiErr := readRequest(w, r)
61
+ if apiErr != nil {
62
+ writeError(w, apiErr)
63
+ return
64
+ }
65
+ caller, route, apiErr := s.authorize(r, req.Method)
66
+ if apiErr != nil {
67
+ writeError(w, apiErr)
68
+ return
69
+ }
70
+ data, err := route.handler(r.Context(), caller, req.Params)
71
+ if err != nil {
72
+ writeError(w, asError(err))
73
+ return
74
+ }
75
+ writeJSON(w, http.StatusOK, map[string]any{"success": true, "data": data})
76
+ }
77
+
78
+ // authorize is the single access decision. Public routes skip authentication;
79
+ // everything else, including unknown methods, authenticates exactly once
80
+ // first, so anonymous callers cannot probe which methods exist.
81
+ func (s *Server) authorize(r *http.Request, method string) (Caller, Route, *Error) {
82
+ route, known := s.routes[method]
83
+ if known && route.policy == policyPublic {
84
+ return Caller{}, route, nil
85
+ }
86
+ identity, err := s.auth.Authenticate(r)
87
+ if errors.Is(err, ErrNoSession) {
88
+ return Caller{}, Route{}, &Error{"session_required", "sign in to DSM again"}
89
+ }
90
+ if err != nil {
91
+ log.Printf("authentication unavailable: %v", err)
92
+ return Caller{}, Route{}, &Error{"auth_unavailable", "DSM authentication is unavailable"}
93
+ }
94
+ if !known {
95
+ return Caller{}, Route{}, &Error{"unknown_method", "unknown method"}
96
+ }
97
+ caller := Caller{identity: identity, authenticated: true}
98
+ if route.policy == policyAdmin && !caller.Admin() {
99
+ return Caller{}, Route{}, &Error{"admin_required", "this action needs a DSM administrator"}
100
+ }
101
+ return caller, route, nil
102
+ }
103
+
104
+ func readRequest(w http.ResponseWriter, r *http.Request) (request, *Error) {
105
+ var req request
106
+ if r.Method != http.MethodPost {
107
+ return req, &Error{"method_not_allowed", "use POST"}
108
+ }
109
+ if mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")); err != nil || mediaType != "application/json" {
110
+ return req, &Error{"json_required", "send Content-Type: application/json"}
111
+ }
112
+ if !sameOrigin(r) {
113
+ return req, &Error{"cross_origin", "cross-origin request refused"}
114
+ }
115
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxRequestBytes))
116
+ if err != nil {
117
+ return req, &Error{"request_too_large", "request body too large"}
118
+ }
119
+ dec := json.NewDecoder(bytes.NewReader(body))
120
+ dec.DisallowUnknownFields()
121
+ if err := dec.Decode(&req); err != nil || dec.More() || req.Method == "" {
122
+ return req, BadRequest(`body must be {"method": "...", "params": {...}}`)
123
+ }
124
+ return req, nil
125
+ }
126
+
127
+ // sameOrigin refuses browser requests another site initiated. The JSON
128
+ // content type already forces a CORS preflight, which DSM never answers; these
129
+ // checks add an explicit refusal when the browser reports the origin.
130
+ func sameOrigin(r *http.Request) bool {
131
+ if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" {
132
+ return false
133
+ }
134
+ if origin := r.Header.Get("Origin"); origin != "" {
135
+ u, err := url.Parse(origin)
136
+ return err == nil && u.Host == r.Host
137
+ }
138
+ return true
139
+ }
140
+
141
+ func writeJSON(w http.ResponseWriter, status int, body any) {
142
+ h := w.Header()
143
+ h.Set("Content-Type", "application/json")
144
+ h.Set("Cache-Control", "no-store")
145
+ h.Set("X-Content-Type-Options", "nosniff")
146
+ w.WriteHeader(status)
147
+ _ = json.NewEncoder(w).Encode(body)
148
+ }
149
+
150
+ func writeError(w http.ResponseWriter, e *Error) {
151
+ writeJSON(w, http.StatusOK, map[string]any{
152
+ "success": false,
153
+ "error": map[string]string{"code": e.Code, "message": e.Message},
154
+ })
155
+ }
@@ -0,0 +1,9 @@
1
+ package app
2
+
3
+ import "dsmapp/internal/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 }
@@ -0,0 +1,127 @@
1
+ // Package app holds this package's business operations. Add a route here and
2
+ // pick its policy: api.User (any signed-in DSM user), api.Admin (DSM
3
+ // administrators only) or, rarely, api.Public (no sign-in at all).
4
+ package app
5
+
6
+ import (
7
+ "bytes"
8
+ "context"
9
+ "encoding/json"
10
+ "errors"
11
+ "io/fs"
12
+ "os"
13
+ "path/filepath"
14
+ "runtime"
15
+ "time"
16
+ "unicode/utf8"
17
+
18
+ "dsmapp/internal/api"
19
+ "dsmapp/internal/helper"
20
+ )
21
+
22
+ // Config is fixed at build time and startup, never taken from a request.
23
+ type Config struct {
24
+ PackageID string
25
+ Version string
26
+ HelperInventory helper.Inventory
27
+ // DataDir is the package's persistent, package-owned directory
28
+ // (/var/packages/<package>/var). It survives upgrades.
29
+ DataDir string
30
+ }
31
+
32
+ func Routes(cfg Config) []api.Route {
33
+ routes := []api.Route{
34
+ api.User("integration.status", cfg.integrationStatus),
35
+ api.User("app.status", cfg.status),
36
+ api.User("greeting.get", cfg.getGreeting),
37
+ api.Admin("greeting.set", cfg.setGreeting),
38
+ }
39
+ return append(routes, cfg.rootRoutes()...)
40
+ }
41
+
42
+ func (cfg Config) integrationStatus(ctx context.Context, _ api.Caller, params json.RawMessage) (any, error) {
43
+ if err := decodeParams(params, &struct{}{}); err != nil {
44
+ return nil, err
45
+ }
46
+ return helper.NewClient(cfg.HelperInventory).Status(ctx), nil
47
+ }
48
+
49
+ func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage) (any, error) {
50
+ return map[string]any{
51
+ "package": cfg.PackageID,
52
+ "version": cfg.Version,
53
+ "user": caller.User(),
54
+ "admin": caller.Admin(),
55
+ "process": map[string]int{"uid": os.Getuid(), "gid": os.Getgid()},
56
+ "go_version": runtime.Version(),
57
+ "platform": runtime.GOOS + "/" + runtime.GOARCH,
58
+ "time": time.Now().UTC().Format(time.RFC3339),
59
+ }, nil
60
+ }
61
+
62
+ type greeting struct {
63
+ Text string `json:"text"`
64
+ UpdatedBy string `json:"updated_by,omitempty"`
65
+ }
66
+
67
+ func (cfg Config) greetingPath() string { return filepath.Join(cfg.DataDir, "greeting.json") }
68
+
69
+ func (cfg Config) getGreeting(context.Context, api.Caller, json.RawMessage) (any, error) {
70
+ data, err := os.ReadFile(cfg.greetingPath())
71
+ if errors.Is(err, fs.ErrNotExist) {
72
+ return greeting{Text: "Hello"}, nil
73
+ }
74
+ if err != nil {
75
+ return nil, err
76
+ }
77
+ var g greeting
78
+ return g, json.Unmarshal(data, &g)
79
+ }
80
+
81
+ func (cfg Config) setGreeting(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) {
82
+ var in struct {
83
+ Text string `json:"text"`
84
+ }
85
+ if err := decodeParams(params, &in); err != nil {
86
+ return nil, err
87
+ }
88
+ if n := utf8.RuneCountInString(in.Text); n < 1 || n > 200 {
89
+ return nil, api.BadRequest("text must be 1 to 200 characters")
90
+ }
91
+ g := greeting{Text: in.Text, UpdatedBy: caller.User()}
92
+ data, err := json.Marshal(g)
93
+ if err != nil {
94
+ return nil, err
95
+ }
96
+ return g, writeFileAtomic(cfg.greetingPath(), data)
97
+ }
98
+
99
+ // decodeParams decodes params strictly: unknown fields are an error.
100
+ func decodeParams(params json.RawMessage, into any) error {
101
+ if len(params) == 0 {
102
+ params = json.RawMessage(`{}`)
103
+ }
104
+ dec := json.NewDecoder(bytes.NewReader(params))
105
+ dec.DisallowUnknownFields()
106
+ if err := dec.Decode(into); err != nil {
107
+ return api.BadRequest("invalid params: " + err.Error())
108
+ }
109
+ return nil
110
+ }
111
+
112
+ // writeFileAtomic replaces path in one rename, so readers never see half a file.
113
+ func writeFileAtomic(path string, data []byte) error {
114
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
115
+ if err != nil {
116
+ return err
117
+ }
118
+ defer os.Remove(tmp.Name())
119
+ if _, err := tmp.Write(data); err != nil {
120
+ tmp.Close()
121
+ return err
122
+ }
123
+ if err := tmp.Close(); err != nil {
124
+ return err
125
+ }
126
+ return os.Rename(tmp.Name(), path)
127
+ }