forked from trent_larson/crowd-funder-for-time-pwa
feat: implement 72-hour JWT token refresh for daily notification plugin
- Add accessTokenForBackground() with 72-hour default expiration - Supports offline-first prefetch operations - Balances security with offline capability - Implement proactive token refresh strategy - Refresh on component mount (DailyNotificationSection) - Refresh on app resume (Capacitor 'resume' event) - Refresh when notifications are enabled - Automatic refresh without user interaction - Update CapacitorPlatformService.configureNativeFetcher() - Automatically retrieves activeDid from database - Generates 72-hour JWT tokens for background operations - Includes starred plans in configuration - Add BroadcastReceivers to AndroidManifest.xml - DailyNotificationReceiver for scheduled notifications - BootReceiver for rescheduling after device reboot - Add comprehensive documentation - JSDoc comments for all token-related functions - Inline comments explaining refresh strategy - Documentation section on authentication & token management Benefits: - No app wake-up required (refresh when app already open) - Works offline (72-hour validity supports extended periods) - Automatic (no user interaction required) - Graceful degradation (uses cached content if refresh fails)
This commit is contained in:
@@ -36,6 +36,30 @@
|
|||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- Daily Notification Plugin Receivers -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.DailyNotificationReceiver"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.timesafari.daily.NOTIFICATION" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
<receiver
|
||||||
|
android:name="com.timesafari.dailynotification.BootReceiver"
|
||||||
|
android:directBootAware="true"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter android:priority="1000">
|
||||||
|
<!-- Delivered very early after reboot (before unlock) -->
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
<!-- Delivered after the user unlocks / credential-encrypted storage is available -->
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<!-- Delivered after app update; great for rescheduling alarms without reboot -->
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|||||||
@@ -35,10 +35,6 @@
|
|||||||
"pkg": "@capawesome/capacitor-file-picker",
|
"pkg": "@capawesome/capacitor-file-picker",
|
||||||
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"pkg": "@timesafari/daily-notification-plugin",
|
|
||||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"pkg": "@timesafari/daily-notification-plugin",
|
"pkg": "@timesafari/daily-notification-plugin",
|
||||||
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
"classpath": "com.timesafari.dailynotification.DailyNotificationPlugin"
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export interface ScheduleOptions {
|
|||||||
|
|
||||||
export interface NativeFetcherConfig {
|
export interface NativeFetcherConfig {
|
||||||
apiServer: string;
|
apiServer: string;
|
||||||
jwt: string;
|
jwt: string; // Ignored - generated automatically by configureNativeFetcher
|
||||||
starredPlanHandleIds: string[];
|
starredPlanHandleIds: string[];
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -250,6 +250,63 @@ export interface NativeFetcherConfig {
|
|||||||
- **Capacitor**: Full implementation, all methods functional
|
- **Capacitor**: Full implementation, all methods functional
|
||||||
- **Web/Electron**: Status/permission methods return `null`, scheduling methods throw errors with clear messages
|
- **Web/Electron**: Status/permission methods return `null`, scheduling methods throw errors with clear messages
|
||||||
|
|
||||||
|
### Authentication & Token Management
|
||||||
|
|
||||||
|
#### Background Prefetch Authentication
|
||||||
|
|
||||||
|
The daily notification plugin requires authentication tokens for background prefetch operations. The implementation uses a **hybrid token refresh strategy** that balances security with offline capability.
|
||||||
|
|
||||||
|
**Token Generation** (`src/libs/crypto/index.ts`):
|
||||||
|
- Function: `accessTokenForBackground(did, expirationMinutes?)`
|
||||||
|
- Default expiration: **72 hours** (4320 minutes)
|
||||||
|
- Token type: JWT with ES256K signing
|
||||||
|
- Payload: `{ exp, iat, iss: did }`
|
||||||
|
|
||||||
|
**Why 72 Hours?**
|
||||||
|
- Balances security (read-only prefetch operations) with offline capability
|
||||||
|
- Reduces need for app to wake itself for token refresh
|
||||||
|
- Allows plugin to work offline for extended periods (e.g., weekend trips)
|
||||||
|
- Longer than typical prefetch windows (5 minutes before notification)
|
||||||
|
|
||||||
|
**Token Refresh Strategy (Hybrid Approach)**:
|
||||||
|
|
||||||
|
1. **Proactive Refresh Triggers**:
|
||||||
|
- Component mount (`DailyNotificationSection.mounted()`)
|
||||||
|
- App resume (Capacitor `resume` event)
|
||||||
|
- Notification enabled (when user enables daily notifications)
|
||||||
|
|
||||||
|
2. **Refresh Implementation** (`DailyNotificationSection.refreshNativeFetcherConfig()`):
|
||||||
|
- Checks if notifications are supported and enabled
|
||||||
|
- Retrieves API server URL from settings
|
||||||
|
- Retrieves starred plans from settings
|
||||||
|
- Calls `configureNativeFetcher()` to generate fresh token
|
||||||
|
- Errors are logged but don't interrupt user experience
|
||||||
|
|
||||||
|
3. **Offline Behavior**:
|
||||||
|
- If token expires while offline → plugin uses cached content
|
||||||
|
- Next time app opens → token automatically refreshed
|
||||||
|
- No app wake-up required (refresh happens when app is already open)
|
||||||
|
|
||||||
|
**Configuration Flow** (`CapacitorPlatformService.configureNativeFetcher()`):
|
||||||
|
|
||||||
|
1. Retrieves active DID from `active_identity` table (single source of truth)
|
||||||
|
2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
|
||||||
|
3. Configures plugin with API server URL, active DID, and JWT token
|
||||||
|
4. Plugin stores token in its Room database for background workers
|
||||||
|
|
||||||
|
**Security Considerations**:
|
||||||
|
- Tokens are used only for read-only prefetch operations
|
||||||
|
- Tokens are stored securely in plugin's Room database
|
||||||
|
- Tokens are refreshed proactively to minimize exposure window
|
||||||
|
- No private keys are exposed to native code
|
||||||
|
- Token generation happens in TypeScript (no Java crypto compatibility issues)
|
||||||
|
|
||||||
|
**Error Handling**:
|
||||||
|
- Returns `null` if active DID not found (no user logged in)
|
||||||
|
- Returns `null` if JWT generation fails
|
||||||
|
- Logs errors but doesn't throw (allows graceful degradation)
|
||||||
|
- Refresh failures don't interrupt user experience (plugin uses cached content)
|
||||||
|
|
||||||
### Component Architecture
|
### Component Architecture
|
||||||
|
|
||||||
#### Views Structure
|
#### Views Structure
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -152,7 +152,7 @@
|
|||||||
},
|
},
|
||||||
"../daily-notification-plugin": {
|
"../daily-notification-plugin": {
|
||||||
"name": "@timesafari/daily-notification-plugin",
|
"name": "@timesafari/daily-notification-plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
|
|||||||
@@ -206,9 +206,146 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
/**
|
/**
|
||||||
* Initialize component state on mount
|
* Initialize component state on mount
|
||||||
* Checks platform support and syncs with plugin state
|
* Checks platform support and syncs with plugin state
|
||||||
|
*
|
||||||
|
* **Token Refresh on Mount:**
|
||||||
|
* - Refreshes native fetcher configuration to ensure plugin has valid token
|
||||||
|
* - This handles cases where app was closed for extended periods
|
||||||
|
* - Token refresh happens automatically without user interaction
|
||||||
|
*
|
||||||
|
* **App Resume Listener:**
|
||||||
|
* - Listens for Capacitor 'resume' event to refresh token when app comes to foreground
|
||||||
|
* - Ensures plugin always has fresh token for background prefetch operations
|
||||||
|
* - Cleaned up in `beforeDestroy()` lifecycle hook
|
||||||
*/
|
*/
|
||||||
async mounted(): Promise<void> {
|
async mounted(): Promise<void> {
|
||||||
await this.initializeState();
|
await this.initializeState();
|
||||||
|
// Refresh native fetcher configuration on mount
|
||||||
|
// This ensures plugin has valid token even if app was closed for extended periods
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
// Listen for app resume events to refresh token when app comes to foreground
|
||||||
|
// This is part of the proactive token refresh strategy
|
||||||
|
document.addEventListener("resume", this.handleAppResume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on component destroy
|
||||||
|
*/
|
||||||
|
beforeDestroy(): void {
|
||||||
|
document.removeEventListener("resume", this.handleAppResume);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle app resume event - refresh native fetcher configuration
|
||||||
|
*
|
||||||
|
* This method is called when the app comes to foreground (via Capacitor 'resume' event).
|
||||||
|
* It proactively refreshes the JWT token to ensure the plugin has valid authentication
|
||||||
|
* for background prefetch operations.
|
||||||
|
*
|
||||||
|
* **Why refresh on resume?**
|
||||||
|
* - Tokens expire after 72 hours
|
||||||
|
* - App may have been closed for extended periods
|
||||||
|
* - Refreshing ensures plugin has valid token for next prefetch cycle
|
||||||
|
* - No user interaction required - happens automatically
|
||||||
|
*
|
||||||
|
* @see {@link refreshNativeFetcherConfig} For implementation details
|
||||||
|
*/
|
||||||
|
async handleAppResume(): Promise<void> {
|
||||||
|
logger.debug(
|
||||||
|
"[DailyNotificationSection] App resumed, refreshing native fetcher config",
|
||||||
|
);
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh native fetcher configuration with fresh JWT token
|
||||||
|
*
|
||||||
|
* This method ensures the daily notification plugin has a valid authentication token
|
||||||
|
* for background prefetch operations. It's called proactively to prevent token expiration
|
||||||
|
* issues during offline periods.
|
||||||
|
*
|
||||||
|
* **Refresh Triggers:**
|
||||||
|
* - Component mount (when notification settings page loads)
|
||||||
|
* - App resume (when app comes to foreground)
|
||||||
|
* - Notification enabled (when user enables daily notifications)
|
||||||
|
*
|
||||||
|
* **Token Refresh Strategy (Hybrid Approach):**
|
||||||
|
* - Tokens are valid for 72 hours (see `accessTokenForBackground`)
|
||||||
|
* - Tokens are refreshed proactively when app is already open
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Next time app opens, token is automatically refreshed
|
||||||
|
*
|
||||||
|
* **Why This Approach?**
|
||||||
|
* - No app wake-up required (tokens refresh when app is already open)
|
||||||
|
* - Works offline (72-hour validity supports extended offline periods)
|
||||||
|
* - Automatic (no user interaction required)
|
||||||
|
* - Includes starred plans (fetcher receives user's starred plans for prefetch)
|
||||||
|
* - Graceful degradation (if refresh fails, cached content still works)
|
||||||
|
*
|
||||||
|
* **Error Handling:**
|
||||||
|
* - Errors are logged but not shown to user (background operation)
|
||||||
|
* - Returns early if notifications not supported or disabled
|
||||||
|
* - Returns early if API server not configured
|
||||||
|
* - Failures don't interrupt user experience
|
||||||
|
*
|
||||||
|
* @returns Promise that resolves when refresh completes (or fails silently)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Called automatically on mount/resume
|
||||||
|
* await this.refreshNativeFetcherConfig();
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link CapacitorPlatformService.configureNativeFetcher} For token generation
|
||||||
|
* @see {@link accessTokenForBackground} For 72-hour token generation
|
||||||
|
*/
|
||||||
|
async refreshNativeFetcherConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
|
// Early return: Only refresh if notifications are supported and enabled
|
||||||
|
// This prevents unnecessary work when notifications aren't being used
|
||||||
|
if (!this.notificationsSupported || !this.nativeNotificationEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get settings for API server and starred plans
|
||||||
|
// API server tells plugin where to fetch content from
|
||||||
|
// Starred plans tell plugin which plans to prefetch
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
const apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
if (!apiServer) {
|
||||||
|
logger.warn(
|
||||||
|
"[DailyNotificationSection] No API server configured, skipping native fetcher refresh",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get starred plans from settings
|
||||||
|
// These are passed to the plugin so it knows which plans to prefetch
|
||||||
|
const starredPlanHandleIds = settings.starredPlanHandleIds || [];
|
||||||
|
|
||||||
|
// Configure native fetcher with fresh token
|
||||||
|
// The jwt parameter is ignored - configureNativeFetcher generates it automatically
|
||||||
|
// This ensures we always have a fresh token with current expiration time
|
||||||
|
await platformService.configureNativeFetcher({
|
||||||
|
apiServer,
|
||||||
|
jwt: "", // Will be generated automatically by configureNativeFetcher
|
||||||
|
starredPlanHandleIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[DailyNotificationSection] Native fetcher configuration refreshed",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Don't show error to user - this is a background operation
|
||||||
|
// Failures are logged for debugging but don't interrupt user experience
|
||||||
|
// If refresh fails, plugin will use existing token (if still valid) or cached content
|
||||||
|
logger.error(
|
||||||
|
"[DailyNotificationSection] Failed to refresh native fetcher config:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,8 +372,7 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
this.notificationsSupported = true;
|
this.notificationsSupported = true;
|
||||||
this.notificationStatus = status;
|
this.notificationStatus = status;
|
||||||
|
|
||||||
// CRITICAL: Sync with plugin state first (source of truth)
|
// Plugin state is the source of truth
|
||||||
// Plugin may have an existing schedule even if settings don't
|
|
||||||
if (status.isScheduled && status.scheduledTime) {
|
if (status.isScheduled && status.scheduledTime) {
|
||||||
// Plugin has a scheduled notification - sync UI to match
|
// Plugin has a scheduled notification - sync UI to match
|
||||||
this.nativeNotificationEnabled = true;
|
this.nativeNotificationEnabled = true;
|
||||||
@@ -244,31 +380,11 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
this.nativeNotificationTime = formatTimeForDisplay(
|
this.nativeNotificationTime = formatTimeForDisplay(
|
||||||
status.scheduledTime,
|
status.scheduledTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also sync settings to match plugin state
|
|
||||||
const settings = await this.$accountSettings();
|
|
||||||
if (settings.nativeNotificationTime !== status.scheduledTime) {
|
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: status.scheduledTime,
|
|
||||||
nativeNotificationTitle:
|
|
||||||
settings.nativeNotificationTitle || this.nativeNotificationTitle,
|
|
||||||
nativeNotificationMessage:
|
|
||||||
settings.nativeNotificationMessage ||
|
|
||||||
this.nativeNotificationMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No plugin schedule - check settings for user preference
|
// No plugin schedule - UI defaults to disabled
|
||||||
const settings = await this.$accountSettings();
|
this.nativeNotificationEnabled = false;
|
||||||
const nativeNotificationTime = settings.nativeNotificationTime || "";
|
this.nativeNotificationTimeStorage = "";
|
||||||
this.nativeNotificationEnabled = !!nativeNotificationTime;
|
this.nativeNotificationTime = "";
|
||||||
this.nativeNotificationTimeStorage = nativeNotificationTime;
|
|
||||||
|
|
||||||
if (nativeNotificationTime) {
|
|
||||||
this.nativeNotificationTime = formatTimeForDisplay(
|
|
||||||
nativeNotificationTime,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[DailyNotificationSection] Failed to initialize:", error);
|
logger.error("[DailyNotificationSection] Failed to initialize:", error);
|
||||||
@@ -452,16 +568,14 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
priority: "high",
|
priority: "high",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Save to settings
|
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: this.nativeNotificationTimeStorage,
|
|
||||||
nativeNotificationTitle: this.nativeNotificationTitle,
|
|
||||||
nativeNotificationMessage: this.nativeNotificationMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update UI state
|
// Update UI state
|
||||||
this.nativeNotificationEnabled = true;
|
this.nativeNotificationEnabled = true;
|
||||||
|
|
||||||
|
// Refresh native fetcher configuration with fresh token
|
||||||
|
// This ensures plugin has valid authentication when notifications are first enabled
|
||||||
|
// Token will be valid for 72 hours, supporting offline prefetch operations
|
||||||
|
await this.refreshNativeFetcherConfig();
|
||||||
|
|
||||||
this.notify.success(
|
this.notify.success(
|
||||||
"Daily notification scheduled successfully",
|
"Daily notification scheduled successfully",
|
||||||
TIMEOUTS.SHORT,
|
TIMEOUTS.SHORT,
|
||||||
@@ -492,13 +606,6 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
// Cancel notification via PlatformService
|
// Cancel notification via PlatformService
|
||||||
await platformService.cancelDailyNotification();
|
await platformService.cancelDailyNotification();
|
||||||
|
|
||||||
// Clear settings
|
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: "",
|
|
||||||
nativeNotificationTitle: "",
|
|
||||||
nativeNotificationMessage: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update UI state
|
// Update UI state
|
||||||
this.nativeNotificationEnabled = false;
|
this.nativeNotificationEnabled = false;
|
||||||
this.nativeNotificationTime = "";
|
this.nativeNotificationTime = "";
|
||||||
@@ -558,10 +665,7 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
if (this.nativeNotificationEnabled) {
|
if (this.nativeNotificationEnabled) {
|
||||||
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
|
await this.updateNotificationTime(this.nativeNotificationTimeStorage);
|
||||||
} else {
|
} else {
|
||||||
// Just save the time preference
|
// Just update local state (time preference stored in component)
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: this.nativeNotificationTimeStorage,
|
|
||||||
});
|
|
||||||
this.showTimeEdit = false;
|
this.showTimeEdit = false;
|
||||||
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
|
this.notify.success("Notification time saved", TIMEOUTS.SHORT);
|
||||||
}
|
}
|
||||||
@@ -574,12 +678,9 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
async updateNotificationTime(newTime: string): Promise<void> {
|
async updateNotificationTime(newTime: string): Promise<void> {
|
||||||
// newTime is in "HH:mm" format from HTML5 time input
|
// newTime is in "HH:mm" format from HTML5 time input
|
||||||
if (!this.nativeNotificationEnabled) {
|
if (!this.nativeNotificationEnabled) {
|
||||||
// If notification is disabled, just save the time preference
|
// If notification is disabled, just update local state
|
||||||
this.nativeNotificationTimeStorage = newTime;
|
this.nativeNotificationTimeStorage = newTime;
|
||||||
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: newTime,
|
|
||||||
});
|
|
||||||
this.showTimeEdit = false;
|
this.showTimeEdit = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -605,11 +706,6 @@ export default class DailyNotificationSection extends Vue {
|
|||||||
this.nativeNotificationTimeStorage = newTime;
|
this.nativeNotificationTimeStorage = newTime;
|
||||||
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
this.nativeNotificationTime = formatTimeForDisplay(newTime);
|
||||||
|
|
||||||
// 4. Save to settings
|
|
||||||
await this.$saveSettings({
|
|
||||||
nativeNotificationTime: newTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.notify.success(
|
this.notify.success(
|
||||||
"Notification time updated successfully",
|
"Notification time updated successfully",
|
||||||
TIMEOUTS.SHORT,
|
TIMEOUTS.SHORT,
|
||||||
|
|||||||
@@ -199,14 +199,6 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
|
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "006_add_nativeNotificationSettings_to_settings",
|
|
||||||
sql: `
|
|
||||||
ALTER TABLE settings ADD COLUMN nativeNotificationTime TEXT;
|
|
||||||
ALTER TABLE settings ADD COLUMN nativeNotificationTitle TEXT;
|
|
||||||
ALTER TABLE settings ADD COLUMN nativeNotificationMessage TEXT;
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ export type Settings = {
|
|||||||
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder
|
||||||
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
notifyingReminderTime?: string; // set to their chosen time for a daily reminder
|
||||||
|
|
||||||
// Native notification settings (Capacitor only)
|
|
||||||
nativeNotificationTime?: string; // "09:00" format (24-hour) - scheduled time for daily notification
|
|
||||||
nativeNotificationTitle?: string; // Default: "Daily Update" - notification title
|
|
||||||
nativeNotificationMessage?: string; // Default message - notification body text
|
|
||||||
|
|
||||||
partnerApiServer?: string; // partner server API URL
|
partnerApiServer?: string; // partner server API URL
|
||||||
|
|
||||||
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes
|
||||||
|
|||||||
@@ -104,6 +104,71 @@ export const accessToken = async (did?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a longer-lived access token for background operations
|
||||||
|
*
|
||||||
|
* This function creates JWT tokens with extended validity (default 72 hours) for use
|
||||||
|
* in background prefetch operations. The longer expiration period allows the daily
|
||||||
|
* notification plugin to work offline for extended periods without requiring the app
|
||||||
|
* to be in the foreground to refresh tokens.
|
||||||
|
*
|
||||||
|
* **Token Refresh Strategy (Hybrid Approach):**
|
||||||
|
* - Tokens are valid for 72 hours (configurable)
|
||||||
|
* - Tokens are refreshed proactively when:
|
||||||
|
* - App comes to foreground (via Capacitor 'resume' event)
|
||||||
|
* - Component mounts (DailyNotificationSection)
|
||||||
|
* - Notifications are enabled
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Next time app opens, token is automatically refreshed
|
||||||
|
*
|
||||||
|
* **Why 72 Hours?**
|
||||||
|
* - Balances security (read-only prefetch operations) with offline capability
|
||||||
|
* - Reduces need for app to wake itself for token refresh
|
||||||
|
* - Allows plugin to work offline for extended periods (e.g., weekend trips)
|
||||||
|
* - Longer than typical prefetch windows (5 minutes before notification)
|
||||||
|
*
|
||||||
|
* **Security Considerations:**
|
||||||
|
* - Tokens are used only for read-only prefetch operations
|
||||||
|
* - Tokens are stored securely in plugin's Room database
|
||||||
|
* - Tokens are refreshed proactively to minimize exposure window
|
||||||
|
* - No private keys are exposed to native code
|
||||||
|
*
|
||||||
|
* @param {string} did - User DID (Decentralized Identifier) for token issuer
|
||||||
|
* @param {number} expirationMinutes - Optional expiration in minutes (defaults to 72 hours = 4320 minutes)
|
||||||
|
* @return {Promise<string>} JWT token with extended validity, or empty string if no DID provided
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Generate token with default 72-hour expiration
|
||||||
|
* const token = await accessTokenForBackground("did:ethr:0x...");
|
||||||
|
*
|
||||||
|
* // Generate token with custom expiration (24 hours)
|
||||||
|
* const token24h = await accessTokenForBackground("did:ethr:0x...", 24 * 60);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link accessToken} For short-lived tokens (1 minute) for regular API requests
|
||||||
|
* @see {@link createEndorserJwtForDid} For JWT creation implementation
|
||||||
|
*/
|
||||||
|
export const accessTokenForBackground = async (
|
||||||
|
did?: string,
|
||||||
|
expirationMinutes?: number,
|
||||||
|
): Promise<string> => {
|
||||||
|
if (!did) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided expiration or default to 72 hours (4320 minutes)
|
||||||
|
// This allows background prefetch operations to work offline for extended periods
|
||||||
|
const expirationSeconds = expirationMinutes
|
||||||
|
? expirationMinutes * 60
|
||||||
|
: 72 * 60 * 60; // Default 72 hours
|
||||||
|
|
||||||
|
const nowEpoch = Math.floor(Date.now() / 1000);
|
||||||
|
const endEpoch = nowEpoch + expirationSeconds;
|
||||||
|
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
|
||||||
|
return createEndorserJwtForDid(did, tokenPayload);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract JWT from various URL formats
|
* Extract JWT from various URL formats
|
||||||
* @param jwtUrlText The URL containing the JWT
|
* @param jwtUrlText The URL containing the JWT
|
||||||
|
|||||||
@@ -1543,6 +1543,51 @@ export class CapacitorPlatformService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure native fetcher for background operations
|
* Configure native fetcher for background operations
|
||||||
|
*
|
||||||
|
* This method configures the daily notification plugin's native content fetcher
|
||||||
|
* with authentication credentials for background prefetch operations. It automatically
|
||||||
|
* retrieves the active DID from the database and generates a fresh JWT token with
|
||||||
|
* 72-hour expiration.
|
||||||
|
*
|
||||||
|
* **Authentication Flow:**
|
||||||
|
* 1. Retrieves active DID from `active_identity` table (single source of truth)
|
||||||
|
* 2. Generates JWT token with 72-hour expiration using `accessTokenForBackground()`
|
||||||
|
* 3. Configures plugin with API server URL, active DID, and JWT token
|
||||||
|
* 4. Plugin stores token in its Room database for background workers
|
||||||
|
*
|
||||||
|
* **Token Management:**
|
||||||
|
* - Tokens are valid for 72 hours (4320 minutes)
|
||||||
|
* - Tokens are refreshed proactively when app comes to foreground
|
||||||
|
* - If token expires while offline, plugin uses cached content
|
||||||
|
* - Token refresh happens automatically via `DailyNotificationSection.refreshNativeFetcherConfig()`
|
||||||
|
*
|
||||||
|
* **Offline-First Design:**
|
||||||
|
* - 72-hour validity supports extended offline periods
|
||||||
|
* - Plugin can prefetch content when online and use cached content when offline
|
||||||
|
* - No app wake-up required for token refresh (happens when app is already open)
|
||||||
|
*
|
||||||
|
* **Error Handling:**
|
||||||
|
* - Returns `null` if active DID not found (no user logged in)
|
||||||
|
* - Returns `null` if JWT generation fails
|
||||||
|
* - Logs errors but doesn't throw (allows graceful degradation)
|
||||||
|
*
|
||||||
|
* @param config - Native fetcher configuration
|
||||||
|
* @param config.apiServer - API server URL (optional, uses default if not provided)
|
||||||
|
* @param config.jwt - JWT token (ignored, generated automatically)
|
||||||
|
* @param config.starredPlanHandleIds - Array of starred plan handle IDs for prefetch
|
||||||
|
* @returns Promise that resolves when configured, or `null` if configuration failed
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* await platformService.configureNativeFetcher({
|
||||||
|
* apiServer: "https://api.endorser.ch",
|
||||||
|
* jwt: "", // Generated automatically
|
||||||
|
* starredPlanHandleIds: ["plan-123", "plan-456"]
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see {@link accessTokenForBackground} For JWT token generation
|
||||||
|
* @see {@link DailyNotificationSection.refreshNativeFetcherConfig} For proactive token refresh
|
||||||
* @see PlatformService.configureNativeFetcher
|
* @see PlatformService.configureNativeFetcher
|
||||||
*/
|
*/
|
||||||
async configureNativeFetcher(
|
async configureNativeFetcher(
|
||||||
@@ -1553,16 +1598,57 @@ export class CapacitorPlatformService
|
|||||||
"@timesafari/daily-notification-plugin"
|
"@timesafari/daily-notification-plugin"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Plugin expects apiBaseUrl, activeDid, and jwtToken
|
// Step 1: Get activeDid from database (single source of truth)
|
||||||
// We'll need to get activeDid from somewhere - for now pass empty string
|
// This ensures we're using the correct user identity for authentication
|
||||||
// Components should provide activeDid when calling this
|
const activeIdentity = await this.getActiveIdentity();
|
||||||
|
const activeDid = activeIdentity.activeDid;
|
||||||
|
|
||||||
|
if (!activeDid) {
|
||||||
|
logger.warn(
|
||||||
|
"[CapacitorPlatformService] No activeDid found, cannot configure native fetcher",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Generate JWT token for background operations
|
||||||
|
// Use 72-hour expiration for offline-first prefetch operations
|
||||||
|
// This allows the plugin to work offline for extended periods
|
||||||
|
const { accessTokenForBackground } = await import(
|
||||||
|
"../../libs/crypto/index"
|
||||||
|
);
|
||||||
|
// Use 72 hours (4320 minutes) for background prefetch tokens
|
||||||
|
// This is longer than passkey expiration to support offline scenarios
|
||||||
|
const expirationMinutes = 72 * 60; // 72 hours
|
||||||
|
const jwtToken = await accessTokenForBackground(
|
||||||
|
activeDid,
|
||||||
|
expirationMinutes,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!jwtToken) {
|
||||||
|
logger.error("[CapacitorPlatformService] Failed to generate JWT token");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Get API server from config or use default
|
||||||
|
// This ensures the plugin knows where to fetch content from
|
||||||
|
const apiServer =
|
||||||
|
config.apiServer ||
|
||||||
|
(await import("../../constants/app")).DEFAULT_ENDORSER_API_SERVER;
|
||||||
|
|
||||||
|
// Step 4: Configure plugin with credentials
|
||||||
|
// Plugin stores these in its Room database for background workers
|
||||||
await DailyNotification.configureNativeFetcher({
|
await DailyNotification.configureNativeFetcher({
|
||||||
apiBaseUrl: config.apiServer,
|
apiBaseUrl: apiServer,
|
||||||
activeDid: "", // Should be provided by caller
|
activeDid,
|
||||||
jwtToken: config.jwt,
|
jwtToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info("[CapacitorPlatformService] Configured native fetcher");
|
logger.info("[CapacitorPlatformService] Configured native fetcher", {
|
||||||
|
activeDid,
|
||||||
|
apiServer,
|
||||||
|
tokenExpirationHours: 72,
|
||||||
|
tokenExpirationMinutes: expirationMinutes,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
"[CapacitorPlatformService] Failed to configure native fetcher:",
|
"[CapacitorPlatformService] Failed to configure native fetcher:",
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { createBuildConfig } from "./vite.config.common.mts";
|
import { createBuildConfig } from "./vite.config.common.mts";
|
||||||
|
|
||||||
export default defineConfig(async () => createBuildConfig('capacitor'));
|
export default defineConfig(async () => {
|
||||||
|
const baseConfig = await createBuildConfig('capacitor');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseConfig,
|
||||||
|
build: {
|
||||||
|
...baseConfig.build,
|
||||||
|
rollupOptions: {
|
||||||
|
...baseConfig.build?.rollupOptions,
|
||||||
|
// Externalize Capacitor plugins that are bundled natively
|
||||||
|
external: [
|
||||||
|
"@timesafari/daily-notification-plugin"
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
...baseConfig.build?.rollupOptions?.output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user