@fitzzero/quickdraw-core 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +218 -64
  2. package/dist/client/index.d.ts +34 -4
  3. package/dist/client/index.js +57 -65
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/testing.d.ts +1 -1
  6. package/dist/client/testing.js.map +1 -1
  7. package/dist/server/express/index.d.ts +37 -0
  8. package/dist/server/express/index.js +60 -0
  9. package/dist/server/express/index.js.map +1 -0
  10. package/dist/server/index.d.ts +330 -5
  11. package/dist/server/index.js +593 -144
  12. package/dist/server/index.js.map +1 -1
  13. package/dist/server/testing-prisma.d.ts +101 -0
  14. package/dist/server/testing-prisma.js +172 -0
  15. package/dist/server/testing-prisma.js.map +1 -0
  16. package/dist/server/testing.d.ts +1 -1
  17. package/dist/server/testing.js +87 -22
  18. package/dist/server/testing.js.map +1 -1
  19. package/dist/shared/index.d.ts +77 -1
  20. package/dist/shared/index.js +5 -1
  21. package/dist/shared/index.js.map +1 -1
  22. package/dist/{types-DniMxkQs.d.ts → types-ClgSr90l.d.ts} +5 -1
  23. package/dist/{types-B1z5aXdR.d.ts → types-DUHPIy_5.d.ts} +20 -1
  24. package/eslint-config/index.mjs +1 -2
  25. package/eslint-plugin-quickdraw/index.mjs +15 -7
  26. package/eslint-plugin-quickdraw/rules/no-cross-service-mutations.mjs +31 -32
  27. package/eslint-plugin-quickdraw/rules/no-direct-prisma-mutations.mjs +17 -18
  28. package/eslint-plugin-quickdraw/rules/no-raw-button-strings.mjs +52 -0
  29. package/eslint-plugin-quickdraw/rules/no-raw-service-room-string.mjs +140 -0
  30. package/eslint-plugin-quickdraw/rules/no-raw-socket-emit.mjs +20 -22
  31. package/eslint-plugin-quickdraw/rules/no-raw-socket-on.mjs +17 -17
  32. package/eslint-plugin-quickdraw/rules/no-raw-tooltip-strings.mjs +59 -0
  33. package/eslint-plugin-quickdraw/rules/no-raw-typography-strings.mjs +52 -0
  34. package/eslint-plugin-quickdraw/rules/no-service-method-record.mjs +3 -3
  35. package/eslint-plugin-quickdraw/rules/no-unsafe-payload-cast.mjs +8 -8
  36. package/eslint-plugin-quickdraw/rules/require-zod-schema.mjs +15 -20
  37. package/package.json +74 -50
