@digitalpaws/starr 0.0.1

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 (290) hide show
  1. package/README.rst +75 -0
  2. package/data/.nfs.811bbb0d.43c34.4 +0 -0
  3. package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
  4. package/data/.nfs.82d127f7.fffff5704.4 +0 -0
  5. package/data/.nfs.82d127fe.fffff5704.4 +0 -0
  6. package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
  7. package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
  8. package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
  9. package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
  10. package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
  11. package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
  12. package/data/.nfs.82d18536.fffff5e24.4 +0 -0
  13. package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
  14. package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
  15. package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
  16. package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
  17. package/data/.nfs.8314122e.fffffb044.4 +0 -0
  18. package/data/.nfs.83141236.fffffb044.4 +0 -0
  19. package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
  20. package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
  21. package/data/logs/starr.log +66244 -0
  22. package/data/starr.db +0 -0
  23. package/data/starr.db-shm +0 -0
  24. package/data/starr.db-wal +0 -0
  25. package/data/starr.jwt +1 -0
  26. package/data/starr.key +1 -0
  27. package/dist/auth/auth-service.d.ts +42 -0
  28. package/dist/auth/auth-service.d.ts.map +1 -0
  29. package/dist/auth/auth-service.js +226 -0
  30. package/dist/auth/auth-service.js.map +1 -0
  31. package/dist/auth/http-server.d.ts +16 -0
  32. package/dist/auth/http-server.d.ts.map +1 -0
  33. package/dist/auth/http-server.js +207 -0
  34. package/dist/auth/http-server.js.map +1 -0
  35. package/dist/auth/jwt-secret.d.ts +6 -0
  36. package/dist/auth/jwt-secret.d.ts.map +1 -0
  37. package/dist/auth/jwt-secret.js +51 -0
  38. package/dist/auth/jwt-secret.js.map +1 -0
  39. package/dist/auth/oidc/oidc-flow.d.ts +60 -0
  40. package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
  41. package/dist/auth/oidc/oidc-flow.js +201 -0
  42. package/dist/auth/oidc/oidc-flow.js.map +1 -0
  43. package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
  44. package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
  45. package/dist/auth/oidc/oidc-provider-service.js +124 -0
  46. package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
  47. package/dist/auth/oidc/user-service.d.ts +67 -0
  48. package/dist/auth/oidc/user-service.d.ts.map +1 -0
  49. package/dist/auth/oidc/user-service.js +177 -0
  50. package/dist/auth/oidc/user-service.js.map +1 -0
  51. package/dist/auth/rate-limiter.d.ts +31 -0
  52. package/dist/auth/rate-limiter.d.ts.map +1 -0
  53. package/dist/auth/rate-limiter.js +70 -0
  54. package/dist/auth/rate-limiter.js.map +1 -0
  55. package/dist/config/config-service.d.ts +83 -0
  56. package/dist/config/config-service.d.ts.map +1 -0
  57. package/dist/config/config-service.js +309 -0
  58. package/dist/config/config-service.js.map +1 -0
  59. package/dist/config/validate.d.ts +12 -0
  60. package/dist/config/validate.d.ts.map +1 -0
  61. package/dist/config/validate.js +140 -0
  62. package/dist/config/validate.js.map +1 -0
  63. package/dist/content-types/registry.d.ts +75 -0
  64. package/dist/content-types/registry.d.ts.map +1 -0
  65. package/dist/content-types/registry.js +171 -0
  66. package/dist/content-types/registry.js.map +1 -0
  67. package/dist/datastore/db.d.ts +9 -0
  68. package/dist/datastore/db.d.ts.map +1 -0
  69. package/dist/datastore/db.js +27 -0
  70. package/dist/datastore/db.js.map +1 -0
  71. package/dist/datastore/encrypt.d.ts +9 -0
  72. package/dist/datastore/encrypt.d.ts.map +1 -0
  73. package/dist/datastore/encrypt.js +84 -0
  74. package/dist/datastore/encrypt.js.map +1 -0
  75. package/dist/datastore/migrations.d.ts +10 -0
  76. package/dist/datastore/migrations.d.ts.map +1 -0
  77. package/dist/datastore/migrations.js +340 -0
  78. package/dist/datastore/migrations.js.map +1 -0
  79. package/dist/datastore/storage.d.ts +4 -0
  80. package/dist/datastore/storage.d.ts.map +1 -0
  81. package/dist/datastore/storage.js +60 -0
  82. package/dist/datastore/storage.js.map +1 -0
  83. package/dist/events/event-bus.d.ts +15 -0
  84. package/dist/events/event-bus.d.ts.map +1 -0
  85. package/dist/events/event-bus.js +140 -0
  86. package/dist/events/event-bus.js.map +1 -0
  87. package/dist/events/spine.d.ts +31 -0
  88. package/dist/events/spine.d.ts.map +1 -0
  89. package/dist/events/spine.js +68 -0
  90. package/dist/events/spine.js.map +1 -0
  91. package/dist/events/trace.d.ts +24 -0
  92. package/dist/events/trace.d.ts.map +1 -0
  93. package/dist/events/trace.js +33 -0
  94. package/dist/events/trace.js.map +1 -0
  95. package/dist/host.d.ts +65 -0
  96. package/dist/host.d.ts.map +1 -0
  97. package/dist/host.js +94 -0
  98. package/dist/host.js.map +1 -0
  99. package/dist/library/importer.d.ts +29 -0
  100. package/dist/library/importer.d.ts.map +1 -0
  101. package/dist/library/importer.js +145 -0
  102. package/dist/library/importer.js.map +1 -0
  103. package/dist/library/library-cache.d.ts +46 -0
  104. package/dist/library/library-cache.d.ts.map +1 -0
  105. package/dist/library/library-cache.js +152 -0
  106. package/dist/library/library-cache.js.map +1 -0
  107. package/dist/library/library-config.d.ts +12 -0
  108. package/dist/library/library-config.d.ts.map +1 -0
  109. package/dist/library/library-config.js +10 -0
  110. package/dist/library/library-config.js.map +1 -0
  111. package/dist/library/library-scanner.d.ts +62 -0
  112. package/dist/library/library-scanner.d.ts.map +1 -0
  113. package/dist/library/library-scanner.js +336 -0
  114. package/dist/library/library-scanner.js.map +1 -0
  115. package/dist/library/library-service.d.ts +65 -0
  116. package/dist/library/library-service.d.ts.map +1 -0
  117. package/dist/library/library-service.js +197 -0
  118. package/dist/library/library-service.js.map +1 -0
  119. package/dist/library/metadata-matcher.d.ts +56 -0
  120. package/dist/library/metadata-matcher.d.ts.map +1 -0
  121. package/dist/library/metadata-matcher.js +207 -0
  122. package/dist/library/metadata-matcher.js.map +1 -0
  123. package/dist/library/naming.d.ts +8 -0
  124. package/dist/library/naming.d.ts.map +1 -0
  125. package/dist/library/naming.js +47 -0
  126. package/dist/library/naming.js.map +1 -0
  127. package/dist/library/scan-controller.d.ts +95 -0
  128. package/dist/library/scan-controller.d.ts.map +1 -0
  129. package/dist/library/scan-controller.js +379 -0
  130. package/dist/library/scan-controller.js.map +1 -0
  131. package/dist/library/source-resolver.d.ts +17 -0
  132. package/dist/library/source-resolver.d.ts.map +1 -0
  133. package/dist/library/source-resolver.js +75 -0
  134. package/dist/library/source-resolver.js.map +1 -0
  135. package/dist/logging/logger.d.ts +3 -0
  136. package/dist/logging/logger.d.ts.map +1 -0
  137. package/dist/logging/logger.js +107 -0
  138. package/dist/logging/logger.js.map +1 -0
  139. package/dist/main.d.ts +36 -0
  140. package/dist/main.d.ts.map +1 -0
  141. package/dist/main.js +229 -0
  142. package/dist/main.js.map +1 -0
  143. package/dist/plugins/context.d.ts +10 -0
  144. package/dist/plugins/context.d.ts.map +1 -0
  145. package/dist/plugins/context.js +110 -0
  146. package/dist/plugins/context.js.map +1 -0
  147. package/dist/plugins/discovery.d.ts +2 -0
  148. package/dist/plugins/discovery.d.ts.map +1 -0
  149. package/dist/plugins/discovery.js +32 -0
  150. package/dist/plugins/discovery.js.map +1 -0
  151. package/dist/plugins/loader.d.ts +9 -0
  152. package/dist/plugins/loader.d.ts.map +1 -0
  153. package/dist/plugins/loader.js +245 -0
  154. package/dist/plugins/loader.js.map +1 -0
  155. package/dist/state/request-intake.d.ts +26 -0
  156. package/dist/state/request-intake.d.ts.map +1 -0
  157. package/dist/state/request-intake.js +74 -0
  158. package/dist/state/request-intake.js.map +1 -0
  159. package/dist/state/state-store.d.ts +146 -0
  160. package/dist/state/state-store.d.ts.map +1 -0
  161. package/dist/state/state-store.js +465 -0
  162. package/dist/state/state-store.js.map +1 -0
  163. package/dist/state/trace-store.d.ts +12 -0
  164. package/dist/state/trace-store.d.ts.map +1 -0
  165. package/dist/state/trace-store.js +24 -0
  166. package/dist/state/trace-store.js.map +1 -0
  167. package/dist/types/plugin.d.ts +93 -0
  168. package/dist/types/plugin.d.ts.map +1 -0
  169. package/dist/types/plugin.js +4 -0
  170. package/dist/types/plugin.js.map +1 -0
  171. package/dist/web/index.d.ts +10 -0
  172. package/dist/web/index.d.ts.map +1 -0
  173. package/dist/web/index.js +153 -0
  174. package/dist/web/index.js.map +1 -0
  175. package/dist/web/routes/activity.d.ts +6 -0
  176. package/dist/web/routes/activity.d.ts.map +1 -0
  177. package/dist/web/routes/activity.js +31 -0
  178. package/dist/web/routes/activity.js.map +1 -0
  179. package/dist/web/routes/auth.d.ts +6 -0
  180. package/dist/web/routes/auth.d.ts.map +1 -0
  181. package/dist/web/routes/auth.js +142 -0
  182. package/dist/web/routes/auth.js.map +1 -0
  183. package/dist/web/routes/config.d.ts +7 -0
  184. package/dist/web/routes/config.d.ts.map +1 -0
  185. package/dist/web/routes/config.js +204 -0
  186. package/dist/web/routes/config.js.map +1 -0
  187. package/dist/web/routes/content-types.d.ts +6 -0
  188. package/dist/web/routes/content-types.d.ts.map +1 -0
  189. package/dist/web/routes/content-types.js +29 -0
  190. package/dist/web/routes/content-types.js.map +1 -0
  191. package/dist/web/routes/discovery.d.ts +6 -0
  192. package/dist/web/routes/discovery.d.ts.map +1 -0
  193. package/dist/web/routes/discovery.js +111 -0
  194. package/dist/web/routes/discovery.js.map +1 -0
  195. package/dist/web/routes/library-import.d.ts +6 -0
  196. package/dist/web/routes/library-import.d.ts.map +1 -0
  197. package/dist/web/routes/library-import.js +120 -0
  198. package/dist/web/routes/library-import.js.map +1 -0
  199. package/dist/web/routes/library-management.d.ts +6 -0
  200. package/dist/web/routes/library-management.d.ts.map +1 -0
  201. package/dist/web/routes/library-management.js +120 -0
  202. package/dist/web/routes/library-management.js.map +1 -0
  203. package/dist/web/routes/library.d.ts +6 -0
  204. package/dist/web/routes/library.d.ts.map +1 -0
  205. package/dist/web/routes/library.js +28 -0
  206. package/dist/web/routes/library.js.map +1 -0
  207. package/dist/web/routes/oidc.d.ts +9 -0
  208. package/dist/web/routes/oidc.d.ts.map +1 -0
  209. package/dist/web/routes/oidc.js +203 -0
  210. package/dist/web/routes/oidc.js.map +1 -0
  211. package/dist/web/routes/requests.d.ts +6 -0
  212. package/dist/web/routes/requests.d.ts.map +1 -0
  213. package/dist/web/routes/requests.js +183 -0
  214. package/dist/web/routes/requests.js.map +1 -0
  215. package/dist/web/routes/sse.d.ts +6 -0
  216. package/dist/web/routes/sse.d.ts.map +1 -0
  217. package/dist/web/routes/sse.js +54 -0
  218. package/dist/web/routes/sse.js.map +1 -0
  219. package/package.json +33 -0
  220. package/package.json.license +2 -0
  221. package/src/auth/auth-service.ts +333 -0
  222. package/src/auth/http-server.ts +249 -0
  223. package/src/auth/jwt-secret.ts +65 -0
  224. package/src/auth/oidc/oidc-flow.ts +266 -0
  225. package/src/auth/oidc/oidc-provider-service.ts +174 -0
  226. package/src/auth/oidc/user-service.ts +227 -0
  227. package/src/auth/rate-limiter.ts +85 -0
  228. package/src/config/config-service.ts +434 -0
  229. package/src/config/validate.ts +153 -0
  230. package/src/content-types/registry.ts +194 -0
  231. package/src/datastore/db.ts +38 -0
  232. package/src/datastore/encrypt.ts +107 -0
  233. package/src/datastore/migrations.ts +367 -0
  234. package/src/datastore/storage.ts +83 -0
  235. package/src/events/event-bus.ts +180 -0
  236. package/src/events/spine.ts +87 -0
  237. package/src/events/trace.ts +38 -0
  238. package/src/host.ts +130 -0
  239. package/src/library/importer.ts +174 -0
  240. package/src/library/library-cache.ts +212 -0
  241. package/src/library/library-config.ts +22 -0
  242. package/src/library/library-scanner.ts +404 -0
  243. package/src/library/library-service.ts +282 -0
  244. package/src/library/metadata-matcher.ts +285 -0
  245. package/src/library/naming.ts +59 -0
  246. package/src/library/scan-controller.ts +490 -0
  247. package/src/library/source-resolver.ts +92 -0
  248. package/src/logging/logger.ts +115 -0
  249. package/src/main.ts +364 -0
  250. package/src/plugins/context.ts +138 -0
  251. package/src/plugins/discovery.ts +29 -0
  252. package/src/plugins/loader.ts +309 -0
  253. package/src/state/request-intake.ts +101 -0
  254. package/src/state/state-store.ts +621 -0
  255. package/src/state/trace-store.ts +27 -0
  256. package/src/types/plugin.ts +110 -0
  257. package/src/web/index.ts +182 -0
  258. package/src/web/routes/activity.ts +41 -0
  259. package/src/web/routes/auth.ts +170 -0
  260. package/src/web/routes/config.ts +282 -0
  261. package/src/web/routes/content-types.ts +42 -0
  262. package/src/web/routes/discovery.ts +156 -0
  263. package/src/web/routes/library-import.ts +196 -0
  264. package/src/web/routes/library-management.ts +140 -0
  265. package/src/web/routes/library.ts +46 -0
  266. package/src/web/routes/oidc.ts +260 -0
  267. package/src/web/routes/requests.ts +250 -0
  268. package/src/web/routes/sse.ts +92 -0
  269. package/tests/auth.test.ts +510 -0
  270. package/tests/config-route.test.ts +345 -0
  271. package/tests/config.test.ts +612 -0
  272. package/tests/content-types.test.ts +376 -0
  273. package/tests/datastore.test.ts +345 -0
  274. package/tests/e2e.test.ts +430 -0
  275. package/tests/event-bus.test.ts +925 -0
  276. package/tests/host.test.ts +449 -0
  277. package/tests/library-cache.test.ts +171 -0
  278. package/tests/library-scanner.test.ts +177 -0
  279. package/tests/library.test.ts +210 -0
  280. package/tests/loader-context.test.ts +644 -0
  281. package/tests/logger-jsonl.test.ts +154 -0
  282. package/tests/metadata-matcher.test.ts +198 -0
  283. package/tests/oidc.test.ts +218 -0
  284. package/tests/scan-controller.test.ts +328 -0
  285. package/tests/source-resolver.test.ts +100 -0
  286. package/tests/starr-video-integration.test.ts +153 -0
  287. package/tests/state-store.test.ts +700 -0
  288. package/tests/trace-history.test.ts +356 -0
  289. package/tsconfig.json +8 -0
  290. package/tsconfig.json.license +2 -0
