@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.
- package/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- 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"}
|