diff --git a/package.json b/package.json index c558b76..efe4ea3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wharfkit/session", "description": "Create account-based sessions, perform transactions, and allow users to login using Antelope-based blockchains.", - "version": "1.6.1", + "version": "1.7.1-beta1", "homepage": "https://github.com/wharfkit/session", "license": "BSD-3-Clause", "main": "lib/session.js", diff --git a/src/encoded.ts b/src/encoded.ts new file mode 100644 index 0000000..a48fe2b --- /dev/null +++ b/src/encoded.ts @@ -0,0 +1,35 @@ +import {Checksum256, Name, Struct} from '@wharfkit/antelope' +import {SerializedSession, Session} from './index-module' + +/** + * The metadata of an [[AccountCreationPlugin]]. + */ +@Struct.type('url_encoded_session') +export class URLEncodedSession extends Struct { + @Struct.field(Checksum256) declare chain: Checksum256 + @Struct.field(Name) declare actor: Name + @Struct.field(Name) declare permission: Name + @Struct.field('string') declare walletPlugin: string + @Struct.field('string', {optional: true}) declare data?: string + + static fromSession(data: Session | SerializedSession): URLEncodedSession { + const session = data instanceof Session ? data.serialize() : data + return new URLEncodedSession({ + chain: session.chain, + actor: session.actor, + permission: session.permission, + walletPlugin: JSON.stringify(session.walletPlugin), + data: JSON.stringify(session.data), + }) + } + + get serialized(): SerializedSession { + return { + chain: this.chain, + actor: this.actor, + permission: this.permission, + walletPlugin: JSON.parse(this.walletPlugin), + data: this.data ? JSON.parse(this.data) : undefined, + } + } +} diff --git a/src/index-module.ts b/src/index-module.ts index aeadb8f..e4f6bbb 100644 --- a/src/index-module.ts +++ b/src/index-module.ts @@ -1,3 +1,4 @@ +export * from './encoded' export * from './kit' export * from './login' export * from './session' diff --git a/src/kit.ts b/src/kit.ts index 1ae8b97..d626909 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -1,12 +1,14 @@ import {ChainDefinition, type ChainDefinitionType, type Fetch} from '@wharfkit/common' import type {Contract} from '@wharfkit/contract' import { + Bytes, Checksum256, Checksum256Type, Name, NameType, PermissionLevel, PermissionLevelType, + Serializer, } from '@wharfkit/antelope' import { @@ -16,7 +18,7 @@ import { LoginPlugin, UserInterfaceWalletPlugin, } from './login' -import {SerializedSession, Session} from './session' +import {PartialSerializedSession, SerializedSession, Session} from './session' import {BrowserLocalStorage, SessionStorage} from './storage' import { AbstractTransactPlugin, @@ -34,11 +36,13 @@ import { CreateAccountOptions, CreateAccountResponse, } from './account-creation' +import {URLEncodedSession} from './encoded' export interface LoginOptions { arbitrary?: Record // Arbitrary data that will be passed via context to wallet plugin chain?: ChainDefinition | Checksum256Type chains?: Checksum256Type[] + equalityFn?: SerializedSessionEqualityFn loginPlugins?: LoginPlugin[] setAsDefault?: boolean transactPlugins?: TransactPlugin[] @@ -47,6 +51,10 @@ export interface LoginOptions { walletPlugin?: string } +export interface LogoutOptions { + equalityFn?: SerializedSessionEqualityFn +} + export interface LoginResult { context: LoginContext response: WalletPluginLoginResponse @@ -58,12 +66,13 @@ export interface LogoutContext { appName: string } -export interface RestoreArgs { - chain: Checksum256Type | ChainDefinition - actor?: NameType - permission?: NameType - walletPlugin?: Record - data?: Record +export type SessionType = Session | SerializedSession + +export type SerializedSessionEqualityFn = (a: SessionType, b: SessionType) => boolean + +export interface PersistOptions { + setAsDefault?: boolean + equalityFn?: SerializedSessionEqualityFn } export interface SessionKitArgs { @@ -75,15 +84,18 @@ export interface SessionKitArgs { export interface SessionKitOptions { abis?: TransactABIDef[] + acceptUrlSession?: boolean + acceptUrlSessionParam?: string + accountCreationPlugins?: AccountCreationPlugin[] allowModify?: boolean contracts?: Contract[] + equalityFn?: SerializedSessionEqualityFn expireSeconds?: number fetch?: Fetch loginPlugins?: LoginPlugin[] storage?: SessionStorage transactPlugins?: TransactPlugin[] transactPluginsOptions?: TransactPluginsOptions - accountCreationPlugins?: AccountCreationPlugin[] } /** @@ -91,8 +103,12 @@ export interface SessionKitOptions { */ export class SessionKit { readonly abis: TransactABIDef[] = [] + readonly acceptUrlSession: boolean = false + readonly acceptUrlSessionParam: string = 'incomingWharfSession' + readonly accountCreationPlugins: AccountCreationPlugin[] = [] readonly allowModify: boolean = true readonly appName: string + readonly equalityFn: SerializedSessionEqualityFn = serializedSessionEquals readonly expireSeconds: number = 120 readonly fetch: Fetch readonly loginPlugins: AbstractLoginPlugin[] @@ -101,7 +117,6 @@ export class SessionKit { readonly transactPluginsOptions: TransactPluginsOptions = {} readonly ui: UserInterface readonly walletPlugins: WalletPlugin[] - readonly accountCreationPlugins: AccountCreationPlugin[] = [] public chains: ChainDefinition[] constructor(args: SessionKitArgs, options: SessionKitOptions = {}) { @@ -123,10 +138,22 @@ export class SessionKit { if (options.abis) { this.abis = [...options.abis] } + // Determine if URL sessions should be accepted + if (options.acceptUrlSession) { + this.acceptUrlSession = options.acceptUrlSession + } + // Determine if URL session param name was overridden + if (options.acceptUrlSessionParam) { + this.acceptUrlSessionParam = options.acceptUrlSessionParam + } // Extract any ABIs from the Contract instances provided if (options.contracts) { this.abis.push(...options.contracts.map((c) => ({account: c.account, abi: c.abi}))) } + // Override equality function if provided + if (options.equalityFn) { + this.equalityFn = options.equalityFn + } // Establish default plugins for login flow if (options.loginPlugins) { this.loginPlugins = options.loginPlugins @@ -187,6 +214,14 @@ export class SessionKit { return chain } + getWalletPlugin(id: string): WalletPlugin { + const walletPlugin = this.walletPlugins.find((p) => p.id === id) + if (!walletPlugin) { + throw new Error(`No WalletPlugin found with the ID of: '${id}'`) + } + return walletPlugin + } + /** * Request account creation. */ @@ -474,7 +509,9 @@ export class SessionKit { for (const hook of context.hooks.afterLogin) await hook(context) // Save the session to storage if it has a storage instance. - this.persistSession(session, options?.setAsDefault) + this.persistSession(session, { + setAsDefault: options?.setAsDefault, + }) // Notify the UI that the login request has completed. await context.ui.onLoginComplete() @@ -512,7 +549,7 @@ export class SessionKit { } } - async logout(session?: Session | SerializedSession) { + async logout(session?: Session | SerializedSession, options: LogoutOptions = {}) { if (!this.storage) { throw new Error('An instance of Storage must be provided to utilize the logout method.') } @@ -528,19 +565,8 @@ export class SessionKit { const sessions = await this.getSessions() if (sessions) { - let serialized = session - if (session instanceof Session) { - serialized = session.serialize() - } - const other = sessions.filter((s: Record) => { - return ( - !Checksum256.from(s.chain).equals( - Checksum256.from(String(serialized.chain)) - ) || - !Name.from(s.actor).equals(Name.from(serialized.actor)) || - !Name.from(s.permission).equals(Name.from(serialized.permission)) - ) - }) + const equalityFn = options.equalityFn || this.equalityFn + const other = sessions.filter((s) => !equalityFn(s, session)) await this.storage.write('sessions', JSON.stringify(other)) } } else { @@ -566,97 +592,73 @@ export class SessionKit { } } - async restore(args?: RestoreArgs, options?: LoginOptions): Promise { - // If no args were provided, attempt to default restore the session from storage. - if (!args) { - const data = await this.storage.read('session') - if (data) { - args = JSON.parse(data) - } else { - return + restoreFromURL(): SerializedSession | undefined { + // Attempt to retrieve session from current URL params + const url = new URL(window.location.href) + const urlSessionParam = url.searchParams.get(this.acceptUrlSessionParam) + if (urlSessionParam) { + try { + const encodedSession = Serializer.decode({ + data: Bytes.from(urlSessionParam, 'hex'), + type: URLEncodedSession, + }) + // Remove the session from the URL to prevent reuse + url.searchParams.delete(this.acceptUrlSessionParam) + window.history.replaceState(null, '', url) + // Return the serialized session + return encodedSession.serialized + } catch (e) { + // eslint-disable-next-line no-console -- warn the developer since this may be unintentional + console.warn('Failed to decode session from URL: ' + urlSessionParam) } } + } - if (!args) { - throw new Error('Either a RestoreArgs object or a Storage instance must be provided.') - } - - const chainId = Checksum256.from( - args.chain instanceof ChainDefinition ? args.chain.id : args.chain - ) - - let serializedSession: SerializedSession - - // Retrieve all sessions from storage - const data = await this.storage.read('sessions') + async restoreWithoutArgs(): Promise { + let serializedSession: SerializedSession | undefined - if (data) { - // If sessions exist, restore the session that matches the provided args - const sessions = JSON.parse(data) - if (args.actor && args.permission) { - // If all args are provided, return exact match - serializedSession = sessions.find((s: SerializedSession) => { - return ( - args && - chainId.equals(s.chain) && - s.actor === args.actor && - s.permission === args.permission - ) - }) - } else { - // If no actor/permission defined, return based on chain - serializedSession = sessions.find((s: SerializedSession) => { - return args && chainId.equals(s.chain) && s.default - }) - } - } else { - // If no sessions were found, but the args contains all the data for a serialized session, use args - if (args.actor && args.permission && args.walletPlugin) { - serializedSession = { - chain: String(chainId), - actor: args.actor, - permission: args.permission, - walletPlugin: { - id: args.walletPlugin.id, - data: args.walletPlugin.data, - }, - data: args.data, - } - } else { - // Otherwise throw an error since we can't establish the session data - throw new Error('No sessions found in storage. A wallet plugin must be provided.') - } + // Attempt to restore from URL (if enabled and in browser) + if (this.acceptUrlSession && typeof window !== 'undefined') { + serializedSession = this.restoreFromURL() } - // If no session found, return + // If no session from from the URL, attempt to restore the session from storage if (!serializedSession) { - return + const data = await this.storage.read('session') + if (data) { + serializedSession = JSON.parse(data) + } } - // Ensure a WalletPlugin was found with the provided ID. - const walletPlugin = this.walletPlugins.find((p) => { - if (!args) { - return false - } - return p.id === serializedSession.walletPlugin.id - }) + return serializedSession + } - if (!walletPlugin) { - throw new Error( - `No WalletPlugin found with the ID of: '${serializedSession.walletPlugin.id}'` - ) + async restoreWithArgs(args: PartialSerializedSession): Promise { + let serializedSession = upgradePossibleSerializedSession(args) + if (!serializedSession) { + const data = await this.storage.read('sessions') + if (data) { + const sessions = JSON.parse(data) as SerializedSession[] + serializedSession = sessions.find( + (s) => Checksum256.from(args.chain).equals(s.chain) && s.default + ) + } } + return serializedSession + } - // Set the wallet data from the serialized session + getWalletPluginFromSerialized(serializedSession: SerializedSession): WalletPlugin { + const walletPlugin = this.getWalletPlugin(serializedSession.walletPlugin.id) + + // Set the WalletPlugin data if it exists on the serialized session if (serializedSession.walletPlugin.data) { walletPlugin.data = serializedSession.walletPlugin.data } - // If walletPlugin data was provided by args, override - if (args.walletPlugin && args.walletPlugin.data) { - walletPlugin.data = args.walletPlugin.data - } + return walletPlugin + } + serializedToSession(serializedSession: SerializedSession, options: LoginOptions = {}): Session { // Create a new session from the provided args. const session = new Session( { @@ -665,52 +667,63 @@ export class SessionKit { actor: serializedSession.actor, permission: serializedSession.permission, }), - walletPlugin, + walletPlugin: this.getWalletPluginFromSerialized(serializedSession), }, this.getSessionOptions(options) ) + // Set the session data if it exists on the serialized session if (serializedSession.data) { session.data = serializedSession.data } - // Save the session to storage if it has a storage instance. - this.persistSession(session, options?.setAsDefault) - - // Return the session return session } + async restore( + args?: PartialSerializedSession, + options?: LoginOptions + ): Promise { + const serializedSession = args + ? await this.restoreWithArgs(args) + : await this.restoreWithoutArgs() + + if (serializedSession) { + // Create a session from the serialized session data + const session = this.serializedToSession(serializedSession, options) + + // Persist the session to storage + this.persistSession(session, {setAsDefault: options?.setAsDefault}) + + // Return the session + return session + } + } + async restoreAll(): Promise { const sessions: Session[] = [] const serializedSessions = await this.getSessions() - if (serializedSessions) { - for (const s of serializedSessions) { - const session = await this.restore(s) - if (session) { - sessions.push(session) - } + for (const serializedSession of serializedSessions) { + const session = await this.restore(serializedSession) + if (session) { + sessions.push(session) } } return sessions } - async persistSession(session: Session, setAsDefault = true) { - // TODO: Allow disabling of session persistence via kit options - - // If no storage exists, do nothing. - if (!this.storage) { - return - } - + async persistSession(session: Session, options: PersistOptions = {}) { // Serialize session passed in const serialized = session.serialize() // Specify whether or not this is now the default for the given chain - serialized.default = setAsDefault + serialized.default = options.setAsDefault !== undefined ? options.setAsDefault : true - // Set this as the current session for all chains - if (setAsDefault) { + // Determine equality function to use + const equalityFn = options.equalityFn || this.equalityFn + + // If this session is the default, write it to the 'session' key for persistence + if (serialized.default) { this.storage.write('session', JSON.stringify(serialized)) } @@ -720,16 +733,10 @@ export class SessionKit { const stored = JSON.parse(existing) const sessions: SerializedSession[] = stored // Filter out any matching session to ensure no duplicates - .filter((s: SerializedSession): boolean => { - return ( - !Checksum256.from(s.chain).equals(Checksum256.from(serialized.chain)) || - !Name.from(s.actor).equals(Name.from(serialized.actor)) || - !Name.from(s.permission).equals(Name.from(serialized.permission)) - ) - }) - // Remove the default status from all other sessions for this chain + .filter((s) => !equalityFn(s, serialized)) + // If the new session is the default, remove the default status from all other sessions for this chain .map((s: SerializedSession): SerializedSession => { - if (session.chain.id.equals(s.chain)) { + if (serialized.default && session.chain.id.equals(s.chain)) { s.default = false } return s @@ -761,12 +768,7 @@ export class SessionKit { try { const parsed = JSON.parse(data) // Only return sessions that have a wallet plugin that is currently registered. - const filtered = parsed.filter((s: SerializedSession) => - this.walletPlugins.some((p) => { - return p.id === s.walletPlugin.id - }) - ) - return filtered + return getSessionsMatchingWalletPlugins(parsed, this.walletPlugins) } catch (e) { throw new Error(`Failed to parse sessions from storage (${e})`) } @@ -786,3 +788,40 @@ export class SessionKit { } } } + +export function getSessionsMatchingWalletPlugins( + sessions: SerializedSession[], + walletPlugins: WalletPlugin[] +) { + return sessions.filter((s) => walletPlugins.some((p) => p.id === s.walletPlugin.id)) +} + +export function upgradePossibleSerializedSession( + possible: PartialSerializedSession | undefined +): SerializedSession | undefined { + if ( + possible && + possible.actor !== undefined && + possible.chain !== undefined && + possible.permission !== undefined && + possible.walletPlugin !== undefined + ) { + return { + actor: possible.actor, + chain: possible.chain, + permission: possible.permission, + walletPlugin: possible.walletPlugin, + data: possible.data, + } + } +} + +export function serializedSessionEquals(a: SessionType, b: SessionType): boolean { + const first = a instanceof Session ? a.serialize() : a + const second = b instanceof Session ? b.serialize() : b + return ( + Checksum256.from(first.chain).equals(second.chain) && + Name.from(first.actor).equals(second.actor) && + Name.from(first.permission).equals(second.permission) + ) +} diff --git a/src/session.ts b/src/session.ts index b51efa5..1047235 100644 --- a/src/session.ts +++ b/src/session.ts @@ -44,6 +44,7 @@ import {SessionStorage} from './storage' import {getFetch, getPluginTranslations} from './utils' import {SerializedWalletPlugin, WalletPlugin, WalletPluginSignResponse} from './wallet' import {UserInterface} from './ui' +import {URLEncodedSession} from './encoded' /** * Arguments required to create a new [[Session]]. @@ -51,6 +52,7 @@ import {UserInterface} from './ui' export interface SessionArgs { actor?: NameType chain: ChainDefinitionType + data?: Record permission?: NameType permissionLevel?: PermissionLevelType | string walletPlugin: WalletPlugin @@ -83,6 +85,17 @@ export interface SerializedSession { data?: Record } +export interface PartialSerializedSession extends Partial { + actor?: NameType + chain: Checksum256Type + default?: boolean + permission?: NameType + walletPlugin?: SerializedWalletPlugin + data?: Record +} + +export type SessionEncodingTypes = 'encoded' | 'json' | 'serialized' | 'url' + /** * A representation of a session to interact with a specific blockchain account. */ @@ -140,6 +153,11 @@ export class Session { // Set the WalletPlugin for this session this.walletPlugin = args.walletPlugin + // Initialize any arbitrary data provided to the constructor + if (args.data) { + this.data = args.data + } + // Handle all the optional values provided if (options.appName) { this.appName = String(options.appName) @@ -682,6 +700,30 @@ export class Session { return abiCache } + + encode(encoding: 'encoded'): URLEncodedSession + encode(encoding: 'json'): string + encode(encoding: 'serialized'): SerializedSession + encode(encoding: 'url'): string + encode( + encoding: SessionEncodingTypes = 'serialized' + ): string | SerializedSession | URLEncodedSession { + const serialized = this.serialize() + switch (encoding) { + case 'encoded': + return URLEncodedSession.fromSession(serialized) + case 'json': + return JSON.stringify(serialized) + case 'serialized': + return serialized + case 'url': + return Serializer.encode({ + object: URLEncodedSession.fromSession(serialized), + }).toString('hex') + default: + throw new Error(`Unsupported encoding: ${encoding}`) + } + } } async function processReturnValues( diff --git a/test/tests/kit.ts b/test/tests/kit.ts index f7da168..63bf4c3 100644 --- a/test/tests/kit.ts +++ b/test/tests/kit.ts @@ -1,5 +1,5 @@ import {assert} from 'chai' -import {Checksum256, PermissionLevel, TimePointSec} from '@wharfkit/antelope' +import {Checksum256, Name, PermissionLevel, TimePointSec} from '@wharfkit/antelope' import {WalletPluginPrivateKey} from '@wharfkit/wallet-plugin-privatekey' import { @@ -10,11 +10,12 @@ import { Logo, Session, SessionKit, + SessionType, UserInterfaceAccountCreationResponse, UserInterfaceLoginResponse, } from '$lib' -import {makeWallet, MockWalletPluginConfigs} from '@wharfkit/mock-data' +import {makeWallet, mockSessionOptions, MockWalletPluginConfigs} from '@wharfkit/mock-data' import {MockTransactPlugin} from '@wharfkit/mock-data' import {makeMockAction} from '@wharfkit/mock-data' import { @@ -336,13 +337,50 @@ suite('kit', function () { assert.lengthOf(sessionsAfterLogout, 0) }) test('session param', async function () { - const {session} = await sessionKit.login() - assertSessionMatchesMockSession(session) + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + + const session2 = new Session( + { + actor: 'session2', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2) + + const session3 = new Session( + { + actor: 'session3', + permission: 'test', + chain: Chains.EOS, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session3) + const sessionsBeforeLogout = await sessionKit.getSessions() - assert.lengthOf(sessionsBeforeLogout, 1) - await sessionKit.logout(session) + assert.lengthOf(sessionsBeforeLogout, 3) + assert.equal(sessionsBeforeLogout[0].actor, session1.actor) + assert.equal(sessionsBeforeLogout[1].actor, session2.actor) + assert.equal(sessionsBeforeLogout[2].actor, session3.actor) + + await sessionKit.logout(session2) const sessionsAfterLogout = await sessionKit.getSessions() - assert.lengthOf(sessionsAfterLogout, 0) + assert.lengthOf(sessionsAfterLogout, 2) + assert.equal(sessionsAfterLogout[0].actor, session1.actor) + assert.equal(sessionsAfterLogout[1].actor, session3.actor) }) test('serialized session param', async function () { const {session} = await sessionKit.login() @@ -371,7 +409,7 @@ suite('kit', function () { }) const {session} = await sessionKit.login() session.data.customField = 'data value' - sessionKit.persistSession(session) + await sessionKit.persistSession(session) const restored = await sessionKit.restore() if (!restored) { throw new Error('Failed to restore session') @@ -460,7 +498,7 @@ suite('kit', function () { assert.isTrue(sessions[2].actor.equals('mock3')) assert.isTrue(sessions[2].chain.id.equals(Chains.EOS.id)) - const restoredEOS = await sessionKit.restore({chain: Chains.EOS}) + const restoredEOS = await sessionKit.restore({chain: Chains.EOS.id}) assert.isDefined(restoredEOS) if (restoredEOS) { assert.instanceOf(restoredEOS, Session) @@ -468,7 +506,7 @@ suite('kit', function () { assert.isTrue(restoredEOS.chain.id.equals(Chains.EOS.id)) } - const restoredJUNGLE = await sessionKit.restore({chain: Chains.Jungle4}) + const restoredJUNGLE = await sessionKit.restore({chain: Chains.Jungle4.id}) assert.isDefined(restoredJUNGLE) if (restoredJUNGLE) { assert.instanceOf(restoredJUNGLE, Session) @@ -476,6 +514,72 @@ suite('kit', function () { assert.isTrue(restoredJUNGLE.chain.id.equals(Chains.Jungle4.id)) } }) + test('session from URL', async function () { + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + acceptUrlSession: true, + storage: new MockStorage(), + }) + + // Mock window object for Node.js environment + if (typeof globalThis.window === 'undefined') { + ;(globalThis as any).window = {} + } + + // Mock window.history for Node.js environment + if (typeof (globalThis as any).window.history === 'undefined') { + ;(globalThis as any).window.history = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + replaceState: () => {}, + } + } + + // Mock window.location with a writable href property + if (typeof (globalThis as any).window.location === 'undefined') { + ;(globalThis as any).window.location = {href: ''} + } else { + try { + ;(globalThis as any).window.location.href = + (globalThis as any).window.location.href || '' + } catch { + ;(globalThis as any).window.location = {href: ''} + } + } + + // Ensure no sessions + const sessions = await sessionKit.restoreAll() + assert.lengthOf(sessions, 0) + + // Set the href to include an incomingWharfSession parameter + window.location.href = + 'https://somewhere.com?incomingWharfSession=73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d104208d9c1754de3000000000090b1ca737b226964223a2277616c6c65742d706c7567696e2d707269766174656b6579222c2264617461223a7b22707269766174654b6579223a225056545f4b315f32355850314c7431527438376879796d6f755369654262676e554541657253317951486939777148433255656b326d677a48227d7d010f7b226669656c64223a22666f6f227d' + + // Attempt to restore the session from the URL + const session = await sessionKit.restore() + if (!session) { + throw new Error('Failed to restore session from URL') + } + + // Ensure session is correct + assert.isDefined(session) + assert.isTrue(session.chain.id.equals(mockChainDefinition.id), 'Incorrect chain') + assert.isTrue(session.actor.equals('wharfkit1111'), 'Incorrect actor') + assert.isTrue(session.permission.equals('test'), 'Incorrect permission') + assert.isTrue( + session.walletPlugin instanceof WalletPluginPrivateKey, + 'Incorrect walletPlugin type' + ) + assert.equal(session.data.field, 'foo', 'Incorrect session data') + assert.equal(session.walletPlugin.id, 'wallet-plugin-privatekey') + assert.equal( + session.walletPlugin.data.privateKey, + 'PVT_K1_25XP1Lt1Rt87hyymouSieBbgnUEAerS1yQHi9wqHC2Uek2mgzH' + ) + + // Ensure session was persisted to storage + const sessionsAfter = await sessionKit.restoreAll() + assert.lengthOf(sessionsAfter, 1) + }) test('no session returns undefined', async function () { const sessionKit = new SessionKit(mockSessionKitArgs, { ...mockSessionKitOptions, @@ -558,6 +662,225 @@ suite('kit', function () { assert.isTrue(sessions[2].actor.equals('mock3')) }) }) + suite('persistSession', function () { + test('persists session data', async function () { + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + storage: new MockStorage(), + }) + const {session} = await sessionKit.login() + const restored = await sessionKit.restore() + if (!restored) { + throw new Error('Failed to restore session') + } + assert.deepEqual(restored.serialize(), session.serialize()) + }) + test('prevent duplicates', async function () { + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + storage: new MockStorage(), + }) + const {session} = await sessionKit.login() + await sessionKit.persistSession(session) + await sessionKit.persistSession(session) + const sessions = await sessionKit.getSessions() + assert.lengthOf(sessions, 1) + }) + test('sets default on new session', async function () { + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + storage: new MockStorage(), + }) + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + const session2 = new Session( + { + actor: 'session2', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2) + const sessions = await sessionKit.getSessions() + assert.lengthOf(sessions, 2) + assert.equal(sessions[0].default, false) + assert.equal(sessions[1].default, true) + }) + test('prevent default on new session', async function () { + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + storage: new MockStorage(), + }) + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + const session2 = new Session( + { + actor: 'session2', + permission: 'test', + chain: mockChainDefinition, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2, {setAsDefault: false}) + const sessions = await sessionKit.getSessions() + assert.lengthOf(sessions, 2) + assert.equal(sessions[0].default, true) + assert.equal(sessions[1].default, false) + }) + }) + suite('equalityFn', function () { + test('base equality check', async function () { + // The base equality uses a combination of chain, actor, and permission + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + storage: new MockStorage(), + }) + // Create two sessions for the same chain, actor, and permission but different appIds + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app1', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + const session2 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app2', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2) + const sessions = await sessionKit.getSessions() + // The base rules prevent duplicate sessions based on chain, actor, and permission only + // it ignores additional data like appId + assert.lengthOf(sessions, 1) + // The second session should have overwritten the first + assert.equal(sessions[0].data?.appId, 'app2') + }) + test('custom equalityFn', async function () { + // This custom rule enforces custom uniqueness based on persisted appId + const equalityFn = (a: SessionType, b: SessionType) => { + const first = a instanceof Session ? a.serialize() : a + const second = b instanceof Session ? b.serialize() : b + const idsEqual = first.data?.appId === second.data?.appId + return ( + Checksum256.from(first.chain).equals(second.chain) && + Name.from(first.actor).equals(second.actor) && + Name.from(first.permission).equals(second.permission) && + idsEqual + ) + } + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + equalityFn, // Initialize with custom equality function + storage: new MockStorage(), + }) + // Create two sessions for the same user with different appIds + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app1', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + const session2 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app2', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2) + const sessions = await sessionKit.getSessions() + // Ensure the uniqueness rule was applied and both sessions exist + assert.lengthOf(sessions, 2) + assert.equal(sessions[0].data?.appId, 'app1') + assert.equal(sessions[1].data?.appId, 'app2') + }) + test('disable equality', async function () { + // This custom rule disables uniqueness entirely + const equalityFn = () => false + const sessionKit = new SessionKit(mockSessionKitArgs, { + ...mockSessionKitOptions, + equalityFn, // Initialize with custom equality function + storage: new MockStorage(), + }) + // Create two sessions for the same user with different appIds + const session1 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app1', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session1) + const session2 = new Session( + { + actor: 'session1', + permission: 'test', + chain: mockChainDefinition, + data: { + appId: 'app2', + }, + walletPlugin: makeWallet(), + }, + mockSessionOptions + ) + await sessionKit.persistSession(session2) + const sessions = await sessionKit.getSessions() + // Ensure the uniqueness rule was applied and both sessions exist + assert.lengthOf(sessions, 2) + assert.equal(sessions[0].data?.appId, 'app1') + assert.equal(sessions[1].data?.appId, 'app2') + }) + }) suite('setEndpoint', function () { test('able to change api endpoint', async function () { // Start with a Session diff --git a/test/tests/session.ts b/test/tests/session.ts index 418e52c..b70bd20 100644 --- a/test/tests/session.ts +++ b/test/tests/session.ts @@ -1,12 +1,20 @@ import {assert} from 'chai' -import SessionKit, {BaseTransactPlugin, ChainDefinition, Session, SessionOptions} from '$lib' +import SessionKit, { + BaseTransactPlugin, + ChainDefinition, + Session, + SessionOptions, + URLEncodedSession, +} from '$lib' import { ABI, ABIDef, + Bytes, FetchProvider, Name, PermissionLevel, + Serializer, Signature, TimePointSec, } from '@wharfkit/antelope' @@ -37,9 +45,18 @@ const mockTransactOptions = { suite('session', function () { let session: Session + let kit: SessionKit setup(function () { // Establish new session before each test - session = new Session(mockSessionArgs, mockSessionOptions) + session = new Session( + { + ...mockSessionArgs, + data: { + field: 'foo', + }, + }, + mockSessionOptions + ) }) nodejsUsage() suite('construct', function () { @@ -535,4 +552,75 @@ suite('session', function () { assert.equal(provider2.url, 'https://wax.greymass.com') }) }) + suite('encoded', function () { + setup(function () { + // Establish new session kit before each test + kit = new SessionKit( + { + appName: 'demo.app', + chains: [ + { + id: '73e4385a2708e6d7048834fbc1079f2fabb17b3c125b146af438971e90716c4d', + url: 'https://jungle4.greymass.com', + }, + ], + ui: new MockUserInterface(), + walletPlugins: [makeWallet()], + }, + { + fetch: mockFetch, // Required for unit tests + storage: new MockStorage(), + } + ) + }) + test('serialized', async function () { + const serialized = session.encode('serialized') + const fromSerialized = await kit.restore(serialized) + if (!fromSerialized) { + throw new Error('Failed to restore session from serialized') + } + assert.equal( + JSON.stringify(serialized), + JSON.stringify(fromSerialized.encode('serialized')) + ) + assert.deepEqual(serialized, fromSerialized.encode('serialized')) + }) + test('json', async function () { + const serialized = session.encode('serialized') + const json = session.encode('json') + const fromJson = await kit.restore(JSON.parse(json)) + if (!fromJson) { + throw new Error('Failed to restore session from json') + } + assert.equal(JSON.stringify(serialized), JSON.stringify(fromJson.encode('serialized'))) + assert.deepEqual(serialized, fromJson.encode('serialized')) + }) + test('encoded', async function () { + const serialized = session.encode('serialized') + const encoded = session.encode('encoded') + const fromEncoded = await kit.restore(encoded.serialized) + if (!fromEncoded) { + throw new Error('Failed to restore session from encoded') + } + assert.equal( + JSON.stringify(serialized), + JSON.stringify(fromEncoded.encode('serialized')) + ) + assert.deepEqual(serialized, fromEncoded.encode('serialized')) + }) + test('url', async function () { + const serialized = session.encode('serialized') + const url = session.encode('url') + const reconstructed = Serializer.decode({ + data: Bytes.from(url, 'hex'), + type: URLEncodedSession, + }) + const fromUrl = await kit.restore(reconstructed.serialized) + if (!fromUrl) { + throw new Error('Failed to restore session from url') + } + assert.equal(JSON.stringify(serialized), JSON.stringify(fromUrl.encode('serialized'))) + assert.deepEqual(serialized, fromUrl.encode('serialized')) + }) + }) })