@ggui-ai/mcp-server 0.1.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -0
  3. package/dist/admin-blueprints-transport.d.ts +114 -0
  4. package/dist/admin-blueprints-transport.d.ts.map +1 -0
  5. package/dist/admin-blueprints-transport.js +118 -0
  6. package/dist/admin-oauth-providers-transport.d.ts +40 -0
  7. package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
  8. package/dist/admin-oauth-providers-transport.js +263 -0
  9. package/dist/auth.d.ts +39 -0
  10. package/dist/auth.d.ts.map +1 -0
  11. package/dist/auth.js +75 -0
  12. package/dist/build-mcp.d.ts +128 -0
  13. package/dist/build-mcp.d.ts.map +1 -0
  14. package/dist/build-mcp.js +113 -0
  15. package/dist/code-store-fs.d.ts +19 -0
  16. package/dist/code-store-fs.d.ts.map +1 -0
  17. package/dist/code-store-fs.js +98 -0
  18. package/dist/console-auth.d.ts +139 -0
  19. package/dist/console-auth.d.ts.map +1 -0
  20. package/dist/console-auth.js +102 -0
  21. package/dist/console-cache.d.ts +78 -0
  22. package/dist/console-cache.d.ts.map +1 -0
  23. package/dist/console-cache.js +105 -0
  24. package/dist/console-headers.d.ts +124 -0
  25. package/dist/console-headers.d.ts.map +1 -0
  26. package/dist/console-headers.js +49 -0
  27. package/dist/console-llm-trace.d.ts +66 -0
  28. package/dist/console-llm-trace.d.ts.map +1 -0
  29. package/dist/console-llm-trace.js +105 -0
  30. package/dist/console-payloads.d.ts +67 -0
  31. package/dist/console-payloads.d.ts.map +1 -0
  32. package/dist/console-payloads.js +105 -0
  33. package/dist/console-theme-routes.d.ts +111 -0
  34. package/dist/console-theme-routes.d.ts.map +1 -0
  35. package/dist/console-theme-routes.js +202 -0
  36. package/dist/console-timeline.d.ts +45 -0
  37. package/dist/console-timeline.d.ts.map +1 -0
  38. package/dist/console-timeline.js +169 -0
  39. package/dist/console-validator.d.ts +67 -0
  40. package/dist/console-validator.d.ts.map +1 -0
  41. package/dist/console-validator.js +105 -0
  42. package/dist/console-welcome.d.ts +7 -0
  43. package/dist/console-welcome.d.ts.map +1 -0
  44. package/dist/console-welcome.js +221 -0
  45. package/dist/csrf-middleware.d.ts +55 -0
  46. package/dist/csrf-middleware.d.ts.map +1 -0
  47. package/dist/csrf-middleware.js +138 -0
  48. package/dist/email-login.d.ts +174 -0
  49. package/dist/email-login.d.ts.map +1 -0
  50. package/dist/email-login.js +254 -0
  51. package/dist/email-resend.d.ts +29 -0
  52. package/dist/email-resend.d.ts.map +1 -0
  53. package/dist/email-resend.js +71 -0
  54. package/dist/email-sender-from-env.d.ts +34 -0
  55. package/dist/email-sender-from-env.d.ts.map +1 -0
  56. package/dist/email-sender-from-env.js +112 -0
  57. package/dist/email-smtp.d.ts +42 -0
  58. package/dist/email-smtp.d.ts.map +1 -0
  59. package/dist/email-smtp.js +81 -0
  60. package/dist/index.d.ts +102 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +122 -0
  63. package/dist/instructions-presets.d.ts +112 -0
  64. package/dist/instructions-presets.d.ts.map +1 -0
  65. package/dist/instructions-presets.js +195 -0
  66. package/dist/llm-backed-negotiator.d.ts +178 -0
  67. package/dist/llm-backed-negotiator.d.ts.map +1 -0
  68. package/dist/llm-backed-negotiator.js +579 -0
  69. package/dist/logger.d.ts +23 -0
  70. package/dist/logger.d.ts.map +1 -0
  71. package/dist/logger.js +41 -0
  72. package/dist/mcp-apps-inbound.d.ts +86 -0
  73. package/dist/mcp-apps-inbound.d.ts.map +1 -0
  74. package/dist/mcp-apps-inbound.js +278 -0
  75. package/dist/mcp-apps-outbound.d.ts +448 -0
  76. package/dist/mcp-apps-outbound.d.ts.map +1 -0
  77. package/dist/mcp-apps-outbound.js +1163 -0
  78. package/dist/mcp-mounts.d.ts +239 -0
  79. package/dist/mcp-mounts.d.ts.map +1 -0
  80. package/dist/mcp-mounts.js +222 -0
  81. package/dist/oauth-login-types.d.ts +160 -0
  82. package/dist/oauth-login-types.d.ts.map +1 -0
  83. package/dist/oauth-login-types.js +9 -0
  84. package/dist/oauth-login.d.ts +77 -0
  85. package/dist/oauth-login.d.ts.map +1 -0
  86. package/dist/oauth-login.js +455 -0
  87. package/dist/oauth-providers/github.d.ts +17 -0
  88. package/dist/oauth-providers/github.d.ts.map +1 -0
  89. package/dist/oauth-providers/github.js +89 -0
  90. package/dist/oauth-providers/google.d.ts +18 -0
  91. package/dist/oauth-providers/google.d.ts.map +1 -0
  92. package/dist/oauth-providers/google.js +59 -0
  93. package/dist/oauth-providers-store.d.ts +32 -0
  94. package/dist/oauth-providers-store.d.ts.map +1 -0
  95. package/dist/oauth-providers-store.js +291 -0
  96. package/dist/oauth.d.ts +347 -0
  97. package/dist/oauth.d.ts.map +1 -0
  98. package/dist/oauth.js +686 -0
  99. package/dist/pairing-transport.d.ts +99 -0
  100. package/dist/pairing-transport.d.ts.map +1 -0
  101. package/dist/pairing-transport.js +223 -0
  102. package/dist/rate-limit-middleware.d.ts +36 -0
  103. package/dist/rate-limit-middleware.d.ts.map +1 -0
  104. package/dist/rate-limit-middleware.js +57 -0
  105. package/dist/render-gate.d.ts +87 -0
  106. package/dist/render-gate.d.ts.map +1 -0
  107. package/dist/render-gate.js +77 -0
  108. package/dist/render-rate-limit.d.ts +59 -0
  109. package/dist/render-rate-limit.d.ts.map +1 -0
  110. package/dist/render-rate-limit.js +73 -0
  111. package/dist/render-signing.d.ts +98 -0
  112. package/dist/render-signing.d.ts.map +1 -0
  113. package/dist/render-signing.js +113 -0
  114. package/dist/request-context.d.ts +113 -0
  115. package/dist/request-context.d.ts.map +1 -0
  116. package/dist/request-context.js +154 -0
  117. package/dist/reserved-validators.d.ts +22 -0
  118. package/dist/reserved-validators.d.ts.map +1 -0
  119. package/dist/reserved-validators.js +101 -0
  120. package/dist/schema-compat.d.ts +167 -0
  121. package/dist/schema-compat.d.ts.map +1 -0
  122. package/dist/schema-compat.js +187 -0
  123. package/dist/security-headers-middleware.d.ts +38 -0
  124. package/dist/security-headers-middleware.d.ts.map +1 -0
  125. package/dist/security-headers-middleware.js +30 -0
  126. package/dist/server.d.ts +2060 -0
  127. package/dist/server.d.ts.map +1 -0
  128. package/dist/server.js +6338 -0
  129. package/dist/session-channel.d.ts +651 -0
  130. package/dist/session-channel.d.ts.map +1 -0
  131. package/dist/session-channel.js +1756 -0
  132. package/dist/storage.d.ts +89 -0
  133. package/dist/storage.d.ts.map +1 -0
  134. package/dist/storage.js +171 -0
  135. package/dist/thread-transport.d.ts +118 -0
  136. package/dist/thread-transport.d.ts.map +1 -0
  137. package/dist/thread-transport.js +478 -0
  138. package/dist/user-session-auth.d.ts +167 -0
  139. package/dist/user-session-auth.d.ts.map +1 -0
  140. package/dist/user-session-auth.js +148 -0
  141. package/package.json +76 -0
