diff --git a/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts b/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts
new file mode 100644
index 000000000..52c513913
--- /dev/null
+++ b/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts
@@ -0,0 +1,426 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { v4 as uuidv4 } from 'uuid';
+import { makeActor, type Actor } from '../../core/actor.js';
+import { runWithContext } from '../../core/context.js';
+import { PuterServer } from '../../server.js';
+import { findNotificationType } from '../../services/notification/notificationTypes.js';
+import { setupTestServer } from '../../testUtil.js';
+import type { IConfig } from '../../types.js';
+import type { NotificationDriver } from './NotificationDriver.js';
+
+const makeUser = async (server: PuterServer): Promise => {
+ const username = `am-${Math.random().toString(36).slice(2, 10)}`;
+ const created = await server.stores.user.create({
+ username,
+ uuid: uuidv4(),
+ password: null,
+ email: `${username}@test.local`,
+ free_storage: 100 * 1024 * 1024,
+ requires_email_confirmation: false,
+ });
+ const user = (await server.stores.user.getById(created.id))!;
+ return makeActor({
+ user: {
+ id: user.id,
+ uuid: user.uuid,
+ username: user.username,
+ email: user.email ?? null,
+ email_confirmed: true,
+ } as Actor['user'],
+ });
+};
+
+const makeApp = async (
+ server: PuterServer,
+ ownerUserId: number,
+): Promise<{ id: number; uid: string }> => {
+ const name = `am-app-${Math.random().toString(36).slice(2, 10)}`;
+ return (await server.stores.app.create(
+ {
+ name,
+ title: 'App message test',
+ index_url: `https://${name}.test/`,
+ },
+ { ownerUserId },
+ )) as { id: number; uid: string };
+};
+
+const asApp = (holder: Actor, app: { id: number; uid: string }): Actor =>
+ makeActor({ user: holder.user, app });
+
+/** A worker acts as its user, with its app as the app. */
+const asWorker = (holder: Actor, app: { id: number; uid: string }): Actor =>
+ makeActor({
+ user: holder.user,
+ app,
+ session: { uid: `ws-${uuidv4()}`, kind: 'worker' },
+ });
+
+const asIssuedToken = (holder: Actor, issuer: Actor): Actor =>
+ makeActor({
+ user: holder.user,
+ accessToken: { uid: `tok-${uuidv4()}`, issuer, authorized: null },
+ });
+
+const withActor = (actor: Actor, fn: () => Promise): Promise =>
+ runWithContext({ actor }, fn);
+
+const message = (fields: Record = {}) => ({
+ object: { value: { type: 'app.message', title: 'Hello', ...fields } },
+});
+
+/** Every payload the bus carried under `key` while `fn` ran. */
+const captureBus = async (
+ server: PuterServer,
+ key: string,
+ fn: () => Promise,
+): Promise>> => {
+ const seen: Array> = [];
+ const handler = (_k: string, data: unknown) =>
+ seen.push(data as Record);
+ server.clients.event.on(key, handler);
+ try {
+ await fn();
+ // Listeners may run after `emit` returns.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ } finally {
+ server.clients.event.off?.(key, handler);
+ }
+ return seen;
+};
+
+const bootServer = (config: Partial) => {
+ const state = {
+ server: null as unknown as PuterServer,
+ driver: null as unknown as NotificationDriver,
+ };
+ beforeAll(async () => {
+ state.server = await setupTestServer(config as IConfig);
+ state.driver = state.server.drivers
+ .notifications as unknown as NotificationDriver;
+ });
+ afterAll(async () => {
+ await state.server?.shutdown();
+ });
+ return state;
+};
+
+describe('app.message with live delivery over the socket wire', () => {
+ const state = bootServer({
+ notificationAppMessageLimits: { perMinute: 3 },
+ });
+
+ it('stores the message for the acting user, under the app', async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+
+ let created: Record | null = null;
+ const pushed = await captureBus(
+ state.server,
+ 'outer.gui.notif.message',
+ async () => {
+ created = (await withActor(asApp(holder, app), () =>
+ state.driver.create(
+ message({ text: 'Body', channel: 'replies' }),
+ ),
+ )) as Record;
+ },
+ );
+
+ const uid = created!.uid as string;
+ expect(created!.value).toEqual({
+ type: 'app.message',
+ title: 'Hello',
+ text: 'Body',
+ channel: 'replies',
+ priority: 'normal',
+ ttl: 3600,
+ });
+ const row = await state.server.stores.notification.getByUid(uid, {
+ userId: holder.user.id,
+ });
+ expect(row).toMatchObject({
+ user_id: holder.user.id,
+ app_uid: app.uid,
+ audience: 'app-user',
+ type: 'app.message',
+ });
+ // Not silent: it went out on the wire the desktop listens to.
+ expect(pushed).toEqual([
+ {
+ user_id_list: [holder.user.id],
+ response: { uid, notification: created!.value },
+ },
+ ]);
+ });
+
+ it("lets the app's worker and the tokens it issued notify their user", async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+
+ for (const actor of [
+ asWorker(holder, app),
+ asIssuedToken(holder, asApp(holder, app)),
+ ]) {
+ const created = (await withActor(actor, () =>
+ state.driver.create(message()),
+ )) as Record;
+ const row = await state.server.stores.notification.getByUid(
+ created.uid as string,
+ { userId: holder.user.id },
+ );
+ expect(row).toMatchObject({
+ user_id: holder.user.id,
+ app_uid: app.uid,
+ });
+ }
+ });
+
+ it('refuses an app any other type', async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ for (const type of ['share.received', 'app.events.suspended']) {
+ await expect(
+ withActor(asApp(holder, app), () =>
+ state.driver.create({ object: { value: { type } } }),
+ ),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'notification_type_not_allowed',
+ });
+ }
+ });
+
+ it('refuses a recipient or app named by the caller', async () => {
+ const holder = await makeUser(state.server);
+ const other = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ const otherApp = await makeApp(state.server, holder.user.id!);
+
+ for (const extra of [
+ { userId: other.user.id },
+ { recipient: other.user.uuid },
+ { appUid: otherApp.uid },
+ ]) {
+ await expect(
+ withActor(asApp(holder, app), () =>
+ state.driver.create({
+ object: { ...message().object, ...extra },
+ }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400 });
+ }
+ const theirs = await state.server.stores.notification.listByUserId(
+ other.user.id!,
+ {},
+ );
+ expect(theirs).toEqual([]);
+ });
+
+ it('refuses an app message that fails validation, writing nothing', async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ await expect(
+ withActor(asApp(holder, app), () =>
+ state.driver.create(message({ icon: 'http://x.test/a.png' })),
+ ),
+ ).rejects.toMatchObject({ statusCode: 400, fields: { field: 'icon' } });
+ await expect(
+ withActor(asApp(holder, app), () =>
+ state.driver.create({
+ object: { value: { type: 'app.message' } },
+ }),
+ ),
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ fields: { field: 'title' },
+ });
+ expect(
+ await state.server.stores.notification.listByUserId(
+ holder.user.id!,
+ {},
+ ),
+ ).toEqual([]);
+ });
+
+ it('keeps the holder creating other types silently', async () => {
+ const holder = await makeUser(state.server);
+ const pushed = await captureBus(
+ state.server,
+ 'outer.gui.notif.message',
+ () =>
+ withActor(holder, () =>
+ state.driver.create({
+ object: {
+ value: { type: 'share.received', title: 'hi' },
+ },
+ }),
+ ),
+ );
+ expect(pushed).toEqual([]);
+ const rows = await state.server.stores.notification.listByUserId(
+ holder.user.id!,
+ {},
+ );
+ expect(rows.map((r: Record) => r.type)).toEqual([
+ 'share.received',
+ ]);
+ });
+
+ it('refuses an app message from the holder, with or without an app', async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ for (const object of [
+ message().object,
+ { ...message().object, appUid: app.uid },
+ ]) {
+ await expect(
+ withActor(holder, () => state.driver.create({ object })),
+ ).rejects.toMatchObject({
+ statusCode: 403,
+ code: 'notification_type_not_allowed',
+ });
+ }
+ });
+
+ it('limits each app per user per minute', async () => {
+ const holder = await makeUser(state.server);
+ const otherUser = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ const otherApp = await makeApp(state.server, holder.user.id!);
+
+ for (let i = 0; i < 3; i++) {
+ await withActor(asApp(holder, app), () =>
+ state.driver.create(message()),
+ );
+ }
+ // The app's worker spends the same budget as the app.
+ await expect(
+ withActor(asWorker(holder, app), () =>
+ state.driver.create(message()),
+ ),
+ ).rejects.toMatchObject({
+ statusCode: 429,
+ legacyCode: 'too_many_requests',
+ });
+
+ await expect(
+ withActor(asApp(holder, otherApp), () =>
+ state.driver.create(message()),
+ ),
+ ).resolves.toMatchObject({ uid: expect.any(String) });
+ await expect(
+ withActor(asApp(otherUser, app), () =>
+ state.driver.create(message()),
+ ),
+ ).resolves.toMatchObject({ uid: expect.any(String) });
+ });
+
+ it("reads back the app's own messages and no other app's", async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ const otherApp = await makeApp(state.server, holder.user.id!);
+ const created = (await withActor(asApp(holder, app), () =>
+ state.driver.create(message()),
+ )) as Record;
+ await withActor(holder, () =>
+ state.driver.create({
+ object: { value: { type: 'share.received', title: 'acct' } },
+ }),
+ );
+
+ const read = (await withActor(asApp(holder, app), () =>
+ state.driver.read({ uid: created.uid }),
+ )) as Record;
+ expect(read.uid).toBe(created.uid);
+
+ for (const subject of [undefined, 'notif:app-user']) {
+ const listed = (await withActor(asApp(holder, app), () =>
+ state.driver.select({ subject }),
+ )) as Array>;
+ expect(listed.map((n) => n.uid)).toEqual([created.uid]);
+ }
+
+ await expect(
+ withActor(asApp(holder, otherApp), () =>
+ state.driver.read({ uid: created.uid }),
+ ),
+ ).rejects.toMatchObject({ statusCode: 404 });
+ expect(
+ await withActor(asApp(holder, otherApp), () =>
+ state.driver.select({}),
+ ),
+ ).toEqual([]);
+ });
+});
+
+describe('app.message with the events fold-in on', () => {
+ const state = bootServer({
+ events: { enabled: true, notificationsFoldIn: true },
+ notificationAppMessageLimits: { perMinute: 0, perDay: 2 },
+ } as Partial);
+
+ it("emits notif.created on the app's app-user subject", async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ let uid = '';
+ const created = await captureBus(
+ state.server,
+ 'notif.created',
+ async () => {
+ const result = (await withActor(asApp(holder, app), () =>
+ state.driver.create(message()),
+ )) as Record;
+ uid = result.uid as string;
+ },
+ );
+
+ expect(created).toEqual([
+ expect.objectContaining({
+ userId: holder.user.id,
+ uid,
+ type: 'app.message',
+ audience: 'app-user',
+ appUid: app.uid,
+ }),
+ ]);
+ expect(
+ findNotificationType('app.message')!.subject({
+ userUuid: holder.user.uuid!,
+ appUid: app.uid,
+ }),
+ ).toBe(`notif:${app.uid}:app-user`);
+ });
+
+ it('limits each app per user per day, with no minute bound configured', async () => {
+ const holder = await makeUser(state.server);
+ const app = await makeApp(state.server, holder.user.id!);
+ for (let i = 0; i < 2; i++) {
+ await withActor(asApp(holder, app), () =>
+ state.driver.create(message()),
+ );
+ }
+ await expect(
+ withActor(asApp(holder, app), () => state.driver.create(message())),
+ ).rejects.toMatchObject({ statusCode: 429 });
+ });
+});
diff --git a/src/backend/drivers/notification/NotificationDriver.test.ts b/src/backend/drivers/notification/NotificationDriver.test.ts
index e27d8e23b..92a9e8a04 100644
--- a/src/backend/drivers/notification/NotificationDriver.test.ts
+++ b/src/backend/drivers/notification/NotificationDriver.test.ts
@@ -222,7 +222,7 @@ describe('NotificationDriver.create', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
- it('rejects an app-actor with 403', async () => {
+ it('rejects an app creating anything but an app message with 403', async () => {
const { actor } = await makeUser();
const appActor = makeActor({
user: actor.user,
@@ -237,7 +237,7 @@ describe('NotificationDriver.create', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
- it('rejects a token an app issued with 403', async () => {
+ it('rejects a token an app issued creating another type with 403', async () => {
const { actor } = await makeUser();
const issued = asIssuedToken(
actor,
diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts
index 928b1b86c..97e0bc80a 100644
--- a/src/backend/drivers/notification/NotificationDriver.ts
+++ b/src/backend/drivers/notification/NotificationDriver.ts
@@ -23,11 +23,17 @@ import {
type UserActor,
} from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
+import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import { resolveNotifFetch } from '../../services/events/notifFetch.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
+import {
+ APP_MESSAGE_RATE_LIMITS,
+ APP_MESSAGE_TYPE,
+ parseAppMessage,
+} from '../../services/notification/appMessage.js';
import {
APP_READABLE_AUDIENCES,
canViewNotification,
@@ -45,6 +51,8 @@ import type { DriverRateLimitConfig } from '../meta.js';
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
const MAX_SELECT_LIMIT = 200;
+const MINUTE_MS = 60_000;
+const DAY_MS = 24 * 60 * MINUTE_MS;
/** The mailbox slice a call named, in the terms the query takes it. */
interface MailboxScope {
@@ -53,6 +61,12 @@ interface MailboxScope {
appUid: string | null | undefined;
}
+const typeNotAllowed = (message: string): HttpError =>
+ new HttpError(403, message, {
+ legacyCode: 'forbidden',
+ code: 'notification_type_not_allowed',
+ });
+
/** Whether a row belongs to the slice, as the same SQL scope selected it. */
const inScope = (
row: Record,
@@ -82,7 +96,9 @@ const inScope = (
* owns the app. `account` rows never reach an actor holding an app, and no
* grant changes that. A slice an actor may not see comes back empty rather
* than refused — the mailbox is not an oracle.
- * - `create` stays holder-only.
+ * - The holder creates any registered type but `app.message`, silently. An actor
+ * holding an app creates `app.message` only, for its own user, and it is
+ * delivered live.
*
* `read` and `select` also take an optional `subject` naming one slice
* (`notif::`); the two-segment form expands from the actor's
@@ -125,7 +141,7 @@ export class NotificationDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
- const actor = this.#requireUserActor();
+ const actor = this.#requireActor();
const value = (object.value ?? {}) as Record;
const type = value.type;
@@ -145,6 +161,18 @@ export class NotificationDriver extends PuterDriver {
);
}
+ if (actor.effectiveApp) {
+ return this.#createAppMessage(
+ actor,
+ actor.effectiveApp.uid,
+ object,
+ value,
+ );
+ }
+ if (type === APP_MESSAGE_TYPE) {
+ throw typeNotAllowed(`Only apps can create ${APP_MESSAGE_TYPE}`);
+ }
+
const appUid = (object.appUid ?? null) as string | null;
// The registry rejects with a plain Error — unregistered, or a type
// wrong for the audience it names. Both are caller input, so neither
@@ -165,23 +193,11 @@ export class NotificationDriver extends PuterDriver {
// Silent: a driver `create` has never pushed to the live socket, only
// ever written the row — moving through the registry does not change
// that.
- const uid = await this.services.notification.notify(
- [actor.user.id],
- value,
- { type: registeredType, appUid, silent: true },
- );
- if (!uid) {
- throw new HttpError(500, 'Notification could not be created', {
- legacyCode: 'internal_error',
- });
- }
- // `notify` hands back only the uid; the primary has the rest.
- return this.#toClient(
- await this.stores.notification.getByUid(uid, {
- userId: actor.user.id,
- primary: true,
- }),
- );
+ return this.#write(actor, value, {
+ type: registeredType,
+ appUid,
+ silent: true,
+ });
}
async read(args: Record): Promise {
@@ -273,6 +289,98 @@ export class NotificationDriver extends PuterDriver {
return { success: !!ok };
}
+ /**
+ * An app's message to the user it acts for. The recipient and the app are
+ * the actor's, never the caller's to name.
+ */
+ async #createAppMessage(
+ actor: UserActor,
+ appUid: string,
+ object: Record,
+ value: Record,
+ ): Promise {
+ const { type, ...fields } = value;
+ if (type !== APP_MESSAGE_TYPE) {
+ throw typeNotAllowed(`Apps can create only ${APP_MESSAGE_TYPE}`);
+ }
+ for (const key of Object.keys(object)) {
+ if (key !== 'value') {
+ throw new HttpError(
+ 400,
+ `\`${key}\` is set by the server for app notifications`,
+ { legacyCode: 'bad_request', fields: { field: key } },
+ );
+ }
+ }
+ const message = parseAppMessage(fields);
+
+ if (!(await this.#withinAppMessageLimits(actor.user.id, appUid))) {
+ throw new HttpError(429, 'Too many notifications from this app.', {
+ legacyCode: 'too_many_requests',
+ });
+ }
+
+ return this.#write(actor, message, {
+ type: APP_MESSAGE_TYPE,
+ appUid,
+ silent: false,
+ });
+ }
+
+ /** Both windows, each in its own bucket per user and app. */
+ async #withinAppMessageLimits(
+ userId: number,
+ appUid: string,
+ ): Promise {
+ const configured = this.config.notificationAppMessageLimits ?? {};
+ const windows: Array<[string, number, number]> = [
+ [
+ 'minute',
+ configured.perMinute ?? APP_MESSAGE_RATE_LIMITS.perMinute,
+ MINUTE_MS,
+ ],
+ [
+ 'day',
+ configured.perDay ?? APP_MESSAGE_RATE_LIMITS.perDay,
+ DAY_MS,
+ ],
+ ];
+ for (const [name, limit, windowMs] of windows) {
+ if (limit <= 0) continue;
+ const key = `notif:app-message:${name}:${userId}:${appUid}`;
+ if (!(await checkRateLimit(key, limit, windowMs))) return false;
+ }
+ return true;
+ }
+
+ async #write(
+ actor: UserActor,
+ value: Record,
+ opts: {
+ type: NotificationTypeName;
+ appUid: string | null;
+ silent: boolean;
+ },
+ ): Promise {
+ const uid = await this.services.notification.notify(
+ [actor.user.id],
+ value,
+ opts,
+ );
+ if (!uid) {
+ throw new HttpError(500, 'Notification could not be created', {
+ legacyCode: 'internal_error',
+ });
+ }
+ // `notify` hands back only the uid; the primary has the rest.
+ return this.#toClient(
+ await this.stores.notification.getByUid(uid, {
+ userId: actor.user.id,
+ primary: true,
+ }),
+ );
+ }
+
// -- Permissions -------------------------------------------------
#requireActor(): UserActor {
@@ -287,16 +395,6 @@ export class NotificationDriver extends PuterDriver {
return actor as UserActor;
}
- /** Writing a notification stays the holder's, per the token table. */
- #requireUserActor(): UserActor {
- const actor = this.#requireActor();
- if (actor.effectiveApp)
- throw new HttpError(403, 'App actors cannot create notifications', {
- legacyCode: 'forbidden',
- });
- return actor;
- }
-
/**
* The slice a call named, or `null` for the holder's whole mailbox. An
* actor holding an app always has a slice — its own — so `null` only ever
diff --git a/src/backend/services/notification/appMessage.test.ts b/src/backend/services/notification/appMessage.test.ts
new file mode 100644
index 000000000..810c9f8ac
--- /dev/null
+++ b/src/backend/services/notification/appMessage.test.ts
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { describe, expect, it } from 'vitest';
+import { parseAppMessage } from './appMessage.js';
+
+/** The 400 a field failing validation raises. */
+const rejects = (input: Record, field: string) =>
+ expect(() => parseAppMessage(input)).toThrow(
+ expect.objectContaining({
+ statusCode: 400,
+ legacyCode: 'bad_request',
+ fields: { field },
+ }),
+ );
+
+describe('parseAppMessage', () => {
+ it('fills in the defaults around a bare title', () => {
+ expect(parseAppMessage({ title: ' Hello ' })).toEqual({
+ title: 'Hello',
+ priority: 'normal',
+ ttl: 3600,
+ });
+ });
+
+ it('keeps every field of a full message', () => {
+ const input = {
+ title: 'New reply',
+ text: 'not markup, stored as text',
+ icon: 'https://example.com/icon.png',
+ target: '/chat/42?from=notif#latest',
+ data: { thread: 42, tags: ['a'] },
+ group: 'chat:42',
+ channel: 'replies',
+ sensitive: true,
+ priority: 'normal',
+ ttl: 86_400,
+ };
+ expect(parseAppMessage(input)).toEqual(input);
+ });
+
+ it('requires a non-empty string title', () => {
+ rejects({}, 'title');
+ rejects({ title: 7 }, 'title');
+ rejects({ title: ' ' }, 'title');
+ });
+
+ it('caps the title at 120 characters, counting an emoji once', () => {
+ expect(parseAppMessage({ title: '😀'.repeat(120) }).title).toBe(
+ '😀'.repeat(120),
+ );
+ rejects({ title: 'x'.repeat(121) }, 'title');
+ });
+
+ it('caps text at 240 characters', () => {
+ expect(
+ parseAppMessage({ title: 't', text: 'x'.repeat(240) }).text,
+ ).toHaveLength(240);
+ rejects({ title: 't', text: 'x'.repeat(241) }, 'text');
+ rejects({ title: 't', text: 5 }, 'text');
+ });
+
+ it('takes only an https icon', () => {
+ rejects({ title: 't', icon: 'http://example.com/a.png' }, 'icon');
+ rejects({ title: 't', icon: 'data:image/png;base64,AAAA' }, 'icon');
+ rejects({ title: 't', icon: 'javascript:alert(1)' }, 'icon');
+ rejects({ title: 't', icon: '/relative.png' }, 'icon');
+ rejects(
+ { title: 't', icon: `https://example.com/${'x'.repeat(2048)}` },
+ 'icon',
+ );
+ });
+
+ it('takes only a path inside the app as the target', () => {
+ rejects({ title: 't', target: 'chat/42' }, 'target');
+ rejects({ title: 't', target: 'https://example.com/' }, 'target');
+ rejects({ title: 't', target: '//example.com/' }, 'target');
+ rejects({ title: 't', target: '/\\example.com/' }, 'target');
+ rejects({ title: 't', target: '/a b' }, 'target');
+ rejects({ title: 't', target: `/${'x'.repeat(512)}` }, 'target');
+ });
+
+ it('caps serialized data at 1024 bytes', () => {
+ const fits = { blob: 'x'.repeat(1024 - '{"blob":""}'.length) };
+ expect(parseAppMessage({ title: 't', data: fits }).data).toEqual(fits);
+ rejects({ title: 't', data: { blob: 'x'.repeat(1024) } }, 'data');
+ });
+
+ it('limits group to 64 characters of a small charset', () => {
+ expect(
+ parseAppMessage({ title: 't', group: 'Chat_1.thread:2-a' }).group,
+ ).toBe('Chat_1.thread:2-a');
+ rejects({ title: 't', group: 'has space' }, 'group');
+ rejects({ title: 't', group: '' }, 'group');
+ rejects({ title: 't', group: 'x'.repeat(65) }, 'group');
+ });
+
+ it('limits channel to 32 lowercase letters, digits and dashes', () => {
+ expect(
+ parseAppMessage({ title: 't', channel: 'new-reply-2' }).channel,
+ ).toBe('new-reply-2');
+ rejects({ title: 't', channel: 'Replies' }, 'channel');
+ rejects({ title: 't', channel: 'a_b' }, 'channel');
+ rejects({ title: 't', channel: 'x'.repeat(33) }, 'channel');
+ });
+
+ it('takes a boolean for sensitive', () => {
+ rejects({ title: 't', sensitive: 'yes' }, 'sensitive');
+ });
+
+ it('refuses high priority to apps', () => {
+ expect(() => parseAppMessage({ title: 't', priority: 'high' })).toThrow(
+ "'high' is not available to apps",
+ );
+ rejects({ title: 't', priority: 'urgent' }, 'priority');
+ });
+
+ it('bounds ttl to whole seconds within a day', () => {
+ expect(parseAppMessage({ title: 't', ttl: 1 }).ttl).toBe(1);
+ rejects({ title: 't', ttl: 0 }, 'ttl');
+ rejects({ title: 't', ttl: 86_401 }, 'ttl');
+ rejects({ title: 't', ttl: 1.5 }, 'ttl');
+ rejects({ title: 't', ttl: '60' }, 'ttl');
+ });
+
+ it('rejects a field the format does not have', () => {
+ rejects({ title: 't', body: 'x' }, 'body');
+ });
+});
diff --git a/src/backend/services/notification/appMessage.ts b/src/backend/services/notification/appMessage.ts
new file mode 100644
index 000000000..9db0ebf85
--- /dev/null
+++ b/src/backend/services/notification/appMessage.ts
@@ -0,0 +1,244 @@
+/*
+ * Copyright (C) 2024-present Puter Technologies Inc.
+ *
+ * This file is part of Puter.
+ *
+ * Puter is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { HttpError } from '../../core/http/HttpError.js';
+
+/** The one notification type an app may create. */
+export const APP_MESSAGE_TYPE = 'app.message';
+
+export const APP_MESSAGE_TITLE_MAX = 120;
+export const APP_MESSAGE_TEXT_MAX = 240;
+export const APP_MESSAGE_ICON_MAX = 2048;
+export const APP_MESSAGE_TARGET_MAX = 512;
+export const APP_MESSAGE_DATA_MAX_BYTES = 1024;
+export const APP_MESSAGE_GROUP_MAX = 64;
+export const APP_MESSAGE_CHANNEL_MAX = 32;
+export const APP_MESSAGE_TTL_MAX_SECONDS = 24 * 60 * 60;
+export const APP_MESSAGE_TTL_DEFAULT_SECONDS = 60 * 60;
+
+/** Creates one app may make for one user, per window. */
+export const APP_MESSAGE_RATE_LIMITS = {
+ perMinute: 30,
+ perDay: 300,
+} as const;
+
+export type AppMessagePriority = 'normal';
+
+/** An `app.message` value as stored, after normalization. */
+export type AppMessage = {
+ title: string;
+ text?: string;
+ /** An https URL. */
+ icon?: string;
+ /** A path inside the app, opened when the message is followed. */
+ target?: string;
+ data?: unknown;
+ group?: string;
+ channel?: string;
+ sensitive?: boolean;
+ priority: AppMessagePriority;
+ ttl: number;
+};
+
+const FIELDS = new Set([
+ 'title',
+ 'text',
+ 'icon',
+ 'target',
+ 'data',
+ 'group',
+ 'channel',
+ 'sensitive',
+ 'priority',
+ 'ttl',
+]);
+
+const GROUP_PATTERN = /^[A-Za-z0-9._:-]+$/u;
+const CHANNEL_PATTERN = /^[a-z0-9-]+$/u;
+const CONTROL_OR_SPACE = /[\u0000- \u007f\\]/u;
+
+const invalid = (field: string, message: string): HttpError =>
+ new HttpError(400, `\`${field}\` ${message}`, {
+ legacyCode: 'bad_request',
+ fields: { field },
+ });
+
+/** Length in characters rather than UTF-16 units, so an emoji counts once. */
+const charLength = (value: string): number => [...value].length;
+
+const optionalString = (
+ input: Record,
+ field: string,
+ max: number,
+): string | undefined => {
+ const value = input[field];
+ if (value === undefined) return undefined;
+ if (typeof value !== 'string') throw invalid(field, 'must be a string');
+ if (charLength(value) > max) {
+ throw invalid(field, `may not exceed ${max} characters`);
+ }
+ return value;
+};
+
+const parseIcon = (input: Record): string | undefined => {
+ const icon = optionalString(input, 'icon', APP_MESSAGE_ICON_MAX);
+ if (icon === undefined) return undefined;
+ let parsed: URL;
+ try {
+ parsed = new URL(icon);
+ } catch {
+ throw invalid('icon', 'must be an https URL');
+ }
+ if (parsed.protocol !== 'https:') {
+ throw invalid('icon', 'must be an https URL');
+ }
+ return icon;
+};
+
+const parseTarget = (input: Record): string | undefined => {
+ const target = optionalString(input, 'target', APP_MESSAGE_TARGET_MAX);
+ if (target === undefined) return undefined;
+ // `//host` and `/\host` both read as another origin to a browser.
+ if (
+ !target.startsWith('/') ||
+ target.startsWith('//') ||
+ CONTROL_OR_SPACE.test(target)
+ ) {
+ throw invalid('target', "must be a path within the app, like '/a/b'");
+ }
+ return target;
+};
+
+const parsePattern = (
+ input: Record,
+ field: string,
+ max: number,
+ pattern: RegExp,
+ allowed: string,
+): string | undefined => {
+ const value = optionalString(input, field, max);
+ if (value === undefined) return undefined;
+ if (!pattern.test(value)) {
+ throw invalid(field, `may contain only ${allowed}`);
+ }
+ return value;
+};
+
+const parseTtl = (input: Record): number => {
+ const ttl = input.ttl;
+ if (ttl === undefined) return APP_MESSAGE_TTL_DEFAULT_SECONDS;
+ if (
+ typeof ttl !== 'number' ||
+ !Number.isInteger(ttl) ||
+ ttl <= 0 ||
+ ttl > APP_MESSAGE_TTL_MAX_SECONDS
+ ) {
+ throw invalid(
+ 'ttl',
+ `must be a whole number of seconds from 1 to ${APP_MESSAGE_TTL_MAX_SECONDS}`,
+ );
+ }
+ return ttl;
+};
+
+/**
+ * Validate an app's message and return it as stored. Throws a 400 naming the
+ * first field that fails; unknown fields fail too, so the format only grows on
+ * purpose. Text is stored as given and escaped where it is shown.
+ */
+export const parseAppMessage = (input: Record): AppMessage => {
+ for (const key of Object.keys(input)) {
+ if (!FIELDS.has(key)) throw invalid(key, 'is not a known field');
+ }
+
+ const rawTitle = input.title;
+ if (typeof rawTitle !== 'string') {
+ throw invalid('title', 'is required and must be a string');
+ }
+ const title = rawTitle.trim();
+ if (title === '') throw invalid('title', 'may not be empty');
+ if (charLength(title) > APP_MESSAGE_TITLE_MAX) {
+ throw invalid(
+ 'title',
+ `may not exceed ${APP_MESSAGE_TITLE_MAX} characters`,
+ );
+ }
+
+ const message: AppMessage = {
+ title,
+ priority: 'normal',
+ ttl: parseTtl(input),
+ };
+
+ const text = optionalString(input, 'text', APP_MESSAGE_TEXT_MAX);
+ if (text !== undefined) message.text = text;
+ const icon = parseIcon(input);
+ if (icon !== undefined) message.icon = icon;
+ const target = parseTarget(input);
+ if (target !== undefined) message.target = target;
+
+ if (input.data !== undefined) {
+ const serialized = JSON.stringify(input.data);
+ if (
+ serialized === undefined ||
+ Buffer.byteLength(serialized, 'utf8') > APP_MESSAGE_DATA_MAX_BYTES
+ ) {
+ throw invalid(
+ 'data',
+ `must be JSON of at most ${APP_MESSAGE_DATA_MAX_BYTES} bytes`,
+ );
+ }
+ message.data = input.data;
+ }
+
+ const group = parsePattern(
+ input,
+ 'group',
+ APP_MESSAGE_GROUP_MAX,
+ GROUP_PATTERN,
+ 'letters, digits and . _ : -',
+ );
+ if (group !== undefined) message.group = group;
+ const channel = parsePattern(
+ input,
+ 'channel',
+ APP_MESSAGE_CHANNEL_MAX,
+ CHANNEL_PATTERN,
+ 'lowercase letters, digits and -',
+ );
+ if (channel !== undefined) message.channel = channel;
+
+ if (input.sensitive !== undefined) {
+ if (typeof input.sensitive !== 'boolean') {
+ throw invalid('sensitive', 'must be a boolean');
+ }
+ message.sensitive = input.sensitive;
+ }
+
+ if (input.priority !== undefined && input.priority !== 'normal') {
+ throw invalid(
+ 'priority',
+ input.priority === 'high'
+ ? "'high' is not available to apps; use 'normal'"
+ : "must be 'normal'",
+ );
+ }
+
+ return message;
+};
diff --git a/src/backend/services/notification/notificationTypes.test.ts b/src/backend/services/notification/notificationTypes.test.ts
index 8f86a52da..3e4e2df88 100644
--- a/src/backend/services/notification/notificationTypes.test.ts
+++ b/src/backend/services/notification/notificationTypes.test.ts
@@ -73,6 +73,11 @@ describe('NOTIFICATION_TYPES', () => {
'notif:app-1:app-user',
);
+ const message = findNotificationType('app.message')!;
+ expect(
+ message.subject({ userUuid: 'user-uuid', appUid: 'app-1' }),
+ ).toBe('notif:app-1:app-user');
+
// A worker bound to no app has no app to name.
const worker = findNotificationType('app.worker.deployed')!;
expect(worker.subject({ userUuid: 'user-uuid', appUid: null })).toBe(
@@ -122,6 +127,15 @@ describe('resolveNotificationWrite', () => {
).toThrow('requires an app uid');
});
+ it('requires an app uid for an app message', () => {
+ expect(resolveNotificationWrite('app.message', 'app-1')).toMatchObject(
+ { audience: 'app-user', appScoped: true, groupable: false },
+ );
+ expect(() => resolveNotificationWrite('app.message', null)).toThrow(
+ 'requires an app uid',
+ );
+ });
+
it('lets a subscription an account made end without naming an app', () => {
expect(resolveNotificationWrite('app.events.ended', null).audience).toBe(
'app-user',
diff --git a/src/backend/services/notification/notificationTypes.ts b/src/backend/services/notification/notificationTypes.ts
index e5b0062bc..f6017ba61 100644
--- a/src/backend/services/notification/notificationTypes.ts
+++ b/src/backend/services/notification/notificationTypes.ts
@@ -109,6 +109,15 @@ export const NOTIFICATION_TYPES = [
groupable: false,
subject: subjectFor('developer'),
},
+ // The one type an app may create, and only for the user it acts for. What
+ // kind of message it is goes in the value's `channel`, not a new type.
+ {
+ type: 'app.message',
+ audience: 'app-user',
+ appScoped: true,
+ groupable: false,
+ subject: subjectFor('app-user'),
+ },
] as const satisfies readonly NotificationType[];
/** Every registered type name; what `notify` accepts. */
diff --git a/src/backend/types.ts b/src/backend/types.ts
index 28206fbbc..f9c1ad29e 100644
--- a/src/backend/types.ts
+++ b/src/backend/types.ts
@@ -971,6 +971,16 @@ interface IConfigOptional {
*/
notificationRetentionDays?: number;
+ /**
+ * How many `app.message` notifications one app may create for one user.
+ * Omit a field for the built-in default (30 per minute, 300 per day); a
+ * non-positive value removes that bound.
+ */
+ notificationAppMessageLimits?: {
+ perMinute?: number;
+ perDay?: number;
+ };
+
// -- Storage / S3 ------------------------------------------------
/** S3 storage config (local fauxqs or remote). */
diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md
index 1ec1b1238..95c5b28a9 100644
--- a/src/docs/src/rate-limits-and-quotas.md
+++ b/src/docs/src/rate-limits-and-quotas.md
@@ -385,6 +385,24 @@ Over these, **the share still succeeds**; only the announcement is skipped. The
Recipients can opt out of share email with the link in the email, and a deployment can turn it off with `share_email_notifications: false`. Recipients can also block shares from one sender or from everyone (**Settings → Security → Blocked people**); the sender's `share` then fails with `recipient_not_accepting_shares`.
+### Notifications
+
+An app, its workers and the tokens it issues can send the user they act for an `app.message` notification. Each app has its own budget per user:
+
+| Limit | Value | Scope |
+| -------------------------------- | --------------------------------- | ----------------- |
+| `app.message` notifications | 30/min, 300/day | Per user, per app |
+| `title` | 120 characters | |
+| `text` | 240 characters | |
+| `icon` (an `https` URL) | 2,048 characters | |
+| `target` (a path inside the app) | 512 characters | |
+| `data` | 1 KiB, measured as JSON | |
+| `group` | 64 characters | |
+| `channel` | 32 characters | |
+| `ttl` | 1 second to 1 day; default 1 hour | |
+
+Over the rate limit the call fails with `429 too_many_requests`. A value over any size limit, or with a field the format doesn't have, fails with `400 bad_request`. A deployment can change the rate limits with `notificationAppMessageLimits: { perMinute, perDay }`.
+
### Teams
Available only where the deployment has teams turned on; elsewhere `puter.teams` rejects with `not_found`.