@terreno/api 0.13.2 → 0.14.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 (175) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +53 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.asyncHandler.test.d.ts +1 -0
  4. package/dist/api.asyncHandler.test.js +236 -0
  5. package/dist/api.d.ts +15 -4
  6. package/dist/api.errors.test.js +1 -0
  7. package/dist/api.hooks.test.js +1 -0
  8. package/dist/api.js +153 -104
  9. package/dist/api.query.test.js +1 -0
  10. package/dist/api.test.js +174 -0
  11. package/dist/auth.d.ts +10 -5
  12. package/dist/auth.js +163 -90
  13. package/dist/auth.test.js +159 -0
  14. package/dist/betterAuthApp.test.js +1 -0
  15. package/dist/betterAuthSetup.d.ts +5 -6
  16. package/dist/betterAuthSetup.js +17 -14
  17. package/dist/betterAuthSetup.test.js +1 -0
  18. package/dist/config.d.ts +48 -0
  19. package/dist/config.js +248 -0
  20. package/dist/config.test.d.ts +1 -0
  21. package/dist/config.test.js +328 -0
  22. package/dist/configuration.test.js +1 -0
  23. package/dist/configurationApp.d.ts +1 -1
  24. package/dist/configurationApp.js +17 -13
  25. package/dist/configurationPlugin.test.js +1 -0
  26. package/dist/consentApp.test.js +1 -0
  27. package/dist/envConfigurationPlugin.d.ts +2 -0
  28. package/dist/envConfigurationPlugin.js +173 -0
  29. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  30. package/dist/envConfigurationPlugin.test.js +322 -0
  31. package/dist/errors.d.ts +18 -7
  32. package/dist/errors.js +106 -10
  33. package/dist/errors.test.js +16 -1
  34. package/dist/example.js +16 -7
  35. package/dist/expressServer.d.ts +10 -9
  36. package/dist/expressServer.js +62 -53
  37. package/dist/expressServer.test.js +53 -2
  38. package/dist/githubAuth.d.ts +2 -1
  39. package/dist/githubAuth.js +41 -26
  40. package/dist/githubAuth.test.js +1 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +4 -0
  43. package/dist/logger.d.ts +1 -1
  44. package/dist/logger.js +42 -20
  45. package/dist/models/versionConfig.d.ts +2 -0
  46. package/dist/models/versionConfig.js +8 -0
  47. package/dist/notifiers/googleChatNotifier.js +14 -16
  48. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  49. package/dist/notifiers/slackNotifier.js +16 -14
  50. package/dist/notifiers/slackNotifier.test.js +41 -3
  51. package/dist/notifiers/zoomNotifier.js +7 -10
  52. package/dist/notifiers/zoomNotifier.test.js +1 -0
  53. package/dist/openApi.d.ts +1 -1
  54. package/dist/openApi.test.js +1 -0
  55. package/dist/openApiBuilder.d.ts +39 -6
  56. package/dist/openApiBuilder.js +1 -31
  57. package/dist/openApiBuilder.test.js +1 -0
  58. package/dist/openApiValidator.js +1 -0
  59. package/dist/openApiValidator.test.js +65 -0
  60. package/dist/permissions.d.ts +4 -4
  61. package/dist/permissions.js +67 -65
  62. package/dist/permissions.middleware.test.js +1 -0
  63. package/dist/permissions.test.js +1 -0
  64. package/dist/plugins.d.ts +5 -5
  65. package/dist/plugins.js +18 -9
  66. package/dist/plugins.test.js +1 -1
  67. package/dist/populate.d.ts +15 -8
  68. package/dist/populate.js +23 -24
  69. package/dist/populate.test.js +1 -0
  70. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  71. package/dist/realtime/changeStreamWatcher.js +720 -0
  72. package/dist/realtime/index.d.ts +6 -0
  73. package/dist/realtime/index.js +27 -0
  74. package/dist/realtime/queryMatcher.d.ts +14 -0
  75. package/dist/realtime/queryMatcher.js +250 -0
  76. package/dist/realtime/queryStore.d.ts +37 -0
  77. package/dist/realtime/queryStore.js +195 -0
  78. package/dist/realtime/realtime.test.d.ts +10 -0
  79. package/dist/realtime/realtime.test.js +2158 -0
  80. package/dist/realtime/realtimeApp.d.ts +93 -0
  81. package/dist/realtime/realtimeApp.js +560 -0
  82. package/dist/realtime/registry.d.ts +40 -0
  83. package/dist/realtime/registry.js +38 -0
  84. package/dist/realtime/socketUser.d.ts +10 -0
  85. package/dist/realtime/socketUser.js +17 -0
  86. package/dist/realtime/types.d.ts +100 -0
  87. package/dist/realtime/types.js +2 -0
  88. package/dist/requestContext.d.ts +37 -0
  89. package/dist/requestContext.js +344 -0
  90. package/dist/requestContext.test.d.ts +1 -0
  91. package/dist/requestContext.test.js +241 -0
  92. package/dist/terrenoApp.d.ts +8 -0
  93. package/dist/terrenoApp.js +50 -13
  94. package/dist/terrenoApp.test.js +194 -21
  95. package/dist/terrenoPlugin.d.ts +11 -0
  96. package/dist/tests/bunSetup.js +1 -0
  97. package/dist/tests.js +1 -1
  98. package/dist/transformers.d.ts +2 -2
  99. package/dist/transformers.js +5 -3
  100. package/dist/transformers.test.js +90 -0
  101. package/dist/types/consentResponse.d.ts +6 -3
  102. package/dist/versionCheckPlugin.d.ts +2 -0
  103. package/dist/versionCheckPlugin.js +18 -12
  104. package/package.json +4 -2
  105. package/src/__tests__/versionCheckPlugin.test.ts +37 -3
  106. package/src/api.arrayOperations.test.ts +1 -0
  107. package/src/api.asyncHandler.test.ts +177 -0
  108. package/src/api.errors.test.ts +1 -0
  109. package/src/api.hooks.test.ts +1 -0
  110. package/src/api.query.test.ts +1 -0
  111. package/src/api.test.ts +132 -0
  112. package/src/api.ts +199 -84
  113. package/src/auth.test.ts +160 -0
  114. package/src/auth.ts +120 -50
  115. package/src/betterAuthApp.test.ts +1 -0
  116. package/src/betterAuthSetup.test.ts +1 -0
  117. package/src/betterAuthSetup.ts +46 -19
  118. package/src/config.test.ts +255 -0
  119. package/src/config.ts +206 -0
  120. package/src/configuration.test.ts +1 -0
  121. package/src/configurationApp.ts +59 -24
  122. package/src/configurationPlugin.test.ts +1 -0
  123. package/src/consentApp.test.ts +1 -0
  124. package/src/envConfigurationPlugin.test.ts +143 -0
  125. package/src/envConfigurationPlugin.ts +100 -0
  126. package/src/errors.test.ts +19 -1
  127. package/src/errors.ts +94 -20
  128. package/src/example.ts +46 -21
  129. package/src/express.d.ts +18 -1
  130. package/src/expressServer.test.ts +50 -2
  131. package/src/expressServer.ts +80 -50
  132. package/src/githubAuth.test.ts +1 -0
  133. package/src/githubAuth.ts +59 -38
  134. package/src/index.ts +4 -0
  135. package/src/logger.ts +47 -17
  136. package/src/models/versionConfig.ts +13 -2
  137. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  138. package/src/notifiers/googleChatNotifier.ts +7 -9
  139. package/src/notifiers/slackNotifier.test.ts +29 -3
  140. package/src/notifiers/slackNotifier.ts +9 -7
  141. package/src/notifiers/zoomNotifier.test.ts +1 -0
  142. package/src/notifiers/zoomNotifier.ts +8 -11
  143. package/src/openApi.test.ts +1 -0
  144. package/src/openApi.ts +4 -4
  145. package/src/openApiBuilder.test.ts +1 -0
  146. package/src/openApiBuilder.ts +14 -11
  147. package/src/openApiValidator.test.ts +59 -0
  148. package/src/openApiValidator.ts +3 -2
  149. package/src/permissions.middleware.test.ts +1 -0
  150. package/src/permissions.test.ts +1 -0
  151. package/src/permissions.ts +30 -25
  152. package/src/plugins.test.ts +1 -1
  153. package/src/plugins.ts +21 -14
  154. package/src/populate.test.ts +1 -0
  155. package/src/populate.ts +44 -36
  156. package/src/realtime/changeStreamWatcher.ts +568 -0
  157. package/src/realtime/index.ts +34 -0
  158. package/src/realtime/queryMatcher.ts +179 -0
  159. package/src/realtime/queryStore.ts +132 -0
  160. package/src/realtime/realtime.test.ts +1755 -0
  161. package/src/realtime/realtimeApp.ts +478 -0
  162. package/src/realtime/registry.ts +64 -0
  163. package/src/realtime/socketUser.ts +25 -0
  164. package/src/realtime/types.ts +112 -0
  165. package/src/requestContext.test.ts +196 -0
  166. package/src/requestContext.ts +368 -0
  167. package/src/terrenoApp.test.ts +137 -11
  168. package/src/terrenoApp.ts +64 -17
  169. package/src/terrenoPlugin.ts +12 -0
  170. package/src/tests/bunSetup.ts +1 -0
  171. package/src/tests.ts +7 -2
  172. package/src/transformers.test.ts +70 -2
  173. package/src/transformers.ts +15 -7
  174. package/src/types/consentResponse.ts +8 -10
  175. package/src/versionCheckPlugin.ts +15 -7
