fix(hosting): apps publish with write again (#4291)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

Reverts the hosting part of #4290. manage stays a user-to-user grant;
an app with write on its user's directory can publish and keep serving it.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-20 17:36:36 -07:00
1 parent 3bce3b056e
commit 5b0205f2bf
7 files changed
+38 -378

No files matched your search

@@ -26,8 +26,7 @@ import { PuterServer } from '../../../server';
import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService';
import { setupTestServer } from '../../../testUtil';
import type { IConfig } from '../../../types';
import { makeActor, type Actor } from '../../actor';
import { runWithContext } from '../../context';
import type { Actor } from '../../actor';
import { generateDefaultFsentries } from '../../../util/userProvisioning';
import { createPuterSiteMiddleware } from './puterSite';
@@ -1851,223 +1850,6 @@ describe('createPuterSiteMiddleware — delegated site roots', () => {
expect((await serveSite(sub)).statusCode).toBe(404);
});
it.each(['write', 'manage'])(
'revalidates app-created sites with %s grants',
async (mode) => {
const owner = await makeUserWithHome();
const app = await server.stores.app.create(
{
name: `publisher-${uuidv4()}`,
title: 'Publisher',
index_url: 'https://publisher.example.test/',
},
{ ownerUserId: owner.id },
);
const root = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Documents`,
))!;
const permission =
mode === 'manage'
? `manage:fs:${root.uuid}`
: `fs:${root.uuid}:write`;
await server.services.permission.grantUserAppPermission(
actorFor(owner),
app.uid,
permission,
);
const sub = `app-${Math.random().toString(36).slice(2, 10)}`;
// Stored rows include publications made before the current authority check.
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: root.id,
appOwner: app.id,
});
await writeFile(
owner.id,
`${root.path}/index.html`,
Buffer.from('private initial'),
'text/html',
);
expect((await serveSite(sub)).statusCode).toBe(
mode === 'manage' ? 200 : 404,
);
await server.services.permission.revokeUserAppPermission(
actorFor(owner),
app.uid,
permission,
);
await server.services.fs.write(owner.id, {
fileMetadata: {
path: `${root.path}/index.html`,
overwrite: true,
size: 14,
contentType: 'text/html',
},
fileContent: Buffer.from('private future'),
});
const out = await serveSite(sub);
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('private future');
},
);
it.each(['session', 'fullAccess', 'scoped'])(
'root changes preserve publication authority for %s',
async (mode) => {
const owner = await makeUserWithHome();
const userActor = makeActor({ user: owner });
const actor =
mode === 'session'
? userActor
: makeActor({
user: owner,
accessToken: {
uid: uuidv4(),
issuer: userActor,
fullAccess: mode === 'fullAccess',
},
});
const app = await server.stores.app.create(
{
name: `takeover-${uuidv4()}`,
title: 'Publisher',
index_url: 'https://publisher.example.test/',
},
{ ownerUserId: owner.id },
);
const appActor = makeActor({
user: owner,
app: { id: app.id, uid: app.uid },
});
const root = await server.services.fs.mkdir(owner.id, {
path: `/${owner.username}/AppData/${app.uid}`,
createMissingParents: true,
});
const sub = `takeover-${Math.random().toString(36).slice(2, 10)}`;
const site = await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: root.id,
appOwner: app.id,
});
await runWithContext({ actor }, () =>
server.drivers.subdomains.update({
uid: site.uuid,
object: { root_dir: root.path },
}),
);
expect(
(await server.stores.subdomain.getByUuid(site.uuid))!.app_owner,
).toBe(app.id);
await runWithContext({ actor }, () =>
server.drivers.subdomains.update({
uid: site.uuid,
object: {},
}),
);
expect(
(await server.stores.subdomain.getByUuid(site.uuid))!.app_owner,
).toBe(app.id);
const newRoot = `/${owner.username}/Documents`;
if (mode === 'scoped') {
const entry =
(await server.stores.fsEntry.getEntryByPath(newRoot))!;
await server.stores.permission.insertAccessTokenPerms(
actor.accessToken!.uid,
{ userId: owner.id, appId: null },
[{ permission: `manage:fs:${entry.uuid}` }],
);
}
await writeFile(
owner.id,
`${newRoot}/index.html`,
Buffer.from('account publication'),
'text/html',
);
await runWithContext({ actor }, () =>
server.drivers.subdomains.update({
uid: site.uuid,
object: { root_dir: newRoot },
}),
);
expect((await serveSite(sub)).statusCode).toBe(200);
expect(
(await server.stores.subdomain.getByUuid(site.uuid))!.app_owner,
).toBeNull();
await expect(
runWithContext({ actor: appActor }, () =>
server.drivers.subdomains.update({
uid: site.uuid,
object: { root_dir: root.path },
}),
),
).rejects.toMatchObject({ statusCode: 403 });
},
);
it.each([false, true])(
'serves own AppData without grant queries (hosted app matches creator: %s)',
async (hostedApp) => {
const owner = await makeUserWithHome();
const sub = `appdata-${Math.random().toString(36).slice(2, 10)}`;
const app = await server.stores.app.create(
{
name: `data-${uuidv4()}`,
title: 'Data',
index_url: hostedApp
? `http://${sub}.site.puter.localhost/`
: 'https://data.example.test/',
},
{ ownerUserId: owner.id },
);
const root = await server.services.fs.mkdir(owner.id, {
path: `/${owner.username}/AppData/${app.uid}`,
createMissingParents: true,
});
await writeFile(
owner.id,
`${root.path}/index.html`,
Buffer.from('app data'),
'text/html',
);
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: root.id,
appOwner: app.id,
});
expect((await serveSite(sub)).statusCode).toBe(200);
const read = vi.spyOn(server.clients.db, 'read');
const get = vi.spyOn(server.clients.redis, 'get');
const acl = vi.spyOn(server.services.acl, 'check');
try {
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect(acl).not.toHaveBeenCalled();
expect(
get.mock.calls.filter(
([key]) => key === `apps:id:${app.id}`,
),
).toHaveLength(hostedApp ? 0 : 1);
expect(
read.mock.calls.filter(([query]) => {
const sql = String(query);
return (
/(?:FROM|JOIN)\s+`?permissions`?\b/i.test(sql) ||
(/FROM\s+`apps`/i.test(sql) &&
!sql.includes('index_url'))
);
}),
).toHaveLength(0);
} finally {
read.mockRestore();
get.mockRestore();
acl.mockRestore();
}
},
);
it("never consults the ACL for a site rooted in the publisher's own tree", async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
@@ -2087,19 +1869,12 @@ describe('createPuterSiteMiddleware — delegated site roots', () => {
});
const aclCheck = vi.spyOn(server.services.acl, 'check');
const redisGet = vi.spyOn(server.clients.redis, 'get');
try {
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect(aclCheck).not.toHaveBeenCalled();
expect(
redisGet.mock.calls.filter(([key]) =>
String(key).startsWith('apps:id:'),
),
).toHaveLength(0);
} finally {
aclCheck.mockRestore();
redisGet.mockRestore();
}
});
});
+18 -31
View File
@@ -25,7 +25,6 @@ import type { puterServices } from '../../../services';
import { HOSTED_ASSET_COOKIES } from '../../../services/auth/AuthService';
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
import type { puterStores } from '../../../stores';
import type { SubdomainRow } from '../../../stores/subdomain/SubdomainStore';
import type { IConfig, LayerInstances } from '../../../types';
import { awaitLater } from '../../../util/concurrency';
import { makeActor } from '../../actor';
@@ -142,6 +141,16 @@ const SUBDOMAIN_404 = `<div style="font-size: 20px;
display: flex;
justify-content: center;
flex-direction: column;"><h1 style="margin:0; color:#727272;">404</h1><p style="margin-top:10px;">Subdomain or site is not pointing to a directory.</p></div>`;
interface SubdomainRow {
id: number;
uuid: string;
subdomain: string;
user_id: number | null;
root_dir_id: number | null;
associated_app_id: number | null;
domain?: string | null;
protected?: number | null;
}
interface AppRow {
id: number;
@@ -246,7 +255,7 @@ export const createPuterSiteMiddleware = (
const site = (await layers.stores.subdomain.getBySubdomain(
subdomain,
)) as SubdomainRow | null;
)) as unknown as SubdomainRow | null;
if (!site || site.user_id === null || site.user_id === undefined) {
res.status(404).type('text/plain').send('Subdomain not found');
return;
@@ -529,32 +538,13 @@ export const createPuterSiteMiddleware = (
return;
}
// Delegated publications depend on their current share authority.
// Account-owned roots keep their direct path without extra lookups.
const publisherApp =
site.app_owner == null
? null
: Number(associatedApp?.id) === Number(site.app_owner)
? associatedApp
: await layers.stores.app.getById(Number(site.app_owner));
const ownAppDataPath = publisherApp
? `/${owner.username}/AppData/${publisherApp.uid}`
: null;
const ownAppData =
rootEntry.userId === site.user_id &&
ownAppDataPath &&
(rootEntry.path === ownAppDataPath ||
rootEntry.path.startsWith(`${ownAppDataPath}/`));
if (site.app_owner != null && !publisherApp) {
res.status(404)
.type('text/html; charset=UTF-8')
.send(SUBDOMAIN_404);
return;
}
if (
rootEntry.userId !== site.user_id ||
(publisherApp && !ownAppData)
) {
// A site rooted in someone else's directory stands on the `manage`
// grant that authorized publishing it, and serves that whole subtree
// with the ACL bypassed — so the grant is re-read on every request.
// Trash is its own check: a grant is keyed on the node, not its
// location, so it survives the owner deleting the directory. Sites
// rooted in their publisher's own tree skip all of this.
if (rootEntry.userId !== site.user_id) {
let stillPublishable = false;
if (!isTrashedPath(rootEntry.path)) {
try {
@@ -565,9 +555,6 @@ export const createPuterSiteMiddleware = (
uuid: owner.uuid,
username: owner.username,
},
app: publisherApp
? { id: publisherApp.id, uid: publisherApp.uid }
: null,
}),
{
path: rootEntry.path,
@@ -645,102 +645,6 @@ describe('SubdomainDriver root_dir publishing rights', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
it.each(['create', 'update'])(
'refuses a same-user write-only app on %s',
async (operation) => {
const owner = await makeUser();
const app = await server.stores.app.create(
{
name: uniqueSubdomain('publisher'),
title: 'Publisher',
index_url: 'https://publisher.example.test/',
},
{ ownerUserId: owner.userId },
);
const actor = makeActor({
user: owner.actor.user,
app: { id: app.id, uid: app.uid },
});
const shared = `/${owner.actor.user!.username}/Documents`;
const entry = (await server.stores.fsEntry.getEntryByPath(shared))!;
await server.services.permission.grantUserAppPermission(
owner.actor,
app.uid,
`fs:${entry.uuid}:write`,
);
let uid: unknown;
if (operation === 'update') {
const root = `/${owner.actor.user!.username}/AppData/${app.uid}`;
await server.services.fs.mkdir(owner.userId, {
path: root,
createMissingParents: true,
});
const created = (await withActor(actor, () =>
driver.create({
object: {
subdomain: uniqueSubdomain('app-root'),
root_dir: root,
},
}),
)) as Record<string, unknown>;
uid = created.uid;
}
await expect(
withActor(actor, () =>
operation === 'create'
? driver.create({
object: {
subdomain: uniqueSubdomain('denied-app'),
root_dir: shared,
},
})
: driver.update({ uid, object: { root_dir: shared } }),
),
).rejects.toMatchObject({ statusCode: 403 });
},
);
it('accepts a same-user app with share authority', async () => {
const owner = await makeUser();
const app = await server.stores.app.create(
{
name: uniqueSubdomain('manager'),
title: 'Manager',
index_url: 'https://manager.example.test/',
},
{ ownerUserId: owner.userId },
);
const root = (await server.stores.fsEntry.getEntryByPath(
`/${owner.actor.user!.username}/Documents`,
))!;
await server.services.permission.grantUserAppPermission(
owner.actor,
app.uid,
`manage:fs:${root.uuid}`,
);
const actor = makeActor({
user: owner.actor.user,
app: { id: app.id, uid: app.uid },
});
const sub = uniqueSubdomain('managed-app');
const result = (await withActor(actor, () =>
driver.create({ object: { subdomain: sub, root_dir: root.path } }),
)) as Record<string, unknown>;
expect(result.subdomain).toBe(sub);
const nextRoot = (await server.stores.fsEntry.getEntryByPath(
`/${owner.actor.user!.username}/Public`,
))!;
await server.services.permission.grantUserAppPermission(
owner.actor, app.uid, `manage:fs:${nextRoot.uuid}`,
);
await withActor(actor, () => driver.update({
uid: result.uid, object: { root_dir: nextRoot.path },
}));
expect((await server.stores.subdomain.getBySubdomain(sub))!.app_owner)
.toBe(app.id);
});
// "Can edit & share" is the owner delegating the decision.
it('accepts a directory shared with the actor at manage', async () => {
const owner = await makeUser();
@@ -783,6 +687,8 @@ describe('SubdomainDriver root_dir publishing rights', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
// The owner's own path still only needs write, so an app hosting a
// directory its user gave it keeps working.
it("still accepts the actor's own directory", async () => {
const { actor } = await makeUser();
const sub = uniqueSubdomain('own');
@@ -357,7 +357,6 @@ export class SubdomainDriver extends PuterDriver {
// Subdomain name is immutable — strip if provided
const patch: Record<string, unknown> = {};
let claimForAccount = false;
if (object.root_dir !== undefined) {
const rootDirPath = expandTildePath(
String(object.root_dir),
@@ -372,8 +371,6 @@ export class SubdomainDriver extends PuterDriver {
}
if (rootDirId !== (row.root_dir_id ?? null)) {
await this.#checkPublishAccess(entry, actor);
// Scoped account credentials have proved manage on the new root.
claimForAccount = actor.effectiveApp === null;
}
patch.root_dir_id = rootDirId;
}
@@ -384,7 +381,7 @@ export class SubdomainDriver extends PuterDriver {
const updated = await this.stores.subdomain.update(
String(row.uuid),
patch,
{ userId: row.user_id as number, claimForAccount },
{ userId: row.user_id as number },
);
const [shaped] = await this.#hydrateRows(
updated ? [updated as Record<string, unknown>] : [],
@@ -564,11 +561,22 @@ export class SubdomainDriver extends PuterDriver {
throw new HttpError(403, 'Access denied', { legacyCode: 'forbidden' });
}
// Publishing exposes future descendants too, so every actor needs share authority.
/**
* Gate on pointing a subdomain at a directory. Hosting serves everything
* under the root dir with the ACL bypassed, so publishing makes that
* subtree world-readable — for good, and including whatever is written
* there later. That is the owner's call to make.
*
* `write` is not it. A shared folder's writer would be exposing the owner's
* tree, the row belongs to the writer's account, and nothing the owner can
* list would show it. `manage` — "Can edit & share" — is the grant that
* delegates the decision.
*/
async #checkPublishAccess(entry: FSEntry, actor: Actor): Promise<void> {
// First, because this is the check that masks a directory the caller
// cannot see at all as a 404.
await this.services.fs.checkFSAccess(entry, actor);
if (entry.userId === actor.user?.id) return;
try {
await this.services.fs.checkFSAccess(
entry,
+3 -15
View File
@@ -63,8 +63,7 @@ export type CascadeRow = Pick<
// - name / identity: `subdomain` — v1 marks this `immutable: true`; rename
// would orphan DNS + ACL wiring tied to the old name.
// - ownership: `user_id`, `app_owner` — set via `create` (or reclaimed in
// place by `reclaimByUuid`), never via patch. Account root reassignment
// clears app ownership through a trusted store option.
// place by `reclaimByUuid`), never via patch.
// Flipping either hands the site to another user / app.
// - access gate: `protected` — clearing this lets a future caller delete
// or rename a protected site (e.g. `puter-app-icons`). The driver itself
@@ -611,11 +610,8 @@ export class SubdomainStore extends PuterStore {
patch: Record<string, unknown>,
{
userId,
claimForAccount = false,
}: {
userId?: number | undefined;
/** The account explicitly selected a different publication root. */
claimForAccount?: boolean;
} = {},
) {
const allowed: Record<string, unknown> = {};
@@ -623,19 +619,11 @@ export class SubdomainStore extends PuterStore {
if (READ_ONLY_COLUMNS.has(k)) continue;
allowed[k] = v;
}
if (Object.keys(allowed).length === 0)
return this.getByUuid(uuid, { userId });
const keys = Object.keys(allowed);
if (keys.length === 0) return this.getByUuid(uuid, { userId });
// Rename drops the old cache key.
const before = await this.getByUuid(uuid, { userId });
if (
claimForAccount &&
allowed.root_dir_id !== undefined &&
allowed.root_dir_id !== before?.root_dir_id
) {
allowed.app_owner = null;
}
const keys = Object.keys(allowed);
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
const values = keys.map((k) => allowed[k]);
+1 -1
View File
@@ -23,7 +23,7 @@ A string containing the name of the subdomain you want to create.
A string containing the path to the directory you want to serve.
Hosting serves the directory publicly, including files added later. An app can publish its own app data directory. Publishing another directory requires `manage` access, which allows editing and sharing. A shared directory stops being served if that access is withdrawn or its owner moves it to Trash.
The directory must be one you own. Hosting serves everything under it publicly, including files added later, so a directory someone shared with you can only be published if they gave you `manage` access — [`share()`](/FS/share/) calls that level "Can edit & share". That access is also what keeps the site up: if the owner withdraws it, or moves the directory to their trash, the site stops being served.
#### `options` (Object) (optional)
-4
View File
@@ -22,10 +22,6 @@ A string containing the name of the subdomain you want to update.
A string containing the path to the directory you want to serve.
An app can publish its own app data directory. Publishing another directory requires `manage` access, which allows editing and sharing. Hosting serves all files under the directory publicly, including files added later. A shared directory stops being served if that access is withdrawn or its owner moves it to Trash.
When an account credential selects a different directory for an app-created site, the account takes ownership of the publication and the creating app loses its management access. This includes an account-issued access token with management authority over the new directory. Selecting the current directory preserves the creating app's access. Credentials acting through an app preserve that app's ownership.
## Return value
A `Promise` that will resolve to a [`Subdomain`](/Objects/subdomain/) object when the subdomain has been updated. If a subdomain with the given name does not exist, the promise will be rejected with an error. If the path does not exist, the promise will be rejected with an error.