diff --git a/src/backend/core/http/middleware/puterSite.test.ts b/src/backend/core/http/middleware/puterSite.test.ts
index bf9ae1343..a0ecd37f3 100644
--- a/src/backend/core/http/middleware/puterSite.test.ts
+++ b/src/backend/core/http/middleware/puterSite.test.ts
@@ -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();
}
});
});
diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts
index 5f36e4d8e..4c3c1d33e 100644
--- a/src/backend/core/http/middleware/puterSite.ts
+++ b/src/backend/core/http/middleware/puterSite.ts
@@ -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 = `
404
Subdomain or site is not pointing to a directory.
`;
+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,
diff --git a/src/backend/drivers/subdomain/SubdomainDriver.test.ts b/src/backend/drivers/subdomain/SubdomainDriver.test.ts
index b1f1ed188..777bd2517 100644
--- a/src/backend/drivers/subdomain/SubdomainDriver.test.ts
+++ b/src/backend/drivers/subdomain/SubdomainDriver.test.ts
@@ -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;
- 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;
- 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');
diff --git a/src/backend/drivers/subdomain/SubdomainDriver.ts b/src/backend/drivers/subdomain/SubdomainDriver.ts
index adeba5855..9dafced69 100644
--- a/src/backend/drivers/subdomain/SubdomainDriver.ts
+++ b/src/backend/drivers/subdomain/SubdomainDriver.ts
@@ -357,7 +357,6 @@ export class SubdomainDriver extends PuterDriver {
// Subdomain name is immutable — strip if provided
const patch: Record = {};
- 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] : [],
@@ -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 {
// 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,
diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts
index 3995d4433..e75173fcd 100644
--- a/src/backend/stores/subdomain/SubdomainStore.ts
+++ b/src/backend/stores/subdomain/SubdomainStore.ts
@@ -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,
{
userId,
- claimForAccount = false,
}: {
userId?: number | undefined;
- /** The account explicitly selected a different publication root. */
- claimForAccount?: boolean;
} = {},
) {
const allowed: Record = {};
@@ -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]);
diff --git a/src/docs/src/Hosting/create.md b/src/docs/src/Hosting/create.md
index 0f8b14563..f9730e515 100755
--- a/src/docs/src/Hosting/create.md
+++ b/src/docs/src/Hosting/create.md
@@ -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)
diff --git a/src/docs/src/Hosting/update.md b/src/docs/src/Hosting/update.md
index c5159ee60..3054daab7 100755
--- a/src/docs/src/Hosting/update.md
+++ b/src/docs/src/Hosting/update.md
@@ -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.