synthos 0.6.0 → 0.7.0

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 (149) hide show
  1. package/README.md +33 -1
  2. package/default-pages/app_builder.html +40 -0
  3. package/default-pages/app_builder.json +1 -0
  4. package/default-pages/json_tools.html +89 -159
  5. package/default-pages/json_tools.json +1 -0
  6. package/default-pages/my_notes.html +33 -0
  7. package/default-pages/my_notes.json +12 -0
  8. package/default-pages/neon_asteroids.html +77 -0
  9. package/default-pages/neon_asteroids.json +12 -0
  10. package/default-pages/sidebar_builder.html +49 -0
  11. package/default-pages/sidebar_builder.json +1 -0
  12. package/default-pages/solar_explorer.html +1956 -0
  13. package/default-pages/solar_explorer.json +12 -0
  14. package/default-pages/solar_tutorial.html +476 -0
  15. package/default-pages/solar_tutorial.json +1 -0
  16. package/default-pages/two-panel_builder.html +66 -0
  17. package/default-pages/two-panel_builder.json +1 -0
  18. package/dist/connectors/index.d.ts +3 -0
  19. package/dist/connectors/index.d.ts.map +1 -0
  20. package/dist/connectors/index.js +6 -0
  21. package/dist/connectors/index.js.map +1 -0
  22. package/dist/connectors/registry.d.ts +3 -0
  23. package/dist/connectors/registry.d.ts.map +1 -0
  24. package/dist/connectors/registry.js +100 -0
  25. package/dist/connectors/registry.js.map +1 -0
  26. package/dist/connectors/types.d.ts +61 -0
  27. package/dist/connectors/types.d.ts.map +1 -0
  28. package/dist/connectors/types.js +3 -0
  29. package/dist/connectors/types.js.map +1 -0
  30. package/dist/files.d.ts +2 -0
  31. package/dist/files.d.ts.map +1 -1
  32. package/dist/files.js +12 -1
  33. package/dist/files.js.map +1 -1
  34. package/dist/init.d.ts +8 -1
  35. package/dist/init.d.ts.map +1 -1
  36. package/dist/init.js +155 -3
  37. package/dist/init.js.map +1 -1
  38. package/dist/migrations.d.ts +11 -0
  39. package/dist/migrations.d.ts.map +1 -0
  40. package/dist/migrations.js +281 -0
  41. package/dist/migrations.js.map +1 -0
  42. package/dist/models/index.d.ts +3 -0
  43. package/dist/models/index.d.ts.map +1 -0
  44. package/dist/models/index.js +10 -0
  45. package/dist/models/index.js.map +1 -0
  46. package/dist/models/providers.d.ts +7 -0
  47. package/dist/models/providers.d.ts.map +1 -0
  48. package/dist/models/providers.js +33 -0
  49. package/dist/models/providers.js.map +1 -0
  50. package/dist/models/types.d.ts +21 -0
  51. package/dist/models/types.d.ts.map +1 -0
  52. package/dist/models/types.js +3 -0
  53. package/dist/models/types.js.map +1 -0
  54. package/dist/pages.d.ts +21 -2
  55. package/dist/pages.d.ts.map +1 -1
  56. package/dist/pages.js +202 -23
  57. package/dist/pages.js.map +1 -1
  58. package/dist/scripts.js +2 -2
  59. package/dist/scripts.js.map +1 -1
  60. package/dist/service/createCompletePrompt.d.ts +3 -2
  61. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  62. package/dist/service/createCompletePrompt.js +11 -16
  63. package/dist/service/createCompletePrompt.js.map +1 -1
  64. package/dist/service/debugLog.d.ts +11 -0
  65. package/dist/service/debugLog.d.ts.map +1 -0
  66. package/dist/service/debugLog.js +26 -0
  67. package/dist/service/debugLog.js.map +1 -0
  68. package/dist/service/modelInstructions.d.ts +7 -0
  69. package/dist/service/modelInstructions.d.ts.map +1 -0
  70. package/dist/service/modelInstructions.js +16 -0
  71. package/dist/service/modelInstructions.js.map +1 -0
  72. package/dist/service/requiresSettings.d.ts +2 -2
  73. package/dist/service/requiresSettings.d.ts.map +1 -1
  74. package/dist/service/requiresSettings.js.map +1 -1
  75. package/dist/service/server.d.ts.map +1 -1
  76. package/dist/service/server.js +15 -0
  77. package/dist/service/server.js.map +1 -1
  78. package/dist/service/transformPage.d.ts +81 -2
  79. package/dist/service/transformPage.d.ts.map +1 -1
  80. package/dist/service/transformPage.js +672 -82
  81. package/dist/service/transformPage.js.map +1 -1
  82. package/dist/service/useApiRoutes.d.ts.map +1 -1
  83. package/dist/service/useApiRoutes.js +579 -13
  84. package/dist/service/useApiRoutes.js.map +1 -1
  85. package/dist/service/useConnectorRoutes.d.ts +4 -0
  86. package/dist/service/useConnectorRoutes.d.ts.map +1 -0
  87. package/dist/service/useConnectorRoutes.js +389 -0
  88. package/dist/service/useConnectorRoutes.js.map +1 -0
  89. package/dist/service/useDataRoutes.d.ts.map +1 -1
  90. package/dist/service/useDataRoutes.js +83 -70
  91. package/dist/service/useDataRoutes.js.map +1 -1
  92. package/dist/service/usePageRoutes.d.ts.map +1 -1
  93. package/dist/service/usePageRoutes.js +243 -38
  94. package/dist/service/usePageRoutes.js.map +1 -1
  95. package/dist/settings.d.ts +33 -4
  96. package/dist/settings.d.ts.map +1 -1
  97. package/dist/settings.js +108 -15
  98. package/dist/settings.js.map +1 -1
  99. package/dist/synthos-cli.d.ts.map +1 -1
  100. package/dist/synthos-cli.js +11 -1
  101. package/dist/synthos-cli.js.map +1 -1
  102. package/dist/themes.d.ts +9 -0
  103. package/dist/themes.d.ts.map +1 -0
  104. package/dist/themes.js +64 -0
  105. package/dist/themes.js.map +1 -0
  106. package/package.json +5 -3
  107. package/required-pages/builder.html +74 -0
  108. package/required-pages/builder.json +1 -0
  109. package/required-pages/pages.html +169 -126
  110. package/required-pages/pages.json +1 -0
  111. package/required-pages/settings.html +812 -156
  112. package/required-pages/settings.json +1 -0
  113. package/required-pages/synthos_apis.html +272 -0
  114. package/required-pages/synthos_apis.json +1 -0
  115. package/required-pages/synthos_scripts.html +87 -0
  116. package/required-pages/synthos_scripts.json +1 -0
  117. package/src/connectors/index.ts +12 -0
  118. package/src/connectors/registry.ts +98 -0
  119. package/src/connectors/types.ts +68 -0
  120. package/src/files.ts +11 -0
  121. package/src/init.ts +151 -5
  122. package/src/migrations.ts +266 -0
  123. package/src/models/index.ts +2 -0
  124. package/src/models/providers.ts +33 -0
  125. package/src/models/types.ts +23 -0
  126. package/src/pages.ts +234 -26
  127. package/src/scripts.ts +2 -2
  128. package/src/service/createCompletePrompt.ts +14 -18
  129. package/src/service/debugLog.ts +17 -0
  130. package/src/service/modelInstructions.ts +14 -0
  131. package/src/service/requiresSettings.ts +3 -3
  132. package/src/service/server.ts +19 -2
  133. package/src/service/transformPage.ts +709 -88
  134. package/src/service/useApiRoutes.ts +632 -16
  135. package/src/service/useConnectorRoutes.ts +427 -0
  136. package/src/service/useDataRoutes.ts +87 -71
  137. package/src/service/usePageRoutes.ts +237 -44
  138. package/src/settings.ts +143 -20
  139. package/src/synthos-cli.ts +11 -1
  140. package/src/themes.ts +71 -0
  141. package/default-pages/[application].html +0 -95
  142. package/default-pages/[markdown].html +0 -271
  143. package/default-pages/[sidebar].html +0 -114
  144. package/default-pages/[split-application].html +0 -118
  145. package/default-pages/solar_system.html +0 -432
  146. package/default-pages/space_invaders.html +0 -617
  147. package/required-pages/apis.html +0 -362
  148. package/required-pages/home.html +0 -126
  149. package/required-pages/scripts.html +0 -350
