@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,282 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { ConfigService } from "../../config/config-service.js";
6
+ import type { ConfigField } from "../../types/plugin.js";
7
+ import { SECRET_UNCHANGED_SENTINEL } from "../../config/config-service.js";
8
+ import type { PluginRuntime } from "../../host.js";
9
+
10
+ let configService: ConfigService | null = null;
11
+ let getRuntimes: (() => readonly PluginRuntime[]) | null = null;
12
+
13
+ export function initConfigRoutes(
14
+ cs: ConfigService,
15
+ runtimeGetter: () => readonly PluginRuntime[],
16
+ ): void {
17
+ configService = cs;
18
+ getRuntimes = runtimeGetter;
19
+ }
20
+
21
+ function requireAdmin(request: any, reply: any): boolean {
22
+ const user = (request as any).user;
23
+ if (!user || user.role !== "admin") {
24
+ reply.status(403).send({ error: "Forbidden: admin role required" });
25
+ return false;
26
+ }
27
+ return true;
28
+ }
29
+
30
+ const configRoutes: FastifyPluginAsync = async (server) => {
31
+ // GET /api/config — list all plugin config summaries
32
+ server.get("/api/config", async (request, reply) => {
33
+ if (!requireAdmin(request, reply)) return;
34
+ if (!configService) {
35
+ return { plugins: [] };
36
+ }
37
+ return { plugins: configService.list() };
38
+ });
39
+
40
+ // GET /api/config/:pluginId — get single plugin config (or instance if ?instanceId=)
41
+ server.get<{ Params: { pluginId: string }; Querystring: { instanceId?: string } }>(
42
+ "/api/config/:pluginId",
43
+ async (request, reply) => {
44
+ if (!requireAdmin(request, reply)) return;
45
+ if (!configService) {
46
+ reply.status(500).send({ error: "config service not available" });
47
+ return;
48
+ }
49
+
50
+ const instanceId = request.query.instanceId ?? "default";
51
+ const entry = configService.get(request.params.pluginId, instanceId);
52
+ if (!entry) {
53
+ reply.status(404).send({ error: "plugin/instance not found" });
54
+ return;
55
+ }
56
+
57
+ // Find runtime for this instance
58
+ const fullId = instanceId === "default"
59
+ ? request.params.pluginId
60
+ : `${request.params.pluginId}::${instanceId}`;
61
+ const runtime = getRuntimes?.().find(
62
+ (r) => r.meta.id === fullId,
63
+ );
64
+
65
+ return {
66
+ ...entry,
67
+ instanceId,
68
+ supportsTest: !!runtime?.lifecycle.testConnection,
69
+ };
70
+ },
71
+ );
72
+
73
+ // PUT /api/config/:pluginId — update config (optional ?instanceId=)
74
+ server.put<{
75
+ Params: { pluginId: string };
76
+ Querystring: { instanceId?: string };
77
+ Body: Record<string, unknown>;
78
+ }>(
79
+ "/api/config/:pluginId",
80
+ async (request, reply) => {
81
+ if (!requireAdmin(request, reply)) return;
82
+ if (!configService) {
83
+ reply.status(500).send({ error: "config service not available" });
84
+ return;
85
+ }
86
+
87
+ const instanceId = request.query.instanceId ?? "default";
88
+ const entry = configService.get(request.params.pluginId, instanceId);
89
+ if (!entry) {
90
+ reply.status(404).send({ error: "plugin/instance not found" });
91
+ return;
92
+ }
93
+
94
+ const result = configService.update(
95
+ request.params.pluginId,
96
+ entry.schema,
97
+ request.body,
98
+ instanceId,
99
+ );
100
+
101
+ if (!result.valid) {
102
+ reply.status(400).send({ errors: result.errors });
103
+ return;
104
+ }
105
+
106
+ return { ok: true };
107
+ },
108
+ );
109
+
110
+ // GET /api/config/:pluginId/instances — list all instances for a plugin
111
+ server.get<{ Params: { pluginId: string } }>(
112
+ "/api/config/:pluginId/instances",
113
+ async (request, reply) => {
114
+ if (!requireAdmin(request, reply)) return;
115
+ if (!configService) {
116
+ reply.status(500).send({ error: "config service not available" });
117
+ return;
118
+ }
119
+
120
+ const instances = configService.listInstances(request.params.pluginId);
121
+ return { instances };
122
+ },
123
+ );
124
+
125
+ // POST /api/config/:pluginId/instances — create a new instance
126
+ server.post<{
127
+ Params: { pluginId: string };
128
+ Body: { instanceId: string; values: Record<string, unknown> };
129
+ }>(
130
+ "/api/config/:pluginId/instances",
131
+ async (request, reply) => {
132
+ if (!requireAdmin(request, reply)) return;
133
+ if (!configService) {
134
+ reply.status(500).send({ error: "config service not available" });
135
+ return;
136
+ }
137
+
138
+ const { pluginId } = request.params;
139
+ const { instanceId, values } = request.body;
140
+
141
+ if (!instanceId || typeof instanceId !== "string") {
142
+ reply.status(400).send({ error: "instanceId is required" });
143
+ return;
144
+ }
145
+
146
+ // Get the schema from the default instance (or any existing instance)
147
+ const defaultEntry = configService.get(pluginId, "default");
148
+ if (!defaultEntry) {
149
+ reply.status(404).send({ error: "plugin not found; configure the default instance first" });
150
+ return;
151
+ }
152
+
153
+ const result = configService.createInstance(
154
+ pluginId,
155
+ instanceId,
156
+ defaultEntry.schema,
157
+ values,
158
+ );
159
+
160
+ if (!result.valid) {
161
+ reply.status(400).send({ errors: result.errors });
162
+ return;
163
+ }
164
+
165
+ reply.status(201).send({
166
+ pluginId,
167
+ instanceId,
168
+ values: result.values,
169
+ });
170
+ },
171
+ );
172
+
173
+ // DELETE /api/config/:pluginId/instances/:instanceId — delete an instance
174
+ server.delete<{
175
+ Params: { pluginId: string; instanceId: string };
176
+ Querystring: { force?: string };
177
+ }>(
178
+ "/api/config/:pluginId/instances/:instanceId",
179
+ async (request, reply) => {
180
+ if (!requireAdmin(request, reply)) return;
181
+ if (!configService) {
182
+ reply.status(500).send({ error: "config service not available" });
183
+ return;
184
+ }
185
+
186
+ const { pluginId, instanceId } = request.params;
187
+
188
+ try {
189
+ // If not force, check if instance has libraryId set (downloader connection)
190
+ if (request.query.force !== "true") {
191
+ const entry = configService.get(pluginId, instanceId);
192
+ if (entry?.values?.libraryId) {
193
+ reply.status(400).send({
194
+ error: `Instance "${instanceId}" is connected to library "${entry.values.libraryId}". Use ?force=true to disconnect and delete, or clear the libraryId first.`,
195
+ });
196
+ return;
197
+ }
198
+ }
199
+
200
+ configService.deleteInstance(pluginId, instanceId);
201
+ return { deleted: true };
202
+ } catch (err: any) {
203
+ reply.status(400).send({ error: err.message });
204
+ }
205
+ },
206
+ );
207
+
208
+ // POST /api/config/:pluginId/test — test connection without saving (optional ?instanceId=)
209
+ server.post<{
210
+ Params: { pluginId: string };
211
+ Querystring: { instanceId?: string };
212
+ Body: Record<string, unknown>;
213
+ }>(
214
+ "/api/config/:pluginId/test",
215
+ async (request, reply) => {
216
+ if (!requireAdmin(request, reply)) return;
217
+ if (!configService) {
218
+ reply.status(500).send({ error: "config service not available" });
219
+ return;
220
+ }
221
+
222
+ const instanceId = request.query.instanceId ?? "default";
223
+ const entry = configService.get(request.params.pluginId, instanceId);
224
+ if (!entry) {
225
+ reply.status(404).send({ error: "plugin/instance not found" });
226
+ return;
227
+ }
228
+
229
+ // Resolve secret sentinels without validating the body
230
+ const decrypted = configService.getDecryptedValues(request.params.pluginId, instanceId);
231
+ const testValues = { ...request.body };
232
+ for (const [key, rawField] of Object.entries(entry.schema)) {
233
+ const field = rawField as ConfigField;
234
+ if (field.type === "secret" && testValues[key] === SECRET_UNCHANGED_SENTINEL) {
235
+ if (!decrypted || decrypted[key] === undefined) {
236
+ reply.status(400).send({
237
+ error: `Secret field "${key}" is required to run the test`,
238
+ });
239
+ return;
240
+ }
241
+ testValues[key] = decrypted[key];
242
+ }
243
+ }
244
+
245
+ // Find the plugin runtime for this instance
246
+ const fullId = instanceId === "default"
247
+ ? request.params.pluginId
248
+ : `${request.params.pluginId}::${instanceId}`;
249
+ const runtime = getRuntimes?.().find(
250
+ (r) => r.meta.id === fullId,
251
+ );
252
+
253
+ if (!runtime?.lifecycle.testConnection) {
254
+ reply.status(400).send({
255
+ error: "Plugin does not support connection tests",
256
+ });
257
+ return;
258
+ }
259
+
260
+ try {
261
+ const result = await runtime.lifecycle.testConnection(testValues);
262
+ return result;
263
+ } catch (err) {
264
+ // Sanitize: mask any secret values in the error message
265
+ let sanitizedError =
266
+ err instanceof Error ? err.message : String(err);
267
+ for (const val of Object.values(testValues)) {
268
+ if (typeof val === "string" && val.length > 0) {
269
+ sanitizedError = sanitizedError.replaceAll(val, "***");
270
+ }
271
+ }
272
+ reply.status(500).send({
273
+ ok: false,
274
+ message: sanitizedError,
275
+ });
276
+ return;
277
+ }
278
+ },
279
+ );
280
+ };
281
+
282
+ export default configRoutes;
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { ContentTypeDescriptor } from "@digitalpaws/starr-types-core";
6
+
7
+ /**
8
+ * This module needs access to the host's content-type registry.
9
+ * We provide it via module-level setters called from main.ts.
10
+ */
11
+ let getDescriptors: () => ContentTypeDescriptor[] = () => [];
12
+ let getDescriptor: (id: string) => ContentTypeDescriptor | undefined = () => undefined;
13
+
14
+ export function initContentTypeRoutes(
15
+ listFn: () => ContentTypeDescriptor[],
16
+ getFn: (id: string) => ContentTypeDescriptor | undefined,
17
+ ): void {
18
+ getDescriptors = listFn;
19
+ getDescriptor = getFn;
20
+ }
21
+
22
+ const contentTypeRoutes: FastifyPluginAsync = async (server) => {
23
+ // GET /api/content-types
24
+ server.get("/api/content-types", async (_request, _reply) => {
25
+ return { types: getDescriptors() };
26
+ });
27
+
28
+ // GET /api/content-types/:id
29
+ server.get<{ Params: { id: string } }>(
30
+ "/api/content-types/:id",
31
+ async (request, reply) => {
32
+ const desc = getDescriptor(request.params.id);
33
+ if (!desc) {
34
+ reply.status(404).send({ error: "content type not found" });
35
+ return;
36
+ }
37
+ return desc;
38
+ },
39
+ );
40
+ };
41
+
42
+ export default contentTypeRoutes;
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { EventBus, Unsubscribe, StarrEvent } from "../../types/plugin.js";
6
+ import type { ContentItemBase } from "@digitalpaws/starr-types-core";
7
+
8
+ let eventBus: EventBus | null = null;
9
+
10
+ export function initDiscoveryRoutes(bus: EventBus): void {
11
+ eventBus = bus;
12
+ }
13
+
14
+ const DEFAULT_WINDOW_MS = 10000;
15
+
16
+ const LOOKUP_TIMEOUT_MS = 5000;
17
+
18
+ function discoveryWindowMs(): number {
19
+ return parseInt(process.env.STARR_DISCOVERY_WINDOW_MS ?? String(DEFAULT_WINDOW_MS), 10);
20
+ }
21
+
22
+ const discoveryRoutes: FastifyPluginAsync = async (server) => {
23
+ server.get<{
24
+ Querystring: {
25
+ q?: string;
26
+ type?: string; // comma-separated contentTypeIds
27
+ offset?: string;
28
+ limit?: string;
29
+ };
30
+ }>(
31
+ "/api/discovery/search",
32
+ async (request, reply) => {
33
+ if (!eventBus) {
34
+ reply.status(500).send({ error: "event bus not available" });
35
+ return;
36
+ }
37
+
38
+ const query = request.query.q ?? "";
39
+ const contentTypeIds = request.query.type
40
+ ? request.query.type.split(",").filter(Boolean)
41
+ : undefined;
42
+ const offset = parseInt(request.query.offset ?? "0", 10) || 0;
43
+ const limit = parseInt(request.query.limit ?? "20", 10) || 20;
44
+
45
+ try {
46
+ // discovery.results is a broadcast reply (multiple metadata plugins
47
+ // may respond). Collect every result that arrives during the window.
48
+ const allItems: ContentItemBase[] = [];
49
+
50
+ const collectPromise = new Promise<Unsubscribe>((resolve) => {
51
+ const unsubscribe = eventBus!.on("discovery.results", (event: StarrEvent<{ contentTypeId: string; items: ContentItemBase[] }>) => {
52
+ allItems.push(...event.payload.items);
53
+ });
54
+ const windowMs = discoveryWindowMs();
55
+ setTimeout(() => resolve(unsubscribe), windowMs);
56
+ });
57
+
58
+ eventBus.emit("discovery.search", {
59
+ query,
60
+ contentTypeIds,
61
+ offset,
62
+ limit,
63
+ });
64
+
65
+ const unsubscribe = await collectPromise;
66
+ unsubscribe();
67
+
68
+ // Apply offset/limit client-side (plugins may not all respect it)
69
+ const paged = allItems.slice(offset, offset + limit);
70
+
71
+ return {
72
+ items: paged,
73
+ total: allItems.length,
74
+ offset,
75
+ limit,
76
+ };
77
+ } catch (err: any) {
78
+ reply.status(500).send({ error: String(err) });
79
+ }
80
+ },
81
+ );
82
+
83
+ // GET /api/discovery/lookup — return a single item by content type + external IDs
84
+ server.get<{
85
+ Querystring: {
86
+ contentTypeId: string;
87
+ externalIds: string; // JSON-encoded Record<string, string>
88
+ };
89
+ }>(
90
+ "/api/discovery/lookup",
91
+ async (request, reply) => {
92
+ if (!eventBus) {
93
+ reply.status(500).send({ error: "event bus not available" });
94
+ return;
95
+ }
96
+
97
+ const { contentTypeId, externalIds: rawExt } = request.query;
98
+ let externalIds: Record<string, string>;
99
+ try {
100
+ externalIds = JSON.parse(rawExt);
101
+ } catch {
102
+ reply.status(400).send({ error: "externalIds must be valid JSON" });
103
+ return;
104
+ }
105
+
106
+ if (!contentTypeId || !externalIds || Object.keys(externalIds).length === 0) {
107
+ reply.status(400).send({ error: "contentTypeId and externalIds are required" });
108
+ return;
109
+ }
110
+
111
+ try {
112
+ // Emit a discovery.lookup broadcast — metadata plugins respond with the item
113
+ let foundItem: ContentItemBase | null = null;
114
+
115
+ const collectPromise = new Promise<Unsubscribe>((resolve) => {
116
+ const unsubscribe = eventBus!.on(
117
+ "discovery.lookup.result",
118
+ (event: StarrEvent<{ contentTypeId: string; items: ContentItemBase[] }>) => {
119
+ // Take the first match that satisfies our external IDs
120
+ for (const item of event.payload.items) {
121
+ if (item.contentTypeId !== contentTypeId) continue;
122
+ const matches = Object.entries(externalIds).every(
123
+ ([key, val]) => item.externalIds?.[key] === val,
124
+ );
125
+ if (matches) {
126
+ foundItem = item;
127
+ }
128
+ }
129
+ },
130
+ );
131
+ // Timed timeout for metadata plugins to respond
132
+ setTimeout(() => resolve(unsubscribe), LOOKUP_TIMEOUT_MS);
133
+ });
134
+
135
+ eventBus.emit("discovery.lookup", {
136
+ contentTypeId,
137
+ externalIds,
138
+ });
139
+
140
+ const unsubscribe = await collectPromise;
141
+ unsubscribe();
142
+
143
+ if (!foundItem) {
144
+ reply.status(404).send({ error: "item not found" });
145
+ return;
146
+ }
147
+
148
+ return foundItem;
149
+ } catch (err: any) {
150
+ reply.status(500).send({ error: String(err) });
151
+ }
152
+ },
153
+ );
154
+ };
155
+
156
+ export default discoveryRoutes;
@@ -0,0 +1,196 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { ScanController } from "../../library/scan-controller.js";
6
+
7
+ let controller: ScanController | null = null;
8
+
9
+ export function initLibraryImportRoutes(sc: ScanController): void {
10
+ controller = sc;
11
+ }
12
+
13
+ const libraryImportRoutes: FastifyPluginAsync = async (server) => {
14
+ // POST /api/library/scan — start a new scan
15
+ server.post<{
16
+ Body: {
17
+ libraryId?: string;
18
+ contentTypeIds?: string[];
19
+ };
20
+ }>(
21
+ "/api/library/scan",
22
+ async (request, reply) => {
23
+ if (!controller) {
24
+ reply.status(500).send({ error: "scan controller not available" });
25
+ return;
26
+ }
27
+
28
+ const { libraryId, contentTypeIds } = request.body ?? {};
29
+ const scanId = controller.startScan(libraryId, contentTypeIds);
30
+
31
+ reply.status(201).send({ scanId });
32
+ },
33
+ );
34
+
35
+ // GET /api/library/scan/:scanId — get scan progress/results
36
+ server.get<{
37
+ Params: { scanId: string };
38
+ }>(
39
+ "/api/library/scan/:scanId",
40
+ async (request, reply) => {
41
+ if (!controller) {
42
+ reply.status(500).send({ error: "scan controller not available" });
43
+ return;
44
+ }
45
+
46
+ const state = controller.getScan(request.params.scanId);
47
+ if (!state) {
48
+ reply.status(404).send({ error: "scan not found" });
49
+ return;
50
+ }
51
+
52
+ return {
53
+ scanId: state.scanId,
54
+ status: state.status,
55
+ progress: state.progress,
56
+ candidates: state.candidates,
57
+ };
58
+ },
59
+ );
60
+
61
+ // POST /api/library/scan/:scanId/import — import selected candidates
62
+ server.post<{
63
+ Params: { scanId: string };
64
+ Body: {
65
+ candidateIds: string[];
66
+ };
67
+ }>(
68
+ "/api/library/scan/:scanId/import",
69
+ async (request, reply) => {
70
+ if (!controller) {
71
+ reply.status(500).send({ error: "scan controller not available" });
72
+ return;
73
+ }
74
+
75
+ const state = controller.getScan(request.params.scanId);
76
+ if (!state) {
77
+ reply.status(404).send({ error: "scan not found" });
78
+ return;
79
+ }
80
+
81
+ if (state.status !== "complete") {
82
+ reply.status(409).send({ error: "scan is not yet complete" });
83
+ return;
84
+ }
85
+
86
+ const { candidateIds } = request.body ?? {};
87
+ if (!Array.isArray(candidateIds) || candidateIds.length === 0) {
88
+ reply.status(400).send({ error: "candidateIds must be a non-empty array" });
89
+ return;
90
+ }
91
+
92
+ const result = await controller.importCandidates(request.params.scanId, candidateIds);
93
+
94
+ return result;
95
+ },
96
+ );
97
+
98
+ // POST /api/library/scan/:scanId/candidate/:candidateId/search — custom search for a candidate
99
+ server.post<{
100
+ Params: { scanId: string; candidateId: string };
101
+ Body: { query: string };
102
+ }>(
103
+ "/api/library/scan/:scanId/candidate/:candidateId/search",
104
+ async (request, reply) => {
105
+ if (!controller) {
106
+ reply.status(500).send({ error: "scan controller not available" });
107
+ return;
108
+ }
109
+
110
+ const { query } = request.body ?? {};
111
+ if (!query || query.trim().length === 0) {
112
+ reply.status(400).send({ error: "query is required" });
113
+ return;
114
+ }
115
+
116
+ const ok = await controller.searchCandidate(
117
+ request.params.scanId,
118
+ request.params.candidateId,
119
+ query,
120
+ );
121
+
122
+ if (!ok) {
123
+ reply.status(404).send({ error: "candidate not found" });
124
+ return;
125
+ }
126
+
127
+ // Return updated scan state so the UI can refresh
128
+ const state = controller.getScan(request.params.scanId);
129
+ return {
130
+ scanId: state?.scanId,
131
+ status: state?.status,
132
+ progress: state?.progress,
133
+ candidates: state?.candidates,
134
+ };
135
+ },
136
+ );
137
+
138
+ // POST /api/library/scan/:scanId/candidate/:candidateId/select — pick a match
139
+ server.post<{
140
+ Params: { scanId: string; candidateId: string };
141
+ Body: { matchIndex: number };
142
+ }>(
143
+ "/api/library/scan/:scanId/candidate/:candidateId/select",
144
+ async (request, reply) => {
145
+ if (!controller) {
146
+ reply.status(500).send({ error: "scan controller not available" });
147
+ return;
148
+ }
149
+
150
+ const { matchIndex } = request.body ?? {};
151
+ if (typeof matchIndex !== "number" || matchIndex < -1) {
152
+ reply.status(400).send({ error: "matchIndex must be a number >= -1" });
153
+ return;
154
+ }
155
+
156
+ const ok = controller.selectMatch(
157
+ request.params.scanId,
158
+ request.params.candidateId,
159
+ matchIndex,
160
+ );
161
+
162
+ if (!ok) {
163
+ reply.status(404).send({ error: "candidate or invalid matchIndex" });
164
+ return;
165
+ }
166
+
167
+ const state = controller.getScan(request.params.scanId);
168
+ return {
169
+ scanId: state?.scanId,
170
+ status: state?.status,
171
+ progress: state?.progress,
172
+ candidates: state?.candidates,
173
+ };
174
+ },
175
+ );
176
+
177
+ // DELETE /api/library/scan/:scanId — cancel a scan and clean up
178
+ server.delete<{
179
+ Params: { scanId: string };
180
+ }>(
181
+ "/api/library/scan/:scanId",
182
+ async (request, reply) => {
183
+ if (!controller) {
184
+ reply.status(500).send({ error: "scan controller not available" });
185
+ return;
186
+ }
187
+
188
+ const cancelled = controller.cancelScan(request.params.scanId);
189
+ controller.cleanupScan(request.params.scanId);
190
+
191
+ return { cancelled };
192
+ },
193
+ );
194
+ };
195
+
196
+ export default libraryImportRoutes;