mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 13:35:20 +00:00
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:
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. */
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in new issue
Block a user