@@ -0,0 +1,37 @@
1
+ import { Request } from 'express';
2
+ import { Options, RateLimitRequestHandler } from 'express-rate-limit';
3
+
4
+ /**
5
+ * Express rate-limit presets built on express-rate-limit.
6
+ *
7
+ * Lives in the dedicated `/server/express` subpath so the core `/server`
8
+ * entry point carries no express-rate-limit dependency. All presets are
9
+ * factories (not module-level singletons) so env/config is read at call time.
10
+ *
11
+ * Note: distinct from the Socket.IO `createRateLimiter` exported from
12
+ * `/server` — these protect REST routes (OAuth, webhooks, public APIs).
13
+ */
14
+
15
+ type PartialOptions = Partial<Options>;
16
+ /** Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 → 1.2.3.4). */
17
+ declare function stripIp(raw: string | undefined): string;
18
+ /**
19
+ * Key by Bearer token when present, falling back to client IP. Lets
20
+ * token-authenticated clients get their own bucket instead of sharing
21
+ * a NAT'd IP.
22
+ */
23
+ declare function bearerOrIp(req: Request): string;
24
+ /**
25
+ * Base factory: JSON 429 body + Retry-After header + standard headers.
26
+ */
27
+ declare function createJsonRateLimiter(options: PartialOptions): RateLimitRequestHandler;
28
+ /** OAuth flows: 20 requests / 15 min per IP (status sub-routes excluded). */
29
+ declare function createAuthLimiter(overrides?: PartialOptions): RateLimitRequestHandler;
30
+ /** Auth status polling: 120 requests / 15 min per IP. */
31
+ declare function createAuthStatusLimiter(overrides?: PartialOptions): RateLimitRequestHandler;
32
+ /** Inbound webhooks: 300 requests / min per IP. */
33
+ declare function createWebhookLimiter(overrides?: PartialOptions): RateLimitRequestHandler;
34
+ /** Generic public API: 60 requests / min per IP. */
35
+ declare function createPublicApiLimiter(overrides?: PartialOptions): RateLimitRequestHandler;
36
+
37
+ export { bearerOrIp, createAuthLimiter, createAuthStatusLimiter, createJsonRateLimiter, createPublicApiLimiter, createWebhookLimiter, stripIp };
@@ -0,0 +1,60 @@
1
+ import { ipKeyGenerator, rateLimit } from 'express-rate-limit';
2
+
3
+ // src/server/express/rateLimit.ts
4
+ function handler(_req, res, _next, options) {
5
+ const retryAfterSec = Math.max(1, Math.ceil((options.windowMs ?? 6e4) / 1e3));
6
+ if (!res.getHeader("Retry-After")) {
7
+ res.setHeader("Retry-After", String(retryAfterSec));
8
+ }
9
+ res.status(options.statusCode ?? 429).json({ error: "Rate limit exceeded" });
10
+ }
11
+ var jsonLimiter = {
12
+ standardHeaders: true,
13
+ legacyHeaders: false,
14
+ handler
15
+ };
16
+ function stripIp(raw) {
17
+ const ip = raw ?? "unknown";
18
+ return ip.startsWith("::ffff:") ? ip.slice(7) : ip;
19
+ }
20
+ function bearerOrIp(req) {
21
+ const auth = req.headers.authorization ?? "";
22
+ if (auth.startsWith("Bearer ")) return auth.slice(7);
23
+ return ipKeyGenerator(stripIp(req.ip));
24
+ }
25
+ function createJsonRateLimiter(options) {
26
+ return rateLimit({ ...jsonLimiter, ...options });
27
+ }
28
+ function createAuthLimiter(overrides = {}) {
29
+ return createJsonRateLimiter({
30
+ windowMs: 15 * 60 * 1e3,
31
+ max: 20,
32
+ skip: (req) => req.path.endsWith("/status"),
33
+ ...overrides
34
+ });
35
+ }
36
+ function createAuthStatusLimiter(overrides = {}) {
37
+ return createJsonRateLimiter({
38
+ windowMs: 15 * 60 * 1e3,
39
+ max: 120,
40
+ ...overrides
41
+ });
42
+ }
43
+ function createWebhookLimiter(overrides = {}) {
44
+ return createJsonRateLimiter({
45
+ windowMs: 60 * 1e3,
46
+ max: 300,
47
+ ...overrides
48
+ });
49
+ }
50
+ function createPublicApiLimiter(overrides = {}) {
51
+ return createJsonRateLimiter({
52
+ windowMs: 60 * 1e3,
53
+ max: 60,
54
+ ...overrides
55
+ });
56
+ }
57
+
58
+ export { bearerOrIp, createAuthLimiter, createAuthStatusLimiter, createJsonRateLimiter, createPublicApiLimiter, createWebhookLimiter, stripIp };
59
+ //# sourceMappingURL=index.js.map
60
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../src/server/express/rateLimit.ts"],"names":[],"mappings":";;;AAqBA,SAAS,OAAA,CAAQ,IAAA,EAAe,GAAA,EAAe,KAAA,EAAqB,OAAA,EAAwB;AAC1F,EAAA,MAAM,aAAA,GAAgB,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,MAAM,OAAA,CAAQ,QAAA,IAAY,GAAA,IAAU,GAAI,CAAC,CAAA;AAChF,EAAA,IAAI,CAAC,GAAA,CAAI,SAAA,CAAU,aAAa,CAAA,EAAG;AACjC,IAAA,GAAA,CAAI,SAAA,CAAU,aAAA,EAAe,MAAA,CAAO,aAAa,CAAC,CAAA;AAAA,EACpD;AACA,EAAA,GAAA,CAAI,MAAA,CAAO,QAAQ,UAAA,IAAc,GAAG,EAAE,IAAA,CAAK,EAAE,KAAA,EAAO,qBAAA,EAAuB,CAAA;AAC7E;AAEA,IAAM,WAAA,GAA8B;AAAA,EAClC,eAAA,EAAiB,IAAA;AAAA,EACjB,aAAA,EAAe,KAAA;AAAA,EACf;AACF,CAAA;AAGO,SAAS,QAAQ,GAAA,EAAiC;AACvD,EAAA,MAAM,KAAK,GAAA,IAAO,SAAA;AAClB,EAAA,OAAO,GAAG,UAAA,CAAW,SAAS,IAAI,EAAA,CAAG,KAAA,CAAM,CAAC,CAAA,GAAI,EAAA;AAClD;AAOO,SAAS,WAAW,GAAA,EAAsB;AAC/C,EAAA,MAAM,IAAA,GAAO,GAAA,CAAI,OAAA,CAAQ,aAAA,IAAiB,EAAA;AAC1C,EAAA,IAAI,KAAK,UAAA,CAAW,SAAS,GAAG,OAAO,IAAA,CAAK,MAAM,CAAC,CAAA;AACnD,EAAA,OAAO,cAAA,CAAe,OAAA,CAAQ,GAAA,CAAI,EAAE,CAAC,CAAA;AACvC;AAKO,SAAS,sBAAsB,OAAA,EAAkD;AACtF,EAAA,OAAO,UAAU,EAAE,GAAG,WAAA,EAAa,GAAG,SAAS,CAAA;AACjD;AAGO,SAAS,iBAAA,CAAkB,SAAA,GAA4B,EAAC,EAA4B;AACzF,EAAA,OAAO,qBAAA,CAAsB;AAAA,IAC3B,QAAA,EAAU,KAAK,EAAA,GAAK,GAAA;AAAA,IACpB,GAAA,EAAK,EAAA;AAAA,IACL,MAAM,CAAC,GAAA,KAAQ,GAAA,CAAI,IAAA,CAAK,SAAS,SAAS,CAAA;AAAA,IAC1C,GAAG;AAAA,GACJ,CAAA;AACH;AAGO,SAAS,uBAAA,CAAwB,SAAA,GAA4B,EAAC,EAA4B;AAC/F,EAAA,OAAO,qBAAA,CAAsB;AAAA,IAC3B,QAAA,EAAU,KAAK,EAAA,GAAK,GAAA;AAAA,IACpB,GAAA,EAAK,GAAA;AAAA,IACL,GAAG;AAAA,GACJ,CAAA;AACH;AAGO,SAAS,oBAAA,CAAqB,SAAA,GAA4B,EAAC,EAA4B;AAC5F,EAAA,OAAO,qBAAA,CAAsB;AAAA,IAC3B,UAAU,EAAA,GAAK,GAAA;AAAA,IACf,GAAA,EAAK,GAAA;AAAA,IACL,GAAG;AAAA,GACJ,CAAA;AACH;AAGO,SAAS,sBAAA,CAAuB,SAAA,GAA4B,EAAC,EAA4B;AAC9F,EAAA,OAAO,qBAAA,CAAsB;AAAA,IAC3B,UAAU,EAAA,GAAK,GAAA;AAAA,IACf,GAAA,EAAK,EAAA;AAAA,IACL,GAAG;AAAA,GACJ,CAAA;AACH","file":"index.js","sourcesContent":["/**\n * Express rate-limit presets built on express-rate-limit.\n *\n * Lives in the dedicated `/server/express` subpath so the core `/server`\n * entry point carries no express-rate-limit dependency. All presets are\n * factories (not module-level singletons) so env/config is read at call time.\n *\n * Note: distinct from the Socket.IO `createRateLimiter` exported from\n * `/server` — these protect REST routes (OAuth, webhooks, public APIs).\n */\n\nimport type { NextFunction, Request, Response } from \"express\";\nimport {\n ipKeyGenerator,\n rateLimit,\n type Options,\n type RateLimitRequestHandler,\n} from \"express-rate-limit\";\n\ntype PartialOptions = Partial<Options>;\n\nfunction handler(_req: Request, res: Response, _next: NextFunction, options: Options): void {\n const retryAfterSec = Math.max(1, Math.ceil((options.windowMs ?? 60_000) / 1000));\n if (!res.getHeader(\"Retry-After\")) {\n res.setHeader(\"Retry-After\", String(retryAfterSec));\n }\n res.status(options.statusCode ?? 429).json({ error: \"Rate limit exceeded\" });\n}\n\nconst jsonLimiter: PartialOptions = {\n standardHeaders: true,\n legacyHeaders: false,\n handler,\n};\n\n/** Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 → 1.2.3.4). */\nexport function stripIp(raw: string | undefined): string {\n const ip = raw ?? \"unknown\";\n return ip.startsWith(\"::ffff:\") ? ip.slice(7) : ip;\n}\n\n/**\n * Key by Bearer token when present, falling back to client IP. Lets\n * token-authenticated clients get their own bucket instead of sharing\n * a NAT'd IP.\n */\nexport function bearerOrIp(req: Request): string {\n const auth = req.headers.authorization ?? \"\";\n if (auth.startsWith(\"Bearer \")) return auth.slice(7);\n return ipKeyGenerator(stripIp(req.ip));\n}\n\n/**\n * Base factory: JSON 429 body + Retry-After header + standard headers.\n */\nexport function createJsonRateLimiter(options: PartialOptions): RateLimitRequestHandler {\n return rateLimit({ ...jsonLimiter, ...options });\n}\n\n/** OAuth flows: 20 requests / 15 min per IP (status sub-routes excluded). */\nexport function createAuthLimiter(overrides: PartialOptions = {}): RateLimitRequestHandler {\n return createJsonRateLimiter({\n windowMs: 15 * 60 * 1000,\n max: 20,\n skip: (req) => req.path.endsWith(\"/status\"),\n ...overrides,\n });\n}\n\n/** Auth status polling: 120 requests / 15 min per IP. */\nexport function createAuthStatusLimiter(overrides: PartialOptions = {}): RateLimitRequestHandler {\n return createJsonRateLimiter({\n windowMs: 15 * 60 * 1000,\n max: 120,\n ...overrides,\n });\n}\n\n/** Inbound webhooks: 300 requests / min per IP. */\nexport function createWebhookLimiter(overrides: PartialOptions = {}): RateLimitRequestHandler {\n return createJsonRateLimiter({\n windowMs: 60 * 1000,\n max: 300,\n ...overrides,\n });\n}\n\n/** Generic public API: 60 requests / min per IP. */\nexport function createPublicApiLimiter(overrides: PartialOptions = {}): RateLimitRequestHandler {\n return createJsonRateLimiter({\n windowMs: 60 * 1000,\n max: 60,\n ...overrides,\n });\n}\n"]}
@@ -1,7 +1,7 @@
1
- import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, A as AdminListPayload, c as AdminListResponse, d as AdminSetACLPayload, e as AdminSubscribersResponse, S as ServiceRegistryInstance, B as BaseServiceInstance, f as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-DniMxkQs.js';
1
+ import { a as QuickdrawSocket, P as PrismaDelegate, b as BaseServiceOptions, I as InstallAdminMethodsOptions, A as AdminListPayload, c as AdminListResponse, d as AdminSetACLPayload, e as AdminSubscribersResponse, S as ServiceRegistryInstance, B as BaseServiceInstance, f as QuickdrawServerOptions, Q as QuickdrawServerResult } from '../types-ClgSr90l.js';
2
2
  import { Server, Socket } from 'socket.io';
