@powersync/service-core 1.18.2 → 1.19.1

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 (77) hide show
  1. package/CHANGELOG.md +35 -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/metrics/open-telemetry/util.js +3 -1
  11. package/dist/metrics/open-telemetry/util.js.map +1 -1
  12. package/dist/replication/AbstractReplicator.d.ts +1 -0
  13. package/dist/replication/AbstractReplicator.js +16 -6
  14. package/dist/replication/AbstractReplicator.js.map +1 -1
  15. package/dist/routes/auth.d.ts +0 -1
  16. package/dist/routes/auth.js +2 -4
  17. package/dist/routes/auth.js.map +1 -1
  18. package/dist/routes/configure-fastify.js +1 -1
  19. package/dist/routes/configure-fastify.js.map +1 -1
  20. package/dist/routes/endpoints/admin.d.ts +3 -0
  21. package/dist/routes/endpoints/admin.js +8 -2
  22. package/dist/routes/endpoints/admin.js.map +1 -1
  23. package/dist/routes/endpoints/checkpointing.js +3 -3
  24. package/dist/routes/endpoints/checkpointing.js.map +1 -1
  25. package/dist/routes/endpoints/socket-route.js +3 -6
  26. package/dist/routes/endpoints/socket-route.js.map +1 -1
  27. package/dist/routes/endpoints/sync-rules.js +5 -5
  28. package/dist/routes/endpoints/sync-rules.js.map +1 -1
  29. package/dist/routes/endpoints/sync-stream.js +3 -6
  30. package/dist/routes/endpoints/sync-stream.js.map +1 -1
  31. package/dist/routes/router.d.ts +0 -1
  32. package/dist/routes/router.js.map +1 -1
  33. package/dist/storage/PersistedSyncRulesContent.d.ts +3 -2
  34. package/dist/storage/SyncRulesBucketStorage.d.ts +12 -4
  35. package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
  36. package/dist/storage/bson.d.ts +3 -3
  37. package/dist/storage/bson.js.map +1 -1
  38. package/dist/sync/BucketChecksumState.d.ts +7 -10
  39. package/dist/sync/BucketChecksumState.js +16 -15
  40. package/dist/sync/BucketChecksumState.js.map +1 -1
  41. package/dist/sync/sync.d.ts +2 -2
  42. package/dist/sync/sync.js +5 -7
  43. package/dist/sync/sync.js.map +1 -1
  44. package/dist/util/config/collectors/config-collector.js +5 -2
  45. package/dist/util/config/collectors/config-collector.js.map +1 -1
  46. package/dist/util/config.d.ts +2 -0
  47. package/dist/util/config.js +15 -2
  48. package/dist/util/config.js.map +1 -1
  49. package/package.json +5 -5
  50. package/src/api/RouteAPI.ts +2 -2
  51. package/src/api/diagnostics.ts +5 -4
  52. package/src/auth/JwtPayload.ts +16 -8
  53. package/src/auth/KeyStore.ts +1 -1
  54. package/src/metrics/open-telemetry/util.ts +3 -1
  55. package/src/replication/AbstractReplicator.ts +15 -7
  56. package/src/routes/auth.ts +2 -4
  57. package/src/routes/configure-fastify.ts +1 -1
  58. package/src/routes/endpoints/admin.ts +8 -2
  59. package/src/routes/endpoints/checkpointing.ts +5 -3
  60. package/src/routes/endpoints/socket-route.ts +3 -6
  61. package/src/routes/endpoints/sync-rules.ts +5 -5
  62. package/src/routes/endpoints/sync-stream.ts +3 -6
  63. package/src/routes/router.ts +0 -2
  64. package/src/storage/PersistedSyncRulesContent.ts +4 -2
  65. package/src/storage/SyncRulesBucketStorage.ts +13 -4
  66. package/src/storage/bson.ts +3 -3
  67. package/src/sync/BucketChecksumState.ts +26 -28
  68. package/src/sync/sync.ts +12 -14
  69. package/src/util/config/collectors/config-collector.ts +9 -2
  70. package/src/util/config.ts +20 -2
  71. package/test/src/auth.test.ts +76 -20
  72. package/test/src/config.test.ts +17 -0
  73. package/test/src/routes/stream.test.ts +9 -9
  74. package/test/src/sync/BucketChecksumState.test.ts +23 -52
  75. package/tsconfig.json +0 -3
  76. package/tsconfig.tsbuildinfo +1 -1
  77. package/vitest.config.ts +6 -7
