Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ EXPO_PUBLIC_SUPABASE_BUCKET=your_supabase_bucket

# PowerSync Configuration
EXPO_PUBLIC_POWERSYNC_URL=your_powersync_url
EXPO_PUBLIC_POWERSYNC_TOKEN=your_powersync_token
# HS256 secret from PowerSync dashboard (base64url encoded)
# Generate with: openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
EXPO_PUBLIC_POWERSYNC_SECRET=your_powersync_hs256_secret

# Sync Configuration
EXPO_PUBLIC_SYNC_ENABLED=true
EXPO_PUBLIC_SYNC_TYPE=unidirectional
EXPO_PUBLIC_SYNC_TYPE=unidirectional
17 changes: 15 additions & 2 deletions docs/DATA_SYNC_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ This guide will help you set up data synchronization for Calendar Notifications
3. Connect PowerSync to your Supabase database:
- Follow the connection instructions in the PowerSync dashboard
- Make sure to configure the necessary permissions and access controls
4. Generate a developer token from the PowerSync dashboard
4. Configure HS256 authentication:
- Go to your instance settings → Client Auth tab
- Under **"JWT Audience (optional)"**, click the "+" button and add: `powersync`
- Under **"HS256 authentication tokens (ADVANCED)"**, click the "+" button
- For **KID**, enter: `powersync`
- For **HS256 secret**, generate one: `openssl rand -base64 32 | tr '+/' '-_' | tr -d '='`
- Copy the generated secret (you'll need it for the app)
- Click **"Save and deploy"**

### 3. Calendar Notifications Plus Configuration

Expand All @@ -41,7 +48,9 @@ This guide will help you set up data synchronization for Calendar Notifications
- Supabase URL (from step 1.3)
- Supabase Anon Key (from step 1.3)
- PowerSync Instance URL (from step 2.2)
- PowerSync Token (from step 2.4)
- PowerSync Token (paste the same HS256 secret from step 2.4)

> **Note:** The app automatically generates short-lived JWT tokens (5 min expiry) using this secret. The JWTs include `sub` (device ID), `aud` (powersync), `iat`, and `exp` claims. Unlike development tokens, the HS256 secret doesn't expire - you only need to configure it once.

## Verification

Expand All @@ -55,6 +64,10 @@ This guide will help you set up data synchronization for Calendar Notifications
- Check the PowerSync status in the app
- Ensure your Supabase and PowerSync instances are running and connected
- Review the app logs for any error messages
- **JWT errors:** Make sure the HS256 secret in the app matches exactly what's configured in PowerSync dashboard
- **"Invalid token" errors:** Verify the HS256 secret is properly configured under Client Auth → HS256 authentication tokens
- **"Missing aud claim" errors:** Add `powersync` to JWT Audience in the PowerSync dashboard (Client Auth → JWT Audience)
- **"Unexpected aud claim" errors:** The JWT Audience in PowerSync dashboard must include `powersync`

## Additional Resources

Expand Down
2 changes: 1 addition & 1 deletion env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ declare module '@env' {
export const EXPO_PUBLIC_SUPABASE_ANON_KEY: string | undefined;
export const EXPO_PUBLIC_SUPABASE_BUCKET: string | undefined;
export const EXPO_PUBLIC_POWERSYNC_URL: string | undefined;
export const EXPO_PUBLIC_POWERSYNC_TOKEN: string | undefined;
export const EXPO_PUBLIC_POWERSYNC_SECRET: string | undefined;
export const EXPO_PUBLIC_SYNC_ENABLED: string | undefined;
export const EXPO_PUBLIC_SYNC_TYPE: string | undefined;
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@testing-library/react-native": "^13.3.3",
"await-to-js": "^3.0.0",
"commander": "^13.1.0",
"crypto-js": "^4.2.0",
"dom-helpers": "^5.2.1",
"dotenv": "^16.3.1",
"execa": "^9.5.2",
Expand Down Expand Up @@ -80,6 +81,7 @@
"@swc-node/register": "^1.6.7",
"@testing-library/react-hooks": "^8.0.1",
"@tsconfig/react-native": "^3.0.2",
"@types/crypto-js": "^4.2.2",
"@types/jest": "^29.5.5",
"@types/node": "^22.14.0",
"@types/react": "~19.1.0",
Expand Down
6 changes: 3 additions & 3 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
EXPO_PUBLIC_SUPABASE_ANON_KEY,
EXPO_PUBLIC_SUPABASE_BUCKET,
EXPO_PUBLIC_POWERSYNC_URL,
EXPO_PUBLIC_POWERSYNC_TOKEN,
EXPO_PUBLIC_POWERSYNC_SECRET,
EXPO_PUBLIC_SYNC_ENABLED,
EXPO_PUBLIC_SYNC_TYPE,
} from '@env';
Expand All @@ -17,7 +17,7 @@ if (__DEV__) {
SUPABASE_URL: EXPO_PUBLIC_SUPABASE_URL ? 'SET' : 'EMPTY',
SUPABASE_ANON_KEY: EXPO_PUBLIC_SUPABASE_ANON_KEY ? 'SET' : 'EMPTY',
POWERSYNC_URL: EXPO_PUBLIC_POWERSYNC_URL ? 'SET' : 'EMPTY',
POWERSYNC_TOKEN: EXPO_PUBLIC_POWERSYNC_TOKEN ? 'SET' : 'EMPTY',
POWERSYNC_SECRET: EXPO_PUBLIC_POWERSYNC_SECRET ? 'SET' : 'EMPTY',
});
}