3
3
  import { z } from 'zod';
4
- import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition, AdminServiceMeta, AdminFieldConfig } from '../shared/index.js';
4
+ import { ACL, Logger, AccessLevel, ServiceMethodContext, ServiceMethodDefinition, ServiceChannelContext, ServiceChannelDefinition, AdminServiceMeta, AdminFieldConfig } from '../shared/index.js';
5
5
  import { Router } from 'express';
6
6
  import 'http';
7
7
 
@@ -19,6 +19,7 @@ import 'http';
19
19
  * @typeParam TCreateInput - The create input type
20
20
  * @typeParam TUpdateInput - The update input type
21
21
  * @typeParam TServiceMethods - Service method definitions map
22
+ * @typeParam TChannels - Service channel payload map (high-frequency fire-and-forget events)
22
23
  *
23
24
  * @example
24
25
  * ```typescript
@@ -48,7 +49,9 @@ declare abstract class BaseService<TEntity extends {
48
49
  } = Record<string, {
49
50
  payload: unknown;
50
51
  response: unknown;
51
- }>> {
52
+ }>, TChannels extends {
53
+ [K in keyof TChannels]: unknown;
54
+ } = Record<string, unknown>> {
52
55
  readonly serviceName: string;
53
56
  protected readonly hasEntryACL: boolean;
54
57
  protected readonly defaultACL: ACL;
@@ -57,6 +60,7 @@ declare abstract class BaseService<TEntity extends {
57
60
  protected delegate: PrismaDelegate<TEntity, TCreateInput, TUpdateInput> | undefined;
58
61
  protected io: Server | null;
59
62
  private readonly publicMethods;
63
+ private readonly publicChannels;
60
64
  private adminMeta;
61
65
  constructor(options: BaseServiceOptions);
62
66
  /**
@@ -138,6 +142,28 @@ declare abstract class BaseService<TEntity extends {
138
142
  * ```
