BREAKING CHANGE(core): implement complete stateless architecture with consumer-controlled session persistence

This commit is contained in:
2025-07-25 02:10:16 +00:00
parent f790984a95
commit bc0517164f
9 changed files with 594 additions and 140 deletions

View File

@@ -1,8 +1,7 @@
import * as plugins from './bunq.plugins.js';
import * as paths from './bunq.paths.js';
import { BunqCrypto } from './bunq.classes.crypto.js';
import { BunqSession } from './bunq.classes.session.js';
import type { IBunqApiContext } from './bunq.interfaces.js';
import type { IBunqApiContext, ISessionData } from './bunq.interfaces.js';
export interface IBunqApiContextOptions {
apiKey: string;
@@ -17,7 +16,6 @@ export class BunqApiContext {
private crypto: BunqCrypto;
private session: BunqSession;
private context: IBunqApiContext;
private contextFilePath: string;
constructor(options: IBunqApiContextOptions) {
this.options = options;
@@ -32,38 +30,14 @@ export class BunqApiContext {
: 'https://public-api.sandbox.bunq.com'
};
// Set context file path based on environment
this.contextFilePath = options.environment === 'PRODUCTION'
? paths.bunqJsonProductionFile
: paths.bunqJsonSandboxFile;
this.session = new BunqSession(this.crypto, this.context);
}
/**
* Initialize the API context (installation, device, session)
* @returns The session data that can be persisted by the consumer
*/
public async init(): Promise<void> {
// Try to load existing context
const existingContext = await this.loadContext();
if (existingContext && existingContext.sessionToken) {
// Restore crypto keys
this.crypto.setKeys(
existingContext.clientPrivateKey,
existingContext.clientPublicKey
);
// Update context
this.context = { ...this.context, ...existingContext };
this.session = new BunqSession(this.crypto, this.context);
// Check if session is still valid
if (this.session.isSessionValid()) {
return;
}
}
public async init(): Promise<ISessionData> {
// Create new session
await this.session.init(
this.options.deviceDescription,
@@ -75,42 +49,96 @@ export class BunqApiContext {
this.session.setOAuthMode(true);
}
// Save context
await this.saveContext();
return this.exportSession();
}
/**
* Save the current context to file
* Initialize the API context with existing session data
* @param sessionData The session data to restore
*/
private async saveContext(): Promise<void> {
await plugins.smartfile.fs.ensureDir(paths.nogitDir);
const contextToSave = {
...this.session.getContext(),
savedAt: new Date().toISOString()
};
await plugins.smartfile.memory.toFs(
JSON.stringify(contextToSave, null, 2),
this.contextFilePath
);
}
/**
* Load context from file
*/
private async loadContext(): Promise<IBunqApiContext | null> {
try {
const exists = await plugins.smartfile.fs.fileExists(this.contextFilePath);
if (!exists) {
return null;
}
const contextData = await plugins.smartfile.fs.toStringSync(this.contextFilePath);
return JSON.parse(contextData);
} catch (error) {
return null;
public async initWithSession(sessionData: ISessionData): Promise<void> {
// Validate session data
if (!sessionData.sessionToken || !sessionData.sessionId) {
throw new Error('Invalid session data: missing session token or ID');
}
// Restore crypto keys
this.crypto.setKeys(
sessionData.clientPrivateKey,
sessionData.clientPublicKey
);
// Update context with session data
this.context = {
...this.context,
sessionToken: sessionData.sessionToken,
sessionId: sessionData.sessionId,
installationToken: sessionData.installationToken,
serverPublicKey: sessionData.serverPublicKey,
clientPrivateKey: sessionData.clientPrivateKey,
clientPublicKey: sessionData.clientPublicKey,
expiresAt: new Date(sessionData.expiresAt)
};
// Create new session instance with restored context
this.session = new BunqSession(this.crypto, this.context);
// Set OAuth mode if applicable
if (this.options.isOAuthToken) {
this.session.setOAuthMode(true);
}
// Check if session is still valid
if (!this.session.isSessionValid()) {
throw new Error('Session has expired');
}
}
/**
* Export the current session data for persistence
* @returns The session data that can be saved by the consumer
*/
public exportSession(): ISessionData {
const context = this.session.getContext();
if (!context.sessionToken || !context.sessionId) {
throw new Error('No active session to export');
}
return {
sessionId: context.sessionId,
sessionToken: context.sessionToken,
installationToken: context.installationToken!,
serverPublicKey: context.serverPublicKey!,
clientPrivateKey: context.clientPrivateKey!,
clientPublicKey: context.clientPublicKey!,
expiresAt: context.expiresAt!,
environment: context.environment,
baseUrl: context.baseUrl
};
}
/**
* Create a new BunqApiContext with existing session data
* @param sessionData The session data to use
* @param apiKey The API key (still needed for refresh)
* @param deviceDescription Device description
* @returns A new BunqApiContext instance
*/
public static async createWithSession(
sessionData: ISessionData,
apiKey: string,
deviceDescription: string
): Promise<BunqApiContext> {
const context = new BunqApiContext({
apiKey,
environment: sessionData.environment,
deviceDescription,
isOAuthToken: false // Set appropriately based on your needs
});
await context.initWithSession(sessionData);
return context;
}
/**
@@ -129,24 +157,24 @@ export class BunqApiContext {
/**
* Refresh session if needed
* @returns Updated session data if session was refreshed, null otherwise
*/
public async ensureValidSession(): Promise<void> {
public async ensureValidSession(): Promise<ISessionData | null> {
const wasValid = this.session.isSessionValid();
await this.session.refreshSession();
await this.saveContext();
// Return updated session data only if session was actually refreshed
if (!wasValid) {
return this.exportSession();
}
return null;
}
/**
* Destroy the current session and clean up
* Destroy the current session
*/
public async destroy(): Promise<void> {
await this.session.destroySession();
// Remove saved context
try {
await plugins.smartfile.fs.remove(this.contextFilePath);
} catch (error) {
// Ignore errors when removing file
}
}
/**
@@ -172,24 +200,27 @@ export class BunqApiContext {
/**
* Initialize with existing installation and device (for OAuth tokens that already completed these steps)
* @param existingInstallation Optional partial session data with just installation/device info
* @returns The new session data
*/
public async initWithExistingInstallation(): Promise<void> {
public async initWithExistingInstallation(existingInstallation?: Partial<ISessionData>): Promise<ISessionData> {
// For OAuth tokens that already have installation/device but need a new session
// We need to:
// 1. Try to load existing installation/device info
// 2. Create a new session using the OAuth token as the secret
const existingContext = await this.loadContext();
if (existingContext && existingContext.clientPrivateKey && existingContext.clientPublicKey) {
if (existingInstallation && existingInstallation.clientPrivateKey && existingInstallation.clientPublicKey) {
// Restore crypto keys from previous installation
this.crypto.setKeys(
existingContext.clientPrivateKey,
existingContext.clientPublicKey
existingInstallation.clientPrivateKey,
existingInstallation.clientPublicKey
);
// Update context with existing installation data
this.context = { ...this.context, ...existingContext };
this.context = {
...this.context,
installationToken: existingInstallation.installationToken,
serverPublicKey: existingInstallation.serverPublicKey,
clientPrivateKey: existingInstallation.clientPrivateKey,
clientPublicKey: existingInstallation.clientPublicKey
};
// Create new session instance
this.session = new BunqSession(this.crypto, this.context);
@@ -206,14 +237,14 @@ export class BunqApiContext {
this.session.setOAuthMode(true);
}
await this.saveContext();
console.log('Successfully created new session with existing installation');
return this.exportSession();
} catch (error) {
throw new Error(`Failed to create session with OAuth token: ${error.message}`);
}
} else {
// No existing installation, fall back to full init
throw new Error('No existing installation found, full initialization required');
throw new Error('No existing installation provided, full initialization required');
}
}
}