Expand All @@ -29,7 +29,7 @@ export const ConfigObj = {
},
powersync:{
url: EXPO_PUBLIC_POWERSYNC_URL || '',
token: EXPO_PUBLIC_POWERSYNC_TOKEN || ''
secret: EXPO_PUBLIC_POWERSYNC_SECRET || ''
},
sync: {
enabled: EXPO_PUBLIC_SYNC_ENABLED !== 'false',
Expand Down
2 changes: 1 addition & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const getEnv = () => {
SUPABASE_ANON_KEY: process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_BUCKET: process.env.EXPO_PUBLIC_SUPABASE_BUCKET,
POWERSYNC_URL: process.env.EXPO_PUBLIC_POWERSYNC_URL,
POWERSYNC_TOKEN: process.env.EXPO_PUBLIC_POWERSYNC_TOKEN,
POWERSYNC_SECRET: process.env.EXPO_PUBLIC_POWERSYNC_SECRET,
SYNC_ENABLED: process.env.EXPO_PUBLIC_SYNC_ENABLED,
SYNC_TYPE: process.env.EXPO_PUBLIC_SYNC_TYPE,
};
Expand Down
2 changes: 1 addition & 1 deletion src/lib/features/SetupSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const isSettingsConfigured = (settings: Settings): boolean => Boolean(
settings.supabaseUrl &&
settings.supabaseAnonKey &&
settings.powersyncUrl &&
settings.powersyncToken
settings.powersyncSecret
);

export const SetupSync = () => {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/features/__tests__/SetupSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const createSettings = (overrides: Partial<Settings> = {}): Settings => ({
supabaseUrl: '',
supabaseAnonKey: '',
powersyncUrl: '',
powersyncToken: '',
powersyncSecret: '',
...overrides,
});

Expand All @@ -34,7 +34,7 @@ const createCompleteSettings = (overrides: Partial<Settings> = {}): Settings =>
supabaseUrl: 'https://example.supabase.co',
supabaseAnonKey: 'anon-key-123',
powersyncUrl: 'https://example.powersync.com',
powersyncToken: 'token123',
powersyncSecret: 'token123',
...overrides,
});

Expand All @@ -56,8 +56,8 @@ describe('SetupSync', () => {
expect(isSettingsConfigured(createCompleteSettings({ powersyncUrl: '' }))).toBe(false);
});

