@powersync/service-core 0.0.0-dev-20250618131818 → 0.0.0-dev-20250714115156
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 +36 -4
- package/dist/auth/CachedKeyCollector.js +26 -2
- package/dist/auth/CachedKeyCollector.js.map +1 -1
- package/dist/auth/KeySpec.d.ts +1 -0
- package/dist/auth/KeySpec.js +12 -0
- package/dist/auth/KeySpec.js.map +1 -1
- package/dist/auth/KeyStore.js +2 -2
- package/dist/auth/KeyStore.js.map +1 -1
- package/dist/auth/RemoteJWKSCollector.js +6 -2
- package/dist/auth/RemoteJWKSCollector.js.map +1 -1
- package/dist/emitters/AbstractEmitterEngine.d.ts +13 -0
- package/dist/emitters/AbstractEmitterEngine.js +57 -0
- package/dist/emitters/AbstractEmitterEngine.js.map +1 -0
- package/dist/emitters/EmitterEngine.d.ts +12 -0
- package/dist/emitters/EmitterEngine.js +35 -0
- package/dist/emitters/EmitterEngine.js.map +1 -0
- package/dist/emitters/emitter-interfaces.d.ts +37 -0
- package/dist/emitters/emitter-interfaces.js +10 -0
- package/dist/emitters/emitter-interfaces.js.map +1 -0
- package/dist/emitters/event-error.d.ts +5 -0
- package/dist/emitters/event-error.js +8 -0
- package/dist/emitters/event-error.js.map +1 -0
- package/dist/emitters/events/connect-event.d.ts +10 -0
- package/dist/emitters/events/connect-event.js +32 -0
- package/dist/emitters/events/connect-event.js.map +1 -0
- package/dist/emitters/events/disconnect-event.d.ts +10 -0
- package/dist/emitters/events/disconnect-event.js +27 -0
- package/dist/emitters/events/disconnect-event.js.map +1 -0
- package/dist/emitters/events/index.d.ts +1 -0
- package/dist/emitters/events/index.js +4 -0
- package/dist/emitters/events/index.js.map +1 -0
- package/dist/replication/AbstractReplicator.d.ts +8 -2
- package/dist/replication/AbstractReplicator.js +25 -9
- package/dist/replication/AbstractReplicator.js.map +1 -1
- package/dist/routes/auth.d.ts +1 -21
- package/dist/routes/auth.js +1 -97
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/configure-rsocket.js +4 -2
- package/dist/routes/configure-rsocket.js.map +1 -1
- package/dist/routes/endpoints/sync-stream.js +23 -1
- package/dist/routes/endpoints/sync-stream.js.map +1 -1
- package/dist/routes/router.d.ts +1 -2
- package/dist/routes/router.js.map +1 -1
- package/dist/system/ServiceContext.d.ts +3 -0
- package/dist/system/ServiceContext.js +8 -1
- package/dist/system/ServiceContext.js.map +1 -1
- package/dist/util/config/compound-config-collector.js +0 -13
- package/dist/util/config/compound-config-collector.js.map +1 -1
- package/dist/util/config/types.d.ts +0 -12
- 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/version.d.ts +1 -0
- package/dist/util/version.js +3 -0
- package/dist/util/version.js.map +1 -0
- package/package.json +4 -4
- package/src/auth/CachedKeyCollector.ts +25 -3
- package/src/auth/KeySpec.ts +14 -0
- package/src/auth/KeyStore.ts +2 -2
- package/src/auth/RemoteJWKSCollector.ts +6 -2
- package/src/emitters/AbstractEmitterEngine.ts +64 -0
- package/src/emitters/EmitterEngine.ts +41 -0
- package/src/emitters/emitter-interfaces.ts +47 -0
- package/src/emitters/event-error.ts +10 -0
- package/src/emitters/events/connect-event.ts +34 -0
- package/src/emitters/events/disconnect-event.ts +36 -0
- package/src/emitters/events/index.ts +4 -0
- package/src/replication/AbstractReplicator.ts +30 -10
- package/src/routes/auth.ts +1 -124
- package/src/routes/configure-rsocket.ts +3 -2
- package/src/routes/endpoints/sync-stream.ts +25 -2
- package/src/routes/router.ts +0 -1
- package/src/system/ServiceContext.ts +11 -1
- package/src/util/config/compound-config-collector.ts +0 -16
- package/src/util/config/types.ts +0 -11
- package/src/util/util-index.ts +1 -0
- package/src/util/version.ts +3 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.0.0-dev-
|
|
8
|
+
"version": "0.0.0-dev-20250714115156",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"license": "FSL-1.1-Apache-2.0",
|
|
11
11
|
"type": "module",
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
"uuid": "^11.1.0",
|
|
33
33
|
"winston": "^3.13.0",
|
|
34
34
|
"yaml": "^2.3.2",
|
|
35
|
-
"@powersync/lib-services-framework": "0.
|
|
35
|
+
"@powersync/lib-services-framework": "0.7.0",
|
|
36
36
|
"@powersync/service-jsonbig": "0.17.10",
|
|
37
|
-
"@powersync/service-rsocket-router": "0.
|
|
37
|
+
"@powersync/service-rsocket-router": "0.1.1",
|
|
38
38
|
"@powersync/service-sync-rules": "0.27.0",
|
|
39
|
-
"@powersync/service-types": "0.0.0-dev-
|
|
39
|
+
"@powersync/service-types": "0.0.0-dev-20250714115156"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/async": "^3.2.24",
|
|
@@ -3,7 +3,7 @@ import timers from 'timers/promises';
|
|
|
3
3
|
import { KeySpec } from './KeySpec.js';
|
|
4
4
|
import { LeakyBucket } from './LeakyBucket.js';
|
|
5
5
|
import { KeyCollector, KeyResult } from './KeyCollector.js';
|
|
6
|
-
import { AuthorizationError } from '@powersync/lib-services-framework';
|
|
6
|
+
import { AuthorizationError, ErrorCode, logger } from '@powersync/lib-services-framework';
|
|
7
7
|
import { mapAuthConfigError } from './utils.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -70,8 +70,21 @@ export class CachedKeyCollector implements KeyCollector {
|
|
|
70
70
|
// e.g. in the case of waiting for error retries.
|
|
71
71
|
// In the case of very slow requests, we don't wait for it to complete, but the
|
|
72
72
|
// request can still complete in the background.
|
|
73
|
-
const
|
|
74
|
-
|
|
73
|
+
const WAIT_TIMEOUT_SECONDS = 3;
|
|
74
|
+
const timeout = timers.setTimeout(WAIT_TIMEOUT_SECONDS * 1000).then(() => {
|
|
75
|
+
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed`, {
|
|
76
|
+
cause: { message: `Key request timed out in ${WAIT_TIMEOUT_SECONDS}s`, name: 'AbortError' }
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
await Promise.race([this.refreshPromise, timeout]);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e instanceof AuthorizationError) {
|
|
83
|
+
return { keys: this.currentKeys, errors: [...this.currentErrors, e] };
|
|
84
|
+
} else {
|
|
85
|
+
throw e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
return { keys: this.currentKeys, errors: this.currentErrors };
|
|
@@ -102,7 +115,16 @@ export class CachedKeyCollector implements KeyCollector {
|
|
|
102
115
|
this.currentErrors = errors;
|
|
103
116
|
this.keyTimestamp = Date.now();
|
|
104
117
|
this.error = false;
|
|
118
|
+
|
|
119
|
+
// Due to caching and background refresh behavior, errors are not always propagated to the request handler,
|
|
120
|
+
// so we log them here.
|
|
121
|
+
for (let error of errors) {
|
|
122
|
+
logger.error(`Soft key refresh error`, error);
|
|
123
|
+
}
|
|
105
124
|
} catch (e) {
|
|
125
|
+
// Due to caching and background refresh behavior, errors are not always propagated to the request handler,
|
|
126
|
+
// so we log them here.
|
|
127
|
+
logger.error(`Hard key refresh error`, e);
|
|
106
128
|
this.error = true;
|
|
107
129
|
// No result - keep previous keys
|
|
108
130
|
this.currentErrors = [mapAuthConfigError(e)];
|
package/src/auth/KeySpec.ts
CHANGED
|
@@ -40,6 +40,20 @@ export class KeySpec {
|
|
|
40
40
|
return this.source.kid;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
get description(): string {
|
|
44
|
+
let details: string[] = [];
|
|
45
|
+
details.push(`kid: ${this.kid ?? '*'}`);
|
|
46
|
+
details.push(`kty: ${this.source.kty}`);
|
|
47
|
+
if (this.source.alg != null) {
|
|
48
|
+
details.push(`alg: ${this.source.alg}`);
|
|
49
|
+
}
|
|
50
|
+
if (this.options.requiresAudience != null) {
|
|
51
|
+
details.push(`aud: ${this.options.requiresAudience.join(', ')}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `<${details.filter((x) => x != null).join(', ')}>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
matchesAlgorithm(jwtAlg: string): boolean {
|
|
44
58
|
if (this.source.alg) {
|
|
45
59
|
return jwtAlg === this.source.alg;
|
package/src/auth/KeyStore.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AuthorizationError, ErrorCode, logger } from '@powersync/lib-services-framework';
|
|
2
2
|
import * as jose from 'jose';
|
|
3
3
|
import secs from '../util/secs.js';
|
|
4
4
|
import { JwtPayload } from './JwtPayload.js';
|
|
@@ -169,7 +169,7 @@ export class KeyStore<Collector extends KeyCollector = KeyCollector> {
|
|
|
169
169
|
ErrorCode.PSYNC_S2101,
|
|
170
170
|
'Could not find an appropriate key in the keystore. The key is missing or no key matched the token KID',
|
|
171
171
|
{
|
|
172
|
-
configurationDetails: `Known
|
|
172
|
+
configurationDetails: `Known keys: ${keys.map((key) => key.description).join(', ')}`
|
|
173
173
|
// tokenDetails automatically populated later
|
|
174
174
|
}
|
|
175
175
|
);
|
|
@@ -49,9 +49,10 @@ export class RemoteJWKSCollector implements KeyCollector {
|
|
|
49
49
|
|
|
50
50
|
private async getJwksData(): Promise<any> {
|
|
51
51
|
const abortController = new AbortController();
|
|
52
|
+
const REQUEST_TIMEOUT_SECONDS = 30;
|
|
52
53
|
const timeout = setTimeout(() => {
|
|
53
54
|
abortController.abort();
|
|
54
|
-
},
|
|
55
|
+
}, REQUEST_TIMEOUT_SECONDS * 1000);
|
|
55
56
|
|
|
56
57
|
try {
|
|
57
58
|
const res = await fetch(this.url, {
|
|
@@ -71,11 +72,14 @@ export class RemoteJWKSCollector implements KeyCollector {
|
|
|
71
72
|
|
|
72
73
|
return (await res.json()) as any;
|
|
73
74
|
} catch (e) {
|
|
75
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
76
|
+
e = { message: `Request timed out in ${REQUEST_TIMEOUT_SECONDS}s`, name: 'AbortError' };
|
|
77
|
+
}
|
|
74
78
|
throw new AuthorizationError(ErrorCode.PSYNC_S2204, `JWKS request failed`, {
|
|
75
79
|
configurationDetails: `JWKS URL: ${this.url}`,
|
|
76
80
|
// This covers most cases of FetchError
|
|
77
81
|
// `cause: e` could lose the error message
|
|
78
|
-
cause: { message: e.message, code: e.code }
|
|
82
|
+
cause: { message: e.message, code: e.code, name: e.name }
|
|
79
83
|
});
|
|
80
84
|
} finally {
|
|
81
85
|
clearTimeout(timeout);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { EmitterEvent, EmitterEventData, EventNames, isEventError } from './emitter-interfaces.js';
|
|
2
|
+
import * as storage from '../storage/storage-index.js';
|
|
3
|
+
import EventEmitter from 'node:events';
|
|
4
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
5
|
+
import { EventError } from './event-error.js';
|
|
6
|
+
|
|
7
|
+
export abstract class AbstractEmitterEngine {
|
|
8
|
+
private emitter: EventEmitter;
|
|
9
|
+
storageEngine: storage.StorageEngine;
|
|
10
|
+
eventsMap: Map<EventNames, EmitterEvent> = new Map();
|
|
11
|
+
|
|
12
|
+
protected constructor(storage: storage.StorageEngine) {
|
|
13
|
+
this.emitter = new EventEmitter({ captureRejections: true });
|
|
14
|
+
this.storageEngine = storage;
|
|
15
|
+
this.emitter.on('error', (error: Error | EventError) => {
|
|
16
|
+
if (isEventError(error) && this.eventsMap.has(error.eventName)) {
|
|
17
|
+
const event = this.eventsMap.get(error.eventName)!;
|
|
18
|
+
const errorHandler =
|
|
19
|
+
event.errorhandler ??
|
|
20
|
+
((error) => {
|
|
21
|
+
logger.error(error.message);
|
|
22
|
+
});
|
|
23
|
+
errorHandler(error);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get events(): EventNames[] {
|
|
29
|
+
return this.emitter.eventNames() as EventNames[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
protected bindEvents(events: EmitterEvent[]): void {
|
|
33
|
+
const eventNames = this.emitter.eventNames();
|
|
34
|
+
for (const event of events) {
|
|
35
|
+
if (!eventNames.includes(event.name)) {
|
|
36
|
+
this.eventsMap.set(event.name, event);
|
|
37
|
+
this.emitter.on(event.name, event.handler(this.storageEngine).bind(event));
|
|
38
|
+
} else {
|
|
39
|
+
logger.warn(`Event ${event.name} is already registered. Skipping.`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected emit(eventName: EventNames, data: EmitterEventData): void {
|
|
45
|
+
if (!this.emitter.eventNames().includes(eventName)) {
|
|
46
|
+
throw new Error(`Event ${eventName} is not registered.`);
|
|
47
|
+
}
|
|
48
|
+
this.emitter.emit(eventName, data);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected removeListeners(eventName?: EventNames): void {
|
|
52
|
+
if (eventName) {
|
|
53
|
+
this.emitter.removeAllListeners(eventName);
|
|
54
|
+
logger.info(`Removed all listeners for event: ${eventName}`);
|
|
55
|
+
} else {
|
|
56
|
+
this.stop();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected stop(): void {
|
|
61
|
+
this.emitter.removeAllListeners();
|
|
62
|
+
logger.info('Emitter engine shut down and all listeners removed.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { BaseEmitterEngine, EmitterEvent, EmitterEventData, EventNames } from './emitter-interfaces.js';
|
|
2
|
+
import * as storage from '../storage/storage-index.js';
|
|
3
|
+
import { AbstractEmitterEngine } from './AbstractEmitterEngine.js';
|
|
4
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
5
|
+
|
|
6
|
+
export class EmitterEngine extends AbstractEmitterEngine implements BaseEmitterEngine {
|
|
7
|
+
private active: boolean;
|
|
8
|
+
constructor(events: EmitterEvent[], storageRef: storage.StorageEngine) {
|
|
9
|
+
super(storageRef);
|
|
10
|
+
this.active = process.env.MICRO_SERVICE_NAME === 'powersync';
|
|
11
|
+
logger.info(`EmitterEngine initialized with active status: ${this.active}`);
|
|
12
|
+
this.bindEvents(events);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
event(eventName: EventNames): EmitterEvent {
|
|
16
|
+
const event = this.eventsMap.get(eventName);
|
|
17
|
+
if (!event) {
|
|
18
|
+
throw new Error(`Event ${eventName} is not registered.`);
|
|
19
|
+
}
|
|
20
|
+
return event;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
removeListeners(eventName?: EventNames): void {
|
|
24
|
+
if (eventName) {
|
|
25
|
+
this.removeListeners(eventName);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
eventNames(): EventNames[] {
|
|
29
|
+
return this.events;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
emitEvent(eventName: EventNames, data: EmitterEventData): void {
|
|
33
|
+
if (this.active) {
|
|
34
|
+
return this.emit(eventName, data);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async shutDown(): Promise<void> {
|
|
39
|
+
this.stop();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { JwtPayload } from '../auth/JwtPayload.js';
|
|
2
|
+
import * as storage from '../storage/storage-index.js';
|
|
3
|
+
import { EventError } from './event-error.js';
|
|
4
|
+
|
|
5
|
+
export enum EventNames {
|
|
6
|
+
// SdK events
|
|
7
|
+
SDK_CONNECT_EVENT = 'sdk-connect-event',
|
|
8
|
+
SDK_DISCONNECT_EVENT = 'sdk-disconnect-event'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SdkEvent = {
|
|
12
|
+
client_id?: string;
|
|
13
|
+
user_id: string;
|
|
14
|
+
user_agent: string;
|
|
15
|
+
jwt_token?: JwtPayload;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type EventConnectData = {
|
|
19
|
+
type: EventNames.SDK_CONNECT_EVENT;
|
|
20
|
+
connect_at: number;
|
|
21
|
+
} & SdkEvent;
|
|
22
|
+
|
|
23
|
+
export type EventDisconnectData = {
|
|
24
|
+
type: EventNames.SDK_DISCONNECT_EVENT;
|
|
25
|
+
disconnect_at: number;
|
|
26
|
+
} & SdkEvent;
|
|
27
|
+
|
|
28
|
+
export type EmitterEventData = EventConnectData | EventDisconnectData;
|
|
29
|
+
export type EventHandler = (data: EmitterEventData) => Promise<void>;
|
|
30
|
+
export type StorageHandler = (storageEngine: storage.StorageEngine) => EventHandler;
|
|
31
|
+
export interface EmitterEvent {
|
|
32
|
+
name: EventNames;
|
|
33
|
+
handler: StorageHandler;
|
|
34
|
+
errorhandler?: (error: Error | EventError) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BaseEmitterEngine {
|
|
38
|
+
eventNames(): EventNames[];
|
|
39
|
+
emitEvent(eventName: EventNames, data: EmitterEventData): void;
|
|
40
|
+
removeListeners(eventName?: EventNames): void;
|
|
41
|
+
event(eventName: EventNames): EmitterEvent;
|
|
42
|
+
shutDown(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isEventError(error: Error | EventError): error is EventError {
|
|
46
|
+
return (error as EventError).eventName !== undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { EmitterEvent, EmitterEventData, EventConnectData, EventHandler, EventNames } from '../emitter-interfaces.js';
|
|
2
|
+
import * as storage from '../../storage/storage-index.js';
|
|
3
|
+
import { EventError } from '../event-error.js';
|
|
4
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
5
|
+
|
|
6
|
+
export class ConnectEvent implements EmitterEvent {
|
|
7
|
+
private type = EventNames.SDK_CONNECT_EVENT;
|
|
8
|
+
get name(): EventNames {
|
|
9
|
+
return this.type;
|
|
10
|
+
}
|
|
11
|
+
errorhandler(error: Error | EventError): void {
|
|
12
|
+
if (error instanceof EventError) {
|
|
13
|
+
logger.error(`EventError in ${error.eventName}:`, error.message);
|
|
14
|
+
} else {
|
|
15
|
+
logger.error(error.message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
handler(storageEngine: storage.StorageEngine): EventHandler {
|
|
19
|
+
// TODO: USE STORAGE ENGINE
|
|
20
|
+
const storage = storageEngine.activeStorage.storage;
|
|
21
|
+
return async (data: EmitterEventData) => {
|
|
22
|
+
try {
|
|
23
|
+
const disconnectData = data as EventConnectData;
|
|
24
|
+
console.log(
|
|
25
|
+
`Connect event triggered for user: ${disconnectData.user_id} at ${new Date(disconnectData.connect_at).toISOString()}`
|
|
26
|
+
);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
throw new EventError(this.type, error.message);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const connectEvent = new ConnectEvent();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EmitterEvent,
|
|
3
|
+
EmitterEventData,
|
|
4
|
+
EventDisconnectData,
|
|
5
|
+
EventHandler,
|
|
6
|
+
EventNames
|
|
7
|
+
} from '../emitter-interfaces.js';
|
|
8
|
+
import * as storage from '../../storage/storage-index.js';
|
|
9
|
+
import { EventError } from '../event-error.js';
|
|
10
|
+
import { logger } from '@powersync/lib-services-framework';
|
|
11
|
+
|
|
12
|
+
export class DisconnectEvent implements EmitterEvent {
|
|
13
|
+
private type = EventNames.SDK_DISCONNECT_EVENT;
|
|
14
|
+
get name(): EventNames {
|
|
15
|
+
return this.type;
|
|
16
|
+
}
|
|
17
|
+
errorhandler(error: Error | EventError): void {
|
|
18
|
+
if (error instanceof EventError) {
|
|
19
|
+
logger.error(`EventError in ${error.eventName}:`, error.message);
|
|
20
|
+
} else {
|
|
21
|
+
logger.error(error.message);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
handler(storageEngine: storage.StorageEngine): EventHandler {
|
|
25
|
+
// TODO: USE STORAGE ENGINE
|
|
26
|
+
const storage = storageEngine.activeStorage.storage;
|
|
27
|
+
return async (data: EmitterEventData) => {
|
|
28
|
+
const disconnectData = data as EventDisconnectData;
|
|
29
|
+
console.log(
|
|
30
|
+
`Disconnect event triggered for user: ${disconnectData.user_id} at ${new Date(disconnectData.disconnect_at).toISOString()}`
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const disconnectEvent = new DisconnectEvent();
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { container, logger } from '@powersync/lib-services-framework';
|
|
2
|
+
import { ReplicationMetric } from '@powersync/service-types';
|
|
2
3
|
import { hrtime } from 'node:process';
|
|
3
4
|
import winston from 'winston';
|
|
5
|
+
import { MetricsEngine } from '../metrics/MetricsEngine.js';
|
|
4
6
|
import * as storage from '../storage/storage-index.js';
|
|
5
7
|
import { StorageEngine } from '../storage/storage-index.js';
|
|
6
8
|
import { SyncRulesProvider } from '../util/config/sync-rules/sync-rules-provider.js';
|
|
7
9
|
import { AbstractReplicationJob } from './AbstractReplicationJob.js';
|
|
8
10
|
import { ErrorRateLimiter } from './ErrorRateLimiter.js';
|
|
9
11
|
import { ConnectionTestResult } from './ReplicationModule.js';
|
|
10
|
-
import { MetricsEngine } from '../metrics/MetricsEngine.js';
|
|
11
|
-
import { ReplicationMetric } from '@powersync/service-types';
|
|
12
12
|
|
|
13
13
|
// 5 minutes
|
|
14
14
|
const PING_INTERVAL = 1_000_000_000n * 300n;
|
|
@@ -40,9 +40,17 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
40
40
|
/**
|
|
41
41
|
* Map of replication jobs by sync rule id. Usually there is only one running job, but there could be two when
|
|
42
42
|
* transitioning to a new set of sync rules.
|
|
43
|
-
* @private
|
|
44
43
|
*/
|
|
45
44
|
private replicationJobs = new Map<number, T>();
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map of sync rule ids to promises that are clearing the sync rule configuration.
|
|
48
|
+
*
|
|
49
|
+
* We primarily do this to keep track of what we're currently clearing, but don't currently
|
|
50
|
+
* use the Promise value.
|
|
51
|
+
*/
|
|
52
|
+
private clearingJobs = new Map<number, Promise<void>>();
|
|
53
|
+
|
|
46
54
|
/**
|
|
47
55
|
* Used for replication lag computation.
|
|
48
56
|
*/
|
|
@@ -242,16 +250,26 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
242
250
|
}
|
|
243
251
|
}
|
|
244
252
|
|
|
245
|
-
// Sync rules stopped previously
|
|
253
|
+
// Sync rules stopped previously, including by a different process.
|
|
246
254
|
const stopped = await this.storage.getStoppedSyncRules();
|
|
247
255
|
for (let syncRules of stopped) {
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
await this.terminateSyncRules(syncRuleStorage);
|
|
252
|
-
} catch (e) {
|
|
253
|
-
this.logger.warn(`Failed clean up replication config for sync rule: ${syncRules.id}`, e);
|
|
256
|
+
if (this.clearingJobs.has(syncRules.id)) {
|
|
257
|
+
// Already in progress
|
|
258
|
+
continue;
|
|
254
259
|
}
|
|
260
|
+
|
|
261
|
+
// We clear storage asynchronously.
|
|
262
|
+
// It is important to be able to continue running the refresh loop, otherwise we cannot
|
|
263
|
+
// retry locked sync rules, for example.
|
|
264
|
+
const syncRuleStorage = this.storage.getInstance(syncRules, { skipLifecycleHooks: true });
|
|
265
|
+
const promise = this.terminateSyncRules(syncRuleStorage)
|
|
266
|
+
.catch((e) => {
|
|
267
|
+
this.logger.warn(`Failed clean up replication config for sync rule: ${syncRules.id}`, e);
|
|
268
|
+
})
|
|
269
|
+
.finally(() => {
|
|
270
|
+
this.clearingJobs.delete(syncRules.id);
|
|
271
|
+
});
|
|
272
|
+
this.clearingJobs.set(syncRules.id, promise);
|
|
255
273
|
}
|
|
256
274
|
}
|
|
257
275
|
|
|
@@ -261,6 +279,8 @@ export abstract class AbstractReplicator<T extends AbstractReplicationJob = Abst
|
|
|
261
279
|
|
|
262
280
|
protected async terminateSyncRules(syncRuleStorage: storage.SyncRulesBucketStorage) {
|
|
263
281
|
this.logger.info(`Terminating sync rules: ${syncRuleStorage.group_id}...`);
|
|
282
|
+
// This deletes postgres replication slots - should complete quickly.
|
|
283
|
+
// It is safe to do before or after clearing the data in the storage.
|
|
264
284
|
await this.cleanUp(syncRuleStorage);
|
|
265
285
|
await syncRuleStorage.terminate({ signal: this.abortController?.signal, clearStorage: true });
|
|
266
286
|
this.logger.info(`Successfully terminated sync rules: ${syncRuleStorage.group_id}`);
|
package/src/routes/auth.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import { AuthorizationError, AuthorizationResponse, ErrorCode } from '@powersync/lib-services-framework';
|
|
3
2
|
import * as auth from '../auth/auth-index.js';
|
|
4
3
|
import { ServiceContext } from '../system/ServiceContext.js';
|
|
5
|
-
import * as util from '../util/util-index.js';
|
|
6
4
|
import { BasicRouterRequest, Context, RequestEndpointHandlerPayload } from './router.js';
|
|
7
|
-
import { AuthorizationError, AuthorizationResponse, ErrorCode, ServiceError } from '@powersync/lib-services-framework';
|
|
8
5
|
|
|
9
6
|
export function endpoint(req: BasicRouterRequest) {
|
|
10
7
|
const protocol = req.headers['x-forwarded-proto'] ?? req.protocol;
|
|
@@ -12,81 +9,6 @@ export function endpoint(req: BasicRouterRequest) {
|
|
|
12
9
|
return `${protocol}://${host}`;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
function devAudience(req: BasicRouterRequest): string {
|
|
16
|
-
return `${endpoint(req)}/dev`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @deprecated
|
|
21
|
-
*
|
|
22
|
-
* Will be replaced by temporary tokens issued by PowerSync Management service.
|
|
23
|
-
*/
|
|
24
|
-
export async function issueDevToken(req: BasicRouterRequest, user_id: string, config: util.ResolvedPowerSyncConfig) {
|
|
25
|
-
const iss = devAudience(req);
|
|
26
|
-
const aud = devAudience(req);
|
|
27
|
-
|
|
28
|
-
const key = config.dev.dev_key;
|
|
29
|
-
if (key == null) {
|
|
30
|
-
throw new Error('Auth disabled');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return await new jose.SignJWT({})
|
|
34
|
-
.setProtectedHeader({ alg: key.source.alg!, kid: key.kid })
|
|
35
|
-
.setSubject(user_id)
|
|
36
|
-
.setIssuedAt()
|
|
37
|
-
.setIssuer(iss)
|
|
38
|
-
.setAudience(aud)
|
|
39
|
-
.setExpirationTime('30d')
|
|
40
|
-
.sign(key.key);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** @deprecated */
|
|
44
|
-
export async function issueLegacyDevToken(
|
|
45
|
-
req: BasicRouterRequest,
|
|
46
|
-
user_id: string,
|
|
47
|
-
config: util.ResolvedPowerSyncConfig
|
|
48
|
-
) {
|
|
49
|
-
const iss = devAudience(req);
|
|
50
|
-
const aud = config.jwt_audiences[0];
|
|
51
|
-
|
|
52
|
-
const key = config.dev.dev_key;
|
|
53
|
-
if (key == null || aud == null) {
|
|
54
|
-
throw new Error('Auth disabled');
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return await new jose.SignJWT({})
|
|
58
|
-
.setProtectedHeader({ alg: key.source.alg!, kid: key.kid })
|
|
59
|
-
.setSubject(user_id)
|
|
60
|
-
.setIssuedAt()
|
|
61
|
-
.setIssuer(iss)
|
|
62
|
-
.setAudience(aud)
|
|
63
|
-
.setExpirationTime('60m')
|
|
64
|
-
.sign(key.key);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function issuePowerSyncToken(
|
|
68
|
-
req: BasicRouterRequest,
|
|
69
|
-
user_id: string,
|
|
70
|
-
config: util.ResolvedPowerSyncConfig
|
|
71
|
-
) {
|
|
72
|
-
const iss = devAudience(req);
|
|
73
|
-
const aud = config.jwt_audiences[0];
|
|
74
|
-
const key = config.dev.dev_key;
|
|
75
|
-
if (key == null || aud == null) {
|
|
76
|
-
throw new Error('Auth disabled');
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const jwt = await new jose.SignJWT({})
|
|
80
|
-
.setProtectedHeader({ alg: key.source.alg!, kid: key.kid })
|
|
81
|
-
.setSubject(user_id)
|
|
82
|
-
.setIssuedAt()
|
|
83
|
-
.setIssuer(iss)
|
|
84
|
-
.setAudience(aud)
|
|
85
|
-
.setExpirationTime('5m')
|
|
86
|
-
.sign(key.key);
|
|
87
|
-
return jwt;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
12
|
export function getTokenFromHeader(authHeader: string = ''): string | null {
|
|
91
13
|
const tokenMatch = /^(Token|Bearer) (\S+)$/.exec(authHeader);
|
|
92
14
|
if (!tokenMatch) {
|
|
@@ -146,51 +68,6 @@ export async function generateContext(serviceContext: ServiceContext, token: str
|
|
|
146
68
|
}
|
|
147
69
|
}
|
|
148
70
|
|
|
149
|
-
/**
|
|
150
|
-
* @deprecated
|
|
151
|
-
*/
|
|
152
|
-
export const authDevUser = async (payload: RequestEndpointHandlerPayload) => {
|
|
153
|
-
const {
|
|
154
|
-
context: {
|
|
155
|
-
service_context: { configuration }
|
|
156
|
-
}
|
|
157
|
-
} = payload;
|
|
158
|
-
|
|
159
|
-
const token = getTokenFromHeader(payload.request.headers.authorization as string);
|
|
160
|
-
if (!configuration.dev.demo_auth) {
|
|
161
|
-
return {
|
|
162
|
-
authorized: false,
|
|
163
|
-
errors: ['Authentication disabled']
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
if (token == null) {
|
|
167
|
-
return {
|
|
168
|
-
authorized: false,
|
|
169
|
-
errors: ['Authentication required']
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Different from the configured audience.
|
|
174
|
-
// Should also not be changed by keys
|
|
175
|
-
const audience = [devAudience(payload.request)];
|
|
176
|
-
|
|
177
|
-
let tokenPayload: auth.JwtPayload;
|
|
178
|
-
try {
|
|
179
|
-
tokenPayload = await configuration.dev_client_keystore.verifyJwt(token, {
|
|
180
|
-
defaultAudiences: audience,
|
|
181
|
-
maxAge: '31d'
|
|
182
|
-
});
|
|
183
|
-
} catch (err) {
|
|
184
|
-
return {
|
|
185
|
-
authorized: false,
|
|
186
|
-
errors: [err.message]
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
payload.context.user_id = tokenPayload.sub;
|
|
191
|
-
return { authorized: true };
|
|
192
|
-
};
|
|
193
|
-
|
|
194
71
|
export const authApi = (payload: RequestEndpointHandlerPayload) => {
|
|
195
72
|
const {
|
|
196
73
|
context: {
|
|
@@ -38,7 +38,9 @@ export function configureRSocket(router: ReactiveSocketRouter<Context>, options:
|
|
|
38
38
|
const extracted_token = getTokenFromHeader(token);
|
|
39
39
|
if (extracted_token != null) {
|
|
40
40
|
const { context, tokenError } = await generateContext(options.service_context, extracted_token);
|
|
41
|
-
if (
|
|
41
|
+
if (tokenError != null) {
|
|
42
|
+
throw tokenError;
|
|
43
|
+
} else if (context?.token_payload == null) {
|
|
42
44
|
throw new errors.AuthorizationError(ErrorCode.PSYNC_S2106, 'Authentication required');
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -46,7 +48,6 @@ export function configureRSocket(router: ReactiveSocketRouter<Context>, options:
|
|
|
46
48
|
token,
|
|
47
49
|
user_agent,
|
|
48
50
|
...context,
|
|
49
|
-
token_error: tokenError,
|
|
50
51
|
service_context: service_context as RouterServiceContext,
|
|
51
52
|
logger: connectionLogger
|
|
52
53
|
};
|