feat(notifications): apps can notify the user they act for

Adds the app.message type (audience app-user). An app, its workers and
the tokens it issues can create it through puter-notifications create,
only for their own user and under their own app. The value is validated
against a fixed format, the write is not silent, and each app has a
per-user budget of 30 per minute and 300 per day, configurable with
notificationAppMessageLimits. Holders keep creating the other types
silently and cannot create app.message.
This commit is contained in:
Juan Castro committed 2026-10-24 16:02:54 -04:00
1 parent 8a55debdae
commit 4b41b9cd54
9 files changed
+995 -31

No files matched your search

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Actor> => {
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 = <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
runWithContext({ actor }, fn);
const message = (fields: Record<string, unknown> = {}) => ({
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<unknown>,
): Promise<Array<Record<string, unknown>>> => {
const seen: Array<Record<string, unknown>> = [];
const handler = (_k: string, data: unknown) =>
seen.push(data as Record<string, unknown>);
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<IConfig>) => {
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<string, unknown> | 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<string, unknown>;
},
);
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<string, unknown>;
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<string, unknown>) => 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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>>;
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<IConfig>);
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<string, unknown>;
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 });
});
});
@@ -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,
@@ -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<string, unknown>,
@@ -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:<appUid>:<audience>`); 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<string, unknown>;
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<string, unknown>): Promise<unknown> {
@@ -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<string, unknown>,
value: Record<string, unknown>,
): Promise<unknown> {
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<boolean> {
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<string, unknown>,
opts: {
type: NotificationTypeName;
appUid: string | null;
silent: boolean;
},
): Promise<unknown> {
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
@@ -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 <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { parseAppMessage } from './appMessage.js';
/** The 400 a field failing validation raises. */
const rejects = (input: Record<string, unknown>, 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: '<b>not markup</b>, 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');
});
});
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<string, unknown>,
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, unknown>): 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, unknown>): 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<string, unknown>,
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<string, unknown>): 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<string, unknown>): 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;
};
@@ -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',
@@ -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. */
+10
View File
@@ -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). */
+18
View File
@@ -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`.