it('returns false when powersyncToken is missing', () => {
expect(isSettingsConfigured(createCompleteSettings({ powersyncToken: '' }))).toBe(false);
it('returns false when powersyncSecret is missing', () => {
expect(isSettingsConfigured(createCompleteSettings({ powersyncSecret: '' }))).toBe(false);
});

it('returns true when all credentials are provided', () => {
Expand Down
8 changes: 4 additions & 4 deletions src/lib/features/__tests__/SetupSync.ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const testState = {
supabaseUrl: '',
supabaseAnonKey: '',
powersyncUrl: '',
powersyncToken: '',
powersyncSecret: '',
},
powerSyncStatus: {
connected: null as boolean | null,
Expand Down Expand Up @@ -78,7 +78,7 @@ const configureSettings = (configured: boolean) => {
supabaseUrl: 'https://example.supabase.co',
supabaseAnonKey: 'anon-key-123',
powersyncUrl: 'https://example.powersync.com',
powersyncToken: 'token123',
powersyncSecret: 'token123',
};
} else {
testState.settings = {
Expand All @@ -87,7 +87,7 @@ const configureSettings = (configured: boolean) => {
supabaseUrl: '',
supabaseAnonKey: '',
powersyncUrl: '',
powersyncToken: '',
powersyncSecret: '',
};
}
};
Expand Down Expand Up @@ -174,7 +174,7 @@ describe('SetupSync UI States', () => {
supabaseUrl: 'https://example.supabase.co',
supabaseAnonKey: 'anon-key-123',
powersyncUrl: 'https://example.powersync.com',
powersyncToken: 'token123',
powersyncSecret: 'token123',
};
configurePowerSyncStatus(null);
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/features/__tests__/__mocks__/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const EXPO_PUBLIC_SUPABASE_URL = '';
export const EXPO_PUBLIC_SUPABASE_ANON_KEY = '';
export const EXPO_PUBLIC_SUPABASE_BUCKET = '';
export const EXPO_PUBLIC_POWERSYNC_URL = '';
export const EXPO_PUBLIC_POWERSYNC_TOKEN = '';
export const EXPO_PUBLIC_POWERSYNC_SECRET = '';
export const EXPO_PUBLIC_SYNC_ENABLED = 'false';
export const EXPO_PUBLIC_SYNC_TYPE = 'unidirectional';

12 changes: 7 additions & 5 deletions src/lib/hooks/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface Settings {
supabaseUrl: string;
supabaseAnonKey: string;
powersyncUrl: string;
powersyncToken: string;
powersyncSecret: string;
}

const DEFAULT_SETTINGS: Settings = {
Expand All @@ -20,7 +20,7 @@ const DEFAULT_SETTINGS: Settings = {
supabaseUrl: '',
supabaseAnonKey: '',
powersyncUrl: '',
powersyncToken: '',
powersyncSecret: '',
};

const SETTINGS_STORAGE_KEY = '@calendar_notifications_settings';
Expand Down Expand Up @@ -48,18 +48,20 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil

if (storedSettingsStr) {
const parsedSettings = JSON.parse(storedSettingsStr);
// Merge with defaults to handle any missing keys (old keys like powersyncToken are just ignored)
const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...parsedSettings };
if (__DEV__) {
console.log('[SettingsContext] Using stored settings');
}
setSettings(parsedSettings);
setSettings(mergedSettings);
} else {
// Initialize with current ConfigObj values
if (__DEV__) {
console.log('[SettingsContext] Initializing from ConfigObj:', {
supabaseUrl: ConfigObj.supabase.url ? 'SET' : 'EMPTY',
supabaseAnonKey: ConfigObj.supabase.anonKey ? 'SET' : 'EMPTY',
powersyncUrl: ConfigObj.powersync.url ? 'SET' : 'EMPTY',
powersyncToken: ConfigObj.powersync.token ? 'SET' : 'EMPTY',
powersyncSecret: ConfigObj.powersync.secret ? 'SET' : 'EMPTY',
});
}
const currentSettings: Settings = {
Expand All @@ -68,7 +70,7 @@ export const SettingsProvider: React.FC<{ children: React.ReactNode }> = ({ chil
supabaseUrl: ConfigObj.supabase.url,
supabaseAnonKey: ConfigObj.supabase.anonKey,
powersyncUrl: ConfigObj.powersync.url,
powersyncToken: ConfigObj.powersync.token,
powersyncSecret: ConfigObj.powersync.secret,
};
setSettings(currentSettings);
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(currentSettings));
Expand Down
39 changes: 33 additions & 6 deletions src/lib/powersync/Connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ import {
getLogFilterLevel,
FailedOperation,
LogFilterLevel,
POWERSYNC_JWT_AUDIENCE,
POWERSYNC_JWT_KID,
POWERSYNC_JWT_EXPIRY_SECONDS,
} from './Connector';
import * as deviceIdModule from './deviceId';

// Mock deviceId module
jest.mock('./deviceId', () => ({
getOrCreateDeviceId: jest.fn().mockResolvedValue('mock-device-uuid'),
}));

// Type the mocked modules
const mockedAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
Expand All @@ -24,7 +33,7 @@ const createMockSettings = () => ({
supabaseUrl: 'https://test.supabase.co',
supabaseAnonKey: 'test-anon-key',
powersyncUrl: 'https://test.powersync.com',
powersyncToken: 'test-token',
powersyncSecret: 'test-token',
});

// Helper to create mock CrudEntry
Expand Down Expand Up @@ -84,17 +93,35 @@ describe('Connector', () => {
});

describe('fetchCredentials', () => {
it('should return PowerSync credentials from settings', async () => {
it('should generate JWT using HS256 secret and device ID', async () => {
const settings = createMockSettings();
mockedCreateClient.mockReturnValue({} as any);

const connector = new Connector(settings);
const credentials = await connector.fetchCredentials();

expect(credentials).toEqual({
endpoint: settings.powersyncUrl,
token: settings.powersyncToken,
});
// Should return the endpoint
expect(credentials.endpoint).toBe(settings.powersyncUrl);

// Should have called getOrCreateDeviceId
expect(deviceIdModule.getOrCreateDeviceId).toHaveBeenCalled();

// Should return a valid JWT (three base64url parts separated by dots)
const parts = credentials.token.split('.');
expect(parts).toHaveLength(3);

// Decode and verify header
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
expect(header.alg).toBe('HS256');
expect(header.kid).toBe(POWERSYNC_JWT_KID);

// Decode and verify payload has required claims
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
expect(payload.sub).toBe('mock-device-uuid');
expect(payload.aud).toBe(POWERSYNC_JWT_AUDIENCE);
expect(payload.iat).toBeDefined();
expect(payload.exp).toBeDefined();
expect(payload.exp - payload.iat).toBe(POWERSYNC_JWT_EXPIRY_SECONDS);
});
});

Expand Down
Loading
Loading