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,232 @@
1
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
2
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { HttpError } from './file-library';
6
+ import type { Identity } from './google';
7
+
8
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex');
9
+ export const ownerId = (identity: Identity) => hash(`google:${identity.sub}`);
10
+
11
+ export function relativePath(path: string, folder = false) {
12
+ if (
13
+ typeof path !== 'string' ||
14
+ path.length > 1024 ||
15
+ path.includes('\\') ||
16
+ /[\x00-\x1f]/.test(path) ||
17
+ (path !== '' && path.split('/').some((p) => !p || p.startsWith('.'))) ||
18
+ path.split('/').length > 13 ||
19
+ (!folder && !path.endsWith('.forma.json')) ||
20
+ (!folder && !path)
21
+ )
22
+ throw new HttpError(400, 'Use a relative path without empty, hidden or parent segments.');
23
+ return path;
24
+ }
25
+
26
+ const recordSchema = z.object({
27
+ id: z.string(),
28
+ digest: z.string().regex(/^[a-f0-9]{64}$/),
29
+ identity: z.object({
30
+ sub: z.string(),
31
+ email: z.string(),
32
+ email_verified: z.literal(true),
33
+ hd: z.string().optional(),
34
+ name: z.string().optional(),
35
+ }),
36
+ name: z.string(),
37
+ permission: z.enum(['read', 'write']),
38
+ folder: z.string(),
39
+ created: z.number(),
40
+ expires: z.number(),
41
+ });
42
+ export type Grant = z.infer<typeof recordSchema>;
43
+ const fileSchema = z.object({ version: z.literal(1), tokens: z.array(recordSchema).max(10000) });
44
+
45
+ export type TokenIO = { load(): Promise<string | null>; save(text: string): Promise<void> };
46
+ function fileTokenIO(directory: string): TokenIO {
47
+ const path = join(directory, 'agent-tokens.json');
48
+ return {
49
+ async load() {
50
+ try {
51
+ return await readFile(path, 'utf8');
52
+ } catch (e) {
53
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
54
+ throw new Error(
55
+ 'Cannot read agent token registry. Restore or repair it before starting hosted mode.',
56
+ );
57
+ }
58
+ },
59
+ async save(text) {
60
+ const tmp = join(directory, `.tokens-${randomBytes(8).toString('hex')}.tmp`);
61
+ try {
62
+ await writeFile(tmp, text, { flag: 'wx', mode: 0o600 });
63
+ await rename(tmp, path);
64
+ } finally {
65
+ await unlink(tmp).catch(() => {});
66
+ }
67
+ },
68
+ };
69
+ }
70
+ export async function createTokenStore(source: string | TokenIO, now: () => number = Date.now) {
71
+ const io = typeof source === 'string' ? fileTokenIO(source) : source;
72
+ let records: Grant[] = [];
73
+ const loaded = await io.load();
74
+ if (loaded) {
75
+ records = fileSchema.parse(JSON.parse(loaded)).tokens;
76
+ for (const r of records) relativePath(r.folder, true);
77
+ }
78
+ let mutations = Promise.resolve();
79
+ const publicRecord = ({ digest: _digest, identity: _identity, ...value }: Grant) => value;
80
+ const update = <T>(operation: () => Promise<T>) => {
81
+ const result = mutations.then(operation);
82
+ mutations = result.then(
83
+ () => {},
84
+ () => {},
85
+ );
86
+ return result;
87
+ };
88
+ const persist = async (next: Grant[]) => {
89
+ await io.save(JSON.stringify({ version: 1, tokens: next }) + '\n');
90
+ records = next;
91
+ };
92
+ return {
93
+ list: (identity: Identity) =>
94
+ records.filter((r) => r.identity.sub === identity.sub).map(publicRecord),
95
+ create: (identity: Identity, input: unknown) =>
96
+ update(async () => {
97
+ if (!identity.email || identity.email_verified !== true)
98
+ throw new HttpError(403, 'This account cannot issue agent tokens.');
99
+ const data = z
100
+ .object({
101
+ name: z.string().trim().min(1).max(80),
102
+ permission: z.enum(['read', 'write']),
103
+ folder: z.string().trim().default(''),
104
+ days: z.number().int().min(1).max(90).default(30),
105
+ })
106
+ .strict()
107
+ .parse(input);
108
+ relativePath(data.folder, true);
109
+ const current = records.filter((r) => r.expires > now());
110
+ if (
111
+ current.filter((r) => r.identity.sub === identity.sub).length >= 20 ||
112
+ current.length >= 10000
113
+ )
114
+ throw new HttpError(409, 'Revoke an existing token before creating another.');
115
+ const id = randomBytes(12).toString('hex'),
116
+ secret = `forma_${id}_${randomBytes(32).toString('hex')}`;
117
+ const record: Grant = {
118
+ id,
119
+ digest: hash(secret),
120
+ identity: {
121
+ sub: identity.sub,
122
+ email: identity.email,
123
+ email_verified: true,
124
+ ...(identity.hd ? { hd: identity.hd } : {}),
125
+ ...(identity.name ? { name: identity.name } : {}),
126
+ },
127
+ name: data.name,
128
+ permission: data.permission,
129
+ folder: data.folder,
130
+ created: now(),
131
+ expires: now() + data.days * 86400000,
132
+ };
133
+ await persist([...current, record]);
134
+ return { token: secret, ...publicRecord(record) };
135
+ }),
136
+ revoke: (identity: Identity, id: string) =>
137
+ update(async () => {
138
+ if (!records.some((r) => r.id === id && r.identity.sub === identity.sub))
139
+ throw new HttpError(404, 'Token not found.');
140
+ await persist(records.filter((r) => r.id !== id || r.identity.sub !== identity.sub));
141
+ return { ok: true };
142
+ }),
143
+ authenticate: (authorization: string | undefined) => {
144
+ const match = /^Bearer (forma_([a-f0-9]{24})_[a-f0-9]{64})$/.exec(authorization ?? '');
145
+ const grant = match && records.find((r) => r.id === match[2]);
146
+ const digest = grant?.digest ?? '0'.repeat(64);
147
+ const presented = hash(match?.[1] ?? '');
148
+ let equal = false;
149
+ try {
150
+ equal = timingSafeEqual(Buffer.from(digest, 'hex'), Buffer.from(presented, 'hex'));
151
+ } catch {
152
+ equal = false;
153
+ }
154
+ if (!match || !grant || grant.expires <= now() || !equal)
155
+ throw new HttpError(401, 'A valid, unexpired Forma agent token is required.');
156
+ return grant;
157
+ },
158
+ };
159
+ }
160
+
161
+ export function createAgentService(
162
+ getGrant: () => Grant,
163
+ libraryFor: (identity: Identity) => Promise<{
164
+ workspace: string;
165
+ list: () => Promise<{ items: { path: string }[]; folders: string[] }>;
166
+ read: (path: string, workspace: string) => Promise<{ document: unknown; revision: string }>;
167
+ save: (data: {
168
+ path: string;
169
+ document: unknown;
170
+ revision: string | null;
171
+ workspace: string;
172
+ }) => Promise<{ path: string; revision: string; workspace: string }>;
173
+ }>,
174
+ ) {
175
+ const check = (path: string, write = false) => {
176
+ relativePath(path);
177
+ const grant = getGrant();
178
+ if (grant.folder && !path.startsWith(grant.folder + '/'))
179
+ throw new HttpError(403, 'This path is outside the token’s folder scope.');
180
+ if (write && grant.permission !== 'write') throw new HttpError(403, 'This token is read-only.');
181
+ return grant;
182
+ };
183
+ return {
184
+ me: () => {
185
+ const grant = getGrant();
186
+ return {
187
+ owner: ownerId(grant.identity),
188
+ email: grant.identity.email,
189
+ permission: grant.permission,
190
+ folder: grant.folder,
191
+ expires: grant.expires,
192
+ };
193
+ },
194
+ list: async () => {
195
+ const grant = getGrant(),
196
+ library = await libraryFor(grant.identity),
197
+ value = await library.list();
198
+ const inside = (path: string) => !grant.folder || path.startsWith(grant.folder + '/');
199
+ return {
200
+ items: value.items.filter((i) => inside(i.path)),
201
+ folders: value.folders.filter(inside),
202
+ };
203
+ },
204
+ read: async (path: string) => {
205
+ const grant = check(path),
206
+ library = await libraryFor(grant.identity);
207
+ return {
208
+ ...(await library.read(path, library.workspace)),
209
+ path,
210
+ owner: ownerId(grant.identity),
211
+ };
212
+ },
213
+ write: async (data: unknown) => {
214
+ const input = z
215
+ .object({
216
+ path: z.string(),
217
+ revision: z
218
+ .string()
219
+ .regex(/^[a-f0-9]{64}$/)
220
+ .nullable(),
221
+ document: z.unknown(),
222
+ })
223
+ .strict()
224
+ .parse(data);
225
+ const grant = check(input.path, true),
226
+ library = await libraryFor(grant.identity);
227
+ const result = await library.save({ ...input, workspace: library.workspace });
228
+ return { path: result.path, revision: result.revision, owner: ownerId(grant.identity) };
229
+ },
230
+ };
231
+ }
232
+ export type AgentService = ReturnType<typeof createAgentService>;
@@ -0,0 +1,241 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseDocument, serializeDocument } from '../../core/src/index';
3
+ import { HttpError, type createFileLibrary } from './file-library';
4
+ import { relativePath } from './agent-access';
5
+ import type { Identity } from './google';
6
+
7
+ export type BlobObject = { pathname: string; size: number; uploadedAt: Date; text?: string };
8
+ export type BlobStore = {
9
+ get(pathname: string): Promise<{ text: string; size: number; uploadedAt: Date } | null>;
10
+ put(pathname: string, text: string): Promise<void>;
11
+ list(prefix: string): Promise<BlobObject[]>;
12
+ };
13
+
14
+ const revision = (text: string) => createHash('sha256').update(text).digest('hex');
15
+ export const ownerKey = (identity: Identity) =>
16
+ createHash('sha256').update(`google:${identity.sub}`).digest('hex');
17
+
18
+ async function vercelBlobStore(): Promise<BlobStore> {
19
+ const { get, put, list, BlobNotFoundError } = await import('@vercel/blob');
20
+ const streamText = async (stream: ReadableStream<Uint8Array>) => {
21
+ const reader = stream.getReader();
22
+ const chunks: Uint8Array[] = [];
23
+ while (true) {
24
+ const { done, value } = await reader.read();
25
+ if (done) break;
26
+ chunks.push(value);
27
+ }
28
+ return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
29
+ };
30
+ return {
31
+ async get(pathname) {
32
+ try {
33
+ const result = await get(pathname, { access: 'private', useCache: false });
34
+ if (!result || result.statusCode !== 200) return null;
35
+ const text = await streamText(result.stream);
36
+ return { text, size: result.blob.size, uploadedAt: result.blob.uploadedAt };
37
+ } catch (error) {
38
+ if (error instanceof BlobNotFoundError) return null;
39
+ throw error;
40
+ }
41
+ },
42
+ async put(pathname, text) {
43
+ await put(pathname, text, {
44
+ access: 'private',
45
+ addRandomSuffix: false,
46
+ allowOverwrite: true,
47
+ cacheControlMaxAge: 0,
48
+ contentType: 'application/json',
49
+ });
50
+ },
51
+ async list(prefix) {
52
+ const items: BlobObject[] = [];
53
+ let cursor: string | undefined;
54
+ do {
55
+ const page = await list({ prefix, cursor, limit: 1000 });
56
+ items.push(
57
+ ...page.blobs.map((b) => ({
58
+ pathname: b.pathname,
59
+ size: b.size,
60
+ uploadedAt: b.uploadedAt,
61
+ })),
62
+ );
63
+ cursor = page.hasMore ? page.cursor : undefined;
64
+ } while (cursor);
65
+ return items;
66
+ },
67
+ };
68
+ }
69
+
70
+ export async function createBlobLibrary(
71
+ store: BlobStore,
72
+ prefix: string,
73
+ limits?: { bytes: number; files: number },
74
+ ) {
75
+ const workspace = `blob:${prefix}`;
76
+ let mutations = Promise.resolve();
77
+ const key = (path: string) => `${prefix}${path}`;
78
+ const list = async () => {
79
+ const blobs = await store.list(prefix);
80
+ const items: { path: string; title: string; modified: string; error?: string }[] = [],
81
+ folders = new Set<string>();
82
+ let bytes = 0;
83
+ for (const blob of blobs) {
84
+ if (!blob.pathname.startsWith(prefix) || !blob.pathname.endsWith('.forma.json')) continue;
85
+ const path = blob.pathname.slice(prefix.length);
86
+ if (!path || path.split('/').some((p) => !p || p.startsWith('.'))) continue;
87
+ bytes += blob.size;
88
+ const parts = path.split('/');
89
+ for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
90
+ let title = parts.at(-1)!,
91
+ error: string | undefined;
92
+ try {
93
+ const file = await store.get(blob.pathname);
94
+ if (!file) throw new Error('Missing diagram.');
95
+ if (file.size > 5_000_000) throw new Error('File exceeds 5 MB.');
96
+ title = parseDocument(JSON.parse(file.text)).title;
97
+ } catch (e) {
98
+ error = (e as Error).message;
99
+ }
100
+ items.push({
101
+ path,
102
+ title,
103
+ modified: blob.uploadedAt.toISOString(),
104
+ ...(error ? { error } : {}),
105
+ });
106
+ }
107
+ return {
108
+ mode: 'hosted',
109
+ bytes,
110
+ directory: 'Your private library',
111
+ workspace,
112
+ items: items.sort((a, b) => a.path.localeCompare(b.path)),
113
+ folders: [...folders].sort(),
114
+ };
115
+ };
116
+ const read = async (path: string, expectedWorkspace: string) => {
117
+ if (expectedWorkspace !== workspace)
118
+ throw new HttpError(409, 'Library changed. Refresh the library before opening a file.');
119
+ relativePath(path);
120
+ const file = await store.get(key(path));
121
+ if (!file) {
122
+ const err = new Error('Not found.') as NodeJS.ErrnoException;
123
+ err.code = 'ENOENT';
124
+ throw err;
125
+ }
126
+ if (file.size > 5_000_000) throw new HttpError(413, 'File exceeds 5 MB.');
127
+ return { document: parseDocument(JSON.parse(file.text)), revision: revision(file.text) };
128
+ };
129
+ const save = (data: {
130
+ path: string;
131
+ document: unknown;
132
+ revision: string | null;
133
+ workspace: string;
134
+ }) => {
135
+ const operation = async () => {
136
+ if (data.workspace !== workspace)
137
+ throw new HttpError(409, 'Library changed. Reopen the diagram from the current library.');
138
+ relativePath(data.path);
139
+ let content: string;
140
+ try {
141
+ content = serializeDocument(parseDocument(data.document));
142
+ } catch {
143
+ throw new HttpError(400, 'Invalid diagram document.');
144
+ }
145
+ if (Buffer.byteLength(content) > 5_000_000)
146
+ throw new HttpError(413, 'Document exceeds 5 MB.');
147
+ const existing = await store.get(key(data.path));
148
+ if ((existing === null ? null : revision(existing.text)) !== data.revision)
149
+ throw new HttpError(
150
+ 409,
151
+ 'This file changed on disk. Your edits are still here. Open the disk version from Library or save your edits under a new path.',
152
+ );
153
+ if (limits) {
154
+ const inventory = await list();
155
+ if (
156
+ (existing === null && inventory.items.length >= limits.files) ||
157
+ inventory.bytes - Buffer.byteLength(existing?.text ?? '') + Buffer.byteLength(content) >
158
+ limits.bytes
159
+ )
160
+ throw new HttpError(
161
+ 413,
162
+ 'Your library storage quota is full. Contact your administrator.',
163
+ );
164
+ }
165
+ await store.put(key(data.path), content);
166
+ return { path: data.path, revision: revision(content), workspace };
167
+ };
168
+ const result = mutations.then(operation);
169
+ mutations = result.then(
170
+ () => {},
171
+ () => {},
172
+ );
173
+ return result;
174
+ };
175
+ return { list, read, save, workspace, root: prefix };
176
+ }
177
+
178
+ export async function blobLibraryFor(
179
+ store: BlobStore,
180
+ identity: Identity,
181
+ limits: { bytes: number; files: number },
182
+ maxUsers: number,
183
+ ) {
184
+ const id = ownerKey(identity);
185
+ const prefix = `users/${id}/diagrams/`;
186
+ const identities = (await store.list('users/')).filter((b) =>
187
+ b.pathname.endsWith('/identity.json'),
188
+ );
189
+ if (
190
+ !identities.some((b) => b.pathname === `users/${id}/identity.json`) &&
191
+ identities.length >= maxUsers
192
+ )
193
+ throw new HttpError(
194
+ 503,
195
+ 'This workspace has reached its user limit. Contact your administrator.',
196
+ );
197
+ await store.put(
198
+ `users/${id}/identity.json`,
199
+ JSON.stringify(
200
+ {
201
+ provider: 'google',
202
+ subject: identity.sub,
203
+ email: identity.email,
204
+ name: identity.name ?? '',
205
+ },
206
+ null,
207
+ 2,
208
+ ) + '\n',
209
+ );
210
+ return createBlobLibrary(store, prefix, limits);
211
+ }
212
+
213
+ export async function defaultVercelBlobStore() {
214
+ return vercelBlobStore();
215
+ }
216
+
217
+ export function memoryBlobStore(seed: Map<string, string> = new Map()): BlobStore {
218
+ const files = new Map(seed);
219
+ return {
220
+ async get(pathname) {
221
+ const text = files.get(pathname);
222
+ if (text === undefined) return null;
223
+ return { text, size: Buffer.byteLength(text), uploadedAt: new Date(0) };
224
+ },
225
+ async put(pathname, text) {
226
+ files.set(pathname, text);
227
+ },
228
+ async list(prefix) {
229
+ return [...files]
230
+ .filter(([pathname]) => pathname.startsWith(prefix))
231
+ .map(([pathname, text]) => ({
232
+ pathname,
233
+ size: Buffer.byteLength(text),
234
+ uploadedAt: new Date(0),
235
+ text,
236
+ }));
237
+ },
238
+ };
239
+ }
240
+
241
+ export type DiagramLibrary = Awaited<ReturnType<typeof createFileLibrary>>;
@@ -0,0 +1,179 @@
1
+ import {
2
+ readFile,
3
+ writeFile,
4
+ rename,
5
+ unlink,
6
+ mkdir,
7
+ realpath,
8
+ lstat,
9
+ readdir,
10
+ } from 'node:fs/promises';
11
+ import { resolve, join, dirname, isAbsolute } from 'node:path';
12
+ import { createHash, randomUUID } from 'node:crypto';
13
+ import { parseDocument, serializeDocument } from '../../core/src/index';
14
+
15
+ const revision = (text: string) => createHash('sha256').update(text).digest('hex');
16
+ export class HttpError extends Error {
17
+ constructor(
18
+ public status: number,
19
+ message: string,
20
+ ) {
21
+ super(message);
22
+ }
23
+ }
24
+ export async function createFileLibrary(
25
+ directory: string,
26
+ limits?: { bytes: number; files: number },
27
+ ) {
28
+ await mkdir(resolve(directory), { recursive: true, mode: 0o700 });
29
+ const root = await realpath(resolve(directory)),
30
+ workspace = randomUUID();
31
+ let mutations = Promise.resolve();
32
+ const safePath = async (path: string, createParents = false, allowMissing = false) => {
33
+ if (
34
+ !path ||
35
+ path.length > 1024 ||
36
+ path.split('/').length > 13 ||
37
+ isAbsolute(path) ||
38
+ path.includes('\\') ||
39
+ path.split('/').some((p) => !p || p === '.' || p === '..' || p.startsWith('.')) ||
40
+ !path.endsWith('.forma.json')
41
+ )
42
+ throw new HttpError(
43
+ 400,
44
+ 'Use a relative .forma.json path, such as architecture/checkout.forma.json.',
45
+ );
46
+ const parts = path.split('/');
47
+ let current = root;
48
+ for (const [i, part] of parts.entries()) {
49
+ current = join(current, part);
50
+ try {
51
+ const stat = await lstat(current);
52
+ if (stat.isSymbolicLink())
53
+ throw new HttpError(400, 'Symbolic links are not supported in the library.');
54
+ if (i < parts.length - 1 && !stat.isDirectory())
55
+ throw new HttpError(400, 'Parent is not a folder.');
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
58
+ if (i < parts.length - 1 && createParents) await mkdir(current);
59
+ else if (i < parts.length - 1 && !allowMissing)
60
+ throw new HttpError(404, 'Folder not found.');
61
+ }
62
+ }
63
+ return current;
64
+ };
65
+ const list = async () => {
66
+ let bytes = 0;
67
+ const items: { path: string; title: string; modified: string; error?: string }[] = [],
68
+ folders: string[] = [];
69
+ const walk = async (relative = '', depth = 0) => {
70
+ if (depth > 12 || items.length >= 1000) return;
71
+ for (const entry of await readdir(join(root, relative), { withFileTypes: true })) {
72
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.isSymbolicLink())
73
+ continue;
74
+ const path = relative ? `${relative}/${entry.name}` : entry.name;
75
+ if (entry.isDirectory()) {
76
+ folders.push(path);
77
+ await walk(path, depth + 1);
78
+ } else if (entry.isFile() && entry.name.endsWith('.forma.json') && items.length < 1000) {
79
+ const stat = await lstat(join(root, path));
80
+ bytes += stat.size;
81
+ let title = entry.name,
82
+ error: string | undefined;
83
+ try {
84
+ if (stat.size > 5_000_000) throw new Error('File exceeds 5 MB.');
85
+ title = parseDocument(JSON.parse(await readFile(join(root, path), 'utf8'))).title;
86
+ } catch (e) {
87
+ error = (e as Error).message;
88
+ }
89
+ items.push({
90
+ path,
91
+ title,
92
+ modified: stat.mtime.toISOString(),
93
+ ...(error ? { error } : {}),
94
+ });
95
+ }
96
+ }
97
+ };
98
+ await walk();
99
+ return {
100
+ mode: 'local',
101
+ bytes,
102
+ directory: root,
103
+ workspace,
104
+ items: items.sort((a, b) => a.path.localeCompare(b.path)),
105
+ folders: folders.sort(),
106
+ };
107
+ };
108
+
109
+ const read = async (path: string, expectedWorkspace: string) => {
110
+ if (expectedWorkspace !== workspace)
111
+ throw new HttpError(409, 'Library changed. Refresh the library before opening a file.');
112
+ const target = await safePath(path);
113
+ const stat = await lstat(target);
114
+ if (stat.size > 5_000_000) throw new HttpError(413, 'File exceeds 5 MB.');
115
+ const text = await readFile(target, 'utf8');
116
+ return { document: parseDocument(JSON.parse(text)), revision: revision(text) };
117
+ };
118
+ const save = (data: {
119
+ path: string;
120
+ document: unknown;
121
+ revision: string | null;
122
+ workspace: string;
123
+ }) => {
124
+ const operation = async () => {
125
+ if (data.workspace !== workspace)
126
+ throw new HttpError(409, 'Library changed. Reopen the diagram from the current library.');
127
+ if (typeof data.path !== 'string') throw new HttpError(400, 'A file path is required.');
128
+ let content: string;
129
+ try {
130
+ content = serializeDocument(parseDocument(data.document));
131
+ } catch {
132
+ throw new HttpError(400, 'Invalid diagram document.');
133
+ }
134
+ if (Buffer.byteLength(content) > 5_000_000)
135
+ throw new HttpError(413, 'Document exceeds 5 MB.');
136
+ // Resolve without creating directories until validation and quota checks pass.
137
+ const path = await safePath(data.path, false, true);
138
+ let existing: string | null = null;
139
+ try {
140
+ existing = await readFile(path, 'utf8');
141
+ } catch (e) {
142
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
143
+ }
144
+ if ((existing === null ? null : revision(existing)) !== data.revision)
145
+ throw new HttpError(
146
+ 409,
147
+ 'This file changed on disk. Your edits are still here. Open the disk version from Library or save your edits under a new path.',
148
+ );
149
+ if (limits) {
150
+ const inventory = await list();
151
+ if (
152
+ (existing === null && inventory.items.length >= limits.files) ||
153
+ inventory.bytes - Buffer.byteLength(existing ?? '') + Buffer.byteLength(content) >
154
+ limits.bytes
155
+ )
156
+ throw new HttpError(
157
+ 413,
158
+ 'Your library storage quota is full. Contact your administrator.',
159
+ );
160
+ }
161
+ await safePath(data.path, true);
162
+ const temp = join(dirname(path), `.forma-${randomUUID()}.tmp`);
163
+ try {
164
+ await writeFile(temp, content, { flag: 'wx', mode: 0o600 });
165
+ await rename(temp, path);
166
+ } finally {
167
+ await unlink(temp).catch(() => {});
168
+ }
169
+ return { path: data.path, revision: revision(content), workspace };
170
+ };
171
+ const result = mutations.then(operation);
172
+ mutations = result.then(
173
+ () => {},
174
+ () => {},
175
+ );
176
+ return result;
177
+ };
178
+ return { list, read, save, workspace, root };
179
+ }
@@ -0,0 +1,51 @@
1
+ import { OAuth2Client, CodeChallengeMethod } from 'google-auth-library';
2
+ export type Identity = {
3
+ sub: string;
4
+ email?: string;
5
+ email_verified?: boolean;
6
+ name?: string;
7
+ hd?: string;
8
+ nonce?: string;
9
+ };
10
+ export type LoginAttempt = { state: string; nonce: string; verifier: string; challenge: string };
11
+ /** Dependency boundary for deterministic tests; the shipped command always uses Google. */
12
+ export interface IdentityProvider {
13
+ authorize(attempt: LoginAttempt): string;
14
+ exchange(code: string, attempt: LoginAttempt): Promise<Identity>;
15
+ }
16
+ export function googleProvider(
17
+ clientId: string,
18
+ clientSecret: string,
19
+ redirectUri: string,
20
+ ): IdentityProvider {
21
+ if (!clientId || !clientSecret)
22
+ throw new Error('Set FORMA_GOOGLE_CLIENT_ID and FORMA_GOOGLE_CLIENT_SECRET.');
23
+ const client = new OAuth2Client({
24
+ clientId,
25
+ clientSecret,
26
+ redirectUri,
27
+ transporterOptions: { timeout: 15_000 },
28
+ });
29
+ return {
30
+ authorize: ({ state, nonce, challenge }) =>
31
+ client.generateAuthUrl({
32
+ scope: ['openid', 'email', 'profile'],
33
+ state,
34
+ nonce,
35
+ code_challenge: challenge,
36
+ code_challenge_method: CodeChallengeMethod.S256,
37
+ prompt: 'select_account',
38
+ access_type: 'online',
39
+ }),
40
+ async exchange(code, attempt) {
41
+ const { tokens } = await client.getToken({ code, codeVerifier: attempt.verifier });
42
+ if (!tokens.id_token) throw new Error('Missing ID token.');
43
+ // The maintained Google library validates the signature, issuer, audience and expiry.
44
+ const ticket = await client.verifyIdToken({ idToken: tokens.id_token, audience: clientId });
45
+ const payload = ticket.getPayload();
46
+ if (!payload) throw new Error('Missing identity.');
47
+ // Do not persist access or refresh tokens: this application needs identity only.
48
+ return payload as Identity;
49
+ },
50
+ };
51
+ }