@@ -0,0 +1,612 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import Database from "better-sqlite3";
6
+ import { mkdtempSync, writeFileSync, existsSync, unlinkSync, rmdirSync, readFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import path from "node:path";
9
+ import { randomBytes } from "node:crypto";
10
+
11
+ import { runMigrations, CORE_MIGRATIONS } from "../src/datastore/migrations.js";
12
+ import { loadEncryptionKey, resetEncryptionKey, encrypt, decrypt } from "../src/datastore/encrypt.js";
13
+ import { validateConfig, type ValidationResult } from "../src/config/validate.js";
14
+ import { ConfigService, SECRET_UNCHANGED_SENTINEL } from "../src/config/config-service.js";
15
+ import { createLogger } from "../src/logging/logger.js";
16
+ import type { ConfigSchema, ConfigField } from "../src/types/plugin.js";
17
+
18
+ // ── Helpers ────────────────────────────────────────────────────────────
19
+
20
+ function createTestDb(): Database.Database {
21
+ const db = new Database(":memory:");
22
+ db.pragma("journal_mode = WAL");
23
+ db.pragma("foreign_keys = ON");
24
+ return db;
25
+ }
26
+
27
+ const testLogger = createLogger("test", "silent");
28
+
29
+ function createTestConfigService(
30
+ db: Database.Database,
31
+ onReload?: (pluginId: string) => void,
32
+ ): ConfigService {
33
+ return new ConfigService(db, testLogger, onReload);
34
+ }
35
+
36
+ // ── 7a. Validation Tests ──────────────────────────────────────────────
37
+
38
+ describe("validateConfig", () => {
39
+ it("returns valid: false with error for missing required field", () => {
40
+ const schema: ConfigSchema<{ x: string }> = {
41
+ x: { type: "string", label: "X", required: true },
42
+ };
43
+ const result = validateConfig(schema, {});
44
+ expect(result.valid).toBe(false);
45
+ expect(result.errors).toHaveLength(1);
46
+ expect(result.errors[0].field).toBe("x");
47
+ expect(result.errors[0].message).toContain("required");
48
+ expect(result.values).toEqual({});
49
+ });
50
+
51
+ it("returns valid: true when required field is present", () => {
52
+ const schema: ConfigSchema<{ x: string }> = {
53
+ x: { type: "string", label: "X", required: true },
54
+ };
55
+ const result = validateConfig(schema, { x: "hello" });
56
+ expect(result.valid).toBe(true);
57
+ expect(result.errors).toHaveLength(0);
58
+ expect(result.values.x).toBe("hello");
59
+ });
60
+
61
+ it("applies default value when field is missing", () => {
62
+ const schema: ConfigSchema<{ port: number }> = {
63
+ port: { type: "number", label: "Port", default: 8080 },
64
+ };
65
+ const result = validateConfig(schema, {});
66
+ expect(result.valid).toBe(true);
67
+ expect(result.values.port).toBe(8080);
68
+ });
69
+
70
+ it("does NOT apply default when user provides a value", () => {
71
+ const schema: ConfigSchema<{ port: number }> = {
72
+ port: { type: "number", label: "Port", default: 8080 },
73
+ };
74
+ const result = validateConfig(schema, { port: 9090 });
75
+ expect(result.valid).toBe(true);
76
+ expect(result.values.port).toBe(9090);
77
+ });
78
+
79
+ it("coerces string '42' to number 42", () => {
80
+ const schema: ConfigSchema<{ val: number }> = {
81
+ val: { type: "number", label: "Val" },
82
+ };
83
+ const result = validateConfig(schema, { val: "42" });
84
+ expect(result.valid).toBe(true);
85
+ expect(result.values.val).toBe(42);
86
+ });
87
+
88
+ it("rejects 'abc' for number field", () => {
89
+ const schema: ConfigSchema<{ val: number }> = {
90
+ val: { type: "number", label: "Val" },
91
+ };
92
+ const result = validateConfig(schema, { val: "abc" });
93
+ expect(result.valid).toBe(false);
94
+ expect(result.errors).toHaveLength(1);
95
+ });
96
+
97
+ it("coerces 'true' and 'false' strings to booleans", () => {
98
+ const schema: ConfigSchema<{ a: boolean; b: boolean }> = {
99
+ a: { type: "boolean", label: "A" },
100
+ b: { type: "boolean", label: "B" },
101
+ };
102
+ const result = validateConfig(schema, { a: "true", b: "false" });
103
+ expect(result.valid).toBe(true);
104
+ expect(result.values.a).toBe(true);
105
+ expect(result.values.b).toBe(false);
106
+ });
107
+
108
+ it("rejects enum value not in options", () => {
109
+ const schema: ConfigSchema<{ color: string }> = {
110
+ color: { type: "enum", label: "Color", options: ["red", "green", "blue"] },
111
+ };
112
+ const result = validateConfig(schema, { color: "yellow" });
113
+ expect(result.valid).toBe(false);
114
+ expect(result.errors).toHaveLength(1);
115
+ expect(result.errors[0].message).toContain("not in allowed options");
116
+ });
117
+
118
+ it("accepts enum value in options", () => {
119
+ const schema: ConfigSchema<{ color: string }> = {
120
+ color: { type: "enum", label: "Color", options: ["red", "green", "blue"] },
121
+ };
122
+ const result = validateConfig(schema, { color: "green" });
123
+ expect(result.valid).toBe(true);
124
+ expect(result.values.color).toBe("green");
125
+ });
126
+
127
+ it("accepts any string for enum with empty options", () => {
128
+ const schema: ConfigSchema<{ val: string }> = {
129
+ val: { type: "enum", label: "Val", options: [] },
130
+ };
131
+ const result = validateConfig(schema, { val: "anything" });
132
+ expect(result.valid).toBe(true);
133
+ expect(result.values.val).toBe("anything");
134
+ });
135
+
136
+ it("validates URL type", () => {
137
+ const schema: ConfigSchema<{ url: string }> = {
138
+ url: { type: "url", label: "URL" },
139
+ };
140
+ const valid = validateConfig(schema, { url: "https://example.com" });
141
+ expect(valid.valid).toBe(true);
142
+ expect(valid.values.url).toBe("https://example.com");
143
+
144
+ const invalid = validateConfig(schema, { url: "not-a-url" });
145
+ expect(invalid.valid).toBe(false);
146
+ expect(invalid.errors).toHaveLength(1);
147
+ });
148
+
149
+ it("validates path type (non-empty string)", () => {
150
+ const schema: ConfigSchema<{ p: string }> = {
151
+ p: { type: "path", label: "Path" },
152
+ };
153
+ const valid = validateConfig(schema, { p: "/some/path" });
154
+ expect(valid.valid).toBe(true);
155
+ expect(valid.values.p).toBe("/some/path");
156
+
157
+ // Empty string is treated as "missing" per spec — optional field stores undefined
158
+ const empty = validateConfig(schema, { p: "" });
159
+ expect(empty.valid).toBe(true);
160
+ expect(empty.values.p).toBeUndefined();
161
+
162
+ const notString = validateConfig(schema, { p: 42 });
163
+ expect(notString.valid).toBe(false);
164
+ });
165
+
166
+ it("validates secret type same as string", () => {
167
+ const schema: ConfigSchema<{ token: string }> = {
168
+ token: { type: "secret", label: "Token" },
169
+ };
170
+ const result = validateConfig(schema, { token: "my-secret" });
171
+ expect(result.valid).toBe(true);
172
+ expect(result.values.token).toBe("my-secret");
173
+ });
174
+
175
+ it("collects multiple errors (not just first)", () => {
176
+ const schema: ConfigSchema<{ a: string; b: string; c: string }> = {
177
+ a: { type: "string", label: "A", required: true },
178
+ b: { type: "number", label: "B", required: true },
179
+ c: { type: "string", label: "C", required: true },
180
+ };
181
+ const result = validateConfig(schema, { b: "not-a-number" });
182
+ expect(result.valid).toBe(false);
183
+ // a missing, b invalid, c missing = 3 errors
184
+ expect(result.errors).toHaveLength(3);
185
+ });
186
+
187
+ it("ignores unknown fields in input", () => {
188
+ const schema: ConfigSchema<{ name: string }> = {
189
+ name: { type: "string", label: "Name" },
190
+ };
191
+ const result = validateConfig(schema, { name: "hello", extra: "ignored" });
192
+ expect(result.valid).toBe(true);
193
+ expect(result.values.name).toBe("hello");
194
+ expect((result.values as Record<string, unknown>).extra).toBeUndefined();
195
+ });
196
+
197
+ it("treats field.default: null as valid default", () => {
198
+ const schema: ConfigSchema<{ val: string | null }> = {
199
+ val: { type: "string", label: "Val", default: null },
200
+ };
201
+ const result = validateConfig(schema, {});
202
+ expect(result.valid).toBe(true);
203
+ expect(result.values.val).toBeNull();
204
+ });
205
+
206
+ it("empty schema returns valid: true with empty values", () => {
207
+ const result = validateConfig({}, {});
208
+ expect(result.valid).toBe(true);
209
+ expect(result.values).toEqual({});
210
+ });
211
+
212
+ it("optional field with no default stores undefined", () => {
213
+ const schema: ConfigSchema<{ name: string }> = {
214
+ name: { type: "string", label: "Name" },
215
+ };
216
+ const result = validateConfig(schema, {});
217
+ expect(result.valid).toBe(true);
218
+ expect(result.values.name).toBeUndefined();
219
+ });
220
+
221
+ it("required field with default does not trigger error when missing", () => {
222
+ const schema: ConfigSchema<{ port: number }> = {
223
+ port: { type: "number", label: "Port", required: true, default: 8080 },
224
+ };
225
+ const result = validateConfig(schema, {});
226
+ expect(result.valid).toBe(true);
227
+ expect(result.values.port).toBe(8080);
228
+ });
229
+
230
+ it("number rejects empty string", () => {
231
+ const schema: ConfigSchema<{ n: number }> = {
232
+ n: { type: "number", label: "N", required: true },
233
+ };
234
+ const result = validateConfig(schema, { n: "" });
235
+ expect(result.valid).toBe(false);
236
+ });
237
+ });
238
+
239
+ // ── 7b. ConfigService Tests ────────────────────────────────────────────
240
+
241
+ describe("ConfigService", () => {
242
+ let db: Database.Database;
243
+ let dataDir: string;
244
+
245
+ beforeEach(() => {
246
+ db = createTestDb();
247
+ runMigrations(db, CORE_MIGRATIONS, testLogger);
248
+ dataDir = mkdtempSync(path.join(tmpdir(), "starr-config-"));
249
+ resetEncryptionKey();
250
+ loadEncryptionKey(dataDir, testLogger);
251
+ });
252
+
253
+ afterEach(() => {
254
+ db.close();
255
+ try {
256
+ const keyPath = path.join(dataDir, "starr.key");
257
+ if (existsSync(keyPath)) unlinkSync(keyPath);
258
+ rmdirSync(dataDir);
259
+ } catch {
260
+ // ignore cleanup failures
261
+ }
262
+ resetEncryptionKey();
263
+ });
264
+
265
+ it("register on first call persists schema and returns defaults", () => {
266
+ const cs = createTestConfigService(db);
267
+ const schema: ConfigSchema<{ port: number }> = {
268
+ port: { type: "number", label: "Port", default: 8080 },
269
+ };
270
+ const values = cs.register("test-plugin", schema);
271
+ expect(values).toEqual({ port: 8080 });
272
+
273
+ // Verify it was persisted
274
+ const entry = cs.get("test-plugin");
275
+ expect(entry).toBeDefined();
276
+ expect(entry!.schema).toEqual(schema);
277
+ expect(entry!.values.port).toBe(8080);
278
+ });
279
+
280
+ it("register on second call returns persisted values", () => {
281
+ const cs = createTestConfigService(db);
282
+ const schema: ConfigSchema<{ name: string }> = {
283
+ name: { type: "string", label: "Name", default: "default" },
284
+ };
285
+
286
+ // First call
287
+ cs.register("test-plugin", schema);
288
+
289
+ // Second call (simulating restart) - should return persisted value
290
+ // But since we never updated, it should still be the default
291
+ const values = cs.register("test-plugin", schema);
292
+ expect(values).toEqual({ name: "default" });
293
+ });
294
+
295
+ it("register returns persisted values after update", () => {
296
+ const cs = createTestConfigService(db);
297
+ const schema: ConfigSchema<{ name: string }> = {
298
+ name: { type: "string", label: "Name" },
299
+ };
300
+
301
+ cs.register("test-plugin", schema);
302
+ cs.update("test-plugin", schema, { name: "updated" });
303
+
304
+ // Re-register (simulating restart) - should get persisted value
305
+ const values = cs.register("test-plugin", schema);
306
+ expect(values).toEqual({ name: "updated" });
307
+ });
308
+
309
+ it("register with required field missing marks needsConfiguration", () => {
310
+ const cs = createTestConfigService(db);
311
+ const schema: ConfigSchema<{ apiKey: string }> = {
312
+ apiKey: { type: "secret", label: "API Key", required: true },
313
+ };
314
+
315
+ cs.register("test-plugin", schema);
316
+
317
+ expect(cs.needsConfiguration("test-plugin")).toBe(true);
318
+ const entry = cs.get("test-plugin");
319
+ expect(entry!.needsConfiguration).toBe(true);
320
+ });
321
+
322
+ it("register with all required fields present does not need configuration", () => {
323
+ const cs = createTestConfigService(db);
324
+ const schema: ConfigSchema<{ apiKey: string }> = {
325
+ apiKey: { type: "secret", label: "API Key", required: true },
326
+ };
327
+
328
+ cs.register("test-plugin", schema);
329
+ cs.update("test-plugin", schema, { apiKey: "sk-123" });
330
+
331
+ expect(cs.needsConfiguration("test-plugin")).toBe(false);
332
+ const entry = cs.get("test-plugin");
333
+ expect(entry!.needsConfiguration).toBe(false);
334
+ });
335
+
336
+ it("update with valid input succeeds and persists", () => {
337
+ const cs = createTestConfigService(db);
338
+ const schema: ConfigSchema<{ port: number; host: string }> = {
339
+ port: { type: "number", label: "Port", default: 8080 },
340
+ host: { type: "string", label: "Host", default: "localhost" },
341
+ };
342
+
343
+ cs.register("test-plugin", schema);
344
+ const result = cs.update("test-plugin", schema, { port: 9090, host: "example.com" });
345
+
346
+ expect(result.valid).toBe(true);
347
+ expect(result.values.port).toBe(9090);
348
+ expect(result.values.host).toBe("example.com");
349
+
350
+ // Verify persisted
351
+ const entry = cs.get("test-plugin");
352
+ expect(entry!.values.port).toBe(9090);
353
+ expect(entry!.values.host).toBe("example.com");
354
+ });
355
+
356
+ it("update with invalid input returns errors and does NOT persist", () => {
357
+ const cs = createTestConfigService(db);
358
+ const schema: ConfigSchema<{ port: number }> = {
359
+ port: { type: "number", label: "Port", required: true },
360
+ };
361
+
362
+ cs.register("test-plugin", schema);
363
+
364
+ // First set a valid value
365
+ cs.update("test-plugin", schema, { port: 8080 });
366
+
367
+ // Now try invalid
368
+ const result = cs.update("test-plugin", schema, { port: "not-a-number" });
369
+ expect(result.valid).toBe(false);
370
+ expect(result.errors).toHaveLength(1);
371
+
372
+ // Verify the good value is still there
373
+ const entry = cs.get("test-plugin");
374
+ expect(entry!.values.port).toBe(8080);
375
+ });
376
+
377
+ it("update triggers the onReload callback on success", () => {
378
+ const reloaded: string[] = [];
379
+ const cs = createTestConfigService(db, (pluginId) => {
380
+ reloaded.push(pluginId);
381
+ });
382
+ const schema: ConfigSchema<{ port: number }> = {
383
+ port: { type: "number", label: "Port" },
384
+ };
385
+ cs.register("test-plugin", schema);
386
+ cs.update("test-plugin", schema, { port: 9090 });
387
+ expect(reloaded).toContain("test-plugin");
388
+ });
389
+
390
+ it("update does NOT call onReload when validation fails", () => {
391
+ const reloaded: string[] = [];
392
+ const cs = createTestConfigService(db, (pluginId) => {
393
+ reloaded.push(pluginId);
394
+ });
395
+ const schema: ConfigSchema<{ port: number }> = {
396
+ port: { type: "number", label: "Port", required: true },
397
+ };
398
+ cs.register("test-plugin", schema);
399
+
400
+ // Attempt invalid update
401
+ cs.update("test-plugin", schema, { port: "bad" });
402
+ expect(reloaded).toHaveLength(0);
403
+ });
404
+
405
+ it("get returns schema + values for a registered plugin", () => {
406
+ const cs = createTestConfigService(db);
407
+ const schema: ConfigSchema<{ name: string }> = {
408
+ name: { type: "string", label: "Name", default: "test" },
409
+ };
410
+ cs.register("test-plugin", schema);
411
+ cs.update("test-plugin", schema, { name: "hello" });
412
+
413
+ const entry = cs.get("test-plugin");
414
+ expect(entry).toBeDefined();
415
+ expect(entry!.schema).toEqual(schema);
416
+ expect(entry!.values.name).toBe("hello");
417
+ expect(entry!.updatedAt).toBeDefined();
418
+ });
419
+
420
+ it("get returns undefined for unknown plugin", () => {
421
+ const cs = createTestConfigService(db);
422
+ const entry = cs.get("nonexistent");
423
+ expect(entry).toBeUndefined();
424
+ });
425
+
426
+ it("get masks secret values (returns sentinel, not plaintext)", () => {
427
+ const cs = createTestConfigService(db);
428
+ const schema: ConfigSchema<{ apiKey: string }> = {
429
+ apiKey: { type: "secret", label: "API Key" },
430
+ };
431
+ cs.register("test-plugin", schema);
432
+ cs.update("test-plugin", schema, { apiKey: "my-secret-key" });
433
+
434
+ const entry = cs.get("test-plugin");
435
+ expect(entry!.values.apiKey).toBe(SECRET_UNCHANGED_SENTINEL);
436
+ });
437
+
438
+ it("get returns needsConfiguration: true for plugins with missing required fields", () => {
439
+ const cs = createTestConfigService(db);
440
+ const schema: ConfigSchema<{ apiKey: string }> = {
441
+ apiKey: { type: "secret", label: "API Key", required: true },
442
+ };
443
+ cs.register("test-plugin", schema);
444
+
445
+ const entry = cs.get("test-plugin");
446
+ expect(entry!.needsConfiguration).toBe(true);
447
+ });
448
+
449
+ it("list returns all registered plugins", () => {
450
+ const cs = createTestConfigService(db);
451
+ const schemaA: ConfigSchema<{ name: string }> = {
452
+ name: { type: "string", label: "Name" },
453
+ };
454
+ const schemaB: ConfigSchema<{ port: number }> = {
455
+ port: { type: "number", label: "Port" },
456
+ };
457
+
458
+ cs.register("plugin-a", schemaA);
459
+ cs.register("plugin-b", schemaB);
460
+
461
+ const list = cs.list();
462
+ expect(list).toHaveLength(2);
463
+ expect(list.map((s) => s.pluginId)).toEqual(["plugin-a", "plugin-b"]);
464
+ });
465
+
466
+ it("secret encryption round-trip: update with secret value, get returns masked, update with sentinel preserves original", () => {
467
+ const cs = createTestConfigService(db);
468
+ const schema: ConfigSchema<{ apiKey: string }> = {
469
+ apiKey: { type: "secret", label: "API Key" },
470
+ };
471
+
472
+ cs.register("test-plugin", schema);
473
+ cs.update("test-plugin", schema, { apiKey: "my-secret-key" });
474
+
475
+ // get returns masked
476
+ const entry1 = cs.get("test-plugin");
477
+ expect(entry1!.values.apiKey).toBe(SECRET_UNCHANGED_SENTINEL);
478
+
479
+ // update with sentinel preserves original secret
480
+ cs.update("test-plugin", schema, { apiKey: SECRET_UNCHANGED_SENTINEL });
481
+
482
+ // register again (simulating restart) - should still have the original secret
483
+ const values = cs.register("test-plugin", schema);
484
+ expect(values).toEqual({ apiKey: "my-secret-key" });
485
+ });
486
+
487
+ it("secret in database is encrypted (not plaintext)", () => {
488
+ const cs = createTestConfigService(db);
489
+ const schema: ConfigSchema<{ apiKey: string }> = {
490
+ apiKey: { type: "secret", label: "API Key" },
491
+ };
492
+
493
+ cs.register("test-plugin", schema);
494
+ cs.update("test-plugin", schema, { apiKey: "my-secret-key" });
495
+
496
+ // Directly query the DB
497
+ const row = db.prepare("SELECT \"values\" FROM plugin_config WHERE plugin_id = ?").get("test-plugin") as { values: string } | undefined;
498
+ expect(row).toBeDefined();
499
+
500
+ const parsed = JSON.parse(row!.values);
501
+ // The secret field should NOT be the plaintext
502
+ expect(parsed.apiKey).not.toBe("my-secret-key");
503
+ // It should be a base64 string (encrypted blob)
504
+ expect(typeof parsed.apiKey).toBe("string");
505
+ expect(parsed.apiKey.length).toBeGreaterThan(0);
506
+
507
+ // Verify we can decrypt it
508
+ const decrypted = decrypt(parsed.apiKey);
509
+ expect(decrypted).toBe("my-secret-key");
510
+ });
511
+
512
+ it("schema changes between register calls are handled (new required field)", () => {
513
+ const cs = createTestConfigService(db);
514
+
515
+ // First registration with simple schema
516
+ const schema1: ConfigSchema<{ name: string }> = {
517
+ name: { type: "string", label: "Name", default: "test" },
518
+ };
519
+ cs.register("test-plugin", schema1);
520
+
521
+ // Update with a value
522
+ cs.update("test-plugin", schema1, { name: "original" });
523
+
524
+ // Now simulate a plugin upgrade with a new required field
525
+ const schema2: ConfigSchema<{ name: string; apiKey: string }> = {
526
+ name: { type: "string", label: "Name", default: "test" },
527
+ apiKey: { type: "secret", label: "API Key", required: true },
528
+ };
529
+
530
+ // Re-register with new schema
531
+ const values = cs.register("test-plugin", schema2);
532
+
533
+ // The old value "original" should be preserved for name
534
+ // But apiKey is required with no default → needs-configuration
535
+ expect(values).toBeDefined();
536
+ expect(cs.needsConfiguration("test-plugin")).toBe(true);
537
+
538
+ // The get should also reflect that
539
+ const entry = cs.get("test-plugin");
540
+ expect(entry!.needsConfiguration).toBe(true);
541
+ });
542
+
543
+ it("update with new secret value overwrites the old one", () => {
544
+ const cs = createTestConfigService(db);
545
+ const schema: ConfigSchema<{ apiKey: string }> = {
546
+ apiKey: { type: "secret", label: "API Key" },
547
+ };
548
+
549
+ cs.register("test-plugin", schema);
550
+ cs.update("test-plugin", schema, { apiKey: "old-secret" });
551
+ cs.update("test-plugin", schema, { apiKey: "new-secret" });
552
+
553
+ const values = cs.register("test-plugin", schema);
554
+ expect(values).toEqual({ apiKey: "new-secret" });
555
+ });
556
+
557
+ it("list returns needsConfiguration status", () => {
558
+ const cs = createTestConfigService(db);
559
+ const schema: ConfigSchema<{ apiKey: string }> = {
560
+ apiKey: { type: "secret", label: "API Key", required: true },
561
+ };
562
+ cs.register("plugin-a", schema);
563
+ cs.update("plugin-a", schema, { apiKey: "set" });
564
+
565
+ cs.register("plugin-b", schema); // missing required
566
+
567
+ const list = cs.list();
568
+ const a = list.find((s) => s.pluginId === "plugin-a");
569
+ const b = list.find((s) => s.pluginId === "plugin-b");
570
+ expect(a!.needsConfiguration).toBe(false);
571
+ expect(b!.needsConfiguration).toBe(true);
572
+ });
573
+
574
+ it("getDecryptedValues returns undefined for unknown plugin", () => {
575
+ const cs = createTestConfigService(db);
576
+ const result = cs.getDecryptedValues("nonexistent");
577
+ expect(result).toBeUndefined();
578
+ });
579
+
580
+ it("getDecryptedValues returns decrypted secret values", () => {
581
+ const cs = createTestConfigService(db);
582
+ const schema: ConfigSchema<{ apiKey: string }> = {
583
+ apiKey: { type: "secret", label: "API Key" },
584
+ };
585
+
586
+ cs.register("test-plugin", schema);
587
+ cs.update("test-plugin", schema, { apiKey: "my-secret-key" });
588
+
589
+ const decrypted = cs.getDecryptedValues("test-plugin");
590
+ expect(decrypted).toBeDefined();
591
+ expect(decrypted!.apiKey).toBe("my-secret-key");
592
+ });
593
+
594
+ it("getDecryptedValues returns non-secret fields as stored", () => {
595
+ const cs = createTestConfigService(db);
596
+ const schema: ConfigSchema<{ endpoint: string; apiKey: string }> = {
597
+ endpoint: { type: "url", label: "Endpoint" },
598
+ apiKey: { type: "secret", label: "API Key" },
599
+ };
600
+
601
+ cs.register("test-plugin", schema);
602
+ cs.update("test-plugin", schema, {
603
+ endpoint: "http://jackett:9117",
604
+ apiKey: "secret-123",
605
+ });
606
+
607
+ const decrypted = cs.getDecryptedValues("test-plugin");
608
+ expect(decrypted).toBeDefined();
609
+ expect(decrypted!.endpoint).toBe("http://jackett:9117");
610
+ expect(decrypted!.apiKey).toBe("secret-123");
611
+ });
612
+ });