forma-diagrams 0.5.2

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 (109) hide show
  1. package/Dockerfile +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/THIRD_PARTY_NOTICES.md +20 -0
  5. package/bin/forma +3 -0
  6. package/deploy/.env.example +11 -0
  7. package/deploy/Caddyfile +4 -0
  8. package/deploy/compose.yml +38 -0
  9. package/dist/assets/IBMPlexSans-Regular-Bl2SjS7V.ttf +0 -0
  10. package/dist/assets/IBMPlexSans-SemiBold-B9auKknr.ttf +0 -0
  11. package/dist/assets/elk.bundled-Y7ymokgr.js +24 -0
  12. package/dist/assets/index-_kg9cdik.css +1 -0
  13. package/dist/assets/index-al0xDdTG.js +452 -0
  14. package/dist/fonts/IBMPlexSans-Regular.ttf +0 -0
  15. package/dist/fonts/IBMPlexSans-SemiBold.ttf +0 -0
  16. package/dist/fonts/OFL.txt +93 -0
  17. package/dist/index.html +14 -0
  18. package/dist/licenses/FORMA_LICENSE.txt +21 -0
  19. package/dist/licenses/THIRD_PARTY_LICENSES.txt +11601 -0
  20. package/docs/api.md +41 -0
  21. package/docs/decisions/001-foundation.md +57 -0
  22. package/docs/decisions/002-composition-and-human-edits.md +47 -0
  23. package/docs/decisions/003-general-composition.md +30 -0
  24. package/docs/decisions/004-library-and-distribution.md +33 -0
  25. package/docs/decisions/005-organizational-hosting.md +33 -0
  26. package/docs/decisions/006-hosted-agent-access.md +25 -0
  27. package/docs/format.md +132 -0
  28. package/docs/gallery.md +73 -0
  29. package/docs/images/architecture.png +0 -0
  30. package/docs/images/editor.png +0 -0
  31. package/docs/images/gallery/architecture.png +0 -0
  32. package/docs/images/gallery/decision.png +0 -0
  33. package/docs/images/gallery/development-signal.png +0 -0
  34. package/docs/images/gallery/development.png +0 -0
  35. package/docs/images/gallery/editor-roundtrip.png +0 -0
  36. package/docs/images/gallery/entities.png +0 -0
  37. package/docs/images/gallery/mindmap.png +0 -0
  38. package/docs/images/gallery/organization.png +0 -0
  39. package/docs/images/gallery/timeline.png +0 -0
  40. package/docs/images/release.png +0 -0
  41. package/docs/install.md +50 -0
  42. package/docs/plans/mvp.md +24 -0
  43. package/docs/self-hosting.md +167 -0
  44. package/docs/verification-v2.md +41 -0
  45. package/docs/verification-v3.md +16 -0
  46. package/docs/verification-v4.1.md +35 -0
  47. package/docs/verification-v4.2.md +20 -0
  48. package/docs/verification-v4.3.md +5 -0
  49. package/docs/verification-v4.md +31 -0
  50. package/docs/verification-v5.1.md +7 -0
  51. package/docs/verification-v5.2.md +7 -0
  52. package/docs/verification-v5.md +7 -0
  53. package/docs/verification.md +91 -0
  54. package/examples/design-systems/atelier.json +53 -0
  55. package/examples/design-systems/signal.json +53 -0
  56. package/examples/gallery/architecture.forma.json +178 -0
  57. package/examples/gallery/decision.forma.json +182 -0
  58. package/examples/gallery/development-signal.forma.json +299 -0
  59. package/examples/gallery/development.forma.json +299 -0
  60. package/examples/gallery/entities.forma.json +123 -0
  61. package/examples/gallery/mindmap.forma.json +158 -0
  62. package/examples/gallery/organization.forma.json +151 -0
  63. package/examples/gallery/timeline.forma.json +138 -0
  64. package/examples/platform.forma.json +116 -0
  65. package/examples/release.forma.json +58 -0
  66. package/examples/roundtrip/agent-continued.forma.json +310 -0
  67. package/examples/roundtrip/agent-patch.json +7 -0
  68. package/examples/roundtrip/human-edited.forma.json +309 -0
  69. package/package.json +81 -0
  70. package/packages/cli/bin.mjs +3 -0
  71. package/packages/cli/src/agent-access.ts +232 -0
  72. package/packages/cli/src/blob-store.ts +241 -0
  73. package/packages/cli/src/file-library.ts +179 -0
  74. package/packages/cli/src/google.ts +51 -0
  75. package/packages/cli/src/hosted.ts +498 -0
  76. package/packages/cli/src/http.ts +67 -0
  77. package/packages/cli/src/index.ts +397 -0
  78. package/packages/cli/src/mcp.ts +114 -0
  79. package/packages/cli/src/remote.ts +228 -0
  80. package/packages/cli/src/server.ts +69 -0
  81. package/packages/cli/src/signed-cookie.ts +42 -0
  82. package/packages/core/src/align.ts +237 -0
  83. package/packages/core/src/document.ts +408 -0
  84. package/packages/core/src/font-metrics.json +1 -0
  85. package/packages/core/src/geometry.ts +463 -0
  86. package/packages/core/src/index.ts +12 -0
  87. package/packages/core/src/inspect.ts +230 -0
  88. package/packages/core/src/layout.ts +450 -0
  89. package/packages/core/src/render.ts +182 -0
  90. package/packages/core/src/resolve-style.ts +29 -0
  91. package/packages/core/src/scene.ts +40 -0
  92. package/packages/core/src/styles.ts +122 -0
  93. package/packages/core/src/text.ts +81 -0
  94. package/packages/core/src/theme.ts +33 -0
  95. package/public/fonts/IBMPlexSans-Regular.ttf +0 -0
  96. package/public/fonts/IBMPlexSans-SemiBold.ttf +0 -0
  97. package/public/fonts/OFL.txt +93 -0
  98. package/schema/design-system.v1.schema.json +734 -0
  99. package/schema/forma.v1.schema.json +273 -0
  100. package/schema/forma.v2.schema.json +1756 -0
  101. package/skills/forma/SKILL.md +20 -0
  102. package/skills/forma/references/composition.md +11 -0
  103. package/skills/forma/references/hosted.md +28 -0
  104. package/skills/forma-architecture/SKILL.md +14 -0
  105. package/skills/forma-entities/SKILL.md +14 -0
  106. package/skills/forma-flow/SKILL.md +14 -0
  107. package/skills/forma-mindmap/SKILL.md +14 -0
  108. package/skills/forma-organization/SKILL.md +14 -0
  109. package/skills/forma-timeline/SKILL.md +14 -0