@@ -2,38 +2,32 @@ import {
2
2
  BucketDescription,
3
3
  BucketPriority,
4
4
  BucketSource,
5
+ HydratedSyncRules,
5
6
  RequestedStream,
6
- RequestJwtPayload,
7
7
  RequestParameters,
8
- ResolvedBucket,
9
- SqlSyncRules
8
+ ResolvedBucket
10
9
  } from '@powersync/service-sync-rules';
11
10
 
12
11
  import * as storage from '../storage/storage-index.js';
13
12
  import * as util from '../util/util-index.js';
14
13
 
15
14
  import {
15
+ logger as defaultLogger,
16
16
  ErrorCode,
17
17
  Logger,
18
18
  ServiceAssertionError,
19
- ServiceError,
20
- logger as defaultLogger
19
+ ServiceError
21
20
  } from '@powersync/lib-services-framework';
22
- import { JSONBig } from '@powersync/service-jsonbig';
23
21
  import { BucketParameterQuerier, QuerierError } from '@powersync/service-sync-rules/src/BucketParameterQuerier.js';
22
+ import { JwtPayload } from '../auth/JwtPayload.js';
24
23
  import { SyncContext } from './SyncContext.js';
25
24
  import { getIntersection, hasIntersection } from './util.js';
26
25
 
27
- export interface VersionedSyncRules {
28
- syncRules: SqlSyncRules;
29
- version: number;
30
- }
31
-
32
26
  export interface BucketChecksumStateOptions {
33
27
  syncContext: SyncContext;
34
28
  bucketStorage: BucketChecksumStateStorage;
35
- syncRules: VersionedSyncRules;
36
- tokenPayload: RequestJwtPayload;
29
+ syncRules: HydratedSyncRules;
30
+ tokenPayload: JwtPayload;
37
31
  syncRequest: util.StreamingSyncRequest;
38
32
  logger?: Logger;
39
33
  }