139
143
  */
140
144
  emitToRoom(roomName: string, eventName: string, data: unknown): void;
145
+ /**
146
+ * Emit a custom event to a room using Socket.io's volatile flag.
147
+ *
148
+ * Volatile events are dropped for clients whose connection is backpressured
149
+ * instead of being buffered — exactly what you want for high-frequency state
150
+ * that is superseded by the next emit (game snapshots, cursor positions).
151
+ * Never use this for events a client must not miss.
152
+ *
153
+ * Intentionally does no per-call logging: this is a hot path, typically
154
+ * called at tick rate (10–60Hz).
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * // In a 20Hz game loop
159
+ * this.emitToRoomVolatile(
160
+ * this.getRoomName(worldId),
161
+ * 'game:snapshot',
162
+ * snapshot
163
+ * );
164
+ * ```
165
+ */
166
+ emitToRoomVolatile(roomName: string, eventName: string, data: unknown): void;
141
167
  /**
142
168
  * Emit a custom event to a specific user's room.
143
169
  * Users join `user:{userId}` on connection (set up in auth middleware).
@@ -274,6 +300,58 @@ declare abstract class BaseService<TEntity extends {
274
300
  * Get all public methods for registry discovery.
275
301
  */
276
302
  getPublicMethods(): ServiceMethodDefinition<unknown, unknown>[];
