json-rest-api 1.0.7 → 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.
- package/.claude/agents/library-guide-expert.md +29 -0
- package/.claude/agents/rest-api-internals-expert.md +53 -0
- package/CLAUDE.md +139 -1
- package/README.md +2 -5
- package/docTest.js +238 -0
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +955 -0
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +54 -0
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +750 -0
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +494 -0
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +398 -0
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +882 -0
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +709 -0
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +1233 -0
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +796 -0
- package/{README_X_Relationship_Plugin.md → docs/GUIDE/GUIDE_2_9_Relationships_Urls.md} +1 -4
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +66 -0
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +1246 -0
- package/docs/GUIDE/GUIDE_5_Non-Db_Stores.md +0 -0
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +1490 -0
- package/{README_X_SocketIO.md → docs/GUIDE/GUIDE_X_SocketIO.md} +17 -17
- package/docs/GUIDE/index.md +58 -0
- package/docs/GUIDE.md +61 -2238
- package/docs/ONBOARDING/Computed_Properties_And_Sparse_Fields.md +398 -0
- package/docs/README.md +57 -2239
- package/{README_2_Computed_and_hidden_fields.md → docs/README_VARIOUS/GUIDE_2_Computed_and_hidden_fields.md} +22 -17
- package/docs/README_VARIOUS/GUIDE_IN_DOCS.md +2277 -0
- package/{README.ORIG.md → docs/README_VARIOUS/README.ORIG.md} +8 -8
- package/{README_8_Polymorphic_Relationships.md → docs/README_VARIOUS/README_8_Polymorphic_Relationships.md} +1 -1
- package/docs/README_VARIOUS/README_IN_DOCS.ms +2277 -0
- package/{RELATIONSHIPS_ADVANCED.md → docs/README_VARIOUS/RELATIONSHIPS_ADVANCED.md} +3 -3
- package/{correct-schema-format.md → docs/README_VARIOUS/correct-schema-format.md} +13 -13
- package/{pagination.md → docs/README_VARIOUS/pagination.md} +11 -8
- package/docs/assets/css/style.scss +78 -5
- package/docs/index.md +2 -12
- package/helper-reorganization-plan.md +85 -0
- package/helper-usage-analysis.md +94 -0
- package/package.json +5 -4
- package/plugins/core/bulk-operations-plugin.js +2 -2
- package/plugins/core/connectors/express-plugin.js +90 -32
- package/plugins/core/lib/querying/knex-cross-table-search.js +609 -0
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +491 -0
- package/plugins/core/lib/querying/knex-pagination-helpers.js +544 -0
- package/plugins/core/lib/{knex-process-includes.js → querying/knex-process-includes.js} +65 -50
- package/plugins/core/lib/querying/knex-query-helpers-base.js +74 -0
- package/plugins/core/lib/{knex-query-helpers.js → querying/knex-query-helpers.js} +416 -180
- package/plugins/core/lib/{knex-relationship-includes.js → querying/knex-relationship-includes.js} +430 -114
- package/plugins/core/lib/querying/knex-window-queries.js +328 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +328 -0
- package/plugins/core/{utils → lib/querying-writing}/connectors-query-parser.js +62 -57
- package/plugins/core/lib/{database-capabilities.js → querying-writing/database-capabilities.js} +113 -7
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +369 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +72 -0
- package/plugins/core/{utils → lib/querying-writing}/field-utils.js +41 -31
- package/plugins/core/{utils → lib/querying-writing}/knex-constants.js +1 -0
- package/plugins/core/lib/{knex-field-helpers.js → querying-writing/knex-field-helpers.js} +190 -57
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +162 -0
- package/plugins/core/lib/{payload-validators.js → querying-writing/payload-validators.js} +297 -217
- package/plugins/core/lib/querying-writing/schema-helpers.js +497 -0
- package/plugins/core/lib/querying-writing/scope-validations.js +431 -0
- package/plugins/core/lib/{simplified-helpers.js → querying-writing/simplified-helpers.js} +216 -176
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +110 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +300 -0
- package/plugins/core/lib/writing/relationship-processor.js +252 -0
- package/plugins/core/rest-api-knex-plugin.js +183 -62
- package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +7 -0
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +72 -0
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +102 -0
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +50 -0
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +39 -0
- package/plugins/core/rest-api-plugin-methods/add-route.js +23 -0
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +18 -0
- package/plugins/core/rest-api-plugin-methods/common.js +605 -0
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +115 -0
- package/plugins/core/rest-api-plugin-methods/delete.js +109 -0
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +186 -0
- package/plugins/core/rest-api-plugin-methods/get-related.js +295 -0
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +62 -0
- package/plugins/core/rest-api-plugin-methods/get.js +187 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +56 -0
- package/plugins/core/rest-api-plugin-methods/patch.js +193 -0
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +104 -0
- package/plugins/core/rest-api-plugin-methods/post.js +157 -0
- package/plugins/core/rest-api-plugin-methods/put.js +257 -0
- package/plugins/core/rest-api-plugin-methods/query.js +208 -0
- package/plugins/core/rest-api-plugin.js +108 -2498
- package/plugins/core/socketio-plugin.js +28 -5
- package/plugins/storage/local-storage.js +2 -2
- package/tests/computed-fields.test.js +483 -0
- package/tests/cors.test.js +4 -4
- package/tests/curl.test.js +5 -4
- package/tests/custom-idproperty-relationships.test.js +891 -0
- package/tests/field-getters.test.js +353 -0
- package/tests/field-setters.test.js +310 -0
- package/tests/fixtures/api-configs.js +800 -28
- package/tests/jwt-auth.test.js +0 -2
- package/tests/nested-includes.test.js +1 -1
- package/tests/pagination-cursor-multifield.test.js +514 -0
- package/tests/pagination-enhanced.test.js +12 -9
- package/tests/polymorphic-relationships.test.js +10 -8
- package/tests/polymorphic-simple.test.js +6 -4
- package/tests/positioning-advanced.test.js +12 -12
- package/tests/positioning-extended.test.js +25 -25
- package/tests/positioning-multifilter.test.js +24 -24
- package/tests/positioning.test.js +3 -3
- package/tests/query-limits.test.js +24 -8
- package/tests/relationship-endpoints.test.js +4 -5
- package/tests/relationships.test.js +0 -1
- package/tests/socketio.test.js +12 -1
- package/tests/virtual-fields.test.js +13 -13
- package/PLAN_FOR_WEBSOCKETS.md +0 -398
- package/README_1_Initial_Setup.md +0 -393
- package/README_X_Hooks.md +0 -647
- package/TODO_rest-api-plugin-extras.todo +0 -23
- package/connectors_ideas/express-plugin.js +0 -120
- package/connectors_ideas/http-plugin.js +0 -138
- package/docs/examples/express-plugin.js +0 -366
- package/example.js +0 -301
- package/plugins/core/HELPER_MODULES.md +0 -285
- package/plugins/core/lib/compile-schemas.js +0 -245
- package/plugins/core/lib/database-value-normalizers.js +0 -192
- package/plugins/core/lib/default-data-helpers.js +0 -100
- package/plugins/core/lib/knex-cross-table-search.js +0 -442
- package/plugins/core/lib/knex-json-api-helpers.js +0 -84
- package/plugins/core/lib/knex-json-api-transformers.js +0 -449
- package/plugins/core/lib/knex-pagination-helpers.js +0 -193
- package/plugins/core/lib/knex-window-queries.js +0 -142
- package/plugins/core/lib/many-to-many-manipulations.js +0 -343
- package/plugins/core/lib/relationship-processor.js +0 -278
- package/plugins/core/lib/schema-helpers.js +0 -343
- package/plugins/core/lib/scope-validations.js +0 -252
- package/plugins/core/rest-api-relationships-plugin.js +0 -700
- package/plugins/core/utils/knex-query-helpers-base.js +0 -56
- /package/{README_X_Permissions.md → docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md} +0 -0
- /package/{README_X_Positioning.md → docs/GUIDE/GUIDE_6_Positioning.md} +0 -0
- /package/{README_X_Bulk_Operations.md → docs/GUIDE/GUIDE_X_Bulk_Operations.md} +0 -0
- /package/{README_X_Cors.md → docs/GUIDE/GUIDE_X_Cors.md} +0 -0
- /package/{README_X_Multihome.md → docs/GUIDE/GUIDE_X_Multihome.md} +0 -0
- /package/{README_X_Appendices.md → docs/GUIDE/GUIDE_Y_Appendices.md} +0 -0
- /package/{QUERY_DEPTH_PROTECTION.md → docs/README_VARIOUS/QUERY_DEPTH_PROTECTION.md} +0 -0
- /package/{RELATIONSHIPS.md → docs/README_VARIOUS/RELATIONSHIPS.md} +0 -0
- /package/{WEBSOCKET_IMPLEMENTATION_STATUS.md → docs/README_VARIOUS/WEBSOCKET_IMPLEMENTATION_STATUS.md} +0 -0
- /package/{helper-functions-chart.md → docs/README_VARIOUS/helper-functions-chart.md} +0 -0
- /package/docs/{implementation-hidden-computed-fields.md → README_VARIOUS/implementation-hidden-computed-fields.md} +0 -0
- /package/docs/{include-limits-migration-guide.md → README_VARIOUS/include-limits-migration-guide.md} +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_behavior.js +0 -0
- /package/{readmeSnippets → docs/README_VARIOUS}/test_put_fields_without_defaults.js +0 -0
package/tests/socketio.test.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/PLAN_FOR_WEBSOCKETS.md
DELETED
|
@@ -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!
|