Skip to content

The in-app store

The database channel persists notifications and powers the in-app notification center — unread badges, a dropdown list, mark-as-read. Three pieces: a model, a repository, and the inApp facade.

The read API — recipient-scoped by construction

Section titled “The read API — recipient-scoped by construction”
import { inApp } from "@warlock.js/notifications";
await inApp.list(user, { type: "order.shipped" }); // all — optional filter + paging (page / limit)
await inApp.listUnread(user); // unread only
await inApp.countUnread(user); // cached — backs the badge
await inApp.find(user, "ntf_123"); // one — for a detail view
await inApp.markAsRead(user, "ntf_123"); // one
await inApp.markAsRead(user); // all unread for this user
await inApp.markAsUnread(user, "ntf_123");
await inApp.dismiss(user, "ntf_123"); // delete one
await inApp.dismiss(user); // clear all for this user

Every method forces the recipient id into the query. markAsRead(user, id) with an id belonging to a different user matches zero rows — IDOR-safe by construction, not by a guard you might forget. All methods accept a cascade model or a raw id.

The write side is IDOR-safe too (since 4.15.0)

Section titled “The write side is IDOR-safe too (since 4.15.0)”

createFor(recipientId, input, tenantId?) treats recipientId as the trusted argument and applies it after input — server-owned keys (id, recipientId, recipientType, tenant, readAt, isRead, and their resolved columnMap physical column names) are stripped from input before the row is built. Before this fix, an input payload assembled from untyped data (request JSON, a channel’s send args) that happened to carry a recipientId key — or the model’s physical recipient column name — could redirect the write into another recipient’s inbox. createManyFor inherits the same protection. type / title / body / payload / idempotencyKey pass through unchanged — you don’t need to sanitize input yourself.

Recipient types — identities that share an id

Section titled “Recipient types — identities that share an id”

A notification belongs to a recipient type and id, not an id alone. With several identity models (staff, customers, platform users) that each count ids from 1, staff 5 and customer 5 are different recipients and must never read each other’s notifications.

Declare the type column in the model’s columnMap and every read, count, mark-read, mark-unread, and delete filters on type and id:

public static columnMap: NotificationColumnMap = {
recipientType: "recipient_type",
readAt: "read_at",
};

The type comes from the recipient model: an auth model’s userType (the same name used in the auth config), otherwise the model’s table name. The database channel writes it for you.

await inApp.listUnread(staffUser); // type "staff", id 5
await inApp.listUnread({ type: "customer", id: 5 }); // by reference, when you only have the id
await inApp.listUnread(5); // throws RecipientTypeRequiredError

When the table stores a type, a bare id is rejected with RecipientTypeRequiredError rather than guessed: an id alone cannot say which identity it belongs to. Pass the model or { type, id }.

Without recipientType in the columnMap, nothing changes: ids are the only key and bare ids are accepted. That is correct only for an app with a single identity type, so any app with more than one should declare the column.

  1. Add the column in a migration (nullable first, so existing rows survive):

    import { Migration, string } from "@warlock.js/cascade";
    import { Notification } from "../notification.model";
    export default Migration.alter(Notification, {
    add: { recipient_type: string().index().nullable() },
    });
  2. Backfill existing rows with the type that owned them, for example UPDATE notifications SET recipient_type = 'staff'. With several identity types the old rows are already ambiguous: assign them from whatever you know about each row, or delete the ones you cannot attribute.

  3. Add recipientType: "recipient_type" to the model’s columnMap (and recipient_type to the model schema).

Rows whose type is still empty match no typed read, so they stop appearing until backfilled; this is deliberate, since showing them would reintroduce the cross-type leak. New tables get the column from notificationColumns(Notification) once the columnMap declares it.

Extend the shipped base and declare your physical columns once via columnMap — the accessors are the stable contract, the column names are yours. The schema mirrors the migration columns; cascade validates + casts every write against it:

import { v } from "@warlock.js/seal";
import { DatabaseNotification, type NotificationColumnMap } from "@warlock.js/notifications";
const notificationSchema = v.object({
user_id: v.string(),
type: v.string(),
title: v.string(),
body: v.string().nullish(),
payload: v.record(v.any()).nullish(),
read_at: v.date().nullish(),
idempotency_key: v.string().nullish(),
});
@RegisterModel()
export class Notification extends DatabaseNotification {
public static table = "notifications";
public static schema = notificationSchema;
// Single-tenant, read_at-only. The accessors, repo, and migration all follow this.
public static columnMap: NotificationColumnMap = { readAt: "read_at" };
}

DatabaseNotification exposes recipientId / tenantId / isRead / readAt / markRead() — all derived from columnMap. You rarely touch the class directly; the read/write API is the inApp facade.

type NotificationColumnMap = {
recipient?: string; // recipient FK. Default "user_id"
recipientType?: string; // recipient identity type column. Omit → ids are the only key
tenant?: string; // multi-tenant scope, written from the recipient. Omit → single-tenant
readAt?: string; // read-timestamp column
isRead?: string; // read-flag column (indexed)
};

Which read-state keys are present chooses the representation:

  • readAt only → unread is read_at IS NULL; marking read stamps it.
  • isRead only → unread is is_read = false; no timestamp.
  • both → is_read is the indexed filter flag, read_at records when.

Declare neither and you get the readAt-only default. For multi-tenant, add tenant: "organization_id" — the database channel reads that column off the recipient and writes it on every row, no custom channel needed.

The package ships a concrete BaseNotificationsRepository; inApp.configure({ model }) instantiates it for you. Its constructor resolves the model’s columnMap and builds both sides from it:

  • the read filter (filterBy) — logical keys (recipientId, unread, type, …) mapped to your columns;
  • the write / update / delete mapping used by createFor / markRead / dismiss.

Because both come from one columnMap, reads and writes can never disagree on a column name. The unread filter is mode-agnostic — it resolves to is_read = false or read_at IS NULL depending on your map.

Subclass the repository only for extra query methods — column naming already lives on the model:

extra methods only
class NotificationsRepository extends BaseNotificationsRepository<Notification> {
public source = Notification; // its columnMap still drives the filter + write mapping
// …app-specific finders
}
inApp.configure({ repository: new NotificationsRepository() });

Pass idempotencyKey and createFor does find-or-create — a retried send returns the existing row instead of inserting a duplicate. A unique constraint on the column is the race backstop (the repo re-fetches on conflict).

await orderShipped.send(user, { order }, { idempotencyKey: `ship:${order.id}` });