@design-twin/dtwin 0.0.0-stage → 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,174 @@
1
+ // daemon-auth.ts — the daemon socket's mutual handshake: each side proves it holds the bridge token
2
+ // before either says anything else.
3
+ //
4
+ // WHY. On POSIX the daemon socket sits in a directory only this user can enter (daemon.ts), so whoever
5
+ // answers on it is us. Windows has no such directory for a named pipe: the runtime (libuv, under Bun's
6
+ // node:net) creates it with the DEFAULT security descriptor — full control for SYSTEM, Administrators and
7
+ // the creating user, READ for Everyone and Anonymous — and offers no way to narrow it, nor for a client to
8
+ // ask who created the server end. So another local user could create the pipe name first (a squatter) and
9
+ // answer our CLI with forged replies, or simply connect and ask the daemon to export. What both sides do
10
+ // share, and nobody else has, is the bridge token (token-store.ts: %APPDATA% is per-user by ACL). This
11
+ // file turns it into proof, in both directions, before a single request or reply crosses the pipe.
12
+ //
13
+ // THE EXCHANGE (newline-delimited JSON, the daemon socket's own framing; every field hex):
14
+ // server → client {"dtwin":"auth/1","ns":<32 random bytes>}
15
+ // client → server {"nc":<32 random bytes>,"mac":HMAC(k, "client\0" + name + "\0" + ns + "\0" + nc)}
16
+ // server → client {"mac":HMAC(k, "server\0" + name + "\0" + ns + "\0" + nc)}
17
+ // then the ordinary request/reply frames. k = HMAC-SHA256(token, "dtwin-daemon-ipc/v1"): a key of its own,
18
+ // so nothing computed here is ever a MAC under the token the plugin's WebSocket presents. `name` is the
19
+ // pipe (or socket path) itself, so a proof made for one daemon's address — another port's — is no proof
20
+ // on another's.
21
+ //
22
+ // What each part buys:
23
+ // - Fresh nonces from BOTH sides, and both bound into both MACs: a recorded exchange replays nowhere (the
24
+ // real server always picks a new `ns`), and a squatter learns only MACs over nonces of its own choosing.
25
+ // - Role labels ("client" / "server"): a server cannot reflect a client's proof back as its own.
26
+ // - Before the proof: at most AUTH_MAX_BYTES read, AUTH_TIMEOUT_MS to finish, and any failure destroys
27
+ // the connection with NO error text — an unauthenticated peer learns nothing but "closed". (The hello
28
+ // itself is public: Everyone can read the pipe, so `ns` is written to be seen.)
29
+ // - Compared in constant time (timingSafeEqual) on fixed 32-byte digests, after a shape check.
30
+ // Authenticating once, at connect, is enough: a connected pipe or Unix socket is a kernel object a third
31
+ // process cannot splice into, so there is no per-message MAC.
32
+ //
33
+ // What it does NOT stop: a squatter that holds the name can still refuse service (our `serve` cannot bind
34
+ // it, and every command then opens its own bridge — the same fallback as a refused socket directory on
35
+ // POSIX). And another process running as THIS user can read the token file, as everywhere (token-store.ts).
36
+ import crypto from "node:crypto";
37
+ import { isRecord } from "./json-util.ts";
38
+
39
+ /** The protocol tag the server's hello carries — what a client checks before it answers. */
40
+ export const AUTH_PROTO = "auth/1";
41
+ /** The most an unauthenticated peer may send before it has proved the key (the client's proof is ~200
42
+ * bytes; the server's hello and proof less). Past it the connection is dropped, not buffered. */
43
+ export const AUTH_MAX_BYTES = 1024;
44
+ /** How long the whole exchange may take, from either side. A peer that stalls is dropped. */
45
+ export const AUTH_TIMEOUT_MS = 5000;
46
+
47
+ /** What both sides key the handshake on: the key derived from the token, and the address it is bound to. */
48
+ export interface IpcAuth {
49
+ key: Buffer;
50
+ name: string;
51
+ }
52
+
53
+ const hmac = (key: crypto.BinaryLike, msg: string): Buffer => crypto.createHmac("sha256", key).update(msg).digest();
54
+
55
+ /** The handshake key for `token` — never the token itself. */
56
+ export function ipcKey(token: string): Buffer {
57
+ return hmac(token, "dtwin-daemon-ipc/v1");
58
+ }
59
+
60
+ /** The Windows pipe the daemon for `port` listens on when the bridge token is `token`. Derived, so it is
61
+ * not guessable before a daemon first runs (anyone can LIST pipe names after that: the name is a speed
62
+ * bump for a squatter, never the protection — the handshake is). Keyed by port, as the POSIX socket is. */
63
+ export function pipeName(token: string, port: number): string {
64
+ return `\\\\.\\pipe\\designtwin-${port}-${hmac(ipcKey(token), "pipe-name|" + port).toString("hex").slice(0, 32)}`;
65
+ }
66
+
67
+ /** The MAC `role` sends over the two nonces, hex. */
68
+ export function proof(auth: IpcAuth, role: "client" | "server", ns: string, nc: string): string {
69
+ return hmac(auth.key, `${role}\0${auth.name}\0${ns}\0${nc}`).toString("hex");
70
+ }
71
+
72
+ const HEX64 = /^[0-9a-f]{64}$/;
73
+ const nonce = (): string => crypto.randomBytes(32).toString("hex");
74
+
75
+ /** Is `got` the MAC `want` (both hex) — shape-checked, then compared in constant time on the 32 bytes. */
76
+ export function macEqual(got: unknown, want: string): boolean {
77
+ if (typeof got !== "string" || !HEX64.test(got) || !HEX64.test(want)) return false;
78
+ return crypto.timingSafeEqual(Buffer.from(got, "hex"), Buffer.from(want, "hex"));
79
+ }
80
+
81
+ /** One side's wiring: `write` sends a line (the newline is added here), `sink` receives every chunk once
82
+ * the peer has proved the key (starting with whatever followed the last handshake line), `fail` is called
83
+ * at most once, on any failure — the caller destroys the connection and says nothing to the peer. */
84
+ export interface HandshakeIo {
85
+ write: (line: string) => void;
86
+ sink: (chunk: string) => void;
87
+ fail: (why: string) => void;
88
+ timeoutMs?: number;
89
+ /** Fixed nonce, for the tests' known-answer vectors only. */
90
+ nonce?: string;
91
+ }
92
+
93
+ /** The connection's data handler for one side, already armed (its timeout runs from this call). Until the
94
+ * peer has proved the key every byte is the handshake's — capped, line by line; after it, chunks go
95
+ * straight to `sink`. `onLine` handles one handshake line and returns true when the handshake is done. */
96
+ function gate(io: HandshakeIo, onLine: (line: string) => boolean | "fail"): { data: (chunk: string) => void; stop: () => void } {
97
+ let buf = "";
98
+ let state: "auth" | "open" | "failed" = "auth";
99
+ // The failure waits one more turn of the loop (setImmediate): a daemon whose loop was blocked (serializing a
100
+ // big export) wakes with this timer due AND the peer's proof already waiting to be read — the timers run
101
+ // first, and the read must get its chance before an honest peer is dropped for the daemon's own stall.
102
+ const t = setTimeout(() => setImmediate(() => fail("timed out")), io.timeoutMs ?? AUTH_TIMEOUT_MS);
103
+ // unref: a half-open handshake must never be what keeps a process alive.
104
+ t.unref?.();
105
+ function fail(why: string): void {
106
+ if (state !== "auth") return;
107
+ state = "failed";
108
+ clearTimeout(t);
109
+ io.fail(why);
110
+ }
111
+ return {
112
+ stop: () => { if (state === "auth") { state = "failed"; clearTimeout(t); } },
113
+ data: (chunk: string) => {
114
+ if (state === "open") return io.sink(chunk);
115
+ if (state === "failed") return;
116
+ buf += chunk;
117
+ // The cap counts what is buffered, so a peer cannot stream megabytes by never sending a newline.
118
+ if (buf.length > AUTH_MAX_BYTES) return fail("too much data before the handshake finished");
119
+ let i;
120
+ while (state === "auth" && (i = buf.indexOf("\n")) >= 0) {
121
+ const line = buf.slice(0, i);
122
+ buf = buf.slice(i + 1);
123
+ const r = onLine(line);
124
+ if (r === "fail") return fail("bad handshake");
125
+ if (r) {
126
+ state = "open";
127
+ clearTimeout(t);
128
+ const rest = buf;
129
+ buf = "";
130
+ if (rest) io.sink(rest);
131
+ }
132
+ }
133
+ },
134
+ };
135
+ }
136
+
137
+ const parse = (line: string): Record<string, unknown> | null => {
138
+ try { const v = JSON.parse(line) as unknown; return isRecord(v) ? v : null; } catch { return null; }
139
+ };
140
+
141
+ /** The server's side: writes the hello at once, then waits for the client's proof, answers with its own,
142
+ * and opens. Returns the connection's data handler, and `stop` (the connection closed: drop the timer). */
143
+ export function serverHandshake(auth: IpcAuth, io: HandshakeIo): { data: (chunk: string) => void; stop: () => void } {
144
+ const ns = io.nonce ?? nonce();
145
+ const g = gate(io, (line) => {
146
+ const m = parse(line);
147
+ if (!m || typeof m.nc !== "string" || !HEX64.test(m.nc)) return "fail";
148
+ if (!macEqual(m.mac, proof(auth, "client", ns, m.nc))) return "fail";
149
+ io.write(JSON.stringify({ mac: proof(auth, "server", ns, m.nc) }));
150
+ return true;
151
+ });
152
+ io.write(JSON.stringify({ dtwin: AUTH_PROTO, ns }));
153
+ return g;
154
+ }
155
+
156
+ /** The client's side, plus `ready`: called once the server has proved the key — the moment the caller may
157
+ * send its request. Nothing is written before the server's hello arrives. */
158
+ export function clientHandshake(auth: IpcAuth, io: HandshakeIo & { ready: () => void }): { data: (chunk: string) => void; stop: () => void } {
159
+ const nc = io.nonce ?? nonce();
160
+ let ns: string | null = null;
161
+ return gate(io, (line) => {
162
+ const m = parse(line);
163
+ if (!m) return "fail";
164
+ if (ns === null) {
165
+ if (m.dtwin !== AUTH_PROTO || typeof m.ns !== "string" || !HEX64.test(m.ns)) return "fail";
166
+ ns = m.ns;
167
+ io.write(JSON.stringify({ nc, mac: proof(auth, "client", ns, nc) }));
168
+ return false;
169
+ }
170
+ if (!macEqual(m.mac, proof(auth, "server", ns, nc))) return "fail";
171
+ io.ready();
172
+ return true;
173
+ });
174
+ }