@powersync/service-core 1.19.0 → 1.19.2

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 (57) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/api/RouteAPI.d.ts +2 -2
  3. package/dist/api/diagnostics.js +4 -3
  4. package/dist/api/diagnostics.js.map +1 -1
  5. package/dist/auth/JwtPayload.d.ts +7 -8
  6. package/dist/auth/JwtPayload.js +19 -1
  7. package/dist/auth/JwtPayload.js.map +1 -1
  8. package/dist/auth/KeyStore.js +2 -1
  9. package/dist/auth/KeyStore.js.map +1 -1
  10. package/dist/routes/auth.d.ts +0 -1
  11. package/dist/routes/auth.js +2 -4
  12. package/dist/routes/auth.js.map +1 -1
  13. package/dist/routes/configure-fastify.js +0 -1
  14. package/dist/routes/configure-fastify.js.map +1 -1
  15. package/dist/routes/endpoints/admin.d.ts +3 -0
  16. package/dist/routes/endpoints/admin.js +5 -2
  17. package/dist/routes/endpoints/admin.js.map +1 -1
  18. package/dist/routes/endpoints/checkpointing.js +3 -3
  19. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  20. package/dist/routes/endpoints/socket-route.js +2 -2
  21. package/dist/routes/endpoints/socket-route.js.map +1 -1
  22. package/dist/routes/endpoints/sync-rules.js +5 -5
  23. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  24. package/dist/routes/endpoints/sync-stream.js +2 -2
  25. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  26. package/dist/routes/router.d.ts +0 -1
  27. package/dist/routes/router.js.map +1 -1
  28. package/dist/storage/PersistedSyncRulesContent.d.ts +2 -2
  29. package/dist/storage/SyncRulesBucketStorage.d.ts +8 -0
  30. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  31. package/dist/sync/BucketChecksumState.d.ts +4 -3
  32. package/dist/sync/BucketChecksumState.js +3 -3
  33. package/dist/sync/BucketChecksumState.js.map +1 -1
  34. package/dist/sync/sync.js +5 -7
  35. package/dist/sync/sync.js.map +1 -1
  36. package/package.json +6 -6
  37. package/src/api/RouteAPI.ts +2 -2
  38. package/src/api/diagnostics.ts +5 -4
  39. package/src/auth/JwtPayload.ts +16 -8
  40. package/src/auth/KeyStore.ts +1 -1
  41. package/src/routes/auth.ts +2 -4
  42. package/src/routes/configure-fastify.ts +0 -1
  43. package/src/routes/endpoints/admin.ts +5 -2
  44. package/src/routes/endpoints/checkpointing.ts +5 -3
  45. package/src/routes/endpoints/socket-route.ts +2 -2
  46. package/src/routes/endpoints/sync-rules.ts +5 -5
  47. package/src/routes/endpoints/sync-stream.ts +2 -2
  48. package/src/routes/router.ts +0 -2
  49. package/src/storage/PersistedSyncRulesContent.ts +2 -2
  50. package/src/storage/SyncRulesBucketStorage.ts +9 -0
  51. package/src/sync/BucketChecksumState.ts +6 -7
  52. package/src/sync/sync.ts +9 -11
  53. package/test/src/auth.test.ts +76 -20
  54. package/test/src/routes/stream.test.ts +7 -7
  55. package/test/src/sync/BucketChecksumState.test.ts +7 -6
  56. package/test/tsconfig.json +3 -6
  57. package/tsconfig.tsbuildinfo +1 -1
package/src/sync/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
2
- import { BucketDescription, BucketPriority, RequestJwtPayload, HydratedSyncRules } from '@powersync/service-sync-rules';
2
+ import { BucketDescription, BucketPriority, HydratedSyncRules, SqliteJsonValue } from '@powersync/service-sync-rules';
3
3
 
4
4
  import { AbortError } from 'ix/aborterror.js';
5
5
 