@@ -118,7 +112,7 @@ export class BucketChecksumState {
118
112
  */
119
113
  async buildNextCheckpointLine(next: storage.StorageCheckpointUpdate): Promise<CheckpointLine | null> {
120
114
  const { writeCheckpoint, base } = next;
121
- const user_id = this.parameterState.syncParams.userId;
115
+ const userIdForLogs = this.parameterState.syncParams.userId;
122
116
 
123
117
  const storage = this.bucketStorage;
124
118
 
@@ -226,7 +220,7 @@ export class BucketChecksumState {
226
220
  message += `removed: ${limitedBuckets(diff.removedBuckets, 20)}`;
227
221
  this.logger.info(message, {
228
222
  checkpoint: base.checkpoint,
229
- user_id: user_id,
223
+ user_id: userIdForLogs,
230
224
  buckets: allBuckets.length,
231
225
  updated: diff.updatedBuckets.length,
232
226
  removed: diff.removedBuckets.length
@@ -245,7 +239,7 @@ export class BucketChecksumState {
245
239
  deferredLog = () => {
246
240
  let message = `New checkpoint: ${base.checkpoint} | write: ${writeCheckpoint} | `;
247
241
  message += `buckets: ${allBuckets.length} ${limitedBuckets(allBuckets, 20)}`;
248
- this.logger.info(message, { checkpoint: base.checkpoint, user_id: user_id, buckets: allBuckets.length });
242
+ this.logger.info(message, { checkpoint: base.checkpoint, user_id: userIdForLogs, buckets: allBuckets.length });
249
243
  };
250
244
  bucketsToFetch = allBuckets.map((b) => ({ bucket: b.bucket, priority: b.priority }));
251
245
 
@@ -253,7 +247,7 @@ export class BucketChecksumState {
253
247
  const streamNameToIndex = new Map<string, number>();
254
248
  this.streamNameToIndex = streamNameToIndex;
255
249
 
256
- for (const source of this.parameterState.syncRules.syncRules.bucketSources) {
250
+ for (const source of this.parameterState.syncRules.definition.bucketSources) {
257
251
  if (this.parameterState.isSubscribedToStream(source)) {
258
252
  streamNameToIndex.set(source.name, subscriptions.length);
259
253
 
@@ -381,7 +375,7 @@ export interface CheckpointUpdate {
381
375
  export class BucketParameterState {
382
376
  private readonly context: SyncContext;
383
377
  public readonly bucketStorage: BucketChecksumStateStorage;
384
- public readonly syncRules: VersionedSyncRules;
378
+ public readonly syncRules: HydratedSyncRules;
385
379
  public readonly syncParams: RequestParameters;
386
380
  private readonly querier: BucketParameterQuerier;
387
381
  /**
@@ -399,13 +393,13 @@ export class BucketParameterState {
399
393
  private cachedDynamicBuckets: ResolvedBucket[] | null = null;
400
394
  private cachedDynamicBucketSet: Set<string> | null = null;
401
395
 
402
- private readonly lookups: Set<string>;
396
+ private lookupsFromPreviousCheckpoint: Set<string> | null = null;
403
397
 
404
398
  constructor(
405
399
  context: SyncContext,
406
400
  bucketStorage: BucketChecksumStateStorage,
407
- syncRules: VersionedSyncRules,
408
- tokenPayload: RequestJwtPayload,
401
+ syncRules: HydratedSyncRules,
402
+ tokenPayload: JwtPayload,
409
403
  request: util.StreamingSyncRequest,
410
404
  logger: Logger
411
405
  ) {
@@ -436,11 +430,10 @@ export class BucketParameterState {
436
430
  this.includeDefaultStreams = subscriptions?.include_defaults ?? true;
437
431
  this.explicitStreamSubscriptions = explicitStreamSubscriptions;
438
432
 
439
- const { querier, errors } = syncRules.syncRules.getBucketParameterQuerier({
433
+ const { querier, errors } = syncRules.getBucketParameterQuerier({
440
434
  globalParameters: this.syncParams,
441
435
  hasDefaultStreams: this.includeDefaultStreams,
442
- streams: streamsByName,
443
- bucketIdTransformer: SqlSyncRules.versionedBucketIdTransformer(`${syncRules.version}`)
436
+ streams: streamsByName
444
437
  });
445
438
  this.querier = querier;
446
439
  this.streamErrors = Object.groupBy(errors, (e) => e.descriptor) as Record<string, QuerierError[]>;
@@ -448,7 +441,6 @@ export class BucketParameterState {
448
441
  this.staticBuckets = new Map<string, ResolvedBucket>(
449
442
  mergeBuckets(this.querier.staticBuckets).map((b) => [b.bucket, b])
450
443
  );
451
- this.lookups = new Set<string>(this.querier.parameterQueryLookups.map((l) => JSONBig.stringify(l.values)));
452
444
  this.subscribedStreamNames = new Set(Object.keys(streamsByName));
453
445
  }
454
446
 
@@ -547,7 +539,6 @@ export class BucketParameterState {
547
539
  */
548
540
  private async getCheckpointUpdateDynamic(checkpoint: storage.StorageCheckpointUpdate): Promise<CheckpointUpdate> {
549
541
  const querier = this.querier;
550
- const storage = this.bucketStorage;
551
542
  const staticBuckets = this.staticBuckets.values();
552
543
  const update = checkpoint.update;
553
544
 
@@ -561,10 +552,10 @@ export class BucketParameterState {
561
552
  invalidateDataBuckets = true;
562
553
  }
563
554
 
564
- if (update.invalidateParameterBuckets) {
555
+ if (update.invalidateParameterBuckets || this.lookupsFromPreviousCheckpoint == null) {
565
556
  hasParameterChange = true;
566
557
  } else {
567
- if (hasIntersection(this.lookups, update.updatedParameterLookups)) {
558
+ if (hasIntersection(this.lookupsFromPreviousCheckpoint, update.updatedParameterLookups)) {
568
559
  // This is a very coarse re-check of all queries
569
560
  hasParameterChange = true;
570
561
  }
@@ -572,13 +563,20 @@ export class BucketParameterState {
572
563
 
573
564
  let dynamicBuckets: ResolvedBucket[];
574
565
  if (hasParameterChange || this.cachedDynamicBuckets == null || this.cachedDynamicBucketSet == null) {
566
+ const recordedLookups = new Set<string>();
567
+
575
568
  dynamicBuckets = await querier.queryDynamicBucketDescriptions({
576
569
  getParameterSets(lookups) {
570
+ for (const lookup of lookups) {
571
+ recordedLookups.add(lookup.serializedRepresentation);
572
+ }
573
+
577
574
  return checkpoint.base.getParameterSets(lookups);
578
575
  }
579
576
  });
580
577
  this.cachedDynamicBuckets = dynamicBuckets;
581
578
  this.cachedDynamicBucketSet = new Set<string>(dynamicBuckets.map((b) => b.bucket));
579
+ this.lookupsFromPreviousCheckpoint = recordedLookups;
582
580
  invalidateDataBuckets = true;
583
581
  } else {
584
582
  dynamicBuckets = this.cachedDynamicBuckets;
package/src/sync/sync.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { JSONBig, JsonContainer } from '@powersync/service-jsonbig';
2
- import { BucketDescription, BucketPriority, RequestJwtPayload } 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
 
@@ -9,7 +9,7 @@ import * as util from '../util/util-index.js';
9
9
 
10
10
  import { Logger, logger as defaultLogger } from '@powersync/lib-services-framework';
11
11
  import { mergeAsyncIterables } from '../streams/streams-index.js';
12
- import { BucketChecksumState, CheckpointLine, VersionedSyncRules } from './BucketChecksumState.js';
12
+ import { BucketChecksumState, CheckpointLine } from './BucketChecksumState.js';
13
13
  import { OperationsSentStats, RequestTracker, statsForBatch } from './RequestTracker.js';
14
14
  import { SyncContext } from './SyncContext.js';
15
15
  import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStream } from './util.js';
@@ -17,7 +17,7 @@ import { TokenStreamOptions, acquireSemaphoreAbortable, settledPromise, tokenStr
17
17
  export interface SyncStreamParameters {
18
18
  syncContext: SyncContext;
19
19
  bucketStorage: storage.SyncRulesBucketStorage;
20
- syncRules: VersionedSyncRules;
20
+ syncRules: HydratedSyncRules;
21
21
  params: util.StreamingSyncRequest;
22
22
  token: auth.JwtPayload;
23
23
  logger?: Logger;
@@ -94,18 +94,15 @@ export async function* streamResponse(
94
94
  async function* streamResponseInner(
95
95
  syncContext: SyncContext,
96
96
  bucketStorage: storage.SyncRulesBucketStorage,
97
- syncRules: VersionedSyncRules,
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,13 +70,20 @@ export abstract class ConfigCollector {
70
70
  return this.parseJSON(content);
71
71
  default: {
72
72
  // No content type provided, need to try both
73
+ let yamlError: unknown;
73
74
  try {
74
75
  return this.parseYaml(content);
75
- } catch (ex) {}
76
+ } catch (ex) {
77
+ yamlError = ex;
78
+ }
76
79
  try {
77
80
  return this.parseJSON(content);
78
81
  } catch (ex) {
79
- throw new Error(`Could not parse PowerSync config file content as JSON or YAML: ${ex}`);
82
+ throw new Error(
83
+ `Could not parse PowerSync config file content as JSON or YAML: JSON Error: ${ex}${
84
+ yamlError ? `\nYAML Error: ${yamlError}` : ''
85
+ }`
86
+ );
80
87
  }
81
88
  }
82
89
  }
@@ -1,15 +1,33 @@
1
1
  import * as fs from 'fs/promises';
2
+ import winston from 'winston';
2
3
 
3
- import { container } from '@powersync/lib-services-framework';
4
+ import { container, logger, LogFormat, DEFAULT_LOG_LEVEL, DEFAULT_LOG_FORMAT } from '@powersync/lib-services-framework';
5
+ import { configFile } from '@powersync/service-types';
4
6
  import { ResolvedPowerSyncConfig, RunnerConfig } from './config/types.js';
5
7
  import { CompoundConfigCollector } from './util-index.js';
6
8
 
9
+ export function configureLogger(config?: configFile.LoggingConfig): void {
10
+ const level = process.env.PS_LOG_LEVEL ?? config?.level ?? DEFAULT_LOG_LEVEL;
11
+ const format =
12
+ (process.env.PS_LOG_FORMAT as configFile.LoggingConfig['format']) ?? config?.format ?? DEFAULT_LOG_FORMAT;
13
+ const winstonFormat = format === 'json' ? LogFormat.production : LogFormat.development;
14
+
15
+ // We want the user to always be aware that a log level was configured (they might forget they set it in the config and wonder why they aren't seeing logs)
16
+ // We log this using the configured format, but before we configure the level.
17
+ logger.configure({ level: DEFAULT_LOG_LEVEL, format: winstonFormat, transports: [new winston.transports.Console()] });
18
+ logger.info(`Configured logger with level "${level}" and format "${format}"`);
19
+
20
+ logger.configure({ level, format: winstonFormat, transports: [new winston.transports.Console()] });
21
+ }
22
+
7
23
  /**
8
24
  * Loads the resolved config using the registered config collector
9
25
  */
10
26
  export async function loadConfig(runnerConfig: RunnerConfig) {
11
27
  const collector = container.getImplementation(CompoundConfigCollector);
12
- return collector.collectConfig(runnerConfig);
28
+ const config = await collector.collectConfig(runnerConfig);
29
+ configureLogger(config.base_config.system?.logging);
30
+ return config;
13
31
  }
14
32
 
15
33
  export async function loadSyncRules(config: ResolvedPowerSyncConfig): Promise<string | undefined> {
@@ -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', () => {
@@ -69,4 +69,21 @@ describe('Config', () => {
69
69
 
70
70
  expect(config.api_parameters.max_buckets_per_connection).toBe(1);
71
71
  });
72
+ it('should throw YAML validation error for invalid base64 config', {}, async () => {
73
+ const yamlConfig = /* yaml */ `
74
+ # PowerSync config
75
+ replication:
76
+ connections: []
77
+ storage:
78
+ type: !env INVALID_VAR
79
+ `;
80
+
81
+ const collector = new CompoundConfigCollector();
82
+
83
+ await expect(
84
+ collector.collectConfig({
85
+ config_base64: Buffer.from(yamlConfig, 'utf-8').toString('base64')
86
+ })
87
+ ).rejects.toThrow(/YAML Error:[\s\S]*Attempting to substitute environment variable INVALID_VAR/);
88
+ });
72
89
  });
@@ -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 = {
@@ -45,7 +45,7 @@ describe('Stream Route', () => {
45
45
 
46
46
  const storage = {
47
47
  getParsedSyncRules() {
48
- return new SqlSyncRules('bucket_definitions: {}');
48
+ return new SqlSyncRules('bucket_definitions: {}').hydrate();
49
49
  },
50
50
  watchCheckpointChanges: async function* (options) {
51
51
  throw new Error('Simulated storage error');
@@ -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
@@ -83,7 +83,7 @@ describe('Stream Route', () => {
83
83
  it('logs the application metadata', async () => {
84
84
  const storage = {
85
85
  getParsedSyncRules() {
86
- return new SqlSyncRules('bucket_definitions: {}');
86
+ return new SqlSyncRules('bucket_definitions: {}').hydrate();
87
87
  },
88
88
  watchCheckpointChanges: async function* (options) {
89
89
  throw new Error('Simulated storage error');
@@ -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 = {