@@ -0,0 +1,427 @@
1
+ import { Application } from 'express';
2
+ import { SynthOSConfig } from '../init';
3
+ import { loadSettings, saveSettings } from '../settings';
4
+ import {
5
+ CONNECTOR_REGISTRY,
6
+ ConnectorSummary,
7
+ ConnectorDetail,
8
+ ConnectorCallRequest,
9
+ ConnectorOAuthConfig
10
+ } from '../connectors';
11
+
12
+ export function useConnectorRoutes(config: SynthOSConfig, app: Application): void {
13
+
14
+ // GET /api/connectors — List connectors (minimal summaries)
15
+ // Also handles POST /api/connectors — Proxy call (see below)
16
+ app.get('/api/connectors', async (req, res) => {
17
+ try {
18
+ const settings = await loadSettings(config.pagesFolder);
19
+ const connectors = settings.connectors ?? {};
20
+
21
+ const categoryFilter = req.query.category as string | undefined;
22
+ const idFilter = req.query.id as string | undefined;
23
+
24
+ const list: ConnectorSummary[] = CONNECTOR_REGISTRY
25
+ .filter(def => {
26
+ if (categoryFilter && def.category !== categoryFilter) return false;
27
+ if (idFilter && def.id !== idFilter) return false;
28
+ return true;
29
+ })
30
+ .map(def => {
31
+ const cfg = connectors[def.id];
32
+ const isOAuth = def.authStrategy === 'oauth2';
33
+ const oauthCfg = cfg as ConnectorOAuthConfig | undefined;
34
+ return {
35
+ id: def.id,
36
+ name: def.name,
37
+ category: def.category,
38
+ configured: isOAuth
39
+ ? !!oauthCfg && oauthCfg.enabled && !!oauthCfg.accessToken
40
+ : !!cfg && cfg.enabled && !!cfg.apiKey
41
+ };
42
+ });
43
+
44
+ res.json(list);
45
+ } catch (err: unknown) {
46
+ console.error(err);
47
+ res.status(500).json({ error: (err as Error).message });
48
+ }
49
+ });
50
+
51
+ // GET /api/connectors/:id — Full connector detail for config modal
52
+ app.get('/api/connectors/:id', async (req, res) => {
53
+ try {
54
+ const { id } = req.params;
55
+ const def = CONNECTOR_REGISTRY.find(d => d.id === id);
56
+ if (!def) {
57
+ res.status(404).json({ error: `Connector "${id}" not found` });
58
+ return;
59
+ }
60
+
61
+ const settings = await loadSettings(config.pagesFolder);
62
+ const cfg = (settings.connectors ?? {})[id];
63
+ const isOAuth = def.authStrategy === 'oauth2';
64
+ const oauthCfg = cfg as ConnectorOAuthConfig | undefined;
65
+
66
+ const detail: ConnectorDetail = {
67
+ ...def,
68
+ configured: isOAuth
69
+ ? !!oauthCfg && oauthCfg.enabled && !!oauthCfg.accessToken
70
+ : !!cfg && cfg.enabled && !!cfg.apiKey,
71
+ enabled: !!cfg?.enabled,
72
+ hasKey: isOAuth ? !!oauthCfg?.clientId : !!cfg?.apiKey,
73
+ connected: isOAuth ? !!oauthCfg?.accessToken : undefined,
74
+ accountName: isOAuth ? oauthCfg?.accountName : undefined,
75
+ userId: isOAuth ? oauthCfg?.userId : undefined
76
+ };
77
+
78
+ res.json(detail);
79
+ } catch (err: unknown) {
80
+ console.error(err);
81
+ res.status(500).json({ error: (err as Error).message });
82
+ }
83
+ });
84
+
85
+ // POST /api/connectors/:id — Save connector config (API key + enabled toggle)
86
+ app.post('/api/connectors/:id', async (req, res) => {
87
+ try {
88
+ const { id } = req.params;
89
+ const def = CONNECTOR_REGISTRY.find(d => d.id === id);
90
+ if (!def) {
91
+ res.status(404).json({ error: `Connector "${id}" not found` });
92
+ return;
93
+ }
94
+
95
+ const settings = await loadSettings(config.pagesFolder);
96
+ const existing = settings.connectors ?? {};
97
+
98
+ if (def.authStrategy === 'oauth2') {
99
+ const { clientId, clientSecret, accessToken, userId, enabled } = req.body;
100
+ const prev = (existing[id] as ConnectorOAuthConfig) ?? { apiKey: '', enabled: false };
101
+ const entry: ConnectorOAuthConfig = {
102
+ ...prev,
103
+ apiKey: prev.apiKey || '',
104
+ enabled: typeof enabled === 'boolean' ? enabled : prev.enabled
105
+ };
106
+
107
+ // OAuth app credentials
108
+ if (typeof clientId === 'string' && clientId.length > 0) entry.clientId = clientId;
109
+ if (typeof clientSecret === 'string' && clientSecret.length > 0) entry.clientSecret = clientSecret;
110
+
111
+ // Manual token entry
112
+ if (typeof accessToken === 'string' && accessToken.length > 0) {
113
+ entry.accessToken = accessToken;
114
+ entry.apiKey = accessToken;
115
+ }
116
+ if (typeof userId === 'string' && userId.length > 0) {
117
+ entry.userId = userId;
118
+ entry.accountName = userId;
119
+ }
120
+
121
+ const updated = { ...existing, [id]: entry };
122
+ await saveSettings(config.pagesFolder, { connectors: updated });
123
+ } else {
124
+ const { apiKey, enabled } = req.body;
125
+ const resolvedKey = (typeof apiKey === 'string' && apiKey.length > 0)
126
+ ? apiKey
127
+ : (existing[id]?.apiKey ?? '');
128
+
129
+ const updated = {
130
+ ...existing,
131
+ [id]: { apiKey: resolvedKey, enabled: !!enabled }
132
+ };
133
+ await saveSettings(config.pagesFolder, { connectors: updated });
134
+ }
135
+
136
+ res.json({ saved: true });
137
+ } catch (err: unknown) {
138
+ console.error(err);
139
+ res.status(500).json({ error: (err as Error).message });
140
+ }
141
+ });
142
+
143
+ // DELETE /api/connectors/:id — Remove connector config
144
+ app.delete('/api/connectors/:id', async (req, res) => {
145
+ try {
146
+ const { id } = req.params;
147
+ const settings = await loadSettings(config.pagesFolder);
148
+ const existing = settings.connectors ?? {};
149
+
150
+ const updated = { ...existing };
151
+ delete updated[id];
152
+
153
+ await saveSettings(config.pagesFolder, { connectors: updated });
154
+ res.json({ deleted: true });
155
+ } catch (err: unknown) {
156
+ console.error(err);
157
+ res.status(500).json({ error: (err as Error).message });
158
+ }
159
+ });
160
+
161
+ // GET /api/connectors/:id/authorize — Start OAuth2 flow
162
+ app.get('/api/connectors/:id/authorize', async (req, res) => {
163
+ try {
164
+ const { id } = req.params;
165
+ const def = CONNECTOR_REGISTRY.find(d => d.id === id);
166
+ if (!def || def.authStrategy !== 'oauth2') {
167
+ res.status(400).json({ error: `Connector "${id}" is not an OAuth2 connector` });
168
+ return;
169
+ }
170
+
171
+ const settings = await loadSettings(config.pagesFolder);
172
+ const cfg = (settings.connectors ?? {})[id] as ConnectorOAuthConfig | undefined;
173
+ if (!cfg?.clientId || !cfg?.clientSecret) {
174
+ res.status(400).json({ error: 'Client ID and Client Secret must be saved before authorizing' });
175
+ return;
176
+ }
177
+
178
+ const redirectUri = `${req.protocol}://${req.get('host')}/api/connectors/callback`;
179
+ const state = JSON.stringify({ connector: id });
180
+
181
+ const authUrl = new URL(def.authorizationUrl!);
182
+ authUrl.searchParams.set('client_id', cfg.clientId);
183
+ authUrl.searchParams.set('redirect_uri', redirectUri);
184
+ authUrl.searchParams.set('scope', (def.scopes ?? []).join(','));
185
+ authUrl.searchParams.set('state', state);
186
+ authUrl.searchParams.set('response_type', 'code');
187
+
188
+ res.redirect(authUrl.toString());
189
+ } catch (err: unknown) {
190
+ console.error(err);
191
+ res.status(500).json({ error: (err as Error).message });
192
+ }
193
+ });
194
+
195
+ // GET /api/connectors/callback — OAuth2 callback
196
+ app.get('/api/connectors/callback', async (req, res) => {
197
+ try {
198
+ const code = req.query.code as string | undefined;
199
+ const stateRaw = req.query.state as string | undefined;
200
+ const error = req.query.error as string | undefined;
201
+
202
+ if (error) {
203
+ res.redirect(`/settings?tab=connectors&error=${encodeURIComponent(error)}`);
204
+ return;
205
+ }
206
+
207
+ if (!code || !stateRaw) {
208
+ res.status(400).json({ error: 'Missing code or state parameter' });
209
+ return;
210
+ }
211
+
212
+ const state = JSON.parse(stateRaw) as { connector: string };
213
+ const connectorId = state.connector;
214
+
215
+ const def = CONNECTOR_REGISTRY.find(d => d.id === connectorId);
216
+ if (!def || def.authStrategy !== 'oauth2') {
217
+ res.status(400).json({ error: `Unknown OAuth2 connector: ${connectorId}` });
218
+ return;
219
+ }
220
+
221
+ const settings = await loadSettings(config.pagesFolder);
222
+ const cfg = (settings.connectors ?? {})[connectorId] as ConnectorOAuthConfig | undefined;
223
+ if (!cfg?.clientId || !cfg?.clientSecret) {
224
+ res.status(400).json({ error: 'Client credentials not found' });
225
+ return;
226
+ }
227
+
228
+ const redirectUri = `${req.protocol}://${req.get('host')}/api/connectors/callback`;
229
+
230
+ // Step 1: Exchange code for short-lived token
231
+ const tokenUrl = new URL(def.tokenUrl!);
232
+ tokenUrl.searchParams.set('client_id', cfg.clientId);
233
+ tokenUrl.searchParams.set('client_secret', cfg.clientSecret);
234
+ tokenUrl.searchParams.set('redirect_uri', redirectUri);
235
+ tokenUrl.searchParams.set('code', code);
236
+ tokenUrl.searchParams.set('grant_type', 'authorization_code');
237
+
238
+ const tokenRes = await fetch(tokenUrl.toString());
239
+ if (!tokenRes.ok) {
240
+ const text = await tokenRes.text();
241
+ console.error('Token exchange failed:', text);
242
+ res.redirect(`/settings?tab=connectors&error=${encodeURIComponent('Token exchange failed')}`);
243
+ return;
244
+ }
245
+ const tokenData = await tokenRes.json() as { access_token: string; token_type?: string; expires_in?: number };
246
+ let accessToken = tokenData.access_token;
247
+ let expiresAt = tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : 0;
248
+
249
+ // Step 2: For Instagram — exchange for long-lived token
250
+ if (connectorId === 'instagram') {
251
+ const llUrl = new URL('https://graph.facebook.com/v21.0/oauth/access_token');
252
+ llUrl.searchParams.set('grant_type', 'fb_exchange_token');
253
+ llUrl.searchParams.set('client_id', cfg.clientId);
254
+ llUrl.searchParams.set('client_secret', cfg.clientSecret);
255
+ llUrl.searchParams.set('fb_exchange_token', accessToken);
256
+
257
+ const llRes = await fetch(llUrl.toString());
258
+ if (llRes.ok) {
259
+ const llData = await llRes.json() as { access_token: string; expires_in?: number };
260
+ accessToken = llData.access_token;
261
+ expiresAt = llData.expires_in ? Date.now() + llData.expires_in * 1000 : 0;
262
+ } else {
263
+ console.error('Long-lived token exchange failed:', await llRes.text());
264
+ }
265
+ }
266
+
267
+ // Step 3: For Instagram — resolve IG Business Account ID
268
+ let userId = '';
269
+ let accountName = '';
270
+ if (connectorId === 'instagram') {
271
+ try {
272
+ const pagesRes = await fetch(`https://graph.facebook.com/v21.0/me/accounts?access_token=${encodeURIComponent(accessToken)}`);
273
+ if (pagesRes.ok) {
274
+ const pagesData = await pagesRes.json() as { data: Array<{ id: string; name: string }> };
275
+ if (pagesData.data && pagesData.data.length > 0) {
276
+ const page = pagesData.data[0];
277
+ accountName = page.name;
278
+
279
+ const igRes = await fetch(`https://graph.facebook.com/v21.0/${page.id}?fields=instagram_business_account&access_token=${encodeURIComponent(accessToken)}`);
280
+ if (igRes.ok) {
281
+ const igData = await igRes.json() as { instagram_business_account?: { id: string } };
282
+ if (igData.instagram_business_account) {
283
+ userId = igData.instagram_business_account.id;
284
+ }
285
+ }
286
+ }
287
+ }
288
+ } catch (igErr) {
289
+ console.error('Failed to resolve Instagram Business Account:', igErr);
290
+ }
291
+ }
292
+
293
+ // Step 4: Save tokens to settings
294
+ const existing = settings.connectors ?? {};
295
+ const prev = (existing[connectorId] as ConnectorOAuthConfig) ?? { apiKey: '', enabled: false };
296
+ const updated = {
297
+ ...existing,
298
+ [connectorId]: {
299
+ ...prev,
300
+ apiKey: prev.apiKey || accessToken,
301
+ accessToken,
302
+ expiresAt,
303
+ userId,
304
+ accountName,
305
+ enabled: true
306
+ }
307
+ };
308
+ await saveSettings(config.pagesFolder, { connectors: updated });
309
+
310
+ res.redirect(`/settings?tab=connectors&connected=${encodeURIComponent(connectorId)}`);
311
+ } catch (err: unknown) {
312
+ console.error('OAuth callback error:', err);
313
+ res.redirect(`/settings?tab=connectors&error=${encodeURIComponent((err as Error).message)}`);
314
+ }
315
+ });
316
+
317
+ // POST /api/connectors — Proxy call
318
+ app.post('/api/connectors', async (req, res) => {
319
+ try {
320
+ const request = req.body as ConnectorCallRequest;
321
+
322
+ if (!request.connector || !request.method || !request.path) {
323
+ res.status(400).json({ error: 'connector, method, and path are required' });
324
+ return;
325
+ }
326
+
327
+ const def = CONNECTOR_REGISTRY.find(d => d.id === request.connector);
328
+ if (!def) {
329
+ res.status(404).json({ error: `Connector "${request.connector}" not found` });
330
+ return;
331
+ }
332
+
333
+ const settings = await loadSettings(config.pagesFolder);
334
+ const cfg = (settings.connectors ?? {})[request.connector];
335
+
336
+ if (def.authStrategy === 'oauth2') {
337
+ const oauthCfg = cfg as ConnectorOAuthConfig | undefined;
338
+ if (!oauthCfg || !oauthCfg.enabled || !oauthCfg.accessToken) {
339
+ res.status(400).json({ error: `Connector "${request.connector}" is not configured or not enabled` });
340
+ return;
341
+ }
342
+ // Check token expiry
343
+ if (oauthCfg.expiresAt && oauthCfg.expiresAt < Date.now()) {
344
+ res.status(401).json({ error: `Access token for "${request.connector}" has expired. Please re-authorize in Settings > Connectors.` });
345
+ return;
346
+ }
347
+ } else {
348
+ if (!cfg || !cfg.enabled || !cfg.apiKey) {
349
+ res.status(400).json({ error: `Connector "${request.connector}" is not configured or not enabled` });
350
+ return;
351
+ }
352
+ }
353
+
354
+ // Build URL — join baseUrl path with request path to avoid
355
+ // absolute paths (e.g. "/me/accounts") replacing the base path
356
+ const base = new URL(def.baseUrl);
357
+ const joinedPath = base.pathname.replace(/\/+$/, '') + '/' + request.path.replace(/^\/+/, '');
358
+ base.pathname = joinedPath;
359
+ const url = base;
360
+ if (request.query) {
361
+ for (const [key, value] of Object.entries(request.query)) {
362
+ url.searchParams.set(key, value);
363
+ }
364
+ }
365
+
366
+ // Attach auth
367
+ const headers: Record<string, string> = { ...request.headers };
368
+ switch (def.authStrategy) {
369
+ case 'bearer':
370
+ headers['Authorization'] = `Bearer ${cfg.apiKey}`;
371
+ break;
372
+ case 'header':
373
+ headers[def.authKey] = cfg.apiKey;
374
+ break;
375
+ case 'query':
376
+ url.searchParams.set(def.authKey, cfg.apiKey);
377
+ break;
378
+ case 'oauth2': {
379
+ const oauthCfg = cfg as ConnectorOAuthConfig;
380
+ const token = oauthCfg.accessToken ?? oauthCfg.apiKey;
381
+ // Use Bearer header — works for all methods and content types
382
+ headers['Authorization'] = `Bearer ${token}`;
383
+ break;
384
+ }
385
+ }
386
+
387
+ // Forward request
388
+ const fetchOpts: RequestInit = {
389
+ method: request.method.toUpperCase(),
390
+ headers
391
+ };
392
+
393
+ if (request.body && !['GET', 'HEAD'].includes(fetchOpts.method!)) {
394
+ fetchOpts.body = JSON.stringify(request.body);
395
+ if (!headers['Content-Type']) {
396
+ headers['Content-Type'] = 'application/json';
397
+ }
398
+ }
399
+
400
+ const upstream = await fetch(url.toString(), fetchOpts);
401
+ const ct = upstream.headers.get('content-type') || '';
402
+ const isJson = ct.includes('application/json');
403
+
404
+ if (!upstream.ok) {
405
+ const text = await upstream.text();
406
+ res.status(upstream.status).json({ error: text });
407
+ return;
408
+ }
409
+
410
+ if (isJson) {
411
+ const data = await upstream.json();
412
+ res.json(data);
413
+ } else {
414
+ // Stream binary-safe: use arrayBuffer to avoid text encoding corruption
415
+ const buffer = Buffer.from(await upstream.arrayBuffer());
416
+ res.set('Content-Type', ct || 'application/octet-stream');
417
+ // Forward content-disposition if present (e.g. file downloads)
418
+ const cd = upstream.headers.get('content-disposition');
419
+ if (cd) res.set('Content-Disposition', cd);
420
+ res.send(buffer);
421
+ }
422
+ } catch (err: unknown) {
423
+ console.error(err);
424
+ res.status(500).json({ error: (err as Error).message });
425
+ }
426
+ });
427
+ }
@@ -1,4 +1,4 @@
1
- import { Application } from 'express';
1
+ import { Application, Response } from 'express';
2
2
  import { SynthOSConfig } from "../init";