@@ -96,16 +96,13 @@ async function* streamResponseInner(
96
96
  bucketStorage: storage.SyncRulesBucketStorage,
97
97
  syncRules: HydratedSyncRules,
98
98
  params: util.StreamingSyncRequest,
99
- tokenPayload: RequestJwtPayload,
99
+ tokenPayload: auth.JwtPayload,
100
100
  tracker: RequestTracker,
101
101
  signal: AbortSignal,
102
102
  logger: Logger,
103
103
  isEncodingAsBson: boolean
104
104
  ): AsyncGenerator<util.StreamingSyncLine | string | null> {
105
- const { raw_data } = params;
106
-
107
- const userId = tokenPayload.sub;
108
- const checkpointUserId = util.checkpointUserId(userId as string, params.client_id);
105
+ const checkpointUserId = util.checkpointUserId(tokenPayload.userIdString, params.client_id);
109
106
 
110
107
  const checksumState = new BucketChecksumState({
111
108
  syncContext,
@@ -236,7 +233,7 @@ async function* streamResponseInner(
236
233
  onRowsSent: markOperationsSent,
237
234
  abort_connection: signal,
238
235
  abort_batch: abortCheckpointSignal,
239
- user_id: userId,
236
+ userIdForLogs: tokenPayload.userIdJson,
240
237
  // Passing null here will emit a full sync complete message at the end. If we pass a priority, we'll emit a partial
241
238
  // sync complete message instead.
242
239
  forPriority: !isLast ? priority : null,
@@ -270,7 +267,8 @@ interface BucketDataRequest {
270
267
  * This signal also fires when abort_connection fires.
271
268
  */
272
269
  abort_batch: AbortSignal;
273
- user_id?: string;
270
+ /** User id for debug purposes, not for sync rules. */
271
+ userIdForLogs?: SqliteJsonValue;
274
272
  forPriority: BucketPriority | null;
275
273
  onRowsSent: (stats: OperationsSentStats) => void;
276
274
  logger: Logger;
@@ -333,7 +331,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
333
331
  let checkpointInvalidated = false;
334
332
 
335
333
  if (syncContext.syncSemaphore.isLocked()) {
336
- logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.user_id });
334
+ logger.info('Sync concurrency limit reached, waiting for lock', { user_id: request.userIdForLogs });
337
335
  }
338
336
  const acquired = await acquireSemaphoreAbortable(syncContext.syncSemaphore, AbortSignal.any([abort_batch]));
339
337
  if (acquired === 'aborted') {
@@ -346,7 +344,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
346
344
  // This can be noisy, so we only log when we get close to the
347
345
  // concurrency limit.
348
346
  logger.info(`Got sync lock. Slots available: ${value - 1}`, {
349
- user_id: request.user_id,
347
+ user_id: request.userIdForLogs,
350
348
  sync_data_slots: value - 1
351
349
  });
352
350
  }
@@ -429,7 +427,7 @@ async function* bucketDataBatch(request: BucketDataRequest): AsyncGenerator<Buck
429
427
  // This can be noisy, so we only log when we get close to the
430
428
  // concurrency limit.
431
429
  logger.info(`Releasing sync lock`, {
432
- user_id: request.user_id
430
+ user_id: request.userIdForLogs
433
431
  });
434
432
  }
435
433
  release();
@@ -70,7 +70,7 @@ describe('JWT Auth', () => {
70
70
  defaultAudiences: ['tests'],
71
71
  maxAge: '6m'
72
72
  });
73
- expect(verified.sub).toEqual('f1');
73
+ expect(verified.userIdJson).toEqual('f1');
74
74
  await expect(
75
75
  store.verifyJwt(signedJwt, {
76
76
  defaultAudiences: ['other'],
@@ -126,6 +126,58 @@ describe('JWT Auth', () => {
126
126
  ).rejects.toThrow('[PSYNC_S2103] JWT has expired');
127
127
  });
128
128
 
129
+ test('KeyStore normalizes sub claim values', async () => {
130
+ const keys = await StaticKeyCollector.importKeys([sharedKey]);
131
+ const store = new KeyStore(keys);
132
+ const signKey = (await jose.importJWK(sharedKey)) as jose.KeyLike;
133
+
134
+ const signWithSub = async (sub: any) => {
135
+ return new jose.SignJWT({ sub })
136
+ .setProtectedHeader({ alg: 'HS256', kid: 'k1' })
137
+ .setIssuedAt()
138
+ .setIssuer('tester')
139
+ .setAudience('tests')
140
+ .setExpirationTime('5m')
141
+ .sign(signKey);
142
+ };
143
+
144
+ const stringToken = await signWithSub('user-1');
145
+ const stringVerified = await store.verifyJwt(stringToken, {
146
+ defaultAudiences: ['tests'],
147
+ maxAge: '6m'
148
+ });
149
+ expect(stringVerified.userIdJson).toEqual('user-1');
150
+ expect(stringVerified.userIdString).toEqual('user-1');
151
+
152
+ const booleanToken = await signWithSub(true);
153
+ const booleanVerified = await store.verifyJwt(booleanToken, {
154
+ defaultAudiences: ['tests'],
155
+ maxAge: '6m'
156
+ });
157
+ expect(booleanVerified.userIdJson).toEqual(1n);
158
+ expect(booleanVerified.userIdString).toEqual('1');
159
+
160
+ const objectSub = { id: 1, name: 'test' };
161
+ const objectToken = await signWithSub(objectSub);
162
+ const objectVerified = await store.verifyJwt(objectToken, {
163
+ defaultAudiences: ['tests'],
164
+ maxAge: '6m'
165
+ });
166
+ // The exact JSON serialization here may change in the future
167
+ expect(objectVerified.userIdJson).toEqual(`{"id":1.0,"name":"test"}`);
168
+ expect(objectVerified.userIdString).toEqual(`{"id":1.0,"name":"test"}`);
169
+
170
+ const arraySub = [1, 'a'];
171
+ const arrayToken = await signWithSub(arraySub);
172
+ const arrayVerified = await store.verifyJwt(arrayToken, {
173
+ defaultAudiences: ['tests'],
174
+ maxAge: '6m'
175
+ });
176
+ // The exact JSON serialization here may change in the future
177
+ expect(arrayVerified.userIdJson).toEqual(`[1.0,"a"]`);
178
+ expect(arrayVerified.userIdString).toEqual(`[1.0,"a"]`);
179
+ });
180
+
129
181
  test('Algorithm validation', async () => {
130
182
  const keys = await StaticKeyCollector.importKeys([publicKeyRSA]);
131
183
  const store = new KeyStore(keys);
@@ -210,12 +262,14 @@ describe('JWT Auth', () => {
210
262
  .setExpirationTime('5m')
211
263
  .sign(signKey2);
212
264
 
213
- await expect(
214
- store.verifyJwt(signedJwt3, {
215
- defaultAudiences: ['tests'],
216
- maxAge: '6m'
217
- })
218
- ).resolves.toMatchObject({ sub: 'f1' });
265
+ expect(
266
+ (
267
+ await store.verifyJwt(signedJwt3, {
268
+ defaultAudiences: ['tests'],
269
+ maxAge: '6m'
270
+ })
271
+ ).parsedPayload
272
+ ).toMatchObject({ sub: 'f1' });
219
273
 
220
274
  // Random kid, matches sharedKey2
221
275
  const signedJwt4 = await new jose.SignJWT({})
@@ -227,12 +281,14 @@ describe('JWT Auth', () => {
227
281
  .setExpirationTime('5m')
228
282
  .sign(signKey2);
229
283
 
230
- await expect(
231
- store.verifyJwt(signedJwt4, {
232
- defaultAudiences: ['tests'],
233
- maxAge: '6m'
234
- })
235
- ).resolves.toMatchObject({ sub: 'f1' });
284
+ expect(
285
+ (
286
+ await store.verifyJwt(signedJwt4, {
287
+ defaultAudiences: ['tests'],
288
+ maxAge: '6m'
289
+ })
290
+ ).parsedPayload
291
+ ).toMatchObject({ sub: 'f1' });
236
292
  });
237
293
 
238
294
  test('KeyOptions', async () => {
@@ -258,7 +314,7 @@ describe('JWT Auth', () => {
258
314
  defaultAudiences: ['tests'],
259
315
  maxAge: '6m'
260
316
  });
261
- expect(verified.sub).toEqual('f1');
317
+ expect(verified.userIdJson).toEqual('f1');
262
318
 
263
319
  const signedJwt2 = await new jose.SignJWT({})
264
320
  .setProtectedHeader({ alg: 'HS256', kid: 'k1' })
@@ -410,12 +466,12 @@ describe('JWT Auth', () => {
410
466
  .setExpirationTime('5m')
411
467
  .sign(signKey);
412
468
 
413
- const verified = (await store.verifyJwt(signedJwt, {
469
+ const verified = await store.verifyJwt(signedJwt, {
414
470
  defaultAudiences: ['tests'],
415
471
  maxAge: '6m'
416
- })) as JwtPayload & { claim: string };
472
+ });
417
473
 
418
- expect(verified.claim).toEqual('test-claim');
474
+ expect(verified.parsedPayload.claim).toEqual('test-claim');
419
475
  });
420
476
 
421
477
  test('signing with ECDSA', async () => {
@@ -432,12 +488,12 @@ describe('JWT Auth', () => {
432
488
  .setExpirationTime('5m')
433
489
  .sign(signKey);
434
490
 
435
- const verified = (await store.verifyJwt(signedJwt, {
491
+ const verified = await store.verifyJwt(signedJwt, {
436
492
  defaultAudiences: ['tests'],
437
493
  maxAge: '6m'
438
- })) as JwtPayload & { claim: string };
494
+ });
439
495
 
440
- expect(verified.claim).toEqual('test-claim-2');
496
+ expect(verified.parsedPayload.claim).toEqual('test-claim-2');
441
497
  });
442
498
 
443
499
  describe('debugKeyNotFound', () => {
@@ -1,4 +1,4 @@
1
- import { BasicRouterRequest, Context, SyncRulesBucketStorage } from '@/index.js';
1
+ import { BasicRouterRequest, Context, JwtPayload, SyncRulesBucketStorage } from '@/index.js';
2
2
  import { RouterResponse, ServiceError, logger } from '@powersync/lib-services-framework';
3
3
  import { SqlSyncRules } from '@powersync/service-sync-rules';
4
4
  import { Readable, Writable } from 'stream';
@@ -15,11 +15,11 @@ describe('Stream Route', () => {
15
15
  const context: Context = {
16
16
  logger: logger,
17
17
  service_context: mockServiceContext(null),
18
- token_payload: {
18
+ token_payload: new JwtPayload({
19
19
  sub: '',
20
20
  exp: 0,
21
21
  iat: 0
22
- }
22
+ })
23
23
  };
24
24
 
25
25
  const request: BasicRouterRequest = {
@@ -56,11 +56,11 @@ describe('Stream Route', () => {
56
56
  const context: Context = {
57
57
  logger: logger,
58
58
  service_context: serviceContext,
59
- token_payload: {
59
+ token_payload: new JwtPayload({
60
60
  exp: new Date().getTime() / 1000 + 10000,
61
61
  iat: new Date().getTime() / 1000 - 10000,
62
62
  sub: 'test-user'
63
- }
63
+ })
64
64
  };
65
65
 
66
66
  // It may be worth eventually doing this via Fastify to test the full stack
@@ -108,11 +108,11 @@ describe('Stream Route', () => {
108
108
  const context: Context = {
109
109
  logger: testLogger,
110
110
  service_context: serviceContext,
111
- token_payload: {
111
+ token_payload: new JwtPayload({
112
112
  exp: new Date().getTime() / 1000 + 10000,
113
113
  iat: new Date().getTime() / 1000 - 10000,
114
114
  sub: 'test-user'
115
- }
115
+ })
116
116
  };
117
117
 
118
118
  const request: BasicRouterRequest = {
@@ -6,6 +6,7 @@ import {
6
6
  CHECKPOINT_INVALIDATE_ALL,
7
7
  ChecksumMap,
8
8
  InternalOpId,
9
+ JwtPayload,
9
10
  ReplicationCheckpoint,
10
11
  StreamingSyncRequest,
11
12
  SyncContext,
@@ -26,7 +27,7 @@ bucket_definitions:
26
27
  data: []
27
28
  `,
28
29
  { defaultSchema: 'public' }
29
- ).hydrate({ hydrationState: versionedHydrationState(1) });
30
+ ).config.hydrate({ hydrationState: versionedHydrationState(1) });
30
31
 
31
32
  // global[1] and global[2]
32
33
  const SYNC_RULES_GLOBAL_TWO = SqlSyncRules.fromYaml(
@@ -39,7 +40,7 @@ bucket_definitions:
39
40
  data: []
40
41
  `,
41
42
  { defaultSchema: 'public' }
42
- ).hydrate({ hydrationState: versionedHydrationState(2) });
43
+ ).config.hydrate({ hydrationState: versionedHydrationState(2) });
43
44
 
44
45
  // by_project[n]
45
46
  const SYNC_RULES_DYNAMIC = SqlSyncRules.fromYaml(
@@ -50,7 +51,7 @@ bucket_definitions:
50
51
  data: []
51
52
  `,
52
53
  { defaultSchema: 'public' }
53
- ).hydrate({ hydrationState: versionedHydrationState(3) });
54
+ ).config.hydrate({ hydrationState: versionedHydrationState(3) });
54
55
 
55
56
  const syncContext = new SyncContext({
56
57
  maxBuckets: 100,
@@ -59,7 +60,7 @@ bucket_definitions:
59
60
  });
60
61
 
61
62
  const syncRequest: StreamingSyncRequest = {};
62
- const tokenPayload: RequestJwtPayload = { sub: '' };
63
+ const tokenPayload = new JwtPayload({ sub: '' });
63
64
 
64
65
  test('global bucket with update', async () => {
65
66
  const storage = new MockBucketChecksumStateStorage();
@@ -497,7 +498,7 @@ bucket_definitions:
497
498
 
498
499
  const state = new BucketChecksumState({
499
500
  syncContext,
500
- tokenPayload: { sub: 'u1' },
501
+ tokenPayload: new JwtPayload({ sub: 'u1' }),
501
502
  syncRequest,
502
503
  syncRules: SYNC_RULES_DYNAMIC,
503
504
  bucketStorage: storage
@@ -615,7 +616,7 @@ config:
615
616
 
616
617
  const rules = SqlSyncRules.fromYaml(source, {
617
618
  defaultSchema: 'public'
618
- }).hydrate({ hydrationState: versionedHydrationState(1) });
619
+ }).config.hydrate({ hydrationState: versionedHydrationState(1) });
619
620
 
620
621
  return new BucketChecksumState({
621
622
  syncContext,
@@ -1,15 +1,12 @@
1
1
  {
2
- "extends": "../../../tsconfig.base.json",
2
+ "extends": "../../../tsconfig.tests.json",
3
3
  "compilerOptions": {
4
- "rootDir": "src",
5
4
  "baseUrl": "./",
6
5
  "outDir": "dist",
7
- "esModuleInterop": true,
8
- "skipLibCheck": true,
9
- "sourceMap": true,
10
6
  "paths": {
11
7
  "@/*": ["../src/*"]
12
- }
8
+ },
9
+ "rootDir": "src"
13
10
  },
14
11
  "include": ["src"],
15
12
  "references": [