@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.
- package/README.rst +75 -0
- package/data/.nfs.811bbb0d.43c34.4 +0 -0
- package/data/.nfs.81ba4cd0.ffff87804.4 +0 -0
- package/data/.nfs.82d127f7.fffff5704.4 +0 -0
- package/data/.nfs.82d127fe.fffff5704.4 +0 -0
- package/data/.nfs.82d159a5.fffff5aa4.4 +0 -0
- package/data/.nfs.82d159aa.fffff5aa4.4 +0 -0
- package/data/.nfs.82d15a9c.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15aa1.fffff5bb4.4 +0 -0
- package/data/.nfs.82d15c65.fffff5c24.4 +0 -0
- package/data/.nfs.82d1852c.fffff5e24.4 +0 -0
- package/data/.nfs.82d18536.fffff5e24.4 +0 -0
- package/data/.nfs.82d18dbe.fffff5e44.4 +0 -0
- package/data/.nfs.82d18dc6.fffff5e44.4 +0 -0
- package/data/.nfs.8309a3f5.fffff82f4.4 +0 -0
- package/data/.nfs.8309a3fe.fffff82f4.4 +0 -0
- package/data/.nfs.8314122e.fffffb044.4 +0 -0
- package/data/.nfs.83141236.fffffb044.4 +0 -0
- package/data/.nfs.833c4fe9.fffffbc34.4 +0 -0
- package/data/.nfs.833c4ff3.fffffbc34.4 +0 -0
- package/data/logs/starr.log +66244 -0
- package/data/starr.db +0 -0
- package/data/starr.db-shm +0 -0
- package/data/starr.db-wal +0 -0
- package/data/starr.jwt +1 -0
- package/data/starr.key +1 -0
- package/dist/auth/auth-service.d.ts +42 -0
- package/dist/auth/auth-service.d.ts.map +1 -0
- package/dist/auth/auth-service.js +226 -0
- package/dist/auth/auth-service.js.map +1 -0
- package/dist/auth/http-server.d.ts +16 -0
- package/dist/auth/http-server.d.ts.map +1 -0
- package/dist/auth/http-server.js +207 -0
- package/dist/auth/http-server.js.map +1 -0
- package/dist/auth/jwt-secret.d.ts +6 -0
- package/dist/auth/jwt-secret.d.ts.map +1 -0
- package/dist/auth/jwt-secret.js +51 -0
- package/dist/auth/jwt-secret.js.map +1 -0
- package/dist/auth/oidc/oidc-flow.d.ts +60 -0
- package/dist/auth/oidc/oidc-flow.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-flow.js +201 -0
- package/dist/auth/oidc/oidc-flow.js.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts +59 -0
- package/dist/auth/oidc/oidc-provider-service.d.ts.map +1 -0
- package/dist/auth/oidc/oidc-provider-service.js +124 -0
- package/dist/auth/oidc/oidc-provider-service.js.map +1 -0
- package/dist/auth/oidc/user-service.d.ts +67 -0
- package/dist/auth/oidc/user-service.d.ts.map +1 -0
- package/dist/auth/oidc/user-service.js +177 -0
- package/dist/auth/oidc/user-service.js.map +1 -0
- package/dist/auth/rate-limiter.d.ts +31 -0
- package/dist/auth/rate-limiter.d.ts.map +1 -0
- package/dist/auth/rate-limiter.js +70 -0
- package/dist/auth/rate-limiter.js.map +1 -0
- package/dist/config/config-service.d.ts +83 -0
- package/dist/config/config-service.d.ts.map +1 -0
- package/dist/config/config-service.js +309 -0
- package/dist/config/config-service.js.map +1 -0
- package/dist/config/validate.d.ts +12 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +140 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/content-types/registry.d.ts +75 -0
- package/dist/content-types/registry.d.ts.map +1 -0
- package/dist/content-types/registry.js +171 -0
- package/dist/content-types/registry.js.map +1 -0
- package/dist/datastore/db.d.ts +9 -0
- package/dist/datastore/db.d.ts.map +1 -0
- package/dist/datastore/db.js +27 -0
- package/dist/datastore/db.js.map +1 -0
- package/dist/datastore/encrypt.d.ts +9 -0
- package/dist/datastore/encrypt.d.ts.map +1 -0
- package/dist/datastore/encrypt.js +84 -0
- package/dist/datastore/encrypt.js.map +1 -0
- package/dist/datastore/migrations.d.ts +10 -0
- package/dist/datastore/migrations.d.ts.map +1 -0
- package/dist/datastore/migrations.js +340 -0
- package/dist/datastore/migrations.js.map +1 -0
- package/dist/datastore/storage.d.ts +4 -0
- package/dist/datastore/storage.d.ts.map +1 -0
- package/dist/datastore/storage.js +60 -0
- package/dist/datastore/storage.js.map +1 -0
- package/dist/events/event-bus.d.ts +15 -0
- package/dist/events/event-bus.d.ts.map +1 -0
- package/dist/events/event-bus.js +140 -0
- package/dist/events/event-bus.js.map +1 -0
- package/dist/events/spine.d.ts +31 -0
- package/dist/events/spine.d.ts.map +1 -0
- package/dist/events/spine.js +68 -0
- package/dist/events/spine.js.map +1 -0
- package/dist/events/trace.d.ts +24 -0
- package/dist/events/trace.d.ts.map +1 -0
- package/dist/events/trace.js +33 -0
- package/dist/events/trace.js.map +1 -0
- package/dist/host.d.ts +65 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +94 -0
- package/dist/host.js.map +1 -0
- package/dist/library/importer.d.ts +29 -0
- package/dist/library/importer.d.ts.map +1 -0
- package/dist/library/importer.js +145 -0
- package/dist/library/importer.js.map +1 -0
- package/dist/library/library-cache.d.ts +46 -0
- package/dist/library/library-cache.d.ts.map +1 -0
- package/dist/library/library-cache.js +152 -0
- package/dist/library/library-cache.js.map +1 -0
- package/dist/library/library-config.d.ts +12 -0
- package/dist/library/library-config.d.ts.map +1 -0
- package/dist/library/library-config.js +10 -0
- package/dist/library/library-config.js.map +1 -0
- package/dist/library/library-scanner.d.ts +62 -0
- package/dist/library/library-scanner.d.ts.map +1 -0
- package/dist/library/library-scanner.js +336 -0
- package/dist/library/library-scanner.js.map +1 -0
- package/dist/library/library-service.d.ts +65 -0
- package/dist/library/library-service.d.ts.map +1 -0
- package/dist/library/library-service.js +197 -0
- package/dist/library/library-service.js.map +1 -0
- package/dist/library/metadata-matcher.d.ts +56 -0
- package/dist/library/metadata-matcher.d.ts.map +1 -0
- package/dist/library/metadata-matcher.js +207 -0
- package/dist/library/metadata-matcher.js.map +1 -0
- package/dist/library/naming.d.ts +8 -0
- package/dist/library/naming.d.ts.map +1 -0
- package/dist/library/naming.js +47 -0
- package/dist/library/naming.js.map +1 -0
- package/dist/library/scan-controller.d.ts +95 -0
- package/dist/library/scan-controller.d.ts.map +1 -0
- package/dist/library/scan-controller.js +379 -0
- package/dist/library/scan-controller.js.map +1 -0
- package/dist/library/source-resolver.d.ts +17 -0
- package/dist/library/source-resolver.d.ts.map +1 -0
- package/dist/library/source-resolver.js +75 -0
- package/dist/library/source-resolver.js.map +1 -0
- package/dist/logging/logger.d.ts +3 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +107 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/main.d.ts +36 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +229 -0
- package/dist/main.js.map +1 -0
- package/dist/plugins/context.d.ts +10 -0
- package/dist/plugins/context.d.ts.map +1 -0
- package/dist/plugins/context.js +110 -0
- package/dist/plugins/context.js.map +1 -0
- package/dist/plugins/discovery.d.ts +2 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +32 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/loader.d.ts +9 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +245 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/state/request-intake.d.ts +26 -0
- package/dist/state/request-intake.d.ts.map +1 -0
- package/dist/state/request-intake.js +74 -0
- package/dist/state/request-intake.js.map +1 -0
- package/dist/state/state-store.d.ts +146 -0
- package/dist/state/state-store.d.ts.map +1 -0
- package/dist/state/state-store.js +465 -0
- package/dist/state/state-store.js.map +1 -0
- package/dist/state/trace-store.d.ts +12 -0
- package/dist/state/trace-store.d.ts.map +1 -0
- package/dist/state/trace-store.js +24 -0
- package/dist/state/trace-store.js.map +1 -0
- package/dist/types/plugin.d.ts +93 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +4 -0
- package/dist/types/plugin.js.map +1 -0
- package/dist/web/index.d.ts +10 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +153 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/routes/activity.d.ts +6 -0
- package/dist/web/routes/activity.d.ts.map +1 -0
- package/dist/web/routes/activity.js +31 -0
- package/dist/web/routes/activity.js.map +1 -0
- package/dist/web/routes/auth.d.ts +6 -0
- package/dist/web/routes/auth.d.ts.map +1 -0
- package/dist/web/routes/auth.js +142 -0
- package/dist/web/routes/auth.js.map +1 -0
- package/dist/web/routes/config.d.ts +7 -0
- package/dist/web/routes/config.d.ts.map +1 -0
- package/dist/web/routes/config.js +204 -0
- package/dist/web/routes/config.js.map +1 -0
- package/dist/web/routes/content-types.d.ts +6 -0
- package/dist/web/routes/content-types.d.ts.map +1 -0
- package/dist/web/routes/content-types.js +29 -0
- package/dist/web/routes/content-types.js.map +1 -0
- package/dist/web/routes/discovery.d.ts +6 -0
- package/dist/web/routes/discovery.d.ts.map +1 -0
- package/dist/web/routes/discovery.js +111 -0
- package/dist/web/routes/discovery.js.map +1 -0
- package/dist/web/routes/library-import.d.ts +6 -0
- package/dist/web/routes/library-import.d.ts.map +1 -0
- package/dist/web/routes/library-import.js +120 -0
- package/dist/web/routes/library-import.js.map +1 -0
- package/dist/web/routes/library-management.d.ts +6 -0
- package/dist/web/routes/library-management.d.ts.map +1 -0
- package/dist/web/routes/library-management.js +120 -0
- package/dist/web/routes/library-management.js.map +1 -0
- package/dist/web/routes/library.d.ts +6 -0
- package/dist/web/routes/library.d.ts.map +1 -0
- package/dist/web/routes/library.js +28 -0
- package/dist/web/routes/library.js.map +1 -0
- package/dist/web/routes/oidc.d.ts +9 -0
- package/dist/web/routes/oidc.d.ts.map +1 -0
- package/dist/web/routes/oidc.js +203 -0
- package/dist/web/routes/oidc.js.map +1 -0
- package/dist/web/routes/requests.d.ts +6 -0
- package/dist/web/routes/requests.d.ts.map +1 -0
- package/dist/web/routes/requests.js +183 -0
- package/dist/web/routes/requests.js.map +1 -0
- package/dist/web/routes/sse.d.ts +6 -0
- package/dist/web/routes/sse.d.ts.map +1 -0
- package/dist/web/routes/sse.js +54 -0
- package/dist/web/routes/sse.js.map +1 -0
- package/package.json +33 -0
- package/package.json.license +2 -0
- package/src/auth/auth-service.ts +333 -0
- package/src/auth/http-server.ts +249 -0
- package/src/auth/jwt-secret.ts +65 -0
- package/src/auth/oidc/oidc-flow.ts +266 -0
- package/src/auth/oidc/oidc-provider-service.ts +174 -0
- package/src/auth/oidc/user-service.ts +227 -0
- package/src/auth/rate-limiter.ts +85 -0
- package/src/config/config-service.ts +434 -0
- package/src/config/validate.ts +153 -0
- package/src/content-types/registry.ts +194 -0
- package/src/datastore/db.ts +38 -0
- package/src/datastore/encrypt.ts +107 -0
- package/src/datastore/migrations.ts +367 -0
- package/src/datastore/storage.ts +83 -0
- package/src/events/event-bus.ts +180 -0
- package/src/events/spine.ts +87 -0
- package/src/events/trace.ts +38 -0
- package/src/host.ts +130 -0
- package/src/library/importer.ts +174 -0
- package/src/library/library-cache.ts +212 -0
- package/src/library/library-config.ts +22 -0
- package/src/library/library-scanner.ts +404 -0
- package/src/library/library-service.ts +282 -0
- package/src/library/metadata-matcher.ts +285 -0
- package/src/library/naming.ts +59 -0
- package/src/library/scan-controller.ts +490 -0
- package/src/library/source-resolver.ts +92 -0
- package/src/logging/logger.ts +115 -0
- package/src/main.ts +364 -0
- package/src/plugins/context.ts +138 -0
- package/src/plugins/discovery.ts +29 -0
- package/src/plugins/loader.ts +309 -0
- package/src/state/request-intake.ts +101 -0
- package/src/state/state-store.ts +621 -0
- package/src/state/trace-store.ts +27 -0
- package/src/types/plugin.ts +110 -0
- package/src/web/index.ts +182 -0
- package/src/web/routes/activity.ts +41 -0
- package/src/web/routes/auth.ts +170 -0
- package/src/web/routes/config.ts +282 -0
- package/src/web/routes/content-types.ts +42 -0
- package/src/web/routes/discovery.ts +156 -0
- package/src/web/routes/library-import.ts +196 -0
- package/src/web/routes/library-management.ts +140 -0
- package/src/web/routes/library.ts +46 -0
- package/src/web/routes/oidc.ts +260 -0
- package/src/web/routes/requests.ts +250 -0
- package/src/web/routes/sse.ts +92 -0
- package/tests/auth.test.ts +510 -0
- package/tests/config-route.test.ts +345 -0
- package/tests/config.test.ts +612 -0
- package/tests/content-types.test.ts +376 -0
- package/tests/datastore.test.ts +345 -0
- package/tests/e2e.test.ts +430 -0
- package/tests/event-bus.test.ts +925 -0
- package/tests/host.test.ts +449 -0
- package/tests/library-cache.test.ts +171 -0
- package/tests/library-scanner.test.ts +177 -0
- package/tests/library.test.ts +210 -0
- package/tests/loader-context.test.ts +644 -0
- package/tests/logger-jsonl.test.ts +154 -0
- package/tests/metadata-matcher.test.ts +198 -0
- package/tests/oidc.test.ts +218 -0
- package/tests/scan-controller.test.ts +328 -0
- package/tests/source-resolver.test.ts +100 -0
- package/tests/starr-video-integration.test.ts +153 -0
- package/tests/state-store.test.ts +700 -0
- package/tests/trace-history.test.ts +356 -0
- package/tsconfig.json +8 -0
- 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;
|