3
3
  import { checkIfExists, deleteFile, ensureFolderExists, listFiles, loadFile, saveFile } from "../files";
4
4
  import path from "path";
@@ -6,90 +6,106 @@ import { v4 } from "uuid";
6
6
  import { clearCachedScripts } from '../scripts';
7
7
 
8
8
  export function useDataRoutes(config: SynthOSConfig, app: Application): void {
9
- // Retrieve all rows from a table
10
- app.get('/api/data/:table', async (req, res) => {
11
- // Get list of row ids
12
- const { table } = req.params;
13
- const folder = await tableFolder(config, table);
14
- const ids = (await listFiles(folder)).filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
15
-
16
- // Enumerate all rows
17
- const rows: Record<string, any>[] = [];
18
- for (const id of ids) {
19
- const file = recordFile(folder, id);
20
- try {
21
- const row = JSON.parse(await loadFile(file));
22
- row.id = id;
23
- rows.push(row);
24
- } catch (err: unknown) {
25
- console.error(err);
26
- }
27
- }
28
-
29
- // Return rows
30
- res.json(rows);
31
- });
9
+ app.get('/api/data/:page/:table', (req, res) => handleList(config, req.params.page, req.params.table, req.query, res));
10
+ app.get('/api/data/:page/:table/:id', (req, res) => handleGet(config, req.params.page, req.params.table, req.params.id, res));
11
+ app.post('/api/data/:page/:table', (req, res) => handleUpsert(config, req.params.page, req.params.table, req.body, res));
12
+ app.delete('/api/data/:page/:table/:id', (req, res) => handleDelete(config, req.params.page, req.params.table, req.params.id, res));
13
+ }
32
14
 
