@powersync/service-core 1.13.4 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +3 -3
- package/dist/api/api-metrics.js +5 -0
- package/dist/api/api-metrics.js.map +1 -1
- package/dist/api/diagnostics.js +31 -1
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/auth/KeyStore.d.ts +19 -0
- package/dist/auth/KeyStore.js +16 -4
- package/dist/auth/KeyStore.js.map +1 -1
- package/dist/auth/RemoteJWKSCollector.d.ts +3 -0
- package/dist/auth/RemoteJWKSCollector.js +3 -1
- package/dist/auth/RemoteJWKSCollector.js.map +1 -1
- package/dist/auth/StaticSupabaseKeyCollector.d.ts +2 -1
- package/dist/auth/StaticSupabaseKeyCollector.js +1 -1
- package/dist/auth/StaticSupabaseKeyCollector.js.map +1 -1
- package/dist/auth/utils.d.ts +19 -0
- package/dist/auth/utils.js +106 -3
- package/dist/auth/utils.js.map +1 -1
- package/dist/entry/commands/compact-action.js +10 -1
- package/dist/entry/commands/compact-action.js.map +1 -1
- package/dist/metrics/open-telemetry/util.d.ts +0 -3
- package/dist/metrics/open-telemetry/util.js +19 -12
- package/dist/metrics/open-telemetry/util.js.map +1 -1
- package/dist/replication/AbstractReplicator.js +2 -2
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/compression.d.ts +19 -0
- package/dist/routes/compression.js +70 -0
- package/dist/routes/compression.js.map +1 -0
- package/dist/routes/configure-fastify.d.ts +40 -5
- package/dist/routes/configure-fastify.js +2 -1
- package/dist/routes/configure-fastify.js.map +1 -1
- package/dist/routes/endpoints/socket-route.js +25 -17
- package/dist/routes/endpoints/socket-route.js.map +1 -1
- package/dist/routes/endpoints/sync-rules.js +1 -27
- package/dist/routes/endpoints/sync-rules.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.d.ts +80 -10
- package/dist/routes/endpoints/sync-stream.js +29 -11
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/route-register.d.ts +4 -0
- package/dist/routes/route-register.js +29 -15
- package/dist/routes/route-register.js.map +1 -1
- package/dist/storage/BucketStorage.d.ts +1 -1
- package/dist/storage/BucketStorage.js.map +1 -1
- package/dist/storage/BucketStorageBatch.d.ts +16 -6
- package/dist/storage/BucketStorageBatch.js.map +1 -1
- package/dist/storage/ChecksumCache.d.ts +4 -19
- package/dist/storage/ChecksumCache.js +4 -0
- package/dist/storage/ChecksumCache.js.map +1 -1
- package/dist/storage/ReplicationEventPayload.d.ts +2 -2
- package/dist/storage/SourceEntity.d.ts +5 -4
- package/dist/storage/SourceTable.d.ts +22 -20
- package/dist/storage/SourceTable.js +34 -30
- package/dist/storage/SourceTable.js.map +1 -1
- package/dist/storage/SyncRulesBucketStorage.d.ts +19 -4
- package/dist/storage/SyncRulesBucketStorage.js.map +1 -1
- package/dist/sync/BucketChecksumState.d.ts +41 -11
- package/dist/sync/BucketChecksumState.js +155 -19
- package/dist/sync/BucketChecksumState.js.map +1 -1
- package/dist/sync/RequestTracker.d.ts +7 -1
- package/dist/sync/RequestTracker.js +22 -2
- package/dist/sync/RequestTracker.js.map +1 -1
- package/dist/sync/sync.d.ts +3 -3
- package/dist/sync/sync.js +23 -42
- package/dist/sync/sync.js.map +1 -1
- package/dist/sync/util.d.ts +3 -1
- package/dist/sync/util.js +30 -2
- package/dist/sync/util.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +23 -0
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/lsn.d.ts +4 -0
- package/dist/util/lsn.js +11 -0
- package/dist/util/lsn.js.map +1 -0
- package/dist/util/protocol-types.d.ts +153 -9
- package/dist/util/protocol-types.js +41 -6
- package/dist/util/protocol-types.js.map +1 -1
- package/dist/util/util-index.d.ts +1 -0
- package/dist/util/util-index.js +1 -0
- package/dist/util/util-index.js.map +1 -1
- package/dist/util/utils.d.ts +18 -3
- package/dist/util/utils.js +33 -9
- package/dist/util/utils.js.map +1 -1
- package/package.json +16 -14
- package/src/api/api-metrics.ts +6 -0
- package/src/api/diagnostics.ts +33 -1
- package/src/auth/KeyStore.ts +28 -4
- package/src/auth/RemoteJWKSCollector.ts +5 -2
- package/src/auth/StaticSupabaseKeyCollector.ts +1 -1
- package/src/auth/utils.ts +123 -3
- package/src/entry/commands/compact-action.ts +9 -1
- package/src/metrics/open-telemetry/util.ts +23 -19
- package/src/replication/AbstractReplicator.ts +2 -2
- package/src/routes/compression.ts +75 -0
- package/src/routes/configure-fastify.ts +3 -1
- package/src/routes/endpoints/socket-route.ts +25 -16
- package/src/routes/endpoints/sync-rules.ts +1 -28
- package/src/routes/endpoints/sync-stream.ts +37 -26
- package/src/routes/route-register.ts +41 -15
- package/src/storage/BucketStorage.ts +2 -2
- package/src/storage/BucketStorageBatch.ts +23 -6
- package/src/storage/ChecksumCache.ts +8 -22
- package/src/storage/ReplicationEventPayload.ts +2 -2
- package/src/storage/SourceEntity.ts +5 -5
- package/src/storage/SourceTable.ts +48 -34
- package/src/storage/SyncRulesBucketStorage.ts +26 -7
- package/src/sync/BucketChecksumState.ts +194 -31
- package/src/sync/RequestTracker.ts +27 -2
- package/src/sync/sync.ts +53 -51
- package/src/sync/util.ts +32 -3
- package/src/util/config/compound-config-collector.ts +24 -0
- package/src/util/lsn.ts +8 -0
- package/src/util/protocol-types.ts +138 -10
- package/src/util/util-index.ts +1 -0
- package/src/util/utils.ts +59 -12
- package/test/src/auth.test.ts +323 -1
- package/test/src/checksum_cache.test.ts +6 -8
- package/test/src/routes/mocks.ts +59 -0
- package/test/src/routes/stream.test.ts +84 -0
- package/test/src/sync/BucketChecksumState.test.ts +375 -76
- package/tsconfig.tsbuildinfo +1 -1
package/test/src/auth.test.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { KeySpec } from '../../src/auth/KeySpec.js';
|
|
|
6
6
|
import { RemoteJWKSCollector } from '../../src/auth/RemoteJWKSCollector.js';
|
|
7
7
|
import { KeyResult } from '../../src/auth/KeyCollector.js';
|
|
8
8
|
import { CachedKeyCollector } from '../../src/auth/CachedKeyCollector.js';
|
|
9
|
-
import { JwtPayload } from '@/index.js';
|
|
9
|
+
import { JwtPayload, StaticSupabaseKeyCollector } from '@/index.js';
|
|
10
|
+
import { debugKeyNotFound } from '../../src/auth/utils.js';
|
|
10
11
|
|
|
11
12
|
const publicKeyRSA: jose.JWK = {
|
|
12
13
|
use: 'sig',
|
|
@@ -438,4 +439,325 @@ describe('JWT Auth', () => {
|
|
|
438
439
|
|
|
439
440
|
expect(verified.claim).toEqual('test-claim-2');
|
|
440
441
|
});
|
|
442
|
+
|
|
443
|
+
describe('debugKeyNotFound', () => {
|
|
444
|
+
test('Supabase token with legacy auth not configured', async () => {
|
|
445
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([]);
|
|
446
|
+
const store = new KeyStore(keys);
|
|
447
|
+
|
|
448
|
+
// Mock Supabase debug info - legacy not enabled
|
|
449
|
+
store.supabaseAuthDebug = {
|
|
450
|
+
jwksDetails: null,
|
|
451
|
+
jwksEnabled: false,
|
|
452
|
+
sharedSecretEnabled: false
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Create a legacy Supabase token (HS256)
|
|
456
|
+
const token = await new jose.SignJWT({})
|
|
457
|
+
.setProtectedHeader({ alg: 'HS256', kid: 'test' })
|
|
458
|
+
.setSubject('test')
|
|
459
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
460
|
+
.setAudience('authenticated')
|
|
461
|
+
.setExpirationTime('1h')
|
|
462
|
+
.setIssuedAt()
|
|
463
|
+
.sign(Buffer.from('secret'));
|
|
464
|
+
|
|
465
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
466
|
+
expect(err.configurationDetails).toMatch(
|
|
467
|
+
'Token is a Supabase Legacy HS256 (Shared Secret) token, but Supabase JWT secret is not configured'
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('Legacy Supabase token with wrong secret', async () => {
|
|
472
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
|
|
473
|
+
const store = new KeyStore(keys);
|
|
474
|
+
|
|
475
|
+
// Mock Supabase debug info - legacy enabled
|
|
476
|
+
store.supabaseAuthDebug = {
|
|
477
|
+
jwksDetails: null,
|
|
478
|
+
jwksEnabled: false,
|
|
479
|
+
sharedSecretEnabled: true
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Create a legacy Supabase token (HS256)
|
|
483
|
+
const token = await new jose.SignJWT({})
|
|
484
|
+
.setProtectedHeader({ alg: 'HS256', kid: sharedKey2.kid })
|
|
485
|
+
.setSubject('test')
|
|
486
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
487
|
+
.setAudience('authenticated')
|
|
488
|
+
.setExpirationTime('1h')
|
|
489
|
+
.setIssuedAt()
|
|
490
|
+
.sign(await jose.importJWK(sharedKey2));
|
|
491
|
+
|
|
492
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
493
|
+
expect(err.configurationDetails).toMatch(
|
|
494
|
+
'Token is a Supabase Legacy HS256 (Shared Secret) token, but configured Supabase JWT secret does not match'
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('New HS256 Supabase token with wrong secret', async () => {
|
|
499
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
|
|
500
|
+
const store = new KeyStore(keys);
|
|
501
|
+
|
|
502
|
+
// Mock Supabase debug info - legacy enabled
|
|
503
|
+
store.supabaseAuthDebug = {
|
|
504
|
+
jwksDetails: null,
|
|
505
|
+
jwksEnabled: false,
|
|
506
|
+
sharedSecretEnabled: true
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Create a new HS256 Supabase token.
|
|
510
|
+
// The only real difference here is that the kid is a UUID
|
|
511
|
+
const token = await new jose.SignJWT({})
|
|
512
|
+
.setProtectedHeader({ alg: 'HS256', kid: '2fc01f1d-90fb-4c8b-b646-1c06ed86be46' })
|
|
513
|
+
.setSubject('test')
|
|
514
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
515
|
+
.setAudience('authenticated')
|
|
516
|
+
.setExpirationTime('1h')
|
|
517
|
+
.setIssuedAt()
|
|
518
|
+
.sign(await jose.importJWK(sharedKey2));
|
|
519
|
+
|
|
520
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
521
|
+
expect(err.configurationDetails).toMatch(
|
|
522
|
+
'Token is a Supabase HS256 (Shared Secret) token, but configured Supabase JWT secret does not match'
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('Supabase signing key token with no Supabase connection', async () => {
|
|
527
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([]);
|
|
528
|
+
const store = new KeyStore(keys);
|
|
529
|
+
|
|
530
|
+
// Mock Supabase debug info - no Supabase connection
|
|
531
|
+
store.supabaseAuthDebug = {
|
|
532
|
+
jwksDetails: null,
|
|
533
|
+
jwksEnabled: false,
|
|
534
|
+
sharedSecretEnabled: false
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const signKey = await jose.importJWK(privateKeyECDSA);
|
|
538
|
+
const token = await new jose.SignJWT({})
|
|
539
|
+
.setProtectedHeader({ alg: 'ES256', kid: 'test-kid' })
|
|
540
|
+
.setSubject('test')
|
|
541
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
542
|
+
.setAudience('authenticated')
|
|
543
|
+
.setExpirationTime('1h')
|
|
544
|
+
.setIssuedAt()
|
|
545
|
+
.sign(signKey);
|
|
546
|
+
|
|
547
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
548
|
+
expect(err.configurationDetails).toMatch(
|
|
549
|
+
'Token uses Supabase JWT Signing Keys, but no Supabase connection is configured'
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('Supabase signing key token with Supabase auth disabled', async () => {
|
|
554
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([]);
|
|
555
|
+
const store = new KeyStore(keys);
|
|
556
|
+
|
|
557
|
+
// Mock Supabase debug info - Supabase project, but Supabase auth not enabled
|
|
558
|
+
store.supabaseAuthDebug = {
|
|
559
|
+
jwksDetails: {
|
|
560
|
+
projectId: 'abc123',
|
|
561
|
+
hostname: 'db.abc123.supabase.co',
|
|
562
|
+
url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json'
|
|
563
|
+
},
|
|
564
|
+
jwksEnabled: false,
|
|
565
|
+
sharedSecretEnabled: false
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const signKey = await jose.importJWK(privateKeyECDSA);
|
|
569
|
+
const token = await new jose.SignJWT({})
|
|
570
|
+
.setProtectedHeader({ alg: 'ES256', kid: 'test-kid' })
|
|
571
|
+
.setSubject('test')
|
|
572
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
573
|
+
.setAudience('authenticated')
|
|
574
|
+
.setExpirationTime('1h')
|
|
575
|
+
.setIssuedAt()
|
|
576
|
+
.sign(signKey);
|
|
577
|
+
|
|
578
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
579
|
+
expect(err.configurationDetails).toMatch(
|
|
580
|
+
'Token uses Supabase JWT Signing Keys, but Supabase Auth is not enabled'
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('Supabase project ID mismatch', async () => {
|
|
585
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]);
|
|
586
|
+
const store = new KeyStore(keys);
|
|
587
|
+
|
|
588
|
+
// Mock Supabase debug info - JWKS enabled with different project ID
|
|
589
|
+
store.supabaseAuthDebug = {
|
|
590
|
+
jwksDetails: {
|
|
591
|
+
projectId: 'expected123',
|
|
592
|
+
hostname: 'db.expected123.supabase.co',
|
|
593
|
+
url: 'https://expected123.supabase.co/auth/v1/.well-known/jwks.json'
|
|
594
|
+
},
|
|
595
|
+
jwksEnabled: true,
|
|
596
|
+
sharedSecretEnabled: false
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Create a modern Supabase token with different project ID
|
|
600
|
+
const token = await new jose.SignJWT({})
|
|
601
|
+
.setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
|
|
602
|
+
.setSubject('test')
|
|
603
|
+
.setIssuer('https://different456.supabase.co/auth/v1')
|
|
604
|
+
.setAudience('authenticated')
|
|
605
|
+
.setExpirationTime('1h')
|
|
606
|
+
.setIssuedAt()
|
|
607
|
+
.sign(await jose.importJWK(privateKeyECDSA));
|
|
608
|
+
|
|
609
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
610
|
+
expect(err.configurationDetails).toMatch(
|
|
611
|
+
'Supabase project id mismatch. Expected project: expected123, got issuer: https://different456.supabase.co/auth/v1'
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('Supabase signing keys configured but no matching keys', async () => {
|
|
616
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([publicKeyRSA]);
|
|
617
|
+
const store = new KeyStore(keys);
|
|
618
|
+
|
|
619
|
+
// Mock Supabase debug info - JWKS enabled with matching project ID
|
|
620
|
+
store.supabaseAuthDebug = {
|
|
621
|
+
jwksDetails: {
|
|
622
|
+
projectId: 'abc123',
|
|
623
|
+
hostname: 'db.abc123.supabase.co',
|
|
624
|
+
url: 'https://abc123.supabase.co/auth/v1/.well-known/jwks.json'
|
|
625
|
+
},
|
|
626
|
+
jwksEnabled: true,
|
|
627
|
+
sharedSecretEnabled: false
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Create a modern Supabase token with matching project ID
|
|
631
|
+
const token = await new jose.SignJWT({})
|
|
632
|
+
.setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
|
|
633
|
+
.setSubject('test')
|
|
634
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
635
|
+
.setAudience('authenticated')
|
|
636
|
+
.setExpirationTime('1h')
|
|
637
|
+
.setIssuedAt()
|
|
638
|
+
.sign(await jose.importJWK(privateKeyECDSA));
|
|
639
|
+
|
|
640
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
641
|
+
expect(err.configurationDetails).toMatch(
|
|
642
|
+
'Supabase signing keys configured, but no matching keys found. Known keys: '
|
|
643
|
+
);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('non-Supabase token', async () => {
|
|
647
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
|
|
648
|
+
const store = new KeyStore(keys);
|
|
649
|
+
|
|
650
|
+
// Create a regular JWT token (not Supabase)
|
|
651
|
+
const token = await new jose.SignJWT({})
|
|
652
|
+
.setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
|
|
653
|
+
.setSubject('test')
|
|
654
|
+
.setIssuer('https://regular-issuer.com')
|
|
655
|
+
.setAudience('my-audience')
|
|
656
|
+
.setExpirationTime('1h')
|
|
657
|
+
.setIssuedAt()
|
|
658
|
+
.sign(await jose.importJWK(privateKeyECDSA));
|
|
659
|
+
|
|
660
|
+
// Treated as just a generic unknown key
|
|
661
|
+
const err = await store.verifyJwt(token, { defaultAudiences: ['my-audience'], maxAge: '1d' }).catch((e) => e);
|
|
662
|
+
expect(err.configurationDetails).toMatch('Known keys:');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('Valid legacy Supabase token', async () => {
|
|
666
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
|
|
667
|
+
const store = new KeyStore(keys);
|
|
668
|
+
|
|
669
|
+
// Mock Supabase debug info - legacy enabled
|
|
670
|
+
store.supabaseAuthDebug = {
|
|
671
|
+
jwksDetails: null,
|
|
672
|
+
jwksEnabled: false,
|
|
673
|
+
sharedSecretEnabled: true
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Create a legacy Supabase token (HS256)
|
|
677
|
+
const token = await new jose.SignJWT({})
|
|
678
|
+
.setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid })
|
|
679
|
+
.setSubject('test')
|
|
680
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
681
|
+
.setAudience('authenticated')
|
|
682
|
+
.setExpirationTime('1h')
|
|
683
|
+
.setIssuedAt()
|
|
684
|
+
.sign(await jose.importJWK(sharedKey));
|
|
685
|
+
|
|
686
|
+
await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' });
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test('Valid Supabase signing key', async () => {
|
|
690
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]);
|
|
691
|
+
const store = new KeyStore(keys);
|
|
692
|
+
|
|
693
|
+
// Mock Supabase debug info - JWKS enabled, legacy disabled
|
|
694
|
+
store.supabaseAuthDebug = {
|
|
695
|
+
jwksDetails: null,
|
|
696
|
+
jwksEnabled: true,
|
|
697
|
+
sharedSecretEnabled: false
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// Create a modern Supabase signing key token (ES256)
|
|
701
|
+
const token = await new jose.SignJWT({})
|
|
702
|
+
.setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
|
|
703
|
+
.setSubject('test')
|
|
704
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
705
|
+
.setAudience('authenticated')
|
|
706
|
+
.setExpirationTime('1h')
|
|
707
|
+
.setIssuedAt()
|
|
708
|
+
.sign(await jose.importJWK(privateKeyECDSA));
|
|
709
|
+
|
|
710
|
+
await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' });
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test('Legacy Supabase anon token', async () => {
|
|
714
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([sharedKey]);
|
|
715
|
+
const store = new KeyStore(keys);
|
|
716
|
+
|
|
717
|
+
// Mock Supabase debug info - legacy enabled
|
|
718
|
+
store.supabaseAuthDebug = {
|
|
719
|
+
jwksDetails: null,
|
|
720
|
+
jwksEnabled: false,
|
|
721
|
+
sharedSecretEnabled: true
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
// Create a legacy Supabase token (HS256)
|
|
725
|
+
const token = await new jose.SignJWT({})
|
|
726
|
+
.setProtectedHeader({ alg: 'HS256', kid: sharedKey.kid })
|
|
727
|
+
.setSubject('test')
|
|
728
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
729
|
+
.setAudience('anon')
|
|
730
|
+
.setExpirationTime('1h')
|
|
731
|
+
.setIssuedAt()
|
|
732
|
+
.sign(await jose.importJWK(sharedKey));
|
|
733
|
+
|
|
734
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
735
|
+
expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"');
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('Supabase signing key anon token', async () => {
|
|
739
|
+
const keys = await StaticSupabaseKeyCollector.importKeys([privateKeyECDSA]);
|
|
740
|
+
const store = new KeyStore(keys);
|
|
741
|
+
|
|
742
|
+
// Mock Supabase debug info - JWKS enabled
|
|
743
|
+
store.supabaseAuthDebug = {
|
|
744
|
+
jwksDetails: null,
|
|
745
|
+
jwksEnabled: true,
|
|
746
|
+
sharedSecretEnabled: false
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// Create a modern Supabase signing key token (ES256)
|
|
750
|
+
const token = await new jose.SignJWT({})
|
|
751
|
+
.setProtectedHeader({ alg: 'ES256', kid: privateKeyECDSA.kid })
|
|
752
|
+
.setSubject('test')
|
|
753
|
+
.setIssuer('https://abc123.supabase.co/auth/v1')
|
|
754
|
+
.setAudience('anon')
|
|
755
|
+
.setExpirationTime('1h')
|
|
756
|
+
.setIssuedAt()
|
|
757
|
+
.sign(await jose.importJWK(privateKeyECDSA));
|
|
758
|
+
|
|
759
|
+
const err = await store.verifyJwt(token, { defaultAudiences: [], maxAge: '1d' }).catch((e) => e);
|
|
760
|
+
expect(err.message).toMatch('[PSYNC_S2105] Unexpected "aud" claim value: "anon"');
|
|
761
|
+
});
|
|
762
|
+
});
|
|
441
763
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ChecksumCache, FetchChecksums, FetchPartialBucketChecksum
|
|
2
|
-
import { addChecksums, InternalOpId } from '@/util/util-index.js';
|
|
1
|
+
import { ChecksumCache, FetchChecksums, FetchPartialBucketChecksum } from '@/storage/ChecksumCache.js';
|
|
2
|
+
import { addChecksums, BucketChecksum, InternalOpId, PartialChecksum } from '@/util/util-index.js';
|
|
3
3
|
import * as crypto from 'node:crypto';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
|
|
@@ -12,22 +12,20 @@ function testHash(bucket: string, checkpoint: InternalOpId) {
|
|
|
12
12
|
return hash;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function testPartialHash(request: FetchPartialBucketChecksum): PartialChecksum {
|
|
15
|
+
function testPartialHash(request: FetchPartialBucketChecksum): PartialChecksum | BucketChecksum {
|
|
16
16
|
if (request.start) {
|
|
17
17
|
const a = testHash(request.bucket, request.start);
|
|
18
18
|
const b = testHash(request.bucket, request.end);
|
|
19
19
|
return {
|
|
20
20
|
bucket: request.bucket,
|
|
21
21
|
partialCount: Number(request.end) - Number(request.start),
|
|
22
|
-
partialChecksum: addChecksums(b, -a)
|
|
23
|
-
isFullChecksum: false
|
|
22
|
+
partialChecksum: addChecksums(b, -a)
|
|
24
23
|
};
|
|
25
24
|
} else {
|
|
26
25
|
return {
|
|
27
26
|
bucket: request.bucket,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
isFullChecksum: true
|
|
27
|
+
checksum: testHash(request.bucket, request.end),
|
|
28
|
+
count: Number(request.end)
|
|
31
29
|
};
|
|
32
30
|
}
|
|
33
31
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BucketStorageFactory,
|
|
3
|
+
createCoreAPIMetrics,
|
|
4
|
+
MetricsEngine,
|
|
5
|
+
OpenTelemetryMetricsFactory,
|
|
6
|
+
RouteAPI,
|
|
7
|
+
RouterEngine,
|
|
8
|
+
ServiceContext,
|
|
9
|
+
StorageEngine,
|
|
10
|
+
SyncContext,
|
|
11
|
+
SyncRulesBucketStorage
|
|
12
|
+
} from '@/index.js';
|
|
13
|
+
import { MeterProvider } from '@opentelemetry/sdk-metrics';
|
|
14
|
+
|
|
15
|
+
export function mockServiceContext(storage: Partial<SyncRulesBucketStorage> | null) {
|
|
16
|
+
// This is very incomplete - just enough to get the current tests passing.
|
|
17
|
+
|
|
18
|
+
const storageEngine: StorageEngine = {
|
|
19
|
+
activeBucketStorage: {
|
|
20
|
+
async getActiveStorage() {
|
|
21
|
+
return storage;
|
|
22
|
+
}
|
|
23
|
+
} as Partial<BucketStorageFactory>
|
|
24
|
+
} as any;
|
|
25
|
+
|
|
26
|
+
const meterProvider = new MeterProvider({
|
|
27
|
+
readers: []
|
|
28
|
+
});
|
|
29
|
+
const meter = meterProvider.getMeter('powersync-tests');
|
|
30
|
+
const metricsEngine = new MetricsEngine({
|
|
31
|
+
disable_telemetry_sharing: true,
|
|
32
|
+
factory: new OpenTelemetryMetricsFactory(meter)
|
|
33
|
+
});
|
|
34
|
+
createCoreAPIMetrics(metricsEngine);
|
|
35
|
+
const service_context: Partial<ServiceContext> = {
|
|
36
|
+
syncContext: new SyncContext({ maxBuckets: 1, maxDataFetchConcurrency: 1, maxParameterQueryResults: 1 }),
|
|
37
|
+
routerEngine: {
|
|
38
|
+
getAPI() {
|
|
39
|
+
return {
|
|
40
|
+
getParseSyncRulesOptions() {
|
|
41
|
+
return { defaultSchema: 'public' };
|
|
42
|
+
}
|
|
43
|
+
} as Partial<RouteAPI>;
|
|
44
|
+
},
|
|
45
|
+
addStopHandler() {
|
|
46
|
+
return () => {};
|
|
47
|
+
}
|
|
48
|
+
} as Partial<RouterEngine> as any,
|
|
49
|
+
storageEngine,
|
|
50
|
+
metricsEngine: metricsEngine,
|
|
51
|
+
// Not used
|
|
52
|
+
configuration: null as any,
|
|
53
|
+
lifeCycleEngine: null as any,
|
|
54
|
+
migrations: null as any,
|
|
55
|
+
replicationEngine: null as any,
|
|
56
|
+
serviceMode: null as any
|
|
57
|
+
};
|
|
58
|
+
return service_context as ServiceContext;
|
|
59
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { BasicRouterRequest, Context, SyncRulesBucketStorage } from '@/index.js';
|
|
2
|
+
import { logger, RouterResponse, ServiceError } from '@powersync/lib-services-framework';
|
|
3
|
+
import { SqlSyncRules } from '@powersync/service-sync-rules';
|
|
4
|
+
import { Readable, Writable } from 'stream';
|
|
5
|
+
import { pipeline } from 'stream/promises';
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import { syncStreamed } from '../../../src/routes/endpoints/sync-stream.js';
|
|
8
|
+
import { mockServiceContext } from './mocks.js';
|
|
9
|
+
|
|
10
|
+
describe('Stream Route', () => {
|
|
11
|
+
describe('compressed stream', () => {
|
|
12
|
+
it('handles missing sync rules', async () => {
|
|
13
|
+
const context: Context = {
|
|
14
|
+
logger: logger,
|
|
15
|
+
service_context: mockServiceContext(null)
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const request: BasicRouterRequest = {
|
|
19
|
+
headers: {},
|
|
20
|
+
hostname: '',
|
|
21
|
+
protocol: 'http'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const error = (await (syncStreamed.handler({ context, params: {}, request }) as Promise<RouterResponse>).catch(
|
|
25
|
+
(e) => e
|
|
26
|
+
)) as ServiceError;
|
|
27
|
+
|
|
28
|
+
expect(error.errorData.status).toEqual(500);
|
|
29
|
+
expect(error.errorData.code).toEqual('PSYNC_S2302');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles a stream error with compression', async () => {
|
|
33
|
+
// This primarily tests that an underlying storage error doesn't result in an uncaught error
|
|
34
|
+
// when compressing the stream.
|
|
35
|
+
|
|
36
|
+
const storage = {
|
|
37
|
+
getParsedSyncRules() {
|
|
38
|
+
return new SqlSyncRules('bucket_definitions: {}');
|
|
39
|
+
},
|
|
40
|
+
watchCheckpointChanges: async function* (options) {
|
|
41
|
+
throw new Error('Simulated storage error');
|
|
42
|
+
}
|
|
43
|
+
} as Partial<SyncRulesBucketStorage>;
|
|
44
|
+
const serviceContext = mockServiceContext(storage);
|
|
45
|
+
|
|
46
|
+
const context: Context = {
|
|
47
|
+
logger: logger,
|
|
48
|
+
service_context: serviceContext,
|
|
49
|
+
token_payload: {
|
|
50
|
+
exp: new Date().getTime() / 1000 + 10000,
|
|
51
|
+
iat: new Date().getTime() / 1000 - 10000,
|
|
52
|
+
sub: 'test-user'
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// It may be worth eventually doing this via Fastify to test the full stack
|
|
57
|
+
|
|
58
|
+
const request: BasicRouterRequest = {
|
|
59
|
+
headers: {
|
|
60
|
+
'accept-encoding': 'gzip'
|
|
61
|
+
},
|
|
62
|
+
hostname: '',
|
|
63
|
+
protocol: 'http'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const response = await (syncStreamed.handler({ context, params: {}, request }) as Promise<RouterResponse>);
|
|
67
|
+
expect(response.status).toEqual(200);
|
|
68
|
+
const stream = response.data as Readable;
|
|
69
|
+
const r = await drainWithTimeout(stream).catch((error) => error);
|
|
70
|
+
expect(r.message).toContain('Simulated storage error');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export async function drainWithTimeout(readable: Readable, ms = 2_000) {
|
|
76
|
+
const devNull = new Writable({
|
|
77
|
+
write(_chunk, _enc, cb) {
|
|
78
|
+
cb();
|
|
79
|
+
} // discard everything
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Throws AbortError if it takes longer than ms, and destroys the stream
|
|
83
|
+
await pipeline(readable, devNull, { signal: AbortSignal.timeout(ms) });
|
|
84
|
+
}
|