@@ -0,0 +1,93 @@
1
+ import type http from "node:http";
2
+ import type express from "express";
3
+ import { Server } from "socket.io";
4
+ import type { TerrenoPlugin } from "../terrenoPlugin";
5
+ import { type SocketWithDecodedToken } from "./socketUser";
6
+ import type { RealtimeAppOptions } from "./types";
7
+ /**
8
+ * Caps on per-socket subscriptions. Prevents a malicious or buggy client from
9
+ * exhausting server memory by opening unbounded subscriptions.
10
+ */
11
+ export declare const MAX_MODEL_SUBSCRIPTIONS = 50;
12
+ export declare const MAX_DOCUMENT_SUBSCRIPTIONS = 500;
13
+ export declare const MAX_QUERY_SUBSCRIPTIONS = 100;
14
+ /**
15
+ * Replace any userinfo (`user:password@`) component in a URL with `***@` so the
16
+ * credentials are not written to logs. Falls back to a regex when the string
17
+ * isn't a valid URL the standard `URL` parser can read.
18
+ *
19
+ * Exported for testing.
20
+ */
21
+ export declare const redactCredentials: (url: string) => string;
22
+ /**
23
+ * Minimal shape this module requires from a Socket.io socket. Lets tests pass a
24
+ * mock without standing up a real server.
25
+ */
26
+ export interface RealtimeSocketLike extends SocketWithDecodedToken {
27
+ id: string;
28
+ join: (room: string) => Promise<void> | void;
29
+ leave: (room: string) => Promise<void> | void;
30
+ emit: (event: string, payload: unknown) => void;
31
+ on: (event: string, handler: (...args: any[]) => any) => void;
32
+ }
33
+ /**
34
+ * Install the realtime subscription handlers on a single socket. Extracted from the
35
+ * RealtimeApp connection handler so this logic can be unit-tested with a mock socket
36
+ * (no real Socket.io / HTTP server / JWT handshake required).
37
+ *
38
+ * Enforces:
39
+ * - per-socket subscription caps (DoS protection)
40
+ * - registry membership (only realtime-enabled collections can be subscribed)
41
+ * - owner-strategy isolation (non-admin users cannot subscribe to other users' rooms)
42
+ * - server-side queryId computation (clients can't hijack queries by colliding ids)
43
+ */
44
+ export declare const installRealtimeSocketHandlers: (socket: RealtimeSocketLike, options?: {
45
+ logInfo?: (msg: string) => void;
46
+ }) => void;
47
+ /**
48
+ * TerrenoPlugin that provides real-time sync via Socket.io and MongoDB change streams.
49
+ *
50
+ * Attaches a Socket.io server to the HTTP server created by TerrenoApp.start(),
51
+ * sets up JWT authentication for socket connections, manages room subscriptions
52
+ * (model, document, and query rooms), and starts a change stream watcher that
53
+ * emits events to connected clients.
54
+ *
55
+ * ## Subscription types
56
+ *
57
+ * - **Model rooms**: `subscribe:model` / `unsubscribe:model` — receive all events for a collection
58
+ * - **Document rooms**: `subscribe:document` / `unsubscribe:document` — receive events for a single document
59
+ * - **Query rooms**: `subscribe:query` / `unsubscribe:query` — receive events matching a MongoDB query
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * const app = new TerrenoApp({
64
+ * userModel: User,
65
+ * realtime: { debug: true },
66
+ * })
67
+ * .register(todoRouter) // todoRouter has realtime config
68
+ * .start();
69
+ * ```
70
+ */
71
+ export declare class RealtimeApp implements TerrenoPlugin {
72
+ private io;
73
+ private config;
74
+ constructor(config?: RealtimeAppOptions);
75
+ /**
76
+ * Register routes and middleware. Adds a /realtime/health endpoint.
77
+ */
78
+ register(app: express.Application): void;
79
+ /**
80
+ * Called after the HTTP server is created. Sets up Socket.io, auth, rooms,
81
+ * and starts the change stream watcher.
82
+ */
83
+ onServerCreated(server: http.Server): void;
84
+ /**
85
+ * Get the Socket.io server instance.
86
+ */
87
+ getIo(): Server | null;
88
+ /**
89
+ * Gracefully shut down the real-time server.
90
+ */
91
+ close(): Promise<void>;
92
+ private setupAdapter;
93
+ }
@@ -0,0 +1,560 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
47
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
48
+ return new (P || (P = Promise))(function (resolve, reject) {
49
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
50
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
51
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
52
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
53
+ });
54
+ };
55
+ var __generator = (this && this.__generator) || function (thisArg, body) {
56
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
57
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
58
+ function verb(n) { return function (v) { return step([n, v]); }; }
59
+ function step(op) {
60
+ if (f) throw new TypeError("Generator is already executing.");
61
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
62
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
63
+ if (y = 0, t) op = [op[0] & 2, t.value];
64
+ switch (op[0]) {
65
+ case 0: case 1: t = op; break;
66
+ case 4: _.label++; return { value: op[1], done: false };
67
+ case 5: _.label++; y = op[1]; op = [0]; continue;
68
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
69
+ default:
70
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
71
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
72
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
73
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
74
+ if (t[2]) _.ops.pop();
75
+ _.trys.pop(); continue;
76
+ }
77
+ op = body.call(thisArg, _);
78
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
79
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
80
+ }
81
+ };
82
+ Object.defineProperty(exports, "__esModule", { value: true });
83
+ exports.RealtimeApp = exports.installRealtimeSocketHandlers = exports.redactCredentials = exports.MAX_QUERY_SUBSCRIPTIONS = exports.MAX_DOCUMENT_SUBSCRIPTIONS = exports.MAX_MODEL_SUBSCRIPTIONS = void 0;
84
+ var Sentry = __importStar(require("@sentry/bun"));
85
+ var socketio_jwt_1 = require("@thream/socketio-jwt");
86
+ var socket_io_1 = require("socket.io");
87
+ var logger_1 = require("../logger");
88
+ var permissions_1 = require("../permissions");
89
+ var changeStreamWatcher_1 = require("./changeStreamWatcher");
90
+ var queryStore_1 = require("./queryStore");
91
+ var registry_1 = require("./registry");
92
+ var socketUser_1 = require("./socketUser");
93
+ /**
94
+ * Caps on per-socket subscriptions. Prevents a malicious or buggy client from
95
+ * exhausting server memory by opening unbounded subscriptions.
96
+ */
97
+ exports.MAX_MODEL_SUBSCRIPTIONS = 50;
98
+ exports.MAX_DOCUMENT_SUBSCRIPTIONS = 500;
99
+ exports.MAX_QUERY_SUBSCRIPTIONS = 100;
100
+ /**
101
+ * Replace any userinfo (`user:password@`) component in a URL with `***@` so the
102
+ * credentials are not written to logs. Falls back to a regex when the string
103
+ * isn't a valid URL the standard `URL` parser can read.
104
+ *
105
+ * Exported for testing.
106
+ */
107
+ var redactCredentials = function (url) {
108
+ try {
109
+ var parsed = new URL(url);
110
+ if (parsed.username || parsed.password) {
111
+ parsed.username = "";
112
+ parsed.password = "";
113
+ // URL serialization drops the empty userinfo; reinsert a sentinel so logs make it
114
+ // obvious credentials were stripped rather than silently absent from the source URL.
115
+ return parsed.toString().replace("".concat(parsed.protocol, "//"), "".concat(parsed.protocol, "//***@"));
116
+ }
117
+ return parsed.toString();
118
+ }
119
+ catch (_a) {
120
+ return url.replace(/^(\w+):\/\/[^@/]*@/, "$1://***@");
121
+ }
122
+ };
123
+ exports.redactCredentials = redactCredentials;
124
+ var canSubscribe = function (entry, method, user) { return __awaiter(void 0, void 0, void 0, function () {
125
+ var permissions;
126
+ return __generator(this, function (_a) {
127
+ permissions = entry.options.permissions[method];
128
+ return [2 /*return*/, (0, permissions_1.checkPermissions)(method, permissions, user)];
129
+ });
130
+ }); };
131
+ var getAuthorizedQuery = function (entry, query, user) { return __awaiter(void 0, void 0, void 0, function () {
132
+ var filteredQuery, error_1;
133
+ return __generator(this, function (_a) {
134
+ switch (_a.label) {
135
+ case 0: return [4 /*yield*/, canSubscribe(entry, "list", user)];
136
+ case 1:
137
+ if (!(_a.sent())) {
138
+ return [2 /*return*/, null];
139
+ }
140
+ if (!entry.options.queryFilter) {
141
+ return [2 /*return*/, query];
142
+ }
143
+ _a.label = 2;
144
+ case 2:
145
+ _a.trys.push([2, 4, , 5]);
146
+ return [4 /*yield*/, entry.options.queryFilter(user, query)];
147
+ case 3:
148
+ filteredQuery = _a.sent();
149
+ return [3 /*break*/, 5];
150
+ case 4:
151
+ error_1 = _a.sent();
152
+ logger_1.logger.error("[realtime] queryFilter threw for ".concat(entry.modelName, "/list: ").concat(error_1, ". Denying query subscription."));
153
+ Sentry.captureException(error_1);
154
+ return [2 /*return*/, null];
155
+ case 5:
156
+ if (filteredQuery === null) {
157
+ return [2 /*return*/, null];
158
+ }
159
+ return [2 /*return*/, __assign(__assign({}, query), filteredQuery)];
160
+ }
161
+ });
162
+ }); };
163
+ /**
164
+ * Install the realtime subscription handlers on a single socket. Extracted from the
165
+ * RealtimeApp connection handler so this logic can be unit-tested with a mock socket
166
+ * (no real Socket.io / HTTP server / JWT handshake required).
167
+ *
168
+ * Enforces:
169
+ * - per-socket subscription caps (DoS protection)
170
+ * - registry membership (only realtime-enabled collections can be subscribed)
171
+ * - owner-strategy isolation (non-admin users cannot subscribe to other users' rooms)
172
+ * - server-side queryId computation (clients can't hijack queries by colliding ids)
173
+ */
174
+ var installRealtimeSocketHandlers = function (socket, options) {
175
+ var _a, _b, _c;
176
+ if (options === void 0) { options = {}; }
177
+ var logInfo = (_a = options.logInfo) !== null && _a !== void 0 ? _a : (function () { });
178
+ var userId = (_b = socket.decodedToken) === null || _b === void 0 ? void 0 : _b.id;
179
+ var isAdmin = ((_c = socket.decodedToken) === null || _c === void 0 ? void 0 : _c.admin) === true;
180
+ var user = (0, socketUser_1.getSocketUser)(socket);
181
+ var counts = { document: 0, model: 0, query: 0 };
182
+ var joinUserRooms = function () { return __awaiter(void 0, void 0, void 0, function () {
183
+ return __generator(this, function (_a) {
184
+ switch (_a.label) {
185
+ case 0:
186
+ if (!userId) return [3 /*break*/, 3];
187
+ return [4 /*yield*/, socket.join("user:".concat(userId))];
188
+ case 1:
189
+ _a.sent();
190
+ return [4 /*yield*/, socket.join("authenticated")];
191
+ case 2:
192
+ _a.sent();
193
+ logInfo("[realtime] User ".concat(userId, " connected"));
194
+ _a.label = 3;
195
+ case 3:
196
+ if (!isAdmin) return [3 /*break*/, 5];
197
+ return [4 /*yield*/, socket.join("admin")];
198
+ case 4:
199
+ _a.sent();
200
+ logInfo("[realtime] Admin user ".concat(userId, " joined admin room"));
201
+ _a.label = 5;
202
+ case 5: return [2 /*return*/];
203
+ }
204
+ });
205
+ }); };
206
+ // Fire-and-forget — there is nothing useful for the caller to await.
207
+ void joinUserRooms();
208
+ socket.on("subscribe:model", function (modelName) { return __awaiter(void 0, void 0, void 0, function () {
209
+ var entry;
210
+ return __generator(this, function (_a) {
211
+ switch (_a.label) {
212
+ case 0:
213
+ if (typeof modelName !== "string" || modelName.length === 0) {
214
+ return [2 /*return*/];
215
+ }
216
+ if (counts.model >= exports.MAX_MODEL_SUBSCRIPTIONS) {
217
+ logInfo("[realtime] User ".concat(userId, " hit model subscription limit"));
218
+ return [2 /*return*/];
219
+ }
220
+ entry = (0, registry_1.findRegistryEntryByRoutePath)(modelName);
221
+ if (!entry) {
222
+ logInfo("[realtime] User ".concat(userId, " denied model subscription: collection \"").concat(modelName, "\" not registered"));
223
+ return [2 /*return*/];
224
+ }
225
+ // Owner-strategy models fan out via user:{ownerId} — there is no shared model room
226
+ // that should be open to all users. Owners receive events through their user room
227
+ // automatically; admins can use the admin room to see everything.
228
+ if (entry.config.roomStrategy === "owner" && !isAdmin) {
229
+ logInfo("[realtime] User ".concat(userId, " denied model subscription for ").concat(modelName, ": ") +
230
+ "owner strategy restricts model room to admins");
231
+ return [2 /*return*/];
232
+ }
233
+ return [4 /*yield*/, canSubscribe(entry, "list", user)];
234
+ case 1:
235
+ if (!(_a.sent())) {
236
+ logInfo("[realtime] User ".concat(userId, " denied model subscription for ").concat(modelName, ": list permission denied"));
237
+ return [2 /*return*/];
238
+ }
239
+ counts.model += 1;
240
+ return [4 /*yield*/, socket.join("model:".concat(modelName))];
241
+ case 2:
242
+ _a.sent();
243
+ logInfo("[realtime] User ".concat(userId, " subscribed to model:").concat(modelName));
244
+ return [2 /*return*/];
245
+ }
246
+ });
247
+ }); });
248
+ socket.on("unsubscribe:model", function (modelName) { return __awaiter(void 0, void 0, void 0, function () {
249
+ return __generator(this, function (_a) {
250
+ switch (_a.label) {
251
+ case 0:
252
+ if (!(typeof modelName === "string" && modelName.length > 0)) return [3 /*break*/, 2];
253
+ return [4 /*yield*/, socket.leave("model:".concat(modelName))];
254
+ case 1:
255
+ _a.sent();
256
+ counts.model = Math.max(0, counts.model - 1);
257
+ logInfo("[realtime] User ".concat(userId, " unsubscribed from model:").concat(modelName));
258
+ _a.label = 2;
259
+ case 2: return [2 /*return*/];
260
+ }
261
+ });
262
+ }); });
263
+ socket.on("subscribe:document", function (payload) { return __awaiter(void 0, void 0, void 0, function () {
264
+ var entry, room;
265
+ return __generator(this, function (_a) {
266
+ switch (_a.label) {
267
+ case 0:
268
+ if (!(payload === null || payload === void 0 ? void 0 : payload.collection) ||
269
+ !(payload === null || payload === void 0 ? void 0 : payload.id) ||
270
+ typeof payload.collection !== "string" ||
271
+ typeof payload.id !== "string") {
272
+ return [2 /*return*/];
273
+ }
274
+ if (counts.document >= exports.MAX_DOCUMENT_SUBSCRIPTIONS) {
275
+ logInfo("[realtime] User ".concat(userId, " hit document subscription limit"));
276
+ return [2 /*return*/];
277
+ }
278
+ entry = (0, registry_1.findRegistryEntryByRoutePath)(payload.collection);
279
+ if (!entry) {
280
+ logInfo("[realtime] User ".concat(userId, " denied document subscription: ") +
281
+ "collection \"".concat(payload.collection, "\" not registered"));
282
+ return [2 /*return*/];
283
+ }
284
+ return [4 /*yield*/, canSubscribe(entry, "read", user)];
285
+ case 1:
286
+ if (!(_a.sent())) {
287
+ logInfo("[realtime] User ".concat(userId, " denied document subscription for ") +
288
+ "".concat(payload.collection, "/").concat(payload.id, ": read permission denied"));
289
+ return [2 /*return*/];
290
+ }
291
+ if (entry.config.roomStrategy === "owner" && !isAdmin) {
292
+ logInfo("[realtime] User ".concat(userId, " denied document subscription for ") +
293
+ "".concat(payload.collection, "/").concat(payload.id, ": owner strategy requires admin"));
294
+ return [2 /*return*/];
295
+ }
296
+ counts.document += 1;
297
+ room = "document:".concat(payload.collection, ":").concat(payload.id);
298
+ return [4 /*yield*/, socket.join(room)];
299
+ case 2:
300
+ _a.sent();
301
+ logInfo("[realtime] User ".concat(userId, " subscribed to ").concat(room));
302
+ return [2 /*return*/];
303
+ }
304
+ });
305
+ }); });
306
+ socket.on("unsubscribe:document", function (payload) { return __awaiter(void 0, void 0, void 0, function () {
307
+ var room;
308
+ return __generator(this, function (_a) {
309
+ switch (_a.label) {
310
+ case 0:
311
+ if (!((payload === null || payload === void 0 ? void 0 : payload.collection) && (payload === null || payload === void 0 ? void 0 : payload.id))) return [3 /*break*/, 2];
312
+ room = "document:".concat(payload.collection, ":").concat(payload.id);
313
+ return [4 /*yield*/, socket.leave(room)];
314
+ case 1:
315
+ _a.sent();
316
+ counts.document = Math.max(0, counts.document - 1);
317
+ logInfo("[realtime] User ".concat(userId, " unsubscribed from ").concat(room));
318
+ _a.label = 2;
319
+ case 2: return [2 /*return*/];
320
+ }
321
+ });
322
+ }); });
323
+ socket.on("subscribe:query", function (payload) { return __awaiter(void 0, void 0, void 0, function () {
324
+ var entry, query, queryId;
325
+ return __generator(this, function (_a) {
326
+ switch (_a.label) {
327
+ case 0:
328
+ if (!(payload === null || payload === void 0 ? void 0 : payload.collection) ||
329
+ !(payload === null || payload === void 0 ? void 0 : payload.query) ||
330
+ typeof payload.collection !== "string" ||
331
+ typeof payload.query !== "object" ||
332
+ Array.isArray(payload.query)) {
333
+ return [2 /*return*/];
334
+ }
335
+ if (counts.query >= exports.MAX_QUERY_SUBSCRIPTIONS) {
336
+ logInfo("[realtime] User ".concat(userId, " hit query subscription limit"));
337
+ return [2 /*return*/];
338
+ }
339
+ entry = (0, registry_1.findRegistryEntryByRoutePath)(payload.collection);
340
+ if (!entry) {
341
+ logInfo("[realtime] User ".concat(userId, " denied query subscription: ") +
342
+ "collection \"".concat(payload.collection, "\" not registered"));
343
+ return [2 /*return*/];
344
+ }
345
+ return [4 /*yield*/, getAuthorizedQuery(entry, __assign({}, payload.query), user)];
346
+ case 1:
347
+ query = _a.sent();
348
+ if (!query) {
349
+ logInfo("[realtime] User ".concat(userId, " denied query subscription for ").concat(payload.collection, ": ") +
350
+ "list permission or query filter denied");
351
+ return [2 /*return*/];
352
+ }
353
+ if (entry.config.roomStrategy === "owner" && !isAdmin) {
354
+ if (!userId) {
355
+ return [2 /*return*/];
356
+ }
357
+ query = __assign(__assign({}, query), { ownerId: userId });
358
+ }
359
+ queryId = (0, queryStore_1.computeQueryId)(payload.collection, query);
360
+ (0, queryStore_1.addQuerySubscription)(socket.id, payload.collection, query, queryId);
361
+ counts.query += 1;
362
+ return [4 /*yield*/, socket.join("query:".concat(queryId))];
363
+ case 2:
364
+ _a.sent();
365
+ socket.emit("query:subscribed", {
366
+ clientQueryId: payload.queryId,
367
+ collection: payload.collection,
368
+ queryId: queryId,
369
+ });
370
+ logInfo("[realtime] User ".concat(userId, " subscribed to query:").concat(queryId, " on ").concat(payload.collection));
371
+ return [2 /*return*/];
372
+ }
373
+ });
374
+ }); });
375
+ socket.on("unsubscribe:query", function (payload) { return __awaiter(void 0, void 0, void 0, function () {
376
+ return __generator(this, function (_a) {
377
+ switch (_a.label) {
378
+ case 0:
379
+ if (!(payload === null || payload === void 0 ? void 0 : payload.queryId)) return [3 /*break*/, 2];
380
+ (0, queryStore_1.removeQuerySubscription)(socket.id, payload.queryId);
381
+ return [4 /*yield*/, socket.leave("query:".concat(payload.queryId))];
382
+ case 1:
383
+ _a.sent();
384
+ counts.query = Math.max(0, counts.query - 1);
385
+ logInfo("[realtime] User ".concat(userId, " unsubscribed from query:").concat(payload.queryId));
386
+ _a.label = 2;
387
+ case 2: return [2 /*return*/];
388
+ }
389
+ });
390
+ }); });
391
+ socket.on("disconnect", function () {
392
+ (0, queryStore_1.removeAllSocketQueries)(socket.id);
393
+ logInfo("[realtime] User ".concat(userId, " disconnected"));
394
+ });
395
+ };
396
+ exports.installRealtimeSocketHandlers = installRealtimeSocketHandlers;
397
+ /**
398
+ * TerrenoPlugin that provides real-time sync via Socket.io and MongoDB change streams.
399
+ *
400
+ * Attaches a Socket.io server to the HTTP server created by TerrenoApp.start(),
401
+ * sets up JWT authentication for socket connections, manages room subscriptions
402
+ * (model, document, and query rooms), and starts a change stream watcher that
403
+ * emits events to connected clients.
404
+ *
405
+ * ## Subscription types
406
+ *
407
+ * - **Model rooms**: `subscribe:model` / `unsubscribe:model` — receive all events for a collection
408
+ * - **Document rooms**: `subscribe:document` / `unsubscribe:document` — receive events for a single document
409
+ * - **Query rooms**: `subscribe:query` / `unsubscribe:query` — receive events matching a MongoDB query
410
+ *
411
+ * @example
412
+ * ```typescript
413
+ * const app = new TerrenoApp({
414
+ * userModel: User,
415
+ * realtime: { debug: true },
416
+ * })
417
+ * .register(todoRouter) // todoRouter has realtime config
418
+ * .start();
419
+ * ```
420
+ */
421
+ var RealtimeApp = /** @class */ (function () {
422
+ function RealtimeApp(config) {
423
+ if (config === void 0) { config = {}; }
424
+ this.io = null;
425
+ this.config = config;
426
+ }
427
+ /**
428
+ * Register routes and middleware. Adds a /realtime/health endpoint.
429
+ */
430
+ RealtimeApp.prototype.register = function (app) {
431
+ var _this = this;
432
+ app.get("/realtime/health", function (_req, res) {
433
+ var _a, _b, _c, _d;
434
+ var connected = (_c = (_b = (_a = _this.io) === null || _a === void 0 ? void 0 : _a.engine) === null || _b === void 0 ? void 0 : _b.clientsCount) !== null && _c !== void 0 ? _c : 0;
435
+ res.json({
436
+ clients: connected,
437
+ debug: (_d = _this.config.debug) !== null && _d !== void 0 ? _d : false,
438
+ status: _this.io ? "running" : "not_started",
439
+ });
440
+ });
441
+ };
442
+ /**
443
+ * Called after the HTTP server is created. Sets up Socket.io, auth, rooms,
444
+ * and starts the change stream watcher.
445
+ */
446
+ RealtimeApp.prototype.onServerCreated = function (server) {
447
+ var _a, _b, _c;
448
+ var debug = (_a = this.config.debug) !== null && _a !== void 0 ? _a : false;
449
+ var logInfo = function (message) {
450
+ if (debug) {
451
+ logger_1.logger.info(message);
452
+ }
453
+ };
454
+ try {
455
+ logInfo("[realtime] Setting up Socket.io server...");
456
+ this.io = new socket_io_1.Server(server, {
457
+ cors: (_b = this.config.cors) !== null && _b !== void 0 ? _b : {
458
+ methods: ["GET", "POST"],
459
+ origin: "*",
460
+ },
461
+ });
462
+ // JWT authentication middleware
463
+ var tokenSecret = (_c = this.config.tokenSecret) !== null && _c !== void 0 ? _c : process.env.TOKEN_SECRET;
464
+ if (!tokenSecret) {
465
+ throw new Error("[realtime] TOKEN_SECRET is required for socket authentication. " +
466
+ "Set process.env.TOKEN_SECRET or pass tokenSecret in RealtimeAppOptions.");
467
+ }
468
+ this.io.use((0, socketio_jwt_1.authorize)({
469
+ secret: tokenSecret,
470
+ }));
471
+ logInfo("[realtime] JWT authorization middleware added");
472
+ // Configure adapter for multi-instance deployments
473
+ this.setupAdapter(logInfo);
474
+ // Connection handling
475
+ this.io.on("connection", function (socket) {
476
+ try {
477
+ // socketio-jwt's authorize middleware adds `decodedToken` at runtime; cast through
478
+ // RealtimeSocketLike (a structural subset) to keep socket handler logic testable.
479
+ (0, exports.installRealtimeSocketHandlers)(socket, { logInfo: logInfo });
480
+ }
481
+ catch (error) {
482
+ logger_1.logger.error("[realtime] Error handling connection: ".concat(error));
483
+ Sentry.captureException(error);
484
+ }
485
+ });
486
+ this.io.on("connect_error", function (error) {
487
+ logger_1.logger.error("[realtime] Connection error: ".concat(error.message));
488
+ Sentry.captureException(error);
489
+ });
490
+ // Start the change stream watcher
491
+ (0, changeStreamWatcher_1.startChangeStreamWatcher)(this.io, this.config.changeStream, debug);
492
+ logInfo("[realtime] Socket.io server setup complete");
493
+ }
494
+ catch (error) {
495
+ logger_1.logger.error("[realtime] Failed to set up Socket.io: ".concat(error));
496
+ Sentry.captureException(error);
497
+ throw error;
498
+ }
499
+ };
500
+ /**
501
+ * Get the Socket.io server instance.
502
+ */
503
+ RealtimeApp.prototype.getIo = function () {
504
+ return this.io;
505
+ };
506
+ /**
507
+ * Gracefully shut down the real-time server.
508
+ */
509
+ RealtimeApp.prototype.close = function () {
510
+ return __awaiter(this, void 0, void 0, function () {
511
+ var error_2;
512
+ return __generator(this, function (_a) {
513
+ switch (_a.label) {
514
+ case 0:
515
+ _a.trys.push([0, 4, , 5]);
516
+ return [4 /*yield*/, (0, changeStreamWatcher_1.stopChangeStreamWatcher)()];
517
+ case 1:
518
+ _a.sent();
519
+ if (!this.io) return [3 /*break*/, 3];
520
+ return [4 /*yield*/, this.io.close()];
521
+ case 2:
522
+ _a.sent();
523
+ this.io = null;
524
+ _a.label = 3;
525
+ case 3: return [3 /*break*/, 5];
526
+ case 4:
527
+ error_2 = _a.sent();
528
+ logger_1.logger.error("[realtime] Error closing: ".concat(error_2));
529
+ return [3 /*break*/, 5];
530
+ case 5: return [2 /*return*/];
531
+ }
532
+ });
533
+ });
534
+ };
535
+ RealtimeApp.prototype.setupAdapter = function (logInfo) {
536
+ var _a, _b, _c;
537
+ if (!this.io) {
538
+ return;
539
+ }
540
+ var adapter = (_a = this.config.adapter) !== null && _a !== void 0 ? _a : "none";
541
+ if (adapter === "redis") {
542
+ var redisUrl = (_c = (_b = this.config.redisUrl) !== null && _b !== void 0 ? _b : process.env.VALKEY_URL) !== null && _c !== void 0 ? _c : process.env.REDIS_URL;
543
+ if (redisUrl) {
544
+ logInfo("[realtime] Redis adapter configured with URL: ".concat((0, exports.redactCredentials)(redisUrl)));
545
+ // Redis adapter must be configured externally by the consuming app
546
+ // since @socket.io/redis-adapter and ioredis are optional peer dependencies.
547
+ // Use realtimeApp.getIo() to access the Socket.io instance and call
548
+ // io.adapter(createRedisAdapter(pubClient, subClient))
549
+ logger_1.logger.info("[realtime] To enable Redis adapter, configure it after server creation via getIo(). " +
550
+ "See @socket.io/redis-adapter docs.");
551
+ }
552
+ else {
553
+ logger_1.logger.warn("[realtime] Redis adapter requested but no VALKEY_URL or REDIS_URL found");
554
+ }
555
+ }
556
+ // 'none' — no adapter, single instance mode
557
+ };
558
+ return RealtimeApp;
559
+ }());
560
+ exports.RealtimeApp = RealtimeApp;