@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,85 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyRequest, FastifyReply } from "fastify";
5
+
6
+ interface RateLimitEntry {
7
+ count: number;
8
+ windowStart: number; // Unix milliseconds
9
+ }
10
+
11
+ export class RateLimiter {
12
+ private map = new Map<string, RateLimitEntry>();
13
+ private maxAttempts: number;
14
+ private windowMs: number;
15
+
16
+ constructor(maxAttempts = 4, windowMinutes = 15) {
17
+ this.maxAttempts = maxAttempts;
18
+ this.windowMs = windowMinutes * 60_000;
19
+ }
20
+
21
+ /**
22
+ * Record a failed attempt for the given key (typically IP).
23
+ * Returns true if the attempt is allowed, false if rate-limited.
24
+ */
25
+ check(key: string): boolean {
26
+ const now = Date.now();
27
+ const entry = this.map.get(key);
28
+
29
+ if (!entry || now - entry.windowStart > this.windowMs) {
30
+ // New window
31
+ this.map.set(key, { count: 1, windowStart: now });
32
+ return true;
33
+ }
34
+
35
+ entry.count++;
36
+ if (entry.count > this.maxAttempts) {
37
+ return false; // rate-limited
38
+ }
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Reset the rate limit for a key (called on successful login).
44
+ */
45
+ reset(key: string): void {
46
+ this.map.delete(key);
47
+ }
48
+
49
+ /**
50
+ * How many seconds until the window resets for a key.
51
+ * Used to inform the client.
52
+ */
53
+ retryAfterSeconds(key: string): number {
54
+ const entry = this.map.get(key);
55
+ if (!entry) return 0;
56
+ const elapsed = Date.now() - entry.windowStart;
57
+ return Math.max(0, Math.ceil((this.windowMs - elapsed) / 1000));
58
+ }
59
+ }
60
+
61
+ // ── Fastify preHandler wrapper ────────────────────────────────
62
+
63
+ /**
64
+ * Return a Fastify preHandler hook that rate-limits by IP.
65
+ * Usage:
66
+ * server.post("/api/auth/login", { preHandler: rateLimit(5, 60) }, handler)
67
+ *
68
+ * @param maxAttempts Maximum attempts allowed in the window.
69
+ * @param windowMinutes Time window in minutes.
70
+ */
71
+ export function rateLimit(maxAttempts: number, windowMinutes: number) {
72
+ const limiter = new RateLimiter(maxAttempts, windowMinutes);
73
+
74
+ return async (request: FastifyRequest, reply: FastifyReply) => {
75
+ const key = request.ip;
76
+ const allowed = limiter.check(key);
77
+ if (!allowed) {
78
+ reply
79
+ .status(429)
80
+ .header("Retry-After", String(limiter.retryAfterSeconds(key)))
81
+ .send({ error: "Too many requests. Please try again later." });
82
+ return;
83
+ }
84
+ };
85
+ }
@@ -0,0 +1,434 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type Database from "better-sqlite3";
5
+ import type { ConfigSchema, ConfigField, Logger } from "../types/plugin.js";
6
+ import { validateConfig, type ValidationResult } from "./validate.js";
7
+ import { encrypt, decrypt } from "../datastore/encrypt.js";
8
+
9
+ /**
10
+ * Sentinel value used to mask secret fields in UI-exported data.
11
+ * When update() receives this value for a secret field, it preserves
12
+ * the existing encrypted value rather than overwriting it.
13
+ */
14
+ export const SECRET_UNCHANGED_SENTINEL = "__STARR_SECRET_UNCHANGED__";
15
+
16
+ export interface PluginConfigEntry {
17
+ schema: ConfigSchema<any>;
18
+ values: Record<string, unknown>;
19
+ needsConfiguration: boolean;
20
+ updatedAt: string;
21
+ }
22
+
23
+ export interface PluginConfigSummary {
24
+ pluginId: string;
25
+ instanceId?: string;
26
+ needsConfiguration: boolean;
27
+ updatedAt: string;
28
+ /** The connected library ID, if any (for downloader instances). */
29
+ libraryId?: string;
30
+ }
31
+
32
+ export class ConfigService {
33
+ private db: Database.Database;
34
+ private logger: Logger;
35
+ private onReload?: (pluginId: string) => void;
36
+
37
+ // Prepared statements
38
+ private getStmt: Database.Statement;
39
+ private getByInstanceStmt: Database.Statement;
40
+ private upsertStmt: Database.Statement;
41
+ private listStmt: Database.Statement;
42
+ private listInstancesStmt: Database.Statement;
43
+ private deleteInstanceStmt: Database.Statement;
44
+
45
+ constructor(
46
+ db: Database.Database,
47
+ logger: Logger,
48
+ onReload?: (pluginId: string) => void,
49
+ ) {
50
+ this.db = db;
51
+ this.logger = logger;
52
+ this.onReload = onReload;
53
+
54
+ this.getStmt = db.prepare(
55
+ 'SELECT schema, "values", updated_at FROM plugin_config WHERE plugin_id = ?',
56
+ );
57
+ this.getByInstanceStmt = db.prepare(
58
+ 'SELECT schema, "values", updated_at FROM plugin_config WHERE plugin_id = ? AND instance_id = ?',
59
+ );
60
+ this.upsertStmt = db.prepare(`
61
+ INSERT INTO plugin_config (plugin_id, instance_id, schema, "values", updated_at)
62
+ VALUES (?, ?, ?, ?, datetime('now'))
63
+ ON CONFLICT(plugin_id, instance_id) DO UPDATE SET
64
+ schema = excluded.schema,
65
+ "values" = excluded."values",
66
+ updated_at = datetime('now')
67
+ `);
68
+ this.listStmt = db.prepare(
69
+ 'SELECT plugin_id, instance_id, schema, "values", updated_at FROM plugin_config ORDER BY plugin_id, instance_id',
70
+ );
71
+ this.listInstancesStmt = db.prepare(
72
+ 'SELECT plugin_id, instance_id, schema, "values", updated_at FROM plugin_config WHERE plugin_id = ? ORDER BY instance_id',
73
+ );
74
+ this.deleteInstanceStmt = db.prepare(
75
+ "DELETE FROM plugin_config WHERE plugin_id = ? AND instance_id = ?",
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Called by the plugin context during register.
81
+ * Returns read-only validated values.
82
+ */
83
+ register<T>(
84
+ pluginId: string,
85
+ schema: ConfigSchema<T>,
86
+ instanceId = "default",
87
+ ): Readonly<T> {
88
+ const row = this.getByInstanceStmt.get(pluginId, instanceId) as
89
+ | { schema: string; values: string; updated_at: string }
90
+ | undefined;
91
+
92
+ if (row) {
93
+ // Existing row — decrypt secrets, re-validate against current schema
94
+ const storedValues = this.decryptValues(schema, JSON.parse(row.values));
95
+ // Merge stored values with defaults from the current schema so that
96
+ // newly added fields (e.g. libraryId) are picked up.
97
+ const mergedValues = { ...this.buildDefaults(schema), ...storedValues };
98
+ const result = validateConfig(schema, mergedValues);
99
+
100
+ if (result.valid) {
101
+ // Schema or values may have changed (new optional fields, new defaults).
102
+ // Persist the current schema so get/list reflect it, and persist the
103
+ // merged values so existing config is preserved.
104
+ const toPersist = this.encryptValues(schema, mergedValues);
105
+ this.upsertStmt.run(pluginId, instanceId, JSON.stringify(schema), JSON.stringify(toPersist));
106
+ return Object.freeze(result.values as T);
107
+ }
108
+
109
+ // Validation failed against current schema (e.g., new required field added)
110
+ this.logger.warn("config re-validation failed, marking needs-configuration", {
111
+ pluginId,
112
+ instanceId,
113
+ errors: result.errors,
114
+ });
115
+ // Persist the new schema (so get/list reflect current schema) but keep
116
+ // existing values so user's prior changes aren't lost.
117
+ const toPersistOnFail = this.encryptValues(schema, mergedValues);
118
+ this.upsertStmt.run(pluginId, instanceId, JSON.stringify(schema), JSON.stringify(toPersistOnFail));
119
+ // Return defaults, plugin will need configuration
120
+ const defaults = this.buildDefaults(schema);
121
+ return Object.freeze(defaults as T);
122
+ }
123
+
124
+ // No existing row — first registration
125
+ const defaults = this.buildDefaults(schema);
126
+ const result = validateConfig(schema, defaults);
127
+
128
+ if (!result.valid) {
129
+ // Has required fields without defaults — persist defaults and mark needs-configuration
130
+ this.logger.warn("config has missing required fields, needs-configuration", {
131
+ pluginId,
132
+ instanceId,
133
+ errors: result.errors,
134
+ });
135
+ }
136
+
137
+ // Persist schema and values (encrypt secrets before storing)
138
+ const toPersist = this.encryptValues(schema, result.valid ? result.values : defaults);
139
+ this.upsertStmt.run(pluginId, instanceId, JSON.stringify(schema), JSON.stringify(toPersist));
140
+
141
+ return Object.freeze((result.valid ? result.values : defaults) as T);
142
+ }
143
+
144
+ /**
145
+ * Called by the admin settings API when saving config.
146
+ * Updates values, persists, and triggers reload.
147
+ */
148
+ update(
149
+ pluginId: string,
150
+ schema: ConfigSchema<any>,
151
+ input: Record<string, unknown>,
152
+ instanceId = "default",
153
+ ): ValidationResult {
154
+ const result = validateConfig(schema, input);
155
+
156
+ if (!result.valid) {
157
+ // Validation errors block save (CF3)
158
+ return result;
159
+ }
160
+
161
+ // Before persisting, handle secret fields:
162
+ // If a secret field's value is the sentinel, retrieve and use the existing
163
+ // decrypted value (so encryptValues() can re-encrypt it properly).
164
+ const existingRow = this.getByInstanceStmt.get(pluginId, instanceId) as
165
+ | { schema: string; values: string }
166
+ | undefined;
167
+
168
+ if (existingRow) {
169
+ const existingParsed = JSON.parse(existingRow.values);
170
+ const existingDecrypted = this.decryptValues(schema, existingParsed);
171
+ for (const [key, field] of Object.entries(schema)) {
172
+ const f = field as ConfigField;
173
+ if (f.type === "secret") {
174
+ if (result.values[key] === SECRET_UNCHANGED_SENTINEL) {
175
+ // Use the decrypted existing value — will be re-encrypted below
176
+ result.values[key] = existingDecrypted[key];
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ // Encrypt secret fields before persisting
183
+ const toPersist = this.encryptValues(schema, result.values);
184
+
185
+ // Persist
186
+ this.upsertStmt.run(pluginId, instanceId, JSON.stringify(schema), JSON.stringify(toPersist));
187
+
188
+ // Trigger reload
189
+ this.logger.info("config updated, triggering reload", { pluginId, instanceId });
190
+ this.onReload?.(pluginId);
191
+
192
+ return result;
193
+ }
194
+
195
+ /**
196
+ * Called by the admin UI to get render data.
197
+ * Returns schema + values (with secrets masked) + needsConfiguration flag.
198
+ */
199
+ get(pluginId: string, instanceId = "default"): PluginConfigEntry | undefined {
200
+ const row = this.getByInstanceStmt.get(pluginId, instanceId) as
201
+ | { schema: string; values: string; updated_at: string }
202
+ | undefined;
203
+
204
+ if (!row) return undefined;
205
+
206
+ const schema = JSON.parse(row.schema) as ConfigSchema<any>;
207
+ const storedValues = JSON.parse(row.values);
208
+
209
+ // Decrypt secrets for internal computation
210
+ const decryptedValues = this.decryptValues(schema, storedValues);
211
+
212
+ // Mask secrets for UI output
213
+ const maskedValues = this.maskSecretValues(schema, decryptedValues);
214
+
215
+ const needsConfiguration = this.computeNeedsConfiguration(schema, decryptedValues);
216
+
217
+ return {
218
+ schema,
219
+ values: maskedValues,
220
+ needsConfiguration,
221
+ updatedAt: row.updated_at,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Called by the admin UI to list all plugins with config status.
227
+ */
228
+ list(): PluginConfigSummary[] {
229
+ const rows = this.listStmt.all() as Array<{
230
+ plugin_id: string;
231
+ instance_id: string;
232
+ schema: string;
233
+ values: string;
234
+ updated_at: string;
235
+ }>;
236
+
237
+ return rows
238
+ .filter((row) => !row.plugin_id.startsWith("_core."))
239
+ .map((row) => {
240
+ const schema = JSON.parse(row.schema) as ConfigSchema<any>;
241
+ const values = JSON.parse(row.values);
242
+ const decryptedValues = this.decryptValues(schema, values);
243
+ const needsConfiguration = this.computeNeedsConfiguration(schema, decryptedValues);
244
+
245
+ return {
246
+ pluginId: row.plugin_id,
247
+ instanceId: row.instance_id,
248
+ needsConfiguration,
249
+ updatedAt: row.updated_at,
250
+ libraryId: decryptedValues.libraryId as string | undefined,
251
+ };
252
+ });
253
+ }
254
+
255
+ /**
256
+ * List all instances for a given plugin.
257
+ */
258
+ listInstances(pluginId: string): PluginConfigSummary[] {
259
+ const rows = this.listInstancesStmt.all(pluginId) as Array<{
260
+ plugin_id: string;
261
+ instance_id: string;
262
+ schema: string;
263
+ values: string;
264
+ updated_at: string;
265
+ }>;
266
+
267
+ return rows
268
+ .filter((row) => !row.plugin_id.startsWith("_core."))
269
+ .map((row) => {
270
+ const schema = JSON.parse(row.schema) as ConfigSchema<any>;
271
+ const values = JSON.parse(row.values);
272
+ const decryptedValues = this.decryptValues(schema, values);
273
+ const needsConfiguration = this.computeNeedsConfiguration(schema, decryptedValues);
274
+
275
+ return {
276
+ pluginId: row.plugin_id,
277
+ instanceId: row.instance_id,
278
+ needsConfiguration,
279
+ updatedAt: row.updated_at,
280
+ libraryId: decryptedValues.libraryId as string | undefined,
281
+ };
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Create a new plugin instance with the given instanceId and config values.
287
+ * Validates and persists.
288
+ */
289
+ createInstance<T>(
290
+ pluginId: string,
291
+ instanceId: string,
292
+ schema: ConfigSchema<T>,
293
+ values: Record<string, unknown>,
294
+ ): ValidationResult {
295
+ // Validate
296
+ const result = validateConfig(schema, values);
297
+ if (!result.valid) return result;
298
+
299
+ // Check for duplicate instance
300
+ const existing = this.get(pluginId, instanceId);
301
+ if (existing) {
302
+ return { valid: false, values: {}, errors: [{ field: "instanceId", message: `Instance "${instanceId}" already exists` }] };
303
+ }
304
+
305
+ // Encrypt and persist
306
+ const toPersist = this.encryptValues(schema, result.values);
307
+ this.upsertStmt.run(pluginId, instanceId, JSON.stringify(schema), JSON.stringify(toPersist));
308
+
309
+ return result;
310
+ }
311
+
312
+ /**
313
+ * Delete a plugin instance and its config.
314
+ */
315
+ deleteInstance(pluginId: string, instanceId: string): void {
316
+ const result = this.deleteInstanceStmt.run(pluginId, instanceId);
317
+ if (result.changes === 0) {
318
+ throw new Error(`Instance not found: ${pluginId}::${instanceId}`);
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Return decrypted stored values for a plugin, or undefined if no row exists.
324
+ * Used by the test-connection endpoint to resolve secret sentinels.
325
+ */
326
+ getDecryptedValues(pluginId: string, instanceId = "default"): Record<string, unknown> | undefined {
327
+ const row = this.getByInstanceStmt.get(pluginId, instanceId) as
328
+ | { schema: string; values: string }
329
+ | undefined;
330
+
331
+ if (!row) return undefined;
332
+
333
+ const schema = JSON.parse(row.schema) as ConfigSchema<any>;
334
+ const storedValues = JSON.parse(row.values);
335
+ return this.decryptValues(schema, storedValues);
336
+ }
337
+
338
+ /**
339
+ * Check whether a plugin needs configuration (required fields missing).
340
+ * Used by context.ts for the debug log.
341
+ */
342
+ needsConfiguration(pluginId: string, instanceId = "default"): boolean {
343
+ const row = this.getByInstanceStmt.get(pluginId, instanceId) as
344
+ | { schema: string; values: string }
345
+ | undefined;
346
+
347
+ if (!row) return false;
348
+ const schema = JSON.parse(row.schema) as ConfigSchema<any>;
349
+ const storedValues = JSON.parse(row.values);
350
+ const decryptedValues = this.decryptValues(schema, storedValues);
351
+ return this.computeNeedsConfiguration(schema, decryptedValues);
352
+ }
353
+
354
+ // ── Private helpers ──────────────────────────────────────
355
+
356
+ private buildDefaults(schema: ConfigSchema<any>): Record<string, unknown> {
357
+ const defaults: Record<string, unknown> = {};
358
+ for (const [key, rawField] of Object.entries(schema)) {
359
+ const field = rawField as ConfigField;
360
+ if (field.default !== undefined) {
361
+ defaults[key] = field.default;
362
+ }
363
+ }
364
+ return defaults;
365
+ }
366
+
367
+ private computeNeedsConfiguration(
368
+ schema: ConfigSchema<any>,
369
+ values: Record<string, unknown>,
370
+ ): boolean {
371
+ for (const [key, rawField] of Object.entries(schema)) {
372
+ const field = rawField as ConfigField;
373
+ if (field.required === true) {
374
+ const val = values[key];
375
+ if (val === undefined || val === null || val === "") {
376
+ if (field.default === undefined) {
377
+ return true;
378
+ }
379
+ }
380
+ }
381
+ }
382
+ return false;
383
+ }
384
+
385
+ private encryptValues(
386
+ schema: ConfigSchema<any>,
387
+ values: Record<string, unknown>,
388
+ ): Record<string, unknown> {
389
+ const result = { ...values };
390
+ for (const [key, rawField] of Object.entries(schema)) {
391
+ const field = rawField as ConfigField;
392
+ if (field.type === "secret" && result[key] !== undefined && result[key] !== null) {
393
+ const plaintext = String(result[key]);
394
+ result[key] = encrypt(plaintext);
395
+ }
396
+ }
397
+ return result;
398
+ }
399
+
400
+ private decryptValues(
401
+ schema: ConfigSchema<any>,
402
+ values: Record<string, unknown>,
403
+ ): Record<string, unknown> {
404
+ const result = { ...values };
405
+ for (const [key, rawField] of Object.entries(schema)) {
406
+ const field = rawField as ConfigField;
407
+ if (field.type === "secret" && result[key] !== undefined && result[key] !== null && typeof result[key] === "string") {
408
+ try {
409
+ result[key] = decrypt(result[key] as string);
410
+ } catch {
411
+ // If decryption fails, leave as-is (corrupted data)
412
+ this.logger.warn("failed to decrypt secret field", { field: key });
413
+ }
414
+ }
415
+ }
416
+ return result;
417
+ }
418
+
419
+ private maskSecretValues(
420
+ schema: ConfigSchema<any>,
421
+ values: Record<string, unknown>,
422
+ ): Record<string, unknown> {
423
+ const result = { ...values };
424
+ for (const [key, rawField] of Object.entries(schema)) {
425
+ const field = rawField as ConfigField;
426
+ if (field.type === "secret") {
427
+ if (result[key] !== undefined && result[key] !== null && result[key] !== "") {
428
+ result[key] = SECRET_UNCHANGED_SENTINEL;
429
+ }
430
+ }
431
+ }
432
+ return result;
433
+ }
434
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { ConfigSchema, ConfigField } from "../types/plugin.js";
5
+
6
+ export interface ValidationResult {
7
+ valid: boolean;
8
+ values: Record<string, unknown>;
9
+ errors: FieldError[];
10
+ }
11
+
12
+ export interface FieldError {
13
+ field: string;
14
+ message: string;
15
+ }
16
+
17
+ function isMissing(value: unknown): boolean {
18
+ if (value === undefined || value === null) return true;
19
+ if (typeof value === "string" && value === "") return true;
20
+ return false;
21
+ }
22
+
23
+ function coerceNumber(value: unknown): { value: number; ok: boolean } {
24
+ if (typeof value === "number") {
25
+ if (Number.isFinite(value)) return { value, ok: true };
26
+ return { value: NaN, ok: false };
27
+ }
28
+ if (typeof value === "string") {
29
+ if (value === "") return { value: NaN, ok: false };
30
+ const n = Number(value);
31
+ if (Number.isFinite(n)) return { value: n, ok: true };
32
+ return { value: NaN, ok: false };
33
+ }
34
+ return { value: NaN, ok: false };
35
+ }
36
+
37
+ function coerceBoolean(value: unknown): { value: boolean; ok: boolean } {
38
+ if (typeof value === "boolean") return { value, ok: true };
39
+ if (value === "true") return { value: true, ok: true };
40
+ if (value === "false") return { value: false, ok: true };
41
+ return { value: false, ok: false };
42
+ }
43
+
44
+ export function validateConfig<T>(
45
+ schema: ConfigSchema<T>,
46
+ input: Record<string, unknown>,
47
+ ): ValidationResult {
48
+ const values: Record<string, unknown> = {};
49
+ const errors: FieldError[] = [];
50
+
51
+ for (const [key, rawField] of Object.entries(schema)) {
52
+ const field = rawField as ConfigField;
53
+ const inputVal = key in input ? input[key] : undefined;
54
+ const missing = isMissing(inputVal);
55
+
56
+ // Handle missing values
57
+ if (missing) {
58
+ if (field.default !== undefined) {
59
+ // Default is defined — use it (even if null)
60
+ values[key] = field.default;
61
+ continue;
62
+ }
63
+ if (field.required === true) {
64
+ errors.push({ field: key, message: "required field is missing" });
65
+ continue;
66
+ }
67
+ // Optional field with no default — store undefined
68
+ values[key] = undefined;
69
+ continue;
70
+ }
71
+
72
+ // Value is present — validate and coerce based on type
73
+ switch (field.type) {
74
+ case "string":
75
+ case "secret": {
76
+ if (typeof inputVal !== "string") {
77
+ errors.push({ field: key, message: `expected string, got ${typeof inputVal}` });
78
+ } else {
79
+ values[key] = inputVal;
80
+ }
81
+ break;
82
+ }
83
+
84
+ case "number": {
85
+ const coerced = coerceNumber(inputVal);
86
+ if (!coerced.ok) {
87
+ errors.push({ field: key, message: `expected a finite number, got ${JSON.stringify(inputVal)}` });
88
+ } else {
89
+ values[key] = coerced.value;
90
+ }
91
+ break;
92
+ }
93
+
94
+ case "boolean": {
95
+ const coerced = coerceBoolean(inputVal);
96
+ if (!coerced.ok) {
97
+ errors.push({ field: key, message: `expected boolean or "true"/"false", got ${JSON.stringify(inputVal)}` });
98
+ } else {
99
+ values[key] = coerced.value;
100
+ }
101
+ break;
102
+ }
103
+
104
+ case "enum": {
105
+ if (typeof inputVal !== "string") {
106
+ errors.push({ field: key, message: `expected string for enum, got ${typeof inputVal}` });
107
+ } else if (field.options && field.options.length > 0 && !field.options.includes(inputVal)) {
108
+ errors.push({
109
+ field: key,
110
+ message: `value "${inputVal}" is not in allowed options: [${field.options.join(", ")}]`,
111
+ });
112
+ } else {
113
+ values[key] = inputVal;
114
+ }
115
+ break;
116
+ }
117
+
118
+ case "url": {
119
+ if (typeof inputVal !== "string") {
120
+ errors.push({ field: key, message: `expected string for URL, got ${typeof inputVal}` });
121
+ } else {
122
+ try {
123
+ new URL(inputVal);
124
+ values[key] = inputVal;
125
+ } catch {
126
+ errors.push({ field: key, message: `"${inputVal}" is not a valid URL` });
127
+ }
128
+ }
129
+ break;
130
+ }
131
+
132
+ case "path": {
133
+ if (typeof inputVal !== "string" || inputVal === "") {
134
+ errors.push({ field: key, message: `expected non-empty string for path, got ${JSON.stringify(inputVal)}` });
135
+ } else {
136
+ values[key] = inputVal;
137
+ }
138
+ break;
139
+ }
140
+
141
+ default:
142
+ // Unknown field type — leniently accept as-is
143
+ values[key] = inputVal;
144
+ break;
145
+ }
146
+ }
147
+
148
+ return {
149
+ valid: errors.length === 0,
150
+ values: errors.length === 0 ? values : {},
151
+ errors,
152
+ };
153
+ }