33
- // Retrieve a single row from a table
34
- app.get('/api/data/:table/:id', async (req, res) => {
35
- const { table, id } = req.params;
36
- const folder = await tableFolder(config, table);
15
+ // ---------------------------------------------------------------------------
16
+ // Route handlers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ async function handleList(config: SynthOSConfig, page: string, table: string, query: Record<string, any>, res: Response): Promise<void> {
20
+ const folder = tableFolder(config, page, table);
21
+ if (!(await checkIfExists(folder))) {
22
+ res.status(404).json({ error: 'table_not_found', page, table });
23
+ return;
24
+ }
25
+
26
+ const ids = (await listFiles(folder)).filter(f => f.endsWith('.json')).map(f => f.replace('.json', ''));
27
+
28
+ const rows: Record<string, any>[] = [];
29
+ for (const id of ids) {
37
30
  const file = recordFile(folder, id);
38
31
  try {
39
32
  const row = JSON.parse(await loadFile(file));
40
33
  row.id = id;
41
- res.json(row);
42
- } catch (err: unknown) {
43
- res.json({});
44
- }
45
- });
46
-
47
- // Upsert a single row into a table
48
- app.post('/api/data/:table', async (req, res) => {
49
- const { table } = req.params;
50
- const id = req.body.id ?? v4();
51
- const folder = await tableFolder(config, table);
52
- const file = recordFile(folder, id);
53
- try {
54
- const row = { ...req.body, id };
55
- await ensureFolderExists(folder);
56
- await saveFile(file, JSON.stringify(row, null, 4));
57
- if (table === 'scripts') {
58
- clearCachedScripts();
59
- }
60
- res.json(row);
34
+ rows.push(row);
61
35
  } catch (err: unknown) {
62
36
  console.error(err);
63
- res.status(500).send((err as Error).message);
64
37
  }
65
- });
38
+ }
66
39
 