303
+ /**
304
+ * Define a channel: a fire-and-forget event for high-frequency traffic
305
+ * (game input, cursor positions, typing indicators).
306
+ *
307
+ * Channels differ from methods:
308
+ * - No ack, no response — the handler returns void and errors are logged,
309
+ * never sent to the client.
310
+ * - Exempt from the global rate limiter; each channel enforces its own
311
+ * per-socket token bucket (`ratePerSecond`/`burst`). Excess messages are
312
+ * silently dropped; sustained extreme abuse disconnects the socket.
313
+ * - Access checks are fully synchronous and in-memory: authentication is
314
+ * always required; "Moderate"/"Admin" check the socket's serviceAccess;
315
+ * entry-level access is expressed via `requireRoom` (the socket must
316
+ * already be in that room, which was ACL-gated at subscribe time).
317
+ * - `schema` is required — channel payloads are untrusted input arriving
318
+ * at tick rate.
319
+ *
320
+ * Routes as the Socket.io event `channel:<serviceName>:<name>`.
321
+ *
322
+ * @example
323
+ * ```typescript
324
+ * this.defineChannel(
325
+ * "input",
326
+ * "Read",
327
+ * (payload, ctx) => this.sim.applyInput(ctx.userId, payload),
328
+ * {
329
+ * schema: gameInputSchema,
330
+ * ratePerSecond: 30,
331
+ * requireRoom: () => this.getRoomName(GLOBAL_WORLD_ID),
332
+ * },
333
+ * );
334
+ * ```
335
+ */
336
+ defineChannel<K extends keyof TChannels & string>(name: K, access: AccessLevel, handler: (payload: TChannels[K], context: ServiceChannelContext) => void, options: {
337
+ schema: z.ZodType<TChannels[K]>;
338
+ /** Sustained messages/second per socket. Default: 30 */
339
+ ratePerSecond?: number;
340
+ /** Bucket capacity for bursts. Default: 2 × ratePerSecond */
341
+ burst?: number;
342
+ /** Room the socket must already be in (null result skips the check). */
343
+ requireRoom?: (payload: TChannels[K]) => string | null;
344
+ }): ServiceChannelDefinition<TChannels[K]>;
345
+ /**
346
+ * Get all channels for registry discovery.
347
+ */
348
+ getPublicChannels(): ServiceChannelDefinition<unknown>[];
349
+ /**
350
+ * Synchronous, in-memory access check for a channel message.
351
+ * Called by the registry on the hot path — must not touch the database.
352
+ * Returns false to silently drop the message.
353
+ */
354
+ checkChannelAccess(channel: ServiceChannelDefinition<unknown>, socket: QuickdrawSocket, payload: unknown): boolean;
277
355
  /**
278
356
  * Verify that all expected service methods have been defined via defineMethod().
279
357
  * Call at the end of initMethods() to catch missing implementations at boot time.
@@ -416,6 +494,7 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
416
494
  private readonly logResponses;
417
495
  private connectionHandlerInstalled;
418
496
  private readonly methodRegistry;
497
+ private readonly channelRegistry;
419
498
  constructor(io: Server, options?: ServiceRegistryOptions);
420
499
  /**
421
500
  * Install the single connection handler that wires all registered services.
@@ -434,6 +513,20 @@ declare class ServiceRegistry implements ServiceRegistryInstance {
434
513
  * Discover and store all public methods from a service.
435
514
  */
436
515
  private discoverServiceMethods;
516
+ /**
517
+ * Discover and store all channels from a service.
518
+ */
519
+ private discoverServiceChannels;
520
+ /**
521
+ * Register a channel as a socket event listener.
522
+ *
523
+ * This is the hot path for high-frequency traffic: no ack, no async access
524
+ * checks, no per-message logging. Order of operations per message:
525
+ * token bucket → schema validation → in-memory access check → handler.
526
+ * Any failure silently drops the message; sustained extreme flooding
527
+ * disconnects the socket.
528
+ */
529
+ private registerChannelListener;
437
530
  /**
438
531
  * Register a method as a socket event listener.
439
532
  */
@@ -624,6 +717,12 @@ interface RateLimitOptions {
624
717
  * Events to exclude from rate limiting (e.g., ['ping', 'pong'])
625
718
  */
626
719
  excludeEvents?: string[];
720
+ /**
721
+ * Event-name prefixes to exclude from rate limiting.
722
+ * Pass `[CHANNEL_EVENT_PREFIX]` ("channel:") so channel traffic bypasses
723
+ * the global limiter — channels enforce their own per-socket token buckets.
724
+ */
725
+ excludePrefixes?: string[];
627
726
  /**
628
727
  * Custom key generator for grouping requests.
629
728
  * Default uses socket.id.
@@ -662,7 +761,7 @@ interface RateLimiter {
662
761
  /**
663
762
  * Configuration options.
664
763
  */
665
- options: Required<Pick<RateLimitOptions, "windowMs" | "maxRequests" | "excludeEvents">>;
764
+ options: Required<Pick<RateLimitOptions, "windowMs" | "maxRequests" | "excludeEvents" | "excludePrefixes">>;
666
765
  }
667
766
  /**
668
767
  * Create a rate limiter instance.
@@ -827,6 +926,10 @@ interface DiscordUser {
827
926
  * Discord OAuth provider configuration.
828
927
  */
829
928
  declare const discordProvider: OAuthProvider<DiscordUser>;
929
+ /**
930
+ * Get Discord avatar URL.
931
+ */
932
+ declare function getDiscordAvatarUrl(user: DiscordUser): string;
830
933
 
831
934
  /**
832
935
  * Google user information.
@@ -843,6 +946,206 @@ interface GoogleUser {
843
946
  */
844
947
  declare const googleProvider: OAuthProvider<GoogleUser>;
845
948
 
