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
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export { configure } from './configure.js'
export { Session } from './src/session.js'
export { stubsRoot } from './stubs/main.js'
export { defineConfig, stores } from './src/define_config.js'
export { SessionCollection } from './src/session_collection.js'
export { ReadOnlyValuesStore, ValuesStore } from './src/values_store.js'
37 changes: 24 additions & 13 deletions providers/session_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { ApplicationService } from '@adonisjs/core/types'

import type { Session } from '../src/session.js'
import SessionMiddleware from '../src/session_middleware.js'
import { SessionCollection } from '../src/session_collection.js'

/**
* Events emitted by the session class
Expand Down Expand Up @@ -45,25 +46,35 @@ export default class SessionProvider {
}

/**
* Registering muddleware
* Resolves the session config from the config provider
*/
register() {
this.app.container.singleton(SessionMiddleware, async (resolver) => {
const sessionConfigProvider = this.app.config.get('session', {})
async #resolveConfig() {
const sessionConfigProvider = this.app.config.get('session', {})
const config = await configProvider.resolve<any>(this.app, sessionConfigProvider)

if (!config) {
throw new RuntimeException(
'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method'
)
}

/**
* Resolve config from the provider
*/
const config = await configProvider.resolve<any>(this.app, sessionConfigProvider)
if (!config) {
throw new RuntimeException(
'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method'
)
}
return config
}

/**
* Registering bindings
*/
register() {
this.app.container.singleton(SessionMiddleware, async (resolver) => {
const config = await this.#resolveConfig()
const emitter = await resolver.make('emitter')
return new SessionMiddleware(config, emitter)
})

this.app.container.singleton(SessionCollection, async () => {
const config = await this.#resolveConfig()
return new SessionCollection(config)
})
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@ export const E_SESSION_NOT_READY = createError(
'E_SESSION_NOT_READY',
500
)

/**
* Raised when trying to use tagging with a store that
* doesn't support tagging operations
*/
export const E_SESSION_TAGGING_NOT_SUPPORTED = createError(
'Session store does not support tagging operations. Only Redis, Database and Memory stores support tagging.',
'E_SESSION_TAGGING_NOT_SUPPORTED',
500
)
15 changes: 14 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
SessionStoreFactory,
AllowedSessionValues,
SessionStoreContract,
SessionStoreWithTaggingContract,
} from './types.js'

/**
Expand All @@ -34,7 +35,7 @@ import type {
* uses a centralized persistence store and
*/
export class Session extends Macroable {
#store: SessionStoreContract
#store: SessionStoreContract | SessionStoreWithTaggingContract
#emitter: EmitterService
#ctx: HttpContext
#readonly: boolean = false
Expand Down Expand Up @@ -421,6 +422,18 @@ export class Session extends Macroable {
this.#sessionId = cuid()
}

/**
* Tag the current session with a user ID. This allows you to
* later retrieve all sessions for a given user via SessionCollection.
*
* Only Memory, Redis and Database stores support tagging. Other stores
* will throw an error.
*/
async tag(userId: string): Promise<void> {
if (!('tag' in this.#store)) throw new errors.E_SESSION_TAGGING_NOT_SUPPORTED()
await this.#store.tag(this.#sessionId, userId)
}

/**
* Commit session changes. No more mutations will be
* allowed after commit.
Expand Down
91 changes: 91 additions & 0 deletions src/session_collection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* @adonisjs/session
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import debug from './debug.js'
import { E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.js'
import type {
ResolvedSessionConfig,
SessionData,
SessionStoreWithTaggingContract,
TaggedSession,
} from './types.js'

/**
* SessionCollection provides APIs for programmatic session
* management. It allows reading, destroying, and tagging
* sessions without an HTTP context.
*
* @example
* ```ts
* import app from '@adonisjs/core/services/app'
* import { SessionCollection } from '@adonisjs/session'
*
* const sessionCollection = await app.container.make(SessionCollection)
*
* // List all sessions for a user
* const sessions = await sessionCollection.tagged(String(user.id))
*
* // Destroy a specific session
* await sessionCollection.destroy(sessionId)
* ```
*/
export class SessionCollection {
#store: SessionStoreWithTaggingContract

constructor(config: ResolvedSessionConfig) {
const storeFactory = config.stores[config.store]
this.#store = storeFactory(null as any, config) as SessionStoreWithTaggingContract
}

/**
* Check if the current store supports tagging
*/
supportsTagging(): boolean {
return 'tag' in this.#store && 'tagged' in this.#store
}

/**
* Returns the session data for the given session ID,
* or null if the session does not exist
*/
async get(sessionId: string): Promise<SessionData | null> {
debug('session collection: getting session data %s', sessionId)
return this.#store.read(sessionId)
}

/**
* Destroys a session by its ID
*/
async destroy(sessionId: string): Promise<void> {
debug('session collection: destroying session %s', sessionId)
return this.#store.destroy(sessionId)
}

/**
* Tag a session with a user ID.
* Only supported by Memory, Redis and Database stores.
*/
async tag(sessionId: string, userId: string): Promise<void> {
debug('session collection: tagging session %s with user %s', sessionId, userId)
if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED()

return (this.#store as SessionStoreWithTaggingContract).tag(sessionId, userId)
}

/**
* Get all sessions for a given user ID (tag).
* Only supported by Memory, Redis and Database stores.
*/
async tagged(userId: string): Promise<TaggedSession[]> {
debug('session collection: getting sessions tagged with user %s', userId)
if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED()

return (this.#store as SessionStoreWithTaggingContract).tagged(userId)
}
}
58 changes: 48 additions & 10 deletions src/stores/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import { MessageBuilder } from '@adonisjs/core/helpers'
import type { QueryClientContract } from '@adonisjs/lucid/types/database'

import debug from '../debug.js'
import type { SessionStoreContract, SessionData } from '../types.js'
import type { SessionStoreWithTaggingContract, SessionData, TaggedSession } from '../types.js'

/**
* Database store to read/write session to SQL databases using Lucid
*/
export class DatabaseStore implements SessionStoreContract {
export class DatabaseStore implements SessionStoreWithTaggingContract {
#client: QueryClientContract
#tableName: string
#ttlSeconds: number
Expand Down Expand Up @@ -67,6 +67,17 @@ export class DatabaseStore implements SessionStoreContract {
}
}

/**
* Parses and verifies session data using MessageBuilder
*/
#parseSessionData(contents: string, sessionId: string): SessionData | null {
try {
return new MessageBuilder().verify<SessionData>(contents, sessionId)
} catch {
return null
}
}

/**
* Returns session data
*/
Expand All @@ -88,14 +99,7 @@ export class DatabaseStore implements SessionStoreContract {
return null
}

/**
* Verify contents with the session id and return them as an object
*/
try {
return new MessageBuilder().verify<SessionData>(row.data, sessionId)
} catch {
return null
}
return this.#parseSessionData(row.data, sessionId)
}

/**
Expand Down Expand Up @@ -143,4 +147,38 @@ export class DatabaseStore implements SessionStoreContract {
.where('id', sessionId)
.update({ expires_at: expiresAt })
}

/**
* Tag a session with a user ID
*/
async tag(sessionId: string, userId: string): Promise<void> {
debug('database store: tagging session %s with user %s', sessionId, userId)

await this.#client.from(this.#tableName).where('id', sessionId).update({ user_id: userId })
}

/**
* Converts a database row to a TaggedSession object
*/
#rowToTaggedSession(row: { id: string; data: string }): TaggedSession | null {
const data = this.#parseSessionData(row.data, row.id)
if (!data) return null

return { id: row.id, data }
}

/**
* Get all sessions for a given user ID (tag)
*/
async tagged(userId: string): Promise<TaggedSession[]> {
debug('database store: getting sessions tagged with user %s', userId)

const rows = await this.#client
.from(this.#tableName)
.select('id', 'data')
.where('user_id', userId)
.where('expires_at', '>', new Date())

return rows.map((row) => this.#rowToTaggedSession(row)).filter((session) => session !== null)
}
}
31 changes: 29 additions & 2 deletions src/stores/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@
* file that was distributed with this source code.
*/

import type { SessionData, SessionStoreContract } from '../types.js'
import type { SessionData, SessionStoreWithTaggingContract, TaggedSession } from '../types.js'

/**
* Memory store is meant to be used for writing tests.
*/
export class MemoryStore implements SessionStoreContract {
export class MemoryStore implements SessionStoreWithTaggingContract {
static sessions: Map<string, SessionData> = new Map()

/**
* Maps session IDs to user IDs (for tagging)
*/
static tags: Map<string, string> = new Map()

/**
* Read session id value from the memory
*/
Expand All @@ -34,7 +39,29 @@ export class MemoryStore implements SessionStoreContract {
*/
destroy(sessionId: string): void {
MemoryStore.sessions.delete(sessionId)
MemoryStore.tags.delete(sessionId)
}

touch(): void {}

/**
* Tag a session with a user ID
*/
async tag(sessionId: string, userId: string): Promise<void> {
MemoryStore.tags.set(sessionId, userId)
}

/**
* Get all sessions for a given user ID (tag)
*/
async tagged(userId: string): Promise<TaggedSession[]> {
const sessions: TaggedSession[] = []

for (const [sessionId, taggedUserId] of MemoryStore.tags) {
const data = MemoryStore.sessions.get(sessionId)
if (taggedUserId === userId && data) sessions.push({ id: sessionId, data })
}

return sessions
}
}
Loading