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
11 changes: 9 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ Both middlewares expect an `Authorization: Bearer <token>` header.
- `Notification.mail(options?)` / `Notification.email(options?)` — create a mail notification driver.
- `Notification.sms(options?)` — create an SMS notification driver.
- `Notification.db()` — create a database notification driver.
- `Notification.realtime(options?)` — create a realtime (Pusher/Firebase) broadcast driver.
- `Notification.realtime(options?)` — create a realtime (Pusher/Firebase) broadcast driver. Pass `driverFactory` to supply your own `RealtimeDriver` instead; `broadcast()` accepts non-notification payloads too.
- `Notification.channel(channel?, options?)` — create a driver from a channel or `notifications.default_driver`.
- `new Notification(channel, options?).prepare(recipient, data?)` — prepare a driver using a user-like recipient or direct address.

Expand All @@ -236,6 +236,12 @@ Broadcasts a notification to connected clients over Pusher Channels or Firebase
- `.channel(name | names[])` / `.event(name)` — override the channel(s) and the client event name (default `notification`). An array fans out to multiple Pusher channels, or is treated as Firebase device tokens (multicast, chunked to 500, returning `invalidTokens` to prune).
- `.type(type)` / `.action(text, link)` / `.meta(data)` — build the payload.
- `.store(enabled?)` — also persist the notification (requires a `User` recipient) so clients can load history.
- `.priority(level?)` — `high` (default) exempts the message from Android Doze / App Standby, which otherwise defer a normal-priority FCM data message. Always maps to Android and Web Push, and to APNs once the message is a visible push; see [the iOS note](/guide/notifications#delivery-options).
- `.ttl(seconds)` — how long the message stays worth delivering (FCM's own default is four weeks). `0` means now-or-never. Converted per platform.
- `.collapseKey(key)` — supersede an earlier undelivered message with the same key instead of stacking beside it.
- `.delivery(options)` — merge raw delivery options, including per-platform `android` / `apns` / `webpush` escape hatches.
- `.tag(tag)` — stable identity for what the notification is about; a later notification with the same tag supersedes it on the client. Independent of `collapseKey` (queue-level, four per device).
- `.retract(enabled?)` — send an instruction to remove the notification carrying this tag instead of one to display. Never persisted. Prefer superseding with replacement content where there is any.
- `.send(message, subject?, recipient?, data?)` — broadcast; resolves to `{ channel, event, payload, stored? }`.

### Notification Config
Expand All @@ -244,7 +250,7 @@ Broadcasts a notification to connected clients over Pusher Channels or Firebase
- `notifications.drivers.mail.transport` — mail transport name, usually `smtp`.
- `notifications.drivers.sms.transport` — SMS transport name, `africastalking` or `twilio`.
- `notifications.drivers.db.table` — database notifications table name.
- `notifications.drivers.realtime` — realtime `transport` (`pusher` | `firebase`), `channel_prefix`, `event`, and default `store`.
- `notifications.drivers.realtime` — realtime `transport` (`pusher` | `firebase`), `channel_prefix`, `event`, default `store`, default `delivery` (`priority`, `ttl`, `collapseKey`), and `driverFactory` to supply a transport of your own.
- `notifications.transports.smtp` — SMTP connection options.
- `notifications.transports.africastalking` — AfricasTalking credentials.
- `notifications.transports.twilio` — Twilio credentials.
Expand All @@ -260,6 +266,7 @@ The framework-neutral client for consuming realtime notifications, with React an
- `createRealtime(config)` — create a `RealtimeClient` (config: `transport`, `event`, `channelPrefix`, `pusher`/`firebase`, or a custom `transportFactory`).
- `client.subscribe(channel, handler)` / `client.forUser(userId, handler)` — subscribe; returns an unsubscribe function.
- `client.channelFor(userId)` — the per-user channel name; `client.disconnect()` — tear down the connection.
- `supersede(list, incoming, limit?)` — fold a notification into a list, replacing a same-tag entry in place, removing what a retraction names, and prepending otherwise. Applied for you by both bindings.
- `@arkstack/realtime/react` — `useNotifications(client, channel, { limit? })` → `{ notifications, latest, clear }`.
- `@arkstack/realtime/vue` — `useNotifications(client, channel, { limit? })` → `{ notifications, latest, clear, stop }`.

Expand Down
59 changes: 59 additions & 0 deletions docs/guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,72 @@ await Notification.realtime({ transport: 'firebase' })
.send('You have a new message');
```

### Delivery options

FCM sends a data message at normal priority, and Android's Doze and App Standby may hold it until the next maintenance window. `.priority('high')` exempts it, and pairs naturally with a short TTL — FCM's own default is four weeks:

```ts
await Notification.realtime({ transport: 'firebase' })
.channel(user.deviceTokens)
.priority('high')
.ttl(60)
.collapseKey(`signin:${attemptId}`) // a retry replaces the prompt it repeats
.send('Approve sign-in from Lagos?');
```

Pass TTL in seconds; each platform's unit is converted for you. Set defaults under `drivers.realtime.delivery`, override per send, and reach anything unmapped through `.delivery({ android, apns, webpush })`. Pusher ignores all of it — it pushes over a connection the client already holds open.

On iOS this maps to APNs only for a visible push (`apns-push-type: alert`), since Apple requires background pushes to be low priority. Ringing or full-screen alerts need PushKit, which FCM cannot address at all.

### Superseding a notification

`.tag()` gives a notification a stable identity, so a later one with the same tag replaces it on the client instead of stacking beside it:

```ts
// asked
await Notification.realtime({ transport: 'firebase' })
.channel(tokens)
.tag(`signin:${attemptId}`)
.collapseKey(`signin:${attemptId}`)
.priority('high')
.ttl(60)
.send('Approve sign-in from Lagos?');

// answered elsewhere
await Notification.realtime({ transport: 'firebase' })
.channel(tokens)
.tag(`signin:${attemptId}`)
.collapseKey(`signin:${attemptId}`)
.send('Sign-in approved');
```

Tag by the thing it is about (`order:42`), not the message. `tag` supersedes what was **displayed**; `collapseKey` supersedes what is still **queued** — set both when you want both. They stay separate because FCM allows only four collapse keys per device, so deriving one per tag would starve that budget.

`.retract()` removes a notification with nothing in its place. Prefer a replacement where the outcome has content of its own: a retraction must travel as a silent push, which platforms throttle hardest. Retractions are never stored, and retracting does not touch a row already written by `.store()`.

The React and Vue bindings apply this for you; for your own list, use the same reducer:

```ts
import { supersede } from '@arkstack/realtime';

client.subscribe(channel, (n) => setItems((items) => supersede(items, n, 50)));
```

Configure the transport in `src/config/notifications.ts` (`drivers.realtime`) and its credentials under `transports.pusher` / `transports.firebase`. The `pusher` / `firebase-admin` SDKs are optional, install only the one you use:

```sh
pnpm add pusher # Pusher transport
pnpm add firebase-admin # Firebase transport
```

**Bringing your own transport.** `driverFactory` replaces the built-ins with anything implementing `RealtimeDriver` — a backend this package does not ship, or a fake in a test. Set it per send, or under `drivers.realtime` for the whole app; it takes precedence over `transport`. Do connection setup lazily inside the driver, as the bundled ones do.

```ts
Notification.realtime({ driverFactory: () => new MyApnsDriver() });
```

`broadcast()` also accepts payloads that are not notifications, so an application can push its own event shapes over the same channel and pick them up with the client's `listen()`.

**Firebase credentials** can be provided two ways. Point `admin_sdk_path` (`FIREBASE_ADMINSDK`, default `firebase-adminsdk.json`, resolved from the project root) at a downloaded service-account JSON file; if that file is absent, the driver falls back to the inline `project_id` / `client_email` / `private_key` values (`FIREBASE_PROJECT_ID` / `FIREBASE_CLIENT_EMAIL` / `FIREBASE_PRIVATE_KEY`). `app_name` (`FIREBASE_APP_NAME`, default your `APP_NAME`) names the Firebase Admin app instance so repeated broadcasts reuse it.

### Consuming on the client
Expand Down
62 changes: 31 additions & 31 deletions packages/auth/src/Auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hash, appKey, env, getModel } from '@arkstack/common'
import { Concrete, Hash, appKey, env, getModel } from '@arkstack/common'
import { JWTPayload, SignJWT, jwtVerify } from 'jose'

import { AuthContract } from './Contracts/AuthContract'
Expand Down Expand Up @@ -33,7 +33,7 @@ export class Auth extends AuthContract {
* @param secret The secret key used for signing and verifying JWTs.
* @returns A new instance of the Auth class.
*/
static make (secret?: string) {
static make(secret?: string) {
return new Auth(secret)
}

Expand All @@ -43,7 +43,7 @@ export class Auth extends AuthContract {
* @param req The HTTP request instance to be set.
* @returns The Auth class itself for method chaining.
*/
static setRequest (req: Request<User> | RequestSource<User>) {
static setRequest(req: Request<User> | RequestSource<User>) {
this.req = Request.from<User>(req)

return this
Expand All @@ -55,7 +55,7 @@ export class Auth extends AuthContract {
* @param req The HTTP request instance to be set.
* @returns The Auth instance itself for method chaining.
*/
setRequest (req: Request<User> | RequestSource<User>) {
setRequest(req: Request<User> | RequestSource<User>) {
Auth.req ??= Request.from<User>(req)

return this
Expand All @@ -67,7 +67,7 @@ export class Auth extends AuthContract {
*
* @returns The current HTTP request instance or undefined if not set.
*/
getRequest (): Request<User> | undefined {
getRequest(): Request<User> | undefined {
return Auth.req
}

Expand All @@ -76,7 +76,7 @@ export class Auth extends AuthContract {
*
* @returns The currently authenticated user or null if not authenticated.
*/
user (): User | null {
user(): User | null {
return this.#user
}

Expand All @@ -87,8 +87,8 @@ export class Auth extends AuthContract {
* @param password The password of the user.
* @returns A boolean indicating whether the credentials are valid.
*/
async verify (email: string, password: string): Promise<boolean> {
const user = await (await getModel<typeof User>('User')).query().where({ email }).first()
async verify(email: string, password: string): Promise<boolean> {
const user = await (getModel<Concrete<typeof User>>('User')).query().where({ email }).first()

return !!user && await Hash.verify(password, user.password)
}
Expand All @@ -100,8 +100,8 @@ export class Auth extends AuthContract {
* @param password
* @returns
*/
async attempt (email: string, password: string): Promise<User> {
const user = await (await getModel<typeof User>('User')).query().where({ email }).first()
async attempt(email: string, password: string): Promise<User> {
const user = await (getModel<Concrete<typeof User>>('User')).query().where({ email }).first()

if (!user) {
throw new AuthenticationException('User account not found', { req: Auth.req, status: 422, errors: { email: ['No account found for this email address'] } })
Expand All @@ -125,7 +125,7 @@ export class Auth extends AuthContract {
* @param password
* @returns
*/
async login (email: string, password: string): Promise<PersonalAccessToken> {
async login(email: string, password: string): Promise<PersonalAccessToken> {
const user = await this.attempt(email, password)

return await this.create(user)
Expand All @@ -140,7 +140,7 @@ export class Auth extends AuthContract {
* @param expiresIn
* @returns
*/
async createTemporaryToken (user: User, purpose: string, expiresIn: string = '10m'): Promise<string> {
async createTemporaryToken(user: User, purpose: string, expiresIn: string = '10m'): Promise<string> {
return await this.createJWT({
sub: user.id.toString(),
email: user.email,
Expand All @@ -156,7 +156,7 @@ export class Auth extends AuthContract {
* @param purpose
* @returns
*/
async authorizeTemporaryToken (token: string, purpose: string): Promise<User> {
async authorizeTemporaryToken(token: string, purpose: string): Promise<User> {
const payload = await this.verifyJWT(token)

if (!payload || payload.purpose !== purpose || !payload.sub) {
Expand All @@ -166,7 +166,7 @@ export class Auth extends AuthContract {
)
}

const user = await (await getModel<typeof User>('User')).query().find(payload.sub)
const user = await (getModel<Concrete<typeof User>>('User')).query().find(payload.sub)

if (!user) {
throw new AuthenticationException(
Expand All @@ -186,21 +186,21 @@ export class Auth extends AuthContract {
* @param token
* @returns
*/
async logout (token?: string | PersonalAccessToken): Promise<void> {
async logout(token?: string | PersonalAccessToken): Promise<void> {
if (!this.#user && !token) {
return
}

if (token) {
if (typeof token === 'string') {
const TokenModel = await getModel<typeof PersonalAccessToken>('PersonalAccessToken')
const TokenModel = getModel<Concrete<typeof PersonalAccessToken>>('PersonalAccessToken')

await TokenModel.query().where({ token }).delete()
} else {
await token.delete()
}
} else {
const TokenModel = await getModel<typeof PersonalAccessToken>('PersonalAccessToken')
const TokenModel = getModel<Concrete<typeof PersonalAccessToken>>('PersonalAccessToken')

await TokenModel.query().where({ userId: this.#user!.id }).delete()
}
Expand All @@ -217,7 +217,7 @@ export class Auth extends AuthContract {
*
* @returns
*/
async check (): Promise<boolean> {
async check(): Promise<boolean> {
return !!this.#user
}

Expand All @@ -226,7 +226,7 @@ export class Auth extends AuthContract {
*
* @returns
*/
session () {
session() {
return new AuthSession(this)
}

Expand All @@ -236,7 +236,7 @@ export class Auth extends AuthContract {
* @param user
* @returns
*/
async create (user: User): Promise<PersonalAccessToken> {
async create(user: User): Promise<PersonalAccessToken> {
const payload: JWTPayload = {
sub: user.id.toString(),
email: user.email,
Expand All @@ -261,8 +261,8 @@ export class Auth extends AuthContract {
* @param token The new bearer token to persist.
* @param deviceInfo The current request's device information.
*/
private async upsertDeviceToken (user: User, token: string, deviceInfo: Record<string, unknown> | null) {
const TokenModel = await getModel<typeof PersonalAccessToken>('PersonalAccessToken')
private async upsertDeviceToken(user: User, token: string, deviceInfo: Record<string, unknown> | null) {
const TokenModel = getModel<Concrete<typeof PersonalAccessToken>>('PersonalAccessToken')
const deviceKey = SessionDevice.getUniqueKey(deviceInfo)
const payload = {
abilities: [],
Expand Down Expand Up @@ -322,7 +322,7 @@ export class Auth extends AuthContract {
* @param token
* @returns
*/
async authorizeToken (token: string): Promise<User> {
async authorizeToken(token: string): Promise<User> {
const payload = await this.verifyJWT(token)

if (!payload) {
Expand All @@ -332,7 +332,7 @@ export class Auth extends AuthContract {
)
}

const TokenModel = await getModel<typeof PersonalAccessToken>('PersonalAccessToken')
const TokenModel = getModel<Concrete<typeof PersonalAccessToken>>('PersonalAccessToken')
const pat = await TokenModel.query().where({ token }).first()

if (!pat) {
Expand All @@ -342,7 +342,7 @@ export class Auth extends AuthContract {
)
}

const user = await (await getModel<typeof User>('User')).query().find(payload.sub!)
const user = await (getModel<Concrete<typeof User>>('User')).query().find(payload.sub!)

if (!user) {
throw new AuthenticationException(
Expand All @@ -368,7 +368,7 @@ export class Auth extends AuthContract {
* @param payload
* @returns
*/
private async createJWT (payload: JWTPayload, expiresIn: string = env('JWT_EXPIRES_IN', '1h')): Promise<string> {
private async createJWT(payload: JWTPayload, expiresIn: string = env('JWT_EXPIRES_IN', '1h')): Promise<string> {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
Expand All @@ -384,7 +384,7 @@ export class Auth extends AuthContract {
* @param token
* @returns
*/
private async verifyJWT (token: string): Promise<JWTPayload | null> {
private async verifyJWT(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(this.getSecret()))

Expand All @@ -394,13 +394,13 @@ export class Auth extends AuthContract {
}
}

private getSecret (): string {
private getSecret(): string {
// Explicitly configured secret wins, then the unified APP_KEY with a
// backward-compatible fallback to the legacy JWT_SECRET variable.
return this.configuredSecret ?? appKey('JWT_SECRET') ?? 'default_secret'
}

private setAuthenticated (user: User, token?: string) {
private setAuthenticated(user: User, token?: string) {
this.#user = user
Auth.req?.setAuthentication(this, user, token)
}
Expand All @@ -412,7 +412,7 @@ export class Auth extends AuthContract {
* @param pat The personal access token to update.
* @returns A promise that resolves when the update is complete.
*/
private async touchSession (pat: PersonalAccessToken) {
private async touchSession(pat: PersonalAccessToken) {
const now = new Date()
const currentDeviceInfo = SessionDevice.fromRequest(Auth.req)
const shouldUpdateLastUsedAt = !pat.lastUsedAt || (now.getTime() - pat.lastUsedAt.getTime()) > 5 * 60 * 1000
Expand All @@ -438,7 +438,7 @@ export class Auth extends AuthContract {
payload.name = currentDisplayName
}

const TokenModel = await getModel<typeof PersonalAccessToken>('PersonalAccessToken')
const TokenModel = getModel<Concrete<typeof PersonalAccessToken>>('PersonalAccessToken')

await TokenModel.query().where({ id: pat.id }).update(payload)

Expand Down
Loading
Loading