Compare commits

..
569 changed files with 10340 additions and 42463 deletions

No files matched your search

-5
View File
@@ -16,8 +16,3 @@ MARIADB_PASSWORD=replace-with-strong-password
S3_ACCESS_KEY=puter
S3_SECRET_KEY=replace-with-strong-secret
S3_BUCKET=puter-local
# ── Inbound SMTP (compose profile `smtp`) ------------------------------
# Host port mapped to the receiver's 2525. 25 is what an MX record means;
# many hosting providers block it.
SMTP_PORT=25
+46 -57
View File
@@ -1,72 +1,61 @@
# Default owner, including backend and SDK areas without a specialist below.
# The last matching rule takes precedence.
- @Salazareo
* @Salazareo
# GUI
/src/gui/ @jelveh @Salazareo
# Documentation, developer site, and CLI
/doc/ @reynaldichernando @Salazareo
/src/docs/ @reynaldichernando @Salazareo
/src/dev-center/ @reynaldichernando @Salazareo
/src/cli/ @reynaldichernando @Salazareo
# Backend AI
/src/backend/controllers/puterai/ @404oops @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai-\*/ @404oops @ProgrammerIn-wonderland @Salazareo
/doc/ @reynaldichernando @Salazareo
/src/docs/ @reynaldichernando @Salazareo
/src/dev-center/ @reynaldichernando @Salazareo
/src/cli/ @reynaldichernando @Salazareo
# Backend: AI, hosting, workers, networking, and WebDAV
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox\* @ProgrammerIn-wonderland @Salazareo
/src/worker/ @ProgrammerIn-wonderland @Salazareo
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
/src/worker/ @ProgrammerIn-wonderland @Salazareo
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
# Backend: sharing, permissions, and teams
/src/backend/controllers/share/ @jfcastro92 @Salazareo
/src/backend/controllers/team/ @jfcastro92 @Salazareo
/src/backend/services/share/ @jfcastro92 @Salazareo
/src/backend/services/permission/ @jfcastro92 @Salazareo
/src/backend/services/acl/ @jfcastro92 @Salazareo
/src/backend/services/team/ @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService\* @jfcastro92 @Salazareo
/src/backend/stores/share/ @jfcastro92 @Salazareo
/src/backend/stores/permission/ @jfcastro92 @Salazareo
/src/backend/stores/group/ @jfcastro92 @Salazareo
/src/backend/stores/team/ @jfcastro92 @Salazareo
/src/backend/controllers/share/ @jfcastro92 @Salazareo
/src/backend/controllers/team/ @jfcastro92 @Salazareo
/src/backend/services/share/ @jfcastro92 @Salazareo
/src/backend/services/permission/ @jfcastro92 @Salazareo
/src/backend/services/acl/ @jfcastro92 @Salazareo
/src/backend/services/team/ @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
/src/backend/stores/share/ @jfcastro92 @Salazareo
/src/backend/stores/permission/ @jfcastro92 @Salazareo
/src/backend/stores/group/ @jfcastro92 @Salazareo
/src/backend/stores/team/ @jfcastro92 @Salazareo
# SDK: AI, hosting, workers, and networking
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer\* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
# SDK: sharing and permissions
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
+4 -6
View File
@@ -47,8 +47,6 @@ Before opening a PR, scan the diff for:
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
Commit messages and PR descriptions never credit anyone or mention a bug report. For security fixes, state what the change does mechanically ("scoped tokens do xyz now"), not the vulnerability or how it could be abused.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
@@ -67,10 +65,10 @@ root (or merge into the existing file):
```yaml
services:
puter:
pull_policy: never
build:
context: .
puter:
pull_policy: never
build:
context: .
```
Compose merges it automatically. Run `docker compose up -d --build` to build and
-2
View File
@@ -79,8 +79,6 @@ ENV PUTER_CONFIG_PATH=/etc/puter/config.json
ENV NODE_OPTIONS=--enable-source-maps
EXPOSE 4100
# The inbound SMTP receiver, when run from this image with the `smtp` profile.
EXPOSE 2525
USER node
-32
View File
@@ -255,38 +255,6 @@
}
},
// ── Inbound mail (separate process) ─────────────────────────────────
// Mailboxes addressed at one of your own domains. `secret` gates the
// ingress endpoint that files a message into the recipient's ~/.mail.
//
// `localServer` additionally runs an SMTP listener that accepts mail from
// other mail servers and hands each message to that endpoint. It runs as
// its own process - `npm run start:smtp`, or the `smtp` compose profile -
// and stores nothing itself. Without it, nothing delivers to the endpoint
// unless you point something else at it.
//
// The listener is unauthenticated by nature: anything on the internet may
// hand it a message for an address in `localDomains`. It performs no spam,
// SPF, DKIM or DMARC checks. Read the self-hosting guide before exposing
// it.
// "userEmail": {
// "secret": "a long random string",
// "localServer": true,
// "localDomains": ["example.com"],
// // Container-internal by default; publish 25 onto it from outside.
// "localPort": 2525,
// // Defaults to <api_base_url>/email/ingress. The endpoint is served
// // on the `api` subdomain, so when this points somewhere internal,
// // `localIngressHost` has to name that virtual host.
// "localIngressUrl": "http://puter:4100/email/ingress",
// "localIngressHost": "api.example.com",
// "localHostname": "mx.example.com",
// "localMaxRecipients": 50,
// // Concurrent connections. Each message is held in memory while it
// // is delivered, so this also bounds memory use.
// "localMaxClients": 20
// },
// ── Sharing ─────────────────────────────────────────────────────────
// Email a recipient who already has an account about a new share. On
// unless set to false; they opt out via the unsubscribe link, or by
-105
View File
@@ -35,12 +35,6 @@ Optional services (compose profile `ai`, opt-in):
| `puter-ollama` | `ollama/ollama` | Local LLM provider (CPU; GPU passthrough opt-in) |
| `puter-ollama-init` | `ollama/ollama` | One-shot — pulls the default model (`tinyllama`) on first boot |
Optional services (compose profile `smtp`, opt-in):
| Container | Image | Role |
| ------------ | ------------------------ | -------------------------------------------------------- |
| `puter-smtp` | `ghcr.io/heyputer/puter` | Receives mail over SMTP and files it into user mailboxes |
State lives under `./puter/data/<service>/`.
---
@@ -150,16 +144,6 @@ Why these knobs:
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
- **Bucket CORS** — the browser uploads file bytes straight to RustFS with a cross-origin `PUT` to the presigned URL (Dev Center deploy, `puter.fs.upload`). That only works if the bucket answers the `OPTIONS` preflight with `Access-Control-Allow-*` headers. The `s3-init` container applies those rules on every boot (`put-bucket-cors`, origins `*` — the signed URL is the credential, and the SDK sends no cookies). If deploys fail with `No 'Access-Control-Allow-Origin' header is present`, re-run it: `docker compose run --rm s3-init`. Verify with:
```bash
curl -sk -X OPTIONS "https://s3.<domain>/<bucket>/test" \
-H "Origin: https://<domain>" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: content-type" -D - -o /dev/null | grep -i access-control
```
You should see `access-control-allow-origin`, `allow-methods: GET, HEAD, PUT, POST, DELETE`, `allow-headers: *`, and `max-age: 3600`.
- `trust_proxy: 1` — Caddy terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the Caddy container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (Caddy). Bump to `2` if you put Cloudflare in front of Caddy; never set `true` (it trusts every hop and makes XFF forgeable).
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
@@ -519,95 +503,6 @@ Without `--profile ai`, the `ollama` containers stay down and Puter (with `enabl
For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama` service in [docker-compose.yml](../docker-compose.yml). Requires `nvidia-container-toolkit` on the host.
## Optional: receiving mail over SMTP
Puter can give each account a mailbox at a domain you control. Two pieces are
involved, and they are configured together under `userEmail`:
- an **ingress endpoint** (`POST /email/ingress`) that files a message into the
recipient's `~/.mail`. It exists whenever `userEmail.secret` is set.
- the **`puter-smtp` receiver**, which accepts mail from other mail servers and
hands each message to that endpoint. It runs as its own process, behind the
`smtp` compose profile.
> **Read this before you expose it.** An inbound mail server is unauthenticated
> by nature: anyone on the internet may hand it a message for any address in
> `localDomains`. Puter performs **no** spam filtering and **no** SPF, DKIM or
> DMARC checks — whatever is delivered is stored in the recipient's mailbox and
> counts against their storage. If that is not acceptable, put a filtering relay
> in front of it and point `localDomains` at that instead.
1. Configure it in `puter/config/config.json`:
```json
"userEmail": {
"secret": "a long random string",
"localServer": true,
"localDomains": ["example.com"]
}
```
`secret` is shared between the two halves; generate it with
`openssl rand -hex 32`. `localDomains` lists the domains this server accepts
mail for — anything else is refused at `RCPT TO`, which is what stops it
being used as an open relay. It has no default, and the receiver refuses to
start without it.
The receiver posts to `<api_base_url>/email/ingress` unless you set
`localIngressUrl`. The endpoint is served on the `api` subdomain, so if you
point it at an internal address to skip the reverse proxy, name that virtual
host too or the request will 404:
```json
"localIngressUrl": "http://puter:4100/email/ingress",
"localIngressHost": "api.example.com"
```
`localIngressHost` defaults to the host of `api_base_url`, which is usually
what you want.
2. Bring it up with the `smtp` profile:
```bash
docker compose --profile smtp up -d
docker compose logs -f puter-smtp
```
3. Point DNS at it:
```dns
mx.example.com. A 203.0.113.10
example.com. MX 10 mx.example.com.
```
Also set a `PTR` record for the address that resolves back to
`mx.example.com` — many senders refuse mail from a server without one.
Inbound TCP port 25 must reach the host. **Most cloud providers block port
25 by default and residential ISPs block it permanently**; if you cannot get
it unblocked, this will not work no matter how it is configured. The
container itself binds 2525 (it runs unprivileged) and compose publishes
`${SMTP_PORT:-25}` onto it, so you can move the host port if you need to.
A message is held in memory while it is being delivered, so peak memory is
bounded by `localMaxClients` (default 20) times the 25 MiB message ceiling.
Lower `localMaxClients` on a small host. There is no queue and no retry
schedule — if delivery fails, the sending server is told to try again later and
retrying is its job.
Mail is stored as `message/rfc822` objects under `~/.mail/objects/<date>/` and
read back through `puter.email` in the SDK.
Every setting is documented in
[config.template.jsonc](../config.template.jsonc). Two deliberate limits, both
in the name of keeping the self-hosted path simple: it does not offer STARTTLS,
so mail arrives in the clear, and it applies no per-sender rate limiting. Put a
filtering relay in front if either matters to you.
Note this is a *receiving* server only: it never sends, and it must not be used
for authenticated submission. Outbound transactional mail is the separate
`email` block above.
## Building from source instead of pulling
To run a local build against the full stack, use a source checkout and complete
+2 -50
View File
@@ -139,10 +139,8 @@ services:
start_period: 5s
s3-init:
# One-shot container that creates the `puter-local` bucket and
# applies its CORS rules on every boot (put-bucket-cors is
# idempotent). Without CORS, browser uploads to presigned URLs
# fail the preflight check.
# One-shot container that creates the `puter-local` bucket on first
# boot. Exits 0 once the bucket exists; stays exited 0 thereafter.
image: amazon/aws-cli:latest
container_name: puter-s3-init
depends_on:
@@ -165,26 +163,6 @@ services:
echo "creating bucket $$bucket"
aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket"
fi
# Browser uploads (Dev Center deploy, puter.fs.upload) PUT to
# presigned URLs cross-origin, so the bucket must answer the
# OPTIONS preflight. Origins are open because the presigned URL
# itself is the credential and the SDK sends no cookies.
cat > /tmp/cors.json <<'EOF'
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD", "PUT", "POST", "DELETE"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 3600
}
]
}
EOF
aws --endpoint-url "$$endpoint" s3api put-bucket-cors \
--bucket "$$bucket" --cors-configuration file:///tmp/cors.json
echo "bucket CORS configured"
restart: "no"
# ── Optional: local LLM ───────────────────────────────────────────
@@ -286,32 +264,6 @@ services:
retries: 3
start_period: 30s
# Inbound mail. Behind the `smtp` profile, so it only starts when asked for:
# docker compose --profile smtp up -d
# Accepts SMTP from other mail servers and hands each message to the ingress
# endpoint, which files it into the recipient's mailbox. Needs `userEmail`
# configured — see doc/self-hosting.md#optional-receiving-mail-over-smtp.
puter-smtp:
profiles: ["smtp"]
image: ghcr.io/heyputer/puter:main
pull_policy: always
container_name: puter-smtp
restart: unless-stopped
command: ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/smtp/index.js"]
depends_on:
puter:
condition: service_healthy
ports:
# The container runs unprivileged and binds 2525; 25 is what an MX record
# means, and many providers block it.
- "${SMTP_PORT:-25}:2525"
environment:
PUID: 1000
PGID: 1000
volumes:
# The same config.json the backend reads; only `userEmail` applies here.
- ./puter/config:/etc/puter:z
caddy:
image: caddy:2.11-alpine
container_name: puter-caddy
-165
View File
@@ -8,7 +8,6 @@ import {
it,
vi,
} from 'vitest';
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
@@ -89,80 +88,6 @@ describe('metering extension — handleMeteringUsage', () => {
(captured.body as Record<string, unknown>).allowanceInfo,
).toBeDefined();
});
describe('usage split across apps', () => {
const seedUsage = async () => {
const user = await seedUser();
const owner = { uuid: user.uuid, id: user.id as number };
const metering = server.services.metering;
await metering.incrementUsage(
makeActor({ user: owner, app: { uid: 'app-mine' } }),
'kv:read',
1,
10,
);
await metering.incrementUsage(
makeActor({ user: owner, app: { uid: 'app-other' } }),
'kv:write',
1,
20,
);
await metering.incrementUsage(
makeActor({ user: owner }),
'kv:list',
1,
40,
);
await server.stores.meteringBuffer.flushCycle();
return owner;
};
const usageAs = async (actor: Actor) => {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleMeteringUsage(makeReq(), res),
);
return captured.body as {
usage: Record<string, unknown> & { total: number };
appTotals: Record<string, unknown>;
allowanceInfo: Record<string, unknown>;
};
};
it('reports only the calling app’s own usage to an app actor', async () => {
const owner = await seedUsage();
const body = await usageAs(
makeActor({ user: owner, app: { uid: 'app-mine' } }),
);
expect(body.usage.total).toBe(10);
expect(Object.keys(body.usage).sort()).toEqual([
'kv:read',
'total',
]);
expect(body.appTotals).toEqual({
'app-mine': { total: 10, count: 1 },
});
expect(body.allowanceInfo).toMatchObject({
remaining: expect.any(Number),
monthUsageAllowance: expect.any(Number),
});
});
it('still reports the whole account to the user', async () => {
const owner = await seedUsage();
const body = await usageAs(makeActor({ user: owner }));
expect(body.usage.total).toBe(70);
expect(body.usage).toHaveProperty('kv:read');
expect(body.usage).toHaveProperty('kv:write');
expect(body.usage).toHaveProperty('kv:list');
expect(body.appTotals).toMatchObject({
'app-mine': { total: 10, count: 1 },
'app-other': { total: 20, count: 1 },
});
});
});
});
describe('metering extension — handleMeteringUsageForApp', () => {
@@ -207,96 +132,6 @@ describe('metering extension — handleMeteringUsageForApp', () => {
),
).rejects.toMatchObject({ statusCode: 404 });
});
describe('scoped to the calling app', () => {
const appUsageAs = async (actor: Actor, appIdOrName: string) => {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleMeteringUsageForApp(
makeReq({ params: { appIdOrName } }),
res,
),
);
return captured.body as { total: number };
};
const seedApps = async () => {
const user = await seedUser();
const owner = { uuid: user.uuid, id: user.id as number };
const mine = `app-${uuidv4()}`;
const other = `app-${uuidv4()}`;
await server.services.metering.incrementUsage(
makeActor({ user: owner, app: { uid: mine } }),
'kv:read',
1,
10,
);
await server.services.metering.incrementUsage(
makeActor({ user: owner, app: { uid: other } }),
'kv:write',
1,
20,
);
await server.stores.meteringBuffer.flushCycle();
return {
owner,
mine,
other,
asMine: makeActor({ user: owner, app: { uid: mine } }),
};
};
it('lets an app read its own usage', async () => {
const { mine, asMine } = await seedApps();
expect((await appUsageAs(asMine, mine)).total).toBe(10);
});
it('refuses an app another app’s usage', async () => {
const { other, asMine } = await seedApps();
await expect(appUsageAs(asMine, other)).rejects.toMatchObject({
statusCode: 403,
});
});
it('refuses an app another app’s usage by name', async () => {
const { owner, other, asMine } = await seedApps();
const name = `other-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[other, name, name, `https://${name}.example/`, owner.id],
);
await expect(appUsageAs(asMine, name)).rejects.toMatchObject({
statusCode: 403,
});
});
it('refuses an app the usage outside any app', async () => {
const { asMine } = await seedApps();
await expect(appUsageAs(asMine, 'os-global')).rejects.toMatchObject(
{ statusCode: 403 },
);
});
it('still lets the user read any of their apps', async () => {
const { owner, mine, other } = await seedApps();
const asUser = makeActor({ user: owner });
expect((await appUsageAs(asUser, mine)).total).toBe(10);
expect((await appUsageAs(asUser, other)).total).toBe(20);
});
it('lets the user read their usage outside any app', async () => {
const { owner } = await seedApps();
const asUser = makeActor({ user: owner });
await server.services.metering.incrementUsage(
asUser,
'kv:read',
1,
5,
);
await server.stores.meteringBuffer.flushCycle();
expect((await appUsageAs(asUser, 'os-global')).total).toBe(5);
});
});
});
describe('metering extension — handleMeteringGlobalUsage', () => {
+2 -4
View File
@@ -6,7 +6,6 @@ import {
servicesContainers,
} from '@heyputer/backend/src/exports';
import { extension } from '@heyputer/backend/src/extensions';
import { GLOBAL_APP_KEY } from '@heyputer/backend/src/services/metering/consts';
import {
creditMultiplierFrom,
toCredits,
@@ -152,9 +151,8 @@ export const handleMeteringUsageForApp = async (
let appId = String(req.params.appIdOrName ?? '');
if (!appId) throw new HttpError(400, 'appId parameter is required');
// If not a UUID-shaped app UID or the global sentinel, look up by name.
// Which apps an actor may read is MeteringService's call, not this route's.
if (!appId.startsWith('app-') && appId !== GLOBAL_APP_KEY) {
// If not a UUID-shaped app UID, look up by name
if (!appId.startsWith('app-')) {
const appRows = (await clients.db.read(
'SELECT `uid` FROM `apps` WHERE `name` = ? LIMIT 1',
[appId],
+173 -496
View File
@@ -1,10 +1,8 @@
import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
type S3Client,
} from '@aws-sdk/client-s3';
import type { Request, Response } from 'express';
import crypto from 'node:crypto';
import {
afterAll,
@@ -14,17 +12,8 @@ import {
it,
vi,
} from 'vitest';
import type { FSController } from '../src/backend/controllers/fs/FSController.ts';
import type {
ClientSignedWriteResponse,
CompleteWriteRequest,
SignedWriteRequest,
} from '../src/backend/controllers/fs/requestTypes.ts';
import type { Actor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import { generateDefaultFsentries } from '../src/backend/util/userProvisioning.ts';
import {
handleFsCopyNodeThumbnail,
handleFsRemoveNodeThumbnail,
@@ -38,33 +27,9 @@ const TINY_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const BUCKET = 'puter-local';
const BUCKET_ENDPOINT = 'http://127.0.0.1:4566/puter-local/';
// Keys the extension mints: `thumbnails/<entry uuid>/<uuid>`.
const mintedKey = (entryUuid: string = crypto.randomUUID()) =>
`thumbnails/${entryUuid}/${crypto.randomUUID()}`;
// Minted before keys were bound to an entry.
const legacyKey = () => `thumbnails/${crypto.randomUUID()}`;
const putObject = async (s3: S3Client, key: string, body: Buffer) => {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: 'image/png',
}),
);
};
const objectExists = async (s3: S3Client, key: string) => {
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
return true;
} catch {
return false;
}
};
// Keys the extension will accept back: its own `thumbnails/<uuid>` namespace.
const mintedKey = () => `thumbnails/${crypto.randomUUID()}`;
const streamToBuffer = async (
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
@@ -75,42 +40,28 @@ const streamToBuffer = async (
describe('thumbnails extension — handleThumbnailCreated', () => {
let server: PuterServer;
let s3: S3Client;
beforeAll(async () => {
server = await setupTestServer();
s3 = server.clients.s3.get();
});
afterAll(async () => {
await server?.shutdown();
});
const created = async (url: unknown, uuid?: unknown) => {
const event: Record<string, unknown> = { url, uuid };
await handleThumbnailCreated(event, {
s3,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
});
return event.url;
};
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {
url: `data:image/png;base64,${TINY_PNG_BASE64}`,
};
it('uploads a valid data: URL thumbnail under a key bound to the entry', async () => {
const entryUuid = crypto.randomUUID();
const newUrl = await created(
`data:image/png;base64,${TINY_PNG_BASE64}`,
entryUuid,
);
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
expect(typeof newUrl).toBe('string');
expect(
(newUrl as string).startsWith(
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
),
).toBe(true);
expect(typeof event.url).toBe('string');
const newUrl = event.url as string;
expect(newUrl.startsWith(`s3://${BUCKET}/`)).toBe(true);
const key = (newUrl as string).slice(`s3://${BUCKET}/`.length);
const key = newUrl.slice(`s3://${BUCKET}/`.length);
const obj = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
@@ -122,77 +73,34 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
});
it('sets event.url to null when the data: URL does not decode to a valid image', async () => {
const url = await created(
`data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
crypto.randomUUID(),
);
expect(url).toBeNull();
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {
url: `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`,
};
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
expect(event.url).toBeNull();
});
it('sets event.url to null for a data: URL with no entry to bind it to', async () => {
const url = await created(`data:image/png;base64,${TINY_PNG_BASE64}`);
expect(url).toBeNull();
});
// A documented SDK input: `thumbnail` / `thumbnailGenerator` may return
// a plain URL. It never names our storage, so it is stored as given.
it('leaves an external image URL untouched', async () => {
it('leaves event.url untouched when the URL is not a data: URL', async () => {
const s3 = server.clients.s3.get();
const original = 'https://example.com/thumb.png';
expect(await created(original, crypto.randomUUID())).toBe(original);
const event: Record<string, unknown> = { url: original };
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
expect(event.url).toBe(original);
});
it('returns without writing to S3 when event.url is missing', async () => {
const s3 = server.clients.s3.get();
const event: Record<string, unknown> = {};
await handleThumbnailCreated(event, {
s3,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
});
await handleThumbnailCreated(event, { s3, bucketName: BUCKET });
expect(event.url).toBeUndefined();
});
it('keeps an uploaded pointer minted for the entry, normalised to our bucket', async () => {
const entryUuid = crypto.randomUUID();
const key = mintedKey(entryUuid);
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
expect(
await created(`s3://client-named-bucket/${key}`, entryUuid),
).toBe(`s3://${BUCKET}/${key}`);
});
// A thumbnail key is visible to anyone who can list the entry holding it.
// Planting it on a file of one's own used to hand its deletion to them.
it.each([
['a key minted for another entry', () => mintedKey()],
['a key that predates entry binding', legacyKey],
])('drops a pointer naming %s', async (_label, makeKey) => {
const key = makeKey();
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
expect(
await created(`s3://${BUCKET}/${key}`, crypto.randomUUID()),
).toBeNull();
expect(await objectExists(s3, key)).toBe(true);
});
it('drops a pointer whose object was never uploaded', async () => {
const entryUuid = crypto.randomUUID();
expect(
await created(`s3://${BUCKET}/${mintedKey(entryUuid)}`, entryUuid),
).toBeNull();
});
it('drops and removes an uploaded object over the size bound', async () => {
const entryUuid = crypto.randomUUID();
const key = mintedKey(entryUuid);
await putObject(s3, key, Buffer.alloc(2 * 1024 * 1024 + 1));
expect(await created(`s3://${BUCKET}/${key}`, entryUuid)).toBeNull();
expect(await objectExists(s3, key)).toBe(false);
});
});
describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
@@ -240,15 +148,10 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
expect(item.thumbnailUrl).toBeUndefined();
});
it.each([
['exceeds the max thumbnail bytes', { size: 999_999_999 }],
['is not declared', {}],
['is not a whole number', { size: 10.5 }],
])('skips items whose size %s', async (_label, sizeField) => {
it('skips items whose size exceeds the max thumbnail bytes', async () => {
const item: Record<string, unknown> = {
contentType: 'image/png',
item_uid: crypto.randomUUID(),
...sizeField,
size: 999_999_999,
};
await handleThumbnailUploadPrepare(
{ items: [item] },
@@ -258,26 +161,10 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
expect(item.thumbnailUrl).toBeUndefined();
});
it('skips items with no entry to bind the key to', async () => {
it('mints a presigned uploadUrl and an s3:// thumbnailUrl for valid items', async () => {
const item: Record<string, unknown> = {
contentType: 'image/png',
size: 1024,
item_uid: 'not-a-uuid',
};
await handleThumbnailUploadPrepare(
{ items: [item] },
{ s3Presign, bucketName: BUCKET },
);
expect(item.uploadUrl).toBeUndefined();
expect(item.thumbnailUrl).toBeUndefined();
});
it('mints a size-bound upload URL for a key bound to the entry', async () => {
const entryUuid = crypto.randomUUID();
const item: Record<string, unknown> = {
contentType: 'image/png',
size: 1024,
item_uid: entryUuid,
};
await handleThumbnailUploadPrepare(
{ items: [item] },
@@ -287,15 +174,8 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
expect((item.uploadUrl as string).startsWith('http')).toBe(true);
expect(typeof item.thumbnailUrl).toBe('string');
expect(
(item.thumbnailUrl as string).startsWith(
`s3://${BUCKET}/thumbnails/${entryUuid}/`,
),
(item.thumbnailUrl as string).startsWith(`s3://${BUCKET}/`),
).toBe(true);
// A signed `content-length` makes the store refuse any other body size.
const signedHeaders = new URL(
item.uploadUrl as string,
).searchParams.get('X-Amz-SignedHeaders');
expect(signedHeaders).toContain('content-length');
});
});
@@ -316,41 +196,34 @@ describe('thumbnails extension — handleThumbnailRead', () => {
await server?.shutdown();
});
// Thumbnails stored before keys were bound to entries keep displaying.
it.each([
['an entry-bound key', () => mintedKey()],
['a key that predates entry binding', legacyKey],
])(
'rewrites an s3:// thumbnail naming %s into a presigned https URL',
async (_label, makeKey) => {
// Seed an object so the presigned URL points at something real
// (the signer itself doesn't validate existence, but this keeps
// the test honest).
const key = makeKey();
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
ContentType: 'image/png',
}),
);
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
// Seed an object so the presigned URL points at something real
// (the signer itself doesn't validate existence, but this keeps
// the test honest).
const key = mintedKey();
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
ContentType: 'image/png',
}),
);
const entry: Record<string, unknown> = {
thumbnail: `s3://${BUCKET}/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
db: stubDb,
});
const entry: Record<string, unknown> = {
thumbnail: `s3://${BUCKET}/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
db: stubDb,
});
expect(typeof entry.thumbnail).toBe('string');
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
},
);
expect(typeof entry.thumbnail).toBe('string');
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
});
// `fsentries.thumbnail` is writable through the FS API, so a stored
// pointer is attacker input. Signing one the extension didn't mint would
@@ -372,10 +245,6 @@ describe('thumbnails extension — handleThumbnailRead', () => {
`s3://${BUCKET}/thumbnails/../${crypto.randomUUID()}`,
],
['a non-uuid inside the namespace', `s3://${BUCKET}/thumbnails/etc`],
[
'a key nested deeper than the namespace allows',
`s3://${BUCKET}/${mintedKey()}/${crypto.randomUUID()}`,
],
])('refuses to presign a pointer naming %s', async (_label, thumbnail) => {
const entry: Record<string, unknown> = { thumbnail };
await handleThumbnailRead(entry, {
@@ -432,9 +301,8 @@ describe('thumbnails extension — handleThumbnailRead', () => {
});
it('migrates an inline data: URL by uploading to S3 and updating the DB row', async () => {
const entryUuid = crypto.randomUUID();
const entry: Record<string, unknown> = {
uuid: entryUuid,
uuid: 'fs-entry-uuid',
thumbnail: `data:image/png;base64,${TINY_PNG_BASE64}`,
};
@@ -454,27 +322,9 @@ describe('thumbnails extension — handleThumbnailRead', () => {
await Promise.resolve();
expect(stubDb.write).toHaveBeenCalledWith(
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
[
expect.stringMatching(
new RegExp(`^s3://${BUCKET}/thumbnails/${entryUuid}/`),
),
entryUuid,
],
[expect.stringMatching(/^s3:\/\//), 'fs-entry-uuid'],
);
});
it('leaves an inline data: URL as is when there is no entry to bind it to', async () => {
const thumbnail = `data:image/png;base64,${TINY_PNG_BASE64}`;
const entry: Record<string, unknown> = { thumbnail };
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
db: stubDb,
});
expect(entry.thumbnail).toBe(thumbnail);
});
});
describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
@@ -490,63 +340,66 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
await server?.shutdown();
});
const remove = (target: { thumbnail?: string | null; uuid?: string }) =>
handleFsRemoveNodeThumbnail(
{ target },
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT },
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
const key = mintedKey();
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
ContentType: 'image/png',
}),
);
it('deletes the object minted for the removed entry', async () => {
const entryUuid = crypto.randomUUID();
const key = mintedKey(entryUuid);
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: `s3://${BUCKET}/${key}` } },
{ s3, bucketName: BUCKET },
);
await remove({ thumbnail: `s3://${BUCKET}/${key}`, uuid: entryUuid });
expect(await objectExists(s3, key)).toBe(false);
// GetObject should now error because the key was deleted.
await expect(
s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: key })),
).rejects.toThrow();
});
// The destructive half of the same confused deputy: the stored pointer
// decides which object is deleted, so only a key minted for the removed
// entry may reach DeleteObject.
it.each([
// Shaped like an fs object key.
[
'an object outside the thumbnails namespace',
() => crypto.randomUUID(),
],
// Visible to anyone who can list the entry it belongs to.
["another entry's thumbnail", () => mintedKey()],
// Can't be told apart from a planted or shared pointer.
['a thumbnail that predates entry binding', legacyKey],
])('does not delete %s', async (_label, makeKey) => {
const victimKey = makeKey();
await putObject(s3, victimKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
// decides which object is deleted, so a key the extension didn't mint
// must never reach DeleteObject.
it('does not delete an object the pointer names but we did not mint', async () => {
const victimKey = crypto.randomUUID(); // shaped like an fs object key
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: victimKey,
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
ContentType: 'image/png',
}),
);
await remove({
thumbnail: `s3://${BUCKET}/${victimKey}`,
uuid: crypto.randomUUID(),
});
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: `s3://${BUCKET}/${victimKey}` } },
{ s3, bucketName: BUCKET },
);
expect(await objectExists(s3, victimKey)).toBe(true);
});
it('does not delete through an external URL that names our key', async () => {
const entryUuid = crypto.randomUUID();
const key = mintedKey(entryUuid);
await putObject(s3, key, Buffer.from(TINY_PNG_BASE64, 'base64'));
await remove({
thumbnail: `https://cdn.example.com/${BUCKET}/${key}`,
uuid: entryUuid,
});
expect(await objectExists(s3, key)).toBe(true);
const survivor = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: victimKey }),
);
expect(survivor.ContentType).toBe('image/png');
});
it('is a no-op when the target has no thumbnail', async () => {
// Should not throw or attempt a delete.
await remove({});
await handleFsRemoveNodeThumbnail(
{ target: {} },
{ s3, bucketName: BUCKET },
);
});
it('is a no-op when the thumbnail URL is not an s3:// pointer', async () => {
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: 'https://cdn.example.com/x.png' } },
{ s3, bucketName: BUCKET },
);
});
});
@@ -563,58 +416,64 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
await server?.shutdown();
});
const copyNode = async (thumbnail: string | null, copyUuid: string) => {
it('duplicates the shared thumbnail object and repoints the copied row', async () => {
const sourceKey = mintedKey();
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: sourceKey,
Body: body,
ContentType: 'image/png',
}),
);
const copyUuid = crypto.randomUUID();
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{ copy: { thumbnail, uuid: copyUuid } },
{ s3, bucketName: BUCKET, bucketEndpoint: BUCKET_ENDPOINT, db },
{
copy: {
thumbnail: `s3://${BUCKET}/${sourceKey}`,
uuid: copyUuid,
},
},
{ s3, bucketName: BUCKET, db },
);
return db;
};
it.each([
['an entry-bound key', () => mintedKey()],
['a key that predates entry binding', legacyKey],
])(
'duplicates a thumbnail naming %s under a key bound to the copy',
async (_label, makeKey) => {
const sourceKey = makeKey();
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
await putObject(s3, sourceKey, body);
// The copied row was repointed at a fresh object...
expect(db.write).toHaveBeenCalledTimes(1);
const [, params] = db.write.mock.calls[0] as [string, [string, string]];
const [newPointer, updatedUuid] = params;
expect(updatedUuid).toBe(copyUuid);
expect(newPointer.startsWith(`s3://${BUCKET}/thumbnails/`)).toBe(true);
expect(newPointer).not.toBe(`s3://${BUCKET}/${sourceKey}`);
const copyUuid = crypto.randomUUID();
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
// The copied row was repointed at a fresh object of its own...
expect(db.write).toHaveBeenCalledTimes(1);
const [, params] = db.write.mock.calls[0] as [
string,
[string, string],
];
const [newPointer, updatedUuid] = params;
expect(updatedUuid).toBe(copyUuid);
expect(
newPointer.startsWith(`s3://${BUCKET}/thumbnails/${copyUuid}/`),
).toBe(true);
// ...whose content matches, while the source object survives — so
// deleting either entry can no longer break the other's thumbnail.
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
const duplicated = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
);
expect(
(await streamToBuffer(duplicated.Body as never)).equals(body),
).toBe(true);
expect(await objectExists(s3, sourceKey)).toBe(true);
},
);
// ...whose content matches, while the source object survives — so
// deleting either entry can no longer break the other's thumbnail.
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
const duplicated = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
);
expect(
(await streamToBuffer(duplicated.Body as never)).equals(body),
).toBe(true);
const original = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey }),
);
expect(original.ContentType).toBe('image/png');
});
it('drops the pointer when the shared object is already gone', async () => {
const copyUuid = crypto.randomUUID();
const db = await copyNode(
`s3://${BUCKET}/${mintedKey()}`, // never uploaded
copyUuid,
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{
copy: {
thumbnail: `s3://${BUCKET}/${mintedKey()}`, // never uploaded
uuid: copyUuid,
},
},
{ s3, bucketName: BUCKET, db },
);
expect(db.write).toHaveBeenCalledTimes(1);
@@ -623,209 +482,27 @@ describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
expect(params).toEqual([copyUuid]);
});
// Only reachable for objects uploaded before upload URLs were size-bound;
// copying one would multiply bytes no allowance counts.
it('drops the pointer instead of duplicating an object over the size bound', async () => {
const sourceKey = legacyKey();
await putObject(s3, sourceKey, Buffer.alloc(2 * 1024 * 1024 + 1));
const copyUuid = crypto.randomUUID();
const db = await copyNode(`s3://${BUCKET}/${sourceKey}`, copyUuid);
expect(db.write).toHaveBeenCalledTimes(1);
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
expect(sql).toContain('NULL');
expect(params).toEqual([copyUuid]);
});
it('does not duplicate an object the pointer names but we did not mint', async () => {
const foreignKey = crypto.randomUUID(); // shaped like an fs object key
const db = await copyNode(
`s3://${BUCKET}/${foreignKey}`,
crypto.randomUUID(),
);
expect(db.write).not.toHaveBeenCalled();
});
it('leaves an external image URL to ride along as is', async () => {
const sourceKey = legacyKey();
await putObject(s3, sourceKey, Buffer.from(TINY_PNG_BASE64, 'base64'));
const db = await copyNode(
`https://cdn.example.com/${BUCKET}/${sourceKey}`,
crypto.randomUUID(),
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{
copy: {
thumbnail: `s3://${BUCKET}/${foreignKey}`,
uuid: crypto.randomUUID(),
},
},
{ s3, bucketName: BUCKET, db },
);
expect(db.write).not.toHaveBeenCalled();
});
it('is a no-op when the copy has no thumbnail', async () => {
const db = await copyNode(null, crypto.randomUUID());
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{ copy: { thumbnail: null, uuid: crypto.randomUUID() } },
{ s3, bucketName: BUCKET, db },
);
expect(db.write).not.toHaveBeenCalled();
});
});
// The flow `puter.fs.upload()` runs — start, PUT to the signed URLs, complete
// with the returned pointer — through the real controller and the listeners
// this extension registers.
describe('thumbnails extension — signed batch upload through /fs', () => {
let server: PuterServer;
let controller: FSController;
const png = Buffer.from(TINY_PNG_BASE64, 'base64');
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.fs as unknown as FSController;
});
afterAll(async () => {
await server?.shutdown();
});
const makeActor = async (): Promise<{ actor: Actor; username: string }> => {
const username = `thumb-${Math.random().toString(36).slice(2, 10)}`;
const user = await server.stores.user.create({
username,
uuid: crypto.randomUUID(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
user,
);
return {
username,
actor: {
user: {
id: user.id,
uuid: user.uuid,
username,
email: user.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
const call = async <B>(
actor: Actor,
handler: (req: Request, res: Response) => Promise<void>,
body: B,
): Promise<unknown> => {
let captured: unknown;
const res = {
json: (value: unknown) => {
captured = value;
return res;
},
status: () => res,
setHeader: () => res,
};
const req = {
body,
query: {},
headers: { 'content-type': 'application/json' },
actor,
user: { id: actor.user!.id, username: actor.user!.username },
};
await runWithContext({ actor }, () =>
handler.call(
controller,
req as unknown as Request,
res as unknown as Response,
),
);
return captured;
};
const upload = async (
actor: Actor,
path: string,
thumbnailData: (
started: ClientSignedWriteResponse,
) => string | undefined,
) => {
const [started] = (await call<SignedWriteRequest[]>(
actor,
controller.startBatchWrites,
[
{
fileMetadata: { path, size: 4 },
thumbnailMetadata: {
contentType: 'image/png',
size: png.length,
},
},
],
)) as ClientSignedWriteResponse[];
await fetch(started!.url!, { method: 'PUT', body: 'abcd' });
if (started!.thumbnailUploadUrl) {
await fetch(started!.thumbnailUploadUrl, {
method: 'PUT',
body: png,
headers: { 'content-type': 'image/png' },
});
}
const thumbnail = thumbnailData(started!);
await call<CompleteWriteRequest[]>(
actor,
controller.completeBatchWrites,
[
{
uploadId: started!.sessionId,
...(thumbnail === undefined
? {}
: { thumbnailData: thumbnail }),
},
],
);
return {
started: started!,
entry: (await server.stores.fsEntry.getEntryByPath(path))!,
};
};
it('stores the pointer minted for the uploaded entry', async () => {
const { actor, username } = await makeActor();
const { started, entry } = await upload(
actor,
`/${username}/Documents/photo.png`,
(s) => s.thumbnailUrl,
);
expect(started.thumbnailUrl).toMatch(
new RegExp(`^s3://${BUCKET}/thumbnails/${entry.uuid}/`),
);
expect(entry.thumbnail).toBe(started.thumbnailUrl);
});
it("drops a pointer minted for another user's entry", async () => {
const victim = await makeActor();
const { entry: victimEntry } = await upload(
victim.actor,
`/${victim.username}/Documents/photo.png`,
(s) => s.thumbnailUrl,
);
const attacker = await makeActor();
const { entry } = await upload(
attacker.actor,
`/${attacker.username}/Documents/decoy.png`,
() => victimEntry.thumbnail!,
);
expect(entry.thumbnail).toBeNull();
});
it('keeps an external image URL as given', async () => {
const { actor, username } = await makeActor();
const { entry } = await upload(
actor,
`/${username}/Documents/linked.png`,
() => 'https://example.com/thumb.png',
);
expect(entry.thumbnail).toBe('https://example.com/thumb.png');
});
});
+51 -144
View File
@@ -2,13 +2,11 @@ import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { extension } from '@heyputer/backend/src/extensions';
import { isMissingObjectError } from '@heyputer/backend/src/stores/fs/S3ObjectStore';
import crypto from 'node:crypto';
import sharp from 'sharp';
const clients = extension.import('client');
@@ -24,13 +22,8 @@ const THUMBNAIL_KEY_PREFIX = 'thumbnails/';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isUuid = (value: unknown): value is string =>
typeof value === 'string' && UUID_PATTERN.test(value);
// `thumbnails/<entry uuid>/<random uuid>`: the entry segment binds the object
// to the entry it was minted for, which is what writes and deletes check.
const mintThumbnailKey = (entryUuid: string): string =>
`${THUMBNAIL_KEY_PREFIX}${entryUuid}/${crypto.randomUUID()}`;
const mintThumbnailKey = (): string =>
`${THUMBNAIL_KEY_PREFIX}${crypto.randomUUID()}`;
/**
* Extract the object key from a stored thumbnail pointer, or null when the
@@ -38,13 +31,12 @@ const mintThumbnailKey = (entryUuid: string): string =>
*
* `fsentries.thumbnail` is writable through the FS API, so neither half of the
* stored string is trusted: the bucket is discarded (callers always pass their
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} as either
* `<uuid>` (minted before keys were bound to entries) or `<entry uuid>/<uuid>`.
* Honouring an arbitrary key would lend this extension's storage credentials to
* whatever object the caller named — in the shared-bucket layout that is every
* user's file, since an fs object's key is its fsentry uuid. Bare-uuid keys
* from before the prefix fail the check and are treated as absent; they are
* indistinguishable from a planted pointer, so there is nothing safer to do
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} with a random
* uuid. Honouring an arbitrary key would lend this extension's storage
* credentials to whatever object the caller named — in the shared-bucket layout
* that is every user's file, since an fs object's key is its fsentry uuid.
* Legacy bare-uuid thumbnails fail the check and are treated as absent; they
* are indistinguishable from a planted pointer, so there is nothing safer to do
* with them than stop signing them.
*/
const resolveThumbnailKey = (pointer: string): string | null => {
@@ -66,26 +58,10 @@ const resolveThumbnailKey = (pointer: string): string | null => {
key = segments.join('/');
}
if (!key.startsWith(THUMBNAIL_KEY_PREFIX)) return null;
const segments = key.slice(THUMBNAIL_KEY_PREFIX.length).split('/');
return segments.length <= 2 && segments.every(isUuid) ? key : null;
const id = key.slice(THUMBNAIL_KEY_PREFIX.length);
return UUID_PATTERN.test(id) ? key : null;
};
// The entry a resolved key was minted for. Null for an unbound key, whose
// holder can't be told apart from a row that copied or planted the pointer.
const thumbnailKeyOwner = (key: string): string | null => {
const segments = key.slice(THUMBNAIL_KEY_PREFIX.length).split('/');
return segments.length === 2 ? segments[0] : null;
};
// Whether a stored thumbnail names an object in our storage. Anything else is
// an external image URL the client supplied: shown as given, never copied or
// deleted.
const isStoragePointer = (pointer: string, bucketEndpoint: string): boolean =>
pointer.startsWith('s3://') ||
// Legacy format — remove after full migration
(pointer.startsWith('https') &&
pointer.includes(new URL(bucketEndpoint).hostname));
// S3 client + bucket config — lazily resolved after boot from config.
let s3Client: S3Client | null = null;
let s3PresignClient: S3Client | null = null;
@@ -168,73 +144,16 @@ async function decodeAndValidateThumbnail(
return { mimeType, data };
}
// A storage pointer a client hands back after a presigned upload is kept only
// if it names a key minted for this entry and an object within the size bound.
async function vetUploadedThumbnail(
pointer: string,
entryUuid: unknown,
deps: { s3: S3Client; bucketName: string },
): Promise<string | null> {
const key = resolveThumbnailKey(pointer);
if (!key || thumbnailKeyOwner(key) !== entryUuid) return null;
let size: number | undefined;
try {
const head = await deps.s3.send(
new HeadObjectCommand({ Bucket: deps.bucketName, Key: key }),
);
size = head.ContentLength;
} catch (err) {
if (!isMissingObjectError(err)) {
console.warn(
'[thumbnails] failed to check uploaded thumbnail',
err,
);
}
return null;
}
if (typeof size !== 'number') return null;
if (size > MAX_THUMBNAIL_BYTES) {
// Only reachable on a store that doesn't enforce the signed length.
// The key is this entry's own, so removing it is safe.
try {
await deps.s3.send(
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
);
} catch (err) {
console.warn(
'[thumbnails] failed to remove oversized thumbnail',
err,
);
}
return null;
}
return `s3://${deps.bucketName}/${key}`;
}
// -- thumbnail.created -----------------------------------------------
// Intercept data-URL thumbnails before they hit the DB: upload to S3
// and replace the URL with an s3:// pointer bound to the entry.
// and replace the URL with an s3:// pointer.
export async function handleThumbnailCreated(
event: Record<string, unknown>,
deps: { s3: S3Client; bucketName: string; bucketEndpoint: string },
deps: { s3: S3Client; bucketName: string },
): Promise<void> {
const url = event.url;
if (typeof url !== 'string') return;
if (!url.startsWith('data:')) {
if (isStoragePointer(url, deps.bucketEndpoint)) {
event.url = await vetUploadedThumbnail(url, event.uuid, deps);
}
return;
}
const entryUuid = event.uuid;
if (!isUuid(entryUuid)) {
event.url = null;
return;
}
if (typeof url !== 'string' || !url.startsWith('data:')) return;
const decoded = await decodeAndValidateThumbnail(url);
if (!decoded) {
@@ -242,7 +161,7 @@ export async function handleThumbnailCreated(
return;
}
const key = mintThumbnailKey(entryUuid);
const key = mintThumbnailKey();
event.url = `s3://${deps.bucketName}/${key}`;
await deps.s3.send(
@@ -271,24 +190,21 @@ export const handleThumbnailUploadPrepare = async (
typeof item.contentType === 'string' ? item.contentType.trim() : '';
if (!contentType) continue;
const size = item.size;
if (
typeof size !== 'number' ||
!Number.isInteger(size) ||
size < 0 ||
size > MAX_THUMBNAIL_BYTES
)
continue;
if (!isUuid(item.item_uid)) continue;
if (item.size !== undefined) {
const size = Number(item.size);
if (
!Number.isFinite(size) ||
size < 0 ||
size > MAX_THUMBNAIL_BYTES
)
continue;
}
const key = mintThumbnailKey(item.item_uid);
const key = mintThumbnailKey();
const command = new PutObjectCommand({
Bucket: deps.bucketName,
Key: key,
ContentType: contentType,
// Signing covers `content-length`, so a body of any other size
// fails the signature instead of landing.
ContentLength: size,
});
item.uploadUrl = await getSignedUrl(presignClient, command, {
expiresIn: 900,
@@ -311,7 +227,12 @@ export const handleThumbnailRead = async (
if (typeof thumb !== 'string' || !thumb) return;
const presignClient = deps.s3Presign;
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
if (
thumb.startsWith('s3://') ||
// Legacy format — remove after full migration
(thumb.startsWith('https') &&
thumb.includes(new URL(deps.bucketEndpoint).hostname))
) {
const key = resolveThumbnailKey(thumb);
if (!key) {
// Not a pointer we minted — refuse to sign it rather than hand
@@ -326,9 +247,7 @@ export const handleThumbnailRead = async (
);
} else if (thumb.startsWith('data')) {
// Inline data-URL migration: upload to S3 and update the DB entry.
const uuid = entry.uuid ?? entry.uid;
if (!isUuid(uuid)) return;
const key = mintThumbnailKey(uuid);
const key = mintThumbnailKey();
const { mimeType, data } = base64ParseDataUrl(thumb);
const newUrl = `s3://${deps.bucketName}/${key}`;
@@ -342,6 +261,7 @@ export const handleThumbnailRead = async (
);
// Best-effort async DB update
const uuid = entry.uuid ?? entry.uid;
if (uuid) {
deps.db
.write(
@@ -366,36 +286,24 @@ export const handleFsCopyNodeThumbnail = async (
deps: {
s3: S3Client;
bucketName: string;
bucketEndpoint: string;
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
},
): Promise<void> => {
const copy = payload.copy;
const thumbnailUrl = copy?.thumbnail;
if (!copy || !isUuid(copy.uuid) || typeof thumbnailUrl !== 'string') return;
if (!isStoragePointer(thumbnailUrl, deps.bucketEndpoint)) return;
if (!copy || !copy.uuid || typeof thumbnailUrl !== 'string') return;
// Same trust rule as the read and remove paths: only touch objects this
// extension minted.
const sourceKey = resolveThumbnailKey(thumbnailUrl);
if (!sourceKey) return;
// The copied row points at the SAME object as its source. Give the copy
// an object of its own, bound to it, so removing either entry leaves the
// other's thumbnail alone.
const newKey = mintThumbnailKey(copy.uuid);
// The copied row points at the SAME S3 object as its source, and
// fs.remove.node deletes the pointed-to object — so the first removal
// among the sharers (an overwrite, a trash purge) would break every
// other sharer's thumbnail. Give the copy an object of its own.
const newKey = mintThumbnailKey();
try {
// Nothing larger than an upload may produce gets duplicated; such an
// object came in through an unbounded upload URL.
const head = await deps.s3.send(
new HeadObjectCommand({ Bucket: deps.bucketName, Key: sourceKey }),
);
if (
typeof head.ContentLength !== 'number' ||
head.ContentLength > MAX_THUMBNAIL_BYTES
) {
throw new Error('thumbnail exceeds the size bound');
}
await deps.s3.send(
new CopyObjectCommand({
Bucket: deps.bucketName,
@@ -404,8 +312,10 @@ export const handleFsCopyNodeThumbnail = async (
}),
);
} catch (err) {
// The shared object is gone or oversized — drop the pointer rather
// than leave the row advertising a thumbnail it doesn't have.
// The shared object is already gone (e.g. a sharer was removed
// before this fix existed) — the pointer is dead either way, so
// drop it rather than leave the row advertising a thumbnail it
// doesn't have.
await deps.db.write(
'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?',
[copy.uuid],
@@ -421,18 +331,18 @@ export const handleFsCopyNodeThumbnail = async (
};
export const handleFsRemoveNodeThumbnail = async (
payload: { target: { thumbnail?: string | null; uuid?: string } },
deps: { s3: S3Client; bucketName: string; bucketEndpoint: string },
payload: { target: { thumbnail?: string | null } },
deps: { s3: S3Client; bucketName: string },
): Promise<void> => {
const { thumbnail: thumbnailUrl, uuid } = payload.target;
if (!thumbnailUrl || !isStoragePointer(thumbnailUrl, deps.bucketEndpoint))
return;
const thumbnailUrl = payload.target.thumbnail;
if (!thumbnailUrl) return;
// The pointer decides which object gets deleted, and a key is visible to
// anyone who can list the entry holding it. Only a key minted for this
// entry is deleted; an unbound one may be shared or planted, so it stays.
// Same trust rule as the read path, and load-bearing for the same reason:
// the pointer decides which object gets deleted, so a key we didn't mint
// would let the owner of one file destroy an object belonging to someone
// else just by naming it here.
const key = resolveThumbnailKey(thumbnailUrl);
if (!key || thumbnailKeyOwner(key) !== uuid) return;
if (!key) return;
await deps.s3.send(
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
@@ -445,7 +355,6 @@ extension.on(
await handleThumbnailCreated(event, {
s3: getClient(),
bucketName: thumbnailBucketName,
bucketEndpoint: extensionBucketEndpoint,
});
},
);
@@ -488,7 +397,6 @@ extension.on('fs.copy.node', async (_key, payload) => {
{
s3: getClient(),
bucketName: thumbnailBucketName,
bucketEndpoint: extensionBucketEndpoint,
db: clients.db,
},
);
@@ -501,6 +409,5 @@ extension.on('fs.remove.node', async (_key, payload) => {
await handleFsRemoveNodeThumbnail(payload, {
s3: getClient(),
bucketName: thumbnailBucketName,
bucketEndpoint: extensionBucketEndpoint,
});
});
-355
View File
@@ -1,355 +0,0 @@
import { EventEmitter } from 'node:events';
import { Readable } from 'node:stream';
import type { Request, Response } from 'express';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const SECRET = 'ingress-secret';
// Only `extension` is stubbed; the secret comparison and the routing are the
// real code paths, and the fake service/store layer is the boundary.
const state = vi.hoisted(() => ({
config: { userEmail: { secret: 'ingress-secret' } } as Record<
string,
unknown
>,
routes: [] as Array<{
path: string;
handler: (req: unknown, res: unknown) => unknown;
}>,
users: new Map<string, Record<string, unknown>>(),
writes: [] as Array<{
userId: number;
path: string;
homeRegion?: string;
content?: unknown;
}>,
feedback: [] as Array<{ to: string; from: string | null; raw: Buffer }>,
takeFeedback: true,
}));
vi.mock('@heyputer/backend/src/extensions', () => ({
extension: {
get config() {
return state.config;
},
post(
path: string,
_opts: unknown,
handler: (req: unknown, res: unknown) => unknown,
) {
state.routes.push({ path, handler });
},
import(layer: string) {
if (layer === 'client') {
return {
event: {
async emitAndWait(
key: string,
data: {
to: string;
from: string | null;
raw: Buffer;
handled: boolean;
},
) {
if (key !== 'email.ingress.feedback') return;
state.feedback.push(data);
if (state.takeFeedback) data.handled = true;
},
},
};
}
if (layer === 'store') {
return {
user: {
getByUsername: async (username: string) =>
state.users.get(username) ?? null,
},
};
}
return {
fs: {
async write(
userId: number,
{
fileMetadata,
fileContent,
}: {
fileMetadata: { path: string };
fileContent: unknown;
},
_uploadTracker?: unknown,
_storageAllowanceMax?: number,
homeRegion?: string,
) {
state.writes.push({
userId,
path: fileMetadata.path,
homeRegion,
content: fileContent,
});
return { fsEntry: { path: fileMetadata.path } };
},
},
};
},
},
}));
import './index.js';
const permanent = (id: number, username: string) => ({
id,
username,
email: `${username}@example.com`,
password: '$2b$10$hash',
});
/** No password and no email is what makes an account temporary. */
const temp = (id: number, username: string) => ({
id,
username,
email: null,
password: null,
});
const ingress = async (
to: string,
over: { secret?: string; contentLength?: string | null } = {},
) => {
const route = state.routes.find((r) => r.path === '/email/ingress');
if (!route) throw new Error('route not registered');
const body = Buffer.from('From: [email protected]\r\n\r\nhello\r\n');
const req = Readable.from([body], { objectMode: false }) as unknown as {
headers: Record<string, string>;
query: Record<string, string>;
};
// `contentLength: null` sends no length header at all, which is the one
// framing the HTTP parser does not bound for us.
req.headers =
over.contentLength === null
? {}
: {
'content-length': over.contentLength ?? String(body.length),
};
req.query = {
SECRET: over.secret ?? SECRET,
to,
from: '[email protected]',
subject: 'hi',
};
let status = 200;
let ended = false;
// `end` takes (chunk, encoding) and no callback, the way the compression
// middleware leaves it, and emits `finish` — which is what the refusal
// paths hang their drain/destroy on. A stub that accepts a callback
// instead hides the bug that took the 413 path to production as a 500.
const emitter = new EventEmitter();
const res = {
status(code: number) {
status = code;
return res;
},
end(chunk?: unknown, encoding?: BufferEncoding) {
if (chunk) Buffer.byteLength(chunk as string, encoding);
ended = true;
emitter.emit('finish');
},
once(event: string, listener: () => void) {
emitter.once(event, listener);
return res;
},
} as unknown as Response;
await route.handler(req as unknown as Request, res);
return {
status,
ended,
drained: (req as unknown as Readable).readableFlowing,
destroyed: (req as unknown as Readable).destroyed,
};
};
beforeEach(() => {
state.users.clear();
state.writes.length = 0;
state.feedback.length = 0;
state.takeFeedback = true;
});
describe('temporary accounts cannot receive', () => {
test('mail for a temp account is accepted and dropped', async () => {
state.users.set('tempy', temp(1, 'tempy'));
const { status, ended, drained } = await ingress('[email protected]');
// Accepted, so the relay neither retries nor bounces to the sender.
expect(status).toBe(200);
expect(ended).toBe(true);
// The body is read off the wire rather than left unconsumed.
expect(drained).toBe(true);
expect(state.writes).toEqual([]);
});
test('mail for a permanent account still lands in the mailbox', async () => {
state.users.set('bob', permanent(2, 'bob'));
const { status } = await ingress('[email protected]');
expect(status).toBe(200);
expect(state.writes).toHaveLength(1);
expect(state.writes[0].userId).toBe(2);
expect(state.writes[0].path).toMatch(/^\/bob\/\.mail\/objects\//);
});
test('an unknown address is still rejected outright', async () => {
const { status } = await ingress('[email protected]');
expect(status).toBe(404);
expect(state.writes).toEqual([]);
});
});
describe('inbound mail is placed in the recipient home region', () => {
/** A permanent account with explicit placement columns. */
const placed = (
id: number,
username: string,
over: { home?: string | null; signup_server?: string | null },
) => ({
...permanent(id, username),
home: null,
signup_server: null,
...over,
});
test("uses the recipient's own home region when they have one", async () => {
state.users.set('bob', placed(2, 'bob', { home: 'frankfurt' }));
await ingress('[email protected]');
expect(state.writes[0].homeRegion).toBe('frankfurt');
});
test('falls back to the server that served their signup', async () => {
// Every account that predates the `home` column is this case.
state.users.set(
'bob',
placed(2, 'bob', { home: null, signup_server: 'london' }),
);
await ingress('[email protected]');
expect(state.writes[0].homeRegion).toBe('london');
});
test('falls back to the primary region when neither is set', async () => {
state.users.set('bob', placed(2, 'bob', {}));
await ingress('[email protected]');
expect(state.writes[0].homeRegion).toBe('oregon');
});
test('home wins over signup_server when both are set', async () => {
state.users.set(
'bob',
placed(2, 'bob', { home: 'sydney', signup_server: 'london' }),
);
await ingress('[email protected]');
expect(state.writes[0].homeRegion).toBe('sydney');
});
});
describe('refusals dispose of the body nobody read', () => {
test('an unknown recipient drains it and keeps the connection', async () => {
// The secret checked out and the length is under the cap, so the body
// was ours to read: discard it rather than resetting a usable socket.
const { status, drained, destroyed } =
await ingress('[email protected]');
expect(status).toBe(404);
expect(drained).toBe(true);
expect(destroyed).toBe(false);
});
test('a bad secret closes rather than reading the upload', async () => {
state.users.set('bob', permanent(2, 'bob'));
const { status, destroyed } = await ingress('[email protected]', {
secret: 'not-the-secret',
});
expect(status).toBe(403);
// Nothing authenticated this caller; their bytes are not worth reading.
expect(destroyed).toBe(true);
expect(state.writes).toEqual([]);
});
test('no content-length is refused with 411', async () => {
state.users.set('bob', permanent(2, 'bob'));
const { status, destroyed } = await ingress('[email protected]', {
contentLength: null,
});
expect(status).toBe(411);
expect(destroyed).toBe(true);
expect(state.writes).toEqual([]);
});
test('a declared length over the cap is refused with 413', async () => {
state.users.set('bob', permanent(2, 'bob'));
const { status, destroyed } = await ingress('[email protected]', {
contentLength: String(25 * 1024 * 1024 + 1),
});
expect(status).toBe(413);
expect(destroyed).toBe(true);
expect(state.writes).toEqual([]);
});
});
describe('feedback addresses', () => {
test('are handed to a listener and reach no mailbox', async () => {
const { status, ended } = await ingress('[email protected]');
expect(status).toBe(200);
expect(ended).toBe(true);
expect(state.feedback).toHaveLength(1);
expect(state.feedback[0].to).toBe('[email protected]');
expect(state.feedback[0].from).toBe('[email protected]');
expect(state.feedback[0].raw.toString()).toContain('hello');
expect(state.writes).toEqual([]);
});
test('match the local part case-insensitively', async () => {
await ingress('[email protected]');
expect(state.feedback).toHaveLength(1);
});
test('fall through to normal routing when nobody takes them', async () => {
state.takeFeedback = false;
state.users.set('abuse', permanent(3, 'abuse'));
const { status } = await ingress('[email protected]');
expect(status).toBe(200);
expect(state.feedback).toHaveLength(1);
// The body was already read, so the buffered copy is what is stored.
expect(state.writes).toHaveLength(1);
expect(Buffer.isBuffer(state.writes[0].content)).toBe(true);
});
test('an unclaimed one with no such account is still a 404', async () => {
state.takeFeedback = false;
const { status } = await ingress('[email protected]');
expect(status).toBe(404);
});
test('only count on a Puter mail domain', async () => {
state.users.set('abuse', permanent(3, 'abuse'));
await ingress('[email protected]');
expect(state.feedback).toEqual([]);
});
test('are refused over the feedback size cap without being read', async () => {
const { status, destroyed } = await ingress('[email protected]', {
contentLength: String(5 * 1024 * 1024 + 1),
});
expect(status).toBe(413);
expect(destroyed).toBe(true);
expect(state.feedback).toEqual([]);
});
});
-161
View File
@@ -1,161 +0,0 @@
import { extension } from '@heyputer/backend/src/extensions';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Request } from 'express';
import {
MAX_MESSAGE_BYTES,
isPuterEmailAddress,
isTempUser,
puterEmailUsername,
storeInboxMessage,
} from '@heyputer/backend/src/services/email/mailbox.js';
// copied form peer secrets checker
const COMPARE_KEY = randomBytes(32);
const secretsEqual = (a: string, b: string): boolean =>
timingSafeEqual(
createHmac('sha256', COMPARE_KEY).update(a).digest(),
createHmac('sha256', COMPARE_KEY).update(b).digest(),
);
const USER_EMAIL_CONFIG = (extension.config as Record<string, unknown>)
.userEmail as { secret?: string; feedbackAddresses?: unknown } | undefined;
const INGRESS_SECRET = USER_EMAIL_CONFIG?.secret;
/**
* Local parts that take complaint feedback rather than naming a mailbox. The
* same names are reserved at signup, so no account can be handed them.
*/
const DEFAULT_FEEDBACK_ADDRESSES = ['fbl', 'abuse', 'postmaster'];
const FEEDBACK_ADDRESSES = new Set(
(Array.isArray(USER_EMAIL_CONFIG?.feedbackAddresses)
? USER_EMAIL_CONFIG.feedbackAddresses
: DEFAULT_FEEDBACK_ADDRESSES
).map((local) => String(local).toLowerCase()),
);
/**
* Feedback is handed over whole, so it is read into memory; a complaint report
* quotes one message and has no business near the mailbox ceiling.
*/
const MAX_FEEDBACK_BYTES = 5 * 1024 * 1024;
const isFeedbackAddress = (to: string): boolean =>
isPuterEmailAddress(to) &&
FEEDBACK_ADDRESSES.has(puterEmailUsername(to).toLowerCase());
const readBody = async (req: Request): Promise<Buffer> => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
return Buffer.concat(chunks);
};
if (!INGRESS_SECRET) {
console.warn(
'[useremailrx] config.userEmail.secret unset - email ingress disabled',
);
} else {
extension.post(
'/email/ingress',
{
subdomain: 'api',
},
async (req, res) => {
// Answer first, then deal with the body nobody read. Touching the
// request before the response has flushed takes the response down
// with the connection and the caller sees a reset with no status,
// so this hangs off `finish` — and it has to be the event rather
// than `res.end(cb)`, because the compression middleware replaces
// `res.end` with an `(chunk, encoding)` signature that has no
// callback parameter, so a callback lands in `chunk` and dies in
// `Buffer.byteLength`.
//
// `drain` discards the rest of a body we were entitled to read and
// leaves the connection reusable. `close` is for bodies we do not
// want to read at all: an unauthenticated caller's, or one whose
// declared length is missing or over the cap.
const refuse = (status: number, how: 'drain' | 'close') => {
res.once('finish', () =>
how === 'drain' ? req.resume() : req.destroy(),
);
res.status(status).end();
};
const presented = req.query.SECRET;
if (
typeof presented !== 'string' ||
!secretsEqual(presented, INGRESS_SECRET)
) {
// Nothing has authenticated this caller, so refuse to spend
// bandwidth reading whatever they were uploading.
refuse(403, 'close');
return;
}
const size = Number(req.headers['content-length']);
if (!Number.isInteger(size) || size <= 0) {
refuse(411, 'close');
return;
}
if (size > MAX_MESSAGE_BYTES) {
refuse(413, 'close');
return;
}
const stores = extension.import('store');
const services = extension.import('service');
const to = String(req.query.to ?? '');
let content: Buffer | Request = req;
if (isFeedbackAddress(to)) {
if (size > MAX_FEEDBACK_BYTES) {
refuse(413, 'close');
return;
}
const raw = await readBody(req);
const event = {
to,
from:
typeof req.query.from === 'string'
? req.query.from
: null,
raw,
handled: false,
};
await extension
.import('client')
.event.emitAndWait('email.ingress.feedback', event, {});
if (event.handled) {
res.end();
return;
}
// Nobody took it: route it like any other message.
content = raw;
}
const user = await stores.user.getByUsername(
puterEmailUsername(to),
);
if (!user) {
refuse(404, 'drain');
return;
}
if (isTempUser(user)) {
if (content === req) req.resume();
res.end();
return;
}
await storeInboxMessage(services.fs, user, {
subject: req.query.subject,
content,
size: Buffer.isBuffer(content) ? content.length : size,
});
const { SECRET: _secret, ..._loggableQuery } = req.query;
// console.log('got email: ', puterUser, loggableQuery, size);
res.end();
},
);
}
-12
View File
@@ -1,12 +0,0 @@
{
"name": "emailrx",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "emailrx",
"version": "1.0.0"
}
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"name": "emailrx",
"version": "1.0.0",
"main": "index.ts",
"description": "Internal service which handles routing puter emails to users"
}
+1 -99
View File
@@ -8,7 +8,7 @@ import {
it,
vi,
} from 'vitest';
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
import { makeActor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
import { PuterServer } from '../src/backend/server.ts';
@@ -285,104 +285,6 @@ describe('whoami extension — handleWhoami', () => {
expect(body.directories).toBeUndefined();
});
describe('fields added by whoami.details listeners', () => {
const addAccountDetails = (
_key: unknown,
event: { details: Record<string, unknown> },
) => {
Object.assign(event.details, {
subscribed: true,
paid_storage: 1024,
subscription: { tier: 'pro', status: 'trialing' },
accountEligible: true,
});
};
const whoamiWithListener = async (
actor: Actor,
listener: typeof addAccountDetails = addAccountDetails,
) => {
const { res, captured } = makeRes();
server.clients.event.on('whoami.details', listener);
try {
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
} finally {
server.clients.event.off('whoami.details', listener);
}
return captured.body as Record<string, unknown>;
};
it('reach an app actor only as the documented subscribed / paid_storage', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
}),
);
expect(body.subscribed).toBe(true);
expect(body.paid_storage).toBe(1024);
expect(body).not.toHaveProperty('subscription');
expect(body).not.toHaveProperty('accountEligible');
// Fields the handler built itself still ship.
expect(body.username).toBe(user.username);
expect(body.app_name).toBe('app-test-actor');
});
it('are withheld from an app reached through an access token it issued', async () => {
const user = await seedUser();
const owner = { uuid: user.uuid, id: user.id as number };
const body = await whoamiWithListener(
makeActor({
user: owner,
accessToken: {
uid: 'tok-1',
issuer: makeActor({
user: owner,
app: { uid: 'app-issuer' },
}),
},
}),
);
expect(body.subscribed).toBe(true);
expect(body).not.toHaveProperty('subscription');
expect(body).not.toHaveProperty('accountEligible');
});
it('all reach a user actor', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
);
expect(body).toMatchObject({
subscribed: true,
paid_storage: 1024,
subscription: { tier: 'pro', status: 'trialing' },
accountEligible: true,
});
});
it('cannot add taskbar_items for an app actor, which core omits', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.taskbar_items = [{ name: 'injected' }];
},
);
expect(body).not.toHaveProperty('taskbar_items');
});
});
it('redacts tmp_password from metadata for user actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
+13 -35
View File
@@ -71,13 +71,6 @@ const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'audit_metadata',
]);
// What an app actor may receive from `whoami.details` listeners. Anything
// else a listener adds describes the account, and only ships to user actors.
const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
'subscribed',
'paid_storage',
]);
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
// at any level (`metadata` and `taskbar_items` are both nested structures).
const scrubSensitive = (
@@ -193,6 +186,19 @@ export const handleWhoami = async (
// this endpoint is polled, and a mint is a write.
referral_code: user.referral_code,
oidc_only: oidcOnly,
taskbar_items: isUser
? await getTaskbarItems(
user,
{
clients,
stores,
services,
apiBaseUrl: String(extension.config.api_base_url ?? ''),
config: extension.config,
},
{ iconSize, noIcons },
)
: undefined,
otp: !!user.otp_enabled,
feature_flags,
created_ts: toUnixSeconds(user.timestamp),
@@ -221,21 +227,6 @@ export const handleWhoami = async (
}
}
// Taskbar items — only sent to user actors
if (isUser) {
details.taskbar_items = await getTaskbarItems(
user,
{
clients,
stores,
services,
apiBaseUrl: String(extension.config.api_base_url ?? ''),
config: extension.config,
},
{ iconSize, noIcons },
);
}
// Directories — only sent to user actors
if (isUser) {
const directories: Record<string, unknown> = {};
@@ -301,12 +292,6 @@ export const handleWhoami = async (
details.app_name = app.uid;
}
// A key core left undefined isn't built, so a listener can't fill it for an app.
const builtKeys = isUser
? null
: new Set(
Object.keys(details).filter((key) => details[key] !== undefined),
);
try {
await clients.event.emitAndWait(
'whoami.details',
@@ -316,13 +301,6 @@ export const handleWhoami = async (
} catch {
/* best-effort */
}
if (builtKeys) {
for (const key of Object.keys(details)) {
if (!builtKeys.has(key) && !APP_VISIBLE_LISTENER_KEYS.has(key)) {
delete details[key];
}
}
}
const subscription = details.subscription as
{ offering?: Record<string, unknown> } | undefined;
+79 -110
View File
@@ -16,7 +16,7 @@
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.14",
"libphonenumber-js": "1.13.13",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
@@ -2043,9 +2043,9 @@
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2124,9 +2124,9 @@
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2412,9 +2412,9 @@
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
@@ -6153,17 +6153,6 @@
"@types/node": "*"
}
},
"node_modules/@types/smtp-server": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@types/smtp-server/-/smtp-server-3.5.13.tgz",
"integrity": "sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/nodemailer": "*"
}
},
"node_modules/@types/tedious": {
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
@@ -7196,13 +7185,13 @@
}
},
"node_modules/axios": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
@@ -7283,6 +7272,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
@@ -7508,9 +7506,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8758,15 +8756,16 @@
}
},
"node_modules/engine.io": {
"version": "6.6.11",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz",
"integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
@@ -9182,9 +9181,9 @@
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9543,9 +9542,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -9569,9 +9568,9 @@
}
},
"node_modules/fastify": {
"version": "5.12.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz",
"integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==",
"version": "5.12.3",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz",
"integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==",
"funding": [
{
"type": "github",
@@ -10798,12 +10797,6 @@
"node": ">= 10"
}
},
"node_modules/ipv6-normalize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz",
"integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==",
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -11499,9 +11492,9 @@
}
},
"node_modules/libphonenumber-js": {
"version": "1.13.14",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz",
"integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==",
"version": "1.13.13",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.13.tgz",
"integrity": "sha512-hV6SmWnyQ8U1QXhhqbqYBHIStNnscG7A5XbGodgdHhf/8N5PwhYpJA5U/yHDjYwPYTd8uNGXqq416XctuvK2PA==",
"license": "MIT"
},
"node_modules/light-my-request": {
@@ -14232,9 +14225,9 @@
}
},
"node_modules/postal-mime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.1.tgz",
"integrity": "sha512-N86EnYOxZjW1uHQIgSXnnRdnJz+wxmtDsnk3C4R/9rQ1utj8A2znauXZ3Q3+stCHxG5ckY4/NrO09WiPU6rcdQ==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
"license": "MIT-0"
},
"node_modules/postcss": {
@@ -14343,9 +14336,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true,
"license": "MIT",
"bin": {
@@ -14495,15 +14488,6 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/puter-mcp-connector": {
"resolved": "src/mcp-connector",
"link": true
@@ -14861,9 +14845,9 @@
"license": "MIT"
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -15546,36 +15530,14 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT"
},
"node_modules/smtp-server": {
"version": "3.19.13",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.13.tgz",
"integrity": "sha512-W1CjNcrPqW4+1gUOYRyjPvTOxu7GgKR9+7l50yQBF0GM8v8jpYwheHFCHX7s+p7pYI8iXKQQZiF2Rv2Na/3i2g==",
"license": "MIT-0",
"dependencies": {
"ipv6-normalize": "1.0.1",
"nodemailer": "10.0.10",
"punycode.js": "2.3.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/smtp-server/node_modules/nodemailer": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.10.tgz",
"integrity": "sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz",
"integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==",
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
@@ -15614,9 +15576,9 @@
}
},
"node_modules/socket.io-client": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz",
"integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==",
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -16171,9 +16133,9 @@
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -16576,9 +16538,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -17376,6 +17338,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/wrangler/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/wrangler/node_modules/workerd": {
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
@@ -17652,7 +17624,7 @@
"@pagerduty/pdjs": "^2.2.4",
"@smithy/node-http-handler": "^2.5.0",
"@socket.io/redis-streams-adapter": "^0.3.1",
"axios": "^1.20.0",
"axios": "^1.15.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"busboy": "^1.6.0",
@@ -17678,11 +17650,9 @@
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
"postal-mime": "^3.0.1",
"replicate": "^1.0.0",
"sharp": "^0.35.4",
"smtp-server": "^3.19.13",
"socket.io": "^4.8.4",
"socket.io": "^4.8.3",
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
@@ -17700,7 +17670,6 @@
"@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/smtp-server": "^3.5.13",
"@types/validator": "^13.15.10",
"pgmock": "^1.0.3",
"typescript": "^5.9.3",
@@ -18468,14 +18437,14 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.6.4",
"version": "2.6.3",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0",
"path-browserify": "1.0.1",
"postal-mime": "^3.0.1",
"socket.io-client": "^4.8.4"
"postal-mime": "^3.0.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
+2 -3
View File
@@ -50,7 +50,6 @@
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node ./tools/start.mjs",
"start:smtp": "node ./tools/start.mjs --smtp",
"dev": "npm start",
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
@@ -77,7 +76,7 @@
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.14",
"libphonenumber-js": "1.13.13",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
@@ -97,6 +96,6 @@
"workerd": true
},
"overrides": {
"undici": "^7.30.0"
"undici": "^7.29.0"
}
}
@@ -184,8 +184,7 @@ describe('compareMigrationFilenames', () => {
//
// `read()` normally goes to the replica batcher; when the replica side is
// degraded (batcher load-shed or a transient connection error) and a real
// replica is configured, the read retries once on the primary read batcher.
// The write batcher is left unset, so a failover that reached it would throw.
// replica is configured, the read retries once on the primary batcher.
type Batcher = { execute: ReturnType<typeof vi.fn> };
@@ -201,7 +200,7 @@ const makeClient = (opts: {
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
Object.assign(client as unknown as Record<string, unknown>, {
dbReplica: opts.replica,
dbPrimaryRead: opts.primary,
db: opts.primary,
configuration: opts.multiNode === false ? 0 : 1,
});
return client;
@@ -285,10 +284,6 @@ const startClient = async (
// must answer with one row-set per statement plus one for the SELECT 1.
const rowSets = (...sets: unknown[][]) => [[...sets, [{ 1: 1 }]], undefined];
// Writes go out one statement at a time, so the driver answers with just the
// result header.
const writeHeader = (header: Record<string, unknown>) => [header, undefined];
beforeEach(() => {
createdPools.length = 0;
createPoolMock.mockClear();
@@ -308,9 +303,6 @@ describe('MySQLDatabaseClient — pool construction', () => {
expect(createdPools[0].poolConfig).toEqual({
maxPreparedStatements: 900,
connectionLimit: 30,
maxIdle: 29,
idleTimeout: 60_000,
gracefulEnd: true,
enableKeepAlive: true,
host: 'db.internal',
port: 3307,
@@ -321,32 +313,6 @@ describe('MySQLDatabaseClient — pool construction', () => {
});
});
// mysql2 skips its idle reaper unless maxIdle < connectionLimit, and an
// unreaped connection is closed by the server under the pool's feet.
it('keeps the idle reaper on when a pool sets its own connection limit', async () => {
await startClient({
replica: { host: 'replica.internal', connectionLimit: 10 },
});
expect(createdPools[1].poolConfig).toMatchObject({
connectionLimit: 10,
maxIdle: 9,
idleTimeout: 60_000,
});
});
// mysql2 reads 0 as unlimited; a maxIdle of -1 would crash its reaper.
it('leaves maxIdle to mysql2 for an unlimited pool', async () => {
await startClient({
replica: { host: 'replica.internal', connectionLimit: 0 },
});
expect(createdPools[1].poolConfig).toMatchObject({
connectionLimit: 0,
});
expect(createdPools[1].poolConfig).not.toHaveProperty('maxIdle');
});
it('falls back to loopback defaults when the endpoint is unspecified', async () => {
await startClient();
@@ -435,30 +401,10 @@ describe('MySQLDatabaseClient — query interface', () => {
]);
});
it('sends a write on its own, outside a transaction', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () => writeHeader({ affectedRows: 1 });
await client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
'ada',
1,
]);
expect(pool.calls).toEqual([
{
sql: 'UPDATE `user` SET `username` = ? WHERE `id` = ?',
values: ['ada', 1],
},
]);
expect(pool.connections[0].beginTransaction).not.toHaveBeenCalled();
expect(pool.connections[0].commit).not.toHaveBeenCalled();
});
it('maps the mysql result header onto the shared write result', async () => {
const client = await startClient();
createdPools[0].respond = () =>
writeHeader({ insertId: 42, affectedRows: 2 });
rowSets({ insertId: 42, affectedRows: 2 } as unknown as unknown[]);
await expect(
client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
@@ -474,7 +420,7 @@ describe('MySQLDatabaseClient — query interface', () => {
it('reports no rows affected when the header omits the counters', async () => {
const client = await startClient();
createdPools[0].respond = () => writeHeader({});
createdPools[0].respond = () => rowSets({} as unknown as unknown[]);
await expect(client.write('DELETE FROM `user`')).resolves.toEqual({
insertId: 0,
@@ -486,14 +432,17 @@ describe('MySQLDatabaseClient — query interface', () => {
it('builds an INSERT with quoted identifiers and positional params', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () => writeHeader({ insertId: 7, affectedRows: 1 });
pool.respond = () =>
rowSets({ insertId: 7, affectedRows: 1 } as unknown as unknown[]);
await expect(
client.insert('user', { username: 'ada', email: null }),
).resolves.toMatchObject({ insertId: 7 });
expect(pool.calls[0]).toEqual({
sql: 'INSERT INTO `user` (`username`, `email`) VALUES (?, ?)',
sql:
'INSERT INTO `user` (`username`, `email`) VALUES (?, ?); ' +
'SELECT 1',
values: ['ada', null],
});
});
@@ -30,8 +30,6 @@ import { compareMigrationFilenames } from './migrationFilenames.js';
import type { IConfig } from '../../types';
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
const DEFAULT_CONNECTION_LIMIT = 30;
const IDLE_TIMEOUT_MS = 60_000;
const replicaFailoverCounter = metrics
.getMeter('puter-backend')
@@ -56,7 +54,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
private replicaPool!: Pool;
private db!: SQLBatcher;
private dbReplica!: SQLBatcher;
/** Primary pool, SELECT-only: same rows as `db`, coalesced like the replica. */
/** Primary pool, SELECT-only: same rows as `db`, without the transaction. */
private dbPrimaryRead!: SQLBatcher;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
@@ -138,7 +136,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
throw error;
}
replicaFailoverCounter.add(1);
result = await this.dbPrimaryRead.execute(query, params);
result = await this.db.execute(query, params);
}
if (!result) return [];
return (result[0] as Record<string, unknown>[]) ?? [];
@@ -302,19 +300,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// ------------------------------------------------------------------
private createPool(poolConfig: PoolConfig): Pool {
const connectionLimit =
poolConfig.connectionLimit ?? DEFAULT_CONNECTION_LIMIT;
const pool = createPool({
maxPreparedStatements: 900,
connectionLimit,
// mysql2 only reaps idle connections when maxIdle < connectionLimit.
// Unreaped, they sit until the server's wait_timeout closes them,
// and the next statement sent on one fails. 0 means unlimited,
// and a negative maxIdle would crash the reaper.
...(connectionLimit > 1 && { maxIdle: connectionLimit - 1 }),
idleTimeout: IDLE_TIMEOUT_MS,
// Reaped connections say goodbye instead of dropping the socket.
gracefulEnd: true,
connectionLimit: 30,
enableKeepAlive: true,
...poolConfig,
multipleStatements: true,
@@ -342,20 +330,21 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return pool;
}
/**
* One write per flush. Coalesced writes share a transaction, so unrelated
* callers hold each other's row locks until COMMIT and deadlock; a lone
* statement needs no BEGIN/COMMIT round trips.
*/
private createPrimaryBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxBatchSize: 1,
maxTimeInQueue: 30,
maxBatchSize: 5,
poolLabel: 'primary',
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
});
}
/** Reads that must see the primary, still coalesced. */
/**
* Reads that must see the primary still only read, so they skip the
* batcher's transaction wrapper. Worth a separate batcher because BEGIN and
* COMMIT are round trips: against a primary in another region they cost
* more than the query.
*/
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxTimeInQueue: 30,
+24 -58
View File
@@ -258,11 +258,16 @@ export class SQLBatcher {
}
if (batch.length === 0) return;
const query = `${batch.map((b) => b.sql.replace(/;+\s*$/, '')).join(';')}; SELECT 1`; // SELECT 1 forces mysql2 to return array
const values = batch.map((b) => b.values ?? []).flat();
let connection;
try {
connection = await this.#acquireConnection();
} catch (error) {
this.#recordFlushFailure();
this.#consecutiveFailures++;
this.#lastFailureAt = Date.now();
flushFailureCounter.add(1, this.#metricAttrs);
console.warn(
'SQLBatcher could not acquire connection for flush:',
error,
@@ -273,14 +278,6 @@ export class SQLBatcher {
return;
}
if (batch.length === 1 && !this.readOnly) {
await this.#flushLoneWrite(batch[0], connection);
return;
}
const query = `${batch.map((b) => b.sql.replace(/;+\s*$/, '')).join(';')}; SELECT 1`; // SELECT 1 forces mysql2 to return array
const values = batch.map((b) => b.values ?? []).flat();
// Run the coalesced multi-statement inside an explicit transaction so
// a single bad statement (e.g. a duplicate-key INSERT) rolls back the
// whole batch atomically, leaving us free to re-run each item
@@ -336,7 +333,7 @@ export class SQLBatcher {
async () => {
while (cursor < batch.length) {
const i = cursor++;
settled[i] = await this.#runItem(batch[i]);
settled[i] = await this.#runFallbackItem(batch[i]);
}
},
);
@@ -370,34 +367,7 @@ export class SQLBatcher {
}
}
// A single statement is atomic under autocommit, so it skips BEGIN/COMMIT
// and their round trips. With no transaction to roll back, a failure is
// not re-run wholesale; only the per-item retry policy applies.
async #flushLoneWrite(item, connection) {
const r = await this.#runItem(item, connection);
if (r.ok) {
this.#consecutiveFailures = 0;
item.resolve(r.value);
return;
}
// An error the server answered with (duplicate key, bad SQL) means it
// is up; only failing to reach it counts toward the breaker.
if (isRetriableError(r.error)) {
this.#recordFlushFailure();
} else {
this.#consecutiveFailures = 0;
flushFailureCounter.add(1, this.#metricAttrs);
}
item.reject(r.error);
}
#recordFlushFailure() {
this.#consecutiveFailures++;
this.#lastFailureAt = Date.now();
flushFailureCounter.add(1, this.#metricAttrs);
}
// Run one item on its own, retrying transient failures with backoff.
// Run one fallback item, retrying transient failures with backoff.
// A read-only batcher may retry anything transient; a batcher that
// carries writes only retries failures where the statement provably
// did not apply — either it never reached the server, or the server
@@ -408,37 +378,33 @@ export class SQLBatcher {
// than surfacing: an item is a single statement, so a deadlock victim has
// been fully undone, and the caller sees an unhandled 500 for what the
// database is telling us to just run again.
async #runItem(b, connection) {
async #runFallbackItem(b) {
let attempt = 0;
while (true) {
if (!connection) {
try {
connection = await this.#acquireConnection();
} catch (error) {
return { ok: false, error };
}
let connection;
try {
connection = await this.#acquireConnection();
} catch (error) {
return { ok: false, error };
}
let error;
try {
return {
ok: true,
value: await connection.query(b.sql, b.values ?? []),
};
} catch (e) {
error = e;
} catch (error) {
const canRetry = this.readOnly
? isRetriableError(error) || isRolledBackError(error)
: isNeverSentError(error) || isRolledBackError(error);
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
return { ok: false, error };
}
attempt++;
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
} finally {
connection.release();
connection = undefined;
}
const canRetry = this.readOnly
? isRetriableError(error) || isRolledBackError(error)
: isNeverSentError(error) || isRolledBackError(error);
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
return { ok: false, error };
}
attempt++;
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
}
}
}
@@ -126,7 +126,7 @@ describe('SQLBatcher', () => {
});
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
const conn = makeConnection(() => [[{ n: 0 }], undefined]);
const conn = makeConnection(happyBatch);
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
@@ -277,65 +277,6 @@ describe('SQLBatcher', () => {
);
});
it('sends a lone write as-is, outside a transaction', async () => {
const conn = makeConnection((sql) => {
if (isBatchQuery(sql)) throw new Error('unexpected batch query');
return [{ affectedRows: 1 }, undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, { maxBatchSize: 1 });
const result = await batcher.query('UPDATE t SET x = ?', [1]);
expect(result[0]).toEqual({ affectedRows: 1 });
expect(conn.query).toHaveBeenCalledTimes(1);
expect(conn.query).toHaveBeenCalledWith('UPDATE t SET x = ?', [1]);
expect(conn.beginTransaction).not.toHaveBeenCalled();
expect(conn.commit).not.toHaveBeenCalled();
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('runs a lone write the server rejects once, without tripping the breaker', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const conn = makeConnection((sql) => {
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
return [{ affectedRows: 1 }, undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxBatchSize: 1,
failureThreshold: 1,
cooldownMs: 60_000,
});
await expect(batcher.query('INSERT dup', [])).rejects.toMatchObject({
code: 'ER_DUP_ENTRY',
});
expect(conn.query).toHaveBeenCalledTimes(1);
expect(conn.rollback).not.toHaveBeenCalled();
expect(warnSpy).not.toHaveBeenCalled();
// The server answered, so it is up: the next write goes through.
await expect(batcher.query('INSERT fine', [])).resolves.toBeTruthy();
warnSpy.mockRestore();
});
it('retries a lone write on a connection the server closed for inactivity', async () => {
let attempts = 0;
const conn = makeConnection(() => {
attempts++;
if (attempts === 1) {
throw makeError('ER_CLIENT_INTERACTION_TIMEOUT');
}
return [{ affectedRows: 1 }, undefined];
});
const { pool, getConnection } = makePool(conn);
const batcher = new SQLBatcher(pool, { maxBatchSize: 1 });
const result = await batcher.query('INSERT x', []);
expect(result[0]).toEqual({ affectedRows: 1 });
expect(attempts).toBe(2);
expect(getConnection).toHaveBeenCalledTimes(2);
});
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
let fallbackAttempts = 0;
const conn = makeConnection((sql) => {
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 86;
const CURRENT_SCHEMA_VERSION = 82;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -116,10 +116,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[79, ['0084_share-anyone-with-link.sql']],
[80, ['0085_event-subscriptions-include-value.sql']],
[81, ['0086_subdomains-app-owner-cascade.sql']],
[82, ['0087_feedback-attachments.sql']],
[83, ['0088_apps-index-url.sql']],
[84, ['0089_team-require-2fa.sql']],
[85, ['0090_user-app-permission-index.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1,39 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array
-- of `{name, type, size}` (NULL when none). Guarded by a throwaway procedure,
-- as in mysql_mig_21.
DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments;
DELIMITER //
CREATE PROCEDURE _puter_add_feedback_attachments()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'feedback'
AND COLUMN_NAME = 'attachments'
) THEN
ALTER TABLE `feedback` ADD COLUMN `attachments` text DEFAULT NULL;
END IF;
END//
DELIMITER ;
CALL _puter_add_feedback_attachments();
DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments;
@@ -1,44 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0088: index `apps.index_url` for origin lookups.
-- TEXT needs a prefix; 191 characters keeps the key under 767 bytes in
-- utf8mb4 whatever the table's row format, and URLs diverge well before it.
--
-- Idempotent: the guarded procedure, as mig_36. The 1061 handler covers two
-- nodes booting at once, where the loser finds the index already built.
DROP PROCEDURE IF EXISTS _puter_add_apps_index_url_index;
DELIMITER //
CREATE PROCEDURE _puter_add_apps_index_url_index()
BEGIN
DECLARE CONTINUE HANDLER FOR 1061 BEGIN END;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'apps'
AND INDEX_NAME = 'idx_apps_index_url'
) THEN
ALTER TABLE `apps`
ADD INDEX `idx_apps_index_url` (`index_url`(191)),
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_apps_index_url_index();
DROP PROCEDURE IF EXISTS _puter_add_apps_index_url_index;
@@ -1,21 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0089: a team may require 2FA of the accounts it
-- provisioned. Added through `_puter_add_col` as mysql_mig_32 does, so a
-- replay finds the column and does nothing.
CALL _puter_add_col('group', 'require_2fa', '`require_2fa` tinyint(1) NOT NULL DEFAULT 0');
@@ -1,39 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0090. Guarded like mysql_mig_22, because there is
-- no per-file applied-state tracking and a replay must do nothing.
DROP PROCEDURE IF EXISTS _puter_add_user_app_permission_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_app_permission_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_to_app_permissions'
AND INDEX_NAME = 'idx_user_to_app_permissions_permission'
) THEN
ALTER TABLE `user_to_app_permissions`
ADD INDEX `idx_user_to_app_permissions_permission` (`permission`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_app_permission_index();
DROP PROCEDURE IF EXISTS _puter_add_user_app_permission_index;
@@ -1,21 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array
-- of `{name, type, size}` (NULL when none).
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS attachments text DEFAULT NULL;
@@ -1,24 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0088: index `apps.index_url` for origin lookups.
-- Hash rather than btree: `index_url` allows 3000 characters, past btree's
-- ~2.7 KB row limit, and every lookup is equality (`=` / `IN`).
--
-- Idempotent via IF NOT EXISTS.
CREATE INDEX IF NOT EXISTS idx_apps_index_url ON apps USING hash (index_url);
@@ -1,21 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0089: a team may require 2FA of the accounts it
-- provisioned. Idempotent -- there is no per-file applied-state tracking, so
-- a replay must find the column already present and do nothing.
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS require_2fa smallint NOT NULL DEFAULT 0;
@@ -1,24 +0,0 @@
-- 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/>.
-- Mirrors SQLite migration 0090, as postgres_mig_11 did for user_to_user.
-- text_pattern_ops is what makes the left-anchored LIKE a range scan under a
-- non-C collation -- the plain index dev_to_app already has does not.
CREATE INDEX IF NOT EXISTS idx_user_to_app_permissions_permission
ON user_to_app_permissions (permission text_pattern_ops);
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission_pattern
ON dev_to_app_permissions (permission text_pattern_ops);
@@ -1,21 +0,0 @@
-- 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/>.
-- Contact Us attachment metadata: a JSON array of `{name, type, size}`, NULL
-- when none. The files themselves go out with the support email.
ALTER TABLE `feedback` ADD COLUMN "attachments" TEXT DEFAULT NULL;
@@ -1,22 +0,0 @@
-- 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/>.
-- Resolving an origin to its app looks `apps` up by `index_url` on every
-- origin-based app token request (`AuthService#findCanonicalAppUidForOrigin`),
-- plus `AppStore.existsByIndexUrl`. With no index each lookup scans the table.
CREATE INDEX IF NOT EXISTS `idx_apps_index_url` ON `apps` (`index_url`);
@@ -1,21 +0,0 @@
-- 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/>.
-- Whether every account this team provisioned must hold 2FA before it can
-- use Puter. Off until the owner turns it on, and the owner must hold 2FA
-- themselves first, or enabling it would lock out the account that did so.
ALTER TABLE `group` ADD COLUMN `require_2fa` INTEGER NOT NULL DEFAULT 0;
@@ -1,26 +0,0 @@
-- 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/>.
-- Withdrawing an app's cross-app data grants looks them up by permission text,
-- and no existing index on either table leads with `permission`. Same fix as
-- `idx_user_to_user_permissions_permission` in 0067.
CREATE INDEX IF NOT EXISTS `idx_user_to_app_permissions_permission`
ON `user_to_app_permissions` (`permission`);
-- The sweep reads both tables; MySQL and Postgres already index this one.
CREATE INDEX IF NOT EXISTS `idx_dev_to_app_permissions_permission`
ON `dev_to_app_permissions` (`permission`);
@@ -32,7 +32,6 @@ const RETRIABLE_ERROR_CODES = new Set([
'ENETUNREACH',
'EAI_AGAIN',
POOL_ACQUIRE_TIMEOUT,
'ER_CLIENT_INTERACTION_TIMEOUT',
]);
const RETRIABLE_ERROR_MESSAGES = [
@@ -53,9 +52,6 @@ const NEVER_SENT_ERROR_CODES = new Set([
'ENETUNREACH',
'EAI_AGAIN',
POOL_ACQUIRE_TIMEOUT,
// The server closed the pooled connection for inactivity before the
// statement arrived, so nothing ran.
'ER_CLIENT_INTERACTION_TIMEOUT',
]);
/**
@@ -142,42 +142,6 @@ describe('DDBClient — item operations', () => {
expect(result.Item).toBeUndefined();
});
it('deletes only while its condition holds', async () => {
await client.put(TABLE, { pk: 'u6', sk: 'guarded', v: 1 });
await expect(
client.del(
TABLE,
{ pk: 'u6', sk: 'guarded' },
{
condition: {
expression: '#v = :wrong',
names: { '#v': 'v' },
values: { ':wrong': 2 },
},
},
),
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
expect(
(await client.get(TABLE, { pk: 'u6', sk: 'guarded' })).Item,
).toBeDefined();
await client.del(
TABLE,
{ pk: 'u6', sk: 'guarded' },
{
condition: {
expression: '#v = :right',
names: { '#v': 'v' },
values: { ':right': 1 },
},
},
);
expect(
(await client.get(TABLE, { pk: 'u6', sk: 'guarded' })).Item,
).toBeUndefined();
});
it('fetches keys from several tables in one batch', async () => {
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
@@ -400,59 +364,6 @@ describe('DDBClient — expired item sweep', () => {
expect((await client.get(table, { pk: 'fresh' })).Item).toBeDefined();
expect((await client.get(table, { pk: 'eternal' })).Item).toBeDefined();
});
it('keeps an item whose ttl is 0, which never expires', async () => {
const client = new DDBClient(localConfig());
const table = 'ttl-zero-items';
const schema = {
TableName: table,
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' as const },
],
ProvisionedThroughput: {
ReadCapacityUnits: 5,
WriteCapacityUnits: 5,
},
};
await client.createTableIfNotExists(schema, 'expireAt');
await awaitTable(client, table, { pk: 'ready' });
await client.put(table, { pk: 'zero', expireAt: 0 });
// Second call finds the table in use, so the sweep runs.
await client.createTableIfNotExists(schema, 'expireAt');
expect((await client.get(table, { pk: 'zero' })).Item).toBeDefined();
});
it('keeps an item whose ttl is not a number, which the sweep can never purge', async () => {
const client = new DDBClient(localConfig());
const table = 'ttl-nan-items';
const schema = {
TableName: table,
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' as const },
],
ProvisionedThroughput: {
ReadCapacityUnits: 5,
WriteCapacityUnits: 5,
},
};
await client.createTableIfNotExists(schema, 'expireAt');
await awaitTable(client, table, { pk: 'ready' });
const past = Math.floor(Date.now() / 1000) - 60;
await client.put(table, { pk: 'stringTtl', expireAt: String(past) });
// Second call finds the table in use, so the sweep runs.
await client.createTableIfNotExists(schema, 'expireAt');
expect(
(await client.get(table, { pk: 'stringTtl' })).Item,
).toBeDefined();
});
});
describe('DDBClient — expired item sweep on an empty table', () => {
+5 -27
View File
@@ -481,15 +481,7 @@ export class DDBClient extends PuterClient {
async del<T extends Record<string, unknown>>(
table: string,
key: T,
opts?: {
returnOld?: boolean;
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
condition?: {
expression: string;
names?: Record<string, string>;
values?: Record<string, unknown>;
};
},
opts?: { returnOld?: boolean },
) {
const command = new DeleteCommand({
TableName: table,
@@ -498,20 +490,6 @@ export class DDBClient extends PuterClient {
// ALL_OLD makes the delete an atomic claim: exactly one caller
// gets the attributes back.
...(opts?.returnOld ? { ReturnValues: 'ALL_OLD' as const } : {}),
...(opts?.condition
? {
ConditionExpression: opts.condition.expression,
...(opts.condition.names
? { ExpressionAttributeNames: opts.condition.names }
: {}),
...(opts.condition.values
? {
ExpressionAttributeValues:
opts.condition.values,
}
: {}),
}
: {}),
});
const client = await this.#getDocumentClient();
@@ -783,15 +761,14 @@ export class DDBClient extends PuterClient {
const scan = await client.send(
new ScanCommand({
TableName: table,
// 0 means no expiry, so it must never sweep.
FilterExpression: '#ttl < :now AND #ttl <> :none',
FilterExpression: '#ttl < :now',
ExpressionAttributeNames: {
'#ttl': ttlAttribute,
...Object.fromEntries(
keyNames.map((key) => [`#k_${key}`, key]),
),
},
ExpressionAttributeValues: { ':now': now, ':none': 0 },
ExpressionAttributeValues: { ':now': now },
ProjectionExpression: keyNames
.map((key) => `#k_${key}`)
.join(', '),
@@ -802,7 +779,8 @@ export class DDBClient extends PuterClient {
);
lastEvaluatedKey = scan.LastEvaluatedKey as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const items = scan.Items;
if (!items || items.length === 0) continue;
-16
View File
@@ -404,22 +404,6 @@ will be asked to choose your own the next time you sign in.</p>
<p>If you did not expect this, the record of who reset it and when is in your
team activity, along with every sign-in to your account.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_2fa_reset: {
subject:
'Two-factor authentication was reset on your {{team_name}} account',
html: `
<p>Hi there,</p>
<p>An administrator of {{team_name}} reset two-factor authentication on your
Puter account <b>{{username}}</b>, so you can set it up again from a device you
still have. You have been signed out everywhere.</p>
<p>Your password is unchanged. If your team requires two-factor authentication,
you will be asked to enrol the next time you sign in.</p>
<p>If you did not expect this, the record of who reset it and when is in your
team activity, along with every sign-in to your account.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
+5 -28
View File
@@ -267,17 +267,6 @@ export type EventMap = {
message: string | null;
[key: string]: unknown;
};
// Inbound mail to a feedback address (`abuse@`, `fbl@`, ...), emitted via
// `emitAndWait`. A listener that takes it sets `handled` and it reaches no
// mailbox; otherwise it is routed like any other message.
'email.ingress.feedback': {
/** The envelope recipient, as the relay presented it. */
to: string;
from: string | null;
/** The whole message, exactly as received. */
raw: Buffer;
handled: boolean;
};
'user.save_account': {
user_id: number;
old_username?: string;
@@ -474,8 +463,6 @@ export type EventMap = {
values?: unknown[];
/** Keys among `keys` private to the namespace's app. */
noShareKeys?: string[];
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
handlerDepth?: number;
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
@@ -499,8 +486,6 @@ export type EventMap = {
appUid: string | null;
value: Record<string, unknown>;
createdAt: number;
/** Events handler runs behind whatever created it. */
handlerDepth?: number;
};
/**
* One notification, addressed at whatever sockets each region holds for the
@@ -604,11 +589,6 @@ export type EventMap = {
generation: number;
/** Whether the table changed; only then does a peer need to re-read it. */
durable: boolean;
/**
* Session subscriptions this bump ended, so siblings stop queueing for
* them.
*/
ended?: string[];
};
/**
* A user's sockets moved between regions, so every node must drop what it
@@ -680,8 +660,7 @@ export type EventMap = {
uid?: string;
thumbnail?: string | null;
};
/** `uuid` is the entry the thumbnail belongs to. */
'thumbnail.created': { url: string; uuid: string };
'thumbnail.created': { url: string };
'thumbnail.upload.prepare': {
items: { index: number; item_uid: string }[];
uploadUrl?: string;
@@ -729,7 +708,6 @@ export type EventMap = {
email?: string;
app_name?: string;
};
/** False for an app actor: add only `subscribed` / `paid_storage` then. */
isUser: boolean;
};
'wisp.get-policy': {
@@ -874,11 +852,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
-159
View File
@@ -1,159 +0,0 @@
/*
* 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 { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { IConfig } from './types';
// Config resolution order:
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
// by deployments whose outer bootstrap writes a merged config to a known
// location.
// 2. `<PACKAGE_ROOT>/config.json` — user's runtime override (gitignored),
// deep-merged over config.default.json so users can omit keys they
// don't care to override (e.g. gui_assets_root, database).
// 3. `<PACKAGE_ROOT>/config.default.json` — bundled OSS defaults.
//
// Post-flatten depth: compiled file is at `packages/puter/dist/src/backend/index.js`,
// so three `..`s land at `packages/puter/`.
const PACKAGE_ROOT = path.resolve(__dirname, '../../..');
// Root of the running code tree. Matches PACKAGE_ROOT for a source run, but
// points at `dist/` for a compiled run — so config-declared paths like
// `./extensions` resolve to `dist/extensions` at runtime without the config
// having to know about the build layout.
const RUNTIME_ROOT = path.resolve(__dirname, '../..');
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
const deepMerge = <T extends Record<string, unknown>>(
base: T,
override: Record<string, unknown>,
): T => {
const out: Record<string, unknown> = { ...base };
for (const [k, v] of Object.entries(override)) {
out[k] =
isPlainObject(v) && isPlainObject(out[k])
? deepMerge(out[k] as Record<string, unknown>, v)
: v;
}
return out as T;
};
export const loadConfig = (): IConfig => {
const envPath = process.env.PUTER_CONFIG_PATH;
const runtimePath = path.join(PACKAGE_ROOT, 'config.json');
const defaultPath = path.join(PACKAGE_ROOT, 'config.default.json');
const defaults = existsSync(defaultPath)
? (JSON.parse(readFileSync(defaultPath, 'utf8')) as Record<
string,
unknown
>)
: {};
// Runtime override path: env wins, then config.json, else no override
// (we still return defaults so single-file installs work).
const overridePath =
envPath && existsSync(envPath)
? envPath
: existsSync(runtimePath)
? runtimePath
: null;
console.log(`[config] defaults from ${defaultPath}`);
if (overridePath) console.log(`[config] override from ${overridePath}`);
const override = overridePath
? (JSON.parse(readFileSync(overridePath, 'utf8')) as Record<
string,
unknown
>)
: {};
const config = deepMerge(defaults, override) as IConfig;
if (!config.version) {
const pkgPath = path.join(PACKAGE_ROOT, 'package.json');
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
version?: string;
};
if (pkg.version) config.version = pkg.version;
} catch {
// fall through — /version returns 'unknown'
}
}
}
// Computed defaults. `origin` and `pub_port` are the externally-visible
// URL+port — what the browser sees. Separate from `port`, which is the
// bind port (can differ when behind a reverse proxy). Code paths that
// build self-referential URLs (GUI bootstrap, email links, OIDC callbacks)
// depend on `origin` having the right port baked in.
if (config.pub_port === undefined) config.pub_port = config.port;
const protocol = config.protocol ?? 'http';
const domain = config.domain ?? 'localhost';
const portSuffix =
config.pub_port === 80 || config.pub_port === 443
? ''
: `:${config.pub_port}`;
if (config.origin === undefined) {
config.origin = `${protocol}://${domain}${portSuffix}`;
}
// API lives on the `api.` subdomain on the same host+port as the main
// origin (see PuterRouter subdomain handling in server.ts). Compute it
// from pub_port/domain so a single-port override (e.g. port: 5101) flows
// through to the GUI bootstrap without users having to restate the URL.
if (config.api_base_url === undefined) {
config.api_base_url = `${protocol}://api.${domain}${portSuffix}`;
}
// Resolve path-valued config fields. Two different roots:
// - `extensions` uses RUNTIME_ROOT because extensions ship inside the
// build output (dist/extensions) and the loader's dynamic import()
// resolves relative paths against the *importing* module file.
// - GUI/puter-js/builtin-apps use PACKAGE_ROOT because those assets
// live only in the source tree (not copied into dist/) and are served
// via express.static at runtime.
const resolveRuntime = (p: string): string =>
path.isAbsolute(p) ? p : path.resolve(RUNTIME_ROOT, p);
const resolvePackage = (p: string): string =>
path.isAbsolute(p) ? p : path.resolve(PACKAGE_ROOT, p);
if (Array.isArray(config.extensions)) {
config.extensions = config.extensions.map(resolveRuntime);
}
if (typeof config.gui_assets_root === 'string') {
config.gui_assets_root = resolvePackage(config.gui_assets_root);
}
if (typeof config.puterjs_root === 'string') {
config.puterjs_root = resolvePackage(config.puterjs_root);
}
if (isPlainObject(config.builtin_apps)) {
for (const [k, v] of Object.entries(config.builtin_apps)) {
if (typeof v === 'string') {
(config.builtin_apps as Record<string, string>)[k] =
resolvePackage(v);
}
}
}
return config;
};
+6 -18
View File
@@ -69,11 +69,6 @@ const APP_ICON_LIMIT = {
key: 'ip',
};
// Shared by `GET /apps/:name` (pipe-separated) and `POST /query/app`, which
// both fan out one lookup per name.
const APP_LOOKUP_MAX_ENTRIES = 200;
const APP_LOOKUP_MAX_SELECTOR_LEN = 200;
export class AppController extends PuterController {
get appStore() {
return this.stores.app;
@@ -271,21 +266,11 @@ export class AppController extends PuterController {
async (req, res) => {
const raw = req.params.name;
const names = raw.split('|').filter(Boolean);
if (names.length > APP_LOOKUP_MAX_ENTRIES) {
throw new HttpError(
400,
`at most ${APP_LOOKUP_MAX_ENTRIES} app names per request`,
{ legacyCode: 'bad_request' },
);
}
const userUid = req.actor?.user?.uuid ?? null;
const results = await Promise.all(
names.map(async (name) => {
if (name.length > APP_LOOKUP_MAX_SELECTOR_LEN) {
return null;
}
const app = await this.appStore.getByName(name);
if (!app) return null;
let shaped;
@@ -342,6 +327,9 @@ export class AppController extends PuterController {
// included for public/owned apps only, consistent with
// marketplace semantics.
const QUERY_APP_MAX_ENTRIES = 200;
const QUERY_APP_MAX_SELECTOR_LEN = 200;
router.post(
'/query/app',
{
@@ -351,10 +339,10 @@ export class AppController extends PuterController {
},
async (req, res) => {
const appList = Array.isArray(req.body) ? req.body : [];
if (appList.length > APP_LOOKUP_MAX_ENTRIES) {
if (appList.length > QUERY_APP_MAX_ENTRIES) {
throw new HttpError(
400,
`request body must contain at most ${APP_LOOKUP_MAX_ENTRIES} selectors`,
`request body must contain at most ${QUERY_APP_MAX_ENTRIES} selectors`,
{ legacyCode: 'bad_request' },
);
}
@@ -366,7 +354,7 @@ export class AppController extends PuterController {
if (
typeof selector !== 'string' ||
selector.length === 0 ||
selector.length > APP_LOOKUP_MAX_SELECTOR_LEN
selector.length > QUERY_APP_MAX_SELECTOR_LEN
) {
continue;
}
@@ -538,57 +538,6 @@ describe('AppController GET /apps/:name', () => {
});
});
it('throws 400 when the pipe-batch exceeds the 200-name cap', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
const names = new Array(201).fill('x').join('|');
await expect(
withActor(actor, () =>
callRoute(
'get',
'/apps/:name',
makeReq({ params: { name: names }, actor }),
res,
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('accepts a pipe-batch at exactly the 200-name cap', async () => {
const owner = await makeUser();
const app = await createApp(owner.actor);
const names = [app.name, ...new Array(199).fill('missing-app')].join(
'|',
);
const { res, captured } = makeRes();
await withActor(owner.actor, () =>
callRoute(
'get',
'/apps/:name',
makeReq({ params: { name: names }, actor: owner.actor }),
res,
),
);
expect(Array.isArray(captured.body)).toBe(true);
expect(captured.body as unknown[]).toHaveLength(200);
});
it('throws 404 for a single name over the per-name length cap', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
withActor(actor, () =>
callRoute(
'get',
'/apps/:name',
makeReq({ params: { name: 'x'.repeat(201) }, actor }),
res,
),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('omits the stale index_url for a non-owner', async () => {
const owner = await makeUser();
const stranger = await makeUser();
@@ -17,18 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { TOTP } from 'otpauth';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
@@ -350,265 +343,4 @@ describe('revoke-own-access-token over HTTP', () => {
}
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
// These three run last in the file's shared `env.users.user`: each one
// revokes a session belonging to `owner.token` / `owner.workerToken`, so
// later tests can't rely on those credentials still being live.
it('a revoked access token yields 401 with no reauth_token or auth_id', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const readToken = await mintReadToken(owner.token, file.uid);
const revoke = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: readToken },
);
expect(revoke.status).toBe(200);
const res = await call('GET', '/whoami', readToken);
expect(res.status).toBe(401);
const body = (await res.json()) as Record<string, unknown>;
expect(body.code).toBe('reauth_required');
expect(body.reauth_token).toBeUndefined();
expect(body.auth_id).toBeUndefined();
});
it('a revoked worker session gets no reauth_token even though it rides the session token type', async () => {
const owner = env.users.user;
const decoded = env.server.services.token.verify(
'auth',
owner.workerToken,
) as { session_uid: string };
await env.server.stores.session.removeByUuid(decoded.session_uid);
const res = await call('GET', '/whoami', owner.workerToken);
expect(res.status).toBe(401);
const body = (await res.json()) as Record<string, unknown>;
expect(body.code).toBe('reauth_required');
expect(body.reauth_token).toBeUndefined();
expect(body.auth_id).toBeUndefined();
});
it('a revoked GUI session still gets a reauth_token', async () => {
const owner = env.users.user;
const decoded = env.server.services.token.verify('auth', owner.token) as {
session_uid: string;
};
await env.server.stores.session.removeByUuid(decoded.session_uid);
const res = await call('GET', '/whoami', owner.token);
expect(res.status).toBe(401);
const body = (await res.json()) as Record<string, unknown>;
expect(body.code).toBe('reauth_required');
expect(typeof body.reauth_token).toBe('string');
const user = await env.server.stores.user.getByUsername(
owner.username,
);
expect(
env.server.services.auth.verifyReauthToken(
body.reauth_token as string,
).authId,
).toBe(user!.uuid);
});
});
/**
* Enrolling a second factor: setup sits behind the user-protected gate, and
* enable needs a live code for the secret setup issued.
*/
describe('2FA enrollment over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const PASSWORD = 'tfa-http-password';
const uniq = () => Math.random().toString(36).slice(2, 10);
/** A signed-in password account with a confirmed address. */
const makeAccount = async () => {
const username = `tfa_${uniq()}`;
const { token } = await createTestUser(env.server, {
username,
password: PASSWORD,
});
const row = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(row.id, {
email: `${username}@test.local`,
email_confirmed: 1,
});
await env.server.stores.user.invalidateById(row.id);
return { username, token, userId: row.id };
};
/** The user-protected gate takes the session cookie on the GUI origin. */
const protectedPost = (
path: string,
token: string,
body: Record<string, unknown>,
credential: 'cookie' | 'bearer' = 'cookie',
) =>
fetch(new URL(path, env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(credential === 'cookie'
? { cookie: `puter_auth_token=${token}` }
: { authorization: `Bearer ${token}` }),
},
body: JSON.stringify(body),
});
const setup = (
token: string,
body: Record<string, unknown>,
credential: 'cookie' | 'bearer' = 'cookie',
) => protectedPost('/user-protected/setup-2fa', token, body, credential);
const configure = (
token: string,
action: string,
body: Record<string, unknown> = {},
) =>
fetch(new URL(`/auth/configure-2fa/${action}`, env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const otpState = async (userId: number) => {
const row = await env.server.stores.user.getByProperty('id', userId, {
force: true,
});
return {
secret: row?.otp_secret ?? null,
enabled: Boolean(row?.otp_enabled),
};
};
const liveTotp = (username: string, secret: string) =>
new TOTP({
issuer: 'puter.com',
label: username,
algorithm: 'SHA1',
digits: 6,
secret,
}).generate();
it('refuses setup to a session that has not re-proved the password', async () => {
const account = await makeAccount();
const noPassword = await setup(account.token, {});
expect(noPassword.status).toBe(403);
expect(await noPassword.text()).toContain('password_required');
const wrongPassword = await setup(account.token, { password: 'nope' });
expect(wrongPassword.status).toBe(400);
expect(await wrongPassword.text()).toContain('password_mismatch');
// Right password, but a bearer token is not the session cookie.
const bearer = await setup(
account.token,
{ password: PASSWORD },
'bearer',
);
expect(bearer.status).toBe(401);
// The old ungated path no longer hands out a secret.
expect((await configure(account.token, 'setup')).status).toBe(400);
expect((await otpState(account.userId)).secret).toBeNull();
});
it('enrolls after the password, and enables only on a live code', async () => {
const account = await makeAccount();
const res = await setup(account.token, { password: PASSWORD });
expect(res.status).toBe(200);
const { secret, codes } = (await res.json()) as {
secret: string;
codes: string[];
};
expect(codes).toHaveLength(10);
expect((await otpState(account.userId)).secret).toBe(secret);
expect((await configure(account.token, 'enable')).status).toBe(400);
const foreign = otpCreateSecret(account.username).secret;
const wrong = await configure(account.token, 'enable', {
code: liveTotp(account.username, foreign),
});
expect(wrong.status).toBe(400);
expect(await wrong.text()).toContain('code_mismatch');
expect((await otpState(account.userId)).enabled).toBe(false);
const enabled = await configure(account.token, 'enable', {
code: liveTotp(account.username, secret),
});
expect(enabled.status).toBe(200);
expect((await otpState(account.userId)).enabled).toBe(true);
});
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
const owner = await makeAccount();
// The rule can only be turned on by an owner who has 2FA.
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
await env.server.stores.user.invalidateById(owner.userId);
const team = await env.server.services.team.createTeam(owner.userId, {
name: 'Acme',
});
const seatName = `seat_${uniq()}`;
const { userId, temporaryPassword } =
await env.server.services.team.provisionAccount(
team.uid,
owner.userId,
{ username: seatName },
);
await env.server.services.team.updateTeam(team.uid, owner.userId, {
require2fa: true,
});
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(userId))!,
);
// First sign-in replaces the temporary password.
const changed = await protectedPost(
'/user-protected/change-password',
token,
{ password: temporaryPassword, new_pass: 'seat-own-password' },
);
expect(changed.status).toBe(200);
const held = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(held.status).toBe(403);
expect(await held.text()).toContain('two_factor_required');
const res = await setup(token, { password: 'seat-own-password' });
expect(res.status).toBe(200);
const { secret } = (await res.json()) as { secret: string };
const enabled = await configure(token, 'enable', {
code: liveTotp(seatName, secret),
});
expect(enabled.status).toBe(200);
expect((await otpState(userId)).enabled).toBe(true);
const released = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(released.status).toBe(200);
});
});
@@ -30,7 +30,6 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { TOTP } from 'otpauth';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { EventClient } from '../../clients/event/EventClient.js';
@@ -44,7 +43,6 @@ import {
type TokenSource,
} from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { kvSharePermission } from '../../services/events/kvShares.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
@@ -718,8 +716,9 @@ describe('AuthController.handleSignup', () => {
}),
makeRes(),
);
const occupant =
await server.stores.user.getByUsername(occupantUsername);
const occupant = await server.stores.user.getByUsername(
occupantUsername,
);
const root = (await server.stores.fsEntry.getRootEntryForUser(
occupant!.id,
))!;
@@ -792,22 +791,6 @@ describe('AuthController.handleSignup', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it.each(['abuse', 'postmaster', 'fbl'])(
'rejects the role mailbox name "%s"',
async (username) => {
await expect(
controller.handleSignup(
makeReq({
username,
email: `a_${uniq()}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it('rejects an invalid email format', async () => {
await expect(
controller.handleSignup(
@@ -1281,47 +1264,6 @@ describe('AuthController.handleLogin', () => {
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
// A deleted row still answering from cache reaches the session INSERT.
it('answers 401 rather than 500 when the account row is gone but still cached', async () => {
const goneName = `lg_${Math.random().toString(36).slice(2, 10)}`;
await controller.handleSignup(
makeReq({
username: goneName,
email: `${goneName}@test.local`,
password,
}),
makeRes(),
);
const gone = await server.stores.user.getByUsername(goneName);
const realWrite = server.clients.db.write.bind(server.clients.db);
const writeSpy = vi
.spyOn(server.clients.db, 'write')
.mockImplementation(async (sql: string, params?: unknown[]) => {
if (/INSERT INTO `sessions`/.test(sql)) {
throw Object.assign(new Error('FOREIGN KEY constraint'), {
code: 'SQLITE_CONSTRAINT_FOREIGNKEY',
});
}
return realWrite(sql, params);
});
try {
await expect(
controller.handleLogin(
makeReq({ username: goneName, password }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 401 });
} finally {
writeSpy.mockRestore();
}
expect(
await server.clients.redis.get(`users:username:${goneName}`),
).toBeNull();
});
it('refuses an email the account has moved off', async () => {
const moverName = `lm_${Math.random().toString(36).slice(2, 10)}`;
const oldEmail = `${moverName}[email protected]`;
@@ -2219,49 +2161,6 @@ describe('AuthController grant flows', () => {
expect(res.body).toEqual({});
});
it('grant-user-app: 400 on an `extra`/`meta` larger than the cap', async () => {
const appName = `tb-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestBoundApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const permission = 'service:tb-app:ii:read';
const oversized = { blob: 'z'.repeat(5000) };
for (const body of [
{ app_uid: app.uid, permission, extra: oversized },
{ app_uid: app.uid, permission, meta: oversized },
]) {
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(body, { actor: issuerActor }),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
}
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: app.uid,
permission,
extra: { blob: 'z'.repeat(1000) },
},
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
});
it('grant-user-app: refuses a key-value delegation over a whole namespace', async () => {
// The prompt for it would read "let this app show your app data to
// whoever it picks", which describes no bounded capability — so the
@@ -3347,139 +3246,6 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
// Proves the row came from the bootstrap path, not an earlier test.
expect(bootstrapped?.description).toMatch(/^App created from origin /);
});
it('does not hand an origin the app that was repointed away from it', async () => {
// Whoever holds the origin next must not inherit the app, or the
// users who already authorized it.
const origin = `https://repointed-${uuidv4()}.example`;
const first = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ origin }, { actor }),
first,
),
);
const movedUid = (first.body as { app_uid: string }).app_uid;
const moved = await server.stores.app.getByUid(movedUid);
await server.stores.app.update(moved!.id, {
index_url: `https://new-home-${uuidv4()}.example`,
});
const check = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(makeReq({ origin }, { actor }), check),
);
const checked = check.body as {
app_uid: string;
authenticated: boolean;
token?: string;
};
expect(checked.app_uid).not.toBe(movedUid);
expect(checked.authenticated).toBe(false);
expect(checked.token).toBeUndefined();
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ origin }, { actor }),
res,
),
);
const body = res.body as { token: string; app_uid: string };
expect(body.app_uid).toBe(checked.app_uid);
const decoded = server.services.token.verify('auth', body.token) as {
app_uid: string;
};
expect(decoded.app_uid).toBe(body.app_uid);
const fresh = await server.stores.app.getByUid(body.app_uid);
expect(fresh?.index_url).toBe(origin);
});
// sqlite doesn't enforce FKs, so inject the driver error at the db client.
// The grant row keys both the app and the user; which one vanished decides
// the answer, and the drivers don't all name the constraint.
describe('when a parent row is gone but still cached', () => {
const fkError = () =>
Object.assign(new Error('FOREIGN KEY constraint'), {
code: 'SQLITE_CONSTRAINT_FOREIGNKEY',
});
const makeApp = async () =>
(
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `fk-${uuidv4()}`,
title: 'Deleted under a warm cache',
index_url: 'https://example.test/fk.html',
},
{ ownerUserId: user.id },
);
/** Fails the grant insert without disturbing the rest of the handler. */
const failingGrant = async (fn: () => Promise<unknown>) => {
const realWrite = server.clients.db.write.bind(server.clients.db);
const spy = vi
.spyOn(server.clients.db, 'write')
.mockImplementation(async (sql: string, params?: unknown[]) => {
if (/user_to_app_permissions/.test(sql)) throw fkError();
return realWrite(sql, params);
});
try {
return await fn();
} finally {
spy.mockRestore();
}
};
it('answers 404 for a deleted app, and retires the stale cache entry', async () => {
const doomed = await makeApp();
// Straight to SQL; AppStore.delete would clear the stale entry.
await server.clients.db.write('DELETE FROM `apps` WHERE `id` = ?', [
doomed.id,
]);
await expect(
failingGrant(() =>
inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ app_uid: doomed.uid }, { actor }),
makeRes(),
),
),
),
).rejects.toMatchObject({ statusCode: 404 });
expect(
await server.clients.redis.get(`apps:uid:${doomed.uid}`),
).toBeNull();
});
it('answers 401 for a deleted account, leaving the live app cacheable', async () => {
const live = await makeApp();
await server.stores.app.getByUid(live.uid);
await expect(
failingGrant(() =>
inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ app_uid: live.uid }, { actor }),
makeRes(),
),
),
),
).rejects.toMatchObject({ statusCode: 401 });
// Tombstoning the app here would strand a perfectly live row.
expect(await server.stores.app.getByUid(live.uid)).toBeTruthy();
expect(
await server.clients.redis.get(`apps:uid:${live.uid}:deleted`),
).toBeNull();
});
});
});
// ── Access tokens: create + revoke ─────────────────────────────────
@@ -3576,75 +3342,6 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a permission wider than the column, leaving no session behind', async () => {
// Nothing rewrites these, so an over-wide one would reach the INSERT
// only after the session row was created and the JWT signed.
const before = (await server.clients.db.read(
"SELECT COUNT(*) AS n FROM sessions WHERE kind = 'access_token'",
[],
)) as Array<{ n: number }>;
await expect(
controller.handleCreateAccessToken(
makeReq(
{ permissions: [`service:${'a'.repeat(300)}:ii:read`] },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const after = (await server.clients.db.read(
"SELECT COUNT(*) AS n FROM sessions WHERE kind = 'access_token'",
[],
)) as Array<{ n: number }>;
expect(after[0].n).toBe(before[0].n);
});
it('rejects more permissions than one request may carry', async () => {
// Each entry costs a permission check and an INSERT.
const permissions = Array.from(
{ length: 17 },
(_, i) => `service:cap-${i}:ii:read`,
);
await expect(
controller.handleCreateAccessToken(
makeReq({ permissions }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('still mints at the cap', async () => {
const permissions = Array.from(
{ length: 16 },
(_, i) => `service:atcap-${i}:ii:read`,
);
const res = makeRes();
await controller.handleCreateAccessToken(
makeReq({ permissions }, { actor }),
res,
);
expect((res.body as { token: string }).token).toEqual(
expect.any(String),
);
});
it.each([
['an oversized permission string', ['x'.repeat(4097)]],
[
'an oversized `extra`',
[['service:x:ii:read', { a: 'y'.repeat(5000) }]],
],
['a non-object `extra`', [['service:x:ii:read', 'nope']]],
['an empty permission string', ['']],
])('rejects %s with 400', async (_label, permissions) => {
await expect(
controller.handleCreateAccessToken(
makeReq({ permissions: permissions as never[] }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('mints a verifiable access-token JWT for valid permissions', async () => {
const res = makeRes();
await controller.handleCreateAccessToken(
@@ -5976,35 +5673,6 @@ describe('AuthController user-protected mutations (validation paths)', () => {
}
});
it('change-username: only completed renames spend the 2-per-30-days budget', async () => {
const { user: other } = await makeUserAndActor();
const { actor } = await makeUserAndActor();
for (let i = 0; i < 3; i++) {
await expect(
controller.handleChangeUsername(
makeReq({ new_username: other.username }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
}
for (let i = 0; i < 2; i++) {
await controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor }),
makeRes(),
);
}
await expect(
controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
});
it('change-username: 400 when the home path is already occupied, before the rename', async () => {
const { user, actor } = await makeUserAndActor();
// A name no account holds, whose home path a stray row does. This is
@@ -6061,48 +5729,6 @@ describe('AuthController user-protected mutations (validation paths)', () => {
expect(after!.username).toBe(mover.username);
});
it('change-username: a name just vacated stays closed to other accounts, not to its last holder', async () => {
const { user: leaver, actor: leaverActor } = await makeUserAndActor();
const vacated = leaver.username;
// Warm the entry cache under the old path, as any read would.
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${vacated}/Documents`,
))!;
await controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor: leaverActor }),
makeRes(),
);
// The cache still maps the old path to the leaver's folder, so a new
// holder of the name would pass ACL on it.
const cached = await server.stores.fsEntry.getEntryByPath(
`/${vacated}/Documents`,
);
expect(cached?.uuid).toBe(docs.uuid);
const { user: claimer, actor: claimerActor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: vacated }, { actor: claimerActor }),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'username_already_in_use',
});
const after = await server.stores.user.getById(claimer.id, {
force: true,
});
expect(after!.username).toBe(claimer.username);
const res = makeRes();
await controller.handleChangeUsername(
makeReq({ new_username: vacated }, { actor: leaverActor }),
res,
);
expect(res.body).toEqual({ username: vacated });
});
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -6980,34 +6606,6 @@ describe('AuthController dev-app permission flows', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it.each([
['a non-object `extra`', { extra: 'nope' }],
['an array `extra`', { extra: [1, 2] }],
['an oversized `extra`', { extra: { blob: 'z'.repeat(5000) } }],
['an oversized `meta`', { meta: { blob: 'z'.repeat(5000) } }],
['an oversized `permission`', { permission: 'x'.repeat(4097) }],
// Survives the route cap but not the column it lands in.
[
'a `permission` wider than the column',
{ permission: `service:${'a'.repeat(300)}:ii:read` },
],
])('grant-dev-app: 400 on %s', async (_label, patch) => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleGrantDevApp(
makeReq(
{
app_uid: `app-${uuidv4()}`,
permission: 'fs:read',
...patch,
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-dev-app: 400 on missing app_uid/origin/permission', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -7207,29 +6805,6 @@ describe('AuthController.handleAppUidFromOrigin', () => {
// ── 2FA configure / disable ────────────────────────────────────────
/** The code an authenticator app would show right now for `secret`. */
const liveTotp = (username: string, secret: string) =>
new TOTP({
issuer: 'puter.com',
label: username,
algorithm: 'SHA1',
digits: 6,
secret,
}).generate();
/** Calls the handler directly; the route's gate is tested over HTTP. */
const setup2fa = async (actor: Actor) => {
const res = makeRes();
await controller.handleSetup2fa(makeReq({}, { actor }), res);
return res.body as { url: string; secret: string; codes: string[] };
};
const enable2fa = (actor: Actor, body: Record<string, unknown> = {}) =>
controller.handleConfigure2fa(
makeReq(body, { actor, params: { action: 'enable' } }),
makeRes(),
);
describe('AuthController 2FA flows', () => {
it('configure-2fa: 400 on an unknown :action', async () => {
const { actor } = await makeUserAndActor();
@@ -7241,95 +6816,18 @@ describe('AuthController 2FA flows', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('configure-2fa: has no setup action, so a bare session mints no secret', async () => {
it('configure-2fa setup: returns {url, secret, codes[10]} and stores the secret', async () => {
const { user, actor } = await makeUserAndActor();
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_secret ?? null).toBeNull();
});
it('lets a team seat enable 2FA without a confirmed email', async () => {
// A seat has no address by design (PUT-1792) while its team can
// require 2FA, so this check would bar it forever.
const owner = await makeUserAndActor();
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const actor = {
user: {
id: seat!.id,
uuid: seat!.uuid,
username: seat!.username,
email: seat!.email ?? null,
email_confirmed: !!seat!.email_confirmed,
},
} as Actor;
expect(Boolean(seat!.email_confirmed)).toBe(false);
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(seat!.username, secret) });
const after = await server.stores.user.getById(seat!.id, {
force: true,
});
expect(Boolean(after!.otp_enabled)).toBe(true);
});
it('refuses to disable 2FA while the team requires it', async () => {
const owner = await makeUserAndActor({ otp_enabled: 1 });
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const actor = {
user: {
id: seat!.id,
uuid: seat!.uuid,
username: seat!.username,
email: seat!.email ?? null,
},
} as Actor;
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(seat!.username, secret) });
await server.services.team.updateTeam(team.uid, owner.user.id, {
require2fa: true,
});
await expect(
controller.handleDisable2fa(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 409 });
const after = await server.stores.user.getById(seat!.id, {
force: true,
});
expect(Boolean(after!.otp_enabled)).toBe(true);
});
it('still refuses an ordinary account with an unconfirmed email', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 0 });
const { secret } = await setup2fa(actor);
await expect(
enable2fa(actor, { code: liveTotp(user.username, secret) }),
).rejects.toMatchObject({ statusCode: 403 });
});
it('setup-2fa: returns {url, secret, codes[10]} and stores the secret', async () => {
const { user, actor } = await makeUserAndActor();
const body = await setup2fa(actor);
const res = makeRes();
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
res,
);
const body = res.body as {
url: string;
secret: string;
codes: string[];
};
expect(body.codes).toHaveLength(10);
expect(typeof body.secret).toBe('string');
const after = await server.stores.user.getById(user.id, {
@@ -7341,10 +6839,19 @@ describe('AuthController 2FA flows', () => {
).toHaveLength(10);
});
it('a recovery code minted by setup-2fa satisfies /login/recovery-code', async () => {
it('a recovery code minted by configure-2fa setup satisfies /login/recovery-code', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
const { secret, codes } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(user.username, secret) });
const setupRes = makeRes();
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
setupRes,
);
const { codes } = setupRes.body as { codes: string[] };
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'enable' } }),
makeRes(),
);
const otpJwt = server.services.token.sign(
'otp',
@@ -7359,11 +6866,14 @@ describe('AuthController 2FA flows', () => {
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('setup-2fa: 409 when 2FA is already enabled', async () => {
it('configure-2fa setup: 409 when 2FA is already enabled', async () => {
const { actor } = await makeUserAndActor({ otp_enabled: 1 });
await expect(setup2fa(actor)).rejects.toMatchObject({
statusCode: 409,
});
await expect(
controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
it('configure-2fa test: 400 when code is missing', async () => {
@@ -7376,37 +6886,6 @@ describe('AuthController 2FA flows', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('configure-2fa enable: refuses a missing or wrong code and leaves 2FA off', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
const { secret } = await setup2fa(actor);
await expect(enable2fa(actor)).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
// A live code for a secret the server never issued, as from a
// session enrolling an authenticator of its own.
const foreign = otpCreateSecret(user.username).secret;
await expect(
enable2fa(actor, { code: liveTotp(user.username, foreign) }),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'code_mismatch',
});
// Not coerced: a number is not a code.
await expect(
enable2fa(actor, {
code: Number(liveTotp(user.username, secret)),
}),
).rejects.toMatchObject({ statusCode: 400 });
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeFalsy();
expect(after!.otp_secret).toBe(secret);
});
it('configure-2fa enable: 403 if email is unconfirmed; 409 if already enabled or no secret', async () => {
// Email unconfirmed → 403.
const { actor: aUnconfirmed } = await makeUserAndActor();
@@ -8482,7 +7961,10 @@ describe('AuthController 2FA additional branches', () => {
it('configure-2fa test: returns ok:false on a mismatched code', async () => {
// Setup so otp_secret is populated.
const { user, actor } = await makeUserAndActor();
await setup2fa(actor);
await controller.handleConfigure2fa(
makeReq({}, { actor, params: { action: 'setup' } }),
makeRes(),
);
const refreshed = await server.stores.user.getById(user.id, {
force: true,
});
@@ -8497,7 +7979,7 @@ describe('AuthController 2FA additional branches', () => {
expect(res.body).toEqual({ ok: false });
});
it('configure-2fa enable: succeeds with a live code for the stored secret', async () => {
it('configure-2fa enable: succeeds when email is confirmed and a secret exists', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
// Bootstrap a secret directly so we don't depend on the setup
// handler's side effects.
@@ -8509,10 +7991,7 @@ describe('AuthController 2FA additional branches', () => {
const res = makeRes();
await controller.handleConfigure2fa(
makeReq(
{ code: liveTotp(user.username, 'TESTSECRETBASE32') },
{ actor, params: { action: 'enable' } },
),
makeReq({}, { actor, params: { action: 'enable' } }),
res,
);
expect(res.body).toEqual({});
+48 -175
View File
@@ -34,11 +34,7 @@ import {
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import {
checkRateLimit,
consumeRouteRateLimit,
peekRouteRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
signStepUpToken,
STEP_UP_COOKIE_NAME,
@@ -120,8 +116,6 @@ const FINGERPRINT_MAX_LENGTH = 128;
// One consent prompt covers a handful of scopes at most. The cap keeps a
// crafted request from turning a single grant call into a bulk write.
const MAX_PERMISSIONS_PER_REQUEST = 16;
// Rides every row a request writes, and the per-(user, app) cache after that.
const GRANT_EXTRA_MAX_BYTES = 4096;
const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
@@ -221,26 +215,6 @@ const SESSION_LIMIT = {
window: 60_000,
key: 'user',
} as const;
/**
* Completed renames. Charged by the handler after the rename lands, so a wrong
* password or a taken name doesn't spend it.
*/
const CHANGE_USERNAME_LIMIT = {
scope: 'change-username-done',
limit: 2,
window: 30 * 24 * 60 * 60_000,
key: 'user',
} as const;
/** Rename attempts, success or not. Bounds password guessing through the gate. */
const CHANGE_USERNAME_ATTEMPT_LIMIT = {
scope: 'change-username-attempt',
limit: 10,
window: 60 * 60_000,
key: 'user',
} as const;
// How long a failed-SMS-send record stays readable by its error_id — long
// enough to cover the typical support round-trip.
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
@@ -259,15 +233,6 @@ export const RESERVED_USERNAMES = new Set([
'contact',
'mail',
'email',
// Role mailboxes: a Puter address names its account, so these must never
// be ownable.
'abuse',
'postmaster',
'hostmaster',
'fbl',
'security',
'noreply',
'no-reply',
'null',
'undefined',
'test',
@@ -281,7 +246,7 @@ export const RESERVED_USERNAMES = new Set([
* Auth controller — login/logout, permission grants/revokes, session
* management, OTP, and permission checks.
*
* Routes are declared via decorators (@Get/@Post on each handler). The
* Routes are declared via decorators (@Get/@Post on each handler). The five
* `/user-protected/*` and `/user-protected/delete-own-user` routes also need a
* per-instance `createUserProtectedGate(...)` middleware built from
* `this.config / this.stores / this.services`, which can't live in a static
@@ -2660,7 +2625,7 @@ export class AuthController extends PuterController {
// -- User-protected mutations ------------------------------------
//
// The `/user-protected/*` and `/user-protected/delete-own-user`
// The five `/user-protected/*` and `/user-protected/delete-own-user`
// routes are wired in the `registerRoutes` override below because
// their `middleware: createUserProtectedGate(...)` argument depends
// on `this.config / this.stores / this.services` and so can't live
@@ -2778,18 +2743,9 @@ export class AuthController extends PuterController {
});
}
if (!(await peekRouteRateLimit(req, CHANGE_USERNAME_LIMIT))) {
throw new HttpError(
429,
'You can only change your username twice every 30 days.',
{ legacyCode: 'too_many_requests' },
);
}
await this.stores.user.update(req.actor!.user.id!, {
username: new_username,
});
await consumeRouteRateLimit(req, CHANGE_USERNAME_LIMIT);
// Rename the user's FS home from `/<old>` to `/<new>` and
// cascade the prefix to all descendants. Without this, any
@@ -3341,16 +3297,6 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
if (
Buffer.byteLength(JSON.stringify(value), 'utf8') >
GRANT_EXTRA_MAX_BYTES
) {
throw new HttpError(
400,
`\`${key}\` may not exceed ${GRANT_EXTRA_MAX_BYTES} bytes`,
{ legacyCode: 'bad_request' },
);
}
}
}
@@ -3918,13 +3864,6 @@ export class AuthController extends PuterController {
async handleGrantDevApp(req: Request, res: Response): Promise<void> {
let { app_uid } = req.body ?? {};
const { origin, permission, extra, meta } = req.body ?? {};
this.#validateAppPermissionParams({
app_uid,
origin,
permission,
extra,
meta,
});
if (origin && !app_uid) {
// Registered apps only, for the same reason the user-app handlers
// insist on it: a synthesised `app-<uuidv5(origin)>` is resolved
@@ -4265,11 +4204,6 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
if (permissions.length > MAX_PERMISSIONS_PER_REQUEST) {
throw new HttpError(400, 'Too many `permissions`', {
legacyCode: 'bad_request',
});
}
// Optional user-facing name for the manage-sessions UI. Trim and clamp
// to the same 64-char limit the rename endpoint enforces.
@@ -4303,25 +4237,13 @@ export class AuthController extends PuterController {
// Normalize specs: string → [string], [string] → [string, {}], [string, extra] → as-is
const normalized = permissions.map((spec) => {
const entry =
typeof spec === 'string'
? [spec]
: Array.isArray(spec)
? spec
: null;
if (!entry || typeof entry[0] !== 'string' || !entry[0]) {
throw new HttpError(
400,
'Each permission must be a string or [string, extra?]',
{ legacyCode: 'bad_request' },
);
}
// Same caps the grant routes apply, before a session or row exists.
this.#validateAppPermissionParams({
permission: entry[0],
extra: entry[1],
});
return entry;
if (typeof spec === 'string') return [spec];
if (Array.isArray(spec)) return spec;
throw new HttpError(
400,
'Each permission must be a string or [string, extra?]',
{ legacyCode: 'bad_request' },
);
});
const token = await this.services.auth.createAccessToken(
@@ -4381,51 +4303,11 @@ export class AuthController extends PuterController {
res.json({ ok: true });
}
// -- 2FA: setup (user-protected, wired in registerRoutes below) ---
async handleSetup2fa(req: Request, res: Response): Promise<void> {
const user = await this.stores.user.getById(req.actor!.user.id!, {
force: true,
});
if (!user)
throw new HttpError(404, 'User not found', {
legacyCode: 'not_found',
});
if (user.otp_enabled) {
throw new HttpError(409, '2FA is already enabled.', {
legacyCode: 'conflict',
});
}
const result = otpCreateSecret(user.username);
// Generate 10 recovery codes
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
codes.push(createRecoveryCode());
}
const hashedCodes = codes.map((c) => hashRecoveryCode(c));
await this.clients.db.write(
'UPDATE `user` SET `otp_secret` = ?, `otp_recovery_codes` = ? WHERE `uuid` = ?',
[result.secret, hashedCodes.join(','), user.uuid],
);
await this.stores.user.invalidateById(user.id);
res.json({
url: result.url,
secret: result.secret,
codes,
});
}
// -- 2FA: configure ----------------------------------------------
@Post('/auth/configure-2fa/:action', {
subdomain: 'api',
requireUserActor: true,
// A member owing their team's 2FA reaches nothing else until this.
allowUnconfirmed: true,
rateLimit: TWO_FACTOR_LIMIT,
})
async handleConfigure2fa(req: Request, res: Response): Promise<void> {
@@ -4438,6 +4320,36 @@ export class AuthController extends PuterController {
legacyCode: 'not_found',
});
if (action === 'setup') {
if (user.otp_enabled) {
throw new HttpError(409, '2FA is already enabled.', {
legacyCode: 'conflict',
});
}
const result = otpCreateSecret(user.username);
// Generate 10 recovery codes
const codes: string[] = [];
for (let i = 0; i < 10; i++) {
codes.push(createRecoveryCode());
}
const hashedCodes = codes.map((c) => hashRecoveryCode(c));
await this.clients.db.write(
'UPDATE `user` SET `otp_secret` = ?, `otp_recovery_codes` = ? WHERE `uuid` = ?',
[result.secret, hashedCodes.join(','), user.uuid],
);
await this.stores.user.invalidateById(user.id);
res.json({
url: result.url,
secret: result.secret,
codes,
});
return;
}
if (action === 'test') {
const { code } = req.body ?? {};
if (!code)
@@ -4450,9 +4362,7 @@ export class AuthController extends PuterController {
}
if (action === 'enable') {
// A seat has no email by design, so this would bar it forever.
const seat = await this.stores.team.getOrgSeat(user.id);
if (!user.email_confirmed && !seat) {
if (!user.email_confirmed) {
throw new HttpError(
403,
'Email must be confirmed before enabling 2FA.',
@@ -4471,18 +4381,6 @@ export class AuthController extends PuterController {
{ legacyCode: 'conflict' },
);
}
// Proves the caller holds the secret setup issued; a session alone
// can't turn 2FA on.
const { code } = req.body ?? {};
if (typeof code !== 'string' || !code)
throw new HttpError(400, 'Missing `code`', {
legacyCode: 'bad_request',
});
if (!verifyOtp(user.username, user.otp_secret, code)) {
throw new HttpError(400, 'Incorrect code.', {
legacyCode: 'code_mismatch',
});
}
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = ? WHERE `uuid` = ?',
@@ -4520,15 +4418,6 @@ export class AuthController extends PuterController {
legacyCode: 'not_found',
});
const seat = await this.stores.team.getOrgSeat(user.id);
if (seat && Number(seat.require_2fa) === 1) {
throw new HttpError(
409,
'Your team requires two-factor authentication, so it cannot be turned off.',
{ legacyCode: 'conflict' },
);
}
await this.clients.db.write(
'UPDATE `user` SET `otp_enabled` = ?, `otp_recovery_codes` = NULL, `otp_secret` = NULL WHERE `uuid` = ?',
[this.clients.db.booleanValue(false), user.uuid],
@@ -4782,7 +4671,7 @@ export class AuthController extends PuterController {
//
// The `@Controller('')` decorator would normally install a default
// `registerRoutes` walker that iterates `prototype[__puterRoutes]`.
// We override it here so we can ALSO wire the
// We override it here so we can ALSO wire the five
// `/user-protected/*` (and `/user-protected/delete-own-user`) routes
// whose `middleware: createUserProtectedGate(...)` argument is
// built from instance state — not expressible inside a static
@@ -4856,7 +4745,12 @@ export class AuthController extends PuterController {
{
requireUserActor: true,
requireVerified: true,
rateLimit: CHANGE_USERNAME_ATTEMPT_LIMIT,
rateLimit: {
scope: 'change-username',
limit: 2,
window: 30 * 24 * 60 * 60_000,
key: 'user',
},
middleware: [
createUserProtectedGate(
userProtectedDeps as never,
@@ -4904,27 +4798,6 @@ export class AuthController extends PuterController {
(req, res) => this.handleDisable2fa(req, res),
);
router.post(
'/user-protected/setup-2fa',
{
requireUserActor: true,
// A member owing their team's 2FA reaches nothing else until this.
allowUnconfirmed: true,
rateLimit: {
scope: 'setup-2fa',
limit: 10,
window: 60 * 60_000,
key: 'user',
},
middleware: [
createUserProtectedGate(
userProtectedDeps as never,
) as unknown as RequestHandler,
],
},
(req, res) => this.handleSetup2fa(req, res),
);
router.post(
'/user-protected/delete-own-user',
{
@@ -21,7 +21,6 @@ import { metrics } from '@opentelemetry/api';
import type { Request, Response } from 'express';
import { actorUid } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { abortOnDisconnect } from '../../core/http/abortOnDisconnect.js';
import { Controller } from '../../core/http/decorators.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import { assertNotUserSession } from '../../core/http/middleware/gates.js';
@@ -326,8 +325,7 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
| PermissionService
| undefined;
PermissionService | undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -461,7 +459,12 @@ export class DriverController extends PuterController {
// A caller that hangs up mid-call gets nothing back, so long-running
// drivers watch this to stop working (and metering) as soon as it does.
Context.set('abortSignal', abortOnDisconnect(res));
// `close` after `finish` is the normal end of a response, not an abort.
const abort = new AbortController();
res.once('close', () => {
if (!res.writableFinished) abort.abort();
});
Context.set('abortSignal', abort.signal);
// Per-method lifecycle events, scoped to `driver.<iface>.<method>`.
// Subscribers can listen on `driver.*`, `driver.<iface>.*`, or the
+9 -22
View File
@@ -21,7 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
subscriptionOverride,
FREE_SUBSCRIPTION_IDS,
} from '../../services/metering/consts.js';
// -- Shared event limits ---------------------------------------------
@@ -45,7 +45,7 @@ const userWindow = (
/**
* A plan-varying cap in route-gate shape: `limit` is what a subscribed account
* gets, `bySubscription` carves out the free tiers, unlisted paid plans get the
* gets, `bySubscription` carves out the free tiers, unlisted plans get the
* base.
*/
export interface TieredLimit {
@@ -67,9 +67,11 @@ export const limitFor = (
subscriptionId: string | null,
): number => {
if (subscriptionId === null) return tier.limit;
return (
subscriptionOverride(tier.bySubscription, subscriptionId) ?? tier.limit
);
const own = tier.bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit)
: tier.limit;
};
/**
@@ -126,20 +128,14 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
export const SUSPENDED_ROW_TTL_DAYS = 30;
/**
* Subscribe calls per minute, per user.
* Subscribe + unsubscribe calls per minute, per user.
*
* Resolves a path and takes a write, so a loop over subscribes is a write loop.
* Both resolve a path and take a write, so a loop over them is a write loop.
* Sized like the sharing verbs, which are the closest existing analogue: a
* client sets its subscriptions up once and then leaves them alone.
*/
export const EVENTS_SUBSCRIBE_LIMIT = userWindow('events:subscribe', 60);
/**
* Unsubscribe calls per minute, per user — its own budget, never the subscribe
* one.
*/
export const EVENTS_UNSUBSCRIBE_LIMIT = userWindow('events:unsubscribe', 600);
/**
* Subscription listings per minute, per user. Reads an index and returns one
* page, so it is budgeted well above the verbs that write.
@@ -325,15 +321,6 @@ export const EVENTS_WORKER_INVOCATION_LIMIT = userWindow(
60,
);
/**
* Handler runs one chain of events may take, by the holder's plan.
*
* A write made with a handler's token is one run deeper than the event that
* invoked it. An event at this depth still reaches sockets but runs no handler,
* which is what ends handlers whose writes trigger each other.
*/
export const EVENTS_HANDLER_DEPTH = tiered(12, 4, 4);
/**
* Filter evaluations one event may spend. Lives with the matcher because the
* primitive that enforces it does; re-exported here so every published number
@@ -353,70 +353,6 @@ describe('GET /fs/readdir over HTTP', () => {
expect(stat.shares).toEqual([]);
});
});
describe('the root listing', () => {
const mint = async (permissions: string[]) => {
const response = await fetch(
new URL('/auth/create-access-token', env.apiOrigin),
{
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify({ permissions }),
},
);
expect(response.status).toBe(200);
return ((await response.json()) as { token: string }).token;
};
const listRoot = async (token: string) => {
const response = await fetch(
readdirUrl({ path: '/', auth_token: token }),
);
expect(response.status).toBe(200);
return response.text();
};
const rootPaths = async (token: string) => {
const entries = JSON.parse(await listRoot(token)) as Array<{
path: string;
}>;
return entries.map((entry) => entry.path);
};
const entryAt = async (path: string) =>
(await env.server.stores.fsEntry.getEntryByPath(path))!;
it('lists the home for a session and a full-access token', async () => {
const { username, token, apiToken } = env.users.user;
for (const credential of [token, apiToken]) {
expect(await rootPaths(credential)).toEqual([`/${username}`]);
}
});
it('leaves the home out for a token scoped below it', async () => {
const { username } = env.users.user;
const home = await entryAt(`/${username}`);
const documents = await entryAt(`/${username}/Documents`);
const scoped = await mint([`fs:${documents.uuid}:read`]);
const raw = await listRoot(scoped);
expect(JSON.parse(raw)).toEqual([]);
expect(raw).not.toContain(home.uuid);
expect(raw).not.toContain(username);
});
it('lists the home for a token that can list it', async () => {
const { username } = env.users.user;
const home = await entryAt(`/${username}`);
const scoped = await mint([`fs:${home.uuid}:list`]);
expect(await rootPaths(scoped)).toEqual([`/${username}`]);
});
});
});
describe('POST /fs/completeWrite over HTTP', () => {
@@ -71,7 +71,7 @@ const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: makeActor({
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
@@ -79,7 +79,7 @@ const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
}),
},
};
};
@@ -843,90 +843,6 @@ describe('FSController.statEntry', () => {
);
expect((captured.body as { name: string }).name).toBe('share-budget');
});
describe('parentUid of a home child', () => {
const tokenActorFor = async (userId: number, permission: string) => {
const user = (await server.stores.user.getById(userId))!;
const token = await server.services.auth.createAccessToken(
makeActor({ user }),
[[permission]],
);
return (await server.services.auth.authenticateFromToken(token))!;
};
it('nulls it for a token scoped below the home', async () => {
const { userId } = await makeUser();
const user = (await server.stores.user.getById(userId))!;
const username = user.username!;
const documents = (await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
))!;
const scoped = await tokenActorFor(
userId,
`fs:${documents.uuid}:read`,
);
const { res, captured } = makeRes();
await withActor(scoped, () =>
controller.statEntry(
makeReq({
body: { path: `/${username}/Documents` },
actor: scoped,
}),
res,
),
);
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
null,
);
});
it('keeps it for a token that can list the home', async () => {
const { userId } = await makeUser();
const user = (await server.stores.user.getById(userId))!;
const username = user.username!;
const home = (await server.stores.fsEntry.getEntryByPath(
`/${username}`,
))!;
const scoped = await tokenActorFor(userId, `fs:${home.uuid}:list`);
const { res, captured } = makeRes();
await withActor(scoped, () =>
controller.statEntry(
makeReq({
body: { path: `/${username}/Documents` },
actor: scoped,
}),
res,
),
);
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
home.uuid,
);
});
it('keeps it for a session actor', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const home = (await server.stores.fsEntry.getEntryByPath(
`/${username}`,
))!;
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.statEntry(
makeReq({
body: { path: `/${username}/Documents` },
actor,
}),
res,
),
);
expect((captured.body as { parentUid: unknown }).parentUid).toBe(
home.uuid,
);
});
});
});
// ── /readdir (readdirEntries) ───────────────────────────────────────
+8 -72
View File
@@ -25,7 +25,6 @@ import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import { clientParentUid } from '../../services/fs/rootListing.js';
import {
expandTildePath,
isOwnersTrash,
@@ -53,9 +52,7 @@ import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterController } from '../types.js';
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
import {
FS_BATCH_WRITE_MAX_ITEMS,
FS_MULTIPART_LIMIT,
FS_MULTIPART_MAX_PARTS,
FS_MUTATE_LIMIT,
FS_READ_CONCURRENT,
FS_READ_LIMIT,
@@ -225,7 +222,6 @@ export class FSController extends PuterController {
const userId = this.#getActorUserId(req);
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
const appUidLookupCache = new Map<string, Promise<number | null>>();
this.#assertBatchItemCount(req.body);
const requests = Array.isArray(req.body)
? await Promise.all(
req.body.map(async (requestBody) => {
@@ -371,7 +367,6 @@ export class FSController extends PuterController {
res: Response<ClientCompleteWriteResponse[]>,
) {
const userId = this.#getActorUserId(req);
this.#assertBatchItemCount(req.body);
const requests = Array.isArray(req.body)
? req.body.map((requestBody) => {
return this.#withGuiMetadata(
@@ -448,17 +443,6 @@ export class FSController extends PuterController {
res: Response<ClientSignMultipartPartsResponse>,
) {
const userId = this.#getActorUserId(req);
const partNumbers = req.body?.partNumbers;
if (
Array.isArray(partNumbers) &&
partNumbers.length > FS_MULTIPART_MAX_PARTS
) {
throw new HttpError(
400,
`Too many partNumbers in one request (max ${FS_MULTIPART_MAX_PARTS})`,
{ legacyCode: 'bad_request' },
);
}
await this.#assertUploadSessionWriteAccess(req, userId, [
req.body?.uploadId,
]);
@@ -879,7 +863,6 @@ export class FSController extends PuterController {
return;
}
this.#assertBatchItemCount(req.body);
const requests = Array.isArray(req.body)
? await Promise.all(
req.body.map(async (requestBody) => {
@@ -1032,7 +1015,7 @@ export class FSController extends PuterController {
legacyCode: 'too_many_requests',
});
}
const [subtreeSize, suggestedApps, shareFlags, shares, parentUid] =
const [subtreeSize, suggestedApps, shareFlags, shares] =
await Promise.all([
entry.isDir && wantsSize
? this.services.fs.getSubtreeSize(userId, entry.path)
@@ -1047,21 +1030,11 @@ export class FSController extends PuterController {
entry.uuid,
)
: undefined,
clientParentUid(
actor,
entry,
this.services.acl,
this.stores.permission,
),
]);
entry.suggestedApps = suggestedApps;
res.json({
...this.#toClientEntry(
entry,
shareFlags.get(entry.uuid) ?? null,
parentUid,
),
...this.#toClientEntry(entry, shareFlags.get(entry.uuid) ?? null),
...(subtreeSize !== undefined ? { size: subtreeSize } : {}),
...(shares !== undefined ? { shares } : {}),
});
@@ -1074,16 +1047,8 @@ export class FSController extends PuterController {
* callers who only hold `see`/`list` on the entry — a share recipient, or
* (with public folders enabled) any authenticated user. The legacy read
* path already curates its output; this does the same for the v2 routes.
*
* `parentUid` is `entry.parentUid` unless `parentUidOverride` is passed
* (even as `null`) — callers reading a scoped access token's home pass the
* result of `clientParentUid` to hide it there.
*/
#toClientEntry(
entry: FSEntry,
isShared?: boolean | null,
parentUidOverride?: string | null,
): ClientFSEntry {
#toClientEntry(entry: FSEntry, isShared?: boolean | null): ClientFSEntry {
// Allowlist, not a denylist: a denylist silently ships every column
// added to `fsentries` later. Omits the numeric primary keys (`id`,
// `parentId`, `associatedAppId`), the storage columns, the owning
@@ -1095,10 +1060,7 @@ export class FSController extends PuterController {
return {
uuid: entry.uuid,
uid: entry.uid ?? entry.uuid,
parentUid:
parentUidOverride !== undefined
? parentUidOverride
: (entry.parentUid ?? null),
parentUid: entry.parentUid ?? null,
path: maskEntryPath(entry),
name: entry.name,
isDir: entry.isDir,
@@ -1219,8 +1181,6 @@ export class FSController extends PuterController {
const rootChildren = await listRootEntries(
actor,
this.stores.fsEntry,
this.services.acl,
this.stores.permission,
);
const rootSuggestions =
await this.services.suggestedApps.getSuggestedAppsForEntries(
@@ -1362,12 +1322,6 @@ export class FSController extends PuterController {
...this.#toClientEntry(
entry,
shareFlags.get(entry.uuid) ?? null,
await clientParentUid(
actor,
entry,
this.services.acl,
this.stores.permission,
),
),
// Fields the client cannot derive on its own.
type: fsEntryMimeType(entry),
@@ -2428,16 +2382,6 @@ export class FSController extends PuterController {
return guiMetadata;
}
#assertBatchItemCount(items: unknown): void {
if (Array.isArray(items) && items.length > FS_BATCH_WRITE_MAX_ITEMS) {
throw new HttpError(
400,
`Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
{ legacyCode: 'bad_request' },
);
}
}
/**
* The write handlers build on `req.body` being an object. A request whose
* body never parsed leaves it undefined, and the first field read after
@@ -2652,9 +2596,8 @@ export class FSController extends PuterController {
};
}
/** `forOwner` — these events go to the owner, not to the acting user. */
async #toGuiFsEntry(entry: FSEntry): Promise<Record<string, unknown>> {
return toLegacyEntry(this.clients.event, entry, { forOwner: true });
return toLegacyEntry(this.clients.event, entry);
}
async #emitGuiWriteEvent(
@@ -2760,13 +2703,10 @@ export class FSController extends PuterController {
return fsEntry;
}
const thumbnailPayload = {
url: requestedThumbnail,
uuid: fsEntry.uuid,
};
const thumbnailPayload = { url: requestedThumbnail };
// emitAndWait — the thumbnails extension may rewrite `url` from a
// data URL to an `s3://` pointer bound to `uuid`, or drop a pointer
// not minted for it; plain `emit` races with the DB update below.
// data URL to an `s3://` pointer; plain `emit` races with the DB
// update below.
await this.clients.event.emitAndWait(
'thumbnail.created',
thumbnailPayload,
@@ -2855,9 +2795,6 @@ export class FSController extends PuterController {
index: item.index,
contentType: item.contentType,
...(item.size !== undefined ? { size: item.size } : {}),
// The entry the upload lands on; its thumbnail key is
// bound to it.
item_uid: responses[item.index]?.objectKey ?? '',
}) as ThumbnailUploadPrepareItem,
),
};
@@ -3081,7 +3018,6 @@ export class FSController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
this.#assertBatchItemCount(manifest.items);
const manifestGuiMetadata = this.#extractGuiMetadata(
manifest,
@@ -28,7 +28,6 @@ import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { FSController } from './FSController.js';
import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js';
import type {
ClientSignedWriteResponse,
CompleteWriteRequest,
@@ -167,18 +166,8 @@ const buildMultipartBody = (parts: MultipartPart[]): Buffer => {
return Buffer.from(chunks.join(''), 'utf8');
};
const makeMultipartReq = (
parts: MultipartPart[],
actor: Actor,
chunkSize?: number,
): Request => {
const body = buildMultipartBody(parts);
const chunks: Buffer[] = [];
const step = chunkSize ?? body.length;
for (let i = 0; i < body.length; i += step) {
chunks.push(body.subarray(i, i + step));
}
const stream = Readable.from(chunks);
const makeMultipartReq = (parts: MultipartPart[], actor: Actor): Request => {
const stream = Readable.from([buildMultipartBody(parts)]);
return Object.assign(stream, {
body: undefined,
query: {},
@@ -239,46 +228,6 @@ describe('FSController.write', () => {
expect(stored?.size).toBe(5);
});
it('emits an upload.progress socket event reaching loaded === total', async () => {
const { actor, userId, username } = await makeUser();
const target = `/${username}/Documents/write-progress.txt`;
const content = 'progress-bytes';
const send = vi.spyOn(server.services.socket, 'send');
const { res } = makeRes();
try {
await withActor(actor, () =>
controller.write(
makeReq<WriteRequest>({
body: {
fileMetadata: {
path: target,
size: content.length,
contentType: 'text/plain',
},
fileContent: content,
} as WriteRequest,
actor,
}),
res,
),
);
expect(send).toHaveBeenCalledWith(
{ room: userId },
'upload.progress',
expect.objectContaining({
total: content.length,
loaded: content.length,
loaded_diff: content.length,
item_path: target,
}),
);
} finally {
send.mockRestore();
}
});
it('reports wasOverwrite and updates the row when overwriting', async () => {
const { actor, username } = await makeUser();
const target = `/${username}/Documents/write-overwrite.txt`;
@@ -662,60 +611,6 @@ describe('batch write handlers with a malformed element', () => {
});
});
describe('batch write handlers at the item cap', () => {
// Null elements fail the per-item body check, so which 400 comes back
// shows whether the cap ran before any item was read.
const handlers: Array<
[string, (c: typeof controller, r: Request, res: Response) => unknown]
> = [
[
'startBatchWrites',
(c, r, res) => c.startBatchWrites(r as never, res as never),
],
['batchWrites', (c, r, res) => c.batchWrites(r as never, res as never)],
[
'completeBatchWrites',
(c, r, res) => c.completeBatchWrites(r as never, res as never),
],
];
const callWith = async (
call: (typeof handlers)[number][1],
length: number,
) => {
const { actor } = await makeUser();
const req = makeReq({ actor });
(req as { body?: unknown }).body = Array(length).fill(null);
const { res } = makeRes();
return withActor(actor, async () => call(controller, req, res));
};
it.each(handlers)(
'%s rejects one item over the cap up front',
async (_label, call) => {
await expect(
callWith(call, FS_BATCH_WRITE_MAX_ITEMS + 1),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
});
},
);
it.each(handlers)(
'%s lets exactly the cap through to the per-item checks',
async (_label, call) => {
await expect(
callWith(call, FS_BATCH_WRITE_MAX_ITEMS),
).rejects.toMatchObject({
statusCode: 400,
message: 'A request body is required',
});
},
);
});
describe('FSController.write storage allowance', () => {
let limitedServer: PuterServer;
let limitedController: FSController;
@@ -912,18 +807,15 @@ describe('FSController.startWrite', () => {
it('attaches signed thumbnail upload targets published by a listener', async () => {
const { actor, username } = await makeUser();
const itemUids: string[] = [];
const listener = (_key: string, data: unknown) => {
const payload = data as {
items: Array<{
index: number;
item_uid: string;
uploadUrl?: string;
thumbnailUrl?: string;
}>;
};
for (const item of payload.items) {
itemUids.push(item.item_uid);
item.uploadUrl = `https://thumbs.test/put/${item.index}`;
item.thumbnailUrl = `https://thumbs.test/get/${item.index}`;
}
@@ -952,13 +844,6 @@ describe('FSController.startWrite', () => {
const body = captured.body as ClientSignedWriteResponse;
expect(body.thumbnailUploadUrl).toBe('https://thumbs.test/put/0');
expect(body.thumbnailUrl).toBe('https://thumbs.test/get/0');
// Listeners are told the entry the upload will complete into, so
// the thumbnail can be bound to it.
const session =
await server.stores.fsEntry.getPendingEntryBySessionId(
body.sessionId,
);
expect(itemUids).toEqual([session?.objectKey]);
} finally {
server.clients.event.off(
'thumbnail.upload.prepare',
@@ -1209,38 +1094,6 @@ describe('FSController.completeWrite', () => {
expect(stored?.thumbnail).toBe('https://thumbs.test/x.png');
});
it('tells thumbnail listeners which entry the thumbnail is for', async () => {
const { actor, username } = await makeUser();
const target = `/${username}/Documents/complete-bound-thumb.txt`;
const started = await startSignedWrite(actor, target, 2);
await fetch(started.url!, { method: 'PUT', body: 'yz' });
const seen: unknown[] = [];
const listener = (_key: string, data: unknown) => {
seen.push(data);
};
server.clients.event.on('thumbnail.created', listener as never);
try {
const { res } = makeRes();
await withActor(actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
body: {
uploadId: started.sessionId,
thumbnailData: 'https://thumbs.test/x.png',
},
actor,
}),
res,
),
);
} finally {
server.clients.event.off('thumbnail.created', listener as never);
}
const stored = await server.stores.fsEntry.getEntryByPath(target);
expect(seen).toEqual([expect.objectContaining({ uuid: stored?.uuid })]);
});
it('404s an unknown upload id', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
@@ -1380,29 +1233,6 @@ describe('FSController.signMultipartParts', () => {
),
).rejects.toMatchObject({ statusCode: 404 });
});
// The session is unknown, so reaching the lookup would answer 404.
it.each([
[FS_MULTIPART_MAX_PARTS + 1, 400],
[FS_MULTIPART_MAX_PARTS, 404],
])('answers %i part numbers with %i', async (length, statusCode) => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.signMultipartParts(
makeReq<SignMultipartPartsRequest>({
body: {
uploadId: uuidv4(),
partNumbers: Array(length).fill(1),
},
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode });
});
});
// -- /fs/batchWrite (JSON mode) ---------------------------------------
@@ -1639,60 +1469,6 @@ describe('FSController.batchWrites (multipart)', () => {
expect(stored?.size).toBe(5);
});
it('coalesces streamed upload progress when the declared size is 0', async () => {
const { actor, userId, username } = await makeUser();
const path = `/${username}/Documents/mp-progress.bin`;
const content = 'x'.repeat(64 * 1024);
const chunkSize = 4 * 1024;
const send = vi.spyOn(server.services.socket, 'send');
const { res } = makeRes();
// Scoped to this user: earlier tests' trailing flushes can land here.
const progressSends = () =>
send.mock.calls.filter(
([spec, key]) =>
key === 'upload.progress' &&
(spec as { room?: unknown }).room === userId,
);
try {
await withActor(actor, () =>
controller.batchWrites(
makeMultipartReq(
[
{
kind: 'field',
name: 'manifest',
value: manifestFor([path]),
},
{
kind: 'file',
name: 'file-0',
filename: 'progress.bin',
content,
},
],
actor,
chunkSize,
),
res,
),
);
const sumDiffs = () =>
progressSends().reduce(
(sum, [, , data]) =>
sum + (data as { loaded_diff: number }).loaded_diff,
0,
);
await vi.waitFor(() => expect(sumDiffs()).toBe(content.length));
expect(progressSends().length).toBeLessThan(
content.length / chunkSize,
);
} finally {
send.mockRestore();
}
});
it('maps positional `file` parts onto manifest order', async () => {
const { actor, username } = await makeUser();
const paths = [`/${username}/Documents/mp-pos.txt`];
@@ -1836,35 +1612,6 @@ describe('FSController.batchWrites (multipart)', () => {
});
});
it('rejects a manifest over the item cap', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
withActor(actor, () =>
controller.batchWrites(
makeMultipartReq(
[
{
kind: 'field',
name: 'manifest',
value: JSON.stringify({
items: Array(
FS_BATCH_WRITE_MAX_ITEMS + 1,
).fill(null),
}),
},
],
actor,
),
res,
),
),
).rejects.toMatchObject({
statusCode: 400,
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
});
});
it('rejects a manifest item without fileMetadata', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
@@ -18,7 +18,6 @@
*/
import type { Request, RequestHandler, Response } from 'express';
import { createHash } from 'node:crypto';
import { Readable, Writable } from 'node:stream';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
@@ -535,36 +534,6 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
expect(row?.thumbnail).toBe(thumbnail);
});
it('tells thumbnail listeners which entry the thumbnail is for', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/bound-thumb.txt`;
const { uuid } = await writeFileEntry(actor, path, 'body');
const seen: unknown[] = [];
const listener = (_key: string, data: unknown) => {
seen.push(data);
};
server.clients.event.on('thumbnail.created', listener as never);
try {
const { res } = makeRes();
await withActor(actor, () =>
controller.updateFsentryThumbnail(
makeReq({
body: {
uid: uuid,
thumbnail: 'data:image/png;base64,aGVsbG8=',
},
actor,
}),
res,
),
);
} finally {
server.clients.event.off('thumbnail.created', listener as never);
}
expect(seen).toEqual([expect.objectContaining({ uuid })]);
});
it("refuses to retag another user's entry", async () => {
const owner = await makeUser();
const stranger = await makeUser();
@@ -766,26 +735,6 @@ describe('LegacyFSController.file (signed streaming)', () => {
);
});
// Metering resolves the billed account's plan from this actor, and some
// plans are keyed on the account's email.
it('bills an anonymous read to the owner, email included', async () => {
const { actor, userId, username } = await makeUser();
const { query } = await signedQuery(
actor,
`/${username}/Documents/signed-billed.txt`,
'billed',
);
const req = makeReq({ query });
const { res, finished } = makeStreamRes();
await controller.file(req, res);
await finished;
expect(req.egressActor?.user).toMatchObject({
id: userId,
uuid: actor.user.uuid,
email: `${username}@test.local`,
});
});
it('rejects a signature minted before the owner was suspended', async () => {
const { actor, userId, username } = await makeUser();
const { query } = await signedQuery(
@@ -806,341 +755,6 @@ describe('LegacyFSController.file (signed streaming)', () => {
});
});
// -- signed URLs across an ownership change ----------------------------
describe('LegacyFSController signed URLs across an ownership change', () => {
type Signed = { uid: string; read_url: string; write_url?: string };
const queryOf = (url: string) => {
const parsed = new URL(url);
return {
uid: parsed.searchParams.get('uid')!,
expires: parsed.searchParams.get('expires')!,
signature: parsed.searchParams.get('signature')!,
};
};
// No actor: a link holder needs no account.
const listSigned = async (url: string) => {
const { res, captured } = makeRes();
await controller.file(makeReq({ query: queryOf(url) }), res);
return captured.body as Array<Signed & { fsentry_name: string }>;
};
const readSigned = async (url: string) => {
const { res, captured, finished } = makeStreamRes();
await controller.file(makeReq({ query: queryOf(url) }), res);
await finished;
return captured.text();
};
const signAs = async (actor: Actor, path: string): Promise<Signed> => {
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.sign(
makeReq({
body: { items: [{ path, action: 'write' }] },
actor,
}),
res,
),
);
return (captured.body as { signatures: Signed[] }).signatures[0];
};
const moveAs = async (
actor: Actor,
body: { source: string; destination: string },
) =>
withActor(actor, () =>
controller.move(makeReq({ body, actor }), makeRes().res),
);
it('refuses the URL and the child URLs it minted once a write share hands the entry to another user', async () => {
const alice = await makeUser();
const bob = await makeUser();
const dir = `/${alice.username}/Documents/signed-dir`;
await writeFileEntry(alice.actor, `${dir}/a.txt`, 'alice-bytes');
const signed = await signAs(alice.actor, dir);
const [child] = await listSigned(signed.read_url);
expect(child.fsentry_name).toBe('a.txt');
expect(await readSigned(child.read_url)).toBe('alice-bytes');
// Bob lets Alice write into his folder, and moving hers there makes
// it Bob's. Then he revokes the share and adds a file of his own.
const inbox = await server.services.fs.mkdir(bob.userId, {
path: `/${bob.username}/Documents/inbox`,
});
const grant = `fs:${inbox.uuid}:write`;
await server.services.permission.grantUserUserPermission(
bob.actor,
alice.username,
grant,
{},
);
await moveAs(alice.actor, { source: dir, destination: inbox.path });
await server.services.permission.revokeUserUserPermission(
bob.actor,
alice.username,
grant,
);
const movedDir = `${inbox.path}/signed-dir`;
await writeFileEntry(bob.actor, `${movedDir}/bob.txt`, 'bob-bytes');
expect(
(await server.stores.fsEntry.getEntryByPath(movedDir))!.userId,
).toBe(bob.userId);
await expect(listSigned(signed.read_url)).rejects.toMatchObject({
statusCode: 403,
});
// Refused on the signature, even for a caller allowed to write there.
await expect(
withActor(bob.actor, () =>
controller.writeFile(
makeReq({
query: {
...queryOf(signed.write_url!),
operation: 'mkdir',
},
body: { name: 'via-old-url' },
actor: bob.actor,
}),
makeRes().res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
// Moved descendants keep a cached owner until the entry cache expires.
await server.stores.fsEntry.invalidateEntryCacheByUuid(child.uid);
await expect(readSigned(child.read_url)).rejects.toMatchObject({
statusCode: 403,
});
});
it('keeps the URL working across moves and renames by the same owner', async () => {
const { actor, username } = await makeUser();
const dir = `/${username}/Documents/kept-dir`;
await writeFileEntry(actor, `${dir}/a.txt`, 'kept-bytes');
const signed = await signAs(actor, dir);
const [child] = await listSigned(signed.read_url);
await moveAs(actor, {
source: dir,
destination: `/${username}/Pictures`,
});
await withActor(actor, () =>
controller.rename(
makeReq({
body: { uid: signed.uid, new_name: 'renamed-dir' },
actor,
}),
makeRes().res,
),
);
const [listed] = await listSigned(signed.read_url);
expect(listed.fsentry_name).toBe('a.txt');
expect(await readSigned(listed.read_url)).toBe('kept-bytes');
expect(await readSigned(child.read_url)).toBe('kept-bytes');
});
it('still serves a URL signed before signatures were bound to the owner', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/legacy.txt`;
const { uuid } = await writeFileEntry(actor, path, 'legacy-bytes');
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
const signature = createHash('sha256')
.update(`${uuid}/read/${signingCfg().secret}/${expires}`)
.digest('hex');
const url = `${signingCfg().apiBaseUrl}/file?uid=${uuid}&expires=${expires}&signature=${signature}`;
expect(await readSigned(url)).toBe('legacy-bytes');
});
it('refuses that same pre-binding URL once legacy_file_signatures is off', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/legacy-off.txt`;
const { uuid } = await writeFileEntry(actor, path, 'legacy-bytes');
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
const signature = createHash('sha256')
.update(`${uuid}/read/${signingCfg().secret}/${expires}`)
.digest('hex');
const url = `${signingCfg().apiBaseUrl}/file?uid=${uuid}&expires=${expires}&signature=${signature}`;
const config = (
controller as unknown as { config: { legacy_file_signatures?: boolean } }
).config;
config.legacy_file_signatures = false;
try {
await expect(readSigned(url)).rejects.toMatchObject({
statusCode: 403,
});
} finally {
config.legacy_file_signatures = undefined;
}
});
it('still serves an owner-bound signature while legacy_file_signatures is off', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/owner-bound-while-off.txt`;
await writeFileEntry(actor, path, 'owner-bytes');
const signed = await signAs(actor, path);
const config = (
controller as unknown as { config: { legacy_file_signatures?: boolean } }
).config;
config.legacy_file_signatures = false;
try {
expect(await readSigned(signed.read_url)).toBe('owner-bytes');
} finally {
config.legacy_file_signatures = undefined;
}
});
});
// -- /file: non-UUID uid skips the entry-store lookup -------------------
describe('LegacyFSController.file (non-UUID uid)', () => {
it('rejects a non-UUID uid without touching the entry store', async () => {
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
const { res } = makeRes();
await expect(
controller.file(
makeReq({
query: {
uid: 'not-a-uuid',
expires: String(expires),
signature: 'deadbeef',
},
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it('still looks up a UUID-shaped uid that does not exist', async () => {
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
const { res } = makeRes();
await expect(
controller.file(
makeReq({
query: {
uid: uuidv4(),
expires: String(expires),
signature: 'deadbeef',
},
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
it('looks up a uuid whose version and variant nibbles are nonstandard', async () => {
const spy = vi.spyOn(server.stores.fsEntry, 'getEntryByUuid');
const expires = Math.ceil(Date.now() / 1000) + 9_999_999_999_999;
const { res } = makeRes();
await expect(
controller.file(
makeReq({
query: {
uid: '0123abcd-0000-0000-0000-0123456789ab',
expires: String(expires),
signature: 'deadbeef',
},
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
// -- /file via a signature minted by someone other than the owner -------
//
// `/sign` binds the signature to the entry's owner, not to whoever calls
// `/sign` — a grantee reading a shared file, or an app signing inside its
// own AppData, both mint signatures over an entry they don't own. Covers
// that those still read through `/file`.
describe('LegacyFSController /file via a non-owner-minted signature', () => {
const queryOf = (url: string) => {
const parsed = new URL(url);
return {
uid: parsed.searchParams.get('uid')!,
expires: parsed.searchParams.get('expires')!,
signature: parsed.searchParams.get('signature')!,
};
};
it('reads through /file when a grantee with read access signs the URL', async () => {
const owner = await makeUser();
const grantee = await makeUser();
const path = `/${owner.username}/Documents/grantee-read.txt`;
const { uuid } = await writeFileEntry(owner.actor, path, 'grantee-bytes');
await server.services.permission.grantUserUserPermission(
owner.actor,
grantee.username,
`fs:${uuid}:read`,
{},
);
const { res: signRes, captured: signCaptured } = makeRes();
await withActor(grantee.actor, () =>
controller.sign(
makeReq({
body: { items: [{ uid: uuid, action: 'read' }] },
actor: grantee.actor,
}),
signRes,
),
);
const signed = (
signCaptured.body as { signatures: Array<{ read_url: string }> }
).signatures[0];
const { res, captured, finished } = makeStreamRes();
await controller.file(makeReq({ query: queryOf(signed.read_url) }), res);
await finished;
expect(captured.statusCode).toBe(200);
expect(captured.text()).toBe('grantee-bytes');
});
it('reads through /file when an app actor signs inside its AppData root', async () => {
const { actor, username } = await makeUser();
const appUid = `routes-sign-app-${uuidv4()}`;
const appActor = makeActor({ ...actor, app: { uid: appUid } });
const path = `/${username}/AppData/${appUid}/app-read.txt`;
await writeFileEntry(actor, path, 'app-bytes');
const { res: signRes, captured: signCaptured } = makeRes();
await withActor(appActor, () =>
controller.sign(
makeReq({
body: { items: [{ path, action: 'read' }] },
actor: appActor,
}),
signRes,
),
);
const signed = (
signCaptured.body as { signatures: Array<{ read_url: string }> }
).signatures[0];
const { res, captured, finished } = makeStreamRes();
await controller.file(makeReq({ query: queryOf(signed.read_url) }), res);
await finished;
expect(captured.statusCode).toBe(200);
expect(captured.text()).toBe('app-bytes');
});
});
// -- /writeFile operation dispatch -------------------------------------
describe('LegacyFSController.writeFile operations', () => {
File diff suppressed because it is too large. Load diff
+149 -395
View File
@@ -32,31 +32,19 @@ import { HttpError } from '../../core/http/HttpError.js';
import { RouteOptions } from '../../core/http/index.js';
import {
assertNotSuspended,
assertTeam2fa,
assertVerifiedAccount,
} from '../../core/http/middleware/gates.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { ACLService } from '../../services/acl/ACLService.js';
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type {
SignAction,
SignedFile,
SigningConfig,
} from '../../util/fileSigning.js';
import type { SignedFile } from '../../util/fileSigning.js';
import {
NON_OWNER_SIGNATURE_TTL_SECONDS,
parseSignedQuery,
verifySignature,
} from '../../util/fileSigning.js';
import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js';
import {
joinChildPath,
normalizeAbsolutePath,
splitParentAndName,
} from '../../services/fs/resolveNode.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
import { maskEntryPath } from '../../services/fs/sharePathMask.js';
import { clientParentUid } from '../../services/fs/rootListing.js';
import {
buildHostedBackingDenial,
hostedIndexUrlBackingIsUnavailable,
@@ -79,7 +67,6 @@ import {
FS_SEARCH_CONCURRENT,
FS_SEARCH_LIMIT,
FS_SIGN_LIMIT,
FS_SIGN_MAX_ITEMS,
FS_SIGNED_CONCURRENT,
FS_SIGNED_READ_LIMIT,
FS_SIGNED_WRITE_LIMIT,
@@ -90,7 +77,6 @@ import {
assertAccess,
assertCanCreate,
assertCanMoveInto,
expandClientPath,
getBoolean,
getString,
loadLegacyAssociatedApps,
@@ -104,55 +90,15 @@ type RouterCache = Map<string, RequestHandler | null>;
const additionalRoutePaths: Record<string, string> = {};
// Shape only: version and variant nibbles aren't checked, so a row whose uuid
// wasn't minted by an RFC-conforming generator still resolves.
const UUID_SHAPE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Legacy `/batch` multipart upload caps. Each file is buffered fully into
// memory before any quota / storage check runs, so without these limits an
// authenticated caller could grow the process heap proportional to whatever
// they sent. Streaming uploads go through `/writeFile`; this path is for
// pre-v2 clients only.
const BATCH_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB per file
const BATCH_MAX_TOTAL_SIZE = 256 * 1024 * 1024; // 256 MiB buffered per request
const BATCH_MAX_FILES = 64;
const BATCH_MAX_PARTS = 256;
const BATCH_MAX_FIELD_SIZE = 1 * 1024 * 1024; // 1 MiB per operation/fileinfo JSON
// Multipart carries at most one op per part, so JSON mode gets the same bound.
const BATCH_MAX_OPS = BATCH_MAX_PARTS;
const BATCH_MUTATING_OPS = new Set(['mkdir', 'shortcut', 'move', 'delete']);
// An op's `as` names the path it produced; later ops in the same batch refer
// to it as `$name` or `$name/rest`, since a deduped mkdir can land elsewhere.
function resolveBatchRef(raw: string, refs: Map<string, string>): string {
if (!raw.startsWith('$')) return raw;
const slashIdx = raw.indexOf('/');
const name = slashIdx === -1 ? raw.slice(1) : raw.slice(1, slashIdx);
const rest = slashIdx === -1 ? '' : raw.slice(slashIdx);
if (!name) {
throw new HttpError(400, 'Invalid batch reference', {
legacyCode: 'bad_request',
});
}
const base = refs.get(name);
if (base === undefined) {
throw new HttpError(400, `Unknown batch reference: $${name}`, {
legacyCode: 'bad_request',
});
}
// Rejects non-normalized results, so `$ref/../x` can't escape.
return normalizeAbsolutePath(base + rest);
}
const malformedMultipart = (err: unknown): HttpError =>
err instanceof HttpError
? err
: new HttpError(
400,
`Malformed multipart body${err instanceof Error ? `: ${err.message}` : ''}`,
{ legacyCode: 'bad_request' },
);
async function loadAdditionalRouter(
key: string,
@@ -515,18 +461,11 @@ export class LegacyFSController extends PuterController {
'see',
);
const [suggestedApps, appsById, shareFlags, parentUid] =
await Promise.all([
this.services.suggestedApps.getSuggestedApps(entry),
loadLegacyAssociatedApps(this.stores.app, [entry]),
this.services.share.shareFlags(actor, [entry]),
clientParentUid(
actor,
entry,
this.services.acl,
this.stores.permission,
),
]);
const [suggestedApps, appsById, shareFlags] = await Promise.all([
this.services.suggestedApps.getSuggestedApps(entry),
loadLegacyAssociatedApps(this.stores.app, [entry]),
this.services.share.shareFlags(actor, [entry]),
]);
entry.suggestedApps = suggestedApps;
const shaped = await toLegacyEntry(this.clients.event, entry, {
@@ -538,7 +477,6 @@ export class LegacyFSController extends PuterController {
},
appsById,
isShared: shareFlags.get(entry.uuid) ?? null,
parentUid,
});
// Optional hydrations:
@@ -587,8 +525,6 @@ export class LegacyFSController extends PuterController {
const rootChildren = await listRootEntries(
actor,
this.stores.fsEntry,
this.services.acl,
this.stores.permission,
);
const rootSuggestions =
await this.services.suggestedApps.getSuggestedAppsForEntries(
@@ -725,21 +661,14 @@ export class LegacyFSController extends PuterController {
// When `parent` is a path string, use it directly without requiring
// the entry to exist — `services.fs.mkdir` honors `create_missing_parents`
// and will materialize any missing intermediate directories.
const username = actor.user?.username;
let targetPath = rawPath.startsWith('/')
? await expandClientPath(this.stores.fsEntry, rawPath, username)
: rawPath;
let targetPath = rawPath;
if (body.parent !== undefined && !rawPath.startsWith('/')) {
let parentPath: string;
if (
typeof body.parent === 'string' &&
(body.parent.startsWith('/') || body.parent.startsWith('~'))
) {
parentPath = await expandClientPath(
this.stores.fsEntry,
body.parent,
username,
);
parentPath = expandTildePath(body.parent, actor.user?.username);
} else {
const parent = await resolveV1Selector(
this.stores.fsEntry,
@@ -1073,13 +1002,9 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
const expanded = await expandClientPath(
this.stores.fsEntry,
rawPath,
actor.user?.username,
const parentPath = pathPosix.dirname(
rawPath.startsWith('/') ? rawPath : `/${rawPath}`,
);
const targetPath = expanded.startsWith('/') ? expanded : `/${expanded}`;
const parentPath = pathPosix.dirname(targetPath);
if (parentPath === '/') {
throw new HttpError(400, 'Cannot touch in root', {
legacyCode: 'bad_request',
@@ -1094,7 +1019,7 @@ export class LegacyFSController extends PuterController {
);
await this.services.fs.touch(userId, {
path: targetPath,
path: rawPath,
setAccessed: getBoolean(body, 'set_accessed_to_now') ?? false,
setModified: getBoolean(body, 'set_modified_to_now') ?? false,
setCreated: getBoolean(body, 'set_created_to_now') ?? false,
@@ -1203,7 +1128,7 @@ export class LegacyFSController extends PuterController {
// emitAndWait is required: the thumbnails extension rewrites
// `event.url` from a data URL to an `s3://` pointer, and the DB
// write below needs to see that rewrite.
const event = { url: thumbnail, uuid: entry.uuid };
const event = { url: thumbnail };
await this.clients.event.emitAndWait('thumbnail.created', event, {});
await this.clients.db.write(
@@ -1339,7 +1264,6 @@ export class LegacyFSController extends PuterController {
// that guard every other authenticated FS route have to run here.
assertNotSuspended(actor!.user);
assertVerifiedAccount(actor!.user);
await assertTeam2fa(actor!.user, this.stores.team);
req.actor = assertResolvedActor(actor!);
Context.set('actor', actor);
@@ -1374,12 +1298,6 @@ export class LegacyFSController extends PuterController {
throw new HttpError(400, '`items` is required', {
legacyCode: 'bad_request',
});
if (items.length > FS_SIGN_MAX_ITEMS)
throw new HttpError(
400,
`Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`,
{ legacyCode: 'bad_request' },
);
const actingApp = actor.effectiveApp;
const signingCfg = signingConfigFromAppConfig(this.config);
@@ -1515,12 +1433,25 @@ export class LegacyFSController extends PuterController {
writeFile = async (req: Request, res: Response): Promise<void> => {
const query = asRecord(req.query);
const signingCfg = signingConfigFromAppConfig(this.config);
const targetEntry = await this.#resolveSignedEntry(
query,
verifySignature(
{
uid: query.uid as string,
expires: query.expires as string,
signature: query.signature as string,
},
'write',
signingCfg,
);
const uid = typeof query.uid === 'string' ? query.uid : '';
const targetEntry = await resolveV1Selector(this.stores.fsEntry, {
uid,
});
if (!targetEntry)
throw new HttpError(404, 'Item not found', {
legacyCode: 'not_found',
});
// Owner suspension check.
const owner = await this.stores.user.getById(targetEntry.userId);
if (!owner)
@@ -1744,7 +1675,18 @@ export class LegacyFSController extends PuterController {
file = async (req: Request, res: Response): Promise<void> => {
const query = asRecord(req.query);
const signingCfg = signingConfigFromAppConfig(this.config);
const entry = await this.#resolveSignedEntry(query, 'read', signingCfg);
verifySignature(
{
uid: query.uid as string,
expires: query.expires as string,
signature: query.signature as string,
},
'read',
signingCfg,
);
const uid = typeof query.uid === 'string' ? query.uid : '';
const entry = await resolveV1Selector(this.stores.fsEntry, { uid });
// Owner-suspension guard — matches v1's /file. A signed URL stays
// valid forever by default, so a signature minted before a suspension
@@ -1768,7 +1710,6 @@ export class LegacyFSController extends PuterController {
uuid: owner.uuid,
id: owner.id,
username: owner.username,
email: owner.email ?? null,
suspended: !!(owner as { suspended?: unknown }).suspended,
},
};
@@ -2125,12 +2066,9 @@ export class LegacyFSController extends PuterController {
// silently ignores. Run the entry through `toLegacyEntry` first so
// the event payload matches what /stat et al. return, then overlay
// per-op extras (e.g. `old_path` for moves).
// `forOwner` — the audience is the owner, who can't read the actor's mask.
try {
const response = {
...(await toLegacyEntry(this.clients.event, entry, {
forOwner: true,
})),
...(await toLegacyEntry(this.clients.event, entry)),
...extra,
from_new_service: true,
};
@@ -2260,7 +2198,6 @@ export class LegacyFSController extends PuterController {
const results: unknown[] = [];
let hasError = false;
let sequentialFileIdx = 0;
const pathRefs = new Map<string, string>();
for (const spec of operationSpecs) {
try {
@@ -2268,16 +2205,6 @@ export class LegacyFSController extends PuterController {
const op = typeof record.op === 'string' ? record.op : '';
let shaped: unknown;
// Non-write ops draw the same budget as the standalone routes.
if (
BATCH_MUTATING_OPS.has(op) &&
!(await consumeRouteRateLimit(req, FS_MUTATE_LIMIT))
) {
throw new HttpError(429, 'Too many requests.', {
legacyCode: 'too_many_requests',
});
}
if (op === 'write') {
// Pair with a file part — prefer `item_upload_id`
// index (what puter-js sets), fall back to the op's
@@ -2311,22 +2238,15 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
}
const parentPath = resolveBatchRef(
getString(record, 'path') ?? '',
pathRefs,
);
const expandedParent = await expandClientPath(
this.stores.fsEntry,
const parentPath = getString(record, 'path') ?? '';
const expandedParent = expandTildePath(
parentPath,
username,
);
// joinChildPath rejects a `name` carrying a slash or a
// `.`/`..` segment, so it can't reach outside the parent
// the ACL check below actually authorized.
const targetPath = joinChildPath(
expandedParent || '/',
name,
);
const targetPath =
expandedParent && expandedParent !== '/'
? `${expandedParent.replace(/\/+$/, '')}/${name}`
: `/${name}`;
// Mirrors the per-op /write|/mkdir routes: assert write
// on the parent dir, but when the parent resolves to `/`
// fall back to the target path so the ACL check rides
@@ -2377,12 +2297,25 @@ export class LegacyFSController extends PuterController {
response.fsEntry,
);
} else if (op === 'mkdir') {
const { targetPath, writeAclPath } =
await this.#resolveBatchMkdirTarget(
record,
username,
pathRefs,
);
const parentPath = getString(record, 'path') ?? '';
const name = getString(record, 'name');
if (!name) {
throw new HttpError(400, 'mkdir op missing `name`', {
legacyCode: 'bad_request',
});
}
const expandedParent = expandTildePath(
parentPath,
username,
);
const targetPath =
expandedParent && expandedParent !== '/'
? `${expandedParent.replace(/\/+$/, '')}/${name}`
: `/${name}`;
const writeAclPath =
expandedParent && expandedParent !== '/'
? expandedParent.replace(/\/+$/, '')
: targetPath;
await assertAccess(
this.services.acl,
this.services.fs,
@@ -2400,13 +2333,12 @@ export class LegacyFSController extends PuterController {
'create_missing_parents',
) ?? false,
});
this.#registerBatchRef(record, pathRefs, entry.path);
await this.#emitGuiEvent('outer.gui.item.added', entry);
shaped = await toLegacyEntry(this.clients.event, entry);
} else if (op === 'shortcut') {
const parentPath = getString(record, 'path') ?? '';
const name = getString(record, 'name');
const shortcutToRaw =
const shortcutToUid =
getString(record, 'shortcut_to_uid') ??
getString(record, 'shortcut_to');
if (!name) {
@@ -2414,21 +2346,19 @@ export class LegacyFSController extends PuterController {
legacyCode: 'shortcut_target_not_found',
});
}
if (!shortcutToRaw) {
if (!shortcutToUid) {
throw new HttpError(
400,
'shortcut op missing `shortcut_to_uid`',
{ legacyCode: 'shortcut_target_not_found' },
);
}
// A path or a uid; the desktop sends a path.
const target = await resolveV1Selector(
this.stores.fsEntry,
resolveBatchRef(shortcutToRaw, pathRefs),
{ uid: shortcutToUid },
);
const expandedParent = await expandClientPath(
this.stores.fsEntry,
resolveBatchRef(parentPath, pathRefs),
const expandedParent = expandTildePath(
parentPath,
username,
);
const parent = await resolveV1Selector(
@@ -2458,21 +2388,13 @@ export class LegacyFSController extends PuterController {
await this.#emitGuiEvent('outer.gui.item.added', link);
shaped = await toLegacyEntry(this.clients.event, link);
} else if (op === 'move') {
const sourceRef =
typeof record.source === 'string'
? resolveBatchRef(record.source, pathRefs)
: record.source;
const destinationRef =
typeof record.destination === 'string'
? resolveBatchRef(record.destination, pathRefs)
: record.destination;
const source = await resolveV1Selector(
this.stores.fsEntry,
sourceRef,
record.source,
);
const destinationParent = await resolveV1Selector(
this.stores.fsEntry,
destinationRef,
record.destination,
);
await assertAccess(
this.services.acl,
@@ -2495,20 +2417,14 @@ export class LegacyFSController extends PuterController {
overwrite: getBoolean(record, 'overwrite') ?? false,
dedupeName: getBoolean(record, 'dedupe_name') ?? false,
});
this.#registerBatchRef(record, pathRefs, moved.path);
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
old_path: source.path,
});
shaped = await toLegacyEntry(this.clients.event, moved);
} else if (op === 'delete') {
const pathField = getString(record, 'path');
const selectorRef =
pathField !== undefined
? resolveBatchRef(pathField, pathRefs)
: record;
const entry = await resolveV1Selector(
this.stores.fsEntry,
selectorRef,
getString(record, 'path') ?? record,
);
await assertAccess(
this.services.acl,
@@ -2557,100 +2473,64 @@ export class LegacyFSController extends PuterController {
}> = [];
const fileinfos: Array<Record<string, unknown>> = [];
let parseError: Error | null = null;
let totalBufferedBytes = 0;
// First error wins. Buffered files are dropped right away rather
// than held while the rest of the body drains.
const fail = (err: Error) => {
if (parseError) return;
parseError = err;
files.length = 0;
};
const tooLarge = (message: string) =>
new HttpError(413, message, {
legacyCode: 'too_large' as never,
});
const reserve = (bytes: number): boolean => {
totalBufferedBytes += bytes;
if (totalBufferedBytes <= BATCH_MAX_TOTAL_SIZE) return true;
fail(
tooLarge(
`Batch exceeds ${BATCH_MAX_TOTAL_SIZE} bytes in total`,
),
);
return false;
};
let bb: ReturnType<typeof Busboy>;
try {
bb = Busboy({
headers: req.headers,
limits: {
fileSize: BATCH_MAX_FILE_SIZE,
files: BATCH_MAX_FILES,
parts: BATCH_MAX_PARTS,
fieldSize: BATCH_MAX_FIELD_SIZE,
},
});
} catch (err) {
// Missing boundary or unparseable content-type.
reject(malformedMultipart(err));
return;
}
const bb = Busboy({
headers: req.headers,
limits: {
fileSize: BATCH_MAX_FILE_SIZE,
files: BATCH_MAX_FILES,
parts: BATCH_MAX_PARTS,
fieldSize: BATCH_MAX_FIELD_SIZE,
},
});
bb.on('filesLimit', () =>
fail(
tooLarge(
// Busboy emits these `*Limit` events when a configured cap is
// hit. Capture the first one as a 413 so callers get a clean
// signal instead of a silently-truncated upload.
bb.on('filesLimit', () => {
if (!parseError) {
parseError = new HttpError(
413,
`Too many files in batch (max ${BATCH_MAX_FILES})`,
),
),
);
bb.on('partsLimit', () =>
fail(
tooLarge(
{ legacyCode: 'too_large' as never },
);
}
});
bb.on('partsLimit', () => {
if (!parseError) {
parseError = new HttpError(
413,
`Too many parts in batch (max ${BATCH_MAX_PARTS})`,
),
),
);
bb.on('fieldsLimit', () =>
fail(tooLarge('Too many fields in batch')),
);
{ legacyCode: 'too_large' as never },
);
}
});
bb.on('fieldsLimit', () => {
if (!parseError) {
parseError = new HttpError(
413,
'Too many fields in batch',
{ legacyCode: 'too_large' as never },
);
}
});
bb.on('field', (fieldName, value, info) => {
if (parseError) return;
if (info?.valueTruncated) {
fail(
tooLarge(
`\`${fieldName}\` field exceeds ${BATCH_MAX_FIELD_SIZE} bytes`,
),
);
return;
}
if (!reserve(Buffer.byteLength(value))) return;
// Ignore operation_id / socket_id / misc fields — not
// needed for v2 batch semantics.
if (fieldName !== 'operation' && fieldName !== 'fileinfo') {
return;
}
let parsed: unknown;
bb.on('field', (fieldName, value) => {
try {
parsed = JSON.parse(value);
} catch {
fail(
new HttpError(
400,
`\`${fieldName}\` field is not valid JSON`,
{ legacyCode: 'bad_request' },
),
);
return;
}
if (fieldName === 'operation') {
ops.push(parsed);
} else {
fileinfos.push(
parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: {},
);
if (fieldName === 'operation') {
ops.push(JSON.parse(value));
} else if (fieldName === 'fileinfo') {
const parsed = JSON.parse(value);
fileinfos.push(
parsed && typeof parsed === 'object'
? (parsed as Record<string, unknown>)
: {},
);
}
// Ignore operation_id / socket_id / misc fields — not
// needed for v2 batch semantics.
} catch (err) {
parseError =
err instanceof Error ? err : new Error(String(err));
}
});
@@ -2659,26 +2539,24 @@ export class LegacyFSController extends PuterController {
// For streaming uploads use the signed `/writeFile` endpoint.
bb.on('file', (_fieldName, stream, info) => {
const chunks: Buffer[] = [];
// After a failure the stream keeps draining so the multipart
// parser reaches `close`, but nothing more is held.
stream.on('data', (chunk: Buffer) => {
if (parseError || !reserve(chunk.length)) {
chunks.length = 0;
return;
}
chunks.push(chunk);
});
// Busboy emits `limit` after the first byte past `fileSize`.
let truncated = false;
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
// Busboy emits `limit` after writing the first byte past
// `fileSize`. The stream continues being drained so the
// multipart parser stays in sync, but we discard the (now
// truncated) buffer and mark the batch as failed.
stream.on('limit', () => {
chunks.length = 0;
fail(
tooLarge(
truncated = true;
if (!parseError) {
parseError = new HttpError(
413,
`File in batch exceeds ${BATCH_MAX_FILE_SIZE} bytes`,
),
);
{ legacyCode: 'too_large' as never },
);
}
});
stream.on('end', () => {
if (parseError) return;
if (truncated) return;
files.push({
content: Buffer.concat(chunks),
mimeType:
@@ -2691,19 +2569,16 @@ export class LegacyFSController extends PuterController {
: undefined,
});
});
stream.on('error', (err: Error) => fail(err));
stream.on('error', (err: Error) => {
parseError = err;
});
});
bb.on('close', () => {
if (parseError) reject(malformedMultipart(parseError));
if (parseError) reject(parseError);
else resolve({ ops, files, fileinfos });
});
bb.on('error', (err) => reject(malformedMultipart(err)));
// A client that disconnects mid-body never lets busboy close.
req.on('error', (err) => reject(malformedMultipart(err)));
req.on('aborted', () =>
reject(malformedMultipart(new Error('Request aborted'))),
);
bb.on('error', (err) => reject(err));
req.pipe(bb);
});
@@ -2711,100 +2586,9 @@ export class LegacyFSController extends PuterController {
#parseJsonBatch(req: Request): unknown[] {
const body = asRecord(req.body);
const ops = Array.isArray(body.operations)
? body.operations
: Array.isArray(body.ops)
? body.ops
: [];
if (ops.length > BATCH_MAX_OPS) {
throw new HttpError(
413,
`Too many operations in batch (max ${BATCH_MAX_OPS})`,
{ legacyCode: 'too_large' as never },
);
}
return ops;
}
/**
* Batch `mkdir` shapes: `{ parent, path }` with `path` relative to
* `parent`, `{ path, name }` with `path` as the parent, or an absolute `{
* path }`.
*/
async #resolveBatchMkdirTarget(
record: Record<string, unknown>,
username: string | undefined,
pathRefs: Map<string, string>,
): Promise<{ targetPath: string; writeAclPath: string }> {
// Write is checked on the immediate parent: a multi-segment relative
// path must not be authorized by a grant on the target itself. Under
// `/` there is no parent to check, so the target stands in.
const withAclPath = (targetPath: string) => {
const parentPath = pathPosix.dirname(targetPath);
return {
targetPath,
writeAclPath: parentPath === '/' ? targetPath : parentPath,
};
};
const name = getString(record, 'name');
if (typeof record.parent === 'string' && !name) {
const parentPath = await expandClientPath(
this.stores.fsEntry,
resolveBatchRef(record.parent, pathRefs),
username,
);
const segments = (getString(record, 'path') ?? '')
.split('/')
.filter((s) => s.length > 0);
if (segments.length === 0) {
throw new HttpError(400, 'mkdir op missing `name`', {
legacyCode: 'bad_request',
});
}
let targetPath = parentPath || '/';
for (const segment of segments) {
targetPath = joinChildPath(targetPath, segment);
}
return withAclPath(targetPath);
}
if (name) {
const parentPath = await expandClientPath(
this.stores.fsEntry,
resolveBatchRef(getString(record, 'path') ?? '', pathRefs),
username,
);
return withAclPath(joinChildPath(parentPath || '/', name));
}
const rawPath = getString(record, 'path');
if (!rawPath) {
throw new HttpError(400, 'mkdir op missing `name`', {
legacyCode: 'bad_request',
});
}
const { parentPath, name: derivedName } = splitParentAndName(
await expandClientPath(
this.stores.fsEntry,
resolveBatchRef(rawPath, pathRefs),
username,
),
);
return withAclPath(joinChildPath(parentPath, derivedName));
}
/** Record an op's `as` (a bare name, no slash) for later `$name` refs. */
#registerBatchRef(
record: Record<string, unknown>,
pathRefs: Map<string, string>,
path: string,
): void {
const as = record.as;
if (typeof as === 'string' && as.length > 0 && !as.includes('/')) {
pathRefs.set(as, path);
}
if (Array.isArray(body.operations)) return body.operations;
if (Array.isArray(body.ops)) return body.ops;
return [];
}
#serializeBatchError(err: unknown): Record<string, unknown> {
@@ -2832,36 +2616,6 @@ export class LegacyFSController extends PuterController {
}
// -- Helpers ---------------------------------------------------------
/**
* Resolve the entry a signed URL names and verify the signature against its
* current owner. Malformed or expired params fail before the lookup.
*/
async #resolveSignedEntry(
query: Record<string, unknown>,
action: SignAction,
signingCfg: SigningConfig,
): Promise<FSEntry> {
const signed = {
uid: query.uid as string,
expires: query.expires as string,
signature: query.signature as string,
};
const { uid } = parseSignedQuery(signed);
// A non-UUID uid can never match a real entry — skip the cache/DB
// round trip a forged request would otherwise pay for on every hit.
const entry = UUID_SHAPE.test(uid)
? await this.stores.fsEntry.getEntryByUuid(uid)
: null;
verifySignature(signed, action, signingCfg, entry?.userId ?? null);
// Only a signature from before owner binding verifies without an entry.
if (!entry)
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
return entry;
}
#requireActor(req: Request) {
const actor = req.actor;
if (!actor) {
@@ -1,173 +0,0 @@
/*
* 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 { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
// Launch adopts whatever directory already sits at `AppData/<appUid>`, and an
// origin app's uid is derived from its origin, so another app knows where a
// not-yet-opened app's root will be.
describe('AppData roots over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (path: string, token: string, body?: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: body === undefined ? 'GET' : 'POST',
headers: {
authorization: `Bearer ${token}`,
...(body === undefined
? {}
: { 'content-type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const origin = (label: string) =>
`https://${label}-${Math.random().toString(36).slice(2, 10)}.example.com`;
const launch = async (userToken: string, appOrigin: string) => {
const res = await call('/auth/get-user-app-token', userToken, {
origin: appOrigin,
});
expect(res.status).toBe(200);
return (await res.json()) as { token: string; app_uid: string };
};
const appRoot = async (app: { token: string; app_uid: string }) => {
const res = await call('/auth/request-app-root-dir', app.token, {
app_uid: app.app_uid,
});
expect(res.status).toBe(200);
return (await res.json()) as { uid: string; path: string };
};
it('refuses an app renaming its root onto another app’s uid', async () => {
const { username, token } = env.users.user;
const a = await launch(token, origin('appdata-a'));
const bOrigin = origin('appdata-b');
const bUid = await env.server.services.auth.appUidFromOrigin(bOrigin);
const root = await appRoot(a);
// A uid-bound grant on its own root, which would follow the root.
const sign = await call('/sign', a.token, {
items: [{ uid: root.uid, action: 'write' }],
app_uid: a.app_uid,
});
expect(sign.status).toBe(200);
const rename = await call('/rename', a.token, {
uid: root.uid,
new_name: bUid,
});
expect(rename.status).toBe(403);
const unmoved = await env.server.stores.fsEntry.getEntryByUuid(
root.uid,
);
expect(unmoved?.path).toBe(`/${username}/AppData/${a.app_uid}`);
// B gets a root of its own, out of A's reach.
const b = await launch(token, bOrigin);
expect(b.app_uid).toBe(bUid);
const bRoot = await appRoot(b);
expect(bRoot.uid).not.toBe(root.uid);
const secret = `/${username}/AppData/${bUid}/secret.txt`;
const write = await call('/fs/write', b.token, {
fileMetadata: {
path: secret,
size: Buffer.byteLength('b-private'),
contentType: 'text/plain',
},
fileContent: 'b-private',
encoding: 'utf8',
});
expect(write.status).toBe(200);
const read = await call(
`/read?file=${encodeURIComponent(secret)}`,
a.token,
);
expect(read.status).toBe(404);
});
it('refuses an app moving its root into the Trash', async () => {
const { username, token } = env.users.user;
const a = await launch(token, origin('appdata-trash'));
const root = await appRoot(a);
const move = await call('/move', a.token, {
source: root.uid,
destination: `/${username}/Trash`,
});
expect(move.status).toBe(403);
const unmoved = await env.server.stores.fsEntry.getEntryByUuid(
root.uid,
);
expect(unmoved?.path).toBe(`/${username}/AppData/${a.app_uid}`);
});
it('rewrites a forged trash original_path from an app instead of trusting it', async () => {
const { username, token } = env.users.user;
const a = await launch(token, origin('appdata-origin'));
const bUid = await env.server.services.auth.appUidFromOrigin(
origin('appdata-origin-b'),
);
const root = await appRoot(a);
const made = await call('/mkdir', a.token, {
path: `${root.path}/inner`,
});
expect(made.status).toBe(200);
const inner = (await made.json()) as { uid: string; path: string };
// Forging the other app's root as the trash origin would let the
// owner's (unchecked) restore recreate the file there. Refusing the
// move outright would also break an ordinary delete by anyone who
// isn't the account, so the forged keys are corrected instead.
const trash = await call('/move', a.token, {
source: inner.uid,
destination: `/${username}/Trash`,
new_name: inner.uid,
new_metadata: {
original_name: bUid,
original_path: `/${username}/AppData/${bUid}`,
trashed_ts: 1,
},
});
expect(trash.status).toBe(200);
const trashed = await env.server.stores.fsEntry.getEntryByUuid(
inner.uid,
);
const metadata = JSON.parse(trashed!.metadata!) as Record<
string,
unknown
>;
expect(metadata.original_path).toBe(inner.path);
expect(metadata.original_name).toBe('inner');
expect(metadata.trashed_ts).not.toBe(1);
});
});
@@ -256,21 +256,7 @@ describe('signingConfigFromAppConfig', () => {
url_signature_secret: 's3cret',
api_base_url: 'https://api.test',
} as never),
).toEqual({
secret: 's3cret',
apiBaseUrl: 'https://api.test',
allowLegacySignatures: true,
});
});
it('stops accepting pre-binding signatures when legacy_file_signatures is false', () => {
expect(
signingConfigFromAppConfig({
url_signature_secret: 's3cret',
api_base_url: 'https://api.test',
legacy_file_signatures: false,
} as never).allowLegacySignatures,
).toBe(false);
).toEqual({ secret: 's3cret', apiBaseUrl: 'https://api.test' });
});
it('fails loudly when the signing secret is missing', () => {
@@ -426,23 +412,6 @@ describe('toLegacyEntry', () => {
);
expect(shaped.associated_app).toBeNull();
});
it('uses the parentUid override, including null, over the entry’s own', async () => {
const hidden = await toLegacyEntry(undefined, baseEntry(), {
parentUid: null,
});
expect(hidden.parent_id).toBeNull();
expect(hidden.parent_uid).toBeNull();
const overridden = await toLegacyEntry(undefined, baseEntry(), {
parentUid: 'other-parent',
});
expect(overridden.parent_id).toBe('other-parent');
expect(overridden.parent_uid).toBe('other-parent');
const defaulted = await toLegacyEntry(undefined, baseEntry());
expect(defaulted.parent_uid).toBe('parent-1');
});
});
describe('loadLegacyAssociatedApps short-circuit', () => {
+31 -71
View File
@@ -22,7 +22,6 @@ import { contentType as contentTypeFromMime } from 'mime-types';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
import type { FSService } from '../../services/fs/FSService.js';
import type { NodeRef } from '../../services/fs/resolveNode.js';
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
import { isAppActor, type Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -32,7 +31,6 @@ import {
resolveNode,
normalizeAbsolutePath,
isOwnersTrash,
assertChildName,
joinChildPath,
expandTildePath,
} from '../../services/fs/resolveNode.js';
@@ -117,32 +115,9 @@ export async function expandClientPath(
);
}
// Nested `{parent, name}` selectors recurse; unbounded depth overflows the stack.
const MAX_SELECTOR_DEPTH = 4;
/**
* Resolve `ref`, reporting any miss as the same 404 an unreadable entry gets,
* so a lookup never echoes a resolved path or tells missing from hidden.
*/
async function resolveOrNotFound(
fsEntryStore: FSEntryStore,
ref: NodeRef,
): Promise<FSEntry> {
try {
const entry = await resolveNode(fsEntryStore, ref, { required: true });
if (entry) return entry;
} catch (err) {
if (!(err instanceof HttpError && err.statusCode === 404)) throw err;
}
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
}
async function resolveV1SelectorAtDepth(
export async function resolveV1Selector(
fsEntryStore: FSEntryStore,
raw: unknown,
depth: number,
): Promise<FSEntry> {
const username = Context.get('actor')?.user?.username;
@@ -155,29 +130,30 @@ async function resolveV1SelectorAtDepth(
const ref = isPath
? { path: await expandClientPath(fsEntryStore, raw, username) }
: { uid: raw };
return resolveOrNotFound(fsEntryStore, ref);
const entry = await resolveNode(fsEntryStore, ref, { required: true });
if (!entry)
throw new HttpError(404, `Entry not found: ${raw}`, {
legacyCode: 'not_found',
});
return entry;
}
const record = asRecord(raw);
// {parent, name}: "child selector" — resolve parent, then child by name.
if (record.parent !== undefined && typeof record.name === 'string') {
if (depth >= MAX_SELECTOR_DEPTH) {
throw new HttpError(400, 'Selector is nested too deeply', {
legacyCode: 'bad_request',
});
}
// Before the parent lookup, so a bad name can't tell whether the
// parent exists.
assertChildName(record.name);
const parent = await resolveV1SelectorAtDepth(
const parent = await resolveV1Selector(fsEntryStore, record.parent);
const childPath = joinChildPath(parent.path, record.name);
const child = await resolveNode(
fsEntryStore,
record.parent,
depth + 1,
{ path: childPath },
{ required: true },
);
return resolveOrNotFound(fsEntryStore, {
path: joinChildPath(parent.path, record.name),
});
if (!child)
throw new HttpError(404, `Entry not found: ${childPath}`, {
legacyCode: 'not_found',
});
return child;
}
const rawPath = typeof record.path === 'string' ? record.path : undefined;
@@ -197,14 +173,12 @@ async function resolveV1SelectorAtDepth(
? record.id
: undefined,
};
return resolveOrNotFound(fsEntryStore, ref);
}
export async function resolveV1Selector(
fsEntryStore: FSEntryStore,
raw: unknown,
): Promise<FSEntry> {
return resolveV1SelectorAtDepth(fsEntryStore, raw, 0);
const entry = await resolveNode(fsEntryStore, ref, { required: true });
if (!entry)
throw new HttpError(404, 'Entry not found', {
legacyCode: 'not_found',
});
return entry;
}
// -- ACL --------------------------------------------------------------
@@ -251,7 +225,7 @@ export async function assertAccess(
// through the error code. User-actor denials keep the real 403.
if (isAppActor(actor)) {
throw new HttpError(404, 'Subject does not exist', {
throw new HttpError(404, `Entry not found: path=${path}`, {
legacyCode: 'subject_does_not_exist',
});
}
@@ -464,9 +438,7 @@ export async function signEntryThumbnail(
* URL for a signed one. Pass `fsEntryStore`/`userStore` to hydrate `is_empty`
* (directories) and `owner` — both are required fields per the legacy stat
* contract but need extra DB lookups. Pass `appsById` (built via
* `loadLegacyAssociatedApps`) to populate `associated_app`. Pass `parentUid`
* (even as `null`, via `clientParentUid`) to override `parent_id`/`parent_uid`
* — callers reading a scoped access token's home use it to hide the uuid.
* `loadLegacyAssociatedApps`) to populate `associated_app`.
*/
export async function toLegacyEntry(
eventClient: EventClient | undefined,
@@ -479,14 +451,11 @@ export async function toLegacyEntry(
appsById?: Map<number, Record<string, unknown>>;
/** Omitted from the response when undefined. */
isShared?: boolean | null;
/** Skip the mask: the mask is the actor's, and only they can read it. */
forOwner?: boolean;
parentUid?: string | null;
} = {},
): Promise<Record<string, unknown>> {
// Someone else's entry is published under its masked path; the owner's
// real one, and everything above the share, stays server-side.
const publishedPath = opts.forOwner ? entry.path : maskEntryPath(entry);
const publishedPath = maskEntryPath(entry);
const dirname = pathPosix.dirname(publishedPath);
const mimeType = fsEntryMimeType(entry);
@@ -494,17 +463,12 @@ export async function toLegacyEntry(
const appdata_app =
pathComponents[2] === 'AppData' ? pathComponents[3] : undefined;
const parentUid =
opts.parentUid !== undefined
? opts.parentUid
: (entry.parentUid ?? null);
const response: Record<string, unknown> = {
id: entry.uuid,
uid: entry.uuid,
uuid: entry.uuid,
parent_id: parentUid,
parent_uid: parentUid,
parent_id: entry.parentUid,
parent_uid: entry.parentUid,
path: publishedPath,
dirname,
dirpath: dirname,
@@ -599,11 +563,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
{ legacyCode: 'internal_error' },
);
}
return {
secret,
apiBaseUrl,
allowLegacySignatures: config.legacy_file_signatures !== false,
};
return { secret, apiBaseUrl };
}
/**
@@ -611,7 +571,7 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig {
*
* Pass `actorUserId` so a signature over someone else's entry — a shared file —
* expires rather than outliving the share (see
* NON_OWNER_SIGNATURE_TTL_SECONDS). The signature is bound to `entry.userId`.
* NON_OWNER_SIGNATURE_TTL_SECONDS).
*/
export function signEntry(
entry: {
@@ -622,7 +582,7 @@ export function signEntry(
accessed: number | null;
modified: number;
created: number | null;
userId: number;
userId?: number;
},
config: SigningConfig,
opts: { actorUserId?: number; ttlSeconds?: number } = {},
+1 -4
View File
@@ -28,10 +28,7 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types';
type Spec = RouteRateLimit | NonNullable<RouteOptions['concurrent']>;
// Rate and concurrency specs only; the per-request item caps are plain numbers.
const all = Object.entries(limits).filter(
([, value]) => typeof value === 'object',
) as Array<[string, Spec | RouteRateLimit[]]>;
const all = Object.entries(limits) as Array<[string, Spec | RouteRateLimit[]]>;
const flat: Array<[string, Spec]> = all.flatMap(([name, value]) =>
Array.isArray(value)
? value.map((v, i): [string, Spec] => [`${name}[${i}]`, v])
+5 -27
View File
@@ -185,37 +185,15 @@ export const FS_HELPER_LIMIT = userWindow('fs:helper', 120, 60, 30);
*/
export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120);
// -- Per-request item caps -------------------------------------------
//
// The windows above count requests; these bound what one request can fan out
// to. Each item costs its own path resolution and ACL check, and an upload item
// also a pending session and presigned URLs. Checked before any per-item work.
/**
* `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks
* uploads at this size.
*/
export const FS_BATCH_WRITE_MAX_ITEMS = 500;
/** Entries per `/sign`. Each may also write an app grant. */
export const FS_SIGN_MAX_ITEMS = 500;
/**
* Part numbers per `signMultipartParts`: every part a multipart upload can have
* (1-10,000).
*/
export const FS_MULTIPART_MAX_PARTS = 10_000;
// -- Legacy multipart upload -----------------------------------------
/**
* `/batch` buffers every file fully into memory before any quota or storage
* check runs, up to BATCH_MAX_FILES × BATCH_MAX_FILE_SIZE, capped in aggregate
* per request by BATCH_MAX_TOTAL_SIZE. The concurrency slot is doing the real
* work here; the window is secondary, and sized to say so — one upload is one
* call, so a per-minute ceiling in the tens is a cap on how many files someone
* may upload rather than a bound on cost. What actually bounds the memory this
* route can tie up is how many run at once.
* check runs, up to BATCH_MAX_FILES × BATCH_MAX_FILE_SIZE. The concurrency slot
* is doing the real work here; the window is secondary, and sized to say so —
* one upload is one call, so a per-minute ceiling in the tens is a cap on how
* many files someone may upload rather than a bound on cost. What actually
* bounds the memory this route can tie up is how many run at once.
*/
export const FS_BATCH_LIMIT = userWindow('fs:batch', 600, 300, 300);
export const FS_BATCH_CONCURRENT = userConcurrent('fs:batch', 5, 2, 2);
@@ -1,197 +0,0 @@
/*
* 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, vi } from 'vitest';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
// A recipient addresses the owner's entries by a masked `/<owner>/<uuid>/<name>`
// path. Routes that create an entry have no row to resolve, so they unmask it
// themselves before the ACL check runs against the result.
describe('writes inside a folder shared with write', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 180_000);
afterAll(async () => {
await env?.shutdown();
});
const post = async (route: string, token: string, body: unknown) => {
const res = await fetch(new URL(route, env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
return {
status: res.status,
json: text ? (JSON.parse(text) as Record<string, unknown>) : null,
};
};
const makeUser = () =>
createTestUser(env.server, {
username: `sfw${Math.random().toString(36).slice(2, 9)}`,
password: 'puter-test-user-password',
});
/** `/<owner>/SharedDir` with `note.txt`, shared `write` with a recipient. */
const shareAFolder = async () => {
const owner = await makeUser();
const recipient = await makeUser();
const dir = await post('/mkdir', owner.token, {
path: `/${owner.username}/SharedDir`,
});
await post('/touch', owner.token, {
path: `/${owner.username}/SharedDir/note.txt`,
});
const note = await post('/stat', owner.token, {
path: `/${owner.username}/SharedDir/note.txt`,
});
await post('/share', owner.token, {
recipients: [recipient.username],
items: [{ uid: dir.json!.uid }],
mode: 'write',
});
return {
owner,
recipient,
realDir: `/${owner.username}/SharedDir`,
maskedDir: `/${owner.username}/${dir.json!.uid}/SharedDir`,
noteUid: note.json!.uid as string,
};
};
const ownerListing = async (s: { owner: { token: string; username: string } }) => {
const res = await post('/readdir', s.owner.token, {
path: `/${s.owner.username}/SharedDir`,
});
return (res.json as unknown as Array<{ name: string }>).map(
(entry) => entry.name,
);
};
// `{parent, path}` is the shape puter.js sends — the GUI's "New Folder".
it('mkdir accepts a masked parent', async () => {
const s = await shareAFolder();
const made = await post('/mkdir', s.recipient.token, {
parent: s.maskedDir,
path: 'New Folder',
});
expect(made.status).toBe(200);
expect(made.json).toMatchObject({
name: 'New Folder',
is_dir: true,
// Anchored to the share root the request came through, so the
// recipient can navigate into what they just made.
path: `${s.maskedDir}/New Folder`,
});
expect(await ownerListing(s)).toContain('New Folder');
}, 180_000);
it('mkdir accepts a masked absolute path', async () => {
const s = await shareAFolder();
const made = await post('/mkdir', s.recipient.token, {
path: `${s.maskedDir}/Reports`,
});
expect(made.status).toBe(200);
expect(await ownerListing(s)).toContain('Reports');
}, 180_000);
it('touch accepts a masked path', async () => {
const s = await shareAFolder();
const touched = await post('/touch', s.recipient.token, {
path: `${s.maskedDir}/from-recipient.txt`,
});
expect(touched.status).toBe(200);
expect(await ownerListing(s)).toContain('from-recipient.txt');
}, 180_000);
it('touch still expands a tilde path', async () => {
const s = await shareAFolder();
const touched = await post('/touch', s.owner.token, {
path: '~/SharedDir/from-tilde.txt',
});
expect(touched.status).toBe(200);
expect(await ownerListing(s)).toContain('from-tilde.txt');
}, 180_000);
// Addressed to the owner, so it carries the path the owner can resolve.
it('publishes the owner’s real path on events about their entries', async () => {
const s = await shareAFolder();
const seen: Array<Record<string, unknown>> = [];
const emit = env.server.clients.event.emit.bind(
env.server.clients.event,
);
vi.spyOn(env.server.clients.event, 'emit').mockImplementation(
((name: string, payload: Record<string, unknown>, meta: unknown) => {
if (name.startsWith('outer.gui.item.')) {
seen.push(payload);
}
return emit(
name as never,
payload as never,
meta as never,
);
}) as never,
);
const moved = await post('/move', s.recipient.token, {
source: s.noteUid,
destination: s.maskedDir,
new_name: 'renamed.txt',
});
expect(moved.status).toBe(200);
const ownerId = (await env.server.stores.user.getByUsername(
s.owner.username,
))!.id;
const event = seen.find(
(payload) =>
(payload.response as { uid?: string })?.uid === s.noteUid,
);
expect(event?.user_id_list).toEqual([ownerId]);
expect(event?.response).toMatchObject({
path: `${s.realDir}/renamed.txt`,
dirpath: s.realDir,
});
// The recipient's own response stays masked.
expect(
(moved.json!.moved as { path: string }).path,
).toBe(`${s.maskedDir}/renamed.txt`);
}, 180_000);
});
@@ -427,25 +427,6 @@ describe('HomepageController GET /app/:name', () => {
});
});
// -- app landing rate limit --
describe('HomepageController app landing rate limit', () => {
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
for (const path of ['/app/:name', '/desktop/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
if (!route) throw new Error(`No GET ${path} route`);
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
}
});
});
// ── /show/* ─────────────────────────────────────────────────────────
describe('HomepageController GET /show/*splat', () => {
@@ -31,17 +31,6 @@ import type {
/** Longest description/og:description/twitter:description on `/app/<name>`. */
export const APP_META_DESCRIPTION_MAX = 150;
/**
* Unauthenticated, rendered on every request, so the bucket is the network
* address rather than an account.
*/
const APP_LANDING_LIMIT = {
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip' as const,
};
/**
* App descriptions can run long; search snippets and social cards want a
* short blurb. Collapses whitespace, then cuts at a word boundary with an
@@ -171,16 +160,8 @@ export class HomepageController extends PuterController {
});
};
router.get(
'/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get(
'/desktop/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get('/app/:name', {}, sendAppShell);
router.get('/desktop/app/:name', {}, sendAppShell);
// -- /show/* - launch explorer with the requested file path --
@@ -93,22 +93,6 @@ export class HostingController extends PuterController {
userId: req.actor.user.id,
});
// Same event the `puter-subdomains` driver fires on delete —
// listeners (CDN purge, abuse tracking, worker cleanup)
// depend on it.
try {
this.clients.event.emit(
'subdomain.delete',
{
subdomain: row.subdomain,
uid: String(row.uuid),
},
{},
);
} catch {
// Non-critical.
}
res.json({});
},
);
@@ -181,24 +181,4 @@ describe('HostingController /delete-site', () => {
expect(captured.body).toEqual({});
expect(await server.stores.subdomain.getByUuid(site.uuid)).toBeNull();
});
it('emits subdomain.delete so listeners can purge caches', async () => {
const { userId, actor } = await makeUser();
const site = await makeSite(userId);
const emitSpy = vi.spyOn(server.clients.event, 'emit');
try {
await deleteSite(
makeReq({ body: { site_uuid: site.uuid }, actor }),
makeRes().res,
);
expect(emitSpy).toHaveBeenCalledWith(
'subdomain.delete',
{ subdomain: site.subdomain, uid: String(site.uuid) },
{},
);
} finally {
emitSpy.mockRestore();
}
});
});
@@ -448,14 +448,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
return String(oidc().verifyState(state!)?.redirect_uri);
};
const returnToFor = (
paths: string[],
path = '/',
recipientUuid?: string,
) => {
const returnToFor = (paths: string[], path = '/') => {
const params = new URLSearchParams();
for (const p of paths) params.append('shared', p);
if (recipientUuid) params.set('user_uuid', recipientUuid);
return `${path}?${params.toString()}`;
};
@@ -489,16 +484,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
]);
});
it('carries a valid recipient account hint with the share', async () => {
const recipientUuid = uuidv4();
const uri = await redirectUriFor(
returnToFor([sharedPath('Report.pdf')], '/', recipientUuid),
);
expect(new URL(uri).searchParams.get('user_uuid')).toBe(
recipientUuid,
);
});
it('carries no more items than a share link may name', async () => {
const paths = Array.from({ length: 25 }, (_, i) =>
sharedPath(`file-${i}.txt`),
@@ -520,8 +505,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
// a parameter that isn't `shared`, alone or alongside one
'/?x=1',
`${returnToFor([sharedPath('a.txt')])}&x=1`,
`${returnToFor([sharedPath('a.txt')])}&user_uuid=not-a-uuid`,
`${returnToFor([sharedPath('a.txt')])}&user_uuid=${uuidv4()}&user_uuid=${uuidv4()}`,
// the root is only a destination when it names something
'/',
// still no origin smuggling, share link or not
@@ -701,26 +684,6 @@ describe('OIDCController login callback', () => {
expect(captured.redirectUrl).toContain('auth_error=1');
});
it('redirects with auth_error when the provider is unreachable', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockRejectedValue(
new TypeError('fetch failed'),
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
expect(captured.redirectUrl).toContain('auth_error=1');
});
it('parses code/state from the POST body (Apple form_post)', async () => {
const state = oidc().signState({
provider: 'custom',
@@ -891,7 +854,7 @@ describe('OIDCController login callback', () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}&user_uuid=${encodeURIComponent(created.user!.uuid)}`,
redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}`,
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
@@ -914,7 +877,6 @@ describe('OIDCController login callback', () => {
expect(url.searchParams.get('auth_error')).toBe('1');
expect(url.searchParams.get('action')).toBe('login');
expect(url.searchParams.getAll('shared')).toEqual([shared]);
expect(url.searchParams.get('user_uuid')).toBe(created.user!.uuid);
});
it('redirects back to an /app/<name> landing after sign-in', async () => {
@@ -1014,6 +976,7 @@ describe('OIDCController login callback', () => {
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
expect(captured.redirectUrl).toContain('oidc_login=true');
@@ -1027,15 +990,6 @@ describe('OIDCController login callback', () => {
const user = await server.stores.user.getByEmail(email);
expect(user?.uuid).toBeTruthy();
expect(proof?.user_uuid).toBe(user!.uuid);
// Session cookie plus the proof's binding companion.
expect(captured.cookies).toHaveLength(2);
const binding = captured.cookies.find(
(c) => c.name === 'puter_oidc_popup_return',
);
expect(binding?.value).toBeTruthy();
expect(proof?.nonce).toBe(binding!.value);
expect(proof?.action).toBe('sign-in');
});
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
@@ -1087,14 +1041,6 @@ describe('OIDCController login callback', () => {
expect(captured.redirectUrl).toContain(
`opener_origin=${encodeURIComponent('http://opener.test')}`,
);
// The error leg must stamp the action its own redirect names.
const url = new URL(captured.redirectUrl!);
const proof = oidc().verifyPopupReturn(
url.searchParams.get('opener_state')!,
);
expect(proof?.oidc_login).toBe(false);
expect(proof?.action).toBe(url.searchParams.get('action'));
});
it('links an OIDC identity to an existing CONFIRMED password account via email match', async () => {
@@ -1746,30 +1692,6 @@ describe('OIDCController revalidate callback', () => {
expect(String(captured.body)).toContain('Missing');
});
it('returns 400 when the userinfo request cannot reach the provider', async () => {
const state = oidc().signState({
provider: 'custom',
flow: 'revalidate',
user_uuid: uuidv4(),
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockRejectedValue(
new TypeError('fetch failed'),
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/revalidate',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.statusCode).toBe(400);
expect(String(captured.body)).toContain('Please try again');
});
it('returns 403 when the OIDC sub resolves to a different account than the session', async () => {
// The linked account exists, but state.user_uuid points at someone else.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
@@ -1937,34 +1859,19 @@ describe('OIDCController GET /auth/revalidate-done', () => {
* attested rather than read.
*/
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
const NONCE = 'binding-nonce';
const mint = (payload: Record<string, unknown>) =>
server.services.oidc.signPopupReturn({
nonce: NONCE,
action: 'sign-in',
...payload,
});
const redeemWith = (openerState: unknown, nonce: string | null = NONCE) =>
makeReq({
body: { opener_state: openerState },
cookies: nonce === null ? {} : { puter_oidc_popup_return: nonce },
});
const redeem = async (openerState: unknown) => {
const redeem = async (opener_state: unknown) => {
const { res, captured } = makeRes();
await callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(openerState),
makeReq({ body: { opener_state } }),
res,
);
return captured;
};
it('hands back what a genuine proof attests', async () => {
const proof = mint({
const proof = server.services.oidc.signPopupReturn({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
@@ -1973,7 +1880,6 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
expect(captured.body).toEqual({
opener_origin: 'https://opener.test',
msg_id: '77',
action: 'sign-in',
oidc_login: true,
user_uuid: null,
});
@@ -1982,7 +1888,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
it('hands back the account a proof is bound to', async () => {
// The popup compares this against its current user to reject a proof
// replayed from another account's login.
const proof = mint({
const proof = server.services.oidc.signPopupReturn({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
@@ -1993,13 +1899,9 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
});
it('rejects a proof signed with someone else’s key', async () => {
// The whole point: only the server can mint one of these.
const forged = jwt.sign(
{
opener_origin: 'https://console.puter.com',
oidc_login: true,
purpose: 'popup-return',
nonce: NONCE,
},
{ opener_origin: 'https://console.puter.com', oidc_login: true },
'not-the-server-secret',
{ keyid: 'v2' },
);
@@ -2007,7 +1909,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(forged),
makeReq({ body: { opener_state: forged } }),
makeRes().res,
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -2018,19 +1920,14 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
// redeemed on the very next request, so a stale one is never genuine.
const stale = server.services.token.sign(
'oidc-state',
{
opener_origin: 'https://opener.test',
oidc_login: true,
purpose: 'popup-return',
nonce: NONCE,
},
{ opener_origin: 'https://opener.test', oidc_login: true },
{ expiresIn: -600 },
);
await expect(
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(stale),
makeReq({ body: { opener_state: stale } }),
makeRes().res,
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -2042,7 +1939,7 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(bad),
makeReq({ body: { opener_state: bad } }),
makeRes().res,
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -2052,82 +1949,13 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
it('reports oidc_login false when the proof does not claim a login', async () => {
// The error leg mints one of these: a real return, but nothing was
// signed in on it, so it must not suppress the account picker.
const proof = mint({
const proof = server.services.oidc.signPopupReturn({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: false,
});
expect((await redeem(proof)).body).toMatchObject({ oidc_login: false });
});
it('rejects an authorization state presented as a proof', async () => {
const state = server.services.oidc.signState({
provider: 'google',
redirect_uri: 'https://puter.test/action/sign-in',
embedded_in_popup: true,
msg_id: '77',
opener_origin: 'https://opener.test',
nonce: NONCE,
});
await expect(
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(state),
makeRes().res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects a proof redeemed without its binding cookie', async () => {
const proof = mint({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
});
for (const cookie of [null, 'a-different-nonce']) {
await expect(
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(proof, cookie),
makeRes().res,
),
).rejects.toMatchObject({ statusCode: 400 });
}
});
it('clears the binding cookie so a proof redeems once', async () => {
const proof = mint({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
});
const { res, captured } = makeRes();
await callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith(proof),
res,
);
expect(captured.clearedCookies).toContainEqual(
expect.objectContaining({ name: 'puter_oidc_popup_return' }),
);
});
it('leaves the binding cookie in place when redemption fails', async () => {
// A junk POST must not consume a cookie an in-flight return needs.
const { res, captured } = makeRes();
await expect(
callRoute(
'post',
'/auth/oidc/verify-popup-return',
redeemWith('not.a.proof'),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
expect(captured.clearedCookies).toEqual([]);
});
});
// ── rate-limit scopes ───────────────────────────────────────────────
+43 -112
View File
@@ -18,7 +18,6 @@
*/
import crypto from 'node:crypto';
import { validate as validateUuid } from 'uuid';
import type { Request, Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
@@ -29,7 +28,6 @@ import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
SHARE_DEEP_LINK_PARAM,
SHARE_RECIPIENT_PARAM,
} from '../../services/share/shareDeepLink.js';
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
@@ -41,14 +39,6 @@ const REVALIDATION_EXPIRY_SEC = 300;
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
const OIDC_NONCE_EXPIRY_SEC = 600;
// Single-use, and binds a popup-return proof to the browser that earned it.
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
// The popup branch below hard-codes this return target.
const POPUP_RETURN_ACTION = 'sign-in';
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
login: { account_not_found: 'signup', other: 'login' },
signup: { account_already_exists: 'login', other: 'signup' },
@@ -122,23 +112,14 @@ function isWhitelistedReturnPath(path: string): boolean {
* or the recipient returns to a bare Home with nothing to say what they were
* sent.
*
* Only `shared` makes the trip, with values shaped like the masked path the
* mail was built from — the value is user-visible text, so a hand-edited one is
* refused rather than reflected back into the browser. The mail's one recipient
* hint (`user_uuid`) rides along when it is a single uuid.
* `shared` is the only parameter that makes the trip, and only values shaped
* like the masked path the mail was built from — the value is user-visible
* text, so a hand-edited one is refused rather than reflected back into the
* browser.
*/
function sharedReturnQuery(query: string): {
paths: string[];
recipientUuid?: string;
} | null {
function sharedPathsFromReturnQuery(query: string): string[] | null {
const paths: string[] = [];
let recipientUuid: string | undefined;
for (const [key, value] of new URLSearchParams(query)) {
if (key === SHARE_RECIPIENT_PARAM) {
if (recipientUuid || !validateUuid(value)) return null;
recipientUuid = value;
continue;
}
if (key !== SHARE_DEEP_LINK_PARAM) return null;
const parsed = parseMaskedSharePath(value);
// The segment after the uuid is the shared item itself. A mask without
@@ -154,7 +135,7 @@ function sharedReturnQuery(query: string): {
paths.push(value);
}
}
return { paths, recipientUuid };
return paths;
}
/**
@@ -173,19 +154,15 @@ function sanitizeReturnTo(raw: string): string | null {
const shared =
separator === -1
? { paths: [] }
: sharedReturnQuery(raw.slice(separator + 1));
? []
: sharedPathsFromReturnQuery(raw.slice(separator + 1));
if (shared === null) return null;
// The root is only a destination when it names something: on its own it is
// where the flow already lands.
if (shared.paths.length === 0) return path === '/' ? null : path;
if (shared.length === 0) return path === '/' ? null : path;
const params = new URLSearchParams();
for (const value of shared.paths)
params.append(SHARE_DEEP_LINK_PARAM, value);
if (shared.recipientUuid) {
params.set(SHARE_RECIPIENT_PARAM, shared.recipientUuid);
}
for (const value of shared) params.append(SHARE_DEEP_LINK_PARAM, value);
return `${path}?${params.toString()}`;
}
@@ -196,8 +173,11 @@ function buildErrorRedirectUrl(
message: string,
stateDecoded?: Record<string, unknown>,
requestCode?: string,
// Omitted by callers with no state to attest.
mintPopupReturn?: (payload: Record<string, unknown>) => string,
// Signs the popup-return proof. Passed in because this is a module-level
// helper with no access to services; omitted by callers that have no
// state to attest (the proof is simply absent then, and the popup falls
// back to its browser-attested sources).
signPopupReturn?: (payload: Record<string, unknown>) => string,
): string {
const targetFlow =
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
@@ -214,15 +194,12 @@ function buildErrorRedirectUrl(
// its success reloads it, so they have to be on it to survive.
let pagePath = '/';
let sharedPaths: string[] = [];
let shareRecipientUuid: string | undefined;
if (typeof stateDecoded?.redirect_uri === 'string') {
try {
const stateUrl = new URL(stateDecoded.redirect_uri);
if (isWhitelistedReturnPath(stateUrl.pathname)) {
pagePath = stateUrl.pathname;
const shared = sharedReturnQuery(stateUrl.search);
sharedPaths = shared?.paths ?? [];
shareRecipientUuid = shared?.recipientUuid;
sharedPaths = sharedPathsFromReturnQuery(stateUrl.search) ?? [];
}
} catch {
// unparsable redirect_uri: fall back to the root page
@@ -244,15 +221,13 @@ function buildErrorRedirectUrl(
// Same reasoning as the success leg: the popup cannot tell a verified
// `opener_origin` from a typed one, so attest it. The error leg is a
// real return from the provider too — the flow failed, not the hop.
if (mintPopupReturn) {
if (signPopupReturn) {
params.set(
'opener_state',
mintPopupReturn({
signPopupReturn({
opener_origin: stateDecoded?.opener_origin ?? null,
msg_id: stateDecoded?.msg_id ?? null,
oidc_login: false,
// The popup checks this against the action it lands on.
action: targetFlow,
}),
);
}
@@ -276,9 +251,6 @@ function buildErrorRedirectUrl(
for (const path of sharedPaths) {
params.append(SHARE_DEEP_LINK_PARAM, path);
}
if (shareRecipientUuid) {
params.set(SHARE_RECIPIENT_PARAM, shareRecipientUuid);
}
return `${base}${pagePath}?${params.toString()}`;
}
@@ -321,21 +293,18 @@ export class OIDCController extends PuterController {
// A sign-in popup returning from a provider is told the opener's
// origin and that a login completed. It cannot check either: the
// values arrive as query parameters, and a URL built from a verified
// `state` looks exactly like one anybody can type. The opener's
// `state` looks exactly like one an attacker typed. The opener's
// origin decides which app a token gets minted for, so the popup
// redeems the signed proof here instead of believing the raw
// parameters.
//
// Served on the GUI origin too: the binding cookie is host-only.
//
// Unauthenticated on purpose — it reveals nothing the caller did not
// already hand over, and a proof that isn't this browser's yields
// nothing.
// already hand over, and a forged or expired proof yields nothing.
router.post(
'/auth/oidc/verify-popup-return',
{
subdomain: ['api', ''],
subdomain: 'api',
rateLimit: {
scope: 'oidc-verify-popup-return',
limit: 60,
@@ -350,25 +319,14 @@ export class OIDCController extends PuterController {
});
}
const decoded = this.services.oidc.verifyPopupReturn(proof);
const cookieNonce =
req.cookies?.[OIDC_POPUP_RETURN_COOKIE_NAME];
if (
!decoded ||
typeof decoded.nonce !== 'string' ||
typeof cookieNonce !== 'string' ||
!constantTimeEqual(cookieNonce, decoded.nonce)
) {
if (!decoded) {
throw new HttpError(400, 'Invalid `opener_state`', {
legacyCode: 'bad_request',
});
}
// Single-use; a rejected call must not burn the cookie.
res.clearCookie(OIDC_POPUP_RETURN_COOKIE_NAME, { path: '/' });
res.json({
opener_origin: decoded.opener_origin ?? null,
msg_id: decoded.msg_id ?? null,
action: decoded.action ?? null,
oidc_login: decoded.oidc_login === true,
user_uuid: decoded.user_uuid ?? null,
});
@@ -472,7 +430,7 @@ export class OIDCController extends PuterController {
: null;
if (embeddedInPopup && msgId) {
appRedirectUri = `${origin}/action/${POPUP_RETURN_ACTION}?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`;
appRedirectUri = `${origin}/action/sign-in?embedded_in_popup=true&msg_id=${encodeURIComponent(msgId)}`;
if (openerOrigin) {
appRedirectUri += `&opener_origin=${encodeURIComponent(openerOrigin)}`;
}
@@ -599,7 +557,7 @@ export class OIDCController extends PuterController {
resolutionErrorCode(resolved.code),
stateDecoded,
resolved.requestCode,
(p) => this.#mintPopupReturn(res, p),
(p) => this.services.oidc.signPopupReturn(p),
),
);
}
@@ -618,7 +576,7 @@ export class OIDCController extends PuterController {
'account_suspended',
stateDecoded,
undefined,
(p) => this.#mintPopupReturn(res, p),
(p) => this.services.oidc.signPopupReturn(p),
),
);
}
@@ -666,7 +624,7 @@ export class OIDCController extends PuterController {
resolutionErrorCode(resolved.code),
stateDecoded,
resolved.requestCode,
(p) => this.#mintPopupReturn(res, p),
(p) => this.services.oidc.signPopupReturn(p),
),
);
}
@@ -682,7 +640,7 @@ export class OIDCController extends PuterController {
'account_suspended',
stateDecoded,
undefined,
(p) => this.#mintPopupReturn(res, p),
(p) => this.services.oidc.signPopupReturn(p),
),
);
}
@@ -799,23 +757,6 @@ if (window.opener) {
// -- Shared helpers ----------------------------------------------
/** Sign a popup-return proof, bound to this browser and this popup. */
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
const nonce = crypto.randomBytes(32).toString('base64url');
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
// Redeemed same-origin.
...sessionCookieFlags(this.config, { crossSite: false }),
httpOnly: true,
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
path: '/',
});
return this.services.oidc.signPopupReturn({
action: POPUP_RETURN_ACTION,
...payload,
nonce,
});
}
/**
* Resolve an OIDC callback to a Puter user. In order:
*
@@ -963,33 +904,23 @@ if (window.opener) {
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
if (!callbackUrl) return { error: 'Invalid flow.' };
try {
const tokens = await this.services.oidc.exchangeCodeForTokens(
provider,
code,
callbackUrl,
);
if (!tokens || !tokens.access_token)
return { error: 'Token exchange failed.' };
const tokens = await this.services.oidc.exchangeCodeForTokens(
provider,
code,
callbackUrl,
);
if (!tokens || !tokens.access_token)
return { error: 'Token exchange failed.' };
const userinfo = await this.services.oidc.getUserInfo(
provider,
tokens.access_token,
typeof tokens.id_token === 'string'
? tokens.id_token
: undefined,
);
if (!userinfo || !userinfo.sub)
return { error: 'Could not get user info.' };
const userinfo = await this.services.oidc.getUserInfo(
provider,
tokens.access_token,
typeof tokens.id_token === 'string' ? tokens.id_token : undefined,
);
if (!userinfo || !userinfo.sub)
return { error: 'Could not get user info.' };
return { provider, userinfo, stateDecoded };
} catch (e) {
// Network failures reaching the provider; the user can retry.
console.warn(`[oidc] ${provider} request failed`, e);
return {
error: 'Could not reach the sign-in provider. Please try again.',
};
}
return { provider, userinfo, stateDecoded };
}
async #finishLogin(
@@ -1038,7 +969,7 @@ if (window.opener) {
target = appendQueryParam(
target,
'opener_state',
this.#mintPopupReturn(res, {
this.services.oidc.signPopupReturn({
opener_origin: stateDecoded.opener_origin ?? null,
msg_id: stateDecoded.msg_id ?? null,
oidc_login: true,
@@ -458,7 +458,6 @@ describe('PeerController TURN', () => {
uuid: created.uuid,
id: created.id,
username: created.username,
email: created.email,
},
effectiveApp: null,
});
@@ -456,7 +456,6 @@ export class PeerController extends PuterController {
uuid: user.uuid,
id: user.id,
username: user.username,
email: user.email ?? null,
},
});
await this.services.metering.incrementUsage(
@@ -45,7 +45,7 @@ import {
import type { Actor } from '../../core/actor.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { PuterServer } from '../../server.js';
import { inRequestScope, setupTestServer } from '../../testUtil.js';
import { setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
let server: PuterServer;
@@ -53,9 +53,7 @@ let controller: PuterAIController;
beforeAll(async () => {
server = await setupTestServer();
controller = inRequestScope(
server.controllers.puterAi as unknown as PuterAIController,
);
controller = server.controllers.puterAi as unknown as PuterAIController;
});
afterAll(async () => {
@@ -48,7 +48,7 @@ import type { RouteOptions } from '../../core/http/index.js';
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
import { PuterServer } from '../../server.js';
import { inRequestScope, setupTestServer } from '../../testUtil.js';
import { setupTestServer } from '../../testUtil.js';
import { PuterAIController } from './PuterAIController.js';
// ── Test harness ────────────────────────────────────────────────────
@@ -58,9 +58,7 @@ let controller: PuterAIController;
beforeAll(async () => {
server = await setupTestServer();
controller = inRequestScope(
server.controllers.puterAi as unknown as PuterAIController,
);
controller = server.controllers.puterAi as unknown as PuterAIController;
});
afterAll(async () => {
@@ -132,7 +130,6 @@ const makeRes = () => {
captured.ended = true;
return res;
}),
once: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
@@ -20,8 +20,6 @@
import type { Request, Response } from 'express';
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { abortOnDisconnect } from '../../core/http/abortOnDisconnect.js';
import { HttpError } from '../../core/http/HttpError.js';
import { RouteOptions } from '../../core/http/index.js';
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
@@ -344,7 +342,7 @@ export class PuterAIController extends PuterController {
...openaiCompatProvider(body),
};
const result = await this.#complete(res, completeArgs);
const result = await this.#driver().complete(completeArgs);
const effectiveModel = completeArgs.model || '';
if (stream) {
@@ -502,7 +500,7 @@ export class PuterAIController extends PuterController {
const completionId = `cmpl-${randomId()}`;
const created = Math.floor(Date.now() / 1000);
const result = await this.#complete(res, completeArgs);
const result = await this.#driver().complete(completeArgs);
const effectiveModel = completeArgs.model || '';
if (stream) {
@@ -585,8 +583,7 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
)?.content,
),
index: 0,
@@ -709,7 +706,7 @@ export class PuterAIController extends PuterController {
const responseId = generateId('resp');
const createdAt = Math.floor(Date.now() / 1000);
const result = await this.#complete(res, completeArgs);
const result = await this.#driver().complete(completeArgs);
const effectiveModel = completeArgs.model || '';
if (stream) {
@@ -999,7 +996,7 @@ export class PuterAIController extends PuterController {
};
const messageId = `msg_${randomId()}`;
const result = await this.#complete(res, completeArgs);
const result = await this.#driver().complete(completeArgs);
const effectiveModel = completeArgs.model || '';
if (stream) {
@@ -1197,15 +1194,6 @@ export class PuterAIController extends PuterController {
});
return driver;
}
/** A completion that stops, and stops billing, when the caller hangs up. */
#complete(
res: Response,
args: ICompleteArguments,
): Promise<IChatCompleteResult> {
Context.set('abortSignal', abortOnDisconnect(res));
return this.#driver().complete(args);
}
}
// -- Shared helpers --------------------------------------------------
@@ -18,12 +18,6 @@
*/
import { HttpError } from '../../core/http/HttpError.js';
import {
MAX_TOTAL_ATTACHMENT_BYTES,
attachmentMetadata,
attachmentSummary,
readContactSubmission,
} from '../../util/contactAttachments.js';
import { PuterController } from '../types.js';
/**
@@ -100,38 +94,6 @@ const LSMOD_LIMIT = {
key: 'user',
};
/** Longest message the Contact Us form will carry, in characters. */
const CONTACT_MESSAGE_MAX_LENGTH = 100_000;
/** UTF-8 worst case for {@link CONTACT_MESSAGE_MAX_LENGTH} UTF-16 code units. */
const CONTACT_MESSAGE_MAX_BYTES = CONTACT_MESSAGE_MAX_LENGTH * 3;
/** Largest valid multipart body: attachments, message, part-header slack. */
const CONTACT_BODY_MAX_BYTES =
MAX_TOTAL_ATTACHMENT_BYTES + CONTACT_MESSAGE_MAX_BYTES + 64 * 1024;
const isMultipart = (req) =>
/^multipart\/form-data\b/i.test(req.headers?.['content-type'] ?? '');
/**
* Per user, plus a per-IP backstop against one machine cycling fresh accounts.
* The IP limit sits well above what a shared office would send.
*/
const CONTACT_US_LIMITS = [
{
scope: 'contact-us',
limit: 10,
window: 15 * 60_000,
key: 'user',
},
{
scope: 'contact-us-ip',
limit: 40,
window: 24 * 60 * 60_000,
key: 'ip',
},
];
export class SystemController extends PuterController {
constructor(config, clients, stores, services, drivers) {
super(config, clients, stores, services, drivers);
@@ -218,78 +180,33 @@ export class SystemController extends PuterController {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: CONTACT_US_LIMITS,
// Each in-flight request holds its attachments in memory until
// the mail is sent.
concurrent: { limit: 2, scope: 'contact-us', key: 'user' },
rateLimit: {
scope: 'contact-us',
limit: 10,
window: 15 * 60_000,
key: 'user',
},
},
async (req, res) => {
let message;
let attachments = [];
if (isMultipart(req)) {
const declaredLength = Number(
req.headers['content-length'],
);
if (
Number.isFinite(declaredLength) &&
declaredLength > CONTACT_BODY_MAX_BYTES
) {
// Don't read a body already known to be too large.
res.setHeader('Connection', 'close');
throw new HttpError(413, 'Request body is too large', {
legacyCode: 'bad_request',
});
}
const submission = await readContactSubmission(req, {
maxMessageBytes: CONTACT_MESSAGE_MAX_BYTES,
maxBodyBytes: CONTACT_BODY_MAX_BYTES,
});
if (!submission.ok) {
throw new HttpError(
submission.status,
submission.reason,
{ legacyCode: 'bad_request' },
);
}
({ message, attachments } = submission);
} else {
message = req.body?.message;
if (req.body?.attachments !== undefined) {
throw new HttpError(
400,
'`attachments` must be sent as multipart/form-data',
{ legacyCode: 'bad_request' },
);
}
}
const { message } = req.body ?? {};
if (!message || typeof message !== 'string') {
throw new HttpError(400, '`message` is required', {
legacyCode: 'bad_request',
});
}
if (message.length > CONTACT_MESSAGE_MAX_LENGTH) {
if (message.length > 100_000) {
throw new HttpError(
400,
`\`message\` is too long (max ${CONTACT_MESSAGE_MAX_LENGTH.toLocaleString('en-US')} characters)`,
'`message` is too long (max 100,000 characters)',
{ legacyCode: 'bad_request' },
);
}
// Persist to feedback table for durability. Attachments are
// recorded by name and size only; the mail carries the bytes.
// Persist to feedback table for durability
try {
await this.clients.db.write(
'INSERT INTO `feedback` (`user_id`, `message`, `attachments`) VALUES (?, ?, ?)',
[
req.actor.user.id,
message,
attachments.length
? JSON.stringify(
attachmentMetadata(attachments),
)
: null,
],
'INSERT INTO `feedback` (`user_id`, `message`) VALUES (?, ?)',
[req.actor.user.id, message],
);
} catch (e) {
console.warn('[contactUs] feedback insert failed:', e);
@@ -304,16 +221,7 @@ export class SystemController extends PuterController {
to: supportEmail,
replyTo: req.actor.user.email,
subject: `Contact from ${req.actor.user.username}`,
text: attachments.length
? `${message}\n\n${attachmentSummary(attachments)}`
: message,
// Never rendered inline by the mail client.
attachments: attachments.map((a) => ({
filename: a.filename,
content: a.content,
contentType: a.contentType,
contentDisposition: 'attachment',
})),
text: message,
});
} catch (e) {
console.warn('[contactUs] email send failed:', e);
@@ -18,7 +18,6 @@
*/
import type { Request, RequestHandler, Response } from 'express';
import { Readable } from 'node:stream';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
@@ -83,12 +82,11 @@ const makeReq = (init: {
body?: unknown;
actor?: Actor;
query?: Record<string, unknown>;
headers?: Record<string, string>;
}): Request => {
return {
body: init.body ?? {},
query: init.query ?? {},
headers: init.headers ?? {},
headers: {},
actor: init.actor,
} as unknown as Request;
};
@@ -432,253 +430,11 @@ describe('SystemController POST /contactUs', () => {
// The row landed in the real `feedback` table for the right user.
const rows = (await server.clients.db.read(
'SELECT `user_id`, `message`, `attachments` FROM `feedback` WHERE `user_id` = ? AND `message` = ?',
'SELECT `user_id`, `message` FROM `feedback` WHERE `user_id` = ? AND `message` = ?',
[userId, message],
)) as Array<{
user_id: number;
message: string;
attachments: string | null;
}>;
)) as Array<{ user_id: number; message: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]?.message).toBe(message);
// No files sent — the column stays null rather than an empty array.
expect(rows[0]?.attachments ?? null).toBeNull();
});
});
describe('SystemController POST /contactUs — attachments', () => {
// A real PNG signature with a filler body; the sniffer only reads the
// first eight bytes, and nothing downstream decodes the image.
const pngBytes = (size = 64): Buffer =>
Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(size - 8, 0x61),
]);
type Part =
| { field: string; value: string }
| { field: string; filename: string; data: Buffer };
const BOUNDARY = '----contact-us-test';
const multipart = (parts: Part[]): Buffer =>
Buffer.concat([
...parts.flatMap((part) =>
'value' in part
? [
Buffer.from(
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"\r\n\r\n${part.value}\r\n`,
),
]
: [
Buffer.from(
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
),
part.data,
Buffer.from('\r\n'),
],
),
Buffer.from(`--${BOUNDARY}--\r\n`),
]);
const submitMultipart = async (
parts: Part[],
headers: Record<string, string> = {},
) => {
const { actor, userId } = await makeUser();
const { res, captured } = makeRes();
const body = multipart(parts);
const req = Object.assign(Readable.from([body]), {
headers: {
'content-type': `multipart/form-data; boundary=${BOUNDARY}`,
'content-length': String(body.length),
...headers,
},
actor,
query: {},
}) as unknown as Request;
const call = callRoute('post', '/contactUs', req, res);
return { call, captured, userId };
};
const readAttachments = async (userId: number, message: string) => {
const rows = (await server.clients.db.read(
'SELECT `attachments` FROM `feedback` WHERE `user_id` = ? AND `message` = ?',
[userId, message],
)) as Array<{ attachments: string | null }>;
return rows[0]?.attachments ?? null;
};
it('stores attachment metadata — names, types and sizes, no payloads', async () => {
const message = `attached ${Math.random().toString(36).slice(2)}`;
const { call, captured, userId } = await submitMultipart([
{ field: 'message', value: message },
{
field: 'attachments',
filename: 'repro-step-3.png',
data: pngBytes(128),
},
]);
await call;
expect(captured.body).toEqual({});
const stored = await readAttachments(userId, message);
expect(JSON.parse(stored!)).toEqual([
{ name: 'repro-step-3.png', type: 'image/png', size: 128 },
]);
});
it('emails the files to support with sanitized names and a manifest', async () => {
const sendRaw = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
try {
const message = `attached ${Math.random().toString(36).slice(2)}`;
const { call } = await submitMultipart([
{ field: 'message', value: message },
// Declares .html, but the bytes are a PNG: the stored and
// emailed extension must follow the bytes.
{
field: 'attachments',
filename: 'payload.html',
data: pngBytes(64),
},
]);
await call;
expect(sendRaw).toHaveBeenCalledTimes(1);
const sent = sendRaw.mock.calls[0][0] as {
text: string;
attachments: Array<{
filename: string;
content: Buffer;
contentType: string;
contentDisposition: string;
}>;
};
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('payload.png');
expect(sent.attachments[0].contentType).toBe('image/png');
expect(sent.attachments[0].contentDisposition).toBe('attachment');
expect(sent.attachments[0].content.equals(pngBytes(64))).toBe(true);
// The body records what should have arrived, in case a gateway
// strips the files on the way.
expect(sent.text).toContain(message);
expect(sent.text).toContain('payload.png');
} finally {
sendRaw.mockRestore();
}
});
it('accepts a multipart submission with no files', async () => {
const sendRaw = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
try {
const message = `plain ${Math.random().toString(36).slice(2)}`;
const { call, captured } = await submitMultipart([
{ field: 'message', value: message },
]);
await call;
expect(captured.body).toEqual({});
const sent = sendRaw.mock.calls[0][0] as {
text: string;
attachments: unknown[];
};
expect(sent.attachments).toEqual([]);
expect(sent.text).toBe(message);
} finally {
sendRaw.mockRestore();
}
});
it('rejects base64 attachments in a JSON body', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/contactUs',
makeReq({
body: {
message: 'hi',
attachments: [{ data: pngBytes().toString('base64') }],
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it.each([
[
'too many files',
[
{ field: 'message', value: 'hi' },
...Array.from({ length: 6 }, (_, i) => ({
field: 'attachments',
filename: `${i}.png`,
data: pngBytes(),
})),
],
],
[
'an unsupported type',
[
{ field: 'message', value: 'hi' },
{
field: 'attachments',
filename: 'a.pdf',
data: Buffer.from('%PDF-1.7 x'),
},
],
],
[
'a script-capable SVG',
[
{ field: 'message', value: 'hi' },
{
field: 'attachments',
filename: 'a.svg',
data: Buffer.from('<svg><script/></svg>'),
},
],
],
[
'a missing message',
[{ field: 'attachments', filename: 'a.png', data: pngBytes() }],
],
] as [string, Part[]][])('rejects %s with 400', async (_label, parts) => {
const { call, captured } = await submitMultipart(parts);
await expect(call).rejects.toMatchObject({ statusCode: 400 });
expect(captured.body).toBeUndefined();
});
it('refuses a body whose declared length is over budget before reading it', async () => {
const { call, captured } = await submitMultipart(
[{ field: 'message', value: 'hi' }],
{ 'content-length': String(64 * 1024 * 1024) },
);
await expect(call).rejects.toMatchObject({ statusCode: 413 });
expect(captured.headers.Connection).toBe('close');
});
it('rejects an upload that breaks a limit mid-stream', async () => {
const { call, captured } = await submitMultipart(
[
{ field: 'message', value: 'hi' },
{
field: 'attachments',
filename: 'big.png',
data: pngBytes(10 * 1024 * 1024 + 1),
},
],
// Chunked uploads carry no length to check up front.
{ 'content-length': '' },
);
await expect(call).rejects.toMatchObject({ statusCode: 413 });
// The rest of the body is drained, so the connection can stay open.
expect(captured.headers.Connection).toBeUndefined();
});
});
@@ -1,136 +0,0 @@
/*
* 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 http from 'node:http';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Contact Us attachments over real HTTP. Multipart bodies must skip the global
* JSON parser so the route's gates run before any of the body is read; only a
* listening server shows that.
*/
describe('POST /contactUs over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const png = (size: number): Buffer =>
Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(size - 8, 0x61),
]);
const BOUNDARY = 'contact-us-http-test';
const multipartHead = Buffer.from(
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="message"\r\n\r\nhi\r\n` +
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="attachments"; filename="a.png"\r\n` +
'Content-Type: image/png\r\n\r\n',
);
/** Send raw bytes and resolve with the response, however early it comes. */
const rawPost = (
headers: Record<string, string>,
write: (req: http.ClientRequest) => void,
) =>
new Promise<{ status: number; connection?: string }>(
(resolve, reject) => {
const url = new URL('/contactUs', env.apiOrigin);
const req = http.request(
url,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.users.user.token}`,
'Content-Type': `multipart/form-data; boundary=${BOUNDARY}`,
...headers,
},
},
(res) => {
res.resume();
resolve({
status: res.statusCode ?? 0,
connection: res.headers.connection,
});
},
);
req.on('error', reject);
write(req);
},
);
it('accepts the FormData the Contact Us window sends', async () => {
const message = `http ${Math.random().toString(36).slice(2)}`;
const body = new FormData();
body.append('message', message);
body.append(
'attachments',
new Blob([png(4096)], { type: 'image/png' }),
'shot.png',
);
const res = await fetch(new URL('/contactUs', env.apiOrigin), {
method: 'POST',
headers: { Authorization: `Bearer ${env.users.user.token}` },
body,
});
expect(res.status).toBe(200);
const rows = (await env.server.clients.db.read(
'SELECT `attachments` FROM `feedback` WHERE `message` = ?',
[message],
)) as Array<{ attachments: string | null }>;
expect(JSON.parse(rows[0]!.attachments!)).toEqual([
{ name: 'shot.png', type: 'image/png', size: 4096 },
]);
});
it('refuses an unauthenticated upload', async () => {
const body = new FormData();
body.append('message', 'hi');
const res = await fetch(new URL('/contactUs', env.apiOrigin), {
method: 'POST',
body,
});
expect(res.status).toBe(401);
});
it('answers an oversized Content-Length before the body is sent', async () => {
const res = await rawPost(
{ 'Content-Length': String(64 * 1024 * 1024) },
(req) => req.flushHeaders(),
);
expect(res).toEqual({ status: 413, connection: 'close' });
});
it('answers a chunked upload that breaks the per-file cap with 413', async () => {
const file = png(12 * 1024 * 1024);
const res = await rawPost({ 'Transfer-Encoding': 'chunked' }, (req) => {
req.write(multipartHead);
req.end(file);
});
expect(res.status).toBe(413);
});
});
@@ -732,17 +732,12 @@ describe('team endpoints over HTTP', () => {
return { username, password, userId: row.id, token };
};
/**
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
* Sent with the seat's own user agent: the session touch otherwise rewrites
* it to Node's once a second boundary passes.
*/
/** Cookie-credentialed on the GUI origin, as the user-protected gate insists. */
const changePassword = (token: string, password: string, next: string) =>
fetch(new URL('/user-protected/change-password', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
'user-agent': 'puter-test-seat',
authorization: `Bearer ${token}`,
cookie: `puter_auth_token=${token}`,
},
@@ -67,7 +67,6 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
is_owner: isOwner,
created_at: team.created_at,
directory_enabled: Number(team.directory_enabled) === 1,
require_2fa: Number(team.require_2fa) === 1,
});
@Controller('/teams')
@@ -154,7 +153,6 @@ export class TeamController extends PuterController {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
require2fa?: boolean;
} = {};
if (body.name !== undefined)
changes.name = this.#requireString(body.name, 'name');
@@ -162,8 +160,6 @@ export class TeamController extends PuterController {
changes.handle = body.handle === null ? null : String(body.handle);
if (body.directory_enabled !== undefined)
changes.directoryEnabled = body.directory_enabled === true;
if (body.require_2fa !== undefined)
changes.require2fa = body.require_2fa === true;
// Through the service: turning the directory on writes an audit row.
const team = await this.services.team.updateTeam(
@@ -336,24 +332,6 @@ export class TeamController extends PuterController {
res.json({ temporary_password: temporaryPassword });
}
// Same budget as a password reset: both are a step toward the account.
@Post('/:uid/members/:username/2fa-reset', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async resetMemberTwoFactor(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.resetMemberTwoFactor(uid, userId, target);
res.json({ success: true });
}
@Post('/:uid/members/:username/disable', {
subdomain: 'api',
requireUserActor: true,
@@ -2,7 +2,6 @@
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
import type { Request, RequestHandler, Response } from 'express';
import { Readable, Writable } from 'node:stream';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { hash as bcryptHash } from 'bcrypt';
@@ -10,9 +9,7 @@ import { PuterRouter } from '../../core/http/PuterRouter.js';
import type { RouteDescriptor } from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { WebDAVController } from './WebDAVController.js';
@@ -1200,7 +1197,7 @@ describe('WebDAVController verbs', () => {
* A response double that is also a Writable, so handlers that end with
* `body.pipe(res)` (GET) work without a socket.
*/
const makeStreamRes = ({ abortOnFirstChunk = false } = {}) => {
const makeStreamRes = () => {
const chunks: Buffer[] = [];
const captured = {
statusCode: 200,
@@ -1212,9 +1209,6 @@ describe('WebDAVController verbs', () => {
const sink = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk as Buffer));
// A client that hangs up mid-download surfaces as the
// response being destroyed, not as a write error.
if (abortOnFirstChunk) sink.destroy();
callback();
},
});
@@ -1340,87 +1334,6 @@ describe('WebDAVController verbs', () => {
expect(captured.text()).toBe('2345');
});
/**
* Swap the object store's answer for one GET so the body stream is
* under the test's control. Intercepts at the S3 SDK client, the real
* boundary; every other command still hits the in-memory S3.
*/
const serveBody = (body: Readable) => {
const client = server.clients.s3.get(
server.stores.s3Object.resolveRegion(null),
);
const original = client.send.bind(client);
return vi.spyOn(client, 'send').mockImplementation(((
command: unknown,
) =>
command instanceof GetObjectCommand
? Promise.resolve({
Body: body,
ContentLength: 7,
ContentType: 'text/plain',
})
: original(command as never)) as never);
};
it('tears down the upstream stream when the client disconnects mid-download', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/abort-me.txt`;
await putFile(actor, path, 'streamed');
// Never ends on its own: only a teardown can finish it.
const body = new Readable({ read() {} });
body.push('partial');
const send = serveBody(body);
const { res } = makeStreamRes({ abortOnFirstChunk: true });
try {
await dispatchMiddleware(
makeReq({ method: 'GET', path, actor }),
res,
noop,
);
} finally {
send.mockRestore();
}
expect((res as unknown as Writable).destroyed).toBe(true);
expect(body.destroyed).toBe(true);
});
it('survives the upstream stream failing mid-download', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/reset-me.txt`;
await putFile(actor, path, 'streamed');
// First read hands over a chunk, the next one fails the way a
// dropped storage connection does. Without an error listener on
// the body that event would take the process down.
let reads = 0;
const body = new Readable({
read() {
reads += 1;
if (reads === 1) this.push('partial');
else this.destroy(new Error('upstream reset'));
},
});
const send = serveBody(body);
const { res, captured } = makeStreamRes();
try {
await expect(
dispatchMiddleware(
makeReq({ method: 'GET', path, actor }),
res,
noop,
),
).resolves.toBeUndefined();
} finally {
send.mockRestore();
}
expect(captured.statusCode).toBe(200);
expect((res as unknown as Writable).destroyed).toBe(true);
expect(body.destroyed).toBe(true);
});
it('refuses to GET a directory', async () => {
const { actor, username } = await makeUser();
const captured = await dispatch({
@@ -1548,98 +1461,6 @@ describe('WebDAVController verbs', () => {
);
});
describe('root collection per credential', () => {
// Own address, so the per-IP auth failure bucket the rest of the
// file draws on can't refuse these.
const propfindRoot = (authorization: string) =>
dispatch({
method: 'PROPFIND',
path: '/',
headers: { authorization },
ip: '203.0.113.53',
});
const mintToken = async (userId: number, permission: string) => {
const user = (await server.stores.user.getById(userId))!;
return server.services.auth.createAccessToken(
makeActor({ user }),
[[permission]],
);
};
it('lists the home for a password login and a full-access token', async () => {
const username = `webdav-root-${Math.random()
.toString(36)
.slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: await bcryptHash('correct-horse', 4),
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const fullAccess = await mintToken(created.id, FULL_API_ACCESS);
for (const authorization of [
basicAuth(username, 'correct-horse'),
basicAuth('-token', fullAccess),
]) {
const captured = await propfindRoot(authorization);
expect(captured.statusCode).toBe(207);
expect(captured.body as string).toContain(
`<D:href>/${username}/</D:href>`,
);
}
});
it('leaves the home out for a token scoped below it', async () => {
const { userId, username } = await makeUser();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${username}`,
))!;
const documents = (await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
))!;
const scoped = await mintToken(
userId,
`fs:${documents.uuid}:read`,
);
const captured = await propfindRoot(
basicAuth('-token', scoped),
);
expect(captured.statusCode).toBe(207);
const xml = captured.body as string;
expect(xml).toContain('<D:href>/</D:href>');
expect(xml).not.toContain(`/${username}/`);
expect(xml).not.toContain(home.uuid);
});
it('lists the home for a token that can read it', async () => {
const { userId, username } = await makeUser();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${username}`,
))!;
const scoped = await mintToken(userId, `fs:${home.uuid}:read`);
const captured = await propfindRoot(
basicAuth('-token', scoped),
);
expect(captured.statusCode).toBe(207);
expect(captured.body as string).toContain(
`<D:href>/${username}/</D:href>`,
);
});
});
it('describes a single file when asked for one', async () => {
const { actor, username } = await makeUser();
const path = `/${username}/Documents/single.md`;
@@ -20,13 +20,11 @@
import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { EventMap } from '../../clients/event/types.js';
import { makeActor, type Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
assertNotSuspended,
assertTeam2fa,
assertVerifiedAccount,
} from '../../core/http/middleware/gates.js';
import {
@@ -48,7 +46,6 @@ import {
import type { RouteOptions } from '../../core/http/types.js';
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
import { expandTildePath } from '../../services/fs/resolveNode.js';
import { rootShowsHome } from '../../services/fs/rootListing.js';
import {
maskEntryPath,
parseMaskedSharePath,
@@ -286,7 +283,6 @@ export class WebDAVController extends PuterController {
// filesystem over the `dav` subdomain, bypassing the gates.
assertNotSuspended(actor.user);
assertVerifiedAccount(actor.user);
await assertTeam2fa(actor.user, this.stores.team);
// The actor was absent when the auth probe ran, so anything that reads
// it off the request or the context — shared-path masking, egress
@@ -555,12 +551,7 @@ export class WebDAVController extends PuterController {
'Content-Length': String(result.contentLength ?? 0),
});
}
try {
await pipeline(result.body, res);
} catch {
// Client disconnect or upstream stream error: pipeline already
// tore down both ends, so nothing is left dangling to crash on.
}
result.body.pipe(res);
}
// -- PROPFIND ----------------------------------------------------
@@ -625,8 +616,7 @@ export class WebDAVController extends PuterController {
/**
* The root collection: the caller's own home directory, plus one collection
* per person who has shared something with them. A scoped access token sees
* the home only when it can read it.
* per person who has shared something with them.
*
* A share is addressed `/<owner>/<uuid>/<name>`, where the uuid stands in
* for the folder the owner keeps it in. Only the `<owner>` segment is a
@@ -641,16 +631,7 @@ export class WebDAVController extends PuterController {
const home = await this.stores.fsEntry.getEntryByPath(
`/${actor.user!.username}`,
);
if (
home &&
(await rootShowsHome(
actor,
home,
'read',
this.services.acl,
this.stores.permission,
))
) {
if (home) {
responses.push(
propfindEntry(maskEntryPath(home), home, home.isDir),
);
@@ -1185,9 +1166,7 @@ export class WebDAVController extends PuterController {
void Promise.resolve()
.then(async () => {
const response = {
...(await toLegacyEntry(this.clients.event, entry, {
forOwner: true,
})),
...(await toLegacyEntry(this.clients.event, entry)),
...extra,
from_new_service: true,
};
-9
View File
@@ -75,15 +75,6 @@ describe('makeActor / effectiveApp', () => {
expect(userRelatedActor(app).effectiveApp).toBeNull();
});
it('keeps the handler depth when narrowing to the underlying user', () => {
const app = makeActor({ user, app: { uid: 'app-1' } });
app.handlerDepth = 3;
expect(userRelatedActor(app).handlerDepth).toBe(3);
expect(
userRelatedActor(makeActor({ user, app: { uid: 'app-1' } })),
).not.toHaveProperty('handlerDepth');
});
it('resolves the system actor', () => {
expect(SYSTEM_ACTOR.effectiveApp).toBeNull();
});
+1 -20
View File
@@ -64,17 +64,6 @@ export interface Actor {
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
*/
session?: { uid: string; kind?: string | null } | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
* derived actors keep it.
*/
handlerDepth?: number;
/**
* When the token behind `handlerDepth` expires, in unix seconds. An app
* token minted from this actor expires no later.
*/
handlerExpiresAt?: number;
}
/** UUID of the baked-in system user (see 0025 seed migration). */
@@ -166,13 +155,5 @@ export const actorUid = (actor: Actor): string => {
*/
export const userRelatedActor = (actor: Actor): Actor => {
if (!actor.app && !actor.accessToken) return actor;
return {
user: actor.user,
effectiveApp: null,
...(actor.handlerDepth ? { handlerDepth: actor.handlerDepth } : {}),
};
return { user: actor.user, effectiveApp: null };
};
/** How many events handler runs led to a write by this actor. */
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
actor?.handlerDepth ?? 0;
@@ -33,7 +33,6 @@ import {
requireAuthGate,
requireCardVerifiedGate,
requirePhoneVerifiedGate,
requireTeam2fa,
requireVerifiedAccount,
requireNonAccessTokenGate,
requireUserActorGate,
@@ -763,46 +762,6 @@ describe('requireVerifiedAccount', () => {
expectHttpError(got, 403, 'card_verification_required');
});
it('returns 403 two_factor_required for a seat whose team requires 2FA', async () => {
const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) };
const got = await runGateAsync(requireTeam2fa(teams), {
actor: { user: { uuid: 'u-1', id: 7, otp_enabled: 0 } },
});
expectHttpError(got, 403, 'two_factor_required');
});
it('lets the same seat through once it holds 2FA', async () => {
const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) };
const got = await runGateAsync(requireTeam2fa(teams), {
actor: { user: { uuid: 'u-1', id: 7, otp_enabled: 1 } },
});
expect(got).toBeUndefined();
});
it('does not gate an account that is not a seat', async () => {
// No seat row: an ordinary account, whatever any team requires.
const teams = { getOrgSeat: async () => null };
const got = await runGateAsync(requireTeam2fa(teams), {
actor: { user: { uuid: 'u-1', id: 7, otp_enabled: 0 } },
});
expect(got).toBeUndefined();
});
it('does not gate a seat whose team does not require 2FA', async () => {
const teams = { getOrgSeat: async () => ({ require_2fa: 0 }) };
const got = await runGateAsync(requireTeam2fa(teams), {
actor: { user: { uuid: 'u-1', id: 7, otp_enabled: 0 } },
});
expect(got).toBeUndefined();
});
it('stays inert where Teams is not deployed', async () => {
const got = await runGateAsync(requireTeam2fa(undefined), {
actor: { user: { uuid: 'u-1', id: 7, otp_enabled: 0 } },
});
expect(got).toBeUndefined();
});
it('returns 403 password_change_required while the account owes a password', () => {
const got = runGate(requireVerifiedAccount(), {
actor: {
-36
View File
@@ -258,42 +258,6 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
};
};
/** Just the seat read the 2FA gate needs, so gates stay store-agnostic. */
export interface Team2faLookup {
getOrgSeat(
userId: number,
): Promise<{ require_2fa?: number | null } | null | undefined>;
}
/**
* Reject a team-provisioned account whose team requires 2FA until it has some.
* Derived, not stamped: a flag left behind by a member leaving would lock them
* out of their own account with no team left to clear it.
*/
export const assertTeam2fa = async (
user: AccountGateUser | undefined,
teams: Team2faLookup | undefined,
): Promise<void> => {
if (!user || user.otp_enabled) return;
if (typeof user.id !== 'number' || !teams?.getOrgSeat) return;
const seat = await teams.getOrgSeat(user.id);
if (!seat || Number(seat.require_2fa) !== 1) return;
throw new HttpError(
403,
'Your team requires two-factor authentication. Set it up to continue.',
{ legacyCode: 'two_factor_required' as never },
);
};
/** {@link assertTeam2fa} as route middleware. */
export const requireTeam2fa = (
teams: Team2faLookup | undefined,
): RequestHandler => {
return (req, _res, next) => {
assertTeam2fa(req.actor?.user, teams).then(() => next(), next);
};
};
/**
* Reject accounts still pending a signup-time verification (email, phone, card,
* password change). Default-on for authenticated routes; the flows that clear
@@ -32,10 +32,8 @@ import {
buildPublicHostRedirect,
getBootstrapToken,
hostMatchesPrivateDomain,
isTopLevelNavigation,
normalizeHost,
normalizeHostRaw,
pathWithoutQueryParam,
renderLoginBootstrapHtml,
resolveOwnedAppForHostedSite,
resolvePrivateIdentity,
@@ -381,61 +379,6 @@ describe('buildPrivateHostRedirect', () => {
});
});
describe('isTopLevelNavigation', () => {
const reqOf = (method: string, dest?: string): Request =>
({
method,
headers: dest ? { 'sec-fetch-dest': dest } : {},
}) as unknown as Request;
it('is true only for a GET of a top-level page', () => {
expect(isTopLevelNavigation(reqOf('GET', 'document'))).toBe(true);
expect(isTopLevelNavigation(reqOf('GET', 'iframe'))).toBe(false);
expect(isTopLevelNavigation(reqOf('GET', 'script'))).toBe(false);
expect(isTopLevelNavigation(reqOf('POST', 'document'))).toBe(false);
expect(isTopLevelNavigation(reqOf('GET'))).toBe(false);
});
});
describe('pathWithoutQueryParam', () => {
const T = 'puter.auth.token';
it('removes the parameter and keeps the rest exactly as sent', () => {
expect(
pathWithoutQueryParam('/p/a.html?x=1&puter.auth.token=abc&y=a%20b+c', T),
).toBe('/p/a.html?x=1&y=a%20b+c');
});
it('drops the query when nothing else is left', () => {
expect(pathWithoutQueryParam('/?puter.auth.token=abc', T)).toBe('/');
});
it('removes every occurrence, including a percent-encoded key', () => {
expect(
pathWithoutQueryParam('/?puter.auth.token=a&k=v&puter%2Eauth%2Etoken=b', T),
).toBe('/?k=v');
});
it('returns null when the parameter is absent', () => {
expect(pathWithoutQueryParam('/?auth_token=abc', T)).toBeNull();
expect(pathWithoutQueryParam('/', T)).toBeNull();
expect(pathWithoutQueryParam(undefined, T)).toBeNull();
});
it('never produces a redirect off the current host', () => {
expect(pathWithoutQueryParam('//evil.example/?puter.auth.token=a', T)).toBe(
'/evil.example/',
);
expect(pathWithoutQueryParam('/\\evil.example/?puter.auth.token=a', T)).toBe(
'/evil.example/',
);
// URL parsing drops tabs, which would turn this into `//evil.example`.
expect(
pathWithoutQueryParam('/\t/evil.example/?puter.auth.token=a', T),
).toBeNull();
});
});
describe('buildPublicHostRedirect', () => {
// Mirror of buildPrivateHostRedirect — swaps the private hosting
// domain for the public one. Used when a non-private app (or no app
@@ -596,43 +596,6 @@ function normalizeRedirectPath(originalUrl: string | undefined): string {
return '/' + (originalUrl || '/').replace(/^[/\\]+/, '');
}
/** A GET for a top-level page, as opposed to a frame, subresource or fetch. */
export function isTopLevelNavigation(req: Request): boolean {
return (
req.method === 'GET' && req.headers?.['sec-fetch-dest'] === 'document'
);
}
/**
* Same-host path of this request with every `name` query parameter removed, or
* null when it has none. The parameters that stay keep their exact encoding.
*/
export function pathWithoutQueryParam(
originalUrl: string | undefined,
name: string,
): string | null {
const base = 'http://gate.invalid';
let url: URL;
try {
url = new URL(normalizeRedirectPath(originalUrl), base);
} catch {
return null;
}
if (url.origin !== base) return null;
const isTarget = (part: string) => {
const rawKey = part.split('=', 1)[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(rawKey) === name;
} catch {
return rawKey === name;
}
};
const parts = url.search.slice(1).split('&');
if (!parts.some(isTarget)) return null;
const kept = parts.filter((part) => part && !isTarget(part));
return url.pathname + (kept.length ? `?${kept.join('&')}` : '');
}
/** Build the URL to redirect a private-app request to its private host. */
export function buildPrivateHostRedirect(
req: Request,
@@ -46,11 +46,10 @@ interface CapturedRes {
contentType?: string;
redirected?: { status?: number; url: string };
headers: Record<string, string>;
cookies: Record<string, string>;
}
const makeRes = () => {
const out: CapturedRes = { headers: {}, cookies: {} };
const out: CapturedRes = { headers: {} };
// The file-serving branch ends with `download.body.pipe(res)`, so
// `res` has to satisfy the WritableStream contract Node's `pipe()`
// expects — write/end/on/emit/etc. Use a real Writable so Node's
@@ -74,7 +73,7 @@ const makeRes = () => {
type: (ct: string) => Response;
send: (payload: unknown) => Response;
redirect: (...args: unknown[]) => Response;
cookie: (name: string, value: string) => Response;
cookie: () => Response;
set: (name: string, value: string) => Response;
setHeader: (name: string, value: string) => Response;
};
@@ -101,10 +100,7 @@ const makeRes = () => {
}
return res;
};
res.cookie = (name: string, value: string) => {
out.cookies[name] = value;
return res;
};
res.cookie = () => res;
res.set = (name: string, value: string) => {
out.headers[name] = value;
return res;
@@ -118,16 +114,13 @@ const makeRes = () => {
const makeReq = (init: {
hostname: string;
method?: string;
path?: string;
originalUrl?: string;
protocol?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
query?: Record<string, string>;
}): Request =>
({
method: init.method ?? 'GET',
hostname: init.hostname,
path: init.path ?? '/',
// Redirect helpers use originalUrl (preserves query string).
@@ -136,7 +129,7 @@ const makeReq = (init: {
protocol: init.protocol ?? 'http',
headers: init.headers ?? {},
cookies: init.cookies ?? {},
query: init.query ?? {},
query: {},
// The file-serve branch wires `req.on('close', ...)` to destroy
// the stream on client disconnect — a no-op event surface is
// enough for the offline tests.
@@ -542,34 +535,6 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(piped!.equals(body)).toBe(true);
});
// Metering resolves the billed account's plan from this actor, and some
// plans are keyed on the account's email.
it('bills an anonymous visit to the owner, email included', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `bill-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(owner.id, `${homePath}/index.html`, Buffer.from('hi'));
const req = makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/index.html',
});
const { res } = makeRes();
await buildMiddleware()(req, res, vi.fn());
expect(req.egressActor?.user).toMatchObject({
uuid: owner.uuid,
id: owner.id,
email: owner.email,
});
});
it('emits site.htmlServed with the original URL target including query string', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
@@ -1692,131 +1657,3 @@ describe('createPuterSiteMiddleware — delegated site roots', () => {
}
});
});
// ── Private app sign-in token in a top-level URL ────────────────────
describe('createPuterSiteMiddleware — private app sign-in token', () => {
let owner: Awaited<ReturnType<typeof makeUserWithHome>>;
let visitor: Awaited<ReturnType<typeof makeUser>>;
let sub: string;
let host: string;
let appUid: string;
let token: string;
const allow = (
_key: string,
data: { appUid: string; result: { allowed: boolean } },
) => {
if (data.appUid === appUid) data.result.allowed = true;
};
beforeAll(async () => {
owner = await makeUserWithHome();
visitor = await makeUser();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
sub = `priv-${Math.random().toString(36).slice(2, 8)}`;
host = `${sub}.app.puter.localhost`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('<html>app</html>'),
'text/html',
);
appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)',
[appUid, sub, sub, `http://${host}/`, owner.id, 1],
);
({ token } = await server.services.auth.createSessionToken(
visitor as never,
));
server.clients.event.on('app.privateAccess.check', allow as never);
});
afterAll(() => {
server.clients.event.off('app.privateAccess.check', allow as never);
});
const visit = async (init: {
dest?: string;
method?: string;
cookies?: Record<string, string>;
}) => {
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: host,
method: init.method,
path: '/',
originalUrl: `/?a=1&puter.auth.token=${token}&b=x%20y`,
query: { a: '1', 'puter.auth.token': token, b: 'x y' },
headers: init.dest ? { 'sec-fetch-dest': init.dest } : {},
cookies: init.cookies,
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
const cookieName = () => server.services.auth.getPrivateAssetCookieNameV2();
it('sets the cookie and redirects a top-level page to the same URL without the token', async () => {
const out = await visit({ dest: 'document' });
expect(out.redirected).toEqual({ status: 302, url: '/?a=1&b=x%20y' });
expect(out.cookies[cookieName()]).toBeTruthy();
expect(out.headers['Cache-Control']).toBe('no-store');
});
it('serves a framed launch with the token left in place for the SDK', async () => {
const out = await visit({ dest: 'iframe' });
expect(out.redirected).toBeUndefined();
expect(out.statusCode).toBe(200);
});
it('serves as before when the browser does not say what the request is for', async () => {
const out = await visit({});
expect(out.redirected).toBeUndefined();
expect(out.statusCode).toBe(200);
});
it('does not redirect a non-GET navigation', async () => {
const out = await visit({ dest: 'document', method: 'POST' });
expect(out.redirected).toBeUndefined();
});
it('still strips the token when the visitor already holds the cookie', async () => {
const cookie = await server.services.auth.createPrivateAssetToken({
appUid,
userUid: visitor.uuid,
subdomain: sub,
privateHost: host,
});
const out = await visit({
dest: 'document',
cookies: { [cookieName()]: cookie },
});
expect(out.redirected).toEqual({ status: 302, url: '/?a=1&b=x%20y' });
});
it('serves without redirecting when the cookie could not be minted', async () => {
// Without the cookie, the clean URL would bounce back to sign-in.
const spy = vi
.spyOn(server.services.auth, 'createPrivateAssetToken')
.mockRejectedValueOnce(new Error('mint failed'));
try {
const out = await visit({ dest: 'document' });
expect(out.redirected).toBeUndefined();
expect(out.statusCode).toBe(200);
} finally {
spy.mockRestore();
}
});
});
+1 -21
View File
@@ -32,8 +32,6 @@ import {
buildPrivateHostRedirect,
buildPublicHostRedirect,
hostMatchesPrivateDomain,
isTopLevelNavigation,
pathWithoutQueryParam,
renderLoginBootstrapHtml,
resolveOwnedAppForHostedSite,
resolvePrivateIdentity,
@@ -148,7 +146,6 @@ interface UserRow {
id: number;
uuid: string;
username: string;
email?: string | null;
suspended?: number | null;
}
@@ -372,8 +369,7 @@ export const createPuterSiteMiddleware = (
// Mint the sticky cookie only when we don't already have a
// valid one — keeps Set-Cookie off of the hot path for repeat
// visitors but still refreshes after rotation/expiry.
let hasStickyCookie = !!identity.hasValidPrivateCookie;
if (!hasStickyCookie) {
if (!identity.hasValidPrivateCookie) {
try {
const token =
await layers.services.auth.createPrivateAssetToken({
@@ -390,7 +386,6 @@ export const createPuterSiteMiddleware = (
requestHostname: host,
}),
);
hasStickyCookie = true;
} catch (e) {
console.warn(
'[puter-site] failed to mint private asset cookie',
@@ -402,20 +397,6 @@ export const createPuterSiteMiddleware = (
// Referrer-policy hardening — don't leak private-host URLs to
// third-party resources loaded from the app.
res.setHeader('Referrer-Policy', 'no-referrer');
// A tab opened straight on the app carries the sign-in token in
// its URL. Once the cookie stands in for it, reload without it so
// it doesn't stay in the address bar. Framed launches keep it:
// the SDK inside reads it from there.
const cleanPath =
hasStickyCookie && isTopLevelNavigation(req)
? pathWithoutQueryParam(req.originalUrl, 'puter.auth.token')
: null;
if (cleanPath) {
res.set('Cache-Control', 'no-store');
res.redirect(302, cleanPath);
return;
}
} else if (privateHostingDomains.has(matched)) {
// Non-private content landed on the private hosting domain —
// mirror of the private redirect above. Covers two cases:
@@ -764,7 +745,6 @@ export const createPuterSiteMiddleware = (
uuid: owner.uuid,
id: owner.id,
username: owner.username,
email: owner.email ?? null,
suspended: !!owner.suspended,
},
};
+14 -25
View File
@@ -21,7 +21,10 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import { subscriptionOverride } from '../../../services/metering/consts.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
FREE_SUBSCRIPTION_IDS,
} from '../../../services/metering/consts.js';
/**
* Sliding-window rate limiter with swappable, **co-resident** backends.
@@ -744,29 +747,6 @@ export async function consumeRouteRateLimit(req, spec) {
}
}
/**
* `consumeRouteRateLimit` without the spend: same spec, same bucket. Pair the
* two when only a successful outcome should count — peek before the work,
* consume after it succeeds. Single-window specs only. Fails open.
*/
export async function peekRouteRateLimit(req, spec) {
const {
window: windowMs,
key: strategy = 'fingerprint',
scope,
backend,
} = spec;
const backendPair = resolveBackend(backend);
const key = resolveKey(req, scope ?? req.route?.path ?? 'route', strategy);
try {
const limit = await resolveSubscriptionLimit(req, spec);
return await backendPair.peek(key, limit, windowMs);
} catch (err) {
console.error('[rate-limit] handler peek failed, failing open:', err);
return true;
}
}
/**
* Read whether `key` still has budget, without spending any. The twin to
* `checkRateLimit` for gates whose budget is consumed by an outcome rather than
@@ -866,6 +846,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS;
* actor, no metering, metering throws) falls through to the base — rate /
* concurrency limiting should never _amplify_ a request failure path.
*/
// An unlisted free plan would otherwise take `limit`, the paid cap.
function overrideFor(bySubscription, subscriptionId) {
const own = bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? bySubscription[DEFAULT_FREE_SUBSCRIPTION]
: undefined;
}
async function resolveSubscriptionLimit(req, opts) {
const base = opts.limit;
if (!opts.bySubscription || !meteringService) return base;
@@ -873,7 +862,7 @@ async function resolveSubscriptionLimit(req, opts) {
if (!actor?.user?.uuid) return base;
try {
const sub = await meteringService.getActorSubscription(actor);
const override = subscriptionOverride(opts.bySubscription, sub.id);
const override = overrideFor(opts.bySubscription, sub.id);
return typeof override === 'number' ? override : base;
} catch {
return base;
+3 -11
View File
@@ -27,12 +27,7 @@ import type { Actor } from '../actor';
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
/** Express router methods plus the WebDAV verbs some endpoints use. */
export type RouteMethod =
@@ -74,9 +69,8 @@ export type AccountGateUser = Partial<
| 'requires_password_change'
| 'phone'
| 'card_fingerprint'
| 'otp_enabled'
>
> & { id?: number };
>;
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
export interface RouteRateLimit {
@@ -341,9 +335,7 @@ export type AuthRequired<O extends RouteOptions> = O extends {
? true
: O extends {
requireSubscription:
| true
| readonly string[]
| string[];
true | readonly string[] | string[];
}
? true
: O extends { requireReputation: string }
@@ -167,25 +167,6 @@ const errorFor = async (
// -- Provider registration -------------------------------------------
describe('ChatCompletionDriver provider registration', () => {
it('routes test mode to fake-chat without calling the selected upstream provider', async () => {
const upstream = vi.spyOn(AzureChatProvider.prototype, 'complete');
const fake = vi.spyOn(FakeChatProvider.prototype, 'complete');
const result = await withTestActor(() =>
fullDriver.complete({
model: 'gpt-4o',
provider: 'azure-openai',
messages: [{ role: 'user', content: 'hi' }],
test_mode: true,
}),
);
expect(upstream).not.toHaveBeenCalled();
expect(fake).toHaveBeenCalledOnce();
expect(fake.mock.calls[0]![0].model).toBe('fake');
expect('message' in result && result.message.model).toBe('fake');
});
it('registers a model surface spanning every credentialed provider', async () => {
const models = await fullDriver.models();
const providers = new Set(models.map((m) => m.provider));
@@ -273,25 +254,6 @@ describe('ChatCompletionDriver provider registration', () => {
});
});
// -- Input validation ------------------------------------------------
describe('ChatCompletionDriver malformed input', () => {
it.each([
['missing messages', { messages: undefined }],
[
'a null content item',
{ messages: [{ role: 'user', content: [null] }] },
],
['a null tool entry', { tools: [null] }],
])('rejects %s with 400 before any provider runs', async (_label, args) => {
const fake = vi.spyOn(FakeChatProvider.prototype, 'complete');
await expect(completeFake(args)).rejects.toMatchObject({
statusCode: 400,
});
expect(fake).not.toHaveBeenCalled();
});
});
// -- Failure classification ------------------------------------------
describe('ChatCompletionDriver exhausted-chain classification', () => {
@@ -623,9 +585,9 @@ describe('ChatCompletionDriver cross-provider fallback', () => {
Object.assign(new Error('azure down'), { status: 503 }),
);
const openai = vi.spyOn(OpenAiChatProvider.prototype, 'complete');
vi.spyOn(server.services.metering, 'getUsageHeadroom')
.mockResolvedValueOnce({ balance: 1_000_000, held: 0 }) // pre-flight
.mockResolvedValue({ balance: 0, held: 0 }); // drained by a parallel request
vi.spyOn(server.services.metering, 'getRemainingUsage')
.mockResolvedValueOnce(1_000_000) // pre-flight
.mockResolvedValue(0); // drained by a parallel request
await expect(completeShared()).rejects.toMatchObject({
statusCode: 402,
@@ -38,7 +38,6 @@ import {
vi,
} from 'vitest';
import { Context } from '../../core/context.js';
import type { UsageInput } from '../../services/metering/types.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -46,7 +45,7 @@ import { withTestActor } from '../integrationTestUtil.js';
import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import type { IChatCompleteResult } from './types.js';
import { type AIChatStream, ChatStreamAbortedError } from './utils/Streaming.js';
import type { AIChatStream } from './utils/Streaming.js';
let server: PuterServer;
@@ -366,8 +365,6 @@ describe('ChatCompletionDriver credit gate against real metering', () => {
// Let the first request clear the gate and take its hold.
await vi.waitFor(() => expect(completeSpy).toHaveBeenCalledTimes(1));
// Nothing is spent yet, only held: a retry once the first finishes is
// the remedy, not a top-up.
await expect(
withTestActor(
() =>
@@ -378,9 +375,8 @@ describe('ChatCompletionDriver credit gate against real metering', () => {
actor,
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
code: 'credits_reserved',
statusCode: 402,
legacyCode: 'insufficient_funds',
});
// The second request never reached a provider.
expect(completeSpy).toHaveBeenCalledTimes(1);
@@ -438,119 +434,6 @@ describe('ChatCompletionDriver credit gate against real metering', () => {
});
});
describe('ChatCompletionDriver credit gate with an AI cost factor', () => {
const FACTOR = 1.3;
let seenKeys: string[];
const listener = (key: string, event: { factor: number }) => {
seenKeys.push(key);
event.factor = FACTOR;
};
beforeEach(() => {
seenKeys = [];
server.clients.event.on('ai.cost.factor.*', listener as never);
});
afterEach(() => {
server.clients.event.off('ai.cost.factor.*', listener as never);
});
const okResult = {
message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
} as never;
// Usage is recorded at cost × factor, so a cap sized at the bare cost
// lets a completion spend past what's left.
it('caps output at the factored price', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const allowance = (await metering.getActorSubscription(actor))
.monthUsageAllowance;
await metering.incrementUsage(
actor,
'test:prior-spend',
1,
Math.floor(allowance * 0.9),
);
const remaining = await metering.getRemainingUsage(actor);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
.mockResolvedValueOnce(okResult);
await withTestActor(
() =>
driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
max_tokens: 100_000,
}),
actor,
);
const passed = completeSpy.mock.calls[0]![0] as { max_tokens: number };
expect(passed.max_tokens * 2000 * FACTOR).toBeLessThanOrEqual(
remaining,
);
expect(passed.max_tokens).toBeGreaterThan(
remaining / (2000 * FACTOR) - 10,
);
});
it('reserves the factored worst case', async () => {
const actor = freeUser() as never;
const reserve = vi.spyOn(server.services.metering, 'reserveCredits');
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
.mockResolvedValueOnce(okResult);
await withTestActor(
() =>
driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
max_tokens: 10,
}),
actor,
);
const passed = completeSpy.mock.calls[0]![0] as { max_tokens: number };
expect(passed.max_tokens).toBe(10);
const [, held] = reserve.mock.calls[0]!;
// At least the 10 capped output tokens at 2000 × factor.
expect(held).toBeGreaterThanOrEqual(10 * 2000 * FACTOR);
});
// The factor is looked up by the key the provider records under, or a
// per-model factor would price the gate and the charge differently.
it('asks for the factor under the metering key the provider records with', async () => {
const proto = FakeChatProvider.prototype as unknown as {
meteringModelKey?: (id: string) => string;
};
proto.meteringModelKey = (id) => `fake-recorded:${id}`;
try {
vi.spyOn(
FakeChatProvider.prototype,
'complete',
).mockResolvedValueOnce(okResult);
await withTestActor(
() =>
driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
}),
freeUser() as never,
);
expect(seenKeys).toContain(
'ai.cost.factor.ai-chat.fake-recorded:priced',
);
} finally {
delete proto.meteringModelKey;
}
});
});
describe('ChatCompletionDriver credit gate on multimodal prompts', () => {
it('prices attachments into the pre-flight affordability check', async () => {
const actor = freeUser() as never;
@@ -623,244 +506,3 @@ describe('ChatCompletionDriver credit gate on multimodal prompts', () => {
expect(completeSpy).toHaveBeenCalledTimes(1);
});
});
// `max_tokens` is a model's output limit and `context` its whole window; the
// cap is the lesser of the limit and what the window leaves after the prompt.
describe('ChatCompletionDriver output ceiling', () => {
// ~15k estimated tokens: past an 8192-token output limit.
const longPrompt = [{ role: 'user', content: 'x '.repeat(20_000) }];
const withModel = async (model: Record<string, unknown>) => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue([
{ ...PRICED_MODEL, ...model },
] as never);
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: Number.MAX_SAFE_INTEGER, held: 0 });
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
.mockResolvedValue({
message: { role: 'assistant', content: 'ok' },
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
} as never);
const d = await makeDriver();
await withTestActor(() =>
d.complete({ model: 'priced', messages: longPrompt }),
);
return (completeSpy.mock.calls[0]![0] as { max_tokens: number })
.max_tokens;
};
it('serves a prompt longer than the output limit when the window fits it', async () => {
expect(await withModel({ max_tokens: 8192, context: 200_000 })).toBe(
8192,
);
});
it('caps output at what the window leaves after the prompt', async () => {
const maxTokens = await withModel({
max_tokens: 32_000,
context: 32_000,
});
expect(maxTokens).toBeGreaterThan(0);
expect(maxTokens).toBeLessThan(32_000 - 10_000);
});
it('leaves a prompt estimated past the window for the provider to refuse', async () => {
expect(await withModel({ max_tokens: 8192, context: 10_000 })).toBe(
8192,
);
});
});
describe('ChatCompletionDriver when the balance runs out', () => {
const spentToATenth = async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const allowance = (await metering.getActorSubscription(actor))
.monthUsageAllowance;
await metering.incrementUsage(
actor,
'test:prior-spend',
1,
Math.floor(allowance * 0.9),
);
return actor;
};
/** A non-stream completion that reports `output(cap)` output tokens. */
const completeWithOutput = async (
actor: never,
output: (cap: number) => number,
) => {
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementationOnce(
async (args) =>
({
message: { role: 'assistant', content: 'cut' },
usage: {
input_tokens: 1,
output_tokens: output(
(args as { max_tokens: number }).max_tokens,
),
},
finish_reason: 'length',
}) as never,
);
return withTestActor(async () => {
await driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
});
return Context.get('driverMetadata') as Record<string, unknown>;
}, actor);
};
it('flags a completion that used all of a balance-bound cap', async () => {
const metadata = await completeWithOutput(
await spentToATenth(),
(cap) => cap,
);
expect(metadata.usage_limited).toBe(true);
});
it('leaves a completion that stopped short of the cap alone', async () => {
const metadata = await completeWithOutput(
await spentToATenth(),
(cap) => cap - 1,
);
expect(metadata.usage_limited).toBeUndefined();
});
it('does not flag a cap that only the account’s own holds shrank', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const allowance = (await metering.getActorSubscription(actor))
.monthUsageAllowance;
// The whole 8192-token limit is affordable from the balance...
expect(allowance - 1000).toBeGreaterThan(8192 * 2000);
// ...but a request in flight holds most of it.
const hold = await metering.reserveCredits(
actor,
allowance - 8192 * 1000,
);
try {
const metadata = await completeWithOutput(actor, (cap) => cap);
expect(metadata.usage_limited).toBeUndefined();
} finally {
await hold.release();
}
});
it('marks the usage line of a stream that ran the balance out', async () => {
const actor = await spentToATenth();
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementationOnce(
async (args) =>
streamOf(async ({ chatStream }) => {
chatStream
.message()
.contentBlock({ type: 'text' })
.addText('cut');
chatStream.end({
input_tokens: 1,
output_tokens: (args as { max_tokens: number })
.max_tokens,
});
}) as never,
);
const { stream } = (await withTestActor(
() =>
driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
stream: true,
}),
actor,
)) as unknown as { stream: Readable };
let body = '';
for await (const chunk of stream as AsyncIterable<Buffer>) {
body += chunk.toString();
}
const usageLine = body
.trim()
.split('\n')
.map((l) => JSON.parse(l))
.find((l) => l.type === 'usage');
expect(usageLine.metadata).toEqual({ usage_limited: true });
});
it('refuses with 402, not the retryable 429, when nothing is held', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const allowance = (await metering.getActorSubscription(actor))
.monthUsageAllowance;
await metering.incrementUsage(actor, 'test:prior-spend', 1, allowance);
await expect(
withTestActor(
() =>
driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
}),
actor,
),
).rejects.toMatchObject({
statusCode: 402,
legacyCode: 'insufficient_funds',
});
});
});
describe('ChatCompletionDriver when the caller hangs up', () => {
it('stops the generation and gives the hold back', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const abort = new AbortController();
let resume: () => void = () => {};
const paused = new Promise<void>((r) => {
resume = r;
});
let providerError: unknown;
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
streamOf(async ({ chatStream }) => {
const block = chatStream.message().contentBlock({ type: 'text' });
block.addText('first');
await paused;
try {
// An upstream still generating: keeps writing until told.
for (let i = 0; i < 1000; i++) block.addText('more');
} catch (e) {
providerError = e;
throw e;
}
}) as never,
);
const metered = vi.spyOn(metering, 'batchIncrementUsages');
const { stream } = (await withTestActor(() => {
Context.set('abortSignal', abort.signal);
return driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
stream: true,
});
}, actor)) as unknown as { stream: Readable };
expect(await metering.getOutstandingHolds(actor)).toBeGreaterThan(0);
abort.abort();
resume();
let body = '';
for await (const chunk of stream as AsyncIterable<Buffer>) {
body += chunk.toString();
}
await new Promise((r) => setTimeout(r, 20));
expect(providerError).toBeInstanceOf(ChatStreamAbortedError);
// Nobody is listening, so no error line is written for the abort.
expect(body).not.toContain('"type":"error"');
expect(await metering.getOutstandingHolds(actor)).toBe(0);
// What was generated before the hang-up is still charged.
expect(metered).toHaveBeenCalled();
});
});
@@ -333,7 +333,7 @@ describe('ChatCompletionDriver credit gate across the fallback chain', () => {
// real balance read of its own.
const remaining = vi.spyOn(
server.services.metering,
'getUsageHeadroom',
'getRemainingUsage',
);
const attempts = await attemptsFor('deepseek-v4-pro');
@@ -180,46 +180,6 @@ describe('ChatCompletionDriver model catalog', () => {
// ── Auth + model resolution ─────────────────────────────────────────
describe('ChatCompletionDriver.complete auth and model resolution', () => {
it('uses a free fake completion in test mode without taking a credit hold', async () => {
const creditGate = vi.spyOn(server.services.metering, 'getUsageHeadroom');
const complete = vi.spyOn(FakeChatProvider.prototype, 'complete');
const result = await withTestActor(() =>
driver.complete({
model: 'costly',
messages: [{ role: 'user', content: 'hi' }],
test_mode: true,
}),
);
expect(creditGate).not.toHaveBeenCalled();
expect(complete).toHaveBeenCalledOnce();
expect(complete.mock.calls[0]![0].model).toBe('fake');
expect('message' in result && result.message.model).toBe('fake');
expect('usage' in result && result.usage).toMatchObject({
input_tokens: 0,
output_tokens: 1,
});
});
it('streams a fake completion in test mode', async () => {
const complete = vi.spyOn(FakeChatProvider.prototype, 'complete');
const result = await withTestActor(() =>
driver.complete({
model: 'costly',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
test_mode: true,
}),
);
expect(complete.mock.calls[0]![0].model).toBe('fake');
const streamResult = result as unknown as { dataType: string; stream: Readable };
expect(streamResult.dataType).toBe('stream');
const chunks = await collectStream(streamResult.stream);
expect(chunks).toContainEqual(expect.objectContaining({ type: 'text' }));
});
it('throws 401 when no actor is in context', async () => {
// Note: not wrapped in `withTestActor` — `Context.get('actor')`
// returns undefined.
@@ -503,6 +463,53 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
expect(data.service_used).toBe('fake-chat');
});
it('charges cached prompt tokens only at their cache rate', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'cached-pricing',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
promptTokensIncludeCached: true,
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
completion_tokens: 200,
cached_tokens: 10,
},
max_tokens: 8192,
},
]);
const driver = await makeDriver();
const costEvents = captureEvent('ai.prompt.cost-calculated');
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: {
prompt_tokens: 100,
completion_tokens: 1,
cached_tokens: 40,
},
finish_reason: 'stop',
} as never);
await withTestActor(() =>
driver.complete({
model: 'cached-pricing',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const data = costEvents[0]![1] as {
input_ucents: number;
output_ucents: number;
};
expect(data.input_ucents).toBe(60 * 100 + 40 * 10);
expect(data.output_ucents).toBe(200);
});
it('injects `usd_cents` on the usage object derived from the cost map', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -675,7 +682,9 @@ describe('ChatCompletionDriver.complete validation event routing', () => {
describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
it('throws 402 `insufficient_funds` when the actor has no remaining credits', async () => {
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 0, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
0,
);
await expect(
withTestActor(() =>
@@ -710,7 +719,9 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
// Approx input cost is tiny (very short prompt), so allowed
// output ≈ 100_000 / 2000 = 50 tokens, comfortably below the
// caller's 10_000 ceiling.
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 100_000, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
100_000,
);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
@@ -757,7 +768,9 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
// A ~100-token prompt is past the threshold. 1_000_000 microcents at
// 2000 * 1.5 per output token leaves at most 333 output tokens before
// the prompt is paid for; the standard rate would allow ~450 after it.
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
@@ -800,7 +813,9 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
// Pass the cheap pre-flight check but leave a balance too small to
// afford a single 2000-microcent output token.
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 100, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
100,
);
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
@@ -843,8 +858,8 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
vi.spyOn(
server.services.metering,
'getUsageHeadroom',
).mockResolvedValue({ balance: 100_000, held: 0 });
'getRemainingUsage',
).mockResolvedValue(100_000);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
@@ -886,7 +901,9 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
},
]);
const d = await makeDriver();
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
vi.spyOn(
server.services.metering,
'getActorSubscription',
@@ -905,44 +922,6 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
});
});
it('admits a plan outside the free set to subscriber-only models', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'subonly-paid',
aliases: [],
costs_currency: 'usd-cents',
costs: { 'input-tokens': 100, 'output-tokens': 100 },
max_tokens: 8192,
subscriberOnly: true,
},
]);
const d = await makeDriver();
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
vi.spyOn(
server.services.metering,
'getActorSubscription',
).mockResolvedValue({ id: 'some-paid-tier' } as never);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
.mockResolvedValueOnce({
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
} as never);
await withTestActor(() =>
d.complete({
model: 'subonly-paid',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(completeSpy).toHaveBeenCalledOnce();
});
it('rejects subscriber-only models for the default free subscription', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -956,7 +935,9 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
]);
const d = await makeDriver();
// Plenty of credits so the credit gate doesn't intercept first.
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
await expect(
withTestActor(() =>
@@ -1471,9 +1452,9 @@ describe('ChatCompletionDriver.complete fallback and error envelope', () => {
new Error('boom'),
);
const remaining = vi
.spyOn(server.services.metering, 'getUsageHeadroom')
.mockResolvedValueOnce({ balance: 1_000_000, held: 0 }) // pre-flight
.mockResolvedValueOnce({ balance: 0, held: 0 }); // drained mid-fallback
.spyOn(server.services.metering, 'getRemainingUsage')
.mockResolvedValueOnce(1_000_000) // pre-flight
.mockResolvedValueOnce(0); // drained mid-fallback
// No second provider serves `fake`, so `#findFallback` returns
// null and the loop exits before reaching the credit re-check.
@@ -23,15 +23,12 @@ import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import { isFreeSubscription } from '../../services/metering/consts.js';
import { FREE_SUBSCRIPTION_IDS } from '../../services/metering/consts.js';
import type { CreditHold } from '../../services/metering/types.js';
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import {
type AiMeteringService,
withAiCostFactor,
} from '../util/aiCostFactor.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import {
isCreditExhaustion as isUpstreamCreditExhaustion,
@@ -44,6 +41,7 @@ import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { deepSeekPricing } from './providers/deepseek/pricing.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
@@ -95,7 +93,6 @@ import {
isOutputCostKey,
longContextMultipliers,
trackedInputTokens,
trackedOutputTokens,
} from './utils/pricing.js';
import {
isRouteUnhealthy,
@@ -125,43 +122,6 @@ const HOLD_RENEW_INTERVAL_MS = 5 * 60 * 1000;
const isModerationRefusal = (e: unknown): boolean =>
isHttpError(e) && e.code === 'moderation_flagged';
/**
* The refusal for a request its funds can't cover. When what's missing is only
* held by the account's own requests still in flight, waiting is the remedy,
* not a top-up: a retryable 429 rather than a 402 that prompts an upgrade.
*/
const fundsRefusal = (heldByInFlight: boolean): HttpError =>
heldByInFlight
? new HttpError(
429,
'Usage is reserved by requests still running. Retry when they finish.',
{ legacyCode: 'too_many_requests', code: 'credits_reserved' },
)
: new HttpError(402, 'No usage left for request.', {
legacyCode: 'insufficient_funds',
});
const positiveOrInfinity = (n: unknown): number =>
typeof n === 'number' && Number.isFinite(n) && n > 0
? n
: Number.POSITIVE_INFINITY;
/**
* The most output a request to `model` can get: its output limit, and no more
* than the context window leaves after the prompt. A limit a catalog doesn't
* know bounds nothing. A prompt estimated to fill the window is left for the
* provider to refuse — the estimate is approximate, and running out of credits
* would be the wrong answer either way.
*/
const outputCeiling = (
model: IChatModel,
promptTokenEstimate: number,
): number => {
const outputLimit = positiveOrInfinity(model.max_tokens);
const contextRoom = positiveOrInfinity(model.context) - promptTokenEstimate;
return contextRoom >= 1 ? Math.min(outputLimit, contextRoom) : outputLimit;
};
type ProviderAttempt = {
model: string;
provider: string;
@@ -367,12 +327,8 @@ export class ChatCompletionDriver extends PuterDriver {
#modelIdMap: Record<string, IChatModel[]> = Object.create(null);
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): AiMeteringService {
return withAiCostFactor(
this.services.metering,
this.clients.event,
this.driverName,
);
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
@@ -456,7 +412,11 @@ export class ChatCompletionDriver extends PuterDriver {
});
}
args.messages = normalizeMediaParts(normalize_messages(args.messages));
if (args.messages) {
args.messages = normalizeMediaParts(
normalize_messages(args.messages),
);
}
if (args.tools) {
normalize_tools_object(args.tools);
}
@@ -534,20 +494,8 @@ export class ChatCompletionDriver extends PuterDriver {
}
}
if (args.test_mode === true && !blocked) {
const fakeModel = this.#resolveModel('fake', 'fake-chat');
if (!fakeModel) {
throw new HttpError(500, 'Test chat provider unavailable', {
legacyCode: 'internal_error',
});
}
model = fakeModel;
}
const useFakeProvider = blocked || args.test_mode === true;
// Fake responses need no credit hold, including blocked prompts and
// test requests.
// Skipped for blocked requests since fake-chat is free and the user
// shouldn't see a billing error in place of the abuse page.
//
// The gate hands back a hold on what this attempt could cost, which
// stands in for its usage until the real numbers land. It is released
@@ -555,15 +503,11 @@ export class ChatCompletionDriver extends PuterDriver {
// where "done" is the stream draining rather than this method
// returning.
let hold: CreditHold = NO_CREDIT_HOLD;
// The current attempt's balance-bound output cap, if it had one.
let fundsCap: number | undefined;
if (!useFakeProvider) {
({ hold, fundsCap } = await this.#applyCreditGate(
actor,
model,
args,
{ promptTokenEstimate, requestedMaxTokens },
));
if (!blocked) {
hold = await this.#applyCreditGate(actor, model, args, {
promptTokenEstimate,
requestedMaxTokens,
});
}
// First attempt
@@ -594,9 +538,7 @@ export class ChatCompletionDriver extends PuterDriver {
};
try {
if (!useFakeProvider) {
await this.#resolvePuterPaths(provider, args, actor);
}
if (!blocked) await this.#resolvePuterPaths(provider, args, actor);
res = await provider.complete({
...args,
model: model.id,
@@ -635,19 +577,17 @@ export class ChatCompletionDriver extends PuterDriver {
// be capped against what is actually left.
// The previous attempt released its hold when it failed, so
// this one starts from nothing held.
if (!useFakeProvider) {
({ hold, fundsCap } = await this.#applyCreditGate(
actor,
fallback,
args,
{ promptTokenEstimate, requestedMaxTokens },
));
if (!blocked) {
hold = await this.#applyCreditGate(actor, fallback, args, {
promptTokenEstimate,
requestedMaxTokens,
});
}
tried.add(routeId(fallback.provider!, fallback.id));
try {
if (!useFakeProvider) {
if (!blocked) {
await this.#resolvePuterPaths(fbProvider, args, actor);
}
res = await fbProvider.complete({
@@ -682,13 +622,6 @@ export class ChatCompletionDriver extends PuterDriver {
const username = actor.user?.username;
// A completion that used all of a balance-bound cap stopped because
// the account ran dry, so the caller is told to top up.
const ranOutOfFunds = (usage: Record<string, unknown> | undefined) =>
fundsCap !== undefined &&
!!usage &&
trackedOutputTokens(usage, model) >= fundsCap;
// Streaming result — create a PassThrough, kick off the provider's
// stream populator, and return a DriverStreamResult so the route
// handler pipes it to the HTTP response as chunked NDJSON.
@@ -698,13 +631,6 @@ export class ChatCompletionDriver extends PuterDriver {
const init = res.init_chat_stream;
const cleanup = res.finally_fn;
// A caller that hangs up stops the generation, and with it the
// hold and the billing for output nobody will read.
const abortSignal = Context.get('abortSignal');
const abortStream = () => chatStream.abort();
if (abortSignal?.aborted) abortStream();
else abortSignal?.addEventListener('abort', abortStream);
// Intercept `chatStream.end(usage)` to fire complete + cost events
// (mirrors the non-streaming branch). Clone usage so providers that
// meter after this call (e.g. Claude) don't pick up `usd_cents`.
@@ -736,12 +662,7 @@ export class ChatCompletionDriver extends PuterDriver {
intendedProvider,
});
}
return originalEnd(
enrichedUsage!,
ranOutOfFunds(usage)
? { metadata: { usage_limited: true } }
: undefined,
);
return originalEnd(enrichedUsage!);
};
// The hold lives for the whole stream, which can outlast its TTL —
@@ -757,20 +678,17 @@ export class ChatCompletionDriver extends PuterDriver {
try {
await init({ chatStream });
} catch (e) {
if (!chatStream.aborted) {
passthrough.write(
`${JSON.stringify({
type: 'error',
message: sanitizeUpstreamMessage(
e instanceof Error ? e.message : String(e),
),
})}\n`,
);
}
passthrough.write(
`${JSON.stringify({
type: 'error',
message: sanitizeUpstreamMessage(
e instanceof Error ? e.message : String(e),
),
})}\n`,
);
passthrough.end();
} finally {
clearInterval(renewHold);
abortSignal?.removeEventListener('abort', abortStream);
// Providers report usage the moment they meter it (see
// `AIChatStream.reportUsage`); a stream that never got
// there was never charged for.
@@ -839,13 +757,6 @@ export class ChatCompletionDriver extends PuterDriver {
Context.set('driverMetadata', {
service_used: model.provider,
providerUsed: model.id,
...(ranOutOfFunds(
'usage' in res
? (res.usage as Record<string, unknown>)
: undefined,
)
? { usage_limited: true }
: {}),
});
// Response-format precedence: an explicit per-call `normalize` wins in
@@ -914,6 +825,9 @@ export class ChatCompletionDriver extends PuterDriver {
model,
trackedInputTokens(usage, model),
);
const cachedTokens = Number.isFinite(usage.cached_tokens)
? Math.max(0, usage.cached_tokens)
: 0;
let inputMicroCents = 0;
let outputMicroCents = 0;
@@ -927,6 +841,11 @@ export class ChatCompletionDriver extends PuterDriver {
if (key === 'usd_cents') continue;
if (key === 'tokens') continue;
const amount =
model.promptTokensIncludeCached && key === inputKey
? Math.max(0, rawAmount - cachedTokens)
: rawAmount;
// thinking_tokens → output rate fallback
let rate = costs[key];
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
@@ -949,9 +868,9 @@ export class ChatCompletionDriver extends PuterDriver {
sawAnyRate = true;
if (isOutputKey(key)) {
outputMicroCents += rawAmount * rate * multipliers.output;
outputMicroCents += amount * rate * multipliers.output;
} else {
inputMicroCents += rawAmount * rate * multipliers.input;
inputMicroCents += amount * rate * multipliers.input;
}
}
@@ -994,9 +913,7 @@ export class ChatCompletionDriver extends PuterDriver {
*
* Returns a hold on what the attempt can cost at worst, so requests this
* account is running in parallel see the spend before it is recorded. The
* caller releases it once the attempt is done. `fundsCap` is set when the
* account's balance, not the request or the model, decided the output cap —
* a completion that uses all of it has run the account dry.
* caller releases it once the attempt is done.
*/
async #applyCreditGate(
actor: Actor,
@@ -1016,46 +933,34 @@ export class ChatCompletionDriver extends PuterDriver {
*/
requestedMaxTokens: number | undefined;
},
): Promise<{ hold: CreditHold; fundsCap?: number }> {
): Promise<CreditHold> {
const metering = this.services.metering;
const { promptTokenEstimate, requestedMaxTokens } = estimates;
const { inputKey, outputKey } = costKeys(model);
// A prompt estimated past a long-context threshold pays the raised
// rates on input and output alike.
const multipliers = longContextMultipliers(model, promptTokenEstimate);
// Recorded costs pass through the AI cost factor; the gate prices at
// the same rate or it under-reserves on every request.
const costFactor = await this.#aiMetering.costFactor(
actor,
this.#meteringModelKey(model),
);
// `|| 0` also catches NaN from a malformed cost table.
const inputTokenCost =
(Number(model.costs?.[inputKey] ?? 0) || 0) *
multipliers.input *
costFactor;
(Number(model.costs?.[inputKey] ?? 0) || 0) * multipliers.input;
const outputTokenCost =
(Number(model.costs?.[outputKey] ?? 0) || 0) *
multipliers.output *
costFactor;
(Number(model.costs?.[outputKey] ?? 0) || 0) * multipliers.output;
const approximateInputCost = promptTokenEstimate * inputTokenCost;
const minimumCredits = Number(model.minimumCredits || 1);
const needed = Math.max(approximateInputCost, minimumCredits);
// One read of the balance and its holds serves the whole gate: the
// affordability check here and the output cap below, net of holds,
// and the balance alone, which tells spent from committed to requests
// that are still running.
const { balance, held } = await metering.getUsageHeadroom(actor);
const remainingCredits = Math.max(0, balance - held);
if (remainingCredits < needed) {
throw fundsRefusal(balance >= needed);
// One balance read serves the whole gate: the affordability check
// here and the output cap below.
const remainingCredits = await metering.getRemainingUsage(actor);
if (remainingCredits < Math.max(approximateInputCost, minimumCredits)) {
throw new HttpError(402, 'No usage left for request.', {
legacyCode: 'insufficient_funds',
});
}
if (model.subscriberOnly) {
const subscription = await metering.getActorSubscription(actor);
if (isFreeSubscription(subscription.id)) {
// Every free plan, not two named ones.
if (FREE_SUBSCRIPTION_IDS.has(subscription.id)) {
throw new HttpError(
403,
`The model ${model.id} is only available to subscribers. Please subscribe to access this model.`,
@@ -1064,17 +969,25 @@ export class ChatCompletionDriver extends PuterDriver {
}
}
let fundsCap: number | undefined;
if (outputTokenCost > 0) {
const affordableOutputTokens = (credits: number): number =>
(credits - approximateInputCost) / outputTokenCost;
// What the request would get with money no object.
const limit = Math.min(
requestedMaxTokens ?? Number.POSITIVE_INFINITY,
outputCeiling(model, promptTokenEstimate),
);
const maxAllowedOutputUcents =
remainingCredits - approximateInputCost;
const maxAllowedOutputTokens =
maxAllowedOutputUcents / outputTokenCost;
// A provider may not know a model's output ceiling. Drop the term
// rather than let a missing value drive the cap: `null` coerces to
// 0, so the subtraction goes negative instead of NaN and the user
// is told they're out of credits.
const modelOutputCeiling =
Number.isFinite(model.max_tokens) && model.max_tokens > 0
? model.max_tokens - promptTokenEstimate
: Number.POSITIVE_INFINITY;
const cap = Math.floor(
Math.min(limit, affordableOutputTokens(remainingCredits)),
Math.min(
requestedMaxTokens ?? Number.POSITIVE_INFINITY,
maxAllowedOutputTokens,
modelOutputCeiling,
),
);
// `cap` is the credit-bounded ceiling on output tokens. When it
// drops below 1 the user can't afford even a single output token,
@@ -1086,20 +999,11 @@ export class ChatCompletionDriver extends PuterDriver {
// too: a NaN requested max_tokens poisons the Math.min above, and
// `NaN < 1` is false — letting an uncapped request through.
if (!(cap >= 1)) {
// Holds only explain a shortfall in funds, not a request that
// asked for less than a token.
const fundsShort = !(
affordableOutputTokens(remainingCredits) >= 1
);
throw fundsRefusal(
fundsShort && affordableOutputTokens(balance) >= 1,
);
throw new HttpError(402, 'No usage left for request.', {
legacyCode: 'insufficient_funds',
});
}
args.max_tokens = cap;
// Holds alone shrinking the cap is a busy account, not a broke one.
if (cap < limit && affordableOutputTokens(balance) < limit) {
fundsCap = cap;
}
} else {
// No output price, nothing to bound — but a previous attempt may
// have written its cap here; give this one the user's own value.
@@ -1109,23 +1013,13 @@ export class ChatCompletionDriver extends PuterDriver {
// What this attempt can cost at worst: the prompt, plus output run to
// the cap just set. Capped output is what makes the number finite —
// for a model with no output price the output term is zero and the
// prompt estimate stands alone. Already factored, so it's held through
// the plain service rather than scaled a second time.
// prompt estimate stands alone.
const worstCaseCost =
approximateInputCost + (args.max_tokens ?? 0) * outputTokenCost;
const hold = await this.services.metering.reserveCredits(
return this.services.metering.reserveCredits(
actor,
Math.max(worstCaseCost, minimumCredits),
);
return { hold, fundsCap };
}
/** The key `model`'s usage is recorded and cost-factored under. */
#meteringModelKey(model: IChatModel): string {
return (
this.#providers[model.provider!]?.meteringModelKey?.(model.id) ??
`${model.provider}:${model.id}`
);
}
/**
@@ -1183,7 +1077,7 @@ export class ChatCompletionDriver extends PuterDriver {
[`estimated_${outputKey}`]: outputTokens,
},
actor,
this.#meteringModelKey(model),
`${model.provider}:${model.id}`,
{
// Undefined when the model has no cost table: the entry is
// recorded unpriced rather than free.
@@ -1391,6 +1285,15 @@ export class ChatCompletionDriver extends PuterDriver {
const deepseekKey = readKey(providers['deepseek']);
if (deepseekKey) {
deepSeekPricing.onRefreshError = () => {
this.clients.alarm.create(
'deepseek_pricing_refresh_failed',
'DeepSeek pricing refresh failed; using last verified rates',
{},
'warning',
{ dedup: true },
);
};
this.#providers['deepseek'] = new DeepSeekProvider(
{ apiKey: deepseekKey },
metering,
@@ -135,10 +135,7 @@ export class FakeChatProvider implements IChatProvider {
inputTokens += Math.ceil(message.content.length / 4);
} else if (Array.isArray(message.content)) {
for (const content of message.content) {
if (
content.type === 'text' &&
typeof content.text === 'string'
) {
if (content.type === 'text') {
inputTokens += Math.ceil(
content.text.length / 4,
);
@@ -20,7 +20,7 @@
/**
* Integration test for the Alibaba provider.
*
* Uses `qwen-flash` (cheapest generally-available model). Skipped
* Uses `qwen-turbo` (cheapest generally-available model). Skipped
* when `PUTER_TEST_AI_ALIBABA_API_KEY` is unset.
*/
@@ -39,7 +39,7 @@ const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'AlibabaProvider (integration)',
() => {
it('returns a non-empty completion from qwen-flash', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
it('returns a non-empty completion from qwen-turbo', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new AlibabaProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
@@ -47,7 +47,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
const result = await withTestActor(() =>
provider.complete({
model: 'qwen-flash',
model: 'qwen-turbo',
messages: [
{ role: 'user', content: 'Say hi in one word.' },
],
@@ -178,34 +178,6 @@ describe('AlibabaProvider model catalog', () => {
expect(ids).toContain('qwen-max');
expect(ids).toContain('qwen/qwen-max');
});
it.each([
['qwen3.8-flash', 15, 47, 1.6],
['qwen3.8-omni-flash', 15, 47, 1.6],
['qwen3.8-max-0902', 200, 600, 25],
['qwen3.7-max-2026-05-17', 250, 750, 0],
['deepseek-v4.1-flash', 30, 120, 0],
])('exposes %s at its list price', (id, input, output, cached) => {
const model = ALIBABA_MODELS.find((m) => m.id === id);
expect(model?.costs).toMatchObject({
prompt_tokens: input,
completion_tokens: output,
cached_tokens: cached,
});
});
it.each([
'qwen3-max',
'qwen3.6-max-preview',
'qwen-turbo',
'qwen3-vl-flash',
'qwen3-coder-plus',
'qwen-omni-turbo',
'deepseek-v4-flash',
])('drops %s, which Model Studio is retiring', async (id) => {
const { provider } = makeProvider();
expect(await provider.list()).not.toContain(id);
});
});
// ── Request shape ───────────────────────────────────────────────────
@@ -580,13 +552,13 @@ describe('AlibabaProvider.complete non-stream output', () => {
await withTestActor(() =>
provider.complete({
model: 'qwen3.8-max',
model: 'qwen3.6-max-preview',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const model = ALIBABA_MODELS.find(
(m) => m.id === 'qwen3.8-max',
(m) => m.id === 'qwen3.6-max-preview',
)!;
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
@@ -594,7 +566,7 @@ describe('AlibabaProvider.complete non-stream output', () => {
completion_tokens: 20,
cached_tokens: 15,
});
expect(prefix).toBe('alibaba:qwen3.8-max');
expect(prefix).toBe('alibaba:qwen3.6-max-preview');
expect(overrides).toEqual({
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
completion_tokens: 20 * Number(model.costs.completion_tokens),
@@ -58,11 +58,6 @@ export class AlibabaProvider implements IChatProvider {
return modelLookupNames(this.models());
}
/** The model key this provider records usage under. */
meteringModelKey(modelId: string): string {
return `alibaba:${modelId}`;
}
async complete({
messages,
stream,
@@ -105,7 +100,7 @@ export class AlibabaProvider implements IChatProvider {
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
this.meteringModelKey(modelUsed.id),
`alibaba:${modelUsed.id}`,
costsOverride,
);
return trackedUsage;
Loaded 100 of 569 files, more files were not shown because too many files have changed in this diff. Show more