@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,99 @@
1
+ /**
2
+ * Pairing transport — HTTP routes that complete a viewer↔server pairing
3
+ * handshake, mint codes on admin trigger, and revoke active pairings.
4
+ *
5
+ * Three routes (all opt-in via `createGguiServer({ pairing })`):
6
+ *
7
+ * POST /pair — public. Body `{code, deviceName}`.
8
+ * Returns `{pairingId, token, serverName,
9
+ * deviceName}`. No auth — pairing IS
10
+ * the bootstrap for future auth.
11
+ *
12
+ * POST /admin/pair/init — admin. Bearer auth → builder only.
13
+ * Returns `{code, codeExpiresAt,
14
+ * serverName}`.
15
+ *
16
+ * POST /admin/pair/:pairingId/revoke
17
+ * — admin. Bearer auth → builder only.
18
+ * Immediate revocation of a minted
19
+ * pairing. Idempotent — revoking
20
+ * a pairingId that does not exist
21
+ * returns the same `{ok: true}`
22
+ * envelope. Delegates to
23
+ * {@link PairingService.revokePairing};
24
+ * `onTokenRevoked` unregisters the
25
+ * token from the active adapter so
26
+ * subsequent `/mcp` calls fail with
27
+ * 401 via the canonical
28
+ * `No valid credentials` envelope.
29
+ *
30
+ * The transport is thin by construction — it delegates every stateful
31
+ * decision to the supplied {@link PairingService}. Error mapping is the
32
+ * only transport-level concern:
33
+ *
34
+ * - Body validation failure → 400 with `{code: 'bad_request', ...}`.
35
+ * - Service throws on mismatched / expired / missing code → 401 with
36
+ * `{code: 'pairing_rejected', ...}`.
37
+ * - Any other service failure → 500 with `{code: 'internal', ...}`.
38
+ *
39
+ * Routes are mounted on the existing Express app the server already
40
+ * owns — no sub-router, no second server surface.
41
+ */
42
+ import type { Express } from 'express';
43
+ import type { AuthAdapter, PairingService } from '@ggui-ai/mcp-server-core';
44
+ import type { Logger } from './logger.js';
45
+ /** Default URL path the pairing-completion route is mounted at. */
46
+ export declare const DEFAULT_PAIRING_PATH = "/pair";
47
+ /**
48
+ * Default URL path the admin-init route is mounted at. Keep prefixed
49
+ * with `/admin/` so operators can firewall the whole family with one
50
+ * rule when fronting the server behind a reverse proxy.
51
+ */
52
+ export declare const DEFAULT_PAIRING_ADMIN_INIT_PATH = "/admin/pair/init";
53
+ /**
54
+ * Default URL-template the admin-revoke route is mounted at. `:pairingId`
55
+ * is the Express route parameter consumed by the handler; operators
56
+ * firewalling behind a reverse proxy should match on the prefix
57
+ * `/admin/pair/` — this template sits under that same umbrella as
58
+ * {@link DEFAULT_PAIRING_ADMIN_INIT_PATH}.
59
+ */
60
+ export declare const DEFAULT_PAIRING_ADMIN_REVOKE_PATH = "/admin/pair/:pairingId/revoke";
61
+ export interface PairingTransportOptions {
62
+ /** Required. The pairing service the routes delegate to. */
63
+ readonly pairing: PairingService;
64
+ /**
65
+ * Required. The same AuthAdapter the `/mcp` and live-channel endpoints
66
+ * use. Gates the admin-init route; MUST resolve to a `builder`
67
+ * identity for init to succeed.
68
+ */
69
+ readonly auth: AuthAdapter;
70
+ /** Structured logger. Child loggers are derived per-route. */
71
+ readonly logger: Logger;
72
+ /**
73
+ * URL path for the public completion route. Defaults to `/pair`.
74
+ */
75
+ readonly path?: string;
76
+ /**
77
+ * URL path for the admin init route. Defaults to
78
+ * `/admin/pair/init`. Pass `null` to disable the route entirely —
79
+ * embedded hosts that trigger `initPairing()` programmatically via
80
+ * `GguiServer.pairingService` may not want an HTTP surface.
81
+ */
82
+ readonly adminInitPath?: string | null;
83
+ /**
84
+ * URL-template for the admin revoke route. Defaults to
85
+ * `/admin/pair/:pairingId/revoke`. Pass `null` to disable the route
86
+ * entirely — embedded hosts that trigger `revokePairing()`
87
+ * programmatically may not want an HTTP surface. Mount symmetry
88
+ * with `adminInitPath`: if a caller disables one, they frequently
89
+ * disable both.
90
+ */
91
+ readonly adminRevokePath?: string | null;
92
+ }
93
+ /**
94
+ * Mount the pairing routes onto an existing Express app. Idempotent is
95
+ * NOT a goal here — call once per server; mounting twice registers the
96
+ * routes twice.
97
+ */
98
+ export declare function mountPairingTransport(app: Express, opts: PairingTransportOptions): void;
99
+ //# sourceMappingURL=pairing-transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-transport.d.ts","sourceRoot":"","sources":["../src/pairing-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,OAAO,KAAK,EAAE,OAAO,EAAqB,MAAM,SAAS,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE5E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,mEAAmE;AACnE,eAAO,MAAM,oBAAoB,UAAU,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,+BAA+B,qBAAqB,CAAC;AAElE;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,kCACb,CAAC;AAElC,MAAM,WAAW,uBAAuB;IACtC,4DAA4D;IAC5D,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;OAEG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC;;;;;;;OAOG;IACH,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1C;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,uBAAuB,GAC5B,IAAI,CAmNN"}
@@ -0,0 +1,223 @@
1
+ import { resolveIdentity, UnauthenticatedError } from './auth.js';
2
+ /** Default URL path the pairing-completion route is mounted at. */
3
+ export const DEFAULT_PAIRING_PATH = '/pair';
4
+ /**
5
+ * Default URL path the admin-init route is mounted at. Keep prefixed
6
+ * with `/admin/` so operators can firewall the whole family with one
7
+ * rule when fronting the server behind a reverse proxy.
8
+ */
9
+ export const DEFAULT_PAIRING_ADMIN_INIT_PATH = '/admin/pair/init';
10
+ /**
11
+ * Default URL-template the admin-revoke route is mounted at. `:pairingId`
12
+ * is the Express route parameter consumed by the handler; operators
13
+ * firewalling behind a reverse proxy should match on the prefix
14
+ * `/admin/pair/` — this template sits under that same umbrella as
15
+ * {@link DEFAULT_PAIRING_ADMIN_INIT_PATH}.
16
+ */
17
+ export const DEFAULT_PAIRING_ADMIN_REVOKE_PATH = '/admin/pair/:pairingId/revoke';
18
+ /**
19
+ * Mount the pairing routes onto an existing Express app. Idempotent is
20
+ * NOT a goal here — call once per server; mounting twice registers the
21
+ * routes twice.
22
+ */
23
+ export function mountPairingTransport(app, opts) {
24
+ const path = opts.path ?? DEFAULT_PAIRING_PATH;
25
+ const adminInitPath = opts.adminInitPath === undefined
26
+ ? DEFAULT_PAIRING_ADMIN_INIT_PATH
27
+ : opts.adminInitPath;
28
+ const adminRevokePath = opts.adminRevokePath === undefined
29
+ ? DEFAULT_PAIRING_ADMIN_REVOKE_PATH
30
+ : opts.adminRevokePath;
31
+ // --- POST /pair ---
32
+ //
33
+ // No auth. Expects JSON `{code, deviceName}`. Either field missing or
34
+ // non-string → 400. Service rejection (bad/expired/consumed code) →
35
+ // 401. Success → 200 with the `PairingCompletion` shape the viewer
36
+ // stores verbatim.
37
+ app.post(path, async (req, res) => {
38
+ const reqLogger = opts.logger.child({ route: 'POST ' + path });
39
+ const body = (req.body ?? {});
40
+ const code = typeof body['code'] === 'string' ? body['code'] : undefined;
41
+ const deviceName = typeof body['deviceName'] === 'string' ? body['deviceName'] : undefined;
42
+ const remoteAddress = req.socket.remoteAddress ?? undefined;
43
+ if (!code || !deviceName) {
44
+ reqLogger.debug?.('pair_bad_request', {
45
+ hasCode: code !== undefined,
46
+ hasDeviceName: deviceName !== undefined,
47
+ });
48
+ res.status(400).json({
49
+ error: {
50
+ code: 'bad_request',
51
+ message: 'POST /pair requires a JSON body with string `code` and `deviceName`.',
52
+ },
53
+ });
54
+ return;
55
+ }
56
+ try {
57
+ const completion = await opts.pairing.completePairing({
58
+ code,
59
+ deviceName,
60
+ ...(remoteAddress ? { remoteAddress } : {}),
61
+ });
62
+ reqLogger.info('pair_completed', {
63
+ pairingId: completion.pairingId,
64
+ deviceName: completion.deviceName,
65
+ });
66
+ res.status(200).json(completion);
67
+ }
68
+ catch (err) {
69
+ // The service's thrown Error messages describe the mismatch
70
+ // precisely but aren't safe to surface verbatim (code values
71
+ // would leak into logs). Collapse to one code + one message.
72
+ reqLogger.warn('pair_rejected', { error: String(err) });
73
+ res.status(401).json({
74
+ error: {
75
+ code: 'pairing_rejected',
76
+ message: 'Pairing code is invalid, expired, or has already been consumed.',
77
+ },
78
+ });
79
+ }
80
+ });
81
+ // --- POST /admin/pair/init ---
82
+ //
83
+ // Bearer-authenticated. Only builder identities may mint codes — the
84
+ // operator triggering a code mint is by definition the server owner.
85
+ // User identities (if any ever land on the OSS tier) are rejected.
86
+ if (adminInitPath) {
87
+ app.post(adminInitPath, async (req, res) => {
88
+ const reqLogger = opts.logger.child({ route: 'POST ' + adminInitPath });
89
+ try {
90
+ const identity = await resolveIdentity(opts.auth, req);
91
+ if (identity.identity.kind !== 'builder') {
92
+ reqLogger.warn('admin_init_forbidden', {
93
+ identityKind: identity.identity.kind,
94
+ });
95
+ res.status(403).json({
96
+ error: {
97
+ code: 'forbidden',
98
+ message: 'POST /admin/pair/init requires a builder identity.',
99
+ },
100
+ });
101
+ return;
102
+ }
103
+ }
104
+ catch (err) {
105
+ if (err instanceof UnauthenticatedError) {
106
+ reqLogger.warn('admin_init_unauthenticated', {
107
+ reason: err.message,
108
+ });
109
+ res.status(401).json({
110
+ error: {
111
+ code: 'unauthenticated',
112
+ message: err.message,
113
+ },
114
+ });
115
+ return;
116
+ }
117
+ reqLogger.error('admin_init_unexpected_error', {
118
+ error: String(err),
119
+ });
120
+ res.status(500).json({
121
+ error: { code: 'internal', message: 'Internal server error' },
122
+ });
123
+ return;
124
+ }
125
+ try {
126
+ const init = await opts.pairing.initPairing();
127
+ reqLogger.info('admin_init_minted', {
128
+ codeExpiresAt: init.codeExpiresAt,
129
+ });
130
+ res.status(200).json(init);
131
+ }
132
+ catch (err) {
133
+ reqLogger.error('admin_init_failed', { error: String(err) });
134
+ res.status(500).json({
135
+ error: { code: 'internal', message: 'Internal server error' },
136
+ });
137
+ }
138
+ });
139
+ }
140
+ // --- POST /admin/pair/:pairingId/revoke ---
141
+ //
142
+ // Bearer-authenticated builder-only — mirrors `/admin/pair/init`'s
143
+ // identity gate. Delegates to `PairingService.revokePairing`, which
144
+ // the in-memory reference is idempotent about (revoking a missing
145
+ // pairingId is a no-op, NOT a 404 — the HTTP surface preserves that
146
+ // so an admin cleanup loop is safe to re-run). The service's
147
+ // `onTokenRevoked` callback is the load-bearing side-effect: it
148
+ // unregisters the bearer from the active AuthAdapter, so a
149
+ // subsequent `/mcp` call with the revoked token fails at
150
+ // `resolveIdentity` with the canonical JSON-RPC 401 envelope.
151
+ //
152
+ // Response envelope on success: `{ok: true, pairingId}`. Thin by
153
+ // design — the service returns void, so the route asserts the
154
+ // operation completed without surfacing internal state.
155
+ if (adminRevokePath) {
156
+ app.post(adminRevokePath, async (req, res) => {
157
+ const reqLogger = opts.logger.child({
158
+ route: 'POST ' + adminRevokePath,
159
+ });
160
+ const pairingId = req.params['pairingId'];
161
+ if (typeof pairingId !== 'string' || pairingId.length === 0) {
162
+ reqLogger.debug?.('pair_revoke_bad_request', {});
163
+ res.status(400).json({
164
+ error: {
165
+ code: 'bad_request',
166
+ message: 'POST /admin/pair/:pairingId/revoke requires a non-empty `pairingId` path parameter.',
167
+ },
168
+ });
169
+ return;
170
+ }
171
+ try {
172
+ const identity = await resolveIdentity(opts.auth, req);
173
+ if (identity.identity.kind !== 'builder') {
174
+ reqLogger.warn('admin_revoke_forbidden', {
175
+ identityKind: identity.identity.kind,
176
+ pairingId,
177
+ });
178
+ res.status(403).json({
179
+ error: {
180
+ code: 'forbidden',
181
+ message: 'POST /admin/pair/:pairingId/revoke requires a builder identity.',
182
+ },
183
+ });
184
+ return;
185
+ }
186
+ }
187
+ catch (err) {
188
+ if (err instanceof UnauthenticatedError) {
189
+ reqLogger.warn('admin_revoke_unauthenticated', {
190
+ reason: err.message,
191
+ pairingId,
192
+ });
193
+ res.status(401).json({
194
+ error: { code: 'unauthenticated', message: err.message },
195
+ });
196
+ return;
197
+ }
198
+ reqLogger.error('admin_revoke_unexpected_error', {
199
+ error: String(err),
200
+ pairingId,
201
+ });
202
+ res.status(500).json({
203
+ error: { code: 'internal', message: 'Internal server error' },
204
+ });
205
+ return;
206
+ }
207
+ try {
208
+ await opts.pairing.revokePairing(pairingId);
209
+ reqLogger.info('admin_revoke_completed', { pairingId });
210
+ res.status(200).json({ ok: true, pairingId });
211
+ }
212
+ catch (err) {
213
+ reqLogger.error('admin_revoke_failed', {
214
+ error: String(err),
215
+ pairingId,
216
+ });
217
+ res.status(500).json({
218
+ error: { code: 'internal', message: 'Internal server error' },
219
+ });
220
+ }
221
+ });
222
+ }
223
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-IP rate-limit middleware for `/pair`.
3
+ *
4
+ * Wraps an already-constructed {@link RateLimiter} (policy lives there —
5
+ * limit + window are baked into the adapter at construction time, so
6
+ * swapping a hosted Redis-backed limiter in for the OSS in-memory
7
+ * `FixedWindowRateLimiter` does not change this middleware).
8
+ *
9
+ * Behavior:
10
+ * - Composes the bucket key as `${quotaKey}:${ip}`. `quotaKey` is
11
+ * caller-tagged so additional pre-auth surfaces can share the
12
+ * middleware with isolated quotas.
13
+ * - Per-IP source: `X-Forwarded-For` (first non-empty hop) when
14
+ * `trustProxy=true`, else `req.socket.remoteAddress`. Falls back to
15
+ * `'unknown'` and treats every unidentified peer as a single bucket.
16
+ * - On denial: 429 + `Retry-After` (seconds, ceiling) + JSON
17
+ * `{error: {code: 'rate_limited', message, retryAfter}}`.
18
+ * - On limiter throw: log + `next()` (fail-open). A limiter outage MUST
19
+ * NOT take down auth endpoints — same posture documented on the
20
+ * `RateLimiter` seam itself.
21
+ */
22
+ import type { Request, RequestHandler } from 'express';
23
+ import type { RateLimiter } from '@ggui-ai/mcp-server-core';
24
+ import type { Logger } from './logger.js';
25
+ export interface PairLoginRateLimitOptions {
26
+ readonly limiter: RateLimiter;
27
+ readonly logger: Logger;
28
+ /** Tag prefix on the bucket key. */
29
+ readonly quotaKey: string;
30
+ /** When true, prefer the first `X-Forwarded-For` hop. Default false. */
31
+ readonly trustProxy?: boolean;
32
+ }
33
+ /** Extracted so tests can target the IP-resolution path directly if needed. */
34
+ export declare function resolveClientIp(req: Pick<Request, 'headers' | 'socket'>, trustProxy: boolean): string;
35
+ export declare function createPairLoginRateLimitMiddleware(opts: PairLoginRateLimitOptions): RequestHandler;
36
+ //# sourceMappingURL=rate-limit-middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit-middleware.d.ts","sourceRoot":"","sources":["../src/rate-limit-middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAC5D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,oCAAoC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAC7B,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,QAAQ,CAAC,EACxC,UAAU,EAAE,OAAO,GAClB,MAAM,CAaR;AAED,wBAAgB,kCAAkC,CAChD,IAAI,EAAE,yBAAyB,GAC9B,cAAc,CA2ChB"}
@@ -0,0 +1,57 @@
1
+ /** Extracted so tests can target the IP-resolution path directly if needed. */
2
+ export function resolveClientIp(req, trustProxy) {
3
+ if (trustProxy) {
4
+ const raw = req.headers['x-forwarded-for'];
5
+ const header = Array.isArray(raw) ? raw[0] : raw;
6
+ if (typeof header === 'string') {
7
+ for (const segment of header.split(',')) {
8
+ const trimmed = segment.trim();
9
+ if (trimmed.length > 0)
10
+ return trimmed;
11
+ }
12
+ }
13
+ }
14
+ const sock = req.socket.remoteAddress;
15
+ return sock && sock.length > 0 ? sock : 'unknown';
16
+ }
17
+ export function createPairLoginRateLimitMiddleware(opts) {
18
+ const { limiter, logger, quotaKey, trustProxy = false } = opts;
19
+ return (req, res, next) => {
20
+ const ip = resolveClientIp(req, trustProxy);
21
+ const key = `${quotaKey}:${ip}`;
22
+ limiter
23
+ .check({ key })
24
+ .then((decision) => {
25
+ if (decision.allowed) {
26
+ next();
27
+ return;
28
+ }
29
+ const retryAfterSec = Math.max(1, Math.ceil((decision.retryAfterMs ?? 0) / 1000));
30
+ logger.info('rate_limit_hit', {
31
+ quotaKey,
32
+ ip,
33
+ remaining: decision.remaining,
34
+ retryAfterSec,
35
+ });
36
+ res
37
+ .status(429)
38
+ .setHeader('Retry-After', String(retryAfterSec))
39
+ .json({
40
+ error: {
41
+ code: 'rate_limited',
42
+ message: `Too many attempts. Try again in ${retryAfterSec} seconds.`,
43
+ retryAfter: retryAfterSec,
44
+ },
45
+ });
46
+ })
47
+ .catch((err) => {
48
+ // Fail-open: limiter outages must not block auth endpoints.
49
+ logger.error('rate_limit_check_failed', {
50
+ quotaKey,
51
+ ip,
52
+ error: err instanceof Error ? err.message : String(err),
53
+ });
54
+ next();
55
+ });
56
+ };
57
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `gateShortCode` — the single chokepoint for capability-URL routes.
3
+ *
4
+ * `/r/<code>` and `/api/bootstrap/<code>` are sibling surfaces that
5
+ * expose the same render state via different content types (HTML
6
+ * shell vs JSON bootstrap envelope). Before this gate, each route
7
+ * implemented its own lookup + session resolve + 404 mapping, which
8
+ * meant: hardening one route (revoke checks, sig verify, rate limit,
9
+ * audit log) silently left the other unhardened. Capability URLs are
10
+ * the credential — drift across the two routes is a defense gap.
11
+ *
12
+ * This module centralizes the gate logic. Routes ask the gate for
13
+ * an outcome; content-type rendering stays on the route.
14
+ *
15
+ * Outcomes today:
16
+ * - `ok` — binding lookup + session resolve both succeeded
17
+ * - `not_found` — shortCode unknown OR revoked (lookup returned null)
18
+ * - `session_missing` — binding present but session record absent
19
+ *
20
+ * Outcomes reserved for future slices (sig verify, rate limit, etc.):
21
+ * - `invalid_signature` (HMAC slice)
22
+ * - `expired` (TTL slice)
23
+ * - `rate_limited` (per-code throttle slice)
24
+ *
25
+ * Each future slice extends the union; routes can map new codes to
26
+ * appropriate status codes (403, 410, 429) without re-implementing
27
+ * the lookup.
28
+ */
29
+ import type { Session } from '@ggui-ai/protocol';
30
+ import type { ShortCodeIndex } from '@ggui-ai/mcp-server-core';
31
+ import type { RenderSigner } from './render-signing.js';
32
+ /** Minimal SessionStore surface the gate uses — narrower than the
33
+ * full interface so test fakes stay tiny. */
34
+ interface GateSessionStore {
35
+ get(id: string): Promise<Session | null>;
36
+ }
37
+ /** The single failure surface the gate emits. New codes added here
38
+ * grow the discriminated union; consumers exhaustively match. */
39
+ export type RenderGateFailureCode = 'not_found' | 'session_missing' | 'invalid_signature' | 'expired' | 'malformed_signature';
40
+ export type RenderGateOutcome = {
41
+ readonly ok: true;
42
+ readonly binding: NonNullable<Awaited<ReturnType<ShortCodeIndex['lookup']>>>;
43
+ readonly session: Session;
44
+ } | {
45
+ readonly ok: false;
46
+ readonly code: RenderGateFailureCode;
47
+ /** Optional context that's safe to pass to a structured logger
48
+ * but MUST NOT leak to the wire — error responses stay generic
49
+ * ("shortCode not recognised") to avoid info-leak amplification
50
+ * on brute-force attempts. */
51
+ readonly logContext?: Record<string, unknown>;
52
+ };
53
+ export interface GateShortCodeInput {
54
+ readonly shortCode: string;
55
+ readonly shortCodeIndex: ShortCodeIndex;
56
+ readonly sessionStore: GateSessionStore;
57
+ /**
58
+ * Optional render-URL signer. When wired, the gate verifies the
59
+ * incoming `sig`+`exp` query pair BEFORE lookup; an invalid or
60
+ * expired signature short-circuits with a tagged failure code so
61
+ * the route can map to an appropriate status (403/410). Absent =
62
+ * signing disabled (legacy or `--no-render-signing` boot).
63
+ */
64
+ readonly signer?: RenderSigner;
65
+ /**
66
+ * Raw `sig`+`exp` from the request query string. When `signer` is
67
+ * wired but these are missing, the gate fails with
68
+ * `malformed_signature`. When `signer` is absent, both fields are
69
+ * ignored.
70
+ */
71
+ readonly signedQuery?: {
72
+ readonly sig?: string | undefined;
73
+ readonly exp?: string | undefined;
74
+ };
75
+ }
76
+ /**
77
+ * Look up a shortCode + resolve its session. Idempotent + side-
78
+ * effect-free; safe to call from any HTTP handler. Returns a tagged
79
+ * outcome the caller maps to its content-type-appropriate response.
80
+ *
81
+ * Empty input MUST be caught upstream (path-param validation) — the
82
+ * gate assumes a non-empty shortCode arrives because the route
83
+ * pattern requires it.
84
+ */
85
+ export declare function gateShortCode(input: GateShortCodeInput): Promise<RenderGateOutcome>;
86
+ export {};
87
+ //# sourceMappingURL=render-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-gate.d.ts","sourceRoot":"","sources":["../src/render-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD;8CAC8C;AAC9C,UAAU,gBAAgB;IACxB,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAAC;CAC1C;AAED;kEACkE;AAClE,MAAM,MAAM,qBAAqB,GAC7B,WAAW,GACX,iBAAiB,GACjB,mBAAmB,GACnB,SAAS,GACT,qBAAqB,CAAC;AAE1B,MAAM,MAAM,iBAAiB,GACzB;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,OAAO,EAAE,WAAW,CAC3B,OAAO,CAAC,UAAU,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC9C,CAAC;IACF,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B,GACD;IACE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,IAAI,EAAE,qBAAqB,CAAC;IACrC;;;mCAG+B;IAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/C,CAAC;AAEN,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,QAAQ,CAAC,YAAY,EAAE,gBAAgB,CAAC;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,YAAY,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE;QACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAClC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;KACnC,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC,iBAAiB,CAAC,CAwC5B"}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `gateShortCode` — the single chokepoint for capability-URL routes.
3
+ *
4
+ * `/r/<code>` and `/api/bootstrap/<code>` are sibling surfaces that
5
+ * expose the same render state via different content types (HTML
6
+ * shell vs JSON bootstrap envelope). Before this gate, each route
7
+ * implemented its own lookup + session resolve + 404 mapping, which
8
+ * meant: hardening one route (revoke checks, sig verify, rate limit,
9
+ * audit log) silently left the other unhardened. Capability URLs are
10
+ * the credential — drift across the two routes is a defense gap.
11
+ *
12
+ * This module centralizes the gate logic. Routes ask the gate for
13
+ * an outcome; content-type rendering stays on the route.
14
+ *
15
+ * Outcomes today:
16
+ * - `ok` — binding lookup + session resolve both succeeded
17
+ * - `not_found` — shortCode unknown OR revoked (lookup returned null)
18
+ * - `session_missing` — binding present but session record absent
19
+ *
20
+ * Outcomes reserved for future slices (sig verify, rate limit, etc.):
21
+ * - `invalid_signature` (HMAC slice)
22
+ * - `expired` (TTL slice)
23
+ * - `rate_limited` (per-code throttle slice)
24
+ *
25
+ * Each future slice extends the union; routes can map new codes to
26
+ * appropriate status codes (403, 410, 429) without re-implementing
27
+ * the lookup.
28
+ */
29
+ /**
30
+ * Look up a shortCode + resolve its session. Idempotent + side-
31
+ * effect-free; safe to call from any HTTP handler. Returns a tagged
32
+ * outcome the caller maps to its content-type-appropriate response.
33
+ *
34
+ * Empty input MUST be caught upstream (path-param validation) — the
35
+ * gate assumes a non-empty shortCode arrives because the route
36
+ * pattern requires it.
37
+ */
38
+ export async function gateShortCode(input) {
39
+ // Signature verification runs BEFORE the lookup so a flood of
40
+ // requests with garbage shortCodes can be rejected without hitting
41
+ // the index. The signer also gates information-leak: revealing
42
+ // "this code doesn't exist" vs "your sig is wrong" lets an
43
+ // attacker probe shortCodes via timing differences. Rejecting on
44
+ // signature first keeps the response timing uniform for any code
45
+ // an attacker hasn't seen a valid sig for.
46
+ if (input.signer) {
47
+ const verify = input.signer.verify({
48
+ shortCode: input.shortCode,
49
+ sig: input.signedQuery?.sig,
50
+ exp: input.signedQuery?.exp,
51
+ });
52
+ if (!verify.ok) {
53
+ return {
54
+ ok: false,
55
+ code: verify.code === 'malformed'
56
+ ? 'malformed_signature'
57
+ : verify.code,
58
+ };
59
+ }
60
+ }
61
+ const binding = await input.shortCodeIndex.lookup(input.shortCode);
62
+ if (!binding) {
63
+ return { ok: false, code: 'not_found' };
64
+ }
65
+ const session = await input.sessionStore.get(binding.sessionId);
66
+ if (!session) {
67
+ return {
68
+ ok: false,
69
+ code: 'session_missing',
70
+ logContext: {
71
+ shortCode: input.shortCode,
72
+ sessionId: binding.sessionId,
73
+ },
74
+ };
75
+ }
76
+ return { ok: true, binding, session };
77
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Per-shortCode rate limiter.
3
+ *
4
+ * Brute-force attempts on `/r/<code>` or `/api/bootstrap/<code>` are
5
+ * trivially detected at the wire by per-shortCode call counts. Even
6
+ * with high-entropy shortCodes and HMAC-signed render URLs, the gate
7
+ * still hits a real backend on every request — so DoS resistance +
8
+ * abuse-signal logging matter independently of entropy.
9
+ *
10
+ * **Design choice — rate-limit by shortCode, not by peer.**
11
+ * Cross-origin iframes (claude.ai) NAT every user through the host's
12
+ * outbound proxy, so per-peer limits would either rate-limit the whole
13
+ * host (false-positive flood) or accept every peer (no signal). Per-
14
+ * shortCode is the right granularity: 30 hits/minute on a single
15
+ * code is abuse no matter who sent them; 30 hits/minute spread across
16
+ * 30 unique codes is normal traffic.
17
+ *
18
+ * In-memory + per-process by default. Operator-grade deployments
19
+ * back this with a shared store (Redis bucket counter) — wire via the
20
+ * {@link RenderRateLimiter} interface; the OSS reference uses a
21
+ * `Map<shortCode, {windowStart, count}>` with periodic cleanup.
22
+ */
23
+ /** Configuration knobs. Operators tune via boot options. */
24
+ export interface RenderRateLimiterConfig {
25
+ /** Window size in seconds. Each shortCode gets `limit` hits per
26
+ * rolling window. Default 60s. */
27
+ readonly windowSeconds?: number;
28
+ /** Per-shortCode hit cap inside the window. Default 30. */
29
+ readonly limit?: number;
30
+ /** Clock seam for tests. */
31
+ readonly now?: () => number;
32
+ }
33
+ export type RenderRateLimitResult = {
34
+ readonly allowed: true;
35
+ } | {
36
+ readonly allowed: false;
37
+ readonly retryAfterSeconds: number;
38
+ };
39
+ export interface RenderRateLimiter {
40
+ /** Record a hit on `shortCode`. Returns the gate decision + (when
41
+ * rejected) a Retry-After hint in seconds. */
42
+ check(shortCode: string): RenderRateLimitResult;
43
+ }
44
+ /**
45
+ * In-memory reference implementation. Cleanup runs lazily on each
46
+ * call — entries past their window-end are reaped before the count is
47
+ * read. Worst-case heap: bounded by the number of distinct shortCodes
48
+ * hit within the last `windowSeconds`; pre-launch OSS workloads stay
49
+ * tiny enough that the periodic pass is sufficient.
50
+ */
51
+ export declare function createInMemoryRenderRateLimiter(cfg?: RenderRateLimiterConfig): RenderRateLimiter;
52
+ /**
53
+ * Mask a shortCode for log output. The full code is the credential —
54
+ * log it verbatim and a leaked log line becomes a leaked URL. Show
55
+ * just enough (first 3 chars) to correlate within a session without
56
+ * giving the credential away.
57
+ */
58
+ export declare function maskShortCode(shortCode: string): string;
59
+ //# sourceMappingURL=render-rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-rate-limit.d.ts","sourceRoot":"","sources":["../src/render-rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,4DAA4D;AAC5D,MAAM,WAAW,uBAAuB;IACtC;uCACmC;IACnC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,2DAA2D;IAC3D,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,4BAA4B;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,OAAO,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,iBAAiB;IAChC;mDAC+C;IAC/C,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,qBAAqB,CAAC;CACjD;AAED;;;;;;GAMG;AACH,wBAAgB,+BAA+B,CAC7C,GAAG,GAAE,uBAA4B,GAChC,iBAAiB,CAmCnB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAIvD"}