json-rest-api 1.0.6 → 1.0.8

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 (146) hide show
  1. package/.claude/agents/library-guide-expert.md +29 -0
  2. package/.claude/agents/rest-api-internals-expert.md +53 -0
  3. package/CLAUDE.md +139 -1
  4. package/README.md +2 -5
  5. package/docTest.js +238 -0
  6. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
  7. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
  8. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
  9. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
  10. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
  11. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
  12. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
  13. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
  14. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
  15. package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
  16. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
  17. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
  18. package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
  19. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
  20. package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
  21. package/docs/GUIDE/index.md +58 -0
  22. package/docs/GUIDE.md +61 -2238
  23. package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
  24. package/docs/README.md +57 -2239
  25. package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
  26. package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
  27. package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
  28. package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
  29. package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
  30. package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
  31. package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
  32. package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
  33. package/docs/assets/css/style.scss +78 -5
  34. package/docs/index.md +2 -12
  35. package/helper-reorganization-plan.md +85 -0
  36. package/helper-usage-analysis.md +94 -0
  37. package/package.json +5 -4
  38. package/plugins/core/bulk-operations-plugin.js +2 -2
  39. package/plugins/core/connectors/express-plugin.js +99 -33
  40. package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
  41. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
  42. package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
  43. package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
  44. package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
  45. package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
  46. package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
  47. package/plugins/core/lib/querying/knex-window-queries.js +328 -0
  48. package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
  49. package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
  50. package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
  51. package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
  52. package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
  53. package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
  54. package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
  55. package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
  56. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
  57. package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
  58. package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
  59. package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
  60. package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
  61. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
  62. package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
  63. package/plugins/core/lib/writing/relationship-processor.js +252 -0
  64. package/plugins/core/rest-api-knex-plugin.js +183 -62
  65. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
  66. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
  67. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
  68. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
  69. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
  70. package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
  71. package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
  72. package/plugins/core/rest-api-plugin-methods/common.js +605 -0
  73. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
  74. package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
  75. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
  76. package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
  77. package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
  78. package/plugins/core/rest-api-plugin-methods/get.js +187 -0
  79. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
  80. package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
  81. package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
  82. package/plugins/core/rest-api-plugin-methods/post.js +157 -0
  83. package/plugins/core/rest-api-plugin-methods/put.js +257 -0
  84. package/plugins/core/rest-api-plugin-methods/query.js +208 -0
  85. package/plugins/core/rest-api-plugin.js +108 -2498
  86. package/plugins/core/socketio-plugin.js +28 -5
  87. package/plugins/storage/local-storage.js +2 -2
  88. package/tests/computed-fields.test.js +483 -0
  89. package/tests/cors.test.js +4 -4
  90. package/tests/curl.test.js +5 -4
  91. package/tests/custom-idproperty-relationships.test.js +891 -0
  92. package/tests/field-getters.test.js +353 -0
  93. package/tests/field-setters.test.js +310 -0
  94. package/tests/fixtures/api-configs.js +800 -28
  95. package/tests/jwt-auth.test.js +0 -2
  96. package/tests/nested-includes.test.js +1 -1
  97. package/tests/pagination-cursor-multifield.test.js +514 -0
  98. package/tests/pagination-enhanced.test.js +12 -9
  99. package/tests/polymorphic-relationships.test.js +10 -8
  100. package/tests/polymorphic-simple.test.js +6 -4
  101. package/tests/positioning-advanced.test.js +12 -12
  102. package/tests/positioning-extended.test.js +25 -25
  103. package/tests/positioning-multifilter.test.js +24 -24
  104. package/tests/positioning.test.js +3 -3
  105. package/tests/query-limits.test.js +24 -8
  106. package/tests/relationship-endpoints.test.js +4 -5
  107. package/tests/relationships.test.js +0 -1
  108. package/tests/socketio.test.js +12 -1
  109. package/tests/virtual-fields.test.js +13 -13
  110. package/PLAN_FOR_WEBSOCKETS.md +0 -398
  111. package/README_1_Initial_Setup.md +0 -393
  112. package/README_X_Hooks.md +0 -647
  113. package/TODO_rest-api-plugin-extras.todo +0 -23
  114. package/connectors_ideas/express-plugin.js +0 -120
  115. package/connectors_ideas/http-plugin.js +0 -138
  116. package/docs/examples/express-plugin.js +0 -366
  117. package/example.js +0 -301
  118. package/plugins/core/HELPER_MODULES.md +0 -285
  119. package/plugins/core/lib/compile-schemas.js +0 -245
  120. package/plugins/core/lib/database-value-normalizers.js +0 -192
  121. package/plugins/core/lib/default-data-helpers.js +0 -100
  122. package/plugins/core/lib/knex-cross-table-search.js +0 -442
  123. package/plugins/core/lib/knex-json-api-helpers.js +0 -84
  124. package/plugins/core/lib/knex-json-api-transformers.js +0 -449
  125. package/plugins/core/lib/knex-pagination-helpers.js +0 -193
  126. package/plugins/core/lib/knex-window-queries.js +0 -142
  127. package/plugins/core/lib/many-to-many-manipulations.js +0 -343
  128. package/plugins/core/lib/relationship-processor.js +0 -278
  129. package/plugins/core/lib/schema-helpers.js +0 -343
  130. package/plugins/core/lib/scope-validations.js +0 -252
  131. package/plugins/core/rest-api-relationships-plugin.js +0 -700
  132. package/plugins/core/utils/knex-query-helpers-base.js +0 -56
  133. /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
  134. /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
  135. /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
  136. /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
  137. /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
  138. /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
  139. /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
  140. /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
  141. /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
  142. /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
  143. /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
  144. /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
  145. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
  146. /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
