@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,140 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { LibraryService, LibraryInput } from "../../library/library-service.js";
6
+
7
+ let libraryService: LibraryService | null = null;
8
+
9
+ export function initLibraryManagementRoutes(ls: LibraryService): void {
10
+ libraryService = ls;
11
+ }
12
+
13
+ function requireAdmin(request: any, reply: any): boolean {
14
+ const user = (request as any).user;
15
+ if (!user || user.role !== "admin") {
16
+ reply.status(403).send({ error: "Forbidden: admin role required" });
17
+ return false;
18
+ }
19
+ return true;
20
+ }
21
+
22
+ const libraryManagementRoutes: FastifyPluginAsync = async (server) => {
23
+ // GET /api/libraries — list all libraries
24
+ server.get("/api/libraries", async (request, reply) => {
25
+ if (!requireAdmin(request, reply)) return;
26
+ if (!libraryService) {
27
+ reply.status(500).send({ error: "library service not available" });
28
+ return;
29
+ }
30
+ const libraries = libraryService.list();
31
+ return { libraries };
32
+ });
33
+
34
+ // POST /api/libraries — create a new library
35
+ server.post<{ Body: LibraryInput }>(
36
+ "/api/libraries",
37
+ async (request, reply) => {
38
+ if (!requireAdmin(request, reply)) return;
39
+ if (!libraryService) {
40
+ reply.status(500).send({ error: "library service not available" });
41
+ return;
42
+ }
43
+ try {
44
+ const lib = libraryService.create(request.body);
45
+ reply.status(201).send(lib);
46
+ } catch (err: any) {
47
+ reply.status(400).send({ error: err.message });
48
+ }
49
+ },
50
+ );
51
+
52
+ // GET /api/libraries/:id — get a single library (including content type defaults)
53
+ server.get<{ Params: { id: string } }>(
54
+ "/api/libraries/:id",
55
+ async (request, reply) => {
56
+ if (!requireAdmin(request, reply)) return;
57
+ if (!libraryService) {
58
+ reply.status(500).send({ error: "library service not available" });
59
+ return;
60
+ }
61
+ const lib = libraryService.get(request.params.id);
62
+ if (!lib) {
63
+ reply.status(404).send({ error: "library not found" });
64
+ return;
65
+ }
66
+ const contentTypes = libraryService.getContentTypeDefaults(request.params.id);
67
+ return { ...lib, contentTypeDefaults: contentTypes };
68
+ },
69
+ );
70
+
71
+ // PUT /api/libraries/:id — update a library
72
+ server.put<{ Params: { id: string }; Body: LibraryInput }>(
73
+ "/api/libraries/:id",
74
+ async (request, reply) => {
75
+ if (!requireAdmin(request, reply)) return;
76
+ if (!libraryService) {
77
+ reply.status(500).send({ error: "library service not available" });
78
+ return;
79
+ }
80
+ try {
81
+ const lib = libraryService.update(request.params.id, request.body);
82
+ return lib;
83
+ } catch (err: any) {
84
+ reply.status(400).send({ error: err.message });
85
+ }
86
+ },
87
+ );
88
+
89
+ // DELETE /api/libraries/:id — delete a library
90
+ server.delete<{ Params: { id: string } }>(
91
+ "/api/libraries/:id",
92
+ async (request, reply) => {
93
+ if (!requireAdmin(request, reply)) return;
94
+ if (!libraryService) {
95
+ reply.status(500).send({ error: "library service not available" });
96
+ return;
97
+ }
98
+ try {
99
+ libraryService.delete(request.params.id);
100
+ return { deleted: true };
101
+ } catch (err: any) {
102
+ reply.status(400).send({ error: err.message });
103
+ }
104
+ },
105
+ );
106
+
107
+ // PUT /api/libraries/:id/content-types — set content type defaults for a library
108
+ server.put<{ Params: { id: string }; Body: { contentTypeIds: string[] } }>(
109
+ "/api/libraries/:id/content-types",
110
+ async (request, reply) => {
111
+ if (!requireAdmin(request, reply)) return;
112
+ if (!libraryService) {
113
+ reply.status(500).send({ error: "library service not available" });
114
+ return;
115
+ }
116
+ try {
117
+ libraryService.setContentTypeDefaults(request.params.id, request.body.contentTypeIds);
118
+ return { ok: true };
119
+ } catch (err: any) {
120
+ reply.status(400).send({ error: err.message });
121
+ }
122
+ },
123
+ );
124
+
125
+ // GET /api/libraries/:id/content-types — get content type defaults for a library
126
+ server.get<{ Params: { id: string } }>(
127
+ "/api/libraries/:id/content-types",
128
+ async (request, reply) => {
129
+ if (!requireAdmin(request, reply)) return;
130
+ if (!libraryService) {
131
+ reply.status(500).send({ error: "library service not available" });
132
+ return;
133
+ }
134
+ const defaults = libraryService.getContentTypeDefaults(request.params.id);
135
+ return { contentTypeIds: defaults };
136
+ },
137
+ );
138
+ };
139
+
140
+ export default libraryManagementRoutes;
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { LibraryCache } from "../../library/library-cache.js";
6
+
7
+ let cache: LibraryCache | null = null;
8
+
9
+ export function initLibraryRoutes(lc: LibraryCache): void {
10
+ cache = lc;
11
+ }
12
+
13
+ const libraryRoutes: FastifyPluginAsync = async (server) => {
14
+ // GET /api/library/items — list imported/library items
15
+ server.get<{
16
+ Querystring: {
17
+ type?: string;
18
+ offset?: string;
19
+ limit?: string;
20
+ };
21
+ }>(
22
+ "/api/library/items",
23
+ async (request, reply) => {
24
+ if (!cache) {
25
+ reply.status(500).send({ error: "library cache not available" });
26
+ return;
27
+ }
28
+
29
+ const contentTypeId = request.query.type || undefined;
30
+ const offset = parseInt(request.query.offset ?? "0", 10) || 0;
31
+ const limit = parseInt(request.query.limit ?? "50", 10) || 50;
32
+
33
+ const allItems = cache.getItems(contentTypeId);
34
+ const paged = allItems.slice(offset, offset + limit);
35
+
36
+ return {
37
+ items: paged,
38
+ total: allItems.length,
39
+ offset,
40
+ limit,
41
+ };
42
+ },
43
+ );
44
+ };
45
+
46
+ export default libraryRoutes;
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { AuthService } from "../../auth/auth-service.js";
6
+ import type { OidcProviderService } from "../../auth/oidc/oidc-provider-service.js";
7
+ import type { OidcFlow } from "../../auth/oidc/oidc-flow.js";
8
+ import type { UserService } from "../../auth/oidc/user-service.js";
9
+ import { rateLimit } from "../../auth/rate-limiter.js";
10
+
11
+ let authService: AuthService | null = null;
12
+ let oidcProviderService: OidcProviderService | null = null;
13
+ let oidcFlow: OidcFlow | null = null;
14
+ let userService: UserService | null = null;
15
+
16
+ export function initOidcRoutes(
17
+ auth: AuthService,
18
+ oidcProviders: OidcProviderService,
19
+ flow: OidcFlow,
20
+ users: UserService,
21
+ ): void {
22
+ authService = auth;
23
+ oidcProviderService = oidcProviders;
24
+ oidcFlow = flow;
25
+ userService = users;
26
+ }
27
+
28
+ const SESSION_COOKIE = "starr_sid";
29
+ const COOKIE_OPTS = {
30
+ httpOnly: true,
31
+ sameSite: "strict" as const,
32
+ path: "/",
33
+ secure: false,
34
+ maxAge: parseInt(process.env.STARR_SESSION_TTL_HOURS ?? "168", 10) * 3600,
35
+ };
36
+
37
+ const oidcRoutes: FastifyPluginAsync = async (server) => {
38
+ // ── Public OIDC endpoints (no auth required) ──
39
+
40
+ // GET /api/oidc/providers — list enabled OIDC providers (for login page)
41
+ server.get("/api/oidc/providers", async (_request, reply) => {
42
+ if (!oidcProviderService) {
43
+ reply.status(500).send({ error: "OIDC service not available" });
44
+ return;
45
+ }
46
+ return { providers: oidcProviderService.listEnabled() };
47
+ });
48
+
49
+ // GET /api/oidc/login/:providerId — initiate OIDC login (rate-limited)
50
+ server.get<{ Params: { providerId: string } }>(
51
+ "/api/oidc/login/:providerId",
52
+ { preHandler: rateLimit(10, 1) },
53
+ async (request, reply) => {
54
+ if (!oidcFlow) {
55
+ reply.status(500).send({ error: "OIDC flow not available" });
56
+ return;
57
+ }
58
+
59
+ try {
60
+ const query = request.query as Record<string, string>;
61
+ const redirectTo = query?.redirect ?? "/browse";
62
+ const authUrl = await oidcFlow.initiateLogin(
63
+ request.params.providerId,
64
+ redirectTo,
65
+ );
66
+ // Redirect the browser to the OIDC provider
67
+ reply.redirect(authUrl, 302);
68
+ } catch (err: any) {
69
+ reply.status(400).send({ error: err.message ?? "Failed to initiate OIDC login" });
70
+ }
71
+ },
72
+ );
73
+
74
+ // GET /api/oidc/callback/:providerId — OIDC callback (rate-limited)
75
+ server.get<{
76
+ Params: { providerId: string };
77
+ Querystring: { code?: string; state?: string };
78
+ }>(
79
+ "/api/oidc/callback/:providerId",
80
+ { preHandler: rateLimit(20, 1) },
81
+ async (request, reply) => {
82
+ if (!oidcFlow || !authService) {
83
+ reply.status(500).send({ error: "OIDC service not available" });
84
+ return;
85
+ }
86
+
87
+ const { code, state } = request.query;
88
+ if (!code || !state) {
89
+ reply.status(400).send({ error: "Missing code or state parameter" });
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const { user, redirectTo } = await oidcFlow.handleCallback(
95
+ request.params.providerId,
96
+ code,
97
+ state,
98
+ );
99
+
100
+ // Create a Starr session (JWT) for the user
101
+ const jwt = authService.loginOidc(user.id, user.role);
102
+ reply.setCookie(SESSION_COOKIE, jwt, COOKIE_OPTS);
103
+
104
+ // Redirect to the original target page (or /browse by default)
105
+ reply.redirect(redirectTo, 302);
106
+ } catch (err: any) {
107
+ reply.status(401).send({ error: err.message ?? "OIDC login failed" });
108
+ }
109
+ },
110
+ );
111
+
112
+ /** Reusable admin-role guard for admin-only routes. */
113
+ function requireAdmin(request: any, reply: any): boolean {
114
+ const user = (request as any).user;
115
+ if (!user) {
116
+ reply.status(401).send({ error: "Unauthorized" });
117
+ return false;
118
+ }
119
+ if (user.role !== "admin") {
120
+ reply.status(403).send({ error: "Forbidden: admin role required" });
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+
126
+ // ── Admin OIDC provider management (auth required, admin-only) ──
127
+
128
+ // GET /api/auth/oidc/providers — list all providers (secrets masked)
129
+ server.get("/api/auth/oidc/providers", async (request, reply) => {
130
+ if (!requireAdmin(request, reply)) return;
131
+ if (!oidcProviderService) {
132
+ reply.status(500).send({ error: "Service not available" });
133
+ return;
134
+ }
135
+ const providers = oidcProviderService.list();
136
+ const masked = providers.map((p) => ({
137
+ ...p,
138
+ clientSecret: p.clientSecret ? "••••••••" : "",
139
+ }));
140
+ return { providers: masked };
141
+ });
142
+
143
+ // GET /api/auth/oidc/providers/:id — get single provider
144
+ server.get<{ Params: { id: string } }>(
145
+ "/api/auth/oidc/providers/:id",
146
+ async (request, reply) => {
147
+ if (!requireAdmin(request, reply)) return;
148
+ if (!oidcProviderService) {
149
+ reply.status(500).send({ error: "Service not available" });
150
+ return;
151
+ }
152
+ const provider = oidcProviderService.get(request.params.id);
153
+ if (!provider) {
154
+ reply.status(404).send({ error: "Provider not found" });
155
+ return;
156
+ }
157
+ return {
158
+ ...provider,
159
+ clientSecret: provider.clientSecret ? "••••••••" : "",
160
+ };
161
+ },
162
+ );
163
+
164
+ // POST /api/auth/oidc/providers — create provider
165
+ server.post("/api/auth/oidc/providers", async (request, reply) => {
166
+ if (!requireAdmin(request, reply)) return;
167
+ if (!oidcProviderService) {
168
+ reply.status(500).send({ error: "Service not available" });
169
+ return;
170
+ }
171
+ try {
172
+ const provider = oidcProviderService.create(request.body as any);
173
+ reply.status(201).send({ ...provider, clientSecret: "••••••••" });
174
+ } catch (err: any) {
175
+ reply.status(400).send({ error: err.message ?? "Failed to create provider" });
176
+ }
177
+ });
178
+
179
+ // PUT /api/auth/oidc/providers/:id — update provider
180
+ server.put<{ Params: { id: string } }>(
181
+ "/api/auth/oidc/providers/:id",
182
+ async (request, reply) => {
183
+ if (!requireAdmin(request, reply)) return;
184
+ if (!oidcProviderService) {
185
+ reply.status(500).send({ error: "Service not available" });
186
+ return;
187
+ }
188
+ const provider = oidcProviderService.update(
189
+ request.params.id,
190
+ request.body as any,
191
+ );
192
+ if (!provider) {
193
+ reply.status(404).send({ error: "Provider not found" });
194
+ return;
195
+ }
196
+ return { ...provider, clientSecret: "••••••••" };
197
+ },
198
+ );
199
+
200
+ // DELETE /api/auth/oidc/providers/:id — delete provider
201
+ server.delete<{ Params: { id: string } }>(
202
+ "/api/auth/oidc/providers/:id",
203
+ async (request, reply) => {
204
+ if (!requireAdmin(request, reply)) return;
205
+ if (!oidcProviderService) {
206
+ reply.status(500).send({ error: "Service not available" });
207
+ return;
208
+ }
209
+ const deleted = oidcProviderService.delete(request.params.id);
210
+ if (!deleted) {
211
+ reply.status(404).send({ error: "Provider not found" });
212
+ return;
213
+ }
214
+ return { ok: true };
215
+ },
216
+ );
217
+
218
+ // ── Admin user management ──
219
+
220
+ // GET /api/auth/users — list all users
221
+ server.get("/api/auth/users", async (request, reply) => {
222
+ if (!requireAdmin(request, reply)) return;
223
+ if (!userService) {
224
+ reply.status(500).send({ error: "Service not available" });
225
+ return;
226
+ }
227
+ return { users: userService.list() };
228
+ });
229
+
230
+ // PATCH /api/auth/users/:id/role — change user role
231
+ server.patch<{ Params: { id: string }; Body: { role: "admin" | "user" } }>(
232
+ "/api/auth/users/:id/role",
233
+ async (request, reply) => {
234
+ if (!requireAdmin(request, reply)) return;
235
+ if (!userService) {
236
+ reply.status(500).send({ error: "Service not available" });
237
+ return;
238
+ }
239
+
240
+ const { role } = request.body;
241
+ if (role !== "admin" && role !== "user") {
242
+ reply.status(400).send({ error: "Role must be 'admin' or 'user'" });
243
+ return;
244
+ }
245
+
246
+ try {
247
+ const user = userService.setRole(request.params.id, role);
248
+ if (!user) {
249
+ reply.status(404).send({ error: "User not found" });
250
+ return;
251
+ }
252
+ return { user };
253
+ } catch (err: any) {
254
+ reply.status(403).send({ error: err.message ?? "Failed to change role" });
255
+ }
256
+ },
257
+ );
258
+ };
259
+
260
+ export default oidcRoutes;
@@ -0,0 +1,250 @@
1
+ // SPDX-License-Identifier: 0BSD
2
+ // SPDX-FileCopyrightText: 2025 Digi <digitalpaws@proton.me>
3
+
4
+ import type { FastifyPluginAsync } from "fastify";
5
+ import type { ContentItemBase } from "@digitalpaws/starr-types-core";
6
+ import type { Starr } from "../../host.js";
7
+ import { requestIntake } from "../../state/request-intake.js";
8
+
9
+ /**
10
+ * In-memory debounce map to prevent rapid retries.
11
+ * Maps requestId → timestamp of last retry.
12
+ */
13
+ const retryDebounceMap = new Map<string, number>();
14
+ const RETRY_DEBOUNCE_MS = 10_000; // 10 seconds
15
+
16
+ class RouteError extends Error {
17
+ status: number;
18
+ constructor(status: number, message: string) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Retry a failed or missing content request.
26
+ *
27
+ * 1. Look up the TrackedRequest; 404 if not found.
28
+ * 2. Check status guards: must be 'failed' or 'downloaded'.
29
+ * 3. Debounce: reject if retried within 10 seconds.
30
+ * 4. Reset the request status and emit a fresh content.requested.
31
+ *
32
+ * Returns the updated request.
33
+ */
34
+ async function retryRequest(
35
+ requestId: string,
36
+ userId: string,
37
+ starr: Starr,
38
+ ): Promise<{ requestId: string; status: string }> {
39
+ const stateStore = starr.getStateStore();
40
+ const logger = starr.logger;
41
+
42
+ // 1. Look up the request
43
+ const req = stateStore.getByRequestId(requestId);
44
+ if (!req) {
45
+ throw new RouteError(404, "Request not found");
46
+ }
47
+
48
+ // 2. Status guard: only failed or downloaded can be retried
49
+ if (req.status === "searching" || req.status === "downloading" || req.status === "requested") {
50
+ throw new RouteError(
51
+ 409,
52
+ `Cannot retry request in status "${req.status}". Wait for the current run to finish.`,
53
+ );
54
+ }
55
+
56
+ // 3. Debounce check
57
+ const now = Date.now();
58
+ const lastRetry = retryDebounceMap.get(requestId);
59
+ if (lastRetry && now - lastRetry < RETRY_DEBOUNCE_MS) {
60
+ const remaining = Math.ceil((RETRY_DEBOUNCE_MS - (now - lastRetry)) / 1000);
61
+ throw new RouteError(
62
+ 429,
63
+ `Please wait ${remaining}s before retrying again.`,
64
+ );
65
+ }
66
+ retryDebounceMap.set(requestId, now);
67
+
68
+ // Cleanup stale entries from debounce map (older than 1 minute)
69
+ for (const [key, ts] of retryDebounceMap) {
70
+ if (now - ts > 60_000) retryDebounceMap.delete(key);
71
+ }
72
+
73
+ logger.info("manual retry initiated", {
74
+ requestId,
75
+ previousStatus: req.status,
76
+ userId,
77
+ });
78
+
79
+ // 4. Reset and re-emit
80
+ stateStore.resetAndRetry(requestId);
81
+
82
+ // Re-emit content.requested to restart the pipeline
83
+ starr.coreBus.emit("content.requested", {
84
+ requestId,
85
+ userId: req.ownerUserId,
86
+ contentTypeId: req.contentTypeId,
87
+ item: req.item,
88
+ libraryId: req.libraryId,
89
+ });
90
+
91
+ return { requestId, status: "requested" };
92
+ }
93
+
94
+ let starrInstance: Starr | null = null;
95
+
96
+ export function initRequestRoutes(starr: Starr): void {
97
+ starrInstance = starr;
98
+ }
99
+
100
+ const requestRoutes: FastifyPluginAsync = async (server) => {
101
+ // POST /api/requests — create a content request
102
+ server.post<{ Body: ContentItemBase }>(
103
+ "/api/requests",
104
+ async (request, reply) => {
105
+ if (!starrInstance) {
106
+ reply.status(500).send({ error: "host not available" });
107
+ return;
108
+ }
109
+
110
+ const item = request.body;
111
+ const user = (request as any).user;
112
+
113
+ if (!item || !item.contentTypeId || !item.title) {
114
+ reply.status(400).send({ error: "invalid content item" });
115
+ return;
116
+ }
117
+
118
+ const stateStore = starrInstance.getStateStore();
119
+ const registry = starrInstance.contentTypeRegistry;
120
+ const libraryService = starrInstance.libraryService ?? undefined;
121
+
122
+ try {
123
+ const requestId = requestIntake(
124
+ { userId: user.username, item },
125
+ stateStore,
126
+ registry,
127
+ starrInstance.coreBus,
128
+ starrInstance.logger,
129
+ libraryService,
130
+ );
131
+
132
+ // Check if this was a new request or re-attach to existing
133
+ const req = stateStore.getByRequestId(requestId);
134
+ const existing = req && req.ownerUserId !== user.username;
135
+
136
+ reply.status(201).send({ requestId, existing });
137
+ } catch (err: any) {
138
+ reply.status(500).send({ error: String(err) });
139
+ }
140
+ },
141
+ );
142
+
143
+ // GET /api/requests — list tracked requests
144
+ server.get("/api/requests", async (request, reply) => {
145
+ if (!starrInstance) {
146
+ reply.status(500).send({ error: "host not available" });
147
+ return;
148
+ }
149
+
150
+ const user = (request as any).user;
151
+ const stateStore = starrInstance.getStateStore();
152
+
153
+ if (user.role === "admin") {
154
+ // Admin sees all requests
155
+ const requests = stateStore.getAllRequests();
156
+ return { requests };
157
+ } else {
158
+ // Regular user sees only their own + watched
159
+ const requests = stateStore.listByUser(user.username);
160
+ return { requests };
161
+ }
162
+ });
163
+
164
+ // GET /api/requests/:id — get single request detail
165
+ server.get<{ Params: { id: string } }>(
166
+ "/api/requests/:id",
167
+ async (request, reply) => {
168
+ if (!starrInstance) {
169
+ reply.status(500).send({ error: "host not available" });
170
+ return;
171
+ }
172
+
173
+ const req = starrInstance.getStateStore().getByRequestId(request.params.id);
174
+ if (!req) {
175
+ reply.status(404).send({ error: "request not found" });
176
+ return;
177
+ }
178
+ return req;
179
+ },
180
+ );
181
+
182
+ // POST /api/requests/:id/retry — manually retry a failed or missing request
183
+ server.post<{ Params: { id: string } }>(
184
+ "/api/requests/:id/retry",
185
+ async (request, reply) => {
186
+ if (!starrInstance) {
187
+ reply.status(500).send({ error: "host not available" });
188
+ return;
189
+ }
190
+
191
+ const requestId = request.params.id;
192
+ const user = (request as any).user;
193
+
194
+ try {
195
+ const result = await retryRequest(
196
+ requestId,
197
+ user.username,
198
+ starrInstance,
199
+ );
200
+ return result;
201
+ } catch (err: any) {
202
+ if (err.status && err.message) {
203
+ reply.status(err.status).send({ error: err.message });
204
+ } else {
205
+ reply.status(500).send({ error: String(err) });
206
+ }
207
+ }
208
+ },
209
+ );
210
+
211
+ // DELETE /api/requests/:id — permanently remove a request
212
+ server.delete<{ Params: { id: string } }>(
213
+ "/api/requests/:id",
214
+ async (request, reply) => {
215
+ if (!starrInstance) {
216
+ reply.status(500).send({ error: "host not available" });
217
+ return;
218
+ }
219
+
220
+ const requestId = request.params.id;
221
+ const user = (request as any).user;
222
+ const stateStore = starrInstance.getStateStore();
223
+
224
+ const req = stateStore.getByRequestId(requestId);
225
+ if (!req) {
226
+ reply.status(404).send({ error: "Request not found" });
227
+ return;
228
+ }
229
+
230
+ const previousStatus = req.status;
231
+ stateStore.deleteRequest(requestId);
232
+
233
+ // Notify plugins so they can clean up storage and cancel in-flight operations
234
+ starrInstance.coreBus.emit("request.deleted", {
235
+ requestId,
236
+ previousStatus,
237
+ });
238
+
239
+ starrInstance.logger.info("request deleted via API", {
240
+ requestId,
241
+ userId: user.username,
242
+ previousStatus,
243
+ });
244
+
245
+ return { deleted: true, requestId };
246
+ },
247
+ );
248
+ };
249
+
250
+ export default requestRoutes;