949
+ /**
950
+ * Mock OAuth provider for local development and tests.
951
+ *
952
+ * Implements a real OAuth 2.0 authorization-code flow served by the app's own
953
+ * API, so `createOAuthURL` and `exchangeOAuthCode` work unchanged — the mock
954
+ * is just a provider whose authorize/token/userinfo endpoints the app mounts
955
+ * via `registerMockOAuthProvider`. Users are supplied by the app (typically
956
+ * seeded demo accounts); no real credentials are involved.
957
+ *
958
+ * Production hard-block: routes are never mounted when NODE_ENV is
959
+ * "production" or ENABLE_MOCK_OAUTH !== "true", and every handler re-checks
960
+ * NODE_ENV at request time. Apps should add a third layer and refuse to boot
961
+ * in production with the flag set.
962
+ */
963
+
964
+ interface MockOAuthUser {
965
+ id: string;
966
+ email: string;
967
+ name: string;
968
+ picture?: string | null;
969
+ }
970
+ declare const DEFAULT_MOCK_PATH_PREFIX = "/auth/mock/provider";
971
+ declare function isMockOAuthEnabled(): boolean;
972
+ interface MockOAuthProviderOptions {
973
+ /**
974
+ * Base URL the API server can use to reach itself for the token/userinfo
975
+ * exchange (server-side fetch). Defaults to apiBaseUrl — override in
976
+ * containers/codespaces where the public URL isn't self-reachable
977
+ * (e.g. "http://localhost:4000").
978
+ */
979
+ internalBaseUrl?: string;
980
+ /** Route prefix for the mock endpoints. Default: "/auth/mock/provider". */
981
+ pathPrefix?: string;
982
+ }
983
+ /**
984
+ * Create the mock OAuth provider definition.
985
+ *
986
+ * `parseUserInfo` returns a GoogleUser-compatible shape with `id` set to the
987
+ * user's email, so `providerAccountId` stays stable across database re-seeds.
988
+ */
989
+ declare function createMockOAuthProvider(apiBaseUrl: string, options?: MockOAuthProviderOptions): OAuthProvider<GoogleUser>;
990
+ interface MockRequest {
991
+ url?: string;
992
+ originalUrl?: string;
993
+ query?: Record<string, unknown>;
994
+ headers: Record<string, string | string[] | undefined>;
995
+ body?: unknown;
996
+ }
997
+ interface MockResponse {
998
+ status(code: number): MockResponse;
999
+ json(body: unknown): unknown;
1000
+ send(body: string): unknown;
1001
+ redirect(url: string): unknown;
1002
+ }
1003
+ type MockHandler = (req: MockRequest, res: MockResponse) => void | Promise<void>;
1004
+ interface MockOAuthRouter {
1005
+ get(path: string, handler: MockHandler): unknown;
1006
+ post(path: string, handler: MockHandler): unknown;
1007
+ }
1008
+ interface RegisterMockOAuthOptions {
1009
+ /**
1010
+ * Supply the users shown in the dev login picker (typically seeded demo
1011
+ * accounts). Injected so core never touches the app's database.
1012
+ */
1013
+ listUsers: () => Promise<MockOAuthUser[]>;
1014
+ /** Route prefix — must match createMockOAuthProvider's. */
1015
+ pathPrefix?: string;
1016
+ /**
1017
+ * Extra origins allowed for redirect_uri beyond CLIENT_URL /
1018
+ * EXTRA_ALLOWED_ORIGINS / localhost / private LAN IPs.
1019
+ */
1020
+ allowedRedirectOrigins?: string[];
1021
+ logger?: Logger;
1022
+ }
1023
+ /**
1024
+ * Mount the mock OAuth provider endpoints (authorize, token, userinfo) on an
1025
+ * Express-compatible router.
1026
+ *
1027
+ * No-ops (returning false) unless `isMockOAuthEnabled()` — i.e. in production
1028
+ * or without ENABLE_MOCK_OAUTH=true the routes are never mounted.
1029
+ */
1030
+ declare function registerMockOAuthProvider(router: MockOAuthRouter, options: RegisterMockOAuthOptions): boolean;
1031
+
1032
+ /**
1033
+ * Origin validation utility for OAuth redirect and CORS security.
1034
+ */
1035
+ declare const OAUTH_RETURN_ORIGIN_COOKIE = "oauth_return_origin";
1036
+ interface ValidateOriginOptions {
1037
+ /**
1038
+ * The primary web client origin. Defaults to process.env.CLIENT_URL.
1039
+ */
1040
+ clientUrl?: string;
1041
+ /**
1042
+ * Additional exact-match origins. Defaults to the comma-separated
1043
+ * process.env.EXTRA_ALLOWED_ORIGINS. Lets a single API back multiple web
1044
+ * hosts (e.g. prod + staging).
1045
+ */
1046
+ extraAllowedOrigins?: string[];
1047
+ /**
1048
+ * App-specific origin patterns (e.g. preview-deploy subdomains).
1049
+ */
1050
+ allowedPatterns?: RegExp[];
1051
+ /**
1052
+ * Allow GitHub Codespace forwarded-port origins
1053
+ * (https://{workspace}-{username}-{port}.app.github.dev). Default: true.
1054
+ */
1055
+ allowCodespaces?: boolean;
1056
+ /**
1057
+ * Allow http://localhost:* when NODE_ENV !== "production". Default: true.
1058
+ */
1059
+ allowLocalhostInDev?: boolean;
1060
+ }
1061
+ /**
1062
+ * Validates if an origin is safe for OAuth redirect / CORS.
1063
+ *
1064
+ * Only the origin (scheme + host + port) is compared, never the path, to
1065
+ * prevent path-injection bypasses.
1066
+ *
1067
+ * @param origin - The origin to validate
1068
+ * @param options - Override env-derived defaults
1069
+ * @returns The validated origin if allowed, null otherwise
1070
+ */
1071
+ declare function validateRedirectOrigin(origin: string, options?: ValidateOriginOptions): string | null;
1072
+
1073
+ /**
1074
+ * Secure session cookie helpers.
1075
+ *
1076
+ * Uses a structural response type so the module works with Express (or any
1077
+ * compatible framework) without a runtime/type dependency on it.
1078
+ */
1079
+ declare const SESSION_COOKIE = "session";
1080
+ interface CookieSettings {
1081
+ httpOnly: boolean;
1082
+ secure: boolean;
1083
+ sameSite: "lax" | "none";
1084
+ path: string;
1085
+ domain?: string;
1086
+ maxAge?: number;
1087
+ }
1088
+ interface CookieResponse {
1089
+ cookie(name: string, value: string, options: CookieSettings): unknown;
1090
+ clearCookie(name: string, options: CookieSettings): unknown;
1091
+ }
1092
+ interface SessionCookieOptions {
1093
+ /** Cookie name. Default: "session". */
1094
+ cookieName?: string;
1095
+ /** Lifetime in ms. Default: 30 days. */
1096
+ maxAgeMs?: number;
1097
+ /** Cookie domain. Defaults to process.env.COOKIE_DOMAIN. */
1098
+ domain?: string;
1099
+ }
1100
+ declare function setSessionCookie(res: CookieResponse, jwt: string, options?: SessionCookieOptions): void;
1101
+ declare function clearSessionCookie(res: CookieResponse, options?: SessionCookieOptions): void;
1102
+
1103
+ /**
1104
+ * REST authentication middleware factory.
1105
+ *
1106
+ * Authenticates requests via session cookie or Bearer token, verifies the JWT,
1107
+ * then checks session revocation via an injected lookup (the app owns the
1108
+ * database). Uses structural request/response types so there is no runtime or
1109
+ * type dependency on Express.
1110
+ */
1111
+ interface AuthRequest {
1112
+ cookies?: Record<string, string>;
1113
+ headers: {
1114
+ authorization?: string;
1115
+ };
1116
+ userId?: string;
1117
+ }
1118
+ interface AuthResponse {
1119
+ status(code: number): {
1120
+ json(body: unknown): unknown;
1121
+ };
1122
+ }
1123
+ interface RequireAuthOptions {
1124
+ /**
1125
+ * Look up the session for a token. Return null when the session does not
1126
+ * exist; sessions past expiresAt are rejected. This mirrors the socket auth
1127
+ * path so a revoked/logged-out session can no longer authenticate REST
1128
+ * requests until the JWT naturally expires.
1129
+ */
1130
+ getSession: (token: string) => Promise<{
1131
+ expiresAt: Date;
1132
+ } | null>;
1133
+ /** JWT signing secret. Defaults to process.env.JWT_SECRET. */
1134
+ jwtSecret?: string;
1135
+ /** Cookie to read the JWT from. Default: "session". */
1136
+ cookieName?: string;
1137
+ }
1138
+ /**
1139
+ * Extract a JWT from the session cookie or Authorization header
1140
+ * (cookie takes priority).
1141
+ */
1142
+ declare function extractBearerOrCookieToken(req: AuthRequest, cookieName?: string): string | null;
1143
+ /**
1144
+ * Create an Express-compatible middleware that attaches req.userId on success
1145
+ * and responds 401 on failure.
1146
+ */
1147
+ declare function createRequireAuth(options: RequireAuthOptions): (req: AuthRequest, res: AuthResponse, next: () => void) => Promise<void>;
1148
+
846
1149
  /**
847
1150
  * Environment variable validation utilities.
848
1151
  * Helps ensure required environment variables are set before the application starts.
@@ -927,6 +1230,28 @@ declare function checkEnv(options: ValidateEnvOptions): EnvValidationResult;
927
1230
  */