@@ -109,6 +109,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
109
109
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
110
110
 
111
111
  const socket = ioClient(`http://localhost:${server.address().port}`, {
112
+ path: '/api/socket.io',
112
113
  auth: { token }
113
114
  });
114
115
 
@@ -172,6 +173,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
172
173
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
173
174
 
174
175
  const socket = ioClient(`http://localhost:${server.address().port}`, {
176
+ path: '/api/socket.io',
175
177
  auth: { token }
176
178
  });
177
179
 
@@ -235,6 +237,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
235
237
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
236
238
 
237
239
  const socket = ioClient(`http://localhost:${server.address().port}`, {
240
+ path: '/api/socket.io',
238
241
  auth: { token }
239
242
  });
240
243
 
@@ -265,6 +268,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
265
268
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
266
269
 
267
270
  const socket = ioClient(`http://localhost:${server.address().port}`, {
271
+ path: '/api/socket.io',
268
272
  auth: { token }
269
273
  });
270
274
 
@@ -285,7 +289,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
285
289
  simplified: false
286
290
  });
287
291
 
288
- // Assuming we add a complex filter to the schema that uses filterUsing function
292
+ // Assuming we add a complex filter to the schema that uses filterOperator function
289
293
  // For now, test with country_id which is a simple filter
290
294
  const response = await new Promise((resolve) => {
291
295
  socket.emit('subscribe', {
@@ -317,6 +321,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
317
321
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
318
322
 
319
323
  const socket = ioClient(`http://localhost:${server.address().port}`, {
324
+ path: '/api/socket.io',
320
325
  auth: { token }
321
326
  });
322
327
 
@@ -373,6 +378,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
373
378
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
374
379
 
375
380
  const socket = ioClient(`http://localhost:${server.address().port}`, {
381
+ path: '/api/socket.io',
376
382
  auth: { token }
377
383
  });
378
384
 
@@ -436,6 +442,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
436
442
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
437
443
 
438
444
  const socket = ioClient(`http://localhost:${server.address().port}`, {
445
+ path: '/api/socket.io',
439
446
  auth: { token }
440
447
  });
441
448
 
@@ -494,6 +501,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
494
501
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
495
502
 
496
503
  const socket = ioClient(`http://localhost:${server.address().port}`, {
504
+ path: '/api/socket.io',
497
505
  auth: { token }
498
506
  });
499
507
 
@@ -552,6 +560,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
552
560
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
553
561
 
554
562
  const socket = ioClient(`http://localhost:${server.address().port}`, {
563
+ path: '/api/socket.io',
555
564
  auth: { token }
556
565
  });
557
566
 
@@ -621,6 +630,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
621
630
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
622
631
 
623
632
  const socket = ioClient(`http://localhost:${server.address().port}`, {
633
+ path: '/api/socket.io',
624
634
  auth: { token }
625
635
  });
626
636
 
@@ -680,6 +690,7 @@ describe('WebSocket/Socket.IO Plugin', () => {
680
690
  const token = jwt.sign({ userId: 'test-user', role: 'user' }, 'test-secret-key', { expiresIn: '1h' });
681
691
 
682
692
  const socket = ioClient(`http://localhost:${server.address().port}`, {
693
+ path: '/api/socket.io',
683
694
  auth: { token }
684
695
  });
685
696
 
@@ -35,7 +35,12 @@ describe('Virtual Fields Tests', () => {
35
35
  // Install plugins
36
36
  await api.use(RestApiPlugin, {
37
37
  simplifiedApi: false,
38
- simplifiedTransport: false
38
+ simplifiedTransport: false,
39
+ returnRecordApi: {
40
+ post: 'full',
41
+ put: 'full',
42
+ patch: 'full'
43
+ }
39
44
  });
40
45
  await api.use(RestApiKnexPlugin, { knex });
41
46
 
@@ -45,11 +50,9 @@ describe('Virtual Fields Tests', () => {
45
50
  id: { type: 'id' },
46
51
  username: { type: 'string', required: true },
47
52
  email: { type: 'string', required: true },
48
- password: { type: 'string', required: true, hidden: true }
49
- },
50
- virtual: {
51
- passwordConfirmation: { type: 'string' },
52
- termsAccepted: { type: 'boolean' }
53
+ password: { type: 'string', required: true, hidden: true },
54
+ passwordConfirmation: { type: 'string', virtual: true },
55
+ termsAccepted: { type: 'boolean', virtual: true }
53
56
  }
54
57
  });
55
58
 
@@ -317,21 +320,18 @@ describe('Virtual Fields Tests', () => {
317
320
  id: { type: 'id' },
318
321
  name: { type: 'string', required: true },
319
322
  price: { type: 'number', required: true },
320
- cost: { type: 'number', required: true, normallyHidden: true }
321
- },
322
- computed: {
323
+ cost: { type: 'number', required: true, normallyHidden: true },
324
+ priceInCents: { type: 'number', virtual: true },
325
+ discountCode: { type: 'string', virtual: true },
323
326
  profitMargin: {
324
327
  type: 'number',
328
+ computed: true,
325
329
  dependencies: ['price', 'cost'],
326
330
  compute: ({ attributes }) => {
327
331
  if (!attributes.price || !attributes.cost) return null;
328
332
  return ((attributes.price - attributes.cost) / attributes.price * 100).toFixed(2);
329
333
  }
330
334
  }
331
- },
332
- virtual: {
333
- priceInCents: { type: 'number' },
334
- discountCode: { type: 'string' }
335
335
  }
336
336
  });
337
337
 
@@ -1,398 +0,0 @@
1
- # WebSocket Subscription Plugin Implementation Plan
2
-
3
- ## Overview
4
- Implement a WebSocket plugin that allows clients to subscribe to real-time changes in resources, fully integrated with the JWT authentication plugin and declarative permission system.
5
-
6
- ## Key Changes from Original Plan
7
-
8
- ### 1. Authentication Integration
9
- The WebSocket plugin now integrates with the JWT Auth Plugin we just built:
10
- - Uses the same JWT validation logic
11
- - Supports the same declarative auth rules
12
- - Automatically disconnects on token revocation/logout
13
-
14
- ### 2. Simplified Architecture
15
- Instead of custom auth hooks, we leverage the existing system:
16
- - Reuses `context.auth` from JWT plugin
17
- - Uses declarative `auth` rules on resources
18
- - No need for separate `authenticateWebSocket` hook
19
-
20
- ## Updated Implementation
21
-
22
- ### 1. **plugins/core/websocket-plugin.js** - Main Plugin
23
- ```javascript
24
- import { WebSocketServer } from 'ws';
25
- import { nanoid } from 'nanoid';
26
- import { connectionManager } from './lib/websocket-connection-manager.js';
27
- import { subscriptionManager } from './lib/websocket-subscription-manager.js';
28
- import { messageHandlers } from './lib/websocket-message-handlers.js';
29
-
30
- export const WebSocketPlugin = {
31
- name: 'websocket',
32
- dependencies: ['rest-api', 'jwt-auth'], // Now depends on JWT auth
33
-
34
- async install({ api, addHook, log, scopes, helpers, vars, on }) {
35
- const config = {
36
- port: api.config.websocket?.port || 3001,
37
- path: api.config.websocket?.path || '/ws',
38
- heartbeatInterval: api.config.websocket?.heartbeatInterval || 30000,
39
- maxSubscriptionsPerConnection: api.config.websocket?.maxSubscriptionsPerConnection || 100,
40
- ...api.config.websocket
41
- };
42
-
43
- // Initialize WebSocket server
44
- const wss = new WebSocketServer({
45
- port: config.port,
46
- path: config.path
47
- });
48
-
49
- // Store managers in vars
50
- vars.wsConnectionManager = connectionManager;
51
- vars.wsSubscriptionManager = subscriptionManager;
52
-
53
- // Store on API instance for external access
54
- api.ws = {
55
- server: wss,
56
- connections: connectionManager.connections,
57
- subscriptions: subscriptionManager.subscriptions,
58
- broadcast: (scopeName, operation, record, context) => {
59
- subscriptionManager.broadcast(scopeName, operation, record, context);
60
- },
61
- disconnectUser: (userId) => {
62
- // Disconnect all connections for a user (e.g., on logout)
63
- for (const [connId, conn] of connectionManager.connections) {
64
- if (conn.context.auth?.userId === userId) {
65
- conn.ws.close(1000, 'User logged out');
66
- connectionManager.remove(connId);
67
- }
68
- }
69
- }
70
- };
71
-
72
- // Handle new connections
73
- wss.on('connection', async (ws, request) => {
74
- const connectionId = nanoid();
75
-
76
- // Extract token and validate using JWT plugin
77
- const token = extractToken(request);
78
- const context = {
79
- request: { token },
80
- auth: null
81
- };
82
-
83
- // Run JWT auth hook to populate context.auth
84
- if (token) {
85
- const authHooks = api.getHooks('transport:request');
86
- const jwtHook = authHooks.find(h => h.name === 'jwt-populate-auth');
87
- if (jwtHook) {
88
- await jwtHook.handler({ context, runHooks: async () => {} });
89
- }
90
- }
91
-
92
- // Store connection with auth context
93
- connectionManager.add(connectionId, ws, context);
94
-
95
- // Setup heartbeat
96
- const heartbeat = setInterval(() => {
97
- if (ws.readyState === ws.OPEN) {
98
- ws.ping();
99
- } else {
100
- clearInterval(heartbeat);
101
- }
102
- }, config.heartbeatInterval);
103
-
104
- // Handle messages
105
- ws.on('message', async (data) => {
106
- try {
107
- const message = JSON.parse(data.toString());
108
- await messageHandlers.handle(message, {
109
- connectionId,
110
- ws,
111
- api,
112
- scopes,
113
- context,
114
- config,
115
- helpers,
116
- vars
117
- });
118
- } catch (error) {
119
- ws.send(JSON.stringify({
120
- type: 'error',
121
- error: {
122
- code: 'INVALID_MESSAGE',
123
- message: error.message
124
- }
125
- }));
126
- }
127
- });
128
-
129
- // Handle disconnect
130
- ws.on('close', () => {
131
- connectionManager.remove(connectionId);
132
- subscriptionManager.removeAllForConnection(connectionId);
133
- clearInterval(heartbeat);
134
- });
135
-
136
- // Send welcome message
137
- ws.send(JSON.stringify({
138
- type: 'connected',
139
- connectionId,
140
- authenticated: !!context.auth,
141
- userId: context.auth?.userId,
142
- timestamp: new Date().toISOString()
143
- }));
144
- });
145
-
146
- // Hook into logout to disconnect WebSocket connections
147
- addHook('afterLogout', 'websocket-disconnect-on-logout', {}, async ({ context }) => {
148
- if (context.auth?.userId) {
149
- api.ws.disconnectUser(context.auth.userId);
150
- }
151
- });
152
-
153
- // Hook into REST API data operations for broadcasting
154
- addHook('afterDataWrite', 'websocket-broadcast', {}, async (result) => {
155
- const { scopeName, operation, record, context } = result;
156
-
157
- // Skip if no subscriptions exist for this scope
158
- if (!subscriptionManager.hasSubscriptionsForScope(scopeName)) {
159
- return result;
160
- }
161
-
162
- // Broadcast to subscribers
163
- await subscriptionManager.broadcast(scopeName, operation, record, context, {
164
- scopes,
165
- helpers,
166
- vars
167
- });
168
-
169
- return result;
170
- });
171
-
172
- log.info(`WebSocket server started on port ${config.port}`);
173
- }
174
- };
175
-
176
- // Helper to extract token from WebSocket request
177
- function extractToken(request) {
178
- // 1. Check Authorization header
179
- const authHeader = request.headers.authorization;
180
- if (authHeader?.startsWith('Bearer ')) {
181
- return authHeader.slice(7);
182
- }
183
-
184
- // 2. Check query parameters
185
- const url = new URL(request.url, `http://${request.headers.host}`);
186
- const token = url.searchParams.get('token');
187
- if (token) return token;
188
-
189
- // 3. Check WebSocket protocol (for browsers that don't support headers)
190
- if (request.headers['sec-websocket-protocol']) {
191
- const protocols = request.headers['sec-websocket-protocol'].split(',').map(p => p.trim());
192
- const authProtocol = protocols.find(p => p.startsWith('token-'));
193
- if (authProtocol) {
194
- return authProtocol.slice(6);
195
- }
196
- }
197
-
198
- return null;
199
- }
200
- ```
201
-
202
- ### 2. **Updated Security Integration**
203
-
204
- The security layer now uses the declarative auth system:
205
-
206
- ```javascript
207
- // In websocket-subscription-manager.js
208
- async broadcast(scopeName, operation, record, context, deps) {
209
- const subs = this.subscriptions.get(scopeName);
210
- if (!subs) return;
211
-
212
- const { scopes, helpers, vars } = deps;
213
- const scope = scopes[scopeName];
214
- const scopeVars = scope.vars;
215
-
216
- for (const subscription of subs) {
217
- try {
218
- // Get connection and its auth context
219
- const connection = connectionManager.get(subscription.connectionId);
220
- if (!connection) continue;
221
-
222
- // Check if record matches subscription filters
223
- const matchesFilters = await this.recordMatchesFilters(
224
- record,
225
- subscription.filters,
226
- scope,
227
- context
228
- );
229
-
230
- if (!matchesFilters) continue;
231
-
232
- // Use declarative auth rules if available
233
- const authRules = scopeVars?.authRules;
234
- if (authRules) {
235
- // Check if user can read this resource
236
- const readRules = authRules.query || authRules.get;
237
- if (readRules) {
238
- const authCheckers = vars.authCheckers;
239
- let hasPermission = false;
240
-
241
- for (const rule of readRules) {
242
- const checker = authCheckers[rule];
243
- if (checker && await checker(connection.context, { existingRecord: record, scopeVars })) {
244
- hasPermission = true;
245
- break;
246
- }
247
- }
248
-
249
- if (!hasPermission) continue;
250
- }
251
- }
252
-
253
- // Transform and send
254
- const jsonApiRecord = await this.transformToJsonApi(
255
- scopeName,
256
- record,
257
- subscription,
258
- context
259
- );
260
-
261
- connectionManager.send(subscription.connectionId, {
262
- type: 'resource.update',
263
- subscriptionId: subscription.id,
264
- operation,
265
- data: jsonApiRecord,
266
- meta: {
267
- timestamp: new Date().toISOString()
268
- }
269
- });
270
-
271
- } catch (error) {
272
- console.error('Error broadcasting to subscription:', error);
273
- }
274
- }
275
- }
276
- ```
277
-
278
- ### 3. **Simplified Client Usage**
279
-
280
- ```javascript
281
- // Frontend with Supabase auth
282
- import { createClient } from '@supabase/supabase-js';
283
-
284
- const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
285
-
286
- // Login and get token
287
- const { data: { session } } = await supabase.auth.signIn({
288
- email: 'user@example.com',
289
- password: 'password'
290
- });
291
-
292
- // Connect to WebSocket with same token
293
- const ws = new WebSocket(`ws://localhost:3001/ws?token=${session.access_token}`);
294
-
295
- ws.onopen = () => {
296
- // Subscribe to books I own
297
- ws.send(JSON.stringify({
298
- id: 'my-books',
299
- type: 'subscribe',
300
- resource: 'books',
301
- filters: {
302
- user_id: session.user.id
303
- }
304
- }));
305
-
306
- // Subscribe to all published books
307
- ws.send(JSON.stringify({
308
- id: 'published-books',
309
- type: 'subscribe',
310
- resource: 'books',
311
- filters: {
312
- published: true
313
- }
314
- }));
315
- };
316
-
317
- ws.onmessage = (event) => {
318
- const message = JSON.parse(event.data);
319
-
320
- switch (message.type) {
321
- case 'resource.update':
322
- // Real-time update!
323
- console.log('Book updated:', message.data);
324
- updateBookInUI(message.data);
325
- break;
326
-
327
- case 'connected':
328
- console.log('Connected, authenticated:', message.authenticated);
329
- break;
330
- }
331
- };
332
-
333
- // On logout
334
- await supabase.auth.signOut();
335
- // WebSocket automatically disconnects due to afterLogout hook
336
- ```
337
-
338
- ## How Authentication Works
339
-
340
- ### Connection Flow
341
- 1. Client connects with JWT token (via query param, header, or protocol)
342
- 2. WebSocket plugin uses JWT plugin to validate token
343
- 3. Connection stores `context.auth` with user info
344
- 4. All subscriptions from this connection use this auth context
345
-
346
- ### Permission Checking
347
- 1. When subscribing, checks if user can access the resource type
348
- 2. When broadcasting, checks each record against declarative auth rules
349
- 3. Only sends updates for records the user has permission to see
350
-
351
- ### Logout Handling
352
- 1. When user logs out via REST API, `afterLogout` hook fires
353
- 2. WebSocket plugin disconnects all connections for that user
354
- 3. Token revocation prevents reconnection with same token
355
-
356
- ## Security Benefits
357
-
358
- 1. **Unified Auth System**: Same tokens, same permissions as REST API
359
- 2. **Automatic Enforcement**: Declarative rules apply to WebSocket too
360
- 3. **No Auth Duplication**: Reuses JWT plugin's validation logic
361
- 4. **Clean Logout**: Connections closed when tokens revoked
362
-
363
- ## Implementation Steps
364
-
365
- 1. **Install dependencies**: `npm install ws nanoid`
366
- 2. **Create plugin files** in order shown above
367
- 3. **No custom auth needed**: JWT plugin handles everything
368
- 4. **Test with auth provider**: Use same tokens as REST API
369
-
370
- ## Example Resource with WebSocket Support
371
-
372
- ```javascript
373
- await api.addResource('posts', {
374
- schema: {
375
- id: { type: 'id' },
376
- title: { type: 'string', required: true },
377
- content: { type: 'text' },
378
- user_id: { type: 'string' },
379
- published: { type: 'boolean', default: false }
380
- },
381
-
382
- // Same auth rules apply to WebSocket!
383
- auth: {
384
- query: ['public'], // Anyone can subscribe to posts
385
- get: ['public'],
386
- post: ['authenticated'],
387
- patch: ['is_owner'],
388
- delete: ['is_owner', 'admin']
389
- }
390
- });
391
-
392
- // WebSocket will:
393
- // - Allow anyone to subscribe (query: ['public'])
394
- // - Only send updates for posts they can see
395
- // - Filter unpublished posts for non-owners automatically
396
- ```
397
-
398
- This approach is much cleaner than the original plan - it reuses all the authentication and authorization infrastructure we just built!