67
- // Delete a single row from a table
68
- app.delete('/api/data/:table/:id', async (req, res) => {
69
- const { table, id } = req.params;
70
- const folder = await tableFolder(config, table);
71
- const file = recordFile(folder, id);
72
- try {
73
- if (await checkIfExists(file)) {
74
- await deleteFile(file);
40
+ // Paginate when limit is provided
41
+ const limitParam = typeof query.limit === 'string' ? parseInt(query.limit, 10) : NaN;
42
+ if (!isNaN(limitParam) && limitParam > 0) {
43
+ const offset = Math.max(0, typeof query.offset === 'string' ? parseInt(query.offset, 10) || 0 : 0);
44
+ const items = rows.slice(offset, offset + limitParam);
45
+ res.json({ items, total: rows.length, offset, limit: limitParam, hasMore: offset + limitParam < rows.length });
46
+ } else {
47
+ res.json(rows);
48
+ }
49
+ }
75
50
 
76
- // Clear cached scripts
77
- if (table === 'scripts') {
78
- clearCachedScripts();
79
- }
51
+ async function handleGet(config: SynthOSConfig, page: string, table: string, id: string, res: Response): Promise<void> {
52
+ const folder = tableFolder(config, page, table);
53
+ if (!(await checkIfExists(folder))) {
54
+ res.status(404).json({ error: 'table_not_found', page, table });
55
+ return;
56
+ }
57
+
58
+ const file = recordFile(folder, id);
59
+ try {
60
+ const row = JSON.parse(await loadFile(file));
61
+ row.id = id;
62
+ res.json(row);
63
+ } catch (err: unknown) {
64
+ res.json({});
65
+ }
66
+ }
67
+
68
+ async function handleUpsert(config: SynthOSConfig, page: string, table: string, body: any, res: Response): Promise<void> {
69
+ const id = body.id ?? v4();
70
+ const folder = tableFolder(config, page, table);
71
+ const file = recordFile(folder, id);
72
+ try {
73
+ const row = { ...body, id };
74
+ await ensureFolderExists(folder);
75
+ await saveFile(file, JSON.stringify(row, null, 4));
76
+ if (table === 'scripts') {
77
+ clearCachedScripts();
78
+ }
79
+ res.json(row);
80
+ } catch (err: unknown) {
81
+ console.error(err);
82
+ res.status(500).send((err as Error).message);
83
+ }
84
+ }
85
+
86
+ async function handleDelete(config: SynthOSConfig, page: string, table: string, id: string, res: Response): Promise<void> {
87
+ const folder = tableFolder(config, page, table);
88
+ const file = recordFile(folder, id);
89
+ try {
90
+ if (await checkIfExists(file)) {
91
+ await deleteFile(file);
92
+ if (table === 'scripts') {
93
+ clearCachedScripts();
80
94
  }
81
- res.json({ success: true });
82
- } catch (err: unknown) {
83
- console.error(err);
84
- res.status(500).send((err as Error).message);
85
95
  }
86
- });
96
+ res.json({ success: true });
97
+ } catch (err: unknown) {
98
+ console.error(err);
99
+ res.status(500).send((err as Error).message);
100
+ }
87
101
  }
88
102
 
89
- async function tableFolder(config: SynthOSConfig, table: string): Promise<string> {
90
- const folder = path.join(config.pagesFolder, table);
91
- await ensureFolderExists(folder);
92
- return folder;
103
+ // ---------------------------------------------------------------------------
104
+ // Helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function tableFolder(config: SynthOSConfig, page: string, table: string): string {
108
+ return path.join(config.pagesFolder, 'pages', page, table);
93
109
  }
94
110
 
95
111
  function recordFile(folder: string, id: string): string {