928
1231
  declare function requireEnv(name: string, defaultValue?: string): string;
929
1232
 
1233
+ /**
1234
+ * AES-256-GCM encryption helpers for at-rest secrets (e.g. stored OAuth tokens).
1235
+ *
1236
+ * Requires ENCRYPTION_KEY: a 64-character hex string (32 bytes).
1237
+ * Ciphertext format: "iv:authTag:ciphertext" (all hex).
1238
+ */
1239
+
1240
+ declare function encrypt(plaintext: string): string;
1241
+ declare function decrypt(ciphertext: string): string;
1242
+ /**
1243
+ * Returns true when the value looks like AES-256-GCM ciphertext
1244
+ * (iv:authTag:data, all hex). Plaintext OAuth tokens (e.g. gho_xxx) will not match.
1245
+ */
1246
+ declare function isEncrypted(value: string): boolean;
1247
+ /**
1248
+ * Decrypt if the value is in encrypted format, otherwise return as-is.
1249
+ * Allows graceful handling of pre-migration plaintext tokens.
1250
+ */
1251
+ declare function decryptIfEncrypted(value: string): string;
1252
+ /** Safe wrapper that returns `null` instead of throwing on invalid/stale ciphertext. */
1253
+ declare function tryDecrypt(ciphertext: string, logger?: Logger): string | null;
1254
+
930
1255
  /**
931
1256
  * Options for customizing field generation.
932
1257
  */
