mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-13 15:20:26 +00:00
* perf(workers): skip hot-reload lookups for files no worker is bound to
The fs.write.file / fs.remove.node / fs.move.node listeners listed every
workers.puter.* row of the file's owner on every event, for every user.
The event's FSEntry already carries the worker rows bound to it, so a file
with none returns before any I/O; a bound file reads just its own rows,
batched, from the primary.
* perf(apps): read the uid alias only after a direct lookup misses
Every AppDriver uid lookup fired an uncached system-KV GetItem for the
merge alias in parallel with the direct read, even when the direct read
hit. The alias is now read only on a miss.
* refactor(apps): one app view with the launch gates, built by AppService
AppDriver's serializer and its private-app / hosted-backing gates move to
AppService.views, built on a pure toAppView that is the one place
index_url is withheld. AppDriver, the subdomains driver's embedded apps and
the GUI shell view all use it. Visibility of protected apps
(filterReadable) moves with it.
Subdomain embeds (associated_app, app_owner) used to return index_url with
no gate; they now withhold it from a viewer who may not open a private app
or whose hosted backing is gone, and carry privateAccess, godmode and
feedback_enabled like a direct app read.
* refactor(apps): one toAppSummary; batch the recommended and taskbar lookups
SuggestedApps, RecommendedApps, /get-launch-apps and the taskbar each built
their own launch summary; they now share toAppSummary (the extra
feedback_enabled / external flags appear on the lists that lacked them).
getRecommendedApps awaited getByName once per recommended app (29) and
getTaskbarItems did the same per taskbar entry on every whoami; both now
make one batched call per key through the new AppStore.getByNames, which
keeps getByName's case-insensitive and old-name matching. The taskbar's
raw UPDATE of the user row goes through UserStore.update, and
/get-launch-apps checks hosted backings in one batch and reaches the
stores without casts.
* refactor(apps): app routes read through AppService; /delete-site calls the subdomains driver
AppController used the global driversContainers through a getter whose
boot-order comment no longer held; it uses the injected drivers and
AppService. GET /apps/:name looked each name up, read it again through the
driver and resolved private access a second time, emitting
app.privateAccess.resolveLaunch twice per private app; it now batches the
names and resolves once. POST /query/app did a lookup, a driver read and a
raw app_filetype_association SELECT per selector (up to 200, serially); it
now makes one batched lookup per key, one grant check and one filetype read.
The driver's read shape is a pure toAppReadView both use.
HostingController's /delete-site re-implemented the subdomains driver's
delete (protected check, delete, subdomain.delete event); it keeps its
owner-scoped 404 and calls the driver. Hosting and Desktop controllers drop
constructors that discarded the drivers argument, and trivial getters.
* refactor(drivers): one per-method policy validator and resolver; drop @Driver
Four policy blocks (rateLimit, concurrent, requireSubscription,
requireReputation) share the { default, methods } shape but each had its
own validator and resolver. They become validatePerMethod(value, label,
policy) and resolvePerMethod(cfg, method); error messages are unchanged.
The @Driver class decorator and the prototype keys it wrote had no users
outside its own tests; drivers declare their fields, and resolveDriverMeta
reads only those. DriverController takes the driver name from the meta it
already resolved instead of probing the prototype.
resolveDriverMethodRateLimit/Concurrent stay as deprecated aliases for the
email extension.
* refactor(drivers): shared actor guards and crud-q concurrency policy
The apps, subdomains, workers and notifications drivers each carried a
private #requireActor, two carried the same #requireVerified, and apps and
subdomains the same owner/app_owner write predicate. These become
requireContextActor, requireContextUserActor, assertActorEmailVerified and
actorOwnsRow in core/actor.ts; each driver keeps its own error for the
cases that differed (system actors still read apps; notifications still
answer 403 for an actor without a user). The identical crud-q concurrency
block is one CRUD_CONCURRENT constant.
The metering, installedApps and appTelemetry extensions re-checked an actor
their routes already require; those checks and the tests for them go.
* refactor(extensions): store methods instead of raw SQL in OSS extensions
installedApps and appTelemetry queried user_to_app_permissions directly
and metering read apps by name with its own SELECT. PermissionStore gains
listAppsGrantedByUser, listAppAuthenticatedUsers (with the email-share
check folded in) and countAppUsers; metering uses AppStore.getByName.
* refactor(server): one layer builder and one lifecycle hook runner
PuterServer built each of its five layers with its own copy of the same
loop (built-ins, then extension additions, into the layer and its export
container), and ran each lifecycle hook with five near-identical loops per
hook. Both are now one helper each: #instantiateLayer and
#runHook(order, hook). Construction order, the args each layer receives,
and hook order (start and prepare bottom-up, shutdown top-down) are
unchanged.
* fix(workers): tear down a deleted source's worker after its FK nulls root_dir_id
219 lines
7.5 KiB
TypeScript
219 lines
7.5 KiB
TypeScript
import type { Request, Response } from 'express';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import {
|
||
afterAll,
|
||
beforeAll,
|
||
describe,
|
||
expect,
|
||
it,
|
||
vi,
|
||
} from 'vitest';
|
||
import { runWithContext } from '../src/backend/core/context.ts';
|
||
import { extension } from '../src/backend/extensions.ts';
|
||
import { PuterServer } from '../src/backend/server.ts';
|
||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||
import { handleInstalledApps } from './installedApps.ts';
|
||
|
||
interface CapturedResponse {
|
||
body: unknown;
|
||
}
|
||
|
||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||
({ query }) as unknown as Request;
|
||
|
||
const makeRes = () => {
|
||
const captured: CapturedResponse = { body: undefined };
|
||
const res = {
|
||
json: vi.fn((value: unknown) => {
|
||
captured.body = value;
|
||
return res;
|
||
}),
|
||
};
|
||
return { res: res as unknown as Response, captured };
|
||
};
|
||
|
||
let server: PuterServer;
|
||
|
||
beforeAll(async () => {
|
||
server = await setupTestServer();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await server?.shutdown();
|
||
});
|
||
|
||
const seedUser = async () => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.user.create({
|
||
username: `iauser_${slug}`,
|
||
uuid: uuidv4(),
|
||
password: 'x',
|
||
email: null,
|
||
});
|
||
};
|
||
|
||
const seedApp = async (ownerUserId: number, overrides = {}) => {
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
return server.stores.app.create(
|
||
{
|
||
name: `iaapp_${slug}`,
|
||
title: `Installed App ${slug}`,
|
||
index_url: `https://example.com/${slug}`,
|
||
...overrides,
|
||
},
|
||
{ ownerUserId },
|
||
);
|
||
};
|
||
|
||
const grantInstalled = async (appId: number, userId: number) => {
|
||
// Mimic the `flag:app-is-authenticated` perm row the handler joins on.
|
||
await server.clients.db.write(
|
||
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
|
||
[userId, appId, 'flag:app-is-authenticated', null],
|
||
);
|
||
};
|
||
|
||
describe('installedApps extension — handleInstalledApps', () => {
|
||
it('throws HttpError(400) when orderBy is not in the allowlist', async () => {
|
||
const user = await seedUser();
|
||
const { res } = makeRes();
|
||
await expect(
|
||
runWithContext(
|
||
{
|
||
actor: {
|
||
user: { uuid: user.uuid, id: user.id as number },
|
||
},
|
||
},
|
||
() =>
|
||
handleInstalledApps(
|
||
// SQL injection attempt — must be rejected.
|
||
makeReq({ orderBy: 'apps.id; DROP TABLE apps;--' }),
|
||
res,
|
||
),
|
||
),
|
||
).rejects.toMatchObject({
|
||
statusCode: 400,
|
||
message: expect.stringContaining('Invalid orderBy'),
|
||
});
|
||
});
|
||
|
||
it('returns an empty list for a user with no installed apps', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
expect(captured.body).toEqual([]);
|
||
});
|
||
|
||
it('returns the caller’s installed apps with an iconUrl field', async () => {
|
||
const user = await seedUser();
|
||
const app = await seedApp(user.id as number);
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].uid).toBe(app!.uid);
|
||
expect(list[0].name).toBe(app!.name);
|
||
expect(list[0].title).toBe(app!.title);
|
||
// index_url is required so the dashboard can derive a hostname title
|
||
// for anonymous (app-…) apps.
|
||
expect(list[0].index_url).toBe(app!.index_url);
|
||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||
true,
|
||
);
|
||
// An owned app is not external, and the raw owner id must not leak.
|
||
expect(list[0].external).toBe(false);
|
||
expect(
|
||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => {
|
||
const user = await seedUser();
|
||
// The resize pipeline leaves an http(s) icon column behind, which is
|
||
// what says the sized PNG is on the icons subdomain.
|
||
const app = await seedApp(user.id as number, {
|
||
icon: 'https://api.puter.com/app-icon/x',
|
||
});
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
const { protocol, static_hosting_domain } = extension.config;
|
||
expect(list[0].iconCdnUrl).toBe(
|
||
`${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`,
|
||
);
|
||
});
|
||
|
||
it('reports no subdomain URL for an app with no generated icon', async () => {
|
||
const user = await seedUser();
|
||
const app = await seedApp(user.id as number);
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
expect(list[0].iconCdnUrl).toBeNull();
|
||
});
|
||
|
||
it('flags apps with no owner_user_id as external', async () => {
|
||
const user = await seedUser();
|
||
const slug = Math.random().toString(36).slice(2, 8);
|
||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||
const app = await server.stores.app.createFromOrigin(
|
||
`app-${slug}`,
|
||
`https://external-${slug}.example.com`,
|
||
);
|
||
await grantInstalled(app!.id as number, user.id as number);
|
||
|
||
const { res, captured } = makeRes();
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() => handleInstalledApps(makeReq({}), res),
|
||
);
|
||
|
||
const list = captured.body as Array<Record<string, unknown>>;
|
||
expect(list).toHaveLength(1);
|
||
expect(list[0].external).toBe(true);
|
||
expect(
|
||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||
const user = await seedUser();
|
||
const { res, captured } = makeRes();
|
||
|
||
// page=0 and limit=999 should be clamped without throwing.
|
||
await runWithContext(
|
||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||
() =>
|
||
handleInstalledApps(
|
||
makeReq({ page: 0, limit: 999, desc: '1' }),
|
||
res,
|
||
),
|
||
);
|
||
|
||
expect(Array.isArray(captured.body)).toBe(true);
|
||
});
|
||
});
|