@@ -0,0 +1,498 @@
1
+ import { createTokenStore, createAgentService } from './agent-access';
2
+ import { z, ZodError } from 'zod';
3
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ import { mkdir, lstat, readdir, writeFile, open, unlink } from 'node:fs/promises';
6
+ import { join, resolve } from 'node:path';
7
+ import { createFileLibrary, HttpError } from './file-library';
8
+ import { json, readBody, serveAsset } from './http';
9
+ import { googleProvider, type IdentityProvider, type Identity, type LoginAttempt } from './google';
10
+ import { loginCookieSchema, sessionCookieSchema, signCookie, verifyCookie } from './signed-cookie';
11
+ import { blobLibraryFor, type BlobStore, type DiagramLibrary } from './blob-store';
12
+ const token = () => randomBytes(32).toString('base64url');
13
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex');
14
+ const cookies = (req: IncomingMessage) =>
15
+ Object.fromEntries(
16
+ (req.headers.cookie ?? '').split(';').map((p) => {
17
+ const i = p.indexOf('=');
18
+ return [p.slice(0, i).trim(), p.slice(i + 1)];
19
+ }),
20
+ );
21
+ export type HostedOptions = {
22
+ directory?: string;
23
+ assets?: string;
24
+ publicUrl: string;
25
+ clientId?: string;
26
+ clientSecret?: string;
27
+ allowedEmails?: string[];
28
+ allowedDomains?: string[];
29
+ host?: string;
30
+ port?: number;
31
+ maxBytes?: number;
32
+ maxFiles?: number;
33
+ maxUsers?: number;
34
+ enableMcp?: boolean;
35
+ listen?: boolean;
36
+ /** Signed cookies for serverless hosts. Memory sessions remain the Node default. */
37
+ sessionSecret?: string;
38
+ blobStore?: BlobStore;
39
+ serveAssets?: boolean;
40
+ /** In-process test injection only. No environment variable enables a fake login. */
41
+ provider?: IdentityProvider;
42
+ now?: () => number;
43
+ sessionMs?: number;
44
+ };
45
+ export type HostedHandler = {
46
+ handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
47
+ close(): Promise<void>;
48
+ };
49
+ export async function createHostedServer(
50
+ options: HostedOptions & { listen: false },
51
+ ): Promise<HostedHandler>;
52
+ export async function createHostedServer(
53
+ options: HostedOptions,
54
+ ): Promise<import('node:http').Server>;
55
+ export async function createHostedServer(options: HostedOptions) {
56
+ let publicUrl = new URL(options.publicUrl);
57
+ if (
58
+ publicUrl.username ||
59
+ publicUrl.password ||
60
+ publicUrl.pathname !== '/' ||
61
+ publicUrl.search ||
62
+ publicUrl.hash
63
+ )
64
+ throw new Error(
65
+ 'FORMA_PUBLIC_URL must be an origin without credentials, path, query or fragment.',
66
+ );
67
+ const development =
68
+ publicUrl.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(publicUrl.hostname);
69
+ if (publicUrl.protocol !== 'https:' && !development)
70
+ throw new Error(
71
+ 'Hosted mode requires HTTPS (HTTP is permitted only on loopback for development).',
72
+ );
73
+ if (publicUrl.port === '0' && !development)
74
+ throw new Error('Port zero is only supported by loopback test servers.');
75
+ const emails = new Set(
76
+ (options.allowedEmails ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean),
77
+ );
78
+ const domains = new Set(
79
+ (options.allowedDomains ?? []).map((s) => s.trim().toLowerCase()).filter(Boolean),
80
+ );
81
+ if (!emails.size && !domains.size)
82
+ throw new Error('Configure at least one allowed Google email or Workspace domain.');
83
+ const maxBytes = options.maxBytes ?? 100_000_000,
84
+ maxFiles = options.maxFiles ?? 1000,
85
+ maxUsers = options.maxUsers ?? 500;
86
+ if (
87
+ !Number.isSafeInteger(maxBytes) ||
88
+ maxBytes < 1 ||
89
+ !Number.isSafeInteger(maxFiles) ||
90
+ maxFiles < 1 ||
91
+ maxFiles > 1000 ||
92
+ !Number.isSafeInteger(maxUsers) ||
93
+ maxUsers < 1
94
+ )
95
+ throw new Error('Invalid storage limits. Max files must be 1–1000.');
96
+ const signed = !!options.sessionSecret;
97
+ const blobStore = options.blobStore;
98
+ if (!options.directory && !blobStore)
99
+ throw new Error('Hosted mode needs a data directory or a blob store.');
100
+ const directory = options.directory ? resolve(options.directory) : '';
101
+ let lock: Awaited<ReturnType<typeof open>> | undefined;
102
+ const release = async () => {
103
+ if (!lock || !directory) return;
104
+ await lock.close();
105
+ await unlink(join(directory, '.forma-host.lock')).catch(() => {});
106
+ };
107
+ if (directory && !blobStore) {
108
+ await mkdir(directory, { recursive: true, mode: 0o700 });
109
+ const lockPath = join(directory, '.forma-host.lock');
110
+ try {
111
+ lock = await open(lockPath, 'wx', 0o600);
112
+ await lock.writeFile(String(process.pid));
113
+ } catch {
114
+ throw new Error(
115
+ 'Hosted data directory is already locked. Stop the other process; after a crash, verify it is stopped before removing .forma-host.lock.',
116
+ );
117
+ }
118
+ }
119
+ try {
120
+ const now = options.now ?? Date.now,
121
+ sessionMs = options.sessionMs ?? 12 * 60 * 60 * 1000;
122
+ const pending = new Map<string, LoginAttempt & { expires: number }>();
123
+ const sessions = new Map<string, { identity: Identity; expires: number }>();
124
+ const libraries = new Map<string, DiagramLibrary>();
125
+ let registrations = Promise.resolve();
126
+ const sessionName = development ? 'forma-session' : '__Host-forma-session';
127
+ const stateName = development ? 'forma-state' : '__Host-forma-state';
128
+ const cookie = (name: string, value: string, age: number) =>
129
+ `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${development ? '' : '; Secure'}`;
130
+ const allowed = (identity: Identity) =>
131
+ !!identity.sub &&
132
+ !!identity.email &&
133
+ identity.email_verified === true &&
134
+ (emails.has(identity.email.toLowerCase()) ||
135
+ (!!identity.hd && domains.has(identity.hd.toLowerCase())));
136
+ const prune = () => {
137
+ for (const [key, v] of pending) if (v.expires <= now()) pending.delete(key);
138
+ for (const [key, v] of sessions) if (v.expires <= now()) sessions.delete(key);
139
+ };
140
+ const libraryFor = async (identity: Identity) => {
141
+ if (blobStore)
142
+ return blobLibraryFor(blobStore, identity, { bytes: maxBytes, files: maxFiles }, maxUsers);
143
+ const usersRoot = join(directory, 'users');
144
+ await mkdir(usersRoot, { recursive: true, mode: 0o700 });
145
+ if ((await lstat(usersRoot)).isSymbolicLink())
146
+ throw new Error('Hosted users directory cannot be a symlink.');
147
+ const id = hash(`google:${identity.sub}`);
148
+ const operation = async () => {
149
+ if (libraries.has(id)) return libraries.get(id)!;
150
+ const userRoot = join(usersRoot, id);
151
+ const entries = await readdir(usersRoot);
152
+ if (!entries.includes(id) && entries.length >= maxUsers)
153
+ throw new HttpError(
154
+ 503,
155
+ 'This workspace has reached its user limit. Contact your administrator.',
156
+ );
157
+ await mkdir(userRoot, { recursive: true, mode: 0o700 });
158
+ if ((await lstat(userRoot)).isSymbolicLink())
159
+ throw new HttpError(400, 'Invalid user storage.');
160
+ const diagramRoot = join(userRoot, 'diagrams');
161
+ await mkdir(diagramRoot, { recursive: true, mode: 0o700 });
162
+ if ((await lstat(diagramRoot)).isSymbolicLink())
163
+ throw new HttpError(400, 'Invalid diagram storage.');
164
+ await writeFile(
165
+ join(userRoot, 'identity.json'),
166
+ JSON.stringify(
167
+ {
168
+ provider: 'google',
169
+ subject: identity.sub,
170
+ email: identity.email,
171
+ name: identity.name ?? '',
172
+ },
173
+ null,
174
+ 2,
175
+ ) + '\n',
176
+ { mode: 0o600 },
177
+ );
178
+ const library = await createFileLibrary(diagramRoot, { bytes: maxBytes, files: maxFiles });
179
+ libraries.set(id, library);
180
+ return library;
181
+ };
182
+ const result = registrations.then(operation);
183
+ registrations = result.then(
184
+ () => {},
185
+ () => {},
186
+ );
187
+ return result;
188
+ };
189
+ const tokens = await createTokenStore(
190
+ blobStore
191
+ ? {
192
+ load: async () => (await blobStore.get('agent-tokens.json'))?.text ?? null,
193
+ save: async (text) => blobStore.put('agent-tokens.json', text),
194
+ }
195
+ : directory,
196
+ now,
197
+ );
198
+ let provider: IdentityProvider;
199
+ const handler = async (req: IncomingMessage, res: ServerResponse) => {
200
+ res.setHeader('Cache-Control', 'no-store');
201
+ res.setHeader('Referrer-Policy', 'no-referrer');
202
+ res.setHeader('X-Frame-Options', 'DENY');
203
+ res.setHeader('X-Content-Type-Options', 'nosniff');
204
+ res.setHeader(
205
+ 'Content-Security-Policy',
206
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
207
+ );
208
+ if (!development) res.setHeader('Strict-Transport-Security', 'max-age=31536000');
209
+ const redirect = (path: string) => {
210
+ res.writeHead(303, { Location: path });
211
+ res.end();
212
+ };
213
+ try {
214
+ if (req.headers.host !== publicUrl.host) throw new HttpError(403, 'Unrecognized host.');
215
+ if (req.headers.origin && req.headers.origin !== publicUrl.origin)
216
+ throw new HttpError(403, 'Cross-origin access is not allowed.');
217
+ const url = new URL(req.url ?? '/', publicUrl.origin);
218
+ prune();
219
+ const jar = cookies(req);
220
+ const signedSession = signed
221
+ ? verifyCookie(options.sessionSecret!, jar[sessionName], sessionCookieSchema)
222
+ : undefined;
223
+ const session = signed
224
+ ? signedSession && signedSession.expires > now()
225
+ ? signedSession
226
+ : undefined
227
+ : sessions.get(hash(jar[sessionName] ?? ''));
228
+ if (req.method === 'GET' && url.pathname === '/healthz') {
229
+ json(res, 200, { ok: true });
230
+ return;
231
+ }
232
+ if (req.method === 'GET' && url.pathname === '/api/session') {
233
+ json(res, 200, {
234
+ mode: 'hosted',
235
+ user:
236
+ session && allowed(session.identity)
237
+ ? {
238
+ name: session.identity.name ?? session.identity.email,
239
+ email: session.identity.email,
240
+ }
241
+ : null,
242
+ });
243
+ return;
244
+ }
245
+ if (req.method === 'GET' && url.pathname === '/auth/login') {
246
+ if (!signed && pending.size >= 1000)
247
+ throw new HttpError(429, 'Sign-in is busy. Try again shortly.');
248
+ const state = token(),
249
+ verifier = token(),
250
+ attempt = {
251
+ state,
252
+ verifier,
253
+ nonce: token(),
254
+ challenge: createHash('sha256').update(verifier).digest('base64url'),
255
+ expires: now() + 600_000,
256
+ };
257
+ if (!signed) pending.set(state, attempt);
258
+ res.setHeader(
259
+ 'Set-Cookie',
260
+ cookie(stateName, signed ? signCookie(options.sessionSecret!, attempt) : state, 600),
261
+ );
262
+ redirect(provider.authorize(attempt));
263
+ return;
264
+ }
265
+ if (req.method === 'GET' && url.pathname === '/auth/callback') {
266
+ const state = url.searchParams.get('state') ?? '';
267
+ const attempt = signed
268
+ ? verifyCookie(options.sessionSecret!, jar[stateName], loginCookieSchema)
269
+ : pending.get(state);
270
+ res.setHeader('Set-Cookie', cookie(stateName, '', 0));
271
+ if (
272
+ !attempt ||
273
+ attempt.state !== state ||
274
+ attempt.expires <= now() ||
275
+ (!signed && jar[stateName] !== state)
276
+ ) {
277
+ redirect('/?authError=expired');
278
+ return;
279
+ }
280
+ pending.delete(state);
281
+ if (url.searchParams.has('error') || !url.searchParams.get('code')) {
282
+ redirect('/?authError=cancelled');
283
+ return;
284
+ }
285
+ let identity: Identity;
286
+ try {
287
+ identity = await provider.exchange(url.searchParams.get('code')!, attempt);
288
+ if (identity.nonce !== attempt.nonce) throw new Error('Nonce mismatch');
289
+ } catch {
290
+ redirect('/?authError=failed');
291
+ return;
292
+ }
293
+ if (!allowed(identity)) {
294
+ redirect('/?authError=denied');
295
+ return;
296
+ }
297
+ await libraryFor(identity);
298
+ const expires = now() + sessionMs;
299
+ if (signed) {
300
+ res.setHeader('Set-Cookie', [
301
+ cookie(stateName, '', 0),
302
+ cookie(
303
+ sessionName,
304
+ signCookie(options.sessionSecret!, { identity, expires }),
305
+ Math.ceil(sessionMs / 1000),
306
+ ),
307
+ ]);
308
+ redirect('/');
309
+ return;
310
+ }
311
+ if (sessions.size >= 10_000)
312
+ throw new HttpError(503, 'Session capacity reached. Try again later.');
313
+ sessions.delete(hash(jar[sessionName] ?? ''));
314
+ const value = token();
315
+ sessions.set(hash(value), { identity, expires });
316
+ res.setHeader('Set-Cookie', [
317
+ cookie(stateName, '', 0),
318
+ cookie(sessionName, value, Math.ceil(sessionMs / 1000)),
319
+ ]);
320
+ redirect('/');
321
+ return;
322
+ }
323
+ if (url.pathname.startsWith('/api/agent/') || url.pathname === '/mcp') {
324
+ if (url.pathname === '/mcp' && !options.enableMcp)
325
+ throw new HttpError(404, 'MCP is not enabled on this host.');
326
+ const getGrant = () => {
327
+ const grant = tokens.authenticate(req.headers.authorization);
328
+ if (!allowed(grant.identity))
329
+ throw new HttpError(403, 'This account is no longer admitted to the workspace.');
330
+ return grant;
331
+ };
332
+ getGrant();
333
+ const service = createAgentService(getGrant, libraryFor);
334
+ if (url.pathname === '/mcp') {
335
+ if (req.method !== 'POST')
336
+ throw new HttpError(405, 'Use MCP Streamable HTTP POST requests.');
337
+ const body = await readBody(req, false);
338
+ const { handleMcp } = await import('./mcp');
339
+ await handleMcp(req, res, body, service);
340
+ return;
341
+ }
342
+ if (req.method === 'GET' && url.pathname === '/api/agent/me') {
343
+ json(res, 200, service.me());
344
+ return;
345
+ }
346
+ if (req.method === 'GET' && url.pathname === '/api/agent/diagrams') {
347
+ json(res, 200, await service.list());
348
+ return;
349
+ }
350
+ if (req.method === 'GET' && url.pathname === '/api/agent/document') {
351
+ json(res, 200, await service.read(url.searchParams.get('path') ?? ''));
352
+ return;
353
+ }
354
+ if (req.method === 'POST' && url.pathname === '/api/agent/document') {
355
+ json(res, 200, await service.write(await readBody(req, false)));
356
+ return;
357
+ }
358
+ throw new HttpError(404, 'Unknown agent operation.');
359
+ }
360
+ if (url.pathname.startsWith('/api/')) {
361
+ if (!session || !allowed(session.identity))
362
+ throw new HttpError(
363
+ 401,
364
+ 'Your session expired. Export unsaved changes, then sign in again.',
365
+ );
366
+ if (req.method === 'POST') {
367
+ if (req.headers.origin !== publicUrl.origin)
368
+ throw new HttpError(403, 'A same-origin request is required.');
369
+ const data = await readBody(req);
370
+ if (url.pathname === '/api/tokens') {
371
+ json(res, 201, await tokens.create(session.identity, data));
372
+ return;
373
+ }
374
+ if (url.pathname === '/api/tokens/revoke') {
375
+ json(
376
+ res,
377
+ 200,
378
+ await tokens.revoke(session.identity, z.object({ id: z.string() }).parse(data).id),
379
+ );
380
+ return;
381
+ }
382
+ if (url.pathname === '/api/logout') {
383
+ if (!signed) sessions.delete(hash(jar[sessionName] ?? ''));
384
+ res.setHeader('Set-Cookie', cookie(sessionName, '', 0));
385
+ json(res, 200, { ok: true });
386
+ return;
387
+ }
388
+ if (url.pathname === '/api/document') {
389
+ json(res, 200, await (await libraryFor(session.identity)).save(data));
390
+ return;
391
+ }
392
+ throw new HttpError(405, 'This operation is not available in hosted mode.');
393
+ }
394
+ if (req.method === 'GET' && url.pathname === '/api/tokens') {
395
+ json(res, 200, { tokens: tokens.list(session.identity), mcp: !!options.enableMcp });
396
+ return;
397
+ }
398
+ const library = await libraryFor(session.identity);
399
+ if (req.method === 'GET' && url.pathname === '/api/library') {
400
+ const value = await library.list();
401
+ json(res, 200, {
402
+ ...value,
403
+ mode: 'hosted',
404
+ directory: 'Your private library',
405
+ owner: session.identity.email,
406
+ quota: { bytes: maxBytes, files: maxFiles },
407
+ });
408
+ return;
409
+ }
410
+ if (req.method === 'GET' && url.pathname === '/api/document') {
411
+ json(
412
+ res,
413
+ 200,
414
+ await library.read(
415
+ url.searchParams.get('path') ?? '',
416
+ url.searchParams.get('workspace') ?? '',
417
+ ),
418
+ );
419
+ return;
420
+ }
421
+ throw new HttpError(404, 'Unknown library operation.');
422
+ }
423
+ if (options.serveAssets !== false && options.assets)
424
+ await serveAsset(req, res, url, options.assets, 'hosted');
425
+ else throw new HttpError(404, 'Not found.');
426
+ } catch (error) {
427
+ const e = error as NodeJS.ErrnoException & { status?: number };
428
+ if (
429
+ e.status === 401 &&
430
+ (req.url?.startsWith('/api/agent/') || req.url?.split('?')[0] === '/mcp')
431
+ )
432
+ res.setHeader('WWW-Authenticate', 'Bearer realm="Forma agents"');
433
+ const bad = e instanceof SyntaxError || e instanceof ZodError;
434
+ json(res, e.status ?? (e.code === 'ENOENT' ? 404 : bad ? 400 : 500), {
435
+ error: e.status
436
+ ? e.message
437
+ : e.code === 'ENOENT'
438
+ ? 'Not found.'
439
+ : bad
440
+ ? 'Invalid document or request.'
441
+ : 'The server could not complete the request.',
442
+ });
443
+ }
444
+ };
445
+ if (options.listen === false) {
446
+ provider =
447
+ options.provider ??
448
+ googleProvider(
449
+ options.clientId ?? '',
450
+ options.clientSecret ?? '',
451
+ `${publicUrl.origin}/auth/callback`,
452
+ );
453
+ return {
454
+ handle: handler,
455
+ close: async () => {
456
+ sessions.clear();
457
+ pending.clear();
458
+ await release();
459
+ },
460
+ };
461
+ }
462
+ const server = createServer(handler);
463
+ server.requestTimeout = 30_000;
464
+ server.headersTimeout = 15_000;
465
+ await new Promise<void>((resolve, reject) => {
466
+ server.once('error', reject);
467
+ server.listen(
468
+ options.port ?? 4242,
469
+ development ? '127.0.0.1' : (options.host ?? '127.0.0.1'),
470
+ resolve,
471
+ );
472
+ });
473
+ if (publicUrl.port === '0') {
474
+ publicUrl = new URL(`http://127.0.0.1:${(server.address() as { port: number }).port}`);
475
+ }
476
+ try {
477
+ provider =
478
+ options.provider ??
479
+ googleProvider(
480
+ options.clientId ?? '',
481
+ options.clientSecret ?? '',
482
+ `${publicUrl.origin}/auth/callback`,
483
+ );
484
+ } catch (e) {
485
+ server.close();
486
+ throw e;
487
+ }
488
+ server.once('close', () => {
489
+ sessions.clear();
490
+ pending.clear();
491
+ void release();
492
+ });
493
+ return server;
494
+ } catch (e) {
495
+ await release();
496
+ throw e;
497
+ }
498
+ }
@@ -0,0 +1,67 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { readFile, realpath } from 'node:fs/promises';
3
+ import { resolve, extname, sep } from 'node:path';
4
+ import { HttpError } from './file-library';
5
+ export function json(res: ServerResponse, status: number, value: unknown) {
6
+ res.writeHead(status, {
7
+ 'Content-Type': 'application/json',
8
+ 'Cache-Control': 'no-store',
9
+ 'X-Content-Type-Options': 'nosniff',
10
+ });
11
+ res.end(JSON.stringify(value));
12
+ }
13
+ export async function readBody(req: IncomingMessage, requireHeader = true) {
14
+ if (
15
+ (requireHeader && req.headers['x-forma-request'] !== '1') ||
16
+ !req.headers['content-type']?.startsWith('application/json')
17
+ )
18
+ throw new HttpError(405, 'Unsupported library request.');
19
+ let size = 0;
20
+ const chunks: Buffer[] = [];
21
+ for await (const chunk of req) {
22
+ size += chunk.length;
23
+ if (size > 5_000_000) throw new HttpError(413, 'Document exceeds 5 MB.');
24
+ chunks.push(chunk);
25
+ }
26
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
27
+ if (!data || typeof data !== 'object' || Array.isArray(data))
28
+ throw new HttpError(400, 'Expected a JSON object.');
29
+ return data;
30
+ }
31
+ export async function serveAsset(
32
+ req: IncomingMessage,
33
+ res: ServerResponse,
34
+ url: URL,
35
+ assetsPath: string,
36
+ mode: 'local' | 'hosted',
37
+ ) {
38
+ if (req.method !== 'GET' && req.method !== 'HEAD')
39
+ throw new HttpError(405, 'Read-only asset route.');
40
+ const relative = decodeURIComponent(url.pathname).replace(/^\//, '') || 'index.html';
41
+ const assets = await realpath(resolve(assetsPath)),
42
+ file = resolve(assets, relative);
43
+ if (!file.startsWith(assets + sep)) throw new HttpError(403, 'Invalid asset path.');
44
+ const real = await realpath(file);
45
+ if (!real.startsWith(assets + sep)) throw new HttpError(403, 'Invalid asset target.');
46
+ let bytes = await readFile(real);
47
+ if (extname(file) === '.html')
48
+ bytes = Buffer.from(
49
+ bytes.toString().replace('<head>', `<head><meta name="forma-${mode}" content="1">`),
50
+ );
51
+ const types: Record<string, string> = {
52
+ '.html': 'text/html',
53
+ '.js': 'text/javascript',
54
+ '.css': 'text/css',
55
+ '.svg': 'image/svg+xml',
56
+ '.png': 'image/png',
57
+ '.ttf': 'font/ttf',
58
+ '.json': 'application/json',
59
+ '.txt': 'text/plain',
60
+ };
61
+ res.writeHead(200, {
62
+ 'Content-Type': types[extname(file)] ?? 'application/octet-stream',
63
+ 'Cache-Control': mode === 'hosted' ? 'no-store' : 'no-cache',
64
+ 'X-Content-Type-Options': 'nosniff',
65
+ });
66
+ res.end(req.method === 'HEAD' ? undefined : bytes);
67
+ }