fix(notifications): website apps are named by their hostname

An app created for an origin keeps its uid as name and title, so its
notifications showed that uid as the sender. appMessageSender now names
such an app by the hostname of its index_url, and keeps the stored name
when index_url doesn't parse. The check AppDriver used for these rows
moves to util/appView so both share it.

The Settings app list shows a generic label for an app whose title is
still its uid.
This commit is contained in:
Juan Castro committed 2026-11-02 09:46:39 -05:00
1 parent 480fa7b85b
commit 8b7475ef39
9 files changed
+153 -23

No files matched your search

+2 -18
View File
@@ -31,7 +31,7 @@ import {
normalizeRawBase64ImageString,
validateIconDataUrl,
} from '../../util/appIcon.js';
import { toAppReadView } from '../../util/appView.js';
import { isOriginBootstrapApp, toAppReadView } from '../../util/appView.js';
import { awaitLater } from '../../util/concurrency.js';
import { isUniqueViolation } from '../../util/dbError.js';
import {
@@ -1088,22 +1088,6 @@ export class AppDriver extends PuterDriver {
}
}
/**
* Origin-bootstrap detection: rows auto-created when an unknown origin
* first needed an app row (no human-supplied metadata). Marker is `name ===
* uid && title === uid` and a description starting with "App created from
* origin ". Only these rows are eligible for same-owner merging — refusing
* to merge arbitrary same-owner apps prevents accidental data loss.
*/
#isOriginBootstrapApp(app) {
if (!app || typeof app !== 'object') return false;
if (typeof app.uid !== 'string' || !app.uid) return false;
if (app.name !== app.uid) return false;
if (app.title !== app.uid) return false;
if (typeof app.description !== 'string') return false;
return app.description.startsWith('App created from origin ');
}
// -- Canonical-uid alias kvstore pair -----------------------------
//
// After a merge, the source app uid → canonical uid mapping is
@@ -1266,7 +1250,7 @@ export class AppDriver extends PuterDriver {
if (
Number.isInteger(conflictOwnerUserId) &&
conflictOwnerUserId === user.id &&
!this.#isOriginBootstrapApp(appToJoin)
!isOriginBootstrapApp(appToJoin)
) {
// Prevent merging arbitrary same-owner apps; only allow the
// auto-created origin bootstrap row to be absorbed.
@@ -243,4 +243,61 @@ describe('appMessageSender', () => {
'a'.repeat(120),
);
});
describe('an app made for an origin', () => {
const uid = 'app-65d98228-cfa5-5d18-a442-f6eeaf3dcfc7';
const originApp = (fields: Record<string, unknown> = {}) => ({
uid,
name: uid,
title: uid,
description: 'App created from origin https://chat.example.com',
index_url: 'https://chat.example.com',
...fields,
});
it('is named by the hostname of its index_url, without a port', () => {
expect(appMessageSender(uid, originApp()).name).toBe(
'chat.example.com',
);
expect(
appMessageSender(
uid,
originApp({
index_url: 'https://chat.example.com:8443/app/',
}),
).name,
).toBe('chat.example.com');
});
it('keeps its stored name when its index_url does not parse', () => {
expect(
appMessageSender(uid, originApp({ index_url: 'not a url' }))
.name,
).toBe(uid);
expect(
appMessageSender(uid, originApp({ index_url: undefined })).name,
).toBe(uid);
});
it('keeps a title its developer set', () => {
expect(
appMessageSender(uid, originApp({ title: 'Chat' })).name,
).toBe('Chat');
});
it('leaves apps that were not made for an origin alone', () => {
expect(
appMessageSender(uid, originApp({ description: 'My chat app' }))
.name,
).toBe(uid);
expect(
appMessageSender('app-1', {
uid: 'app-1',
name: 'chat',
title: 'Chat',
index_url: 'https://chat.example.com',
}).name,
).toBe('Chat');
});
});
});
@@ -18,6 +18,7 @@
*/
import { HttpError } from '../../core/http/HttpError.js';
import { isOriginBootstrapApp } from '../../util/appView.js';
/** The one notification type an app may create. */
export const APP_MESSAGE_TYPE = 'app.message';
@@ -60,14 +61,35 @@ export type AppMessage = {
export type AppMessageSender = { uid: string; name?: string; icon?: string };
/** The hostname an app made for an origin is named by. */
const originAppHost = (row: unknown): string | undefined => {
if (!isOriginBootstrapApp(row) || typeof row.index_url !== 'string') {
return undefined;
}
try {
return new URL(row.index_url).hostname || undefined;
} catch {
return undefined;
}
};
/** What a message says about its app, read off the app's row. */
export const appMessageSender = (
uid: string,
row: { name?: unknown; title?: unknown; icon?: unknown } | null | undefined,
row:
| {
name?: unknown;
title?: unknown;
icon?: unknown;
description?: unknown;
index_url?: unknown;
}
| null
| undefined,
): AppMessageSender => {
const sender: AppMessageSender = { uid };
// Cleaned before the empty check, so a title of only invisible characters still leaves a visible sender.
const name = [row?.title, row?.name]
const name = [originAppHost(row), row?.title, row?.name]
.map((v) =>
typeof v === 'string'
? [...cleanText(v)]
+1 -1
View File
@@ -788,7 +788,7 @@ export class AppStore extends PuterStore {
* the row stays unclaimed until a developer registers an app for the same
* origin and AppDriver's merge path takes ownership. Marker shape (`name
* === uid && title === uid` + description prefix) is what
* `#isOriginBootstrapApp` checks.
* `isOriginBootstrapApp` (util/appView) checks.
*/
async createFromOrigin(
uid,
+30 -1
View File
@@ -18,7 +18,12 @@
*/
import { describe, expect, it } from 'vitest';
import { toAppReadView, toAppSummary, toAppView } from './appView.js';
import {
isOriginBootstrapApp,
toAppReadView,
toAppSummary,
toAppView,
} from './appView.js';
const row = (overrides: Record<string, unknown> = {}) => ({
id: 42,
@@ -192,3 +197,27 @@ describe('toAppReadView', () => {
).toBe(64);
});
});
describe('isOriginBootstrapApp', () => {
const originRow = (overrides: Record<string, unknown> = {}) =>
row({
name: 'app-1234',
title: 'app-1234',
description: 'App created from origin https://chat.example.com',
...overrides,
});
it('matches a row created for an origin', () => {
expect(isOriginBootstrapApp(originRow())).toBe(true);
});
it('does not match once the app has its own name, title or description', () => {
expect(isOriginBootstrapApp(originRow({ name: 'chat' }))).toBe(false);
expect(isOriginBootstrapApp(originRow({ title: 'Chat' }))).toBe(false);
expect(isOriginBootstrapApp(originRow({ description: 'Chat' }))).toBe(
false,
);
expect(isOriginBootstrapApp(row())).toBe(false);
expect(isOriginBootstrapApp(null)).toBe(false);
});
});
+12
View File
@@ -25,6 +25,18 @@ import type { PrivateLaunchDecision } from './privateLaunchAccess.js';
/** A raw `apps` store row. */
export type AppRow = Record<string, unknown>;
/** A row created for an origin, whose name and title are still its uid. */
export function isOriginBootstrapApp(app: unknown): app is AppRow {
if (!app || typeof app !== 'object') return false;
const { uid, name, title, description } = app as AppRow;
if (typeof uid !== 'string' || !uid) return false;
if (name !== uid || title !== uid) return false;
return (
typeof description === 'string' &&
description.startsWith('App created from origin ')
);
}
/** Client shape of an app. Never carries internal ids or the owner's id. */
export interface AppView {
uid: unknown;
+10 -1
View File
@@ -127,12 +127,21 @@ export const isLoadableIcon = (url) => {
}
};
const APP_UID_PATTERN = /^app-[0-9a-f-]{36}$/;
/** @param {import('../../helpers/notificationApi.js').PushApp} app @returns {string} Encoded */
const appTitleHtml = (app) => {
const title = app.title || app.name || '';
if ( ! APP_UID_PATTERN.test(title) ) return html_encode(title);
return i18n('push_app_unnamed_website');
};
/** @param {import('../../helpers/notificationApi.js').PushApp} app */
export const appCardHtml = (app) => {
const icon = isLoadableIcon(app.icon) ? app.icon : '';
return card({
icon: `<img ${appIconAttrs({ icon }, window.icons?.['app.svg'] ?? '')} alt="">`,
title: html_encode(app.title || app.name || ''),
title: appTitleHtml(app),
action: `<button type="button" class="button push-revoke-app" data-app-uid="${html_encode(app.uid)}">${i18n('push_turn_off')}</button>`,
extraClass: 'push-app-card',
});
@@ -700,6 +700,22 @@ describe('apps that can send pushes', () => {
delete window.icons;
});
it('shows a readable name for an app still named by its uid', async () => {
const uid = 'app-65d98228-cfa5-5d18-a442-f6eeaf3dcfc7';
serve({
'GET /push/devices': { devices: [] },
'GET /push/apps': { apps: [
{ uid, name: uid, title: uid, icon: null },
{ uid: 'app-2', name: 'app-2', title: 'app-2', icon: null },
] },
'GET /push/preferences': { puter: true },
});
await open();
expect($('.push-app-card strong').map((_, el) => $(el).text()).get())
.toEqual(['push_app_unnamed_website', 'app-2']);
});
it('shows an app name and icon containing markup as text', async () => {
serve({
'GET /push/devices': { devices: [] },
+1
View File
@@ -466,6 +466,7 @@ const en = {
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
push_app_turn_off_failed: "Couldn't turn that off. Please try again.",
push_app_unnamed_website: 'Unnamed website',
push_apps: 'Apps that can send you notifications',
push_apps_empty: 'No apps can send you push notifications.',
push_browser_blocked: 'Notifications are blocked in your browser settings',