@@ -1106,4 +1431,4 @@ declare function createMcpRoutes(router: Router, options: McpHttpRoutesOptions):
1106
1431
  */
1107
1432
  declare function generateToolMetadata(options: GenerateToolMetadataOptions): void;
1108
1433
 
1109
- export { AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, BaseService, BaseServiceInstance, BaseServiceOptions, type DiscordUser, type EnvValidationResult, type GenerateToolMetadataOptions, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type McpHttpRoutesOptions, ServiceMethodContext as McpMethodContext, ServiceMethodDefinition as McpMethodDefinition, McpRegistry, type McpRegistryInstance, type McpRegistryOptions, type McpServiceInstance, type McpStdioServerOptions, type McpToolDefinition, type MethodSpec, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, type RateLimitOptions, type RateLimiter, type RedisAdapterOptions, type RedisAdapterResult, type ServiceInfo, type ServiceMethodInfo, ServiceRegistry, ServiceRegistryInstance, type ServiceRegistryOptions, type ServiceToolSpec, type ValidateEnvOptions, type ZodToAdminFieldsOptions, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, createJWT, createMcpRoutes, createMcpStdioServer, createOAuthURL, createQuickdrawServer, createRateLimiter, createTieredRateLimiter, discordProvider, exchangeOAuthCode, generateToolMetadata, getDefaultEntityFields, googleProvider, isRedisAdapterAvailable, mergeWithDefaultFields, requireEnv, setupRedisAdapter, validateEnv, verifyJWT, zodToAdminFields };
1434
+ export { AdminListPayload, AdminListResponse, AdminSetACLPayload, AdminSubscribersResponse, type AuthRequest, type AuthResponse, BaseService, BaseServiceInstance, BaseServiceOptions, type CookieResponse, type CookieSettings, DEFAULT_MOCK_PATH_PREFIX, type DiscordUser, type EnvValidationResult, type GenerateToolMetadataOptions, type GoogleUser, InstallAdminMethodsOptions, type JWTPayload, type McpHttpRoutesOptions, ServiceMethodContext as McpMethodContext, ServiceMethodDefinition as McpMethodDefinition, McpRegistry, type McpRegistryInstance, type McpRegistryOptions, type McpServiceInstance, type McpStdioServerOptions, type McpToolDefinition, type MethodSpec, type MockOAuthProviderOptions, type MockOAuthRouter, type MockOAuthUser, OAUTH_RETURN_ORIGIN_COOKIE, type OAuthConfig, type OAuthProvider, type OAuthTokenResponse, PrismaDelegate, QuickdrawServerOptions, QuickdrawServerResult, QuickdrawSocket, type RateLimitOptions, type RateLimiter, type RedisAdapterOptions, type RedisAdapterResult, type RegisterMockOAuthOptions, type RequireAuthOptions, SESSION_COOKIE, type ServiceInfo, type ServiceMethodInfo, ServiceRegistry, ServiceRegistryInstance, type ServiceRegistryOptions, type ServiceToolSpec, type SessionCookieOptions, type ValidateEnvOptions, type ValidateOriginOptions, type ZodToAdminFieldsOptions, applyRateLimitMiddleware, bootstrapMcpServer, checkEnv, clearSessionCookie, createJWT, createMcpRoutes, createMcpStdioServer, createMockOAuthProvider, createOAuthURL, createQuickdrawServer, createRateLimiter, createRequireAuth, createTieredRateLimiter, decrypt, decryptIfEncrypted, discordProvider, encrypt, exchangeOAuthCode, extractBearerOrCookieToken, generateToolMetadata, getDefaultEntityFields, getDiscordAvatarUrl, googleProvider, isEncrypted, isMockOAuthEnabled, isRedisAdapterAvailable, mergeWithDefaultFields, registerMockOAuthProvider, requireEnv, setSessionCookie, setupRedisAdapter, tryDecrypt, validateEnv, validateRedirectOrigin, verifyJWT, zodToAdminFields };