@@ -0,0 +1,221 @@
1
+ const ESCAPE_MAP = {
2
+ '&': '&',
3
+ '<': '&lt;',
4
+ '>': '&gt;',
5
+ '"': '&quot;',
6
+ "'": '&#39;',
7
+ };
8
+ const escapeHtml = (raw) => raw.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch] ?? ch);
9
+ const operatorBlock = (op) => {
10
+ if (!op)
11
+ return '';
12
+ const { name, url, tagline, contact } = op;
13
+ if (!name && !url && !tagline && !contact)
14
+ return '';
15
+ const heading = name
16
+ ? url
17
+ ? `<a class="op-link" href="${escapeHtml(url)}" rel="noopener noreferrer">${escapeHtml(name)}</a>`
18
+ : escapeHtml(name)
19
+ : url
20
+ ? `<a class="op-link" href="${escapeHtml(url)}" rel="noopener noreferrer">${escapeHtml(url)}</a>`
21
+ : '';
22
+ const taglineLine = tagline
23
+ ? `<p class="op-tagline">${escapeHtml(tagline)}</p>`
24
+ : '';
25
+ const contactLine = contact
26
+ ? `<p class="op-contact"><a class="cta" href="mailto:${escapeHtml(contact)}">${escapeHtml(contact)}</a></p>`
27
+ : '';
28
+ return `
29
+ <section class="block">
30
+ <p class="eyebrow">Operated by</p>
31
+ ${heading ? `<p class="op-name">${heading}</p>` : ''}
32
+ ${taglineLine}
33
+ ${contactLine}
34
+ </section>`;
35
+ };
36
+ // Inline ggui Wordmark — paper/ink primitives per brand-kit v1.0.
37
+ // Mirrors `apps/landing-ggui-ai/src/components/Wordmark.tsx`. Kept
38
+ // inline (not externally fetched) so the welcome page renders without
39
+ // any network round-trips.
40
+ const WORDMARK_SVG = `<svg viewBox="0 0 224 50" width="112" height="25" aria-label="ggui — generative graphical user interface">
41
+ <path d="M 0 0 H 50 V 25 H 25 V 50 H 0 Z" class="chrome-fill" />
42
+ <rect x="33" y="33" width="17" height="17" class="ink-fill" />
43
+ <path d="M 58 0 H 108 V 25 H 83 V 50 H 58 Z" class="ink-fill" />
44
+ <rect x="91" y="33" width="17" height="17" class="chrome-fill" />
45
+ <path d="M 141 50 C 154.807 50 166 38.8071 166 25 V 0 H 116 V 25 C 116 38.8071 127.193 50 141 50 Z" class="ink-fill" />
46
+ <rect x="174" y="0" width="50" height="50" class="chrome-fill" />
47
+ </svg>`;
48
+ export const renderWelcomeHtml = (inputs, serverName) => {
49
+ const appName = inputs.appName ?? serverName;
50
+ const title = appName ? `${appName} — ggui` : 'ggui';
51
+ return `<!doctype html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="utf-8" />
55
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
56
+ <title>${escapeHtml(title)}</title>
57
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
58
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
59
+ <link
60
+ rel="stylesheet"
61
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist+Mono:wght@400;500;700&display=swap"
62
+ />
63
+ <style>
64
+ :root {
65
+ color-scheme: light dark;
66
+ --paper: #f4f3ed;
67
+ --paper-2: #ebe9e1;
68
+ --chrome: #d9d9d9;
69
+ --chrome-2: #e4e4e2;
70
+ --ink: #292929;
71
+ --ink-2: #3d3d3d;
72
+ --ink-3: #5a5a5a;
73
+ --ink-4: #8c8c93;
74
+ --line: #d6d4cb;
75
+ --font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
76
+ --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
77
+ }
78
+ @media (prefers-color-scheme: dark) {
79
+ :root {
80
+ --paper: #1a1a1a;
81
+ --paper-2: #1f1f1f;
82
+ --chrome: #5a5a5a;
83
+ --chrome-2: #3d3d3d;
84
+ --ink: #f4f3ed;
85
+ --ink-2: #ebe9e1;
86
+ --ink-3: #d9d9d9;
87
+ --ink-4: #8c8c93;
88
+ --line: #3d3d3d;
89
+ }
90
+ }
91
+ * { box-sizing: border-box; }
92
+ html, body {
93
+ margin: 0;
94
+ background: var(--paper);
95
+ color: var(--ink);
96
+ font-family: var(--font-sans);
97
+ font-size: 14px;
98
+ line-height: 1.5;
99
+ font-feature-settings: "ss01", "cv11";
100
+ -webkit-font-smoothing: antialiased;
101
+ }
102
+ a { color: inherit; }
103
+ .chrome-fill { fill: var(--chrome); }
104
+ .ink-fill { fill: var(--ink); }
105
+ main {
106
+ max-width: 560px;
107
+ margin: 0 auto;
108
+ padding: 64px 24px 48px;
109
+ }
110
+ .mark { margin-bottom: 40px; }
111
+ .mark svg { display: block; }
112
+ .eyebrow {
113
+ font-family: var(--font-mono);
114
+ font-size: 11px;
115
+ letter-spacing: 0.18em;
116
+ text-transform: uppercase;
117
+ color: var(--ink-4);
118
+ margin: 0 0 8px;
119
+ }
120
+ .title {
121
+ font-size: 22px;
122
+ font-weight: 600;
123
+ letter-spacing: -0.01em;
124
+ margin: 0 0 4px;
125
+ }
126
+ .subtitle {
127
+ font-family: var(--font-mono);
128
+ font-size: 13px;
129
+ color: var(--ink-3);
130
+ margin: 0;
131
+ }
132
+ .block {
133
+ margin-top: 32px;
134
+ padding-top: 24px;
135
+ border-top: 1px solid var(--line);
136
+ }
137
+ .op-name { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
138
+ .op-tagline { color: var(--ink-3); margin: 0; }
139
+ .op-contact { margin: 12px 0 0; }
140
+ .op-link {
141
+ text-decoration: none;
142
+ border-bottom: 1px solid var(--line);
143
+ padding-bottom: 1px;
144
+ transition: border-color 120ms ease;
145
+ }
146
+ .op-link:hover { border-color: var(--ink); }
147
+ .surfaces { display: flex; flex-direction: column; gap: 12px; }
148
+ .surface {
149
+ padding: 14px 16px;
150
+ background: var(--paper-2);
151
+ border: 1px solid var(--line);
152
+ font-family: var(--font-mono);
153
+ font-size: 13px;
154
+ line-height: 1.6;
155
+ }
156
+ .surface-label {
157
+ display: block;
158
+ font-family: var(--font-sans);
159
+ font-size: 11px;
160
+ letter-spacing: 0.06em;
161
+ text-transform: uppercase;
162
+ color: var(--ink-4);
163
+ margin-bottom: 4px;
164
+ }
165
+ .surface code {
166
+ color: var(--ink);
167
+ font-family: inherit;
168
+ }
169
+ .surface .desc {
170
+ display: block;
171
+ margin-top: 6px;
172
+ font-family: var(--font-sans);
173
+ color: var(--ink-3);
174
+ font-size: 13px;
175
+ }
176
+ .login {
177
+ margin-top: 40px;
178
+ padding-top: 24px;
179
+ border-top: 1px solid var(--line);
180
+ }
181
+ .cta {
182
+ font-family: var(--font-mono);
183
+ font-size: 13px;
184
+ letter-spacing: 0.04em;
185
+ color: var(--ink);
186
+ text-decoration: none;
187
+ border-bottom: 1px solid var(--line);
188
+ padding-bottom: 2px;
189
+ transition: border-color 120ms ease;
190
+ }
191
+ .cta:hover { border-color: var(--ink); }
192
+ </style>
193
+ </head>
194
+ <body>
195
+ <main>
196
+ <div class="mark">${WORDMARK_SVG}</div>
197
+ <p class="eyebrow">App</p>
198
+ <h1 class="title">${escapeHtml(appName || 'ggui')}</h1>
199
+ <p class="subtitle">A ggui server.</p>
200
+ ${operatorBlock(inputs.operator)}
201
+ <section class="block">
202
+ <p class="eyebrow">Public surfaces</p>
203
+ <div class="surfaces">
204
+ <div class="surface">
205
+ <span class="surface-label">Session viewer</span>
206
+ <code>/s/&lt;short-code&gt;</code>
207
+ <span class="desc">Open the live session your agent linked you to.</span>
208
+ </div>
209
+ <div class="surface">
210
+ <span class="surface-label">Blueprint preview</span>
211
+ <code>/preview/&lt;id&gt;</code>
212
+ <span class="desc">View a published UI blueprint by id.</span>
213
+ </div>
214
+ </div>
215
+ </section>
216
+ <p class="login"><a class="cta" href="/admin-login">Operator login &rarr;</a></p>
217
+ </main>
218
+ </body>
219
+ </html>
220
+ `;
221
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * CSRF middleware.
3
+ *
4
+ * Double-submit token pattern: token = `${randomB64url}.${hmacB64url}`,
5
+ * HMAC-SHA256 over `${random}|${sessionBearer ?? ''}` with the
6
+ * server-provided secret. Binding the HMAC to the current session
7
+ * bearer prevents an anonymous-session token from being replayed
8
+ * after the user logs in (post-login the cookie value differs, so
9
+ * the recomputed HMAC won't match).
10
+ *
11
+ * The middleware skips:
12
+ *
13
+ * - Safe HTTP methods (GET / HEAD / OPTIONS).
14
+ * - Operator-configured `skipPaths` (default: `/pair`,
15
+ * `/ggui/email-login/start`) — pre-auth endpoints that exist
16
+ * BEFORE the user has a session.
17
+ * - Programmatic Bearer requests with no session cookie. CSRF is a
18
+ * browser-context defense; the claude.ai connector posts pre-auth
19
+ * bearers without a same-origin cookie context.
20
+ *
21
+ * On every GET response that has a session cookie, the middleware
22
+ * also sets `X-Ggui-CSRF-Token` so SPA bootstrap reads the latest
23
+ * token straight off the response without an extra round-trip.
24
+ * Anonymous GETs get a token bound to empty-bearer for pre-auth
25
+ * POSTs (e.g. magic-link start).
26
+ */
27
+ import type { Express, RequestHandler } from 'express';
28
+ import type { Logger } from './logger.js';
29
+ export declare const CSRF_HEADER_NAME = "X-Ggui-CSRF";
30
+ export declare const CSRF_RESPONSE_HEADER_NAME = "X-Ggui-CSRF-Token";
31
+ export declare const DEFAULT_CSRF_TOKEN_PATH = "/ggui/csrf-token";
32
+ export interface CsrfMiddlewareOptions {
33
+ readonly secret: string;
34
+ readonly logger: Logger;
35
+ readonly skipPaths?: ReadonlyArray<string>;
36
+ }
37
+ export interface MintCsrfTokenInput {
38
+ readonly sessionBearer: string | null;
39
+ readonly secret: string;
40
+ }
41
+ /** Mint a fresh CSRF token bound to `sessionBearer`. Format:
42
+ * `${randomBase64url}.${hmacBase64url}`. */
43
+ export declare function mintCsrfToken(input: MintCsrfTokenInput): string;
44
+ /** Express middleware: enforces CSRF on state-changing requests + sets
45
+ * `X-Ggui-CSRF-Token` on safe responses. */
46
+ export declare function createCsrfMiddleware(opts: CsrfMiddlewareOptions): RequestHandler;
47
+ export interface MountCsrfTokenRouteOptions {
48
+ readonly secret: string;
49
+ readonly path?: string;
50
+ }
51
+ /** Mount `GET /ggui/csrf-token` returning `{ token, expiresAt }`. SPAs
52
+ * call this on app boot when they prefer an explicit fetch over
53
+ * reading `X-Ggui-CSRF-Token` off another response. */
54
+ export declare function mountCsrfTokenRoute(app: Express, opts: MountCsrfTokenRouteOptions): void;
55
+ //# sourceMappingURL=csrf-middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf-middleware.d.ts","sourceRoot":"","sources":["../src/csrf-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAW,cAAc,EAAY,MAAM,SAAS,CAAC;AAG1E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,eAAO,MAAM,gBAAgB,gBAAgB,CAAC;AAC9C,eAAO,MAAM,yBAAyB,sBAAsB,CAAC;AAC7D,eAAO,MAAM,uBAAuB,qBAAqB,CAAC;AAc1D,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,CAAC,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAiBD;6CAC6C;AAC7C,wBAAgB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,MAAM,CAI/D;AAmCD;6CAC6C;AAC7C,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,qBAAqB,GAAG,cAAc,CAiEhF;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;wDAEwD;AACxD,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,0BAA0B,GAC/B,IAAI,CAUN"}
@@ -0,0 +1,138 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { readUserSessionCookie } from './user-session-auth.js';
3
+ export const CSRF_HEADER_NAME = 'X-Ggui-CSRF';
4
+ export const CSRF_RESPONSE_HEADER_NAME = 'X-Ggui-CSRF-Token';
5
+ export const DEFAULT_CSRF_TOKEN_PATH = '/ggui/csrf-token';
6
+ // Anonymous flow-starters — no authenticated-state mutation, so CSRF
7
+ // adds no protection. Same threat model as `/pair` (rate-limited,
8
+ // idempotent, the magic link goes to the typed email not the
9
+ // attacker). Verify is a GET and auto-skipped via SAFE_METHODS.
10
+ const DEFAULT_SKIP_PATHS = [
11
+ '/pair',
12
+ '/ggui/email-login/start',
13
+ ];
14
+ const RANDOM_BYTES = 16;
15
+ /** Header-side TTL hint (5 min). The server doesn't cache tokens; the
16
+ * HMAC itself binds validity to the current session bearer. */
17
+ const TOKEN_LIFETIME_MS = 5 * 60 * 1000;
18
+ function base64url(bytes) {
19
+ return bytes
20
+ .toString('base64')
21
+ .replace(/=+$/g, '')
22
+ .replace(/\+/g, '-')
23
+ .replace(/\//g, '_');
24
+ }
25
+ function computeHmac(random, sessionBearer, secret) {
26
+ const mac = createHmac('sha256', secret)
27
+ .update(`${random}|${sessionBearer ?? ''}`)
28
+ .digest();
29
+ return base64url(mac);
30
+ }
31
+ /** Mint a fresh CSRF token bound to `sessionBearer`. Format:
32
+ * `${randomBase64url}.${hmacBase64url}`. */
33
+ export function mintCsrfToken(input) {
34
+ const random = base64url(randomBytes(RANDOM_BYTES));
35
+ const sig = computeHmac(random, input.sessionBearer, input.secret);
36
+ return `${random}.${sig}`;
37
+ }
38
+ function validateCsrfToken(token, sessionBearer, secret) {
39
+ const parts = token.split('.');
40
+ if (parts.length !== 2)
41
+ return false;
42
+ const [random, sig] = parts;
43
+ if (!random || !sig)
44
+ return false;
45
+ const expected = computeHmac(random, sessionBearer, secret);
46
+ // timingSafeEqual requires equal-length buffers; cheap length check
47
+ // first to avoid throwing on malformed input.
48
+ if (expected.length !== sig.length)
49
+ return false;
50
+ return timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(sig, 'utf8'));
51
+ }
52
+ function isSafeMethod(method) {
53
+ return method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
54
+ }
55
+ function pathMatches(path, skipPaths) {
56
+ for (const skip of skipPaths) {
57
+ if (path === skip)
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /** Express middleware: enforces CSRF on state-changing requests + sets
63
+ * `X-Ggui-CSRF-Token` on safe responses. */
64
+ export function createCsrfMiddleware(opts) {
65
+ const skipPaths = opts.skipPaths ?? DEFAULT_SKIP_PATHS;
66
+ const reqLogger = opts.logger.child({ middleware: 'csrf' });
67
+ return (req, res, next) => {
68
+ const sessionBearer = readUserSessionCookie(req);
69
+ // On safe-method responses, set the response header so SPAs read
70
+ // the freshly-minted token without an extra round-trip.
71
+ if (isSafeMethod(req.method)) {
72
+ const token = mintCsrfToken({ sessionBearer, secret: opts.secret });
73
+ res.setHeader(CSRF_RESPONSE_HEADER_NAME, token);
74
+ next();
75
+ return;
76
+ }
77
+ if (pathMatches(req.path, skipPaths)) {
78
+ next();
79
+ return;
80
+ }
81
+ // CSRF defends ONLY browser-cookie sessions. If there's no
82
+ // user-session cookie on the request, the auth surface is one
83
+ // of: (a) programmatic Bearer (claude.ai connector) — no
84
+ // cross-origin cookie to ride; (b) fully unauthenticated — the
85
+ // downstream auth gate will 401. Skip CSRF in both cases so
86
+ // CSRF doesn't masquerade as the auth gate (which produces
87
+ // misleading 403s on 401-deserving requests).
88
+ if (sessionBearer === null) {
89
+ // No cookie: cookieAuthMiddleware may have synthesized
90
+ // `Authorization: Bearer <cookie>` upstream, but we read the
91
+ // cookie directly above so the synthesis doesn't confuse the
92
+ // skip rule. Bearer-without-cookie programmatic requests
93
+ // (claude.ai connector) and fully unauthenticated requests
94
+ // both fall through here.
95
+ next();
96
+ return;
97
+ }
98
+ const headerValueRaw = req.headers[CSRF_HEADER_NAME.toLowerCase()];
99
+ const headerValue = Array.isArray(headerValueRaw)
100
+ ? headerValueRaw[0]
101
+ : headerValueRaw;
102
+ if (typeof headerValue !== 'string' || headerValue.length === 0) {
103
+ reqLogger.warn('csrf_missing', { path: req.path, method: req.method });
104
+ res.status(403).json({
105
+ error: {
106
+ code: 'csrf_required',
107
+ message: 'Missing or invalid CSRF token.',
108
+ },
109
+ });
110
+ return;
111
+ }
112
+ if (!validateCsrfToken(headerValue, sessionBearer, opts.secret)) {
113
+ reqLogger.warn('csrf_mismatch', { path: req.path, method: req.method });
114
+ res.status(403).json({
115
+ error: {
116
+ code: 'csrf_required',
117
+ message: 'Missing or invalid CSRF token.',
118
+ },
119
+ });
120
+ return;
121
+ }
122
+ next();
123
+ };
124
+ }
125
+ /** Mount `GET /ggui/csrf-token` returning `{ token, expiresAt }`. SPAs
126
+ * call this on app boot when they prefer an explicit fetch over
127
+ * reading `X-Ggui-CSRF-Token` off another response. */
128
+ export function mountCsrfTokenRoute(app, opts) {
129
+ const path = opts.path ?? DEFAULT_CSRF_TOKEN_PATH;
130
+ app.get(path, (req, res) => {
131
+ const sessionBearer = readUserSessionCookie(req);
132
+ const token = mintCsrfToken({ sessionBearer, secret: opts.secret });
133
+ res.status(200).json({
134
+ token,
135
+ expiresAt: Date.now() + TOKEN_LIFETIME_MS,
136
+ });
137
+ });
138
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Email magic-link login.
3
+ *
4
+ * Passwordless flow: user enters their email → server mints a single-
5
+ * use token → server emails the user a `verify` URL → user clicks
6
+ * → server consumes the token + mints a session bearer → 302 to
7
+ * `nextPath`. Two routes (`/start`, `/verify`) plus a public-readable
8
+ * `/config` so `/login` can know whether to render the form.
9
+ *
10
+ * Identity model: `userId = "email:" + normalizedEmail`. Different
11
+ * email = different identity (no email-rotation linking, same
12
+ * stance as oauth-login-types.ts). Lowercase + trim is the only
13
+ * normalization — Gmail-dot/plus-tag tricks deliberately stay
14
+ * unmodified so `alice+ggui@gmail.com` is a distinct identity from
15
+ * `alice@gmail.com` (the user is in control of which inbox the
16
+ * link goes to).
17
+ *
18
+ * Security boundary:
19
+ *
20
+ * - Tokens are 256-bit URL-safe, single-use, ≤15 min TTL.
21
+ * Consume-or-reject-atomically per request — a successful verify
22
+ * deletes the token before the response goes out so a refresh
23
+ * can't replay.
24
+ * - `/start` ALWAYS returns 200, regardless of whether the email
25
+ * was deliverable or even valid. This prevents email-enumeration
26
+ * attacks (an attacker probing for "is alice@example.com a known
27
+ * user" would otherwise see a different status / latency).
28
+ * - The verify URL goes ONLY to the user's inbox — never echoed
29
+ * back to the start-call response. The server logs it once for
30
+ * audit, but the user picks it up out-of-band.
31
+ * - `nextPath` is sanitized to same-origin relative paths. An
32
+ * attacker can't craft a magic link that bounces a verified
33
+ * session through to `https://evil/`.
34
+ */
35
+ import type { Express } from 'express';
36
+ import type { AuditSink, AuthAdapter } from '@ggui-ai/mcp-server-core';
37
+ import type { Logger } from './logger.js';
38
+ /**
39
+ * Public route paths. Operators may override via
40
+ * `EmailLoginRoutesOptions.{startPath,verifyPath,configPath}` for
41
+ * test or sub-mount scenarios.
42
+ */
43
+ export declare const DEFAULT_EMAIL_LOGIN_START_PATH = "/ggui/email-login/start";
44
+ export declare const DEFAULT_EMAIL_LOGIN_VERIFY_PATH = "/ggui/email-login/verify";
45
+ export declare const DEFAULT_EMAIL_LOGIN_CONFIG_PATH = "/ggui/email-login/config";
46
+ /**
47
+ * Outbound email contract. The default `ConsoleEmailSender` just
48
+ * logs to the server logger — useful for `ggui serve` development
49
+ * (the magic link shows up in the terminal). Reference
50
+ * implementations (SMTP via nodemailer, Resend, AWS SES) live in
51
+ * adapter packages so the OSS core stays dep-light.
52
+ *
53
+ * @public
54
+ */
55
+ export interface EmailSender {
56
+ send(message: EmailMessage): Promise<void>;
57
+ }
58
+ /**
59
+ * Plain-text first, HTML second — every transactional-email
60
+ * service we'd plausibly target accepts this shape. `from` lets the
61
+ * caller override the default `fromAddress` per message; absent
62
+ * means the server-wide default.
63
+ *
64
+ * @public
65
+ */
66
+ export interface EmailMessage {
67
+ readonly to: string;
68
+ readonly from?: string;
69
+ readonly subject: string;
70
+ readonly text: string;
71
+ readonly html?: string;
72
+ }
73
+ /**
74
+ * Fallback sender that logs every "sent" email to the configured
75
+ * logger at info level. The verify URL appears in the logs so an
76
+ * operator running `ggui serve` locally can copy/paste it without
77
+ * setting up real email infrastructure. Production deploys MUST
78
+ * swap this for a real sender — the fallback is a developer-mode
79
+ * convenience, not a security control.
80
+ *
81
+ * @public
82
+ */
83
+ export declare class ConsoleEmailSender implements EmailSender {
84
+ private readonly logger;
85
+ constructor(logger?: Logger);
86
+ send(message: EmailMessage): Promise<void>;
87
+ }
88
+ /**
89
+ * Storage for outstanding magic-link tokens. Each `mintToken` returns
90
+ * an opaque URL-safe string the caller emails to the user; the user's
91
+ * subsequent click hits `/verify`, which calls `consumeToken` (atomic
92
+ * single-use). The default `InMemoryMagicLinkStore` is per-process
93
+ * and forgets on restart — fine for a single-host OSS server, wrong
94
+ * for multi-host. Cluster deployments swap in a Redis/DDB-backed
95
+ * impl whose contract matches this interface.
96
+ *
97
+ * @public
98
+ */
99
+ export interface MagicLinkStore {
100
+ mintToken(input: MintTokenInput): Promise<string>;
101
+ /**
102
+ * Atomic consume — returns `null` if the token is unknown,
103
+ * already consumed, or expired. Successful consumption MUST
104
+ * delete the token before returning so a replay finds nothing.
105
+ */
106
+ consumeToken(token: string): Promise<MagicLinkRecord | null>;
107
+ }
108
+ export interface MintTokenInput {
109
+ readonly email: string;
110
+ readonly nextPath: string;
111
+ readonly ttlMs: number;
112
+ }
113
+ export interface MagicLinkRecord {
114
+ readonly email: string;
115
+ readonly nextPath: string;
116
+ }
117
+ /**
118
+ * Default in-memory store. Loses tokens on process restart (which
119
+ * is acceptable — pending magic links expire in 15 min anyway, and
120
+ * a restart is itself a "request a new link" prompt to the user).
121
+ *
122
+ * @public
123
+ */
124
+ export declare class InMemoryMagicLinkStore implements MagicLinkStore {
125
+ private readonly records;
126
+ mintToken({ email, nextPath, ttlMs }: MintTokenInput): Promise<string>;
127
+ consumeToken(token: string): Promise<MagicLinkRecord | null>;
128
+ }
129
+ /**
130
+ * Configuration for {@link mountEmailLoginRoutes}.
131
+ *
132
+ * @public
133
+ */
134
+ export interface EmailLoginRoutesOptions {
135
+ readonly sender: EmailSender;
136
+ readonly auth: AuthAdapter;
137
+ readonly logger: Logger;
138
+ /** Public base URL — used to compose the magic-link verify URL. */
139
+ readonly publicBaseUrl: string;
140
+ /**
141
+ * From-address stamped on every email. Format follows RFC 5322:
142
+ * `"display name" <addr@host>` or just `addr@host`. SMTP/Resend
143
+ * typically REQUIRE the address to be on a domain the sender has
144
+ * authenticated; check the sender adapter's docs.
145
+ */
146
+ readonly fromAddress: string;
147
+ /** Override store (e.g., RedisMagicLinkStore for multi-host). */
148
+ readonly store?: MagicLinkStore;
149
+ /** Optional audit sink — fires `auth.email.start/.success/.failure`. */
150
+ readonly auditSink?: AuditSink;
151
+ /** Adds `Secure` to the session cookie. */
152
+ readonly secure?: boolean;
153
+ /** User-session cookie TTL (seconds). */
154
+ readonly ttlSec?: number;
155
+ /** Path overrides (mostly for tests). */
156
+ readonly startPath?: string;
157
+ readonly verifyPath?: string;
158
+ readonly configPath?: string;
159
+ /**
160
+ * Subject line + body templating. Defaults are sensible; override
161
+ * to match brand copy. `{verifyUrl}` is replaced before send.
162
+ */
163
+ readonly subject?: string;
164
+ readonly bodyText?: string;
165
+ readonly bodyHtml?: string;
166
+ }
167
+ /**
168
+ * Mount `POST /start`, `GET /verify`, `GET /config`. Idempotent
169
+ * across multiple calls (later mount wins for the same path).
170
+ *
171
+ * @public
172
+ */
173
+ export declare function mountEmailLoginRoutes(app: Express, opts: EmailLoginRoutesOptions): void;
174
+ //# sourceMappingURL=email-login.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-login.d.ts","sourceRoot":"","sources":["../src/email-login.ts"],